# Two recipe apps, one story

Two complete visual directions for the same recipe app, built so they can be
compared side by side and one can be picked. Everything except the visual
language is held constant: same twelve dishes, same words, same nine story beats.

Directory page: `index.html`. Live at **appscreens.joonas.wtf/food**.

## The three directions

| dir | name | ground | the idea |
|---|---|---|---|
| `noir/` | **Noir** | near-black, warm | The kitchen at night. Food is the only light source in the frame. |
| `studio/` | **Studio** | pure white `#fff` | Swiss precision. Type and grid do the work; one accent, used once. |

These are not three palettes on one layout. Each direction owns its own
markup, its own type scale, its own spatial logic and its own motion
personality. If you could recolour one into another, we failed.

## The story (the reason this exists)

It goes in the storyboards. Nine beats, ~27 s, no narration.

**One interaction, one transition.** The app opens on the choices for tonight as
recipe cards in a sideways carousel. The carousel is the only control on the
screen — it slides two cards, and where it lands is what you are making. Then the
card **expands into the recipe page** as a shared element.

Two earlier versions were more clever and worse: a carbohydrate axis under the
row with a live gram readout, and a rearranging row that re-sorted itself. Both
were more apparatus than the idea needed.

1. **Tonight's choices** — a row of cards. Nothing decided, nothing to configure.
2. **It slides** — the row travels sideways; a card passes on the way.
3. **It lands** — two cards along, on the gochujang chicken.
4. **It expands** — the card photograph grows into the page hero.
5. **The recipe** — settled.
6. **Cooking for two** — quantities roll, the list updates behind them.
7. **Cook** — chrome falls away. One step, one timer.

**The expand is one photograph, not a crossfade.** A single `<img>` sits in a
layer above both screens and its rect is interpolated from the card's to the
hero's; the corner radius interpolates too, and the image inside is sized to
*cover* the destination at every frame so the crop does not swim as the frame
grows. Two copies of the same photograph dissolving into each other always reads
as a dissolve; one object moving reads as continuity.

The geometry is **declared, not measured** (`cardRect()` / `HERO_RECT`). Measuring
would make the transition depend on layout having settled, which is exactly what a
frame-exact render cannot rely on.

The app never says "healthy", never says "optimise", never congratulates
anybody. It says the number and gets out of the way. Copy is deadpan and
lowercase-leaning; the smartest thing in the product is delivered as an aside.

## Screens (functional only — no onboarding, no auth, no settings)

| screen | what it has to do |
|---|---|
| `explore` | The entry, and deliberately plain: a title, tonight's choices as recipe cards in a sideways carousel you can push, and one quiet line. No controls. Tapping a card expands it into the recipe. |
| `recipe` | Hero photo → title → meta row → **fresh** ingredients as photo chips with a serves stepper → **pantry** items typographically with have/need state → numbered steps. |
| `cook` | One step at a time, full bleed. Step count, timer where the recipe has one, the ingredients that step uses. Nothing else. |
| `library` | The twelve dishes. Filter row. Enough density to feel like a real cookbook. |
| `list` | Shopping list grouped fresh / pantry, with what the swap just added highlighted. |

## The fresh / pantry rule (not optional)

`shared/data.js` splits every recipe into `fresh[]` and `pantry[]`.

- `fresh[]` items get **photographic chips** — `img/ing/<id>.webp`, transparent
  cut-outs, 256², unbranded produce/protein/herbs only.
- `pantry[]` items are **typographic** — name, quantity, have/need. Never a
  photo. There is no photo for them and inventing one would look like stock.

This is also the product idea: photos are what you buy, the pantry line is what
is already in the cupboard, and that split is what makes `list` mean anything.

## Assets

```
fonts/    Selecta Regular/Medium/Bold · Kalice Regular   (two families, four files)
img/hero/<id>.webp       1600×1067  wide hero
img/hero/<id>-sq.webp    1000²      card crop
img/ing/<id>.webp        256²       transparent cut-out (102 of them)
img/ing/_index.json      the full list of available cut-outs
```

Only these fonts. No web fonts, no CDNs, no external requests of any kind —
every direction must work offline from its own folder.

## The film contract (mandatory, identical in all three)

The gallery scrubs both phones in lockstep, and screenshots are
taken headlessly, so **`seek(t)` must be deterministic**: the same `t` renders
the same pixels, with no dependence on accumulated animation state or on rAF
having run. Set state from `t`; never integrate.

```js
window.__recipe = {
  style: 'noir',            // 'noir' | 'studio'
  duration,                 // seconds, = filmDuration from shared/data.js
  beats,                    // the film array from shared/data.js
  seek(t),                  // absolute, idempotent, synchronous
  play(), pause(), playing, // play() from current t
  goto(screenId),           // 'explore'|'recipe'|'cook'|'library'|'list'
  onBeat(fn),               // fn({id, i, t}) whenever the current beat changes
  sample(),                 // {style, t, beat, screen, ok:true} — for headless checks
};
```

**Derive, never integrate.** The harness arrives at t=13.5 twice, once via
`1.0 → 13.5` and once via `24.0 → 6.0 → 13.5`, and diffs the DOM. Anything that
advances by steps rather than being computed from `t` diverges — a cook-step
index that increments, a timer that counts down from whenever it was started, a
carousel that remembers which way it last moved, a "cards seen" counter. Every
one of those must be a pure function of `t` (or of explicit hand-driven state
that the film sets absolutely, not relatively). `report.json.firstDivergence`
names the exact element and both values when this breaks.

`?scene=<id>` deep-links a beat, `?screen=<id>` a screen, `?film=1` autoplays
on load. The page must also be **fully interactive by hand** with the film
idle — tapping the card really opens the recipe, the stepper really changes
quantities, the timer really counts.

## Quality bar

The bar is: a designer at Apple or Airbnb sees this and cannot find the seam.
Concretely, all of these are pass/fail:

- **Optical alignment, not numeric.** Text left edges align across a screen,
  including punctuation and photo-chip label baselines.
- **One type scale per direction**, stated in a comment at the top of the CSS,
  and nothing off it. No arbitrary `font-size: 13px` because it looked better.
- **Every interactive element has a pressed state**, and it is not `opacity`.
- **Motion is one spring family.** State changes are 180–320 ms, entrances stay
  under 420 ms, and nothing linear except opacity. Respect
  `prefers-reduced-motion` by cutting movement but keeping the state change.
- **Type never reflows during motion.** Numbers that change get tabular figures
  and a fixed-width slot.
- **Photography is never stretched, never dimmed to be legible.** If text has to
  sit on a photo, it sits on real scrim geometry, not a global overlay.
- **Safe areas honoured** — `env(safe-area-inset-*)`, `100dvh`, no bottom bar
  under the home indicator.
- **44 px minimum touch targets**, and thumb-reachable primary actions.
- **Contrast** ≥ 4.5:1 for body text, ≥ 3:1 for large text and glyph controls.
- No emoji as UI. No default browser focus rings. No `text-shadow` to fix
  contrast. No purple-blue gradients. No generic card-with-shadow soup.

## Build rules

- Plain static HTML + CSS + ES modules. No build step, no dependencies.
- One folder per direction, self-contained apart from `../shared/data.js`,
  `../fonts/`, `../img/`.
- Frame target 393 × 852 (iPhone 16 logical). Must also survive 360 × 740 and
  430 × 932 without a broken layout.
- The gallery loads both in iframes at once — keep each direction
  under ~120 KB of JS/CSS and lazy-load photos that are not on screen.

## Rendering for After Effects

`tools/render.mjs` steps `t` at exactly 1/fps and screenshots, so the output is
analytically exact rather than a captured performance — no dropped frames, no
timing drift, reproducible, re-renderable at any size. Encodes ProRes 4444 by
default and writes a beat-marker CSV.

**CSS transitions are disabled during a render**, deliberately: a transition
interpolates in wall-clock time, which is meaningless when `t` is being stepped by
hand, and it would lag behind the state and smear. Everything that moves in the
film is therefore written as an **inline value derived from `t`** — card
positions, the morph rect, the note's opacity. Anything animated only by a CSS
transition would become a one-frame cut, which is why the note's fade was moved
out of CSS and into state.

## The desktop device frame

Opened on its own above 820 × 900, each direction pins itself to 402 × 874 pt and
sits in an iPhone 17 Pro frame. The gate is well above both any phone in landscape
and the ~400 px iframes the gallery embeds, so the gallery keeps getting the bare
app and the renderer keeps capturing at 393 × 852.
