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¶
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¶
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.