Kim Nejudne ← All work

TALLOW — Case Study

A dry-aged steakhouse with a live 86 board. Reservations checked against real cover capacity, a back-of-house console that strikes a dish from the public menu the moment the kitchen runs out, and a printed-menu art direction that holds up under a loupe.

Stack: Vite 6 · React 19 · TypeScript · Express 4 · Postgres · Tailwind 4 · Docker on a DigitalOcean droplet.


Where this started, plainly

TALLOW was scaffolded by an AI app builder in about ten minutes. Concept, art direction, seven views and thirteen API routes arrived essentially free. I did not hand-build this from a blank page, and the interesting part of the story is not that I could have.

The interesting part is the distance from that draft to this repo — because a generated app is a convincing shell, and a shell is exactly what a hiring manager should be suspicious of. The scaffold was the starting line. What follows is what it cost to make it real.

Two things the draft genuinely got right, and I kept:

Everything below is what the draft did not buy.


1. The admin surface had no lock on it

The generated auth compared a hardcoded PIN (tallow123) and returned the literal string 'staff-session-active-token'. No other route checked it. Every admin endpoint — 86 the menu, delete a dish, read the full reservation book with every guest’s name, email and phone — was open to anyone who typed the URL.

Fixed with signed, expiring session tokens, a rate limiter on the sign-in route, and requireSession / requireStaff guards on every admin route. SESSION_SECRET is mandatory and the server refuses to boot without it rather than failing on the first sign-in, so a misconfigured deploy dies at the healthcheck instead of quietly serving an unlocked dashboard.

The demo problem, and the interesting part of the answer. A portfolio piece has to let a stranger walk through the back of house. A portfolio piece also cannot let a stranger delete the menu. So there are two roles: staff writes, demo reads. The read-only session is enforced on the server, and the UI mirrors that refusal — every writing control shares one writeProps spread, so a read-only session sees them as unavailable rather than clicking them and eating a 403. The demo PIN is served from an API route rather than compiled into the bundle, so rotating it doesn’t need a rebuild.

The cover maths, the sitting loads and the 86 board are all live in the demo. Nothing is faked for the tour.

Then the invitation was measured, and it failed. Having built the demo role, the sign-in screen announced it in a 10px brass caption tucked under the PIN field — #8C6E43 on #F7F4EE, which measures 4.32:1 against AA’s 4.5:1 floor for normal text, and 10px is nowhere near the large-text exemption. The one screen written to be read by a stranger was the one place the contrast budget had been quietly overspent, and it survived because nobody had pointed a contrast checker at a hint — hints read as decoration until you need one.

It is now the same brass notice the dashboard already uses for its read-only banner, moved above the form: #5C4A2E on #EFE6D5 at 11px, 6.85:1, verified against getComputedStyle in a real browser rather than against the stylesheet. No new colour was invented — the fix was to stop inventing one and reuse the pair that already passed.

The screen also stopped asking a reviewer to do transcription. Reading a PIN and typing it into a password field is three chances to give up, so there is a second button that signs in with the PIN the server already publishes. It is deliberately the secondary control even though it is the only one a public visitor can complete: making it primary would turn a staff portal into a portfolio demo, and the PIN field is what proves the lock is real. Both controls share one code path and one in-flight flag, so the button is not a second kind of sign-in that can drift from the first — it is the same POST. Where DEMO_PIN is unset, the banner and the button both vanish and an ordinary staff sign-in is left behind.

2. Accessibility: three attributes to a keyboard-operable app

The scaffold shipped one aria-label and a single prefers-reduced-motion rule covering one class. The app now carries 85 ARIA attributes, but the count is not the point — these were the failures that actually locked people out:

Client-side routing said nothing. Changing route swapped the entire page while the document title stayed put, focus stayed on the link that was clicked, the viewport kept its old scroll offset, and a screen reader announced nothing at all. RouteAnnouncer restores all four, and route names live in one pageMeta module so the tab title and the spoken announcement can differ — the tab wants the brand, the announcement does not want to hear “TALLOW” read aloud before every page name.

The choice grids were buttons wearing radio costumes. Party size and sitting time are single-select, and both were rendered as <button> grids: no group name, no checked state, no arrow-key traversal, nothing telling a screen reader the choices were exclusive. They are now real radio inputs, visually hidden under the same printed styling, so all of that behaviour comes from the platform instead of from me.

Unavailable sittings are aria-disabled, not disabled — a fully committed 19:30 is information a guest wants, and a truly disabled input is skipped silently by the keyboard and never read at all. Each option’s spoken label carries the detail the visual cell can leave implicit: “19:30, 12 covers open”.

The modal was a modal for mouse users only. The menu editor rendered over the page and left focus behind it, so a keyboard user tabbed straight through to the document underneath, operating controls they could not see. useFocusTrap moves focus in, cycles Tab within, closes on Escape, locks background scroll, and — the part most often skipped — returns focus to the control that opened it.

Reduced motion is now a policy, not a per-component afterthought. One global rule neutralises anything that animates; new motion has to opt back in deliberately rather than escape by being forgotten. Durations go to 0.01ms rather than 0, so transitionend still fires and nothing waiting on it is stranded.

3. The keyboard shortcuts fought the keyboard

The staff table has vim-style bindings — j/k to move, 14 to set a booking to seated / confirmed / completed / cancelled, / to focus the filter. Good idea, correct instinct for a service console.

They were bound to window. Pressing j while typing in the date picker moved the table cursor instead of typing a j. The only guard was a check for one specific input element.

Now the listener is scoped to the panel, ignores any event originating in a field, and row commands require the cursor to actually be in the table body. Rows use a roving tabindex — the table is one tab stop and the arrow keys move within it — and the focused index is clamped when a filter shrinks the list under it, which previously left the shortcuts acting on a row that no longer existed. Because a number-key press has no visible affordance, each status change is announced: “Eleanor Vance, 19:30: seated.”

4. Earning the 86 strike

The 86 board is the app’s signature move: the kitchen strikes a cut, and it goes through on the public menu. It happened instantly, with no transition at all.

text-decoration-line is not animatable. text-decoration-color is. So every menu line carries line-through at all times, held transparent, and being 86’d transitions the colour in over 420ms — the strike inks in rather than popping. Colour and opacity only: no layout, no compositing surprises, no restyling of a design that was already right.

That is the whole motion budget on this spoke, and deliberately so. The restrained end of the range is where the steakhouse belongs; the animation showcase is a different piece. I removed the motion library — it had been installed by the scaffold and never imported once. Shipping a dependency you don’t use is the same failure as shipping motion you don’t need.

5. Bugs found by actually running it

Typechecking green is not the same as working. These came out of exercising the thing:

6. Performance

7. Architecture

StaffDashboard.tsx was 879 lines holding auth, four panels, a modal, data fetching and a global key handler. It is now nine focused modules behind a StaffSession context, the largest of which is 289 lines. Same behaviour, minus the bugs that were hiding in it.

The scaffold’s other leavings went too: an AI-Studio .env.example documenting a Gemini key the app never used, a metadata.json claiming a server-side AI capability that does not exist, and HMR config commented “do not modify” for a platform this no longer runs on. The .env.example now documents the variables the app actually reads, and says which ones the server refuses to boot without.


What I chose not to build

The private dining enquiry form on the Visit page never went anywhere — it set a submitted flag and showed a success message. Wiring it to a real endpoint meant inventing product beyond this spoke’s scope, so instead the confirmation stops claiming something was sent: private hire is arranged by hand, and the copy now says so and gives the phone number and address. A form that lies about what it did is the shell problem in miniature, and the honest version cost one paragraph.

What the numbers say

Measure Scaffold Shipped
Admin routes behind auth 0 of 6 6 of 6
ARIA attributes 1 85
prefers-reduced-motion coverage 1 class global policy
Font payload 4 families, CDN, render-blocking 156KB, self-hosted
Largest component 879 lines 289 lines
TypeScript implicitly any throughout strict

The honest summary

The AI builder gave me a good-looking, well-modelled draft in ten minutes and saved me the blank page — which is real, and I’d do it again. It also gave me an unauthenticated admin panel, an app that was inaccessible to anyone not using a mouse, a typecheck that checked nothing, and a calendar export that emitted invalid files.

Knowing which of those matter, in what order, and what each actually costs to fix — that is the job. The scaffold is not the work. This is.