CV & portfolio

A one-page personal site. To make it yours, edit one file: content.py.

cv-portfolio/
├── app.py               # composition root
├── content.py           # the CV, as data
├── models/              # profile.py, contact.py (the form's rules)
├── services/            # contact.py (ContactService, + API)
└── views/
    ├── components/      # header, hero, section, experience, skills, projects, contact
    ├── pages/           # home.py
    └── styles/

1. Content as data

@dataclass(frozen=True)
class Profile:
    name: str
    role: str
    location: str
    email: str
    summary: str
    links: tuple[SocialLink, ...]
    jobs: tuple[Job, ...]
    skills: dict[str, tuple[str, ...]]
    projects: tuple[Project, ...]
    education: tuple[str, ...] = field(default=())

    @property
    def technologies(self) -> list[str]:
        """Every technology used in a project, for the filter buttons."""
        return sorted({tech for project in self.projects for tech in project.tech})

Components read PROFILE; no personal details live in markup. A non-developer can update the site by editing data.

2. Function components

views/components/section.py
from rational import Heading
from rational import Section as SectionElement

from views.styles import SECTION


def Section(anchor: str, title: str, *content):  # noqa: N802 - a function component
    """A titled section the header navigation can jump to (``#anchor``)."""
    return SectionElement(Heading(title, level=2), *content, id=anchor, aria_label=title, style=SECTION)

When a piece of UI has no state, a plain function that returns nodes is all you need. It’s the lightest way to share markup.

3. A timeline drawn with styles

views/components/experience.py
from rational import Column, Component, Heading, Row, Spacer, Style, Text, h

from .section import Section
from content import PROFILE
from views.styles import DOT, JOB, TIMELINE


class Experience(Component):
    def render(self):
        jobs = [
            h(
                "li",
                h("span", style=DOT, aria_hidden="true"),
                Row(
                    Column(Heading(job.role, level=3), Text(f"{job.company} · {job.location}", muted=True), gap=2),
                    Spacer(),
                    Text(job.period, size="sm", weight=600, color="primary"),
                    wrap=True,
                    align="start",
                ),
                h("ul", *[h("li", item) for item in job.highlights], style=Style(margin=0, padding_left="lg")),
                key=job.company,
                style=JOB,
            )
            for job in PROFILE.jobs
        ]
        education = [Text(item, muted=True) for item in PROFILE.education]
        return Section(
            "experience",
            "Experience",
            h("ol", *jobs, style=TIMELINE),
            Heading("Education", level=3) if education else None,
            *education,
        )

The vertical line is a border_left; each dot is an absolutely positioned span. No images and no stylesheet.

4. An interactive island

class Projects(Component):
    """Filter buttons are server events: the grid re-renders with the matching projects only."""

    technology = State("All")

    def choose(self, technology: str):
        return lambda: setattr(self, "technology", technology)

    def card(self, project: Project):
        return Card(
            Heading(project.name, level=3),
            Text(project.summary, muted=True),
            Spacer(),
            Row(*[Badge(tech) for tech in project.tech], gap="xs", wrap=True),
            Link("View project →", href=project.url, external=True) if project.url else None,
            key=project.name,
            style=Style(height="100%"),
        )

    def render(self):
        options = ["All", *PROFILE.technologies]
        shown = [p for p in PROFILE.projects if self.technology == "All" or self.technology in p.tech]
        filters = Row(
            *[
                Button(
                    tech,
                    size="sm",
                    variant="secondary" if tech == self.technology else "ghost",
                    aria_pressed="true" if tech == self.technology else "false",
                    on_click=self.choose(tech),
                )
                for tech in options
            ],
            gap="xs",
            wrap=True,
            role="group",
            aria_label="Filter projects by technology",
        )
        return Section(
            "projects",
            "Projects",
            filters,
            Text(f"{len(shown)} project{'s' if len(shown) != 1 else ''}", muted=True, size="sm", aria_live="polite"),
            Grid(*[self.card(p) for p in shown], min_column_width=260),
        )

Only the project filter and the contact form talk to the server. Everything else is static HTML.

5. A form that helps, and a trap for bots

class ContactForm(BaseModel):
    """What the contact form may submit, with friendly messages."""

    name: str = Field(min_length=1, max_length=80)
    email: str = Field(max_length=254)
    message: str = Field(min_length=10, max_length=4000)
    website: str = ""  # the honeypot: hidden from people, filled in by bots

    @field_validator("email")
    @classmethod
    def valid_email(cls, value: str) -> str:
        if not EMAIL.match(value.strip()):
            raise ValueError("Enter a valid email address so I can reply")
        return value.strip()

    @field_validator("message")
    @classmethod
    def not_blank(cls, value: str) -> str:
        if len(value.strip()) < 10:
            raise ValueError("Tell me a little more (at least 10 characters)")
        return value.strip()
class Contact(Component):
    """Validated with a Pydantic model; errors appear under each field. A hidden "website"
    field catches bots: people never see it, so anything typed there is spam."""

    sent_to = State("")

    def send(self, form: ContactForm):
        self.use(ContactService).send(form.name, form.email, form.message, form.website)
        self.sent_to = form.name

    def render(self):
        if self.sent_to:
            body = Card(
                Heading(f"Thanks, {self.sent_to}!", level=3),
                Text("Your message is on its way. I usually reply within two days."),
                role="status",
                style=Style(border_color="success"),
            )
        else:
            body = Form(
                Input("Name", name="name", required=True, autocomplete="name"),
                Input("Email", name="email", type="email", required=True, autocomplete="email"),
                Textarea("Message", name="message", required=True, rows=5),
                # The honeypot: off-screen and hidden from assistive technology, so only bots fill it in.
                h(
                    "div",
                    Input("Website", name="website", tabindex="-1", autocomplete="off"),
                    aria_hidden="true",
                    style=OFF_SCREEN,
                ),
                Button("Send message", type="submit"),
                on_submit=self.send,
                style=Style(max_width=560),
            )
        return Section(
            "contact",
            "Contact",
            Text(
                "Have a project in mind? Write to me here or at ",
                Link(PROFILE.email, href=f"mailto:{PROFILE.email}"),
                ".",
            ),
            body,
        )

Custom validators produce friendly messages under each field. The hidden website field is a honeypot: it’s off-screen and hidden from screen readers, so only bots fill it in. The service answers bots the same way it answers people, so they can’t tell they were filtered.