This is a submission for Frontend Challenge - Comfort Food Edition, Perfect Landing
Most food websites ask what you want to eat. I wanted to ask what you remember.
Still Warm is a museum where comfort food is the art. Four rooms, four dishes, four stories about home. You walk in as a visitor, and if you leave a memory at the donation desk, you leave as part of the collection.
You enter as a visitor. You leave as part of the collection.
That loop is the whole idea. Everything else on the page exists to make it land.
What I Built
A landing page for a museum that does not exist, built the way a real museum's site would be built.
Four rooms, each a numbered exhibit. CAT. 001 Homesickness (varenyky), CAT. 002 Rainy Days (grilled cheese), CAT. 003 Celebration (empanadas), CAT. 004 Sunday Morning (pancakes). Each room has its own light: Rainy Days sits in a blue-gray evening, Celebration in warm gold, Sunday Morning is the brightest room you can walk into. You feel the walk between rooms before you read a word.
Placards you open. Every dish has a museum placard with a catalog number, medium, provenance, and a folded label. Press "Read the label" and it unfolds into the story, plus one sensory detail. The dish's spotlight warms, the room dims a little, and the dish performs one short serving detail: syrup finishing its run, cheese stretching, a steam puff. Then it goes still again. Museum logic: exhibits do not loop forever, motion means someone just served the dish.
Exhibit 000: The Ramp. The accessibility statement is not a link in the footer. It is the museum's first exhibit, before Room 001:
Every museum needs a ramp, and this one is built in. The keyboard routes through every room, motion quiets on request, the contrast holds AA in every room, and every label can be read aloud by a screen reader. Accessibility is not an amenity here; it is the architecture.
Every one of those claims got verified line by line before I shipped. More on that below.
The reserved frame. The exhibition ends with CAT. 007 - RESERVED: a brass frame under a dimmed spotlight, holding a dotted conservation drawing of a covered dish, with a real placard.
This space is held for a memory that has not arrived yet. Donations accepted below.
It is never a blank box that looks broken. It is a museum device: the "temporarily on loan" card.
The donation desk. Type your dish, your feeling, your memory. The placard fills in as you type. Submit, and the cloche lifts away to reveal your exhibit under full light, with your own catalog number: CAT. V-3350.
No two donated memories produce the same exhibit. This is the part I am proudest of. Your four fields are hashed, and that hash chooses what gets plated: which food primitive (a dumpling, a stack, a bowl, a wedge, a bun, a disc), how many pieces, where they sit, how they tilt, which garnish, which accent tone. It is deterministic, so the same memory always builds the same exhibit, and it is drawn from real food parts so every combination still reads as a plated dish. Then you can take a postcard home with your exhibit printed on it.
It prints as a catalogue. The page composes itself into a seven-sheet exhibition booklet. Every label opens for the print pass, the gallery darkness turns to ink on white, and each room takes its own sheet with the plate on the left and the label on the right, the way a catalog sets an entry. The header, the donation desk and the footer stay off the paper, because none of them can do anything there. The rule is an allow-list rather than a hide list, so a section added to the site prints nothing until someone decides it belongs.
Why a museum
Because everyone else shows one dish. A museum curates emotions and treats dishes as artifacts, which is a different thing.
It also solves a structural problem. "An interactive experience" is a hard sell as a landing page. But museums have real landing pages, with a current exhibition, a plan-your-visit section, and a way to contribute. The museum frame makes an experience formally a legitimate landing page.
Demo
Live: still-warm.boyko-nazar.workers.dev
Source: github.com/nazboyko/still-warm (MIT)
The whole visit, from the lights coming up to the last frame:
Journey
I shipped this page seven times before I shipped it. Each time, everything was green.
That is the only thing I really learned building it, and it took seven separate humiliations to land: "everything is green" is a hypothesis. It needs testing as rigorously as the code does. A green check is not a fact about your software. It is a claim made by a specific tool, looking at a specific thing, on a specific machine, at a specific moment. Every one of those four qualifiers is somewhere a bug can live.
Here is where they lived.
The machine was right, the page was stale
Early on I fixed a bug, reloaded the preview, and watched the bug happen again. The code was correct. I read it three times. The page disagreed.
The preview server was serving a build compiled before the fix existed. I had spent an afternoon verifying fixes against a bundle that did not contain them - and every check had passed, because the checks were honest about a thing that was out of date.
The lesson is unglamorous and permanent: when the code says one thing and the page says another, suspect the pipeline between them before you suspect either end.
The test could not see
Room 002 is a grilled cheese caught mid-pull, with strands stretching between the halves. I drew a strand. The tests passed. The element was in the DOM with the right attributes, the SVG was well formed, and the strand was not on screen. A CSS rule in another file had set it to opacity: 0.
Every test I owned was a DOM test. Not one of them could tell the difference between "this element exists correctly" and "a person can see this." That gap is invisible until something falls into it, and then it is obvious forever. It is why the project now has visual regression baselines, and why I stopped treating a passing assertion as a substitute for looking.
My laptop was not the customer
I develop on a Mac. CI runs Linux. Three defects lived only on the far side of that gap: 624 pixels of horizontal overflow at 320px, caused by a long unbroken dish name driving the placard's minimum width; a layout shift of 0.132 from font fallbacks I had tuned against macOS metrics; and a room-to-room walk that silently did nothing on WebKit.
All three would have shipped. Not one was subtle in the environment where it existed. The fix was not cleverness - it was running the full browser matrix on every pull request instead of only on the main branch, so that "green" started meaning "green somewhere other than my desk."
The browser lied, and my own test knew
This is the one I would put on a poster.
When you open one room while another is open above it, the page must not jump. So I asked the browser whether it handled that itself:
const browserAnchors = CSS.supports("overflow-anchor", "auto");
if (openRoomId && next && !browserAnchors) {
// compensate for the collapsing room above
}
Reasonable code. I measured it in all three engines. CSS.supports("overflow-anchor", "auto") returns true in every one of them - so that compensation branch had never run, in any browser, since the day I wrote it. It was dead code wearing the costume of a feature.
Chromium and WebKit got away with it because they genuinely do anchor. Firefox reports support and then does not anchor this particular mutation. The label you clicked jumped 390 pixels off the top of the screen. On the other two engines it moved four.
And here is the part that stings. I already had a test for exactly this, named for exactly this, and it was correct on the day I wrote it. It had never run on Firefox, because CI installed Chromium and WebKit only. The test was right, the product was wrong, and the matrix was configured so the two could never meet.
The fix stopped asking and started measuring: note where the clicked label sits, let the layout change, put it back. Firefox went from 390 pixels to zero. Feature detection tells you what a browser claims. Measurement tells you what it does.
The fix became the bug
Days later, in a polish pass, I added scroll-behavior: smooth so in-page links would stop teleporting. Good change. Unrelated, I assumed.
CI went red on that same anti-jump test. The correction that exists to make a room swap invisible was now being animated by the smooth scrolling I had just switched on. A fix for drift had become drift, caused by a change three groups earlier in the same pull request. One word - behavior: "instant" on that one call - and it was gone.
Nothing was wrong with either change. They were wrong together, and only a browser could tell me that.
I read the part that said green
Then I ran a full verification sweep, specifically to catch things like the above. I ran the matrix, read the last three lines, and reported it green.
Playwright prints its failure list immediately above the summary. My tail -3 had captured "28 skipped, 274 passed" and cut off the line one row higher that said 11 failed.
I was running a sweep whose entire premise is that green is a claim, and I made the claim from a green-looking fragment. There is no clever lesson here. Read the whole output. The part you skipped is where the failures print.
I argued with the test, and the test was right
The last one is the worst, because I had already learned it.
A test failed once on Firefox, on CI's runner, measuring where the trigger sits after Escape closes a label. My first instinct was that the assertion was too strict - that it sampled mid-animation, and the page settled correctly afterwards. I even wrote that down as the diagnosis.
Then I measured under the real conditions. The trigger came to rest 50.6 pixels under the header and stayed there. Not an animation frame. A real defect, reproducible, user-facing. My guard checked a single frame after the close, but the panel takes about 240 milliseconds to collapse and Firefox re-anchors after any one frame, so everything that moved after that frame went uncorrected. It now watches thirty frames, about half a second, and corrects only when the trigger actually crosses under the header.
I had spent a week writing that the tests were right and the product was wrong, and the first time a test inconvenienced me I reached for "the test is too strict." That reflex is the whole problem in one gesture.
What it adds up to
Seven failures, one shape. In every case a green signal was technically true and practically worthless: true about a stale bundle, true about the DOM but not the pixels, true on my OS but not on CI's, true about a browser's claim but not its behaviour, true before an unrelated change, true in the three lines I read, true at the millisecond I sampled.
None of this made me distrust testing. It made me treat the report as evidence rather than a verdict - to ask what exactly was checked, where, and when. That habit found more real bugs in this project than any amount of careful coding did.
What I can prove
The Ramp makes four promises, so here is evidence instead of adjectives. All of it is measured against the deployed build.
Lighthouse: Accessibility 100, Best Practices 100 on desktop and mobile.
Zero axe violations and zero incompletes across 24 state-and-viewport combinations: homepage, each of the four rooms expanded, the Ramp, the reserved frame, the donation form pristine and with errors, the live preview mid-typing, the donated state and the gift shop. Incompletes matter here: those are the checks axe could not decide, and this project treats them as failures to resolve by hand rather than noise to ignore. One of them found a real problem - a decorative glow behind a label made its contrast unverifiable - and I moved the glow instead of silencing the check.
586 text runs measured for contrast, sampled from painted pixels rather than read from my tokens: render a frame, render a second with the text transparent, compare, so the background is provably the pixels behind the text. Zero AA failures. My first two attempts produced false failures by sampling text sitting behind the sticky header, and I threw those runs away rather than publish them.
27 keyboard stops, tab order matching reading order, with a visible focus ring at every one. The ring changes colour by surface - tungsten on dark walls, beet on the plaster placards - because tungsten on plaster does not clear 3:1.
Cumulative layout shift of 0.00, including on Fast 3G, and 0.0000 across 24 separate interaction measurements: every room opening and closing, the guide walk, typing into every field, the reveal, and the header compacting.
No main-thread block longer than 76ms in an entire session under 4x CPU throttling on a Pixel 7, with a 95th-percentile frame gap of 9ms and nothing over 100ms.
Reduced motion is a true twin, not a degraded mode. Under prefers-reduced-motion, document.getAnimations() returns zero in every state, and the body text is byte-identical between modes - 2965 characters either way. The guide and the room-to-room walk still work, landing at identical scroll positions.
What I cannot prove, and will say out loud
- Brass on ink measures 4.74:1 in paint against a 4.5 requirement, thinner than the 4.94:1 the tokens promise, because the ground it sits on is not quite as dark as the token says. That is about five percent of headroom, and it carries about ten pieces of small text. It passes, but it is really a do-not-darken constraint on the whole palette rather than a comfortable pass.
- Entrance CLS and the scroll-driven room lighting are verified on Chromium only. Both depend on browser features the other engines do not implement. The fallbacks run everywhere; the measurements do not.
- Performance is reported from traces, not a Lighthouse Performance score. I trusted the trace numbers more than a synthetic one, and I was not going to add a dependency the day before shipping to produce a rounder figure.
- It is a client-rendered single-page app with no prerendering. The content is fully present and readable without any interaction, but it needs JavaScript to render at all. On Fast 3G that is about a second of empty screen before the museum arrives.
What I left undone on purpose
Shipping means choosing what not to fix. These are known, deliberate, and written down rather than quietly hoped-over.
The food primitive library is 545 lines, and six other files are over the 150-line limit this project set for itself. The four room illustrations share a copy-pasted chassis - the same spotlight cone, glow, plate and steam block, four times over, plus a fifth copy in the hero. Extracting it would delete roughly 240 lines and bring three files back under the rule.
I did not do it. The art was frozen and verified, the deadline was real, and refactoring drawing code the day before shipping trades a genuine regression risk for a cosmetic gain. That is the trade, stated plainly. A judge reading foodPrimitives.tsx will see a long file, and they will be right.
Stack
React, TypeScript in strict mode, Vite, plain CSS with design tokens, Motion for state transitions. Vitest and Playwright with axe-core. One production dependency beyond React. Every dish is hand-authored SVG, no images, no icon library, no CSS framework.
Three typefaces do the talking, none of them mine. All are self-hosted and all are used under the SIL Open Font License 1.1: Young Serif, copyright 2023 The Young Serif Project Authors, for the display type; Familjen Grotesk, copyright 2021 The Familjen Grotesk Project Authors, for the body; and IBM Plex Mono, copyright 2017 IBM Corp. with Reserved Font Name "Plex", for the catalog numbers and every mark the museum sets in a typewritten voice. The full licence text for each ships beside the fonts in the repo.
Deployed on Cloudflare Workers as static assets. MIT licensed.
Thanks
Thanks to DEV and the challenge sponsors for a prompt that turned out to be about memory rather than food.
If you visit: leave a memory. The last frame is genuinely reserved for it, and the exhibit it builds for you will not exist anywhere else.
The exhibits are still warm.





Top comments (0)