Skip to content

Repository files navigation

Portfolio site

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 clean

The two warnings are the launch state, reported rather than hidden.

Quick start

Needs uv.

uv sync
uv run python build.py --serve     # http://127.0.0.1:8000

Adding an entry

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

What the linter covers

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.

What the linter does not cover

These four self-lint items need a person. They are listed here so they are not mistaken for covered.

  1. Open every repository and compare its real size against its description. Cut the description until it matches (rule 15).
  2. Compare each pair of sibling rows in the rendered page: identical hover, padding, alignment, height (rule 40).
  3. Replace your name with a stranger's, reread the bio, and delete every sentence that stays true (rule 25).
  4. 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).

Layout

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.

Design

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.

The design preview

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

Deploying

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.

Licence

MIT for the code. IBM Plex is under the SIL Open Font Licence 1.1; see static/fonts/LICENCE.md.

About

Personal site. Static generator, content as Markdown, portfolio rules enforced at build time.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages