A tablet-first, zero-touch cooking companion. Redesign of a recipe page for a cook whose hands are covered in flour, oil or water, reading a screen from 3 to 5 ft away.
The design rests on one finding from a recorded cook test: 15 device lookups in 50 minutes, and not one was a request for the next step. Every one was a question about the step the cook was already standing in. So this is a question answerer, not a step advancer.
Full reasoning lives in .docs/. Read those before changing anything structural:
| Document | What it settles |
|---|---|
.docs/cook-along-mode-handoff.md |
Problem, field evidence, personas, jobs, principles, interaction model |
.docs/cook-along-ia-sitemap.md |
Information architecture, sitemap, state machine, legibility maths |
.docs/non-touch-interaction.md |
How the app is driven with your hands in a pan — the mic, the grammar, the alerts, and what each failure looks like |
.docs/ai-layer-scope.md |
The import pipeline, the validator, and where the authored answers come from |
.docs/parallel-cooking.md |
How several pans run at once — the clocks, the strip, which pan is allowed to interrupt you. Written for a non-engineer |
.docs/ui-audit.md |
The design system: the token layer, the two zone scales, and the language rules |
.docs/recipe-data-audit.md |
Where the two hand-authored dishes do and do not hold up their end of what the engine expects |
.docs/decision-log.md |
Every choice taken, what forced it, what it rules out |
This table is not the whole of .docs/. Also there, and not yet indexed:
voice-interaction.md, app-content.md, user-journey-paneer-butter-masala.md and
edge-cases-ai-layer.md.
Requires: Node 22+, npm 10+.
npm install
npm run devThen open http://localhost:5173.
The dev server runs with --host, so it also prints LAN addresses like
http://192.168.1.x:5173. Use one of those to open it on the actual tablet — this is a
tablet-first design and it cannot be judged in a desktop window.
| Command | What it does |
|---|---|
npm run dev |
Vite dev server, exposed on the LAN for tablet testing |
npm run build |
Typecheck, then production build to dist/ |
npm run check |
The one to run before committing. Typecheck + voice grammar + design system + render tests |
npm run test:render |
Renders every route and every interrupt, and checks the content rules |
npm run test:voice |
The phrase table: what each utterance means, and what must be ignored |
npm run test:system |
The design system: no raw colours, no off-scale values, every button a variant |
React 19 + TypeScript + Vite. The recipe service is a Vite middleware plugin
(server/aiPlugin.ts) mounted at /api/ai/*, so it runs under dev and preview and not
in a static build — which is why an import fails with a message saying exactly that.
src/
App.tsx device frame, prototype chrome, routing
ui/
tokens.ts the palette, the legibility maths, and every scale
system.ts the semantic layer, and the distance-derived CSS custom properties
type.ts mono() and sans() — the text styles
Btn.tsx every button in the app, as variants
design/system.test.ts the guard: no raw colours, no off-scale values
data/recipes.ts Paneer Butter Masala, Chapati, Cherry Tomato Pasta — with provenance
model/ types, scaling, clock formatting, photo handling, food safety
ai/ the import client, the two prompts, the response schema
voice/ recogniser, grammar, speaker, and the controller that owns the mic
session/
engine.ts the state machine — clock, timers, cursor, ledger, voice
store.ts the engine as a React external store
derive.ts gaps, attention, and flame visuals
TimerStrip.tsx one slot per pan, for the whole session
IngredientRail.tsx what is left, struck through live from the ledger
StepColumn.tsx the one step that matters, and the assistant behind it
ElbowBand.tsx 50 mm a wet forearm can hit blind
screens/ launcher, dishes, source, reading, triage, gaps, servings, ready, session, plate
overlays/ S5 alert, S6 fallback, S7 wet hands, S8 back again, S11/S12 done, S13 recovery
devtools/ prototype controls — not part of the design
The seam is hand state, not app phase. Zone A is setup: read at arm's length with the
pan still cold, sized in a fixed 4 px grid and a fixed em scale, and it does not move when
the cook changes the distance. Zone B is the cook: read from across a kitchen, and every
size in it is a ratio of --type-answer. Crossing them is the bug that once made setup type
half-size at 2.5 ft. npm run test:system is what keeps them apart.
session/engine.ts is a plain class. The clock, the timers and the voice layer are
imperative by nature, and keeping them outside React means a remount cannot restart the
pan. store.ts exposes it through useSyncExternalStore. The same engine can later be
driven by a real speech recogniser, or by a server, without touching a component.
A recipe is a list of moments, not steps.
A conventional step — "fry the onions until golden, about 8 minutes, then add the
tomatoes" — decomposes into three moments with three different primary objects: an order
(Onions in), a threshold (Golden, and smell sweet), and another order (Purée in).
There is deliberately no field anywhere that holds prose. If you find yourself wanting one, that is the recipe-viewer failure mode the whole design exists to prevent.
Word budgets: prep 4, threshold 5, order 3. Do not raise a budget to make content fit. Cut the content.
The same rule holds for the design system. There is a radius scale, a spacing scale and a
type scale, and npm run test:system fails the build on a value outside them. If a size
looks wrong, the fix is a different rung — not a new one.
A published recipe is written for someone standing at the counter reading it. Cooking
hands-free needs heat, a clock and a finish line at every step. data/recipes.ts records
which of those the source actually gave ('r') and which we filled in ('i'), and the
/setup #gaps screen shows the whole list before a pan is touched. Filled-in values stay
marked while cooking — hatched flame icons, "no time given — cue decides" — and are never
passed off as the recipe's own.
The clearest case: PBM step 3 says "turn off and let it cool" with no duration. The cook asked exactly this on 2 Aug. The app supplies a 10-minute floor, says so, and lets the cue decide.
The entire type scale hangs off one number: how far away the cook stands.
cap height = viewing distance / 120 comfortable glance
cap height = 0.727 × em box Roboto Flex
At 5 ft that is a ~12.7 mm cap height, which at the target panel's 5.33 px/mm works out
around 93 px of body type. Emitted by rootVars() in ui/system.ts as CSS custom
properties on the root; every size in the cook screen is a ratio of --type-answer.
The ready screen is where both of its claims get tested rather than stated. The sample line
renders at true --type-answer — stand where you will cook and read it. Below it, the elbow
band is drawn at its true 50 mm, because a promise that the screen stops asking for a finger
is worth nothing until the cook has seen what replaces the finger.
--px-per-mm is measured for the target panel and needs verifying with a ruler on the
real device before the layout can be fully trusted.
| State | |
|---|---|
| The state machine, timers, ledger, scaling, session restore | Real. Timers are wall-clock epochs, so process death does not pause the pan. |
| Voice | Real, and it is a lookup. The mic is open for the whole cook — no wake word — and every utterance goes through src/voice/grammar.ts into engine.hearUtterance. Answers come out of Moment.answers, authored at import time, so nothing said during a cook touches the network. Recognition itself is on-device where the tablet has a model and cloud where it does not; the band says which. |
| Recipe import | Real. Fetch → extract → triage → compose → validate, against the Gemini API through server/aiPlugin.ts. Dev and preview only: a static build has no server. |
| Reference photos on the done-check | Placeholders. |
Voice needs a secure context, and this is now the first thing to get right.
SpeechRecognition only works on localhost or HTTPS. npm run dev -- --host serves a
LAN address, which is not one — the page loads fine and the mic never opens. Pick one:
adb reverse tcp:5173 tcp:5173 # then open http://localhost:5173 on the tablet
adb reverseover USB is cleanest: no dependency, no certificate warning- or add
@vitejs/plugin-basic-ssland accept the self-signed certificate - or allowlist the origin in
chrome://flags/#unsafely-treat-insecure-origin-as-secure
The app no longer fails quietly here: the ready screen reports Microphone needs https
rather than claiming permission it never asked for.
On-device recognition is asked for first. Where Chrome has an en-IN model the audio
never leaves the tablet and voice keeps working with the wifi off. Where it does not, the
mic falls back to cloud recognition — the kitchen goes to the browser vendor for the whole
cook — and both the ready screen and the elbow band say so. See D29.
Developer detail is behind the chrome flag. The adb line above, the missing-API-key
instruction, the model and prompt version, the raw import error and the recognition-accuracy
count are all real and all useless to a cook, so they render only when the prototype chrome
is on (Esc toggles it). With chrome off, each has a plain replacement that says what the
cook can actually do about it. See D33.
Target is an Android tablet, which is why the voice layer can use
SpeechRecognition.continuous = true directly. On iPadOS that flag is unreliable and the
layer would need rebuilding as a restart loop.
| Source | What it decides | |
|---|---|---|
| Quantities and timings | Swasthi's Recipes | What is true about the dish |
| Shape | The 2 Aug cook test | Which moment holds the screen, and what its primary object is |
Most moments carry a sourceStep tracing them back to a published step; the rest are prep
moments the source lists as ingredients rather than numbered steps. sourceGaps on each
dish records where the published recipe is silent or contradicts itself.