diff --git a/.github/test-baseline.json b/.github/test-baseline.json index 4f8f413a..da98b66a 100644 --- a/.github/test-baseline.json +++ b/.github/test-baseline.json @@ -1,5 +1,5 @@ { "_comment": "Floor for how much testing this repo has. Raised by scripts/check-test-baseline.mjs when you add tests; lowering it is a deliberate, reviewable edit.", - "unit": 940, - "e2e": 90 + "unit": 961, + "e2e": 99 } diff --git a/CLAUDE.md b/CLAUDE.md index 48ec71af..003a3b90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Source of truth: `DoughInputs` in `src/lib/dough/types.ts`. ### Fermentation window slider -- **Lives in the form column, at the end of the `When` fieldset** — it spans the two times above it and rewrites `startAt` as you drag, so it belongs with them, not in the schedule. Its benefit paragraph is therefore **always shown**, like every other field's help text: the schedule's short/detailed toggle sits in the other column and must not reach across into this one. +- **Lives in the settings rail, at the end of the `When` fieldset** — it spans the two times above it and rewrites `startAt` as you drag, so it belongs with them. Its benefit paragraph is therefore **always shown**, like every other field's help text: the schedule's short/detailed toggle sits over on the instrument and must not reach across into this one. The dial's coil tail is a **second** control on the same value (see Design); both write through `FormState.setWindowHours` and snap to the same `stopsWithIdeal` list, so they can never hand back a window the other cannot express. - `src/lib/dough/windowPresets.ts` (pure) + `FermentWindowSlider.svelte`. The slider's value **is a stop index** into `WINDOW_STOPS` (6, 8, 12, 16, 18, 24, 36, 48, 72, 80 h — canonical Neapolitan windows, except 80 h which is the schedule's own ceiling), and the rail is linear in that index so every stop gets the same target size on a phone. It writes `FormState.fermentWindowHours`, which moves `startAt` — `readyBy` stays the anchor, so the night-window guard, the cold/room switch and the yeast solve all re-run on it normally. - **The drag moves `startAt`, and says so when that crosses a day.** `readyBy` is the anchor, so a longer window can only push the start earlier, and past a certain length onto a different date — easy to miss while the readout counts hours. Every path that moves the start — a drag, or a re-pick after a bake-time or flour edit — reports it through `FormState.startDayMoved`, set by comparing the calendar day either side of the write. A change raises `schedule.window_start_moved` naming the new moment, in the info (dough) style rather than the red used for refusals: the slider did its job, it is just reporting the consequence. Same-day shifts stay quiet — the start field sits directly above. - **Nothing may ferment past the bake time.** `reachableStopIndex` bounds the slider at the time still left; longer stops are greyed, refused on input, and the deadline is named by a labelled flag above the rail pointing at the spot where the grey begins — the grey edge and the flag already say it twice, so there is deliberately no third marker drawn on the rail itself. A refused drag raises a red `schedule.window_overrun` notice naming the longest window that still fits — a control that springs back without a word reads as broken — and the handler writes the clamped index back onto the DOM node, because a refusal usually leaves the bound index unchanged and Svelte would otherwise leave the thumb sitting out in the grey. @@ -75,7 +75,7 @@ Nudges ball weight (0.1 g) so flour lands on a multiple of 100 g — always, for ## Outputs - **Ingredients (grams).** The flour rows are labelled with the **chosen bag's own name** (`flourIngredientName` in `stepCopy.ts` — preset name when `flourW` matches one, generic "Flour" for a hand-typed W or `null`), on the screen table and the print sheet alike; the schedule steps' own ingredient lists keep the generic word, since they read as instructions. Naming a row is presentation — W still never touches a mass. No pre-ferment → flat table. With pre-ferments → one typed pre-dough section per entry (`Biga (pre-dough)`, `Poolish (pre-dough)`) / Main dough / Totals (a single subtracted table reads as a math error). With pre-ferments, main-dough yeast row is hidden — totals row surfaces the yeast. **Oil and sugar rows render only when > 0** (so defaults-only recipes stay unchanged). **What is weighed, and in what order, is `src/lib/ingredientRows.ts`** — one list, rendered by both `Ingredients.svelte` and the print route, so the paper cannot quietly disagree with the screen (`ingredientRows.test.ts` for the rules, `e2e/recipe-output.spec.ts` for the two renderings matching row for row). Under a pre-ferment the main dough has **no** yeast row at all — `computeIngredients` sets that mass to exactly 0, so the old `yeast > 0` guard in both components was a condition that had never been true. -- **Schedule.** Rendered as a **day-grouped vertical timeline** (`ScheduleTable.svelte`): consecutive steps fall under one date header, a rail threads the nodes (filled = baker-action step, hollow = waiting phase, dashed rail segment leaving a fermentation step), and the in-progress step gets a pulsing node + `Now` badge. **Step copy is split** (`stepCopy.ts`): `stepIngredients(step, msgs, schedule)` returns the amounts a step **newly** puts on the scale as a structured `{amount, name}[]` list (rendered as a mini-table, never prose) — each ingredient appears on exactly one step, never repeated. `preferment-mix` and `prep` carry the lists; `mix` lists only oil/sugar and only under a pre-ferment (without one they're weighed at `prep`, so `mix` lists nothing). **Under an autolyse** (`hasAutolyse`, no pre-ferment) the split shifts: `prep` weighs flour+water only, `mix` weighs the held-back salt+yeast (plus any oil/sugar), and the `{water_temp}` note moves to `prep` (where the water first meets the flour); the `autolyse` step itself lists nothing. `stepDescription` is method-only copy; `divide` keeps `{n}`/`{weight}` interpolation; `mix` keeps `{water_temp}`; `prep_desc_autolyse` gains `{water_temp}` and `mix_desc_autolyse` folds in the held-back salt+yeast. Day-two `prep` omits yeast under a pre-ferment. **Separate `mix`/`prep` method templates per pre-ferment shape** (`*_with_biga`, `*_with_poolish`, `*_with_both`, `prep_desc_with_preferment`): biga = stiff/no-knead day-one + day-two fold-in; poolish = whisk-and-pour; both = tear-in + pour-over. Mix bases are method-neutral; the kneading sentence comes from `mix_technique_{spiral,stand,hand}`. `preferment-mix` titles/descs branch on `step.preFermentType` (`preferment_mix_{biga,poolish}`); each row spans its full duration; no separate `preferment-proof` step. Beginner mode adds a `steps._detail` paragraph under every step. **Source-timing badge**: when the form matches a `pizzeriaEntries` row and a step's computed duration falls outside the source range (±15 % tolerance), `ScheduleTable` renders the original value beneath the duration. +- **Schedule.** Drawn twice, from one set of copy: as the **coil on the dial** (see Design) and as a **day-grouped vertical timeline** (`ScheduleTable.svelte`) — the list is the secondary representation and the accessible equivalent of the drawing, and the two share a selection. `StepReadout.svelte` renders whichever step is selected in full, beside the dial. The list: consecutive steps fall under one date header, a rail threads the nodes (filled = baker-action step, hollow = waiting phase, dashed rail segment leaving a fermentation step), and the in-progress step gets a pulsing node + `Now` badge. **Step copy is split** (`stepCopy.ts`): `stepIngredients(step, msgs, schedule)` returns the amounts a step **newly** puts on the scale as a structured `{amount, name}[]` list (rendered as a mini-table, never prose) — each ingredient appears on exactly one step, never repeated. `preferment-mix` and `prep` carry the lists; `mix` lists only oil/sugar and only under a pre-ferment (without one they're weighed at `prep`, so `mix` lists nothing). **Under an autolyse** (`hasAutolyse`, no pre-ferment) the split shifts: `prep` weighs flour+water only, `mix` weighs the held-back salt+yeast (plus any oil/sugar), and the `{water_temp}` note moves to `prep` (where the water first meets the flour); the `autolyse` step itself lists nothing. `stepDescription` is method-only copy; `divide` keeps `{n}`/`{weight}` interpolation; `mix` keeps `{water_temp}`; `prep_desc_autolyse` gains `{water_temp}` and `mix_desc_autolyse` folds in the held-back salt+yeast. Day-two `prep` omits yeast under a pre-ferment. **Separate `mix`/`prep` method templates per pre-ferment shape** (`*_with_biga`, `*_with_poolish`, `*_with_both`, `prep_desc_with_preferment`): biga = stiff/no-knead day-one + day-two fold-in; poolish = whisk-and-pour; both = tear-in + pour-over. Mix bases are method-neutral; the kneading sentence comes from `mix_technique_{spiral,stand,hand}`. `preferment-mix` titles/descs branch on `step.preFermentType` (`preferment_mix_{biga,poolish}`); each row spans its full duration; no separate `preferment-proof` step. Beginner mode adds a `steps._detail` paragraph under every step. **Source-timing badge**: when the form matches a `pizzeriaEntries` row and a step's computed duration falls outside the source range (±15 % tolerance), `ScheduleTable` renders the original value beneath the duration. - **`.ics` export.** One VEVENT per step. `DESCRIPTION` is `stepDetailText` — the ingredient list (one `amount name` line each) followed by the method copy, so the calendar event **matches the on-page step verbatim**; in beginner mode the explanation paragraph is appended too. UIDs include `preFermentType` — two parallel pre-ferment mixes can share a start time. - **Print / Save as PDF.** Print button opens a dedicated `/print/[[locale]]?` route in a new tab (SSR + prerendered, mirrors the TRMNL push pattern). The route auto-triggers `window.print()` on mount with inline styles so the main app's gradient/dark-mode rules don't bleed in. This is the only print path — the legacy `@media print` block on the main route was removed in v3.6 (Cmd-P from the screen now prints the screen layout). Output must read on **B&W** (borders + text colour, no background fills) and **fit one page** on A4/Letter for common shapes (fresh × {no-preferment, biga, poolish, biga+poolish} × {room, cold} — the 9-step biga+poolish cold schedule is the worst case). Print never carries the beginner detail copy. QR of the share URL via `src/lib/qr.ts` (wraps `qrcode-generator`). - **TRMNL e-ink view** is **pushed** to a Private Plugin webhook from the user's browser — see the TRMNL push section. @@ -171,13 +171,27 @@ The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a Private P ## Design -Responsive, playful, Italian-warm (tomato / basil / dough). Must read well on a phone on the counter at narrow widths. - +**The app is one instrument for time.** The centre of the page is a dial: a 24-hour face with noon at the top, the hours running clockwise, and the schedule drawn on it as a coil — one revolution per day, so a 32 h bake is a turn and a bit and a 72 h bake is three coils, and the length of a plan is something you _see_ before you read a number. The bake sits on the outer rim and the dough unwinds inward from it, which is what "everything schedules backwards from `readyBy`" looks like when you draw it. Everything else on the page orbits that: the settings are a rail, the plan is a log, the collections are a detour. + +- **`src/lib/dial.ts` is the geometry, and it is pure.** Angles, coil radii, sampled spiral paths, the night wedge, hit areas, the hour ring — all of it is arithmetic over `ScheduleStep[]` and a `now`, unit-tested to the same 100 % as the rest of `src/lib/`. `Dial.svelte` only draws what it returns. **Nothing in `src/lib/dough/` knows the dial exists** and no field was added to `ComputedSchedule` for it. The hard cases are pinned by name in `dial.test.ts`: midnight wrap-around, a bake shorter than one revolution, a 72 h three-coil bake, a phase too brief to draw, and two pre-ferments sharing the same minutes. + - **A moment's angle is its time of day and nothing else.** That is what lets the 22:00–08:00 night wedge be a fixed shape on the face that every coil passes through, whatever the plan looks like. Don't "improve" the angle to account for which day it is; the whole reading depends on it. + - **The coil's angle is unwrapped, its radius is not.** `angleAt` keeps counting past 360° so an overnight leg is one continuous stroke; `radiusAt` is anchored on the bake at `R_OUTER` and steps inward by one `pitch` per day. Under a day of window the coil simply does not reach `R_INNER` — the pitch is never stretched to fill the band, or a six-hour bake would draw as a fat spiral ramp. + - **A phase too short to read as an arc becomes a bead** (`bead`, decided on arc _length_ in viewBox units, not on sweep — the same fifteen minutes is legible on the outer coil and invisible on the inner one), and every phase gets a `hitD` padded to at least `MIN_HIT_DEGREES` so a finger can land on it. Hit paths are invisible and may overlap; that is the point. + - **`R_INNER` is set by the centre plate, not by the coil.** The bake time is written at the axis, and at a radius of 17 the innermost turn of a three-coil plan ran straight through it. The plate is SVG ``, not an HTML overlay, so it scales with the face instead of keeping rem sizes while the dial shrinks. +- **Hue means temperature on the dial, and nothing else.** `--color-warm` for the counter, `--color-cold` for the fridge (the new `frost` scale — the app had no cold colour at all, so "in the fridge" was told in the same warm palette as "on the counter"). Tomato stays the accent for the bake, for `now` and for selection; basil stays the flour's tolerance band inside the window card, which is a different axis ("good", not "warm"). A legend under the dial says so once. +- **The dial is a control, not a picture.** The coil is a `role="listbox"` of steps with roving tabindex — one tab stop, arrows walk the plan — and the coil's tail is a `role="slider"` that turns like a knob, one revolution to the day, snapping to exactly the stops the rail in the form offers. Both are duplicated in plain controls: the plan list's step titles are buttons, and the window has its rail. **Anything draggable keeps a keyboard path and a plain field behind it.** +- **The selection is one value, lifted to the page.** `stepKey(step)` is `kind` plus `preFermentType` — deliberately _not_ the start time, because the key has to survive an edit that moves every step, or the readout would reset on every stop of a window drag. Until the baker picks something the readout opens on the step that is running (or the next one): "what now?" is the question the app exists to answer. +- **The readout and the plan list are two renderings of one set of copy.** Both call `stepCopy.ts`, so they cannot disagree about what a step says; the readout gives one step in full beside the dial, the list reads straight through. When a phase ends on another day the readout names it: "01:00 PM – 02:45 PM" for a 25 h fridge leg reads as an hour and three quarters. +- **Layout.** A masthead (name, language, theme, the actions menu), then a grid: at `lg` a settings rail on the left and the instrument on the right; at `xl` the instrument splits again into dial + plan on the wide side and readout + weights on the narrow one. Every cell is placed explicitly, so DOM order is free to serve the phone, where it reads dial → readout → plan → weights → settings. **The form is how you adjust the answer, not how you reach it** — it is no longer a column standing in front of the schedule. + - The rail is `sticky` **and its own scroll region**. A sticky box taller than the viewport pins itself and then scrolls with the page, which takes its own lower half permanently out of reach; the fridge-temperature field simply could not be clicked. Pinned in `e2e/dial.spec.ts`. + - The rail is **27 rem wide on purpose** — the window card lives in it, and that card carries the densest small type in the app. Narrowing it means re-answering the marker captions and the tick labels, which `e2e/window-slider.spec.ts` will tell you about. +- **One ink scale, one rule colour, one surface.** `--kt-*` custom properties carry the thirteen roles (`ink`, `ink-soft`, `ink-faint`, `face`, `face-deep`, `rule`, `rule-soft`, `warm`, `warm-soft`, `cold`, `cold-soft`, `night`) and `@theme` aliases them to `--color-*`, so every Tailwind utility (`text-ink`, `bg-face`, `border-rule`) resolves through them and `html.dark` swaps all of them at once by redefining the `--kt-*` half. **Don't reintroduce `text-stone-* dark:text-stone-*` pairs**: that is two palettes with a hand-written dark half each, and it is how `text-ink-faint`'s predecessor sat at 3.6:1 on 46 nodes until the axe sweep caught it. The dark values are chosen, not filtered. +- **A repeated Tailwind class list gets a name in `app.css`, not a copy.** The `@layer components` block holds the shapes more than one place needs: `.card`, `.instrument`, `.readout`, `.step-row{,-on}`, `.field-label`, `.legend-dot`, `.btn-tomato{,-sm}`, `.btn-quiet`, `.menu-item`, `.notice` + `.notice-{danger,info}`, `.pill-group`/`.pill`/`.pill-{on,off}`, `.input`, `.link-quiet`, `.link-action`, `.dialog-panel`, `.row-divider`, `.text-accent`. Hand-copied lists drift silently. One-off styling stays inline; the rule is about the second occurrence, not the first. + - `.card` is a **hairline surface with no shadow and no blur**. The instrument is the only thing on this page allowed to look like an object; eight drop-shadowed boxes around it is what made the old layout read as a stack of widgets rather than a tool. - **One dismissal rule for every popover** — `dismissOnOutsideClickOrEscape` in `src/lib/components/dismiss.svelte.ts`, used by the actions menu and the fit-score panel. Outside click closes; Escape closes and hands focus back to the trigger; state is read through callbacks at event time, never snapshotted (a bound copy of a `
`'s `open` is one tick behind the attribute the browser already flipped, which is what made the panel's own copy fail about one browser-test run in four). Both halves are pinned in `e2e/focus-dismissal.spec.ts` — a menu test there waits for **focus** rather than visibility, because `
` opens itself a tick before the effect that attaches the key handler runs. - -- **A repeated Tailwind class list gets a name in `app.css`, not a copy.** The `@layer components` block holds the shapes more than one place needs: `.card`, `.btn-tomato{,-sm}`, `.btn-quiet`, `.menu-item`, `.notice` + `.notice-{danger,info}`, `.pill-group`/`.pill`/`.pill-{on,off}`, `.input`, `.link-quiet`, `.dialog-panel`, `.row-divider`, `.text-accent`. Hand-copied lists drift silently — the window card's status box had ended up a different shade from the warning list directly below it. One-off styling stays inline; the rule is about the second occurrence, not the first. - **The segmented switch is a component**, `SegmentedControl.svelte` — language, theme and schedule verbosity all render through it. The three had drifted apart in markup as well as size: two named themselves with a ``, one with `aria-label` on a `role="group"`. A `
` + sr-only `` needs no ARIA at all, so that is the shape it settled on; an icon strip passes `labelFor` for the spoken name and a snippet for the glyph. -- **e2e specs address the app through these names**, so a class in `app.css` is closer to an API than a style — `windowCard()` finds `form div.rounded-2xl`, `card()` finds `.card`. Renaming one means grepping `e2e/` too. +- **e2e specs address the app through these names**, so a class in `app.css` is closer to an API than a style — `card()` finds `.card`, `formCard()` finds the `.card` holding the range input, `windowCard()` finds `form div.rounded-2xl`, and `e2e/dial.spec.ts` finds the instrument through `section.instrument` and `.readout`. Renaming one means grepping `e2e/` too. +- **The formula blocks in the "Get nerdy" panel wrap rather than scroll sideways.** In a rail this narrow they overflow, and a scrollable box has to be reachable from the keyboard — which for a formula means a tab stop that leads nowhere. Soft wrapping leaves the text identical, which is what `e2e/info-panel.spec.ts` reads. ## Git workflow @@ -205,7 +219,7 @@ Math/schedule bugs are silent until a dough overproofs. **Coverage is a hard gat - **UI components are not in the coverage target.** `.svelte` and `.svelte.ts` are excluded — vitest has no Svelte plugin, so those modules cannot even be imported by a unit test (`$state` is undefined). They are covered by the browser suite instead. - **Browser tests live in `e2e/`** (Playwright, Chromium only) and run as their own CI job. `npm run test:e2e`; `npm run test:e2e:ui` for the debugger. They build and serve the real static output, because the app ships as prerendered HTML that hydrates and only then decodes the URL — reading before that swap sees build-time defaults, which is how a check can pass against numbers that were never on screen. Every spec waits for the decoded recipe (`waitForHydration`). - The clock is pinned (`page.clock.install`) and so are `timezoneId` and `locale`: the whole app is wall-clock arithmetic, so a real clock makes assertions drift by the hour and fail overnight. - - **What belongs here**: rules that live in a control rather than in `src/lib/dough/` — the window slider's clamping and marker geometry, the re-pick triggers, `startAt ≤ readyBy`, view-mode and verbosity resolution, recipe memory (issue #201), storage being blocked outright (issue #195), legacy share-link fidelity, and one regression test per browser-only bug we have already shipped a fix for. + - **What belongs here**: rules that live in a control rather than in `src/lib/dough/` — the window slider's clamping and marker geometry, the re-pick triggers, `startAt ≤ readyBy`, view-mode and verbosity resolution, recipe memory (issue #201), storage being blocked outright (issue #195), legacy share-link fidelity, and one regression test per browser-only bug we have already shipped a fix for. The dial adds its own family in `e2e/dial.spec.ts`: the drawing and the list agreeing on one selection, the coil being walkable from the keyboard, the tail and the rail landing on the same stops, the face fitting 390 px without the page scrolling sideways, and the settings rail being reachable to its bottom. ## Conventions diff --git a/README.md b/README.md index 62b5328d..baca4394 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,15 @@ A time-anchored Neapolitan pizza dough calculator — [try it live](https://kneadtime.pizza). You enter **when you want to bake**; the app schedules every step backwards from that moment, auto-switches between cold and room fermentation based on available time, and gives you an on-screen schedule, an `.ics` you can drop into a calendar, a print-to-PDF recipe sheet for the kitchen counter, and a [TRMNL](https://trmnl.com/) e-ink view for the counter clock. +New in 6.11: **the bake dial**. The whole plan is drawn on a 24-hour face — one +revolution per day, so a 32 h bake is a turn and a bit and a 72 h bake is three +coils, and you see how long the dough takes before you read a number. The bake +sits on the rim and the dough unwinds inward from it; the 22:00–08:00 night is a +shaded wedge every coil passes through; warm phases are amber, fridge phases +blue. Pick any arc (click, tap or arrow keys) and the panel beside it reads that +step out in full; drag the coil's tail to lengthen the fermentation window. The +step list is still there underneath, and the two always agree. + New in v6: **flour strength (W)** and a **fermentation-window slider**. Pick your flour and the schedule paints the window that flour actually tolerates. Twelve presets are shelved by what each strength is for — same-day, ~24 h, ~48 h, 48–72 h, plus a too-weak and a too-strong shelf, with the AVPN spec's W 220–380 as the outer edges — covering Caputo (Doppio Zero, Pizzeria, Nuvola, Saccorosso, Cuoco, Nuvola Super), Dallagiovanna (Classica Oro, La Napoletana, Uniqua Blu), Le 5 Stagioni Pizza Napoletana, Polselli Classica and a generic supermarket tipo 00. Or type a W yourself. @@ -66,12 +75,16 @@ src/ │ │ ├── types.ts shared types │ │ └── *.test.ts colocated tests │ ├── components/ ← Svelte 5 UI (uses runes) +│ │ ├── Dial.svelte the instrument: the schedule as a coil on a 24-hour face +│ │ ├── StepReadout.svelte the selected step in full, beside the dial +│ │ └── ScheduleTable.svelte the same plan as a day-grouped list │ ├── i18n/ ← messages (en/de/it/fr/nl), locale detection, runtime interpolation │ ├── community/ ← community.md (data) + parser, rendered as a table at the bottom of the page │ ├── pizzerias/ ← pizzerias.md (50 Top Pizza recipes) + parser, rendered below the community table │ ├── trmnl/ ← TRMNL Private-Plugin webhook payload + client +│ ├── dial.ts ← bake-dial geometry: angles, coil radii, night wedge, hit areas (pure) │ ├── state.svelte.ts ← form state as a $state class (window re-pick, startAt/readyBy floors) -│ ├── warningSlots.ts ← which card each schedule warning is rendered in +│ ├── warningSlots.ts ← which region each schedule warning is rendered in │ ├── mode.svelte.ts / storedMode.ts ← beginner/expert view mode (+ localStorage) │ ├── verbosity.svelte.ts / storedVerbosity.ts ← schedule short/detailed switch (+ localStorage) │ ├── storedRecipes.ts ← last-recipe restore + named recipe book (localStorage) @@ -80,9 +93,9 @@ src/ ├── routes/ │ ├── +layout.svelte ← global styles, language bootstrap │ ├── +layout.ts ← prerender + ssr=false (fully client-side) -│ ├── +page.svelte ← the entire calculator UI +│ ├── +page.svelte ← the whole app: masthead, settings rail, dial + readout, plan, weights │ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog) -├── app.css ← Tailwind v4 entrypoint + @theme palette +├── app.css ← Tailwind v4 entrypoint, --kt-* theme roles, component classes └── app.html ← shell e2e/ ← Playwright browser tests (the parts vitest cannot reach) diff --git a/docs/redesign/quadrante/desktop-dark.png b/docs/redesign/quadrante/desktop-dark.png new file mode 100644 index 00000000..192d146d Binary files /dev/null and b/docs/redesign/quadrante/desktop-dark.png differ diff --git a/docs/redesign/quadrante/desktop-light.png b/docs/redesign/quadrante/desktop-light.png new file mode 100644 index 00000000..100184ef Binary files /dev/null and b/docs/redesign/quadrante/desktop-light.png differ diff --git a/docs/redesign/quadrante/dial-72h-biga-poolish.png b/docs/redesign/quadrante/dial-72h-biga-poolish.png new file mode 100644 index 00000000..5ef2bc2b Binary files /dev/null and b/docs/redesign/quadrante/dial-72h-biga-poolish.png differ diff --git a/docs/redesign/quadrante/dial-room-mode.png b/docs/redesign/quadrante/dial-room-mode.png new file mode 100644 index 00000000..f13602d0 Binary files /dev/null and b/docs/redesign/quadrante/dial-room-mode.png differ diff --git a/docs/redesign/quadrante/phone-light.png b/docs/redesign/quadrante/phone-light.png new file mode 100644 index 00000000..b6ba0a95 Binary files /dev/null and b/docs/redesign/quadrante/phone-light.png differ diff --git a/e2e/dial.spec.ts b/e2e/dial.spec.ts new file mode 100644 index 00000000..1a46079c --- /dev/null +++ b/e2e/dial.spec.ts @@ -0,0 +1,228 @@ +import { expect, test } from '@playwright/test'; +import { card, chosenWindow, dragTo, openRecipe, slider } from './helpers'; + +// The dial is the app's primary control now, and everything about it lives in +// a component: the geometry module underneath is unit-tested to 100 %, but +// whether the drawing and the plan list agree, whether the coil can be worked +// without a mouse, and whether a 390 px face still fits the phone are all +// things only a browser can answer. + +// A two-day cold plan: a coil and a bit, with a fridge leg long enough that +// its arc dominates the face. +const COLD = + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z'; +// Three coils, two pre-ferments in parallel — the densest thing the dial draws. +const LONG_PREFERMENT = + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=310&p=b30_p20&r=2026-09-04T17%3A00%3A00.000Z&sa=2026-09-01T17%3A00%3A00.000Z'; + +const dial = (page: import('@playwright/test').Page) => page.locator('section.instrument svg'); +const options = (page: import('@playwright/test').Page) => dial(page).getByRole('option'); +const readout = (page: import('@playwright/test').Page) => page.locator('.readout'); + +/** Click a step's arc on the coil, at a point that is really on the stroke. */ +async function clickArc(page: import('@playwright/test').Page, step: string) { + // The coordinates below are viewport-relative, and selecting a step in the + // plan list first can scroll the dial off the top of the screen — a click + // at a negative y lands nowhere and reports nothing. + await dial(page).scrollIntoViewIfNeeded(); + const at = await dial(page) + .locator(`[data-step="${step}"] path`) + .first() + .evaluate((el: SVGPathElement) => { + const p = el.getPointAtLength(el.getTotalLength() / 2); + const box = el.ownerSVGElement!.getBoundingClientRect(); + // The face is drawn in a 100-unit viewBox scaled to the box. + return { x: box.left + (p.x / 100) * box.width, y: box.top + (p.y / 100) * box.height }; + }); + await page.mouse.click(at.x, at.y); +} + +test('the dial and the plan list are two views of one selection', async ({ page }) => { + // They read the same stepCopy functions, so they cannot disagree about what + // a step SAYS — but they each keep their own idea of which step is showing + // unless the selection is lifted out of both, which is what this pins. + await openRecipe(page, COLD); + + await card(page, 'Schedule').getByRole('button', { name: 'Divide & ball' }).click(); + await expect(readout(page).getByRole('heading')).toHaveText('Divide & ball'); + await expect(options(page).and(page.locator('[aria-selected="true"]'))).toHaveAttribute( + 'data-step', + 'divide-' + ); + + // ...and back the other way: picking an arc moves the list's marker too. + // Clicked where the arc is actually drawn, not at the centre of its + // bounding box — a 180° arc's box centre is empty face. + await clickArc(page, 'final-proof-'); + await expect(readout(page).getByRole('heading')).toHaveText('Final proof'); + await expect(card(page, 'Schedule').getByRole('button', { name: 'Final proof' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); +}); + +test('the whole coil is walkable from the keyboard', async ({ page }) => { + // The dial is one tab stop with a roving selection, not nine — and the + // arrow keys have to move the readout, or the drawing is mouse-only. + await openRecipe(page, COLD); + + await options(page).and(page.locator('[tabindex="0"]')).focus(); + const first = await readout(page).getByRole('heading').innerText(); + await page.keyboard.press('ArrowRight'); + await expect(readout(page).getByRole('heading')).not.toHaveText(first); + + await page.keyboard.press('End'); + await expect(readout(page).getByRole('heading')).toHaveText('Shape & bake'); + await page.keyboard.press('Home'); + await expect(readout(page).getByRole('heading')).toHaveText('Weigh & prep'); +}); + +test('the window handle moves between the same stops as the slider', async ({ page }) => { + // Two controls for one value: the coil's tail and the rail in the form. A + // handle that landed anywhere else would hand back a window the slider + // could never express again. + await openRecipe(page, COLD); + const before = await chosenWindow(page); + + const handle = dial(page).getByRole('slider'); + await handle.focus(); + await handle.press('ArrowLeft'); + + await expect.poll(() => chosenWindow(page)).not.toBe(before); + // The rail followed, rather than the two drifting apart. + await expect + .poll(async () => Number(await slider(page).inputValue())) + .toBe(Number(await handle.getAttribute('aria-valuenow'))); + await expect(handle).toHaveAttribute('aria-valuetext', await chosenWindow(page)); +}); + +test('dragging the handle past the bake deadline is refused, out loud', async ({ page }) => { + // Same rule the rail enforces, said in the same words: a control that + // springs back without a word reads as broken. + await openRecipe( + page, + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T19%3A00%3A00.000Z' + ); + + const handle = dial(page).getByRole('slider'); + await handle.focus(); + // Turning the tail back against the clock is what lengthens the window. + for (let i = 0; i < 12; i++) await handle.press('ArrowRight'); + + const alert = page.locator('section.instrument [role="alert"]'); + await expect(alert).toBeVisible(); + await expect(alert).toContainText('before now'); +}); + +test('a leg that ends on another day says which day', async ({ page }) => { + // The readout printed "01:00 PM – 02:45 PM" for a 25 h fridge leg, which + // reads as an hour and three quarters. Only the rendered pair shows it. + await openRecipe(page, COLD); + + await card(page, 'Schedule').getByRole('button', { name: 'Bulk ferment (fridge)' }).click(); + const times = readout(page).locator('p').nth(1); + await expect(times).toContainText('Sep'); +}); + +test('the dial opens on the step that is running', async ({ page }) => { + // "What now?" is the question the app exists to answer, so the readout must + // not start on step one of a plan that began yesterday. + await openRecipe(page, COLD); + // NOW is 2026-09-01T09:00Z and this plan opens on 2026-09-04, so nothing is + // running yet: the first step is the right answer here. + await expect(readout(page).getByRole('heading')).toHaveText('Weigh & prep'); + + await openRecipe( + page, + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T13%3A00%3A00.000Z&sa=2026-09-01T05%3A00%3A00.000Z' + ); + // This one started four hours before NOW, so a middle step is current. + await expect(readout(page).getByRole('heading')).not.toHaveText('Weigh & prep'); +}); + +test.describe('phone', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('the face fits the phone and the page never scrolls sideways', async ({ page }) => { + // A coil is the one element on this page that wants to be square and as + // wide as it can get, which is exactly how a page starts overflowing. + await openRecipe(page, LONG_PREFERMENT); + + const box = await dial(page).boundingBox(); + expect(box!.width).toBeLessThanOrEqual(390); + // Square, so the geometry the module computes is the geometry drawn. + expect(Math.abs(box!.width - box!.height)).toBeLessThan(2); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + 390 + ); + }); + + test('the plan comes before the weights, and the settings are past both', async ({ page }) => { + // The schedule is what the app is for; on a phone it used to sit behind + // the form AND the ingredients. The dial answers first now, and the form + // is how you adjust the answer rather than how you reach it. + await openRecipe(page, COLD); + + const stage = (await page.locator('section.instrument').boundingBox())!; + const plan = (await card(page, 'Schedule').boundingBox())!; + const weights = (await card(page, 'Ingredients').boundingBox())!; + const settings = (await page.locator('.card', { has: slider(page) }).boundingBox())!; + + expect(stage.y).toBeLessThan(plan.y); + expect(plan.y).toBeLessThan(weights.y); + expect(weights.y).toBeLessThan(settings.y); + }); +}); + +test.describe('desktop', () => { + test.use({ viewport: { width: 1440, height: 1000 } }); + + test('the settings are a rail beside the instrument, not a column ahead of it', async ({ + page + }) => { + await openRecipe(page, COLD); + + const settings = (await page.locator('.card', { has: slider(page) }).boundingBox())!; + const stage = (await page.locator('section.instrument').boundingBox())!; + const readoutBox = (await readout(page).boundingBox())!; + + // Rail on the left, instrument to its right, both starting at the top. + expect(settings.x).toBeLessThan(stage.x); + expect(Math.abs(settings.y - stage.y)).toBeLessThan(2); + // The step detail sits beside the dial at this width, not under it. + expect(readoutBox.x).toBeGreaterThan(stage.x + stage.width - 2); + }); +}); + +test('the drag that changes the window is not the only way to change it', async ({ page }) => { + // Everything the coil can do has a plain field behind it. The rail is that + // field, and moving it has to move the coil's tail. + await openRecipe(page, COLD); + + const tailBefore = await dial(page).getByRole('slider').getAttribute('aria-valuetext'); + await dragTo(page, 1); + await expect(dial(page).getByRole('slider')).not.toHaveAttribute('aria-valuetext', tailBefore!); +}); + +test.describe('the settings rail', () => { + test.use({ viewport: { width: 1280, height: 700 } }); + + test('scrolls on its own, so a long form is not pinned out of reach', async ({ page }) => { + // The rail is sticky, and the expert form is taller than any laptop + // screen. A sticky box taller than the viewport sticks immediately and + // then scrolls with the page, so everything below its first screenful + // becomes unreachable — the fridge-temperature field simply could not be + // clicked. It gets its own scroll region instead. + await openRecipe(page, COLD); + + const rail = page.locator('.card', { has: slider(page) }); + const reachable = await rail.evaluate( + (el) => el.scrollHeight <= el.clientHeight + 1 || getComputedStyle(el).overflowY === 'auto' + ); + expect(reachable).toBe(true); + + const fridge = page.locator('form label', { hasText: 'Fridge temperature' }).locator('input'); + await fridge.fill('6'); + await expect(fridge).toHaveValue('6'); + }); +}); diff --git a/e2e/focus-dismissal.spec.ts b/e2e/focus-dismissal.spec.ts index e15190e8..0499dc26 100644 --- a/e2e/focus-dismissal.spec.ts +++ b/e2e/focus-dismissal.spec.ts @@ -35,7 +35,11 @@ test('links and disclosure triggers get the same focus ring as the rest', async test('the fit-score panel closes on Escape and on an outside click', async ({ page }) => { await openRecipe(page, RECIPE); - const details = card(page, 'Schedule').locator('details').filter({ hasText: 'fit' }); + // Filtered on the SUMMARY's text: the "Get nerdy" panel's body happens to + // contain the word too, and `hasText` reads a details' whole content. + const details = page + .locator('main details') + .filter({ has: page.locator('summary', { hasText: 'fit' }) }); const trigger = details.locator('summary'); await trigger.click(); diff --git a/e2e/headings.spec.ts b/e2e/headings.spec.ts index d8d7d874..e5ec5b3c 100644 --- a/e2e/headings.spec.ts +++ b/e2e/headings.spec.ts @@ -25,11 +25,14 @@ test('every card is reachable by heading, and the steps sit under their day', as await openRecipe(page, RECIPE); const heads = await outline(page); - // The input card is named, even though its heading is visually hidden. + // Every region is named, including the two whose headings are visually + // hidden (the dial's stage, which needs no title on screen, and the form, + // whose own group legends already label it). expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([ - 'Your recipe', + 'Bake dial', 'Schedule', 'Ingredients', + 'Your recipe', 'My recipes', 'Community recipes', '50 Top Pizza recipes' diff --git a/e2e/layout.spec.ts b/e2e/layout.spec.ts index 83e6bc04..0dabc64b 100644 --- a/e2e/layout.spec.ts +++ b/e2e/layout.spec.ts @@ -1,49 +1,16 @@ import { expect, test } from '@playwright/test'; -import { card, formCard, openRecipe } from './helpers'; +import { openRecipe } from './helpers'; const RECIPE = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z'; -// The schedule is what the app is for, and on a phone it used to sit behind -// BOTH the form and the ingredients — measured at 2.2 screens down in beginner -// view and 3.7 in expert, against a stated design goal of reading well on a -// phone on the counter. All three cards carry explicit lg: col/row placement, -// so DOM order is free to put the schedule second; only a browser can show -// that the reorder actually reaches the phone and leaves the desktop alone. -test.describe('phone', () => { - test.use({ viewport: { width: 390, height: 844 } }); - - test('the schedule comes before the ingredients on a phone', async ({ page }) => { - await openRecipe(page, RECIPE); - - const schedule = await card(page, 'Schedule').boundingBox(); - const ingredients = await card(page, 'Ingredients').boundingBox(); - expect(schedule).not.toBeNull(); - expect(ingredients).not.toBeNull(); - - // Single column here, so "before" is purely vertical. - expect(schedule!.y).toBeLessThan(ingredients!.y); - }); -}); - -test.describe('desktop', () => { - test.use({ viewport: { width: 1440, height: 1000 } }); - - test('the schedule still shares the top row with the form at lg+', async ({ page }) => { - await openRecipe(page, RECIPE); - - const form = await formCard(page).boundingBox(); - const schedule = await card(page, 'Schedule').boundingBox(); - const ingredients = await card(page, 'Ingredients').boundingBox(); - - // Right-hand column, top row — beside the form, not under it. - expect(schedule!.x).toBeGreaterThan(form!.x); - expect(Math.abs(schedule!.y - form!.y)).toBeLessThan(2); - // Ingredients stays in the left column, below the form. - expect(ingredients!.x).toBeCloseTo(form!.x, 0); - expect(ingredients!.y).toBeGreaterThan(form!.y + form!.height - 2); - }); -}); +// The two placement rules that used to live here — "the schedule comes before +// the ingredients on a phone" and "the schedule shares the top row with the +// form at lg+" — were about the form/schedule/ingredients grid that the dial +// replaced. They are not gone: e2e/dial.spec.ts pins the same two questions +// against the structure that exists now ("the plan comes before the weights, +// and the settings are past both", "the settings are a rail beside the +// instrument, not a column ahead of it"). // A