Kim Nejudne ← All work

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:

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.

  1. 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.
  2. The annotation asserted the two lines cross at week 11. They crossed at week 12. Hard-coded X11 = xAt(10), pointing at nothing.
  3. 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:

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


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.