A static site generator for one portfolio, plus the site it generates. Content is
Markdown, output is HTML, and a linter enforces the rules in
PORTFOLIO-RULES.md on every build.
$ uv run python build.py
built 2 pages into dist\ (projects 0, writing 0, experience 0)
warning: content/site.yml: rule 32: no 'detail' set; one specific unusual verifiable fact
warning: content/projects/: rule 1: 0 projects, target is 3 to 5
lint: 0 errors, 2 warnings
$ uv run python growth_check.py
growth check
ok zero entries render no section headings
ok one entry per section
ok five projects, two writing, one experience
ok six projects fail the build
ok summary at the 60 word cap builds
ok summary at 61 words fails
growth check cleanThe two warnings are the launch state, reported rather than hidden.
Needs uv.
uv sync
uv run python build.py --serve # http://127.0.0.1:8000One file, one row. Drop it in content/projects/, content/writing/, or
content/experience/ and rebuild. There is no index to update and no list to
keep in sync.
---
title: Usage metering and billing service
order: 1
summary: >
Records billable usage events exactly once, rolls them into hourly buckets,
pushes additive deltas to a billing backend, and reconciles what was metered
against what was billed.
repo: https://github.com/USERNAME/usage-metering
---
Everything below the front matter becomes the entry's own page. Stack, design
decisions, and limits go here, in prose.| Field | Required | Notes |
|---|---|---|
title |
yes | Rule 49: a plain descriptive name, no invented brand |
summary |
yes | Rule 21: 60 words maximum, and the build fails above it |
order |
no | Sort position. Defaults to last |
repo |
no | Shown on the entry's page |
url |
no | An external link, used when the entry has no body of its own |
traction |
no | Rule 7: state the number, or leave the field out entirely |
period |
no | For experience entries |
code |
no | The short name on the folder tab. Defaults to the filename |
An entry with a body gets a page at /<section>/<slug>/. An entry with only a
url links straight out. An entry with neither renders as plain text rather than
as a dead link, and the linter says so.
Sections with no entries do not render at all. Not an empty heading, not a
placeholder, not a reserved gap. Rule 23 bans "coming soon" and "work in
progress", which leaves an empty section no honest way to describe itself. Set
show_empty_sections: true in content/site.yml to override.
It runs on every build. An error stops the build; a warning reports and passes.
--strict promotes warnings to errors for a polish pass.
| Check | Rule | Level |
|---|---|---|
| Summary over 60 words | 21 | error |
| Bio over 100 words | 21 | error |
| Stated skill level | 12 | error |
| Marketing register | 19 | error |
| Disclaimers | 23 | error |
| Emoji | 46 | error |
| Em dash, en dash, curly quotes | house formatting | error |
A skills or technologies field |
11, 14 | error |
| Internal link does not resolve | 16 | error |
| More than five projects | 1 | error |
A coloured gradient in static/ |
37, see Design | error |
| Baseline tool named as a skill | 13 | warning |
| Evaluative adjective | 22 | warning |
| Fewer than three projects | 1 | warning |
No detail set |
32 | warning |
| Project title over 52 characters | folder geometry | warning |
Two of those splits are deliberate and worth knowing.
Rule 22 bans evaluative adjectives applied to your own work, and no regular expression can tell what the subject of a sentence is. Making "clean" an error would eventually flag "clean shutdown", and a check that has to be silenced to build is a check that stops being true.
Fewer than three projects is a warning rather than an error because the repository has to build on day one, before the first project exists. More than five is an error, because that failure is unambiguous.
These four self-lint items need a person. They are listed here so they are not mistaken for covered.
- Open every repository and compare its real size against its description. Cut the description until it matches (rule 15).
- Compare each pair of sibling rows in the rendered page: identical hover, padding, alignment, height (rule 40).
- Replace your name with a stranger's, reread the bio, and delete every sentence that stays true (rule 25).
- Confirm at least one project is hard in a way a language model cannot shortcut, and be able to say in one sentence why (rule 3).
content/
site.yml name, role, positioning line, bio, links
projects/ one Markdown file per row
writing/
experience/
templates/ Jinja2, autoescaped, StrictUndefined
static/ style.css, deck.js, copy.js, self-hosted fonts
build.py generator and CLI
lint.py the checks above
growth_check.py proves the layout holds as entries are added
A folder is an anchor to a real page before it is anything else. deck.js
intercepts the click, opens the overlay from a template already in the markup,
and pushes the folder's own URL, so the address bar stays honest and the back
button closes. Landing on that URL directly serves the standalone page. With no
script the deck is a list of links that works, rendered as an expanded stack
with every summary visible.
dist/ is build output and is not committed. GitHub Actions builds it on every
push to main and deploys it, so the repository stays readable as source rather
than as source plus a copy of its own output.
Three references govern the look. A soft light UI styleguide supplies the material: one light source from above, an elevation ladder, a border scale of 1 / 1.5 / 2, a radius scale, four states for anything interactive, and a single blue. A poster of paper folders supplies the folder itself, a squared sheet with a tab cut from its top edge and a diagonal shoulder. A line drawing of a filing cabinet supplies the stack: files packed tight, tabs stepping across, the selected one filled solid, and a drawer front the files disappear behind.
The system is the point rather than the components. A folder, a row, a field, a
button, a switch, and the drawer pull are the same dozen tokens cut differently,
and every token a rule constrains sits in the first two hundred lines of
static/style.css.
Layout. A rail and a column. The rail holds who you are, what you do, and how to reach you, and stays in view while the page scrolls, which answers rule 30 more completely than a first screenful can. Below 64rem the two tracks become two rows and the rail stops sticking. One layout reflowing, not a second design.
Every value in the rail is a field, and a field is one component with one padding. That is the whole answer to values not lining up: not a rule about spacing but a rule about which component a value goes in, so two of them cannot start at different places.
The deck. Every folder is the same box at the same height. A collapsed folder is short only because the folder below covers it, so bringing one forward moves the folders under it and nothing resizes:
offset(i) = i * pitch + open * (i > active ? 1 : 0)
That conditional is a clamp() in the stylesheet, so the entire mechanism is one
declaration and JavaScript only ever writes --active. No height animates, no
layout runs, and the drawer is the same height whichever folder is forward. The
last folder has nothing below it to cover it, so the deck clips at its own bottom
edge and covers it the way the next folder would.
Every number in the geometry follows from one fact about the shape: a folder's tab band is transparent everywhere except the tab, so whatever sits under that band shows through beside it. The pitch is tab, padding, one title line, and the clearance that stops the next folder's tab ruling through the title. The gap between a title and its summary is that clearance plus one tab height, because that is the band the next tab occupies. And every folder ends one tab height underneath the next one, because without that tuck an open folder's bottom edge is followed by the drawer showing through the next folder's transparent band, and the stack stops looking like paper resting on paper. The tuck is dead space by construction: it is the one part of a folder that is always covered.
The tab is cut from the sheet's own top edge with clip-path, not glued on as a
second box. Its outline cannot be a border or a box-shadow, since both follow
the element's rectangle and would draw straight through the notch; four zero-blur
drop-shadow filters at plus and minus one pixel follow the clipped silhouette
exactly, and the cast shadows join the same chain. Nothing on a folder is
rounded where the paper was cut, including the fill that marks the selected one:
a rounded black tab inside a square notch was visible the moment a folder came
forward.
Separating one sheet from the next takes a pair, not a hairline. At this
contrast a lone hairline reads as a fold in one sheet rather than as the boundary
between two, so each folder casts a tight crease upward onto the one it overlaps
and lights one pixel of its own top edge where the paper begins. The lit edge is
two bars rather than one, stopping either side of the tab, because a single bar
across the full width ran under the folder's own tab and cut it off from its
body, which is the seam clip-path was introduced to remove.
Writing and experience render as numbered rows in the same well: same material, cut without a tab. A deck is for work that earns a folder; a list of documents is a list.
Colour. Achromatic, with one soft blue lifted from the styleguide's slider. It means system feedback and nothing else, and it has four uses: the focus ring, the groove in the drawer wall that says which file is forward, the motion switch, and the moment a copy succeeds. Solid ink stays the mark for a selected file, as in the cabinet drawing.
Which folder is forward is two pieces of state, and the distinction is what makes the deck feel like a drawer rather than like a menu that latches. A click, a tap, or focus pins a folder. A hover moves it temporarily and taking the pointer away puts it back. With only the second, hovering a folder left it open forever and the deck slowly became whatever the pointer had last brushed past.
Motion. Three durations and two curves, and nothing carries a duration of its
own. --t covers colour, border, shadow, and opacity. --move covers anything
that changes position. --t-view covers a whole view arriving or leaving.
The split between --t and --move is the accessibility story:
prefers-reduced-motion zeroes --move and --t-view and leaves --t alone,
so movement stops and feedback survives. WCAG 2.3.3 governs motion; it does not
ask that a control stop acknowledging that it was pressed. An earlier version
zeroed every transition on the page under that preference, which is why it read
as inert rather than as calm.
The curves matter more than the durations. The first pass eased to a control
point of (0.26, 1), which is almost flat: the last third of every move covered
about two pixels spread over eighty milliseconds, and that reads as a stutter at
the end rather than as easing. Both curves now land with a short enough tail that
the motion finishes when it looks finished.
The rail carries a switch that overrides the operating system preference in either direction and remembers the choice, because that setting is a blunt instrument and someone looking at a portfolio may want to see the thing move.
Opening a folder grows the panel out of it: deck.js sets the panel's transform
origin to that folder's centre and the panel scales up into place. An earlier
version clipped the panel to the folder's rectangle and animated the clip open,
and it was dropped for two visible reasons. The panel is a composite of a tab and
a rounded sheet, so a rectangular clip cut across both and every frame showed a
hard edge belonging to neither. And the clip ran on the Web Animations API while
the opacity ran on a CSS transition of a different duration, so the two finished
at different moments and the tail of the longer one read as a stutter. Everything
is now one mechanism, one duration, and one curve, on properties the compositor
animates without touching layout.
Nothing in static/deck.js uses requestAnimationFrame. It did, to let a class
transition from the state on screen, and a background tab does not run animation
frames: the callback never fired, the overlay opened invisible, and the page
stayed scroll locked with no way out. Reading a layout property forces the same
style flush synchronously and does not care whether anything is being painted.
Two deliberate deviations from the standard, both marked in the CSS where they happen.
Rule 44 caps transitions between 120 and 250 milliseconds. Every state change obeys it. The entrance runs at 320ms and the overlay's growth at 280ms, because that cap governs a control answering a person and these are transitions between views.
Rule 37 allows glow, neon, or gradient on at most one element per page. The
styleguide reference is made of gradients: its controls are gradient filled and
its ground carries a vignette, so reproducing that material means breaking the
rule as written. The deviation is bounded and made checkable instead: every
gradient is an achromatic luminance shift modelling the single declared light
source, and the only coloured fill is the accent. lint.py enforces the
achromatic half of that sentence, so a later edit cannot quietly turn the page's
material into a colour effect. A narrow rule that holds is worth more than a wide
one that gets ignored.
Typeface is IBM Plex Sans and IBM Plex Mono, self-hosted, no third-party request. One superfamily doing two jobs: the sans sets everything a person reads, the mono sets anything a machine reads back. An address, a repository path, a measurement, a file number.
/preview/ renders the deck with placeholder folders so the assembly can be
judged before there is anything real to put in it. It carries a noindex tag and
nothing links to it, so the published index still shows no projects section at
all, which is what rule 23 requires while the folders are empty.
Delete templates/preview.html.j2, the PREVIEW_* block in build.py, and
write_preview once the first real project lands.
GitHub Pages, source set to GitHub Actions in the repository settings. The workflow lints, builds, runs the growth check, and only then deploys. A rule violation fails the build job, and the deploy job never runs.
MIT for the code. IBM Plex is under the SIL Open Font Licence 1.1; see
static/fonts/LICENCE.md.