PLINTH — case study
A 16-week periodised strength program, as a single static page. Live at plinth.kimnejudne.dev.
Vite · React 19 · TypeScript (strict) · Tailwind 4 · GSAP · no backend, no router, no photography.
Where this page came from, plainly
The first draft was generated by an AI app builder from a written brief
(../block-scaffold-prompt.md). I did not hand-build it from zero and this
document does not pretend otherwise.
That matters because of what the scaffold is for. It buys concept, art direction and a working full-stack draft in minutes — a starting line. The portfolio value is the distance from there to what shipped, and on this page the distance is unusually legible, because the brief I wrote declared its own definition of done and listed thirteen named failure modes to avoid. It hit most of them. It also missed clauses of its own spec.
So this is a case study about the gap between a page that looks finished and a page that is.
The second PLINTH
There have been two. The first was a gym marketing site — austere, near-
monochrome, photographed. It was built, craft-passed, deployed and case-studied,
and it is preserved on recovered/plinth-craft-pass.
I retired it because of a conflict I should have caught in the brief: the fitness spoke is the portfolio’s animation showcase, and I had art-directed it as an architectural monograph photographed at 06:00 with the lights half up. Austerity and showcase pull against each other. Restraint was the right instinct for a building; it was the wrong instinct for the piece that has to prove I can move things.
The replacement changes the subject to change the medium. A gym is a place, and
places get photographed. A program is a method, and methods get drawn —
which is what makes this an animation showcase rather than a photo gallery with
parallax. There is not a single photograph on the page, and that is a constraint
I set on purpose, having watched the previous scaffold invent Unsplash IDs (half
of which 404’d) and bury the six that resolved at opacity-30 because they were
off-brief. When the art direction is being carried by CSS opacity, it isn’t being
carried.
What the scaffold got right
More than the last one, and it’s worth being specific:
- Reduced motion was correct out of the box. Every animation registered
inside
gsap.matchMedia()with areduceMotionbranch that sets explicit end states — planner assembled, curve drawn, counters at final values, blocks stacked, marquee stopped. I verified all of it in a browser under emulated reduced motion rather than trusting the code: cells at opacity 1, counters reading41,600 / 1.9M / +18% / 71%, curvestroke-dasharray: none,scroll-behavior: auto. Nothing missing, nothing invisible. - The hero doesn’t depend on JavaScript. Its entrance is CSS keyframes gated
on
prefers-reduced-motion: no-preference, so the LCP element renders with no bundle at all. - Plugins registered once at module scope. Every tween scoped by
useGSAP.ScrollTrigger.refresh()afterdocument.fonts.ready— display type at 8rem changes section heights when the real face swaps in.dvhthroughout. NoMath.random()in a render path. Typecheck clean with real React types andstricton. - And the hero is genuinely good. I changed nothing about it. The craft pass is not a redesign.
What it missed
The one that mattered most: the page argued with itself
This spoke’s pitch is measurement. So the worst defect wasn’t visual — it was that the load curve contradicted the words printed beside it, three separate ways.
- The plotted tonnage rose across the cycle and put its maximum in block four. Two feet away, the block panels read “Volume drops away” and “Load falls away”, and their own volume bars declined. The picture and the copy were telling opposite stories.
- The annotation asserted the two lines cross at week 11. They crossed at
week 12. Hard-coded
X11 = xAt(10), pointing at nothing. - A statement read “Intensity rises on one line for sixteen weeks. It does not negotiate” — set beside a vermilion line visibly sawtoothing at weeks 4, 8 and 12.
The bars were the tell. They were four unsourced percentages — 92/44, 74/63,
46/95, 22/18 — with no unit and no origin. Numbers with no source are free to
say anything, and these had drifted into saying the opposite of the data they sat
next to.
Fixing the label would have been a twenty-second job and would have left the page lying more quietly. I fixed the source instead:
- Both series rewritten to match the block story, holding the program’s stated 12,000–28,000 kg and 65–92.5% ranges.
- Both axis domains derived from the data rather than hard-coded, so editing a figure can no longer move the lines against a scale that no longer fits them.
crossoverIndex()computes where the lines meet by walking the segments. The annotation is drawn at the result. It cannot drift from the picture again.- The panel bars now derive from the same two arrays the curve plots, and each is labelled with the real figure it represents — mean kg/week, mean % 1RM.
The crossover lands at index 7.496, which is the INTENSIFY/REALISE divider. That isn’t luck, it’s tuning — but it’s tuning I can show, because the number is computed from the arrays and the three statements beside the plot are each checkable against them.
Normalising the bars taught me something. My first attempt scaled them from zero, and all four rendered nearly identical: mean intensity only spans 69–82% across the cycle, because it’s a percentage of a maximum. A zero-based bar is “honest” and destroys the inversion that is the entire point of the panel. They now normalise within each series, with a 12% floor so the smallest block stays visible as a mark — a relative scale, with the absolute figure printed next to it.
Two horizontal scrollbars
The desktop page shipped with a 4,320px horizontal scrollbar, visible at page
load. The four w-screen panels of the blocks track stand 300vw proud of their
section and nothing clipped them. Measured: scrollWidth 5760 against
clientWidth 1440.
The fix is one utility, but which utility is the interesting part.
overflow-x: hidden forces overflow-y to compute to auto, which would make
the section a scroll container — and a ScrollTrigger pin inside a scroll
container fails silently, which is the failure mode my own brief listed at number
thirteen. overflow-x: clip has no such side effect. Clip, not hidden.
Mobile had a second one, 20px, from the Sunday day-strip entering at
xPercent: 10 while full-width below md.
The loudest plate couldn’t hold its own numbers
The evidence figures were sized clamp(2.4rem, 6.5vw, 5.5rem) — against the
viewport, inside boxes that are a quarter of it. At 1280, “41,600”
measured 295px in a 235px cell and ran into the hairline.
Container queries are the right tool and I hadn’t reached for them before. Each
cell becomes container-type: inline-size; the figure is min(26cqi, 5.5rem).
Because cqi resolves against the cell’s content box, the padding falls out of
the arithmetic for free. Measured across eight widths from 320 to 1920, every one
now holds 8% headroom, identically — which is what “sized by its container”
actually buys you: not a value that happens to work, a relationship that holds.
A defect I had already fixed once
Below md, the five section links were simply hidden. One of six links
reachable on a phone.
This is the same defect the previous spoke’s craft pass fixed, reintroduced verbatim by a different scaffold. That’s the honest lesson about working this way: scaffolds don’t accumulate your corrections. A gap list is not a one-time document, it’s a checklist you re-run.
Contrast, in the two plates that most needed it
The brief said, in as many words, to check bone-on-blue. It wasn’t checked.
opacity-70 bone on blue measures 3.75:1 — carrying a section label, a
footnote, and the secondary copy on two block panels. Raised to opacity-85
(4.93:1), and kept at 70% on ink where it measures 8.31:1 and the muting is safe.
The marquee was worse: blue on ink, 2.58:1, under even the 3:1 large-text floor. And that one is on me — I specified it that way in the brief. The scaffold followed the instruction correctly; the instruction was wrong. I inverted it to bone on blue (6.37:1), which keeps blue carrying “the plan” while moving it from the type to the plate — which is how the rest of the page uses a full-bleed colour anyway.
The site had no 404
nginx was serving try_files $uri /index.html — a single-page-app fallback on a
page with no router. /favicon.ico, /og-card.png and /robots.txt all
returned 200 and the homepage, as text/html. No 404 existed anywhere on the
site, and a crawler could have indexed unlimited duplicate URLs.
The head, meanwhile, had viewport and description and nothing else. No
favicon, no share card, no manifest, no canonical.
scripts/brand.sh now regenerates the card and icons from HTML sources through
headless chromium. No new dependency: the card has to be set in the same two
faces as the page, so it’s rendered by a real browser off a real stylesheet
rather than assembled in an image library — and that takes chromium and a static
file server, both of which were already here. The brand assets sit at the site
root, deliberately outside the content-hashed /assets/ path that nginx serves
immutable for a year, because platforms cache a share card by URL and re-scrape
it; a year of immutability would pin the first card forever.
Small things
- The planner lost its grid in two of four blocks — the stipple ran continuously across weeks 09–12 and read as one dotted rectangle, the hollow block had no border at all — both directly under a caption reading “one cell per session”.
- The 8× planner zoom scaled a 48-cell subtree with gradient backgrounds across a 300vh pin, unguarded on phones. Mobile now gets the assembly and a 2.2× zoom over a shorter pin.
- The enrol form said “Demo — no card is taken”. The confirmation that follows it — “You’re in.”, with a billing summary — said nothing. That’s the state that reads as a completed transaction.
- The evidence
<dl>put<dd>before<dt>, whichdldoesn’t permit. backend/pytest.ini,.emergent/and its cron dispatch scripts,tests/,test_result.md, a.gitconfigcommitting asemergent-agent-e1, and a README reading “Here are your Instructions”. The brief explicitly asked for the template’s backend to be deleted rather than left unused. It was left unused.
How it was verified
Driven in a real browser over CDP, not inspected by eye. Layout claims are measurements:
| Measurement | before | after |
|---|---|---|
| Horizontal overflow @1440 | 4320px | 0 |
| Horizontal overflow @390 | 20px | 0 |
| “41,600” @1280 | 295px in a 235px cell | 8% headroom |
| Nav links reachable @390 | 1 of 6 | 6 of 6 |
| Lowest text contrast | 2.58:1 | 4.93:1 |
| Missing paths | 200 + homepage | 404 |
Reduced-motion end states re-checked after every change. Mobile nav verified
through its full cycle: aria-expanded toggling, five links appearing, Escape
closing and restoring focus to the toggle, and a link landing #curve at exactly
56px — var(--nav-height), so scroll-margin-top is doing its job.
What is still open
A case study about a scaffold overclaiming shouldn’t overclaim.
- No screen reader pass. Nothing here has been heard, only measured. Three
sections carry
sr-onlyheadings, including both pinned centrepieces — legal, but it means the two most important sections have no visible heading, and I don’t yet know whether 900vh of pinning reads as navigable or endless. - No real phone. The planner pin is the thing to watch, and it’s the third spoke in a row where this is the open item.
- No axe sweep. Specific defects were measured; the page hasn’t been swept.
- 123KB gzipped of JavaScript for a page with no application logic — GSAP plus React. Effectively unchanged from the previous spoke’s 122KB, which was flagged there and also never closed. Two in a row is a pattern, not an oversight, and it’s the next thing to fix.
- The load-wipe is inherited, not decided. A full-screen wipe plays for ~1.5s before the hero settles at ~2.2s. It costs no JavaScript and honours reduced motion, but it’s a splash screen in front of the LCP element and the hero is strong enough without one.