diff --git a/.github/test-baseline.json b/.github/test-baseline.json index 4f8f413a..35ff2ae8 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": 949, + "e2e": 114 } diff --git a/CLAUDE.md b/CLAUDE.md index 48ec71af..52dac93e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,8 @@ 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. +- **It gets a whole screen of its own** as the fourth question on the ask flow — it is the decision that changes everything — and lives in the recipe sheet's `When` group, at the end, because it spans the two times above it and rewrites `startAt` as you drag. Its benefit paragraph is **always shown**, like every other field's help text: the plan's short/detailed toggle is a reading preference for the schedule and must not reach into this control. +- **Its warnings are a prop, not a given.** `FermentWindowSlider` takes `warnings` (default off): on for the question screen, where the slider IS the page; off inside the sheet, because the plan behind the sheet already renders that family and two copies of one warning is worse than none. The range input carries `id="field-window"` so a plan chip can focus it. - `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. @@ -54,19 +55,19 @@ Source of truth: `DoughInputs` in `src/lib/dough/types.ts`. ### Beginner / expert view mode - `UiMode = 'beginner' | 'expert'` (`src/lib/storedMode.ts` pure helpers + `src/lib/mode.svelte.ts` runtime singleton). **UI-level only — never enters `DoughInputs` or the math.** -- Beginner shows startAt/readyBy/**flour**/the window slider/pizzaCount/mixingMethod plus a switch to expert; ball weight is fixed at the 280 g default and every other input keeps its default too. The flour preset select sits in `When`, above the window slider, in both views (which flour is in the cupboard is something every baker knows, and it sets the fermentation band the schedule paints); the raw `flourW` number field behind it stays expert-only, since the presets already carry it. **The view mode only shapes the form — it never affects the schedule.** -- **Field order is one list, not two** — expert simply reveals more of it, so the two views never disagree about what comes first. `When`: startAt (the floor, with its "Now" button) → readyBy (the anchor the whole app schedules back from) → **flour** (+ the expert-only W field) → the window slider. The flour is in `When` rather than `Recipe` because it is the other half of what makes a window ideal: it sets the band the rail paints and the stop the re-pick aims at, so it reads directly above the control it drives. The **visual** order runs earliest-moment-first; it does not mirror the data flow, which still hangs off `readyBy` — the re-pick fires on a bake-time edit, and the slider still moves `startAt`. `Recipe`: batch (pizzas, ball weight) → baker's percentages (hydration, salt, oil, sugar) → mixing method → what leavens it (yeast, starter/pre-ferments, autolyse) → how and where it proofs (cold ball proof, room and fridge temperature). +- Beginner shows startAt/readyBy/**flour**/the window slider/pizzaCount/mixingMethod plus a switch to expert; ball weight is fixed at the 280 g default and every other input keeps its default too. The five ask-flow questions are deliberately the beginner subset minus the two times, which the first question covers between them. The flour preset select sits in `When`, above the window slider, in both views (which flour is in the cupboard is something every baker knows, and it sets the fermentation band the schedule paints); the raw `flourW` number field behind it stays expert-only, since the presets already carry it. **The view mode only shapes the recipe sheet and which chips the plan shows — it never affects the schedule.** +- **Field order is one list, not two** — expert simply reveals more of it, so the two views never disagree about what comes first. The sheet groups it as `When` / `Batch` / `Dough` / what leavens it / where it proofs. `When`: startAt (the floor, with its "Now" button) → readyBy (the anchor the whole app schedules back from) → **flour** (+ the expert-only W field) → the window slider. The flour is in `When` rather than `Recipe` because it is the other half of what makes a window ideal: it sets the band the rail paints and the stop the re-pick aims at, so it reads directly above the control it drives. The **visual** order runs earliest-moment-first; it does not mirror the data flow, which still hangs off `readyBy` — the re-pick fires on a bake-time edit, and the slider still moves `startAt`. Then batch (pizzas, ball weight) → baker's percentages (hydration, salt, oil, sugar) → mixing method → what leavens it (yeast, starter/pre-ferments, autolyse) → how and where it proofs (cold ball proof, room and fridge temperature). ### Schedule verbosity (short / detailed) -- `ScheduleVerbosity = 'short' | 'descriptive'` (`src/lib/storedVerbosity.ts` pure helpers + `src/lib/verbosity.svelte.ts` runtime singleton), toggled by the pill switch in the schedule header. Descriptive shows a `steps._detail` explanation paragraph under every step (`stepDetail` in `stepCopy.ts`) and appends it to `.ics` events via `stepDetailText(..., { includeDetail: true })`; short hides both. Print and TRMNL never carry the detail copy. +- `ScheduleVerbosity = 'short' | 'descriptive'` (`src/lib/storedVerbosity.ts` pure helpers + `src/lib/verbosity.svelte.ts` runtime singleton), toggled by the pill switch in the plan's status row. Descriptive shows a `steps._detail` explanation paragraph under every step (`stepDetail` in `stepCopy.ts`) and appends it to `.ics` events via `stepDetailText(..., { includeDetail: true })`; short hides both. Print and TRMNL never carry the detail copy. - Device-level reading preference: **not in the share URL** and independent of the view mode. Default is descriptive; explicit toggles persist to `localStorage` `kneadtime:scheduleVerbosity`. - **Resolution order on mount**: URL `md` param (`md=b` = beginner; encode stamps it only for beginner) → any other query carrying known recipe keys = expert (`hasRecipeParams` in `urlState.ts`; stray `utm_*`/`fbclid`-only URLs don't count — they behave like a bare visit) → `localStorage` `kneadtime:mode` → **beginner** (the fresh-visit default that justified the v4 major bump). Only explicit toggles persist to localStorage — opening someone's beginner link never overwrites the local preference. ### Recipe memory - Recipe changes mirror the encoded share query to `localStorage` `kneadtime:lastRecipe` — but **only after a user edit** (issue #201): the recipe-only encoding is snapshotted at hydration and the save effect skips while it still matches, so merely _opening_ someone else's link never overwrites the memory. A fresh visit with no recipe URL params restores it — **recipe parameters only** (the stale `startAt`/`readyBy` are dropped so the dates keep today's defaults). Any recipe link beats the memory; `hasRecipeParams` decides what counts as one. The memory decodes through **`decodeStoredRecipe`**, not `decodeInputs`: it drops `flourW` when the stored query carries no `fw` rather than applying the v<6 → null gate, because the gate exists to protect _other people's_ old links — applying it here would pin every returning user to "no flour stated" forever. -- Named recipe book in `kneadtime:recipes` (`src/lib/storedRecipes.ts`, pure + tested): "Save recipe" in the schedule menu (overwrite by name, newest first); a collapsed "My recipes" section above Community lists them with open (full-reload link like community rows) and delete. Device-local only. +- Named recipe book in `kneadtime:recipes` (`src/lib/storedRecipes.ts`, pure + tested): "Save recipe" in the plan's actions menu (overwrite by name, newest first); a collapsed "My recipes" section at the top of the library view lists them with open (full-reload link like community rows) and delete. Device-local only. ### "Round numbers" action @@ -75,14 +76,15 @@ 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.** Rendered as a **day-grouped vertical timeline** (`ScheduleTable.svelte`) in three columns: the time hangs in its own left gutter and reads first, because a schedule is a list of moments before it is a list of jobs; then the rail, then the step. Consecutive steps fall under one sentence-case date header, the rail threads the nodes (filled = baker-action step, hollow = waiting phase, dashed and dough-coloured 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. ### Warnings -- **Warnings render next to what causes them, not in one pile.** `src/lib/warningSlots.ts` (pure + tested) maps every `ScheduleWarning` to a slot and `Warnings.svelte` takes a `place` prop that filters on it: `window` (`too-short`, `night-step`, `flour-window-{long,short}`) renders inside the slider card, `temperature` (`too-cold`, `too-warm` — both fire on `roomTempC` alone) under the two temperature fields, `ingredients` (`yeast-tiny`, `yeast-large` — their copy is about the number you weigh) under the ingredients table. The mapping is a `Record`, so a **new warning fails to compile until it is placed** — there is deliberately no catch-all slot. `computeSchedule` is untouched; each mount renders `form.schedule.warnings` filtered. +- **Warnings render next to what causes them, not in one pile.** `src/lib/warningSlots.ts` (pure + tested) maps every `ScheduleWarning` to a slot and `Warnings.svelte` takes a `place` prop that filters on it: `window` (`too-short`, `night-step`, `flour-window-{long,short}`), `temperature` (`too-cold`, `too-warm` — both fire on `roomTempC` alone) and `ingredients` (`yeast-tiny`, `yeast-large` — their copy is about the number you weigh). + - **The plan mounts all three**: `window` and `temperature` under the summary chips, above the schedule, and `ingredients` beside the weights. The chips are where those values are _displayed_, and the fields that set them live behind a button — a warning inside a closed sheet is a warning nobody sees. The sheet itself therefore mounts none, which is also what keeps the count of live regions at exactly three. The ask flow's window question mounts `window` inside the slider (see the `warnings` prop above). The mapping is a `Record`, so a **new warning fails to compile until it is placed** — there is deliberately no catch-all slot. `computeSchedule` is untouched; each mount renders `form.schedule.warnings` filtered. - The yeast pair is in `ingredients` rather than next to the yeast field because that field is expert-only, while a beginner can reach both extremes through the window alone. ## Math @@ -97,7 +99,7 @@ Nudges ball weight (0.1 g) so flour lands on a multiple of 100 g — always, for - `PREFERMENT_REF_HOURS_{BIGA,POOLISH}` in `fermentation.ts` shifts every existing recipe's yeast % → **major app-version bump**. - With pre-ferments + fresh yeast: `ingredients.yeast = 0`; the `ingredients.preFerments` entries carry the full mass split by flour share. **No hardcoded pinch** — it must come from the equivalent-hours solve. - All calculation logic stays pure and framework-free in `src/lib/dough/`. Components only render. -- **The "Get nerdy" info section is a contract with the user.** Every calculation the app performs — everything in `src/lib/dough/` (fermentation model, water temperature, schedule budget split, mass balance, pre-ferment math, round-numbers snapping) plus the `quality.ts` star rating — must be represented there, and the copy must match the code, not the other way around. The code is authoritative: whenever a formula, constant, band or branch changes, update the info section (all five locales) **in the same PR**. The panel is **data**: `src/lib/infoSections.ts` lists the sections as message keys plus verbatim formulas, and `InputForm.svelte` renders that list. Two tests hold the contract in both directions — `infoSections.test.ts` fails if a key is rendered that no locale has, if an `info_*` message exists that nothing renders, or if a printed formula changes; `e2e/info-panel.spec.ts` fails if a section stops reaching the screen. Adding a calculation therefore means adding an entry here and copy in all five locales; neither half compiles away quietly. UI-status helpers (`scheduleStatus.ts`) and encoders (URL/ics/QR) are presentation, not math — they're exempt. Everything else in `src/lib/dough/` is represented, **including `defaults.ts` and `inputBounds.ts`** (`info_defaults_*`): the starting recipe and the bands every input is silently clamped into, from the form and from a hand-edited share link alike. Clamping changes a user's recipe without telling them, so it belongs in the contract even though it is not a formula. +- **The "Get nerdy" info section is a contract with the user.** Every calculation the app performs — everything in `src/lib/dough/` (fermentation model, water temperature, schedule budget split, mass balance, pre-ferment math, round-numbers snapping) plus the `quality.ts` star rating — must be represented there, and the copy must match the code, not the other way around. The code is authoritative: whenever a formula, constant, band or branch changes, update the info section (all five locales) **in the same PR**. The panel is **data**: `src/lib/infoSections.ts` lists the sections as message keys plus verbatim formulas, and `InputForm.svelte` renders that list at the foot of the recipe sheet (expert only). Two tests hold the contract in both directions — `infoSections.test.ts` fails if a key is rendered that no locale has, if an `info_*` message exists that nothing renders, or if a printed formula changes; `e2e/info-panel.spec.ts` fails if a section stops reaching the screen. Adding a calculation therefore means adding an entry here and copy in all five locales; neither half compiles away quietly. UI-status helpers (`scheduleStatus.ts`) and encoders (URL/ics/QR) are presentation, not math — they're exempt. Everything else in `src/lib/dough/` is represented, **including `defaults.ts` and `inputBounds.ts`** (`info_defaults_*`): the starting recipe and the bands every input is silently clamped into, from the form and from a hand-edited share link alike. Clamping changes a user's recipe without telling them, so it belongs in the contract even though it is not a formula. - `ComputedSchedule` also exposes `naturalColdBulkMin`, `desiredColdBulkMin`, `naturalPreferments` (one `{type, naturalHours}` per pre-ferment, matched to steps via `preFermentType`) — pre-shift / pre-clamp values for the recipe-fit metric below. Read-only signals; nothing in the math branches on them. Don't drop them without also updating `quality.ts`. ### Recipe fit score @@ -113,7 +115,7 @@ Per-hour rates and per-factor deduction caps are named constants at the top of ` - Source: `src/lib/community/community.md`, rows `| Name | Date | Recipe-URL |` (date `YYYY-MM-DD`, URL = full Share output). Name is plain text; a leading `@` matching GitHub's username rules links to that profile. - Imported via Vite `?raw`, parsed in `src/lib/community/community.ts`. **Bad rows are dropped silently** so one bad row can't break the page. -- `Community.svelte` renders cards on narrow widths (with an `Open` button up front + secondary fields under a `
`) and the full table at `md+`. Link uses `resolve('/')` and `rel="external"` so a full reload re-runs `onMount` → `decodeInputs`. Hidden in print. +- `Community.svelte` renders cards on narrow widths (with an `Open` button up front + secondary fields under a `
`) and the full table at `md+`, inside the library view. Link uses `resolve('/')` and `rel="external"` so a full reload re-runs `onMount` → `decodeInputs`. Not on the print route. ## 50 Top Pizza recipes @@ -147,6 +149,7 @@ The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a Private P - **Current version is `v=6`** (adds `fw` = flourW). Flour strength defaults to Caputo Pizzeria (W 265), so `fw` is **omitted at the default** and stamped `fw=0` for "no flour stated". Its missing value is **version-gated** like `al`: `v ≥ 6` → the default flour; `v < 6` (and missing-`v` legacy) → **null**, so an old link never retroactively claims a flour it was never made with. `decode()` now reads `VERSION_KEY` for exactly two defaults (`al`, `fw`); every other key stays add-only. `fw` is advisory — it changes no ingredient mass and no step time — so a pre-v6 link decoding to null loses nothing but the tolerance band. - **`v=5`** adds `al` = autolyse; **the first version whose decode was version-gated**. Autolyse defaults on, so `al` is **omitted when on** and stamped `al=0` only for the expert opt-out. Because autolyse changes the schedule (and yeast %) of a no-pre-ferment recipe, a **missing `al` is read differently by version**: `v ≥ 5` → on (the new default, simply omitted); `v < 5` (and missing-`v` legacy) → **off**, so every pre-v5 share-link, community row and pizzeria row reproduces its original no-autolyse recipe. This is the case the "branch on `v` when a version breaks the add-only contract" rule anticipated — `decode()` reads `VERSION_KEY` for the autolyse default only; all other keys stay add-only. - **`v=4`** adds `mm` = mixingMethod, omitted for spiral — v4.0 wrote `m` for "machine", still decoded as spiral; `md=b` view mode, stamped only for beginner; `y` gains `i`/`a` for the dry yeasts; `pt` = preFermentTempC, omitted when following the room; `bp=c` = cold ball proof, omitted for the classic shape; and extends `p` to an underscore-separated pre-ferment list, e.g. `p=b30_p20` — the old single token parses as a 1-element list, `,` is accepted in hand-written links, decode clamps each share to [5, 80] with Σ ≤ 80 and canonicalises biga-first. `v=3` added `o`/`sg`; `v=2` added `ft`; older links decode those fields as `undefined` and the form defaults fill in — every pre-v=4 share-link reproduces its original recipe, yeast % included. +- **The query is the recipe; the fragment is the place.** Which view is on screen (`#ask/`, `#plan`, `#library`) lives in the URL fragment and adds **no** query key — see the Design section. A share link is therefore byte-for-byte what it always was, `hasRecipeParams` needs no exception list, and the v=6 schema is untouched by the v6.11 restructure. - **Schema-change protocol**: bump `CURRENT_VERSION`, keep `decode()` understanding every published key shape. **Never break an old key** — old bookmarks and community rows must keep resolving. - **The app's major version tracks `CURRENT_VERSION`.** A schema bump is always a major bump, so `v=6` links are exactly the ones written by a 6.x app — see the App version section. @@ -169,15 +172,52 @@ The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a Private P - All user-facing strings live in `src/lib/i18n/messages.ts`. **No hardcoded copy in components.** Parity test fails loudly on missing keys — add to all five in the same change. - **User-selected locale is persisted to `localStorage`** (`kneadtime:locale`) and preferred over `detectLocale(navigator.languages)` on mount — `src/lib/i18n/storedLocale.ts`. A full reload (e.g. a community Open link with `rel="external"`) keeps the user's chosen language. `/print/*` owns its own locale via the URL path and opts out of both auto-detect and the stored value. A load-time shim migrates any legacy `doughcalc:locale` value once and clears it. -## Design +## Design — "Servizio" -Responsive, playful, Italian-warm (tomato / basil / dough). Must read well on a phone on the counter at narrow widths. +The app is a service, not a form: it asks a short sequence of questions and hands back a plan you live inside for two days. That shape **is** the design, so most of what follows is about where things are rather than what colour they are. It must still read on a phone on the counter at 390 px. -- **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. +### The three views + +`src/lib/view.ts` (pure + tested) owns the whole of it: `AppView = 'ask' | 'plan' | 'library'` and `ASK_STEPS = when → pizzas → flour → window → method`. `+page.svelte` is the only file that knows which one is on screen — exactly one ever is, so each view is `.view` (`min-h-[100dvh]`) and stamps `data-view` for the browser suite to address it by. + +- **The place lives in the URL fragment, never in the query.** `#ask/`, `#plan`, `#library`. The query stays the recipe and nothing else — no `view` key, no schema bump, and `hasRecipeParams` never has to learn to ignore a piece of interface state. A fragment is linkable, survives a reload and gives back/forward for free. `go()` pushes; the recipe effect replaces — and it must keep appending `viewHash(...)`, or the next recipe edit swallows the view. +- **`initialLocation()` is the rule that matters.** An explicit fragment wins; then anyone arriving with a recipe — a share link, or the recipe this device was last working on — goes **straight to the plan**. Walking a returning baker through five questions again is the failure this restructure exists to prevent; pinned in `e2e/views.spec.ts`. +- **Ask** (`AskFlow.svelte`): one decision per screen, each answerable in one gesture and each skippable — "Skip to the plan" from question one, plus a progress rail that jumps to any of the five. `PlanGlance.svelte` sits beside the question showing the consequence forming (start moment, window, flour, mode, step count). Without it, a sequence of questions is a survey. +- **Plan** (`PlanView.svelte`): the destination, and the calm one — the questions are loud, the plan is read at 07:00 with flour on your hands. **Nothing on it is an input.** The bake moment is the largest thing on the page and is itself the control that changes it; every other editable value is a `.chip` (dotted underline) that opens the sheet **with that field focused**, via `AdjustPanel.open('field-…')` against the ids in `InputForm.svelte`. +- **Adjust** (`AdjustPanel.svelte`): a native modal `` — focus trap, inert background and Escape all for free — laid out as a bottom sheet under `lg` and a right-hand drawer above it, so on a desktop the plan stays visible and live behind it. It carries **every** field in `DoughInputs` on one dense surface: that is the expert's door, one press to twenty numbers with no wizard in the way. Its header repeats the three live facts, because on a phone the schedule is behind the sheet. +- **Library** (`LibraryView.svelte`): My recipes, Community and 50 Top Pizza together, one press from the first question and one from the plan. They are entry points to a recipe, not an appendix — they used to sit at the bottom of a 5600 px column. Each still ships as a collapsed disclosure: three long tables stacked would be a wall, and closed they read as an index of three. + +### Palette + +Warmth is earned, not wallpaper. The ground is **limewashed plaster with a green cast** (`#e9ece1` light, `#141711` dark), not cookbook cream. The dough colour is spent only where something is actually fermenting — the dashed rail, the room-ferment badge, the `.notice-info` box — and **tomato means acting or danger, nothing else**: not section headings, not totals, not selected table rows. + +The five surface roles live as `--kt-*` variables on `:root` / `.dark` inside `@layer base` and are re-exported through `@theme` as `--color-{ground,plane,ink,ink-soft,line,line-soft}`. **Never reach past them for a raw `stone-*` or `bg-white`** — half the tree doing exactly that is why the old palette resolved to "cream plus one red" whatever the tokens said. The three hue ramps (dough / tomato / basil) are unchanged, and `--color-tomato-500` stays `#c8401a` because three e2e specs pin the focus ring to that literal. + +### Type + +**One family, three jobs.** A system serif paired with a system sans is the pairing every framework hands you for free; the roles are separated by size, weight and tracking instead — `.question` at 300 weight and −0.035em, `.data` (tabular) for anything the reader treats as a number, `.field-label` and `.section-head` at the small end. No webfont: the app is a static, offline-capable build that makes no third-party requests, and a Google Fonts link would be its only one. Sentence case throughout — the schedule's day label used to be a tracked-out uppercase strip, which is chrome pretending to be structure. + +### Structure + +**There are no cards.** `.card` is gone. A region is separated from its neighbour by space and, where the eye needs a seam, by one `.rule` hairline. `.plane` — the only shape with a radius and a shadow — is for things that genuinely float: the adjust sheet, the actions menu, the two dialogs, the ask flow's glance. **Radius reads as elevation**, so don't put `.plane` on something sitting in the flow of the page. + +**A repeated Tailwind class list gets a name in `app.css`, not a copy.** The `@layer components` block holds every shape more than one place needs: `.view`/`.view-pad`/`.rule`/`.plane`; `.question`/`.lede`/`.data`/`.field-label`/`.section-head`/`.text-accent`/`.text-time`; `.chip`; `.btn-tomato{,-sm}`/`.btn-ghost`/`.btn-quiet`; `.tile`; `.stepper`; `.dot{,-on}`; `.wordmark`; `.menu-item`; `.notice` + `.notice-{danger,info}`; `.pill-group`/`.pill`/`.pill-{on,off}`; `.input`/`.input-lg`; `.link-quiet`/`.link-action`; `.dialog-panel`; `.window-card`; `.row-divider`. One-off styling stays inline; the rule is about the second occurrence, not the first. + +`.dot` draws its 10 px mark with `::after` so the button around it can stay 24 px square. A 10 px target passes WCAG 2.5.8 only through the spacing exception, which is a property of the current layout rather than of the control. -- **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. + +### Motion + +One orchestrated moment: moving between two questions slides the whole question block (`.kt-enter`, with `--kt-dir` set to +1 forward and −1 back by whichever control moved us), so the flow reads as one surface travelling rather than five pages loading. It is the **only** non-user-triggered animation besides the current step's node pulse, and both go silent under `prefers-reduced-motion` — pinned in `e2e/ask-flow.spec.ts`. Nothing on the plan animates: it has to be readable the instant it paints. + +### Dismissal + +- **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. The adjust sheet needs none of it: a native modal `` already closes on Escape, and its backdrop click arrives on the dialog element itself. + +### e2e specs address the app through these names + +A class here is closer to an API than a style, and renaming one means grepping `e2e/` too. `e2e/helpers.ts` reaches the app through `main [data-view]` (`view`/`currentView`), `section, aside` filtered by heading (`region`), `dialog[open]` containing a form (`sheet`, `openAdjust`), `.window-card` + its first `.data` (`chosenWindow`), `#field-window` (`slider`), the marker paths `M5 0` / `M5 6` (`arrowCentreX`), and `summary[aria-label^="Recipe fit"]`. `card()` and `formCard()` are gone with `.card`. ## Git workflow @@ -205,7 +245,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, **which view a visitor lands on and that it survives a reload and the back button** (`views.spec.ts`), and one regression test per browser-only bug we have already shipped a fix for. ## Conventions diff --git a/README.md b/README.md index 62b5328d..6362af54 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ 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 v6.11 — **Servizio**: the app stopped being a form. It opens with one question set large — _when are you eating?_ — and walks through four more, each answerable in a single gesture, with the plan forming beside you. The answer is a **plan you return to**: a full-screen schedule you open at 07:00 with flour on your hands, where every value is underlined and opens for editing in place. Anyone arriving with a share link or a saved recipe lands **straight on the plan** and is never asked the questions again; anyone who already knows all twelve numbers opens the **Adjust** sheet and fills them in at once. My recipes, Community and 50 Top Pizza moved out of the foot of the page into a **Recipes** view of their own, one press from anywhere. Which view you are on lives in the URL fragment, so it is linkable, survives a reload and walks with the back button — the recipe query is untouched and every old share-link still resolves, gram for gram. + 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. @@ -65,13 +67,15 @@ src/ │ │ ├── quality.ts recipe-fit score (0–100 → 0–5 stars) │ │ ├── types.ts shared types │ │ └── *.test.ts colocated tests -│ ├── components/ ← Svelte 5 UI (uses runes) +│ ├── components/ ← Svelte 5 UI (uses runes); AskFlow / PlanView / LibraryView are the three views, +│ │ AdjustPanel is the sheet that holds every input │ ├── 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 +│ ├── community/ ← community.md (data) + parser, rendered as a table in the Recipes view │ ├── pizzerias/ ← pizzerias.md (50 Top Pizza recipes) + parser, rendered below the community table │ ├── trmnl/ ← TRMNL Private-Plugin webhook payload + client │ ├── 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 +│ ├── view.ts ← the three views (ask / plan / library) and where a visitor lands +│ ├── warningSlots.ts ← which surface each schedule warning is rendered on │ ├── 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,7 +84,7 @@ 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 router: mounts exactly one of the three views │ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog) ├── app.css ← Tailwind v4 entrypoint + @theme palette └── app.html ← shell @@ -103,19 +107,19 @@ playwright.config.ts ← Playwright (builds and serves the real static outp ### npm scripts -| Command | What it does | -| ----------------------- | ---------------------------------------------------------- | -| `npm run dev` | Vite dev server on port 5173 with HMR | -| `npm test` | Run vitest once (`npm run test:watch` for watch mode) | -| `npm run test:coverage` | Run vitest with v8 coverage → `./coverage/` | -| `npm run test:e2e` | Browser tests (Playwright, Chromium) against a real build | -| `npm run test:e2e:ui` | The same suite in Playwright's debugger | -| `npm run test:baseline` | Refuse a change that removes tests or relaxes coverage | -| `npm run check` | `svelte-kit sync` + `svelte-check` (type & template check) | -| `npm run lint` | Prettier check + ESLint | -| `npm run format` | Prettier write | -| `npm run build` | Production build → `./build/` (static site) | -| `npm run preview` | Serve the built site locally | +| Command | What it does | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `npm run dev` | Vite dev server on port 5173 with HMR | +| `npm test` | Run vitest once (`npm run test:watch` for watch mode) | +| `npm run test:coverage` | Run vitest with v8 coverage → `./coverage/` | +| `npm run test:e2e` | Browser tests (Playwright, Chromium) against a real build; `E2E_PORT` moves the preview server off 4173 | +| `npm run test:e2e:ui` | The same suite in Playwright's debugger | +| `npm run test:baseline` | Refuse a change that removes tests or relaxes coverage | +| `npm run check` | `svelte-kit sync` + `svelte-check` (type & template check) | +| `npm run lint` | Prettier check + ESLint | +| `npm run format` | Prettier write | +| `npm run build` | Production build → `./build/` (static site) | +| `npm run preview` | Serve the built site locally | ### Pre-commit hooks @@ -125,7 +129,7 @@ Husky + lint-staged are configured (`.husky/pre-commit`). The hook runs lint-sta 1. **Math/logic first.** Add or extend a module in `src/lib/dough/`. Keep it pure (no Svelte imports). Add a `*.test.ts` next to it. Run `npm test` until green. 2. **Wire to state.** If new inputs are needed, extend `FormState` in `src/lib/state.svelte.ts`, then `SerializableInputs` in `src/lib/dough/urlState.ts` (encode + decode + round-trip test). -3. **UI.** Add fields to `src/lib/components/InputForm.svelte`; render results in the existing components or add a new one. Use Svelte 5 runes (`$state`, `$derived`, `$effect`). +3. **UI.** Add fields to `src/lib/components/InputForm.svelte` — the dense form inside the Adjust sheet, which is where every `DoughInputs` field lives; render results in `PlanView.svelte` or its children. A field worth putting on the plan gets an `id="field-…"` so a chip can open the sheet focused on it. Use Svelte 5 runes (`$state`, `$derived`, `$effect`). 4. **i18n.** Every new user-facing string goes into `src/lib/i18n/messages.ts` for all five locales. The parity test will fail loudly if a key is missing. 5. **Verify.** `npm run test:coverage && npm run check && npm run build`. The CI workflow runs `npm run lint`, `npm run check`, `npm run test:coverage` (the 100 % coverage gate — plain `npm test` skips it), and `npm run build`. A second CI job runs `npm run test:e2e`: Playwright drives a real build for the parts that live in components and so cannot be reached by vitest. First run locally needs `npx playwright install chromium`. @@ -137,7 +141,7 @@ If you touch the printed layout, check it in your browser's print preview — do ### TRMNL e-ink view -The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a **Private Plugin webhook**, straight from the user's browser: the **Send to TRMNL** action in the schedule menu POSTs pre-formatted `merge_variables` to `https://trmnl.com/api/custom_plugins/`, and the device renders them through a Liquid template at its own refresh cadence. The template picks the current step at render time with Liquid date math, so one POST per recipe change keeps the Now/Next/Done highlight moving all day. +The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a **Private Plugin webhook**, straight from the user's browser: the **Send to TRMNL** action in the plan's actions menu POSTs pre-formatted `merge_variables` to `https://trmnl.com/api/custom_plugins/`, and the device renders them through a Liquid template at its own refresh cadence. The template picks the current step at render time with Liquid date math, so one POST per recipe change keeps the Now/Next/Done highlight moving all day. Implementation lives in `src/lib/trmnl/` (payload builder + webhook client); the setup walkthrough and the Liquid template are in `docs/trmnl-setup.md`. The payload uses 1–2 character keys to stay under the free tier's 2 KB cap in every locale — a regression test measures the wire size, so adding fields without measuring fails CI. There is **no `/trmnl` route** any more: the earlier screenshot-plugin approach failed because TRMNL's renderer doesn't reliably execute JS, so every capture showed build-time defaults. @@ -185,7 +189,7 @@ The `main` runs exist so Codecov gets a main-branch baseline (the badge at the t ## Contributing a community recipe -The bottom of the page lists recipes other bakers have shared. Each entry is a +The **Recipes** view lists recipes other bakers have shared. Each entry is a single row in [`src/lib/community/community.md`](src/lib/community/community.md): ```md diff --git a/docs/redesign/servizio/adjust-desktop.png b/docs/redesign/servizio/adjust-desktop.png new file mode 100644 index 00000000..1feb0890 Binary files /dev/null and b/docs/redesign/servizio/adjust-desktop.png differ diff --git a/docs/redesign/servizio/adjust-phone.png b/docs/redesign/servizio/adjust-phone.png new file mode 100644 index 00000000..02c050b8 Binary files /dev/null and b/docs/redesign/servizio/adjust-phone.png differ diff --git a/docs/redesign/servizio/ask-when-desktop.png b/docs/redesign/servizio/ask-when-desktop.png new file mode 100644 index 00000000..1c8f16e1 Binary files /dev/null and b/docs/redesign/servizio/ask-when-desktop.png differ diff --git a/docs/redesign/servizio/ask-when-phone.png b/docs/redesign/servizio/ask-when-phone.png new file mode 100644 index 00000000..9ff76058 Binary files /dev/null and b/docs/redesign/servizio/ask-when-phone.png differ diff --git a/docs/redesign/servizio/ask-window-desktop.png b/docs/redesign/servizio/ask-window-desktop.png new file mode 100644 index 00000000..24f7396c Binary files /dev/null and b/docs/redesign/servizio/ask-window-desktop.png differ diff --git a/docs/redesign/servizio/ask-window-phone-dark.png b/docs/redesign/servizio/ask-window-phone-dark.png new file mode 100644 index 00000000..83af2775 Binary files /dev/null and b/docs/redesign/servizio/ask-window-phone-dark.png differ diff --git a/docs/redesign/servizio/library-desktop.png b/docs/redesign/servizio/library-desktop.png new file mode 100644 index 00000000..9f2b6a94 Binary files /dev/null and b/docs/redesign/servizio/library-desktop.png differ diff --git a/docs/redesign/servizio/plan-biga-cold.png b/docs/redesign/servizio/plan-biga-cold.png new file mode 100644 index 00000000..4e8d04a0 Binary files /dev/null and b/docs/redesign/servizio/plan-biga-cold.png differ diff --git a/docs/redesign/servizio/plan-desktop-dark.png b/docs/redesign/servizio/plan-desktop-dark.png new file mode 100644 index 00000000..e3301b3a Binary files /dev/null and b/docs/redesign/servizio/plan-desktop-dark.png differ diff --git a/docs/redesign/servizio/plan-desktop.png b/docs/redesign/servizio/plan-desktop.png new file mode 100644 index 00000000..c1c5eb69 Binary files /dev/null and b/docs/redesign/servizio/plan-desktop.png differ diff --git a/docs/redesign/servizio/plan-phone-dark.png b/docs/redesign/servizio/plan-phone-dark.png new file mode 100644 index 00000000..56d1af6a Binary files /dev/null and b/docs/redesign/servizio/plan-phone-dark.png differ diff --git a/docs/redesign/servizio/plan-phone.png b/docs/redesign/servizio/plan-phone.png new file mode 100644 index 00000000..2ccc716b Binary files /dev/null and b/docs/redesign/servizio/plan-phone.png differ diff --git a/e2e/announcements.spec.ts b/e2e/announcements.spec.ts index b1c07e7d..18d1f6b5 100644 --- a/e2e/announcements.spec.ts +++ b/e2e/announcements.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { openRecipe, slider, windowCard } from './helpers'; +import { openAdjust, openRecipe, slider, windowCard } 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'; @@ -62,6 +62,7 @@ test('the TRMNL dialog is named by its own heading', async ({ page }) => { // aria-hidden decoration, so the slider announced a duration and nothing else. test('the slider is described by the words that judge the window', async ({ page }) => { await openRecipe(page, RECIPE); + await openAdjust(page); const ids = await slider(page).getAttribute('aria-describedby'); expect(ids).toBe('window-band window-benefit'); diff --git a/e2e/ask-flow.spec.ts b/e2e/ask-flow.spec.ts new file mode 100644 index 00000000..a33aea7e --- /dev/null +++ b/e2e/ask-flow.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { chosenWindow, currentView, dragTo, openQuestion, slider } from './helpers'; + +// The five questions. Each has to be answerable in one gesture and skippable by +// somebody who already knows what they want — a flow that costs a power user +// twelve taps is a worse form, not a better one. + +const RECIPE = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-06T17%3A00%3A00.000Z'; + +test('every question is one gesture, and the rail walks between them', async ({ page }) => { + await openQuestion(page, 'when', RECIPE); + + // Forward through all five, then back to the first, by the rail alone. + const rail = page.getByRole('navigation', { name: 'Questions' }).getByRole('button'); + await expect(rail).toHaveCount(5); + + for (const heading of [ + 'How many pizzas?', + 'Which flour is in your cupboard?', + 'How long should it ferment?', + 'How will you knead it?' + ]) { + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(heading); + } + // The last question hands over rather than offering a sixth. + await expect(page.getByRole('button', { name: 'Next', exact: true })).toHaveCount(0); + + await rail.first().click(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('When are you eating?'); + await expect(page.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); +}); + +test('the whole flow is skippable from the first question', async ({ page }) => { + // Speed for someone who knows what they want is the requirement a wizard + // usually fails. One press from question one to the finished plan. + await openQuestion(page, 'when', RECIPE); + + await page.getByRole('button', { name: 'Skip to the plan' }).click(); + expect(await currentView(page)).toBe('plan'); +}); + +test('the pizza stepper answers with a single tap in either direction', async ({ page }) => { + await openQuestion(page, 'pizzas', RECIPE); + + const count = page.getByLabel('Pizzas', { exact: true }); + await expect(count).toHaveValue('6'); + await page.getByRole('button', { name: 'One more' }).click(); + await expect(count).toHaveValue('7'); + await page.getByRole('button', { name: 'One fewer' }).click(); + await expect(count).toHaveValue('6'); +}); + +// The window question is the one that changes everything, so it gets a whole +// screen — and the slider on it is the same control, with the same clamping, +// as the one in the recipe sheet. +test('the window question drives the same slider as the sheet', async ({ page }) => { + await openQuestion(page, 'window', `${RECIPE}&sa=2026-09-05T09%3A00%3A00.000Z`); + + expect(await chosenWindow(page)).toBe('32 h'); + await dragTo(page, 0); + expect(await chosenWindow(page)).toBe('6 h'); + // ...and the answer is written into the shared recipe query, not held aside. + await expect.poll(() => new URL(page.url()).searchParams.get('sa')).toContain('2026-09-06'); + expect(await slider(page).getAttribute('aria-label')).toBe('Fermentation window'); +}); + +// Motion is part of the argument here — the question block slides so the flow +// reads as one surface travelling — but it is the only non-user-triggered +// animation in the app and it has to disappear entirely for anyone who asked +// for less of it. A CSS-only guard is easy to lose in a refactor. +test('the question surface does not animate when the reader asked for less motion', async ({ + page +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openQuestion(page, 'pizzas', RECIPE); + + const running = await page.locator('.kt-enter').evaluate((el) => el.getAnimations().length); + expect(running).toBe(0); +}); diff --git a/e2e/axe.spec.ts b/e2e/axe.spec.ts index 756f7241..868e8872 100644 --- a/e2e/axe.spec.ts +++ b/e2e/axe.spec.ts @@ -1,6 +1,6 @@ import AxeBuilder from '@axe-core/playwright'; -import { expect, test } from '@playwright/test'; -import { openRecipe } from './helpers'; +import { expect, test, type Page } from '@playwright/test'; +import { openAdjust, openQuestion, openRecipe } from './helpers'; // An automated sweep, not a substitute for the hand-written a11y specs beside // it: axe catches the mechanical rules (contrast, names, roles, structure) and @@ -24,39 +24,80 @@ function summarise(violations: Awaited>['viola .join('\n '); } +async function scan(page: Page) { + const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + expect(violations, `\n ${summarise(violations)}\n`).toEqual([]); +} + +// Content disclosures only, never the popovers. The actions menu and the +// fit-score panel are absolutely positioned and are MEANT to cover what is +// beneath them while open, so forcing them open alongside everything else made +// axe report the control under the menu as "partially obscured" — a true +// observation about an arrangement no user is ever in, since opening either +// popover is a deliberate act that dismisses on the next click. Their own +// contents are covered by the menu-keyboard and dialog specs instead. +async function openContentDisclosures(page: Page) { + await page.evaluate(() => + document.querySelectorAll('details').forEach((d) => { + const panel = d.querySelector(':scope > :not(summary)'); + const floats = panel !== null && getComputedStyle(panel).position === 'absolute'; + if (!floats) d.open = true; + }) + ); +} + // Both themes, because the palette is defined twice and only one half is ever // on screen at a time. The dark half is how `text-tomato-600` links sat at // 2.71:1 in the community and pizzeria tables without anyone seeing it. for (const theme of ['light', 'dark'] as const) { + const applyTheme = async (page: Page) => { + if (theme === 'dark') { + await page.evaluate(() => document.documentElement.classList.add('dark')); + } + }; + // Both view modes, because expert reveals roughly fourteen more controls // that beginner never renders. for (const mode of ['expert', 'beginner'] as const) { - test(`no accessibility violations: ${mode}, ${theme}`, async ({ page }) => { + test(`no accessibility violations: the plan, ${mode}, ${theme}`, async ({ page }) => { await openRecipe(page, mode === 'beginner' ? `${RECIPE}&md=b` : RECIPE); - if (theme === 'dark') { - await page.evaluate(() => document.documentElement.classList.add('dark')); - } - // Community and 50 Top Pizza ship collapsed. Their rows are the - // densest markup in the app and would otherwise never be scanned. - // - // Content sections only, never the popovers. The actions menu and the - // fit-score panel are absolutely positioned and are MEANT to cover - // what is beneath them while open, so forcing them open alongside - // everything else made axe report the verbosity toggle under the menu - // as "partially obscured" — a true observation about an arrangement no - // user is ever in, since opening either popover is a deliberate act - // that dismisses on the next click. Their own contents are covered by - // the menu-keyboard and dialog specs instead. - await page.evaluate(() => - document.querySelectorAll('details').forEach((d) => { - const panel = d.querySelector(':scope > :not(summary)'); - const floats = panel !== null && getComputedStyle(panel).position === 'absolute'; - if (!floats) d.open = true; - }) - ); - - const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze(); - expect(violations, `\n ${summarise(violations)}\n`).toEqual([]); + await applyTheme(page); + await openContentDisclosures(page); + await scan(page); + }); + + // The dense surface: every input in DoughInputs on one sheet, inside a + // modal . It is where the app's densest markup now lives, and a + // closed dialog is invisible to axe, so it has to be opened deliberately. + test(`no accessibility violations: the recipe sheet, ${mode}, ${theme}`, async ({ page }) => { + await openRecipe(page, mode === 'beginner' ? `${RECIPE}&md=b` : RECIPE); + await applyTheme(page); + await openAdjust(page); + await openContentDisclosures(page); + await scan(page); }); } + + test(`no accessibility violations: the questions, ${theme}`, async ({ page }) => { + // The window question, because it carries the busiest control in the app. + await openQuestion(page, 'window', RECIPE); + await applyTheme(page); + // Let the entrance animation finish first. axe measures the colour it + // finds, and mid-fade every foreground on the screen is below its settled + // contrast — a real failure to report about a state nobody reads. + await page + .locator('.kt-enter') + .evaluate((el) => Promise.all(el.getAnimations().map((a) => a.finished))); + await scan(page); + }); + + test(`no accessibility violations: the recipe library, ${theme}`, async ({ page }) => { + await openRecipe(page, RECIPE); + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + await applyTheme(page); + // Community and 50 Top Pizza ship collapsed. Their rows are the densest + // markup in the app and would otherwise never be scanned. + await openContentDisclosures(page); + await scan(page); + }); } diff --git a/e2e/cascade.spec.ts b/e2e/cascade.spec.ts index 5a2ad752..d107c5ce 100644 --- a/e2e/cascade.spec.ts +++ b/e2e/cascade.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { card, openRecipe } from './helpers'; +import { openAdjust, openQuestion, openRecipe, sheet } 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'; @@ -8,24 +8,21 @@ const RECIPE = // every Tailwind utility no matter how specific. Writing the obvious class did // nothing, silently: `font-sans` on a heading, `focus:outline-none` on an input. // They live in @layer base now. These two tests pin the consequence from both -// ends — a utility must be able to win, and the focus ring must still be there. +// ends — something later in the cascade must be able to win, and the focus ring +// must still be there. -test('a utility can restyle a heading', async ({ page }) => { - await openRecipe(page, RECIPE); +test('a later layer can retune a heading the base rule already styled', async ({ page }) => { + await openQuestion(page, 'when', RECIPE); - const face = (loc: ReturnType) => - loc.first().evaluate((el) => { - const cs = getComputedStyle(el); - return { font: cs.fontFamily.split(',')[0].trim(), tracking: cs.letterSpacing }; - }); + const type = await page.locator('h1.question').evaluate((el) => { + const cs = getComputedStyle(el); + return { tracking: parseFloat(cs.letterSpacing), size: parseFloat(cs.fontSize) }; + }); - // Untouched headings keep the display serif from the base rule... - expect((await face(card(page, 'Schedule').locator('h2'))).font).toBe('ui-serif'); - // ...while the day label, which asks for sans and wide tracking with nothing - // but utilities, actually gets them. - const day = await face(card(page, 'Schedule').locator('h3')); - expect(day.font).toBe('ui-sans-serif'); - expect(day.tracking).toBe('1.68px'); + // @layer base gives every h1 −0.02em; `.question` asks for −0.035em and gets + // it. Unlayered, the base rule would have won and the app's one loud piece + // of type would have been silently detuned. + expect(type.tracking / type.size).toBeCloseTo(-0.035, 3); }); // The TRMNL uuid field carried `focus:outline-none`. It never took effect — @@ -35,6 +32,7 @@ test('a utility can restyle a heading', async ({ page }) => { // This is the check that the removal actually held. test('every control keeps the focus ring, including the TRMNL uuid field', async ({ page }) => { await openRecipe(page, RECIPE); + await openAdjust(page); const ring = (loc: ReturnType) => loc.first().evaluate((el: HTMLElement) => { @@ -42,8 +40,11 @@ test('every control keeps the focus ring, including the TRMNL uuid field', async return getComputedStyle(el).outline; }); - expect(await ring(page.locator('form input[type="number"]'))).toBe('rgb(200, 64, 26) solid 2px'); - expect(await ring(page.locator('form select'))).toBe('rgb(200, 64, 26) solid 2px'); + expect(await ring(sheet(page).locator('input[type="number"]'))).toBe( + 'rgb(200, 64, 26) solid 2px' + ); + expect(await ring(sheet(page).locator('select'))).toBe('rgb(200, 64, 26) solid 2px'); + await page.getByRole('button', { name: 'Done', exact: true }).click(); // The trigger is a ; Playwright does not expose it as a button. await page.locator('summary').filter({ hasText: 'Actions' }).click(); diff --git a/e2e/focus-dismissal.spec.ts b/e2e/focus-dismissal.spec.ts index e15190e8..196523d3 100644 --- a/e2e/focus-dismissal.spec.ts +++ b/e2e/focus-dismissal.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { card, openRecipe } from './helpers'; +import { openAdjust, openRecipe, sheet } 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'; @@ -10,32 +10,35 @@ const RING = 'rgb(200, 64, 26) solid 2px'; // element types it left out are used here as primary controls, not as prose: // every disclosure in the app is a styled , and every "Open" in the // recipe tables is an . They fell back to the browser default ring, which -// differs by engine and is easy to lose against a dark card. +// differs by engine and is easy to lose against a dark surface. test('links and disclosure triggers get the same focus ring as the rest', async ({ page }) => { await openRecipe(page, RECIPE); - const ring = (sel: string) => - page - .locator(sel) - .first() - .evaluate((el: HTMLElement) => { - el.focus(); - return getComputedStyle(el).outline; - }); - - expect(await ring('footer a'), 'a footer link').toBe(RING); - expect(await ring('summary'), 'a disclosure trigger').toBe(RING); + const ring = (loc: ReturnType) => + loc.first().evaluate((el: HTMLElement) => { + el.focus(); + return getComputedStyle(el).outline; + }); + + expect(await ring(page.locator('footer a')), 'a footer link').toBe(RING); + expect(await ring(page.locator('summary')), 'a disclosure trigger').toBe(RING); + + await openAdjust(page); // unchanged, and the reason the rule exists - expect(await ring('form input[type="number"]')).toBe(RING); + expect(await ring(sheet(page).locator('input[type="number"]'))).toBe(RING); }); // The actions menu beside it dismisses on outside-click and Escape; this panel -// did neither, so once opened it floated over the schedule it describes until -// the same summary was clicked again. +// did neither, so once opened it floated over the plan it describes until the +// same summary was clicked again. 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' }); + // By the summary's own accessible name: "fit" as a substring also matches the + // Get nerdy panel, which explains the fit score. + const details = page + .locator('details') + .filter({ has: page.locator('summary[aria-label^="Recipe fit"]') }); const trigger = details.locator('summary'); await trigger.click(); @@ -50,11 +53,11 @@ test('the fit-score panel closes on Escape and on an outside click', async ({ pa await trigger.click(); await expect(details.locator('p, ul').first()).toBeVisible(); - await card(page, 'Schedule').getByRole('heading', { name: 'Schedule' }).click(); + await page.getByRole('heading', { name: 'Schedule' }).click(); await expect(details).not.toHaveAttribute('open', ''); }); -// The menu and the fit panel now share one dismissal rule +// The menu and the fit panel share one dismissal rule // (src/lib/components/dismiss.svelte.ts). The panel's half was pinned above; // the menu's never was, even though it is the copy that started out doing // nothing at all — a
toggles on its own summary and dismisses no @@ -77,7 +80,7 @@ test('the actions menu closes on Escape and on an outside click', async ({ page await menu.click(); await expect(items.first()).toBeFocused(); - await card(page, 'Schedule').getByRole('heading', { name: 'Schedule' }).click(); + await page.getByRole('heading', { name: 'Schedule' }).click(); await expect(items.first()).toBeHidden(); }); diff --git a/e2e/form-rules.spec.ts b/e2e/form-rules.spec.ts index 834252bf..dd17f86b 100644 --- a/e2e/form-rules.spec.ts +++ b/e2e/form-rules.spec.ts @@ -1,11 +1,27 @@ import { expect, test } from '@playwright/test'; -import { chosenWindow, dateField, openRecipe, timeField, windowCard } from './helpers'; +import { + chosenWindow, + dateField, + openAdjust, + openRecipe, + sheet, + slider, + timeField, + windowCard +} from './helpers'; + +// Every rule here lives in the recipe sheet — the app's dense "everything" +// surface — so each test opens it first. The rules themselves are unchanged. +async function openForm(page: import('@playwright/test').Page, query: string) { + await openRecipe(page, query); + await openAdjust(page); +} const CAPUTO = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265'; const FAR_BAKE = 'r=2026-09-06T17%3A00%3A00.000Z'; test('editing the bake time re-picks the longest good window', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); expect(await chosenWindow(page)).toBe('32 h'); await dateField(page, 'bake').fill('2026-09-06'); @@ -15,11 +31,11 @@ test('editing the bake time re-picks the longest good window', async ({ page }) }); test('changing the flour re-picks too — both inputs of the ideal', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); await dateField(page, 'bake').fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); - const flour = page.locator('form select').first(); + const flour = sheet(page).locator('select').first(); await flour.selectOption('dallagiovanna-napoletana'); await expect.poll(() => chosenWindow(page)).toBe('72 h'); @@ -29,20 +45,20 @@ test('changing the flour re-picks too — both inputs of the ideal', async ({ pa }); test('"not specified" has no band to aim at, so it leaves the window alone', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); await dateField(page, 'bake').fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); - await page.locator('form select').first().selectOption('none'); + await sheet(page).locator('select').first().selectOption('none'); expect(await chosenWindow(page)).toBe('40 h'); }); test('the W field re-picks on commit, not on every keystroke', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); await dateField(page, 'bake').fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); - const w = page.locator('form label', { hasText: 'Flour strength' }).locator('input'); + const w = sheet(page).locator('label', { hasText: 'Flour strength' }).locator('input'); // typing must not rewrite the schedule under the cursor await w.fill('310'); expect(await chosenWindow(page)).toBe('40 h'); @@ -55,9 +71,9 @@ test('emptying the W field does not silently discard the flour', async ({ page } // A backspace used to write null through the binding, which reads as "no // flour stated": the preset select flipped to "not specified" and fw=0 went // into the share URL while the user was mid-edit. - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); - const flour = page.locator('form select').first(); - const w = page.locator('form label', { hasText: 'Flour strength' }).locator('input'); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); + const flour = sheet(page).locator('select').first(); + const w = sheet(page).locator('label', { hasText: 'Flour strength' }).locator('input'); await w.fill(''); await w.blur(); @@ -68,27 +84,27 @@ test('emptying the W field does not silently discard the flour', async ({ page } }); test('a start time after the bake is refused and explained', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); await dateField(page, 'start').fill('2026-09-05'); // clamped to the bake moment rather than accepted as a negative window await expect(dateField(page, 'start')).toHaveValue('2026-09-02'); await expect(timeField(page, 'start')).toHaveValue('19:00'); - await expect(page.locator('form [role="alert"]').first()).toContainText('after the bake'); + await expect(sheet(page).locator('[role="alert"]').first()).toContainText('after the bake'); }); test('the start field cannot be pushed past the bake by the picker either', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); await expect(dateField(page, 'start')).toHaveAttribute('max', '2026-09-02'); }); test('a drag that moves the start onto another day says so', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-06T09%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-06T09%3A00%3A00.000Z`); - const slider = page.locator('form input[type="range"]'); - await slider.focus(); - for (let i = 0; i < 6; i++) await slider.press('ArrowRight'); + const rail = slider(page); + await rail.focus(); + for (let i = 0; i < 6; i++) await rail.press('ArrowRight'); await expect(windowCard(page).locator('[role="status"]')).toContainText('different day'); }); @@ -99,8 +115,8 @@ test('pulling the bake time back past the start drags the start with it', async // reaches it — with a flour stated the re-pick rewrites the start anyway — // so the branch had no coverage at all, and stranding the start after the // bake is exactly the negative window setStartAt refuses from the front. - await openRecipe(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=0&${FAR_BAKE}`); - await expect(page.locator('form select').first()).toHaveValue('none'); + await openForm(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=0&${FAR_BAKE}`); + await expect(sheet(page).locator('select').first()).toHaveValue('none'); await dateField(page, 'start').fill('2026-09-05'); await expect(dateField(page, 'start')).toHaveValue('2026-09-05'); @@ -115,14 +131,14 @@ test('turning a pre-ferment on and off again gives the autolyse choice back', as // A biga already rests the flour, so the autolyse toggle is hidden while one // is on — but the flag is a real DoughInputs field, not a view preference, // and it has to survive being hidden. Only a browser sees the round trip. - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); - const autolyse = page.locator('form label', { hasText: 'Autolyse rest' }).locator('input'); + const autolyse = sheet(page).locator('label', { hasText: 'Autolyse rest' }).locator('input'); await expect(autolyse).toBeChecked(); await autolyse.uncheck(); await expect.poll(() => new URL(page.url()).searchParams.get('al')).toBe('0'); - const biga = page.locator('form label', { hasText: 'Biga (' }).locator('input'); + const biga = sheet(page).locator('label', { hasText: 'Biga (' }).locator('input'); await biga.check(); await expect(autolyse).toHaveCount(0); // hidden: the biga rests the flour diff --git a/e2e/headings.spec.ts b/e2e/headings.spec.ts index d8d7d874..4417a21a 100644 --- a/e2e/headings.spec.ts +++ b/e2e/headings.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { card, openRecipe } from './helpers'; +import { openQuestion, openRecipe, region } from './helpers'; // A two-day cold plan, so the schedule really does group steps under more than // one date heading. @@ -9,30 +9,27 @@ const RECIPE = function outline(page: import('@playwright/test').Page) { return page.evaluate(() => [...document.querySelectorAll('main h1, main h2, main h3, main h4')] - // The TRMNL dialog's heading lives in the page but belongs to a modal - // that is closed; it is not part of the document outline being read. + // The adjust sheet and the TRMNL dialog live in the page but belong to + // modals that are closed; they are not part of the document outline + // being read. .filter((h) => !h.closest('dialog')) .map((h) => ({ level: Number(h.tagName[1]), text: h.textContent!.trim() })) ); } -// The input card was the only card without a heading, so the app's primary -// surface was missing from the outline entirely; and the schedule's day labels -// were plain spans, so a multi-day plan read as one flat run of step titles -// with no date structure. Both are invisible in the markup diff and only show -// up in the rendered outline, which is why this is a browser test. -test('every card is reachable by heading, and the steps sit under their day', async ({ page }) => { +// Every view names itself with exactly one h1 and hangs its regions off it. +// This is invisible in the markup diff and only shows up in the rendered +// outline, which is why it is a browser test. +test('the plan is headed by the bake moment, with its two regions under it', async ({ page }) => { await openRecipe(page, RECIPE); const heads = await outline(page); - // The input card is named, even though its heading is visually hidden. + expect(heads[0].level).toBe(1); + expect(heads[0].text).toContain('Ready to bake'); + expect(heads.filter((h) => h.level === 1)).toHaveLength(1); expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([ - 'Your recipe', 'Schedule', - 'Ingredients', - 'My recipes', - 'Community recipes', - '50 Top Pizza recipes' + 'Ingredients' ]); // Every step title is an h4 introduced by an h3 date, never the other way @@ -46,42 +43,57 @@ test('every card is reachable by heading, and the steps sit under their day', as expect(schedule.filter((h) => h.level === 3).length).toBeGreaterThan(1); }); -test('no heading level is skipped', async ({ page }) => { +test('the ask flow is headed by its question, the library by its own title', async ({ page }) => { + await openQuestion(page, 'window', RECIPE); + let heads = await outline(page); + expect(heads[0]).toEqual({ level: 1, text: 'How long should it ferment?' }); + expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual(['Your plan so far']); + await openRecipe(page, RECIPE); - const heads = await outline(page); + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + heads = await outline(page); + expect(heads[0]).toEqual({ level: 1, text: 'Start from a recipe' }); + // The three collections are entry points now, gathered on one screen rather + // than buried under the calculator. + expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([ + 'My recipes', + 'Community recipes', + '50 Top Pizza recipes' + ]); +}); - for (let i = 1; i < heads.length; i++) { - // Going deeper may only ever step down one level at a time. - expect(heads[i].level - heads[i - 1].level, `after "${heads[i - 1].text}"`).toBeLessThanOrEqual( - 1 - ); +test('no heading level is skipped, on any view', async ({ page }) => { + for (const open of [() => openRecipe(page, RECIPE), () => openQuestion(page, 'when', RECIPE)]) { + await open(); + const heads = await outline(page); + for (let i = 1; i < heads.length; i++) { + expect( + heads[i].level - heads[i - 1].level, + `after "${heads[i - 1].text}"` + ).toBeLessThanOrEqual(1); + } } }); -// app.css styles `h1, h2, h3, .font-display` from OUTSIDE any cascade layer, so -// that rule beats every Tailwind utility — unlayered always wins over -// @layer utilities. Turning the day label into an h3 silently made it serif and -// dropped its wide tracking; turning the step title into an h4 silently dropped -// the serif it had been inheriting. Both faces are pinned here because the -// markup gives no hint that the levels and the fonts are coupled. +// app.css styles `h1, h2, h3, .font-display` from inside @layer base, so a +// utility can still win — but the family and the tracking come from that rule, +// and the whole type system is one grotesque separated by size and weight +// rather than a serif/sans pair. Changing a heading's LEVEL must not change its +// face, and the markup gives no hint that the two are coupled. test('changing a heading level does not change its typeface', async ({ page }) => { await openRecipe(page, RECIPE); - const day = await card(page, 'Schedule') - .locator('h3') - .first() - .evaluate((el) => { + const face = (loc: ReturnType) => + loc.first().evaluate((el) => { const cs = getComputedStyle(el); return { font: cs.fontFamily.split(',')[0].trim(), tracking: cs.letterSpacing }; }); - // The date label has always been the sans face with wide tracking. - expect(day.font).toBe('ui-sans-serif'); - expect(day.tracking).toBe('1.68px'); - const step = await page - .locator('main ol h4') - .first() - .evaluate((el) => getComputedStyle(el).fontFamily.split(',')[0].trim()); - // Step titles have always been the display serif. - expect(step).toBe('ui-serif'); + // One family for every role, so the day label and the step title agree. + const day = await face(region(page, 'Schedule').locator('h3')); + const step = await face(page.locator('main ol h4')); + expect(day.font).toBe('"Helvetica Neue"'); + expect(step.font).toBe('"Helvetica Neue"'); + // The base rule's tightening, in pixels at the day label's 1.125 rem. + expect(day.tracking).toBe('-0.36px'); }); diff --git a/e2e/help-text.spec.ts b/e2e/help-text.spec.ts index 1913e2f3..42733f72 100644 --- a/e2e/help-text.spec.ts +++ b/e2e/help-text.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from '@playwright/test'; -import { formCard, openRecipe, windowCard } from './helpers'; +import { openAdjust, openRecipe, sheet, windowCard } from './helpers'; + +// Field help is a property of the recipe sheet, so every test opens it. +async function openForm(page: import('@playwright/test').Page, query: string) { + await openRecipe(page, query); + await openAdjust(page); +} 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'; @@ -13,22 +19,22 @@ const AUTOLYSE_HELP = 'Rest flour and water for 30 min'; // Assertions are about VISIBILITY, not text: the copy is in the DOM either // way, which is the whole point of it still being reachable. test('beginner shows the help standing', async ({ page }) => { - await openRecipe(page, `${RECIPE}&md=b`); + await openForm(page, `${RECIPE}&md=b`); await expect(page.getByText(START_HELP)).toBeVisible(); - await expect(formCard(page).getByText('A spiral mixer kneads most efficiently')).toBeVisible(); + await expect(sheet(page).getByText('A spiral mixer kneads most efficiently')).toBeVisible(); }); test('expert shows nothing at rest, and the field being edited explains itself', async ({ page }) => { - await openRecipe(page, RECIPE); + await openForm(page, RECIPE); await expect(page.getByText(START_HELP)).toBeHidden(); - await page.locator('form input[type="date"]').first().focus(); + await sheet(page).locator('input[type="date"]').first().focus(); await expect(page.getByText(START_HELP)).toBeVisible(); // ...and it goes away again, so the form does not accumulate height. - await page.locator('form select').first().focus(); + await sheet(page).locator('select').first().focus(); await expect(page.getByText(START_HELP)).toBeHidden(); }); @@ -36,7 +42,7 @@ test('expert shows nothing at rest, and the field being edited explains itself', // never renders, so hiding help in expert left them with no view at all — // `oil` in particular carries a number a reader cannot infer from the label. test('the notes on expert-only fields are reachable again', async ({ page }) => { - await openRecipe(page, RECIPE); + await openForm(page, RECIPE); const oil = page.getByText(OIL_HELP); await expect(oil).toBeHidden(); @@ -53,7 +59,7 @@ test('the notes on expert-only fields are reachable again', async ({ page }) => // benefit paragraph was kept visible on purpose. Neither may follow the rest. test('the window card keeps its band caption and benefit in both views', async ({ page }) => { for (const query of [RECIPE, `${RECIPE}&md=b`]) { - await openRecipe(page, query); + await openForm(page, query); await expect(windowCard(page).getByText(/tolerates/)).toBeVisible(); await expect(windowCard(page).getByText(/enzymes/)).toBeVisible(); } @@ -62,12 +68,12 @@ test('the window card keeps its band caption and benefit in both views', async ( // A notice that appears in response to a choice is not a description of a // field: both of these are needed at the moment they show up, so they stand. test('conditional notices stay visible in the expert view', async ({ page }) => { - await openRecipe( + await openForm( page, 'v=6&n=6&b=280&h=70&s=3&y=a&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z' ); - await expect(formCard(page).getByText('Dissolve active dry yeast')).toBeVisible(); + await expect(sheet(page).getByText('Dissolve active dry yeast')).toBeVisible(); - await openRecipe(page, `${RECIPE}&p=b30_p20`); - await expect(formCard(page).getByText(/at most 80%/)).toBeVisible(); + await openForm(page, `${RECIPE}&p=b30_p20`); + await expect(sheet(page).getByText(/at most 80%/)).toBeVisible(); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index dffd639f..897f1acb 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -14,38 +14,67 @@ export async function openRecipe(page: Page, query: string) { } // The app ships as prerendered HTML carrying build-time defaults; `onMount` -// then decodes the URL. Reading before that swap is how a check can "pass" -// against numbers that were never on screen, so every spec waits for the -// decoded recipe to land rather than for `load`. +// then decodes the URL, picks the view and stamps the fragment. Reading before +// that swap is how a check can "pass" against numbers that were never on +// screen, so every spec waits for the decoded recipe rather than for `load`. export async function waitForHydration(page: Page) { - await expect(page.locator('form input[type="range"]')).toBeEnabled(); + await expect(view(page)).toBeVisible(); await expect .poll(async () => new URL(page.url()).searchParams.get('sa'), { timeout: 10_000 }) .not.toBeNull(); } -/** A top-level card, addressed by its heading. */ -export function card(page: Page, heading: string) { - return page.locator('.card').filter({ has: page.getByRole('heading', { name: heading }) }); +/** + * Whichever of the three views is mounted. Exactly one ever is — the app is a + * sequence of places, not one page and a scroll — and each stamps its own name, + * so a spec can assert where the visitor landed. + */ +export function view(page: Page) { + return page.locator('main [data-view]'); +} + +export async function currentView(page: Page): Promise { + return view(page).getAttribute('data-view'); +} + +/** A titled region of the plan or the library, addressed by its heading. */ +export function region(page: Page, heading: string) { + return page + .locator('section, aside') + .filter({ has: page.getByRole('heading', { name: heading }) }); +} + +/** The adjust sheet: every input in DoughInputs, on one surface. */ +export function sheet(page: Page) { + return page.locator('dialog[open]').filter({ has: page.locator('form') }); } -/** The card holding the form (it has no heading of its own). */ -export function formCard(page: Page) { - return page.locator('.card').filter({ has: page.locator('input[type="range"]') }); +/** Open the adjust sheet from the plan, the way a user would. */ +export async function openAdjust(page: Page) { + await page.getByRole('button', { name: 'Adjust', exact: true }).click(); + await expect(sheet(page)).toBeVisible(); + return sheet(page); } -/** The fermentation-window card. */ +/** Walk the ask flow to one of its questions. */ +export async function openQuestion(page: Page, step: string, query = '') { + await page.clock.install({ time: NOW }); + await page.goto(`/?${query}#ask/${step}`); + await waitForHydration(page); +} + +/** The fermentation-window control, wherever it currently lives. */ export function windowCard(page: Page) { - return page.locator('form div.rounded-2xl').filter({ has: page.locator('input[type="range"]') }); + return page.locator('.window-card'); } /** The big duration readout, e.g. "40 h". */ export async function chosenWindow(page: Page): Promise { - return (await windowCard(page).locator('.font-display').innerText()).trim(); + return (await windowCard(page).locator('.data').first().innerText()).trim(); } export function slider(page: Page) { - return page.locator('form input[type="range"]'); + return page.locator('#field-window'); } /** Drag the slider to a stop index the way a user would: focus and arrow-key. */ @@ -69,19 +98,24 @@ export async function allStops(page: Page): Promise { return out; } -/** Date part of an input pair, as the form shows it. */ +/** Date part of an input pair, as the adjust sheet shows it. */ export function dateField(page: Page, which: 'start' | 'bake') { - return page.locator('form input[type="date"]').nth(which === 'start' ? 0 : 1); + return page.locator('dialog input[type="date"]').nth(which === 'start' ? 0 : 1); } export function timeField(page: Page, which: 'start' | 'bake') { - return page.locator('form input[type="time"]').nth(which === 'start' ? 0 : 1); + return page.locator('dialog input[type="time"]').nth(which === 'start' ? 0 : 1); } export async function setBakeDate(page: Page, value: string) { await dateField(page, 'bake').fill(value); } +/** A field in the adjust sheet, addressed by the label above it. */ +export function sheetField(page: Page, label: string | RegExp) { + return sheet(page).locator('label', { hasText: label }).locator('input'); +} + /** * Where the browser actually paints the range thumb's centre. A native thumb * travels between `radius` and `width - radius`, which is exactly the geometry diff --git a/e2e/info-panel.spec.ts b/e2e/info-panel.spec.ts index eaab538e..d9c1db0e 100644 --- a/e2e/info-panel.spec.ts +++ b/e2e/info-panel.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { openRecipe } from './helpers'; +import { openAdjust, openRecipe, sheet } from './helpers'; import { INFO_SECTIONS } from '../src/lib/infoSections'; const RECIPE = @@ -13,8 +13,9 @@ const RECIPE = // closed, and expert-only besides. test('the nerdy panel renders every section and every formula', async ({ page }) => { await openRecipe(page, RECIPE); + await openAdjust(page); - const panel = page.locator('form details').filter({ hasText: 'Get nerdy' }); + const panel = sheet(page).locator('details').filter({ hasText: 'Get nerdy' }); await panel.locator('summary').click(); // Each section is one heading paragraph plus its parts. diff --git a/e2e/layout.spec.ts b/e2e/layout.spec.ts index 83e6bc04..e17f4fc7 100644 --- a/e2e/layout.spec.ts +++ b/e2e/layout.spec.ts @@ -1,23 +1,22 @@ import { expect, test } from '@playwright/test'; -import { card, formCard, openRecipe } from './helpers'; +import { openAdjust, openRecipe, region, sheet } 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. +// view and 3.7 in expert. The inputs have left the page entirely now, but the +// schedule-before-weights order is still a rule and still only a browser can +// show that it reaches the phone. 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(); + const schedule = await region(page, 'Schedule').boundingBox(); + const ingredients = await region(page, 'Ingredients').boundingBox(); expect(schedule).not.toBeNull(); expect(ingredients).not.toBeNull(); @@ -29,19 +28,20 @@ test.describe('phone', () => { 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 }) => { + test('the weights sit beside the schedule, and no form sits on the plan', 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(); + const schedule = await region(page, 'Schedule').boundingBox(); + const ingredients = await region(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); + // Right-hand rail, level with the schedule — both are outputs, and the + // plan is the one screen where nothing competes with them. + expect(ingredients!.x).toBeGreaterThan(schedule!.x + schedule!.width - 2); + expect(Math.abs(ingredients!.y - schedule!.y)).toBeLessThan(20); + + // The whole point of the restructure: the twelve inputs are in a sheet + // that has to be asked for, so the plan carries no visible form at all. + await expect(page.locator('form:visible')).toHaveCount(0); }); }); @@ -51,6 +51,7 @@ test.describe('desktop', () => { // tree, not in the markup, which is why this lives in the browser suite. test('both halves of each date+time pair have an accessible name', async ({ page }) => { await openRecipe(page, RECIPE); + await openAdjust(page); for (const name of [ 'Start time — Date', @@ -58,7 +59,7 @@ test('both halves of each date+time pair have an accessible name', async ({ page 'Ready to bake — Date', 'Ready to bake — Time' ]) { - await expect(page.getByLabel(name)).toBeVisible(); + await expect(sheet(page).getByLabel(name)).toBeVisible(); } }); @@ -68,8 +69,8 @@ test('both halves of each date+time pair have an accessible name', async ({ page // of the controls. A reflow (a warning appearing, copy growing in another // locale) could take it away silently. 24 px is now intrinsic to each one. // -// `Now` and `Use best` got there by using .btn-tomato-sm, the component class -// they had been hand-copying with tighter padding all along. +// The ask flow's progress dots are on the list because the visible mark is +// 10 px: the button around it carries the target size, drawn by ::after. test.describe('tap targets', () => { test.use({ viewport: { width: 390, height: 844 } }); @@ -89,16 +90,40 @@ test.describe('tap targets', () => { page, '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-03T17%3A00%3A00.000Z' ); + await openAdjust(page); - for (const name of ['Now', 'Use best', 'Back to the simple view', 'Delete']) { + for (const name of ['Now', 'Use best', 'Back to the simple view']) { const box = await page.getByRole('button', { name, exact: true }).first().boundingBox(); expect(box, `${name} is not on the page`).not.toBeNull(); expect(box!.height, `${name} height`).toBeGreaterThanOrEqual(24); expect(box!.width, `${name} width`).toBeGreaterThanOrEqual(24); } + await page.getByRole('button', { name: 'Done', exact: true }).click(); + // The fit-score disclosure is a , not a button. - const fit = await page.locator('summary').filter({ hasText: 'fit' }).first().boundingBox(); + const fit = await page.locator('summary[aria-label^="Recipe fit"]').boundingBox(); expect(fit!.height).toBeGreaterThanOrEqual(24); + + // Delete lives in the recipe library now, not at the foot of the page. + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + const del = await page + .getByRole('button', { name: 'Delete', exact: true }) + .first() + .boundingBox(); + expect(del, 'Delete is not on the page').not.toBeNull(); + expect(del!.height).toBeGreaterThanOrEqual(24); + expect(del!.width).toBeGreaterThanOrEqual(24); + await page.getByRole('button', { name: 'Back to your plan' }).click(); + + // One question dot on the ask flow's progress rail. + await page.getByRole('button', { name: 'Plan another bake' }).click(); + const dot = await page + .getByRole('navigation', { name: 'Questions' }) + .getByRole('button') + .first() + .boundingBox(); + expect(dot!.height).toBeGreaterThanOrEqual(24); + expect(dot!.width).toBeGreaterThanOrEqual(24); }); }); diff --git a/e2e/persistence.spec.ts b/e2e/persistence.spec.ts index 41d14e38..77c852d8 100644 --- a/e2e/persistence.spec.ts +++ b/e2e/persistence.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { NOW, waitForHydration } from './helpers'; +import { currentView, NOW, openAdjust, sheet, waitForHydration } from './helpers'; // Everything here is a fix that already shipped once. Each has a bug number // because each was found in a browser and could only ever have been found there. @@ -13,14 +13,27 @@ async function open(page: import('@playwright/test').Page, query = '') { await waitForHydration(page); } +// The recipe fields live in the adjust sheet; a bare visit lands on the first +// question, so it is walked to the plan before the sheet is opened. +async function openForm(page: import('@playwright/test').Page, query = '') { + await open(page, query); + if ((await currentView(page)) === 'ask') { + await page.getByRole('button', { name: 'Skip to the plan' }).click(); + } + await openAdjust(page); +} + +const pizzas = (page: import('@playwright/test').Page) => + sheet(page).locator('label', { hasText: 'Pizzas' }).locator('input'); + const remembered = (page: import('@playwright/test').Page) => page.evaluate(() => localStorage.getItem('kneadtime:lastRecipe')); test('merely opening someone else’s link never overwrites your recipe memory', async ({ page }) => { // issue #201. The saved recipe is snapshotted at hydration and the save is // skipped while it still matches, so a visit alone must leave it untouched. - await open(page, MINE); - await page.locator('form label', { hasText: 'Pizzas' }).locator('input').fill('7'); + await openForm(page, MINE); + await pizzas(page).fill('7'); await expect.poll(() => remembered(page)).toContain('n=7'); const mine = await remembered(page); @@ -29,22 +42,22 @@ test('merely opening someone else’s link never overwrites your recipe memory', }); test('a real edit does update the memory, and a bare visit restores it', async ({ page }) => { - await open(page, MINE); - await page.locator('form label', { hasText: 'Pizzas' }).locator('input').fill('9'); + await openForm(page, MINE); + await pizzas(page).fill('9'); await expect.poll(() => remembered(page)).toContain('n=9'); - await open(page); - await expect(page.locator('form label', { hasText: 'Pizzas' }).locator('input')).toHaveValue('9'); + await openForm(page); + await expect(pizzas(page)).toHaveValue('9'); }); test('the restored memory keeps the recipe but not its stale dates', async ({ page }) => { - await open(page, MINE); - await page.locator('form label', { hasText: 'Pizzas' }).locator('input').fill('5'); + await openForm(page, MINE); + await pizzas(page).fill('5'); await expect.poll(() => remembered(page)).toContain('n=5'); - await open(page); + await openForm(page); // today's default bake time, not the one baked into the remembered query - await expect(page.locator('form input[type="date"]').nth(1)).not.toHaveValue('2026-09-06'); + await expect(sheet(page).locator('input[type="date"]').nth(1)).not.toHaveValue('2026-09-06'); }); test('the app still works with localStorage blocked entirely', async ({ page, context }) => { @@ -61,12 +74,12 @@ test('the app still works with localStorage blocked entirely', async ({ page, co }); await open(page, MINE); - await expect(page.locator('form input[type="range"]')).toBeEnabled(); await expect(page.getByRole('heading', { name: 'Schedule' })).toBeVisible(); await expect(page.locator('ol li').first()).toBeVisible(); // and it still responds to input rather than being a frozen shell - await page.locator('form label', { hasText: 'Pizzas' }).locator('input').fill('8'); + await openAdjust(page); + await pizzas(page).fill('8'); await expect.poll(() => page.url()).toContain('n=8'); }); diff --git a/e2e/recipe-collections.spec.ts b/e2e/recipe-collections.spec.ts index 30090660..e5d1f1b1 100644 --- a/e2e/recipe-collections.spec.ts +++ b/e2e/recipe-collections.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from '@playwright/test'; import { openRecipe } from './helpers'; +// The collections moved out of the foot of the calculator and into a view of +// their own: they are entry points to a recipe, not an appendix to one. +async function openLibrary(page: import('@playwright/test').Page, query: string) { + await openRecipe(page, query); + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); +} + 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'; @@ -17,7 +24,7 @@ for (const section of SECTIONS) { test(`${section.heading}: ships collapsed, opens to rows that link back into the app`, async ({ page }) => { - await openRecipe(page, RECIPE); + await openLibrary(page, RECIPE); const details = page.locator('details').filter({ has: page.getByRole('heading', { name: section.heading }) @@ -35,7 +42,7 @@ for (const section of SECTIONS) { test(`${section.heading}: the contribute note points at its own source file`, async ({ page }) => { - await openRecipe(page, RECIPE); + await openLibrary(page, RECIPE); const details = page.locator('details').filter({ has: page.getByRole('heading', { name: section.heading }) @@ -51,7 +58,7 @@ for (const section of SECTIONS) { // them only for a recipe that uses them. One spec list serves both, so the // labels a section does not have are the thing that keeps them apart. test('the card details list only the fields a section actually has', async ({ page }) => { - await openRecipe(page, RECIPE); + await openLibrary(page, RECIPE); await page.setViewportSize({ width: 390, height: 900 }); const community = page.locator('details').filter({ diff --git a/e2e/recipe-output.spec.ts b/e2e/recipe-output.spec.ts index 969fe27f..6049963c 100644 --- a/e2e/recipe-output.spec.ts +++ b/e2e/recipe-output.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { card, NOW, openRecipe } from './helpers'; +import { NOW, openAdjust, openRecipe, region, sheet } from './helpers'; const BASE = 'n=6&b=280&h=70&s=3&y=f&t=22&ft=4&r=2026-09-06T17%3A00%3A00.000Z'; @@ -9,7 +9,7 @@ test('no ingredient is weighed twice across the schedule', async ({ page }) => { // weighing of each thing, on exactly one step. await openRecipe(page, `v=6&${BASE}&sa=2026-09-05T09%3A00%3A00.000Z`); - const schedule = card(page, 'Schedule'); + const schedule = region(page, 'Schedule'); for (const name of [/^Flour$/, /^Water$/, /^Salt$/]) { await expect(schedule.locator('span').filter({ hasText: name })).toHaveCount(1); } @@ -17,19 +17,19 @@ test('no ingredient is weighed twice across the schedule', async ({ page }) => { test('oil and sugar rows appear only when the recipe uses them', async ({ page }) => { await openRecipe(page, `v=6&${BASE}`); - const ingredients = card(page, 'Ingredients'); + const ingredients = region(page, 'Ingredients'); await expect(ingredients).not.toContainText('Oil'); await expect(ingredients).not.toContainText('Sugar'); await openRecipe(page, `v=6&${BASE}&o=3&sg=1`); - await expect(card(page, 'Ingredients')).toContainText('Oil'); - await expect(card(page, 'Ingredients')).toContainText('Sugar'); + await expect(region(page, 'Ingredients')).toContainText('Oil'); + await expect(region(page, 'Ingredients')).toContainText('Sugar'); }); test('a pre-ferment carries all the fresh yeast, none on baking day', async ({ page }) => { await openRecipe(page, `v=6&${BASE}&p=b30&sa=2026-09-04T09%3A00%3A00.000Z`); - const ingredients = card(page, 'Ingredients'); + const ingredients = region(page, 'Ingredients'); await expect(ingredients).toContainText('Biga'); await expect(ingredients).toContainText('Totals'); // the main-dough section hides its yeast row; the totals row surfaces it @@ -42,50 +42,50 @@ test('the ingredients list names the flour that was picked', async ({ page }) => // bag. The name lives in the preset table, which only the mounted form // reaches, so nothing but a browser check covers the wiring. await openRecipe(page, `v=6&${BASE}`); - await expect(card(page, 'Ingredients').locator('tr').first()).toContainText('Caputo Pizzeria'); + await expect(region(page, 'Ingredients').locator('tr').first()).toContainText('Caputo Pizzeria'); // a hand-typed strength matches no bag, so the row keeps the generic label await openRecipe(page, `v=6&${BASE}&fw=300`); - await expect(card(page, 'Ingredients').locator('tr').first()).toContainText('Flour'); + await expect(region(page, 'Ingredients').locator('tr').first()).toContainText('Flour'); }); test('"Round numbers" lands the flour on a tidy figure and is idempotent', async ({ page }) => { await openRecipe(page, `v=6&n=6&b=283.5&h=70&s=3&y=f&t=22&ft=4&r=2026-09-06T17%3A00%3A00.000Z`); + const ball = () => sheet(page).locator('label', { hasText: 'Ball weight' }).locator('input'); const round = page.locator('button:has-text("Round numbers")'); await round.click(); - const ballAfterFirst = await page - .locator('form label', { hasText: 'Ball weight' }) - .locator('input') - .inputValue(); + await openAdjust(page); + const ballAfterFirst = await ball().inputValue(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); // first row is the flour — it is labelled with the chosen bag's name, not // the word "Flour", so address it by position - const flour = await card(page, 'Ingredients').locator('tr').first().innerText(); + const flour = await region(page, 'Ingredients').locator('tr').first().innerText(); const grams = Number(flour.replace(/[^\d.]/g, '')); expect(grams % 50).toBe(0); // second click is a no-op — the snap must not creep await round.click(); - await expect(page.locator('form label', { hasText: 'Ball weight' }).locator('input')).toHaveValue( - ballAfterFirst - ); + await openAdjust(page); + await expect(ball()).toHaveValue(ballAfterFirst); }); test('a pre-v5 link reproduces its original no-autolyse recipe', async ({ page }) => { // The version gate: `al` is absent from old links and must read as OFF, // or every bookmark silently gains a rest step it never had. await openRecipe(page, `v=4&${BASE}&sa=2026-09-05T09%3A00%3A00.000Z`); - await expect(card(page, 'Schedule')).not.toContainText('Autolyse'); + await expect(region(page, 'Schedule')).not.toContainText('Autolyse'); await openRecipe(page, `v=6&${BASE}&sa=2026-09-05T09%3A00%3A00.000Z`); - await expect(card(page, 'Schedule')).toContainText('Autolyse'); + await expect(region(page, 'Schedule')).toContainText('Autolyse'); }); test('a pre-v6 link claims no flour it was never made with', async ({ page }) => { await openRecipe(page, `v=5&${BASE}&sa=2026-09-05T09%3A00%3A00.000Z`); + await openAdjust(page); - await expect(page.locator('form select').first()).toHaveValue('none'); + await expect(sheet(page).locator('select').first()).toHaveValue('none'); }); test('the print route renders the same recipe as the screen', async ({ page }) => { @@ -109,7 +109,7 @@ test('the print route renders the same recipe as the screen', async ({ page }) = // pre-doughs, a main dough and a totals section, with oil and sugar in play. test('the print sheet weighs exactly what the screen weighs', async ({ page }) => { const RICH = `v=6&${BASE}&o=2&sg=1&p=b30_p20&sa=2026-09-05T09%3A00%3A00.000Z`; - const rows = (scope: ReturnType) => + const rows = (scope: ReturnType) => scope.locator('tr').evaluateAll((trs) => trs .map((tr) => { @@ -122,7 +122,7 @@ test('the print sheet weighs exactly what the screen weighs', async ({ page }) = ); await openRecipe(page, RICH); - const onScreen = await rows(card(page, 'Ingredients')); + const onScreen = await rows(region(page, 'Ingredients')); // biga + poolish + main + totals, each with its rows, plus the total line expect(onScreen.length).toBeGreaterThan(10); @@ -141,8 +141,9 @@ test('the flour select is shelved by what each strength is for', async ({ page } // plan. The shelves are cut on W, labelled by ferment length, with the AVPN // spec (w220-380) at the outer edges. await openRecipe(page, `v=6&${BASE}&sa=2026-09-05T09%3A00%3A00.000Z`); + await openAdjust(page); - const groups = page.locator('form select').first().locator('optgroup'); + const groups = sheet(page).locator('select').first().locator('optgroup'); await expect(groups).toHaveCount(6); await expect(groups.first()).toHaveAttribute('label', /Too weak/); await expect(groups.last()).toHaveAttribute('label', /Very strong/); diff --git a/e2e/save-recipe.spec.ts b/e2e/save-recipe.spec.ts index 4f5ecd2b..ebb8b54b 100644 --- a/e2e/save-recipe.spec.ts +++ b/e2e/save-recipe.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { card, openRecipe } from './helpers'; +import { openRecipe, region } 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'; @@ -36,7 +36,9 @@ test('naming a recipe happens in the app, not in a browser prompt', async ({ pag await dialog.getByRole('button', { name: 'Save' }).click(); await expect(dialog).not.toBeVisible(); - await expect(card(page, 'My recipes')).toContainText('Saturday dough'); + // The recipe book lives in the library now — one press from the plan. + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + await expect(region(page, 'My recipes')).toContainText('Saturday dough'); }); test('cancelling saves nothing, and Escape does the same', async ({ page }) => { @@ -46,7 +48,6 @@ test('cancelling saves nothing, and Escape does the same', async ({ page }) => { await dialog.locator('input[type="text"]').fill('Discard me'); await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).not.toBeVisible(); - await expect(card(page, 'My recipes')).not.toContainText('Discard me'); // A native gives Escape for free; pin it so a future refactor to a // hand-rolled overlay cannot quietly drop it. @@ -54,5 +55,9 @@ test('cancelling saves nothing, and Escape does the same', async ({ page }) => { await dialog.locator('input[type="text"]').fill('Also discard'); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); - await expect(card(page, 'My recipes')).not.toContainText('Also discard'); + + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + const book = region(page, 'My recipes'); + await expect(book).not.toContainText('Discard me'); + await expect(book).not.toContainText('Also discard'); }); diff --git a/e2e/view-modes.spec.ts b/e2e/view-modes.spec.ts index 48fb7252..df0eccd3 100644 --- a/e2e/view-modes.spec.ts +++ b/e2e/view-modes.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { NOW, waitForHydration } from './helpers'; +import { currentView, NOW, openAdjust, sheet, waitForHydration } from './helpers'; // Beginner/expert and short/detailed are resolved from three sources in a fixed // order (URL → recipe params → localStorage → default) and persisted only on an @@ -8,41 +8,50 @@ import { NOW, waitForHydration } 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-06T17%3A00%3A00.000Z'; +// Every mode assertion below is about which fields the recipe sheet offers, so +// each visit opens it. A bare visit lands on the first question, not the plan, +// so it is walked to the plan first. async function open(page: import('@playwright/test').Page, query = '') { await page.clock.install({ time: NOW }); await page.goto(query ? `/?${query}` : '/'); await waitForHydration(page); + if ((await currentView(page)) === 'ask') { + await page.getByRole('button', { name: 'Skip to the plan' }).click(); + } + await openAdjust(page); } const expertToggle = (page: import('@playwright/test').Page) => page.getByRole('button', { name: 'Show all options (expert)' }); const beginnerToggle = (page: import('@playwright/test').Page) => page.getByRole('button', { name: 'Back to the simple view' }); +const field = (page: import('@playwright/test').Page, label: string | RegExp) => + sheet(page).locator('label', { hasText: label }); test('a bare visit lands in beginner, showing only the everyday inputs', async ({ page }) => { await open(page); await expect(expertToggle(page)).toBeVisible(); - await expect(page.locator('form label', { hasText: 'Pizzas' })).toBeVisible(); - await expect(page.locator('form label', { hasText: 'Flour' }).first()).toBeVisible(); + await expect(field(page, 'Pizzas')).toBeVisible(); + await expect(field(page, 'Flour').first()).toBeVisible(); // expert-only fields stay out of the way - await expect(page.locator('form label').filter({ hasText: /Hydration \(%\)/ })).toHaveCount(0); - await expect(page.locator('form label', { hasText: 'Ball weight' })).toHaveCount(0); - await expect(page.locator('form label', { hasText: 'Fridge temperature' })).toHaveCount(0); + await expect(field(page, /Hydration \(%\)/)).toHaveCount(0); + await expect(field(page, 'Ball weight')).toHaveCount(0); + await expect(field(page, 'Fridge temperature')).toHaveCount(0); }); test('a link carrying recipe params opens in expert', async ({ page }) => { await open(page, RECIPE); await expect(beginnerToggle(page)).toBeVisible(); - await expect(page.locator('form label').filter({ hasText: /Hydration \(%\)/ })).toBeVisible(); + await expect(field(page, /Hydration \(%\)/)).toBeVisible(); }); test('md=b forces beginner even with a full recipe attached', async ({ page }) => { await open(page, `md=b&${RECIPE}`); await expect(expertToggle(page)).toBeVisible(); - await expect(page.locator('form label').filter({ hasText: /Hydration \(%\)/ })).toHaveCount(0); + await expect(field(page, /Hydration \(%\)/)).toHaveCount(0); }); test('utm-only junk behaves like a bare visit, not a recipe link', async ({ page }) => { @@ -78,6 +87,7 @@ test("opening someone's beginner link never overwrites your own preference", asy test('the schedule verbosity toggle shows and hides the step explanations', async ({ page }) => { await open(page, RECIPE); + await page.getByRole('button', { name: 'Done', exact: true }).click(); const detail = page.locator('ol li p'); const before = await detail.count(); @@ -92,6 +102,7 @@ test('the schedule verbosity toggle shows and hides the step explanations', asyn test('verbosity is a device preference, not part of the share URL', async ({ page }) => { await open(page, RECIPE); + await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Short', exact: true }).click(); expect(await page.evaluate(() => localStorage.getItem('kneadtime:scheduleVerbosity'))).toBe( diff --git a/e2e/views.spec.ts b/e2e/views.spec.ts new file mode 100644 index 00000000..a73f1bd4 --- /dev/null +++ b/e2e/views.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from '@playwright/test'; +import { currentView, NOW, openAdjust, sheet, waitForHydration } from './helpers'; + +// The app is three places now — the questions, the plan, the collections — and +// which one is on screen lives in the URL fragment. None of that is reachable +// from a unit test: the view is resolved in `onMount` against the real URL and +// real storage, and the rules below are exactly the ones a restructure like +// this gets wrong. + +const RECIPE = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-06T17%3A00%3A00.000Z'; + +async function open(page: import('@playwright/test').Page, query = '', hash = '') { + await page.clock.install({ time: NOW }); + await page.goto(`/${query ? `?${query}` : ''}${hash}`); + await waitForHydration(page); +} + +test('a share link goes straight to the plan, never through the questions', async ({ page }) => { + // The fatal failure mode of a question flow: making a returning baker answer + // it again. Anyone who arrives carrying a recipe is already past that. + await open(page, RECIPE); + + expect(await currentView(page)).toBe('plan'); + await expect(page.getByRole('heading', { level: 1 })).toContainText('Ready to bake'); +}); + +test('a genuinely fresh visit is asked the first question', async ({ page }) => { + await open(page); + + expect(await currentView(page)).toBe('ask'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('When are you eating?'); + expect(new URL(page.url()).hash).toBe('#ask/when'); +}); + +test('a remembered recipe also lands on the plan', async ({ page }) => { + // Recipe memory means a second visit is not a fresh one, even with a bare + // URL: the baker has a dough in progress and wants to see it, not re-answer. + await page.addInitScript(() => { + localStorage.setItem('kneadtime:lastRecipe', 'v=6&n=9&b=280&h=70&s=3&y=f&t=22&ft=4'); + }); + await open(page); + + expect(await currentView(page)).toBe('plan'); +}); + +test('the view survives a reload and the back button walks it', async ({ page }) => { + await open(page, RECIPE); + + await page.getByRole('button', { name: 'Recipes', exact: true }).click(); + expect(await currentView(page)).toBe('library'); + expect(new URL(page.url()).hash).toBe('#library'); + + // Linkable and reload-proof: the fragment is the whole of the view state. + await page.reload(); + await waitForHydration(page); + expect(await currentView(page)).toBe('library'); + + await page.goBack(); + await expect.poll(() => currentView(page)).toBe('plan'); + await page.goForward(); + await expect.poll(() => currentView(page)).toBe('library'); +}); + +test('the recipe query is untouched by every move between views', async ({ page }) => { + // The fragment carries the place; the query carries the recipe, and it stays + // the authoritative, shareable half. A view key in the query would have + // changed what `hasRecipeParams` counts as a recipe link. + await open(page, RECIPE); + const recipeOf = () => { + const p = new URL(page.url()).searchParams; + p.delete('sa'); + p.delete('r'); + return p.toString(); + }; + const before = recipeOf(); + + for (const name of ['Recipes', 'Back to your plan'] as const) { + await page.getByRole('button', { name, exact: true }).click(); + } + await page.getByRole('button', { name: 'Plan another bake' }).click(); + expect(await currentView(page)).toBe('ask'); + + expect(recipeOf()).toBe(before); + expect(new URL(page.url()).searchParams.has('view')).toBe(false); +}); + +test('answering a question moves the plan forming beside it', async ({ page }) => { + // A sequence of questions that shows no consequence is a survey. The glance + // beside the question is what stops this being one. + await open(page, '', '#ask/pizzas'); + + const flour = page + .locator('aside dl > div') + .filter({ has: page.getByText('Flour', { exact: true }) }) + .locator('dd'); + const before = await flour.innerText(); + + await page.getByRole('button', { name: 'One more' }).click(); + await expect.poll(() => flour.innerText()).not.toBe(before); +}); + +test('the last question hands over to the plan', async ({ page }) => { + await open(page, '', '#ask/method'); + + await page.getByRole('button', { name: 'See the plan' }).click(); + expect(await currentView(page)).toBe('plan'); +}); + +test('tapping a value in the plan opens the sheet with that field focused', async ({ page }) => { + // The plan is not a form, but every value on it is editable in place — one + // press, and the field you pointed at has the cursor. Without the focus + // hand-off this is just a button that opens twenty fields. + await open(page, RECIPE); + + await page.getByRole('button', { name: /Pizzas/ }).click(); + await expect(sheet(page)).toBeVisible(); + await expect(page.locator('#field-pizzaCount')).toBeFocused(); +}); + +test('the sheet closes on Escape and on a click outside it', async ({ page }) => { + await open(page, RECIPE); + + await openAdjust(page); + await page.keyboard.press('Escape'); + await expect(sheet(page)).toHaveCount(0); + + await openAdjust(page); + // The backdrop of a modal receives the click as the dialog itself. + await page.mouse.click(20, 400); + await expect(sheet(page)).toHaveCount(0); +}); diff --git a/e2e/warnings.spec.ts b/e2e/warnings.spec.ts index a63a9c2b..4f06c0a0 100644 --- a/e2e/warnings.spec.ts +++ b/e2e/warnings.spec.ts @@ -1,29 +1,57 @@ import { expect, test } from '@playwright/test'; -import { card, formCard, openRecipe, windowCard } from './helpers'; +import { openQuestion, openRecipe, region, windowCard } from './helpers'; -// Which card each warning is rendered in. warningSlots.ts pins the mapping; +// Which surface each warning is rendered on. warningSlots.ts pins the mapping; // only a browser can show that all three mount points actually exist — a slot // with no mount is a warning nobody ever sees, and the unit test cannot tell. -test('the window warnings render in the window card', async ({ page }) => { +// +// The mount points moved with the restructure. The rule did not: a warning +// still reads next to what caused it. On the plan the causes are the values in +// the summary (the window, the room temperature) and the weights themselves — +// the fields that set them live in a sheet that has to be asked for, and a +// warning behind a button is a warning nobody sees. + +const PLAN = + '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'; + +test('the window warnings read with the plan’s window value', async ({ page }) => { // Weak flour, long window: past what it tolerates. await openRecipe( page, 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-02T17%3A00%3A00.000Z' ); + const warning = page.getByRole('listitem').filter({ hasText: 'tolerates' }); + await expect(warning).toBeVisible(); + // Above the schedule, next to the chips it is about — not buried under it. + const box = await warning.boundingBox(); + const schedule = await region(page, 'Schedule').boundingBox(); + expect(box!.y).toBeLessThan(schedule!.y); +}); + +test('the same warning follows the slider onto the ask flow', async ({ page }) => { + // On the question screen the slider IS the page, so the window family reads + // inside the control rather than beside a value. + await openQuestion( + page, + 'window', + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-02T17%3A00%3A00.000Z' + ); + await expect(windowCard(page).getByRole('listitem')).toContainText('tolerates'); }); -test('the temperature warning renders with the temperature fields', async ({ page }) => { +test('the temperature warning renders with the other plan-level notices', async ({ page }) => { await openRecipe( page, 'v=6&n=6&b=280&h=70&s=3&y=f&t=10&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T17%3A00%3A00.000Z' ); - const warning = formCard(page).getByRole('listitem').filter({ hasText: 'chilly' }); - await expect(warning).toBeVisible(); - // and not in the window card, which owns a different family - await expect(windowCard(page).getByRole('listitem').filter({ hasText: 'chilly' })).toHaveCount(0); + await expect(page.getByRole('listitem').filter({ hasText: 'chilly' })).toBeVisible(); + // ...and not with the weights, which own a different family + await expect( + region(page, 'Ingredients').getByRole('listitem').filter({ hasText: 'chilly' }) + ).toHaveCount(0); }); test('the yeast warning renders with the weights', async ({ page }) => { @@ -33,10 +61,14 @@ test('the yeast warning renders with the weights', async ({ page }) => { 'v=6&n=6&b=280&h=70&s=3&y=f&t=10&ft=4&fw=310&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-05T14%3A00%3A00.000Z' ); + // Exactly one live region carries it, and it is the one beside the weights. + // (The schedule's own steps list "Fresh yeast" as an amount to weigh, which + // is why this counts warning regions rather than list items.) + const yeast = page.locator('ul[aria-live="polite"] li').filter({ hasText: 'Yeast' }); + await expect(yeast).toHaveCount(1); await expect( - card(page, 'Ingredients').getByRole('listitem').filter({ hasText: 'Yeast' }) - ).toBeVisible(); - await expect(windowCard(page).getByRole('listitem').filter({ hasText: 'Yeast' })).toHaveCount(0); + region(page, 'Ingredients').locator('ul[aria-live="polite"] li').filter({ hasText: 'Yeast' }) + ).toHaveCount(1); }); test('no warning is rendered twice, and none is dropped', async ({ page }) => { @@ -52,13 +84,13 @@ test('no warning is rendered twice, and none is dropped', async ({ page }) => { expect(trimmed.length).toBeGreaterThanOrEqual(2); }); -test('the schedule column carries no warnings any more', async ({ page }) => { +test('the schedule itself carries no warnings', async ({ page }) => { await openRecipe( page, 'v=6&n=6&b=280&h=70&s=3&y=f&t=10&ft=4&fw=180&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-02T17%3A00%3A00.000Z' ); - await expect(card(page, 'Schedule').locator('ul[aria-live="polite"]')).toHaveCount(0); + await expect(region(page, 'Schedule').locator('ul[aria-live="polite"]')).toHaveCount(0); }); // WCAG 1.4.1: the two severities used to differ only in hue — red box versus @@ -73,8 +105,8 @@ test('severity is carried by shape and wording, not colour alone', async ({ page 'v=6&n=6&b=280&h=70&s=3&y=f&t=10&ft=4&fw=180&r=2026-09-08T17%3A00%3A00.000Z&sa=2026-09-05T17%3A00%3A00.000Z' ); - const danger = windowCard(page).getByRole('listitem').filter({ hasText: 'tolerates' }); - const info = formCard(page).getByRole('listitem').filter({ hasText: 'chilly' }); + const danger = page.getByRole('listitem').filter({ hasText: 'tolerates' }); + const info = page.getByRole('listitem').filter({ hasText: 'chilly' }); await expect(danger).toBeVisible(); await expect(info).toBeVisible(); @@ -96,10 +128,7 @@ test('severity is carried by shape and wording, not colour alone', async ({ page // while there is still nothing to say. test('the live region is present before any warning is', async ({ page }) => { // A recipe that trips nothing. - await openRecipe( - page, - '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' - ); + await openRecipe(page, PLAN); const regions = page.locator('main ul[aria-live="polite"]'); await expect(regions).toHaveCount(3); diff --git a/e2e/window-slider.spec.ts b/e2e/window-slider.spec.ts index 060ceb6a..61fb503f 100644 --- a/e2e/window-slider.spec.ts +++ b/e2e/window-slider.spec.ts @@ -4,12 +4,22 @@ import { arrowCentreX, chosenWindow, dragTo, + openAdjust, openRecipe, + sheet, slider, thumbCentreX, windowCard } from './helpers'; +// The window control lives in the recipe sheet on the plan (and gets a whole +// screen of its own on the ask flow — see ask-flow.spec.ts). Every geometry +// rule below is the same one, reached through the sheet. +async function openForm(page: import('@playwright/test').Page, query: string) { + await openRecipe(page, query); + await openAdjust(page); +} + // Caputo Pizzeria (W 265): cold band tops out at 40 h, which is not one of the // canonical stops — the case the ideal-as-its-own-stop work exists for. const CAPUTO = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265'; @@ -30,7 +40,7 @@ const IDEAL_RECIPE = `${CAPUTO}&r=2026-09-02T17%3A30%3A00.000Z`; */ async function tickRowBoxes(page: Page): Promise<{ t: string; left: number; right: number }[]> { return page.evaluate(() => { - const card = document.querySelector('form div.rounded-2xl')!; + const card = document.querySelector('.window-card')!; const spans = [...card.querySelectorAll('span')] .filter((s) => /^\d+\s*h$/.test(s.textContent!.trim()) && s.checkVisibility()) .map((s) => { @@ -53,7 +63,7 @@ async function tickRowBoxes(page: Page): Promise<{ t: string; left: number; righ test('a decoded link reproduces its own window, without re-picking', async ({ page }) => { // The share-link contract: opening someone's recipe must not quietly rewrite // it to what this app would have chosen. 32 h is deliberately not the ideal. - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&${FAR_BAKE}&sa=2026-09-05T09%3A00%3A00.000Z`); expect(await chosenWindow(page)).toBe('32 h'); await expect(windowCard(page).locator('[role="status"]')).toHaveCount(0); @@ -62,16 +72,16 @@ test('a decoded link reproduces its own window, without re-picking', async ({ pa test('the ideal window is a stop the slider can reach', async ({ page }) => { // The reported bug: the app picked the ideal on arrival, and once you dragged // away no slider position could return to it. - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); - await page.locator('form input[type="date"]').nth(1).fill('2026-09-06'); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); + await sheet(page).locator('input[type="date"]').nth(1).fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); expect(await allStops(page)).toContain('40 h'); }); test('the ideal marker names the same window the app picks', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); - await page.locator('form input[type="date"]').nth(1).fill('2026-09-06'); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); + await sheet(page).locator('input[type="date"]').nth(1).fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); await expect(windowCard(page)).toContainText('40 h'); @@ -81,14 +91,14 @@ test('the ideal marker names the same window the app picks', async ({ page }) => test('no ideal marker for a flour the rail cannot serve', async ({ page }) => { // Supermarket 00 tolerates 2–4 h at room temperature and cannot reach the // cold switch at all, so no slider position is inside its band. - await openRecipe(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&${FAR_BAKE}`); + await openForm(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&${FAR_BAKE}`); expect(await arrowCentreX(page, 'up')).toBeNull(); }); test('dragging past the bake deadline is refused, out loud', async ({ page }) => { // Bake is ~34 h out, so the long stops would have to start before now. - await openRecipe(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); const max = Number(await slider(page).getAttribute('max')); await dragTo(page, max); @@ -106,7 +116,7 @@ test('dragging past the bake deadline is refused, out loud', async ({ page }) => }); test('a legal drag clears the refusal', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); const max = Number(await slider(page).getAttribute('max')); await dragTo(page, max); @@ -128,15 +138,15 @@ test('the ideal arrow sits on the thumb, at both ends of the rail', async ({ pag expect(Math.abs(arrow! - (await thumbCentreX(page)))).toBeLessThanOrEqual(1); }; - await openRecipe(page, `${NAPOLETANA}&${FAR_BAKE}`); - await page.locator('form input[type="date"]').nth(1).fill('2026-09-06'); + await openForm(page, `${NAPOLETANA}&${FAR_BAKE}`); + await sheet(page).locator('input[type="date"]').nth(1).fill('2026-09-06'); // 72 h sits past the 88 % pivot threshold — an end-anchored caption await expect.poll(() => chosenWindow(page)).toBe('72 h'); await arrowIsOnTheThumb(); // now pull the bake in so the ideal lands near the left end instead - await page.locator('form input[type="date"]').nth(1).fill('2026-09-01'); - await page.locator('form input[type="time"]').nth(1).fill('20:00'); + await sheet(page).locator('input[type="date"]').nth(1).fill('2026-09-01'); + await sheet(page).locator('input[type="time"]').nth(1).fill('20:00'); await expect.poll(() => chosenWindow(page)).not.toBe('72 h'); await arrowIsOnTheThumb(); }); @@ -145,7 +155,7 @@ test('the rail marker names a ceiling, not the bake moment', async ({ page }) => // It used to reuse the form's "Ready to bake" label, which read as if the // arrow pointed at the bake itself rather than at the longest window it // allows. The moment stays on the line beneath. - await openRecipe(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); + await openForm(page, `${CAPUTO}&r=2026-09-02T19%3A00%3A00.000Z`); const marker = windowCard(page).locator('div.absolute').filter({ hasText: 'Limit set by' }); await expect(marker).toContainText('Limit set by ‘ready to bake’ time'); @@ -153,8 +163,8 @@ test('the rail marker names a ceiling, not the bake moment', async ({ page }) => }); test('the "use best" button restores the ideal, then gets out of the way', async ({ page }) => { - await openRecipe(page, `${CAPUTO}&${FAR_BAKE}`); - await page.locator('form input[type="date"]').nth(1).fill('2026-09-06'); + await openForm(page, `${CAPUTO}&${FAR_BAKE}`); + await sheet(page).locator('input[type="date"]').nth(1).fill('2026-09-06'); await expect.poll(() => chosenWindow(page)).toBe('40 h'); // already at the ideal, so there is nothing to restore @@ -171,13 +181,13 @@ test('the "use best" button restores the ideal, then gets out of the way', async }); test('no "use best" button when the flour has no ideal to offer', async ({ page }) => { - await openRecipe(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&${FAR_BAKE}`); + await openForm(page, `v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=180&${FAR_BAKE}`); await expect(windowCard(page).getByRole('button', { name: 'Use best' })).toHaveCount(0); }); test('every marker caption stays inside the rail', async ({ page }) => { - await openRecipe(page, `${NAPOLETANA}&r=2026-09-01T20%3A00%3A00.000Z`); + await openForm(page, `${NAPOLETANA}&r=2026-09-01T20%3A00%3A00.000Z`); const rail = await windowCard(page).locator('.overflow-hidden.rounded-full').boundingBox(); for (const caption of await windowCard(page).locator('span.whitespace-nowrap').all()) { @@ -195,7 +205,7 @@ test.describe('tick labels on a phone', () => { test.use({ viewport: { width: 390, height: 844 } }); test('the rail labels never run into each other', async ({ page }) => { - await openRecipe(page, IDEAL_RECIPE); + await openForm(page, IDEAL_RECIPE); const boxes = await tickRowBoxes(page); expect(boxes.length).toBeGreaterThanOrEqual(3); @@ -215,7 +225,7 @@ test.describe('tick labels with room', () => { test.use({ viewport: { width: 1280, height: 900 } }); test('all four labels come back once there is width for them', async ({ page }) => { - await openRecipe(page, IDEAL_RECIPE); + await openForm(page, IDEAL_RECIPE); const boxes = await tickRowBoxes(page); expect(boxes.map((b) => b.t)).toContain('48 h'); @@ -231,7 +241,7 @@ test.describe('tick labels with room', () => { // ideal capped by the bake time rather than the flour is SHORTER than the // window in hand, so the button offered to throw away the extra minutes. test('no "use best" while the thumb already sits on the ideal', async ({ page }) => { - await openRecipe(page, IDEAL_RECIPE); + await openForm(page, IDEAL_RECIPE); // Precondition: the two really are on the same pixel, so a pass cannot be // an accident of the button being absent for some other reason. @@ -247,7 +257,7 @@ test('no "use best" while the thumb already sits on the ideal', async ({ page }) // The rail painted two green stretches and nothing said what the colour meant. test('the band caption carries a swatch in the band colour', async ({ page }) => { - await openRecipe(page, IDEAL_RECIPE); + await openForm(page, IDEAL_RECIPE); const swatch = windowCard(page).locator('p span.size-2'); await expect(swatch).toHaveCount(1); diff --git a/package-lock.json b/package-lock.json index fe785384..5fcebc2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "knead-time", - "version": "6.10.10", + "version": "6.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "knead-time", - "version": "6.10.10", + "version": "6.11.0", "license": "Apache-2.0", "dependencies": { "qrcode-generator": "^2.0.4" diff --git a/package.json b/package.json index 40d58544..34c96db3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knead-time", - "version": "6.10.10", + "version": "6.11.0", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 8d8a44d6..dc93ca3d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ import { defineConfig, devices } from '@playwright/test'; // Runs against the real static build, not the dev server: the app ships as // prerendered HTML that hydrates and only then decodes the URL, and that // sequence is itself something worth testing. -const PORT = 4173; +const PORT = Number(process.env.E2E_PORT ?? 4173); export default defineConfig({ testDir: 'e2e', diff --git a/src/app.css b/src/app.css index f64024d0..2ce7dca9 100644 --- a/src/app.css +++ b/src/app.css @@ -3,8 +3,15 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --font-display: ui-serif, Georgia, serif; + /* One family, three jobs. The app used to pair a system serif for headings + with a system sans for everything else, which is the pairing every + framework hands you for free. Servizio uses a single grotesque and + separates the roles by size, weight and tracking instead: a question at + 300/-0.035em cannot be mistaken for a label at 500/0. No webfont — the + app is a static, offline-capable, third-party-request-free build and a + Google Fonts link would be the only network call it makes. */ + --font-sans: 'Helvetica Neue', Inter, 'Segoe UI', Roboto, system-ui, sans-serif; + --font-display: 'Helvetica Neue', Inter, 'Segoe UI', Roboto, system-ui, sans-serif; --color-dough-50: #fdf8ef; --color-dough-100: #f8ecd0; @@ -38,39 +45,69 @@ --color-basil-700: #2c5722; --color-basil-800: #20401a; --color-basil-900: #142810; + + /* The five surface roles, indirected through the --kt-* variables below so + one set of utilities serves both themes. Never write a raw stone-* or + white here again: the reason the old palette read as "cream plus one red" + is that half the tree reached past the tokens for `bg-white/80` and + `text-stone-600`. */ + --color-ground: var(--kt-ground); + --color-plane: var(--kt-plane); + --color-ink: var(--kt-ink); + --color-ink-soft: var(--kt-ink-soft); + --color-line: var(--kt-line); + --color-line-soft: var(--kt-line-soft); } -/* These are element defaults, so they belong in @layer base — and being in a - layer is what lets a utility override them. Unlayered, they outranked every - Tailwind utility no matter how specific, because unlayered CSS wins over any - @layer. Two things were silently broken by that: `font-sans` or `tracking-*` - on an h1-h3 did nothing (a heading could not be restyled without a - component-scoped rule to fight back), and `focus:outline-none` on an input - did nothing either. Both are the obvious class to reach for, and both were - being ignored with no error anywhere. */ @layer base { + /* Limewashed plaster with a green cast, not cookbook cream: the warm colour + in this app is dough, and dough is reserved for the parts of the plan + that are actually fermenting. The ground stays out of the way. */ + :root { + --kt-ground: #e9ece1; + --kt-plane: #fbfcf7; + --kt-ink: #1e2419; + --kt-ink-soft: #5b6152; + --kt-line: #ccd2bf; + --kt-line-soft: #dde2d4; + } + + .dark { + --kt-ground: #141711; + --kt-plane: #1d211a; + --kt-ink: #e8e9df; + --kt-ink-soft: #9ba393; + --kt-line: #333a2c; + --kt-line-soft: #262c21; + } + + /* These are element defaults, so they belong in @layer base — and being in a + layer is what lets a utility override them. Unlayered, they outranked every + Tailwind utility no matter how specific, because unlayered CSS wins over any + @layer. Two things were silently broken by that: `font-sans` or `tracking-*` + on an h1-h3 did nothing (a heading could not be restyled without a + component-scoped rule to fight back), and `focus:outline-none` on an input + did nothing either. Both are the obvious class to reach for, and both were + being ignored with no error anywhere. */ html { font-family: var(--font-sans); -webkit-font-smoothing: antialiased; - background: radial-gradient( - ellipse at top, - var(--color-dough-100) 0%, - var(--color-dough-50) 60% - ) - fixed; + background: var(--kt-ground); } html.dark { - background: radial-gradient(ellipse at top, #2a1f17 0%, #15110d 60%) fixed; color-scheme: dark; } + /* Headings carry the family and the tightening; the size and weight are the + caller's, because a question at 4.5 rem and a step title at 0.9 rem are + the same face doing two different jobs. */ h1, h2, h3, .font-display { font-family: var(--font-display); - letter-spacing: -0.01em; + letter-spacing: -0.02em; } input[type='number'] { @@ -95,28 +132,107 @@ } @layer components { - .card { - @apply border-dough-200 rounded-2xl border bg-white/80 p-6 shadow-sm backdrop-blur dark:border-stone-700 dark:bg-stone-900/70; + /* ---- Structure ------------------------------------------------------- + There are no cards. A region is separated from its neighbour by space + and, where the eye needs a seam, by one hairline in the ground's own + hue. The only elements that get a plane and a radius are the ones that + genuinely float above the page — the adjust sheet, the actions menu, the + two dialogs — so radius reads as elevation rather than as decoration. + Do not put `.plane` on something that sits in the flow of the page. */ + .plane { + @apply border-line bg-plane rounded-[1.25rem] border shadow-[0_18px_48px_-24px_rgba(20,28,16,0.45)]; + } + + /* A full-height view. Exactly one of these is mounted at a time — the ask + flow, the plan, or the library — which is what makes the app a sequence + of places rather than one long scroll. */ + .view { + @apply flex min-h-[100dvh] w-full flex-col; + } + + .view-pad { + @apply mx-auto w-full max-w-6xl px-5 sm:px-8; + } + + /* The seam. One hairline, one hue, everywhere. */ + .rule { + @apply border-line-soft border-t; + } + + /* ---- Type roles ------------------------------------------------------ */ + + /* The one loud thing in the app: a single question, set large enough that + nothing can compete with it. Weight 300 and tight tracking keep it from + reading as a slide-deck headline. */ + .question { + @apply font-display text-ink text-[clamp(2.5rem,8.5vw,4.5rem)] leading-[0.98] font-light tracking-[-0.035em]; + } + + /* The sentence under a question, and every other piece of running help. */ + .lede { + @apply text-ink-soft max-w-[46ch] text-base leading-relaxed sm:text-lg; } + /* Anything the reader treats as a number: times, grams, hours, counts. + Tabular so a column of them lines up and a changing value does not jitter. */ + .data { + @apply font-display font-medium tabular-nums; + } + + /* Names a field or a group. Sentence case on purpose — a tracked-out + uppercase eyebrow above every heading is chrome, not information. */ + .field-label { + @apply text-ink text-sm font-medium; + } + + .section-head { + @apply text-ink-soft text-sm font-semibold; + } + + .text-accent { + @apply text-tomato-700 dark:text-tomato-300; + } + + .text-time { + @apply text-basil-700 dark:text-basil-300; + } + + /* ---- Controls -------------------------------------------------------- */ + + /* Red is for acting and for danger, and for nothing else. */ .btn-tomato { - @apply bg-tomato-500 hover:bg-tomato-600 rounded-full px-4 py-2 text-sm font-semibold text-white disabled:opacity-50; + @apply bg-tomato-500 hover:bg-tomato-600 rounded-full px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50; } .btn-tomato-sm { @apply bg-tomato-500 hover:bg-tomato-600 rounded-full px-3 py-1 text-xs font-semibold text-white; } - .menu-item { - @apply hover:bg-dough-100 block w-full px-4 py-2 text-left text-sm font-medium text-stone-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-stone-200 dark:hover:bg-stone-700; + /* An action with no weight of its own: Back, Cancel, the quiet half of a + pair. Outlined rather than filled so it cannot be mistaken for the + primary move. */ + .btn-ghost { + @apply border-line text-ink hover:border-ink-soft rounded-full border px-5 py-2.5 text-sm font-medium disabled:opacity-50; } - .text-accent { - @apply text-tomato-700 dark:text-tomato-300; + .btn-quiet { + @apply text-ink-soft hover:text-ink rounded-full px-3 py-2 text-xs; + } + + /* A value in the plan that can be edited: tap it and the adjust sheet opens + on that field. The dotted underline is the affordance — a plan full of + buttons would look like a form again, which is the thing this redesign + exists to undo. */ + .chip { + @apply text-ink hover:decoration-tomato-500 decoration-line inline-flex items-baseline gap-1.5 rounded-md underline decoration-dotted decoration-1 underline-offset-4; + } + + .menu-item { + @apply text-ink hover:bg-ground block w-full px-4 py-2.5 text-left text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50; } .row-divider { - @apply border-dough-200/70 border-b last:border-0 dark:border-stone-700/70; + @apply border-line-soft border-b last:border-0; } /* A boxed message under the control that caused it. Danger is the same red @@ -138,7 +254,7 @@ /* The rounded switch strip: language, theme, schedule verbosity. Rendered by SegmentedControl.svelte, which owns the markup as well as the look. */ .pill-group { - @apply border-dough-300 m-0 inline-flex overflow-hidden rounded-full border bg-white/70 p-0 text-xs font-semibold tracking-wider shadow-sm dark:border-stone-700 dark:bg-stone-800/70; + @apply border-line bg-plane/70 m-0 inline-flex overflow-hidden rounded-full border p-0 text-xs font-semibold tracking-wide; } .pill { @@ -150,13 +266,19 @@ } .pill-off { - @apply hover:bg-dough-100 text-stone-700 dark:text-stone-200 dark:hover:bg-stone-700; + @apply text-ink-soft hover:bg-ground hover:text-ink; } /* Every box the form types into. Size and width stay with the caller — the date pair splits a row, the rest fill it. */ .input { - @apply border-dough-300 focus:border-tomato-500 rounded-lg border bg-white px-3 py-2 shadow-sm dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100; + @apply border-line focus:border-tomato-500 bg-plane text-ink rounded-lg border px-3 py-2; + } + + /* The answer boxes on the ask flow. Same control, three times the presence: + a question set at 4 rem cannot be answered in a 14 px field. */ + .input-lg { + @apply border-line focus:border-tomato-500 bg-plane text-ink font-display rounded-xl border px-4 py-3 text-2xl font-medium tabular-nums sm:text-3xl; } /* A link in running text — the footer, the setup hint in the TRMNL dialog. @@ -179,11 +301,74 @@ /* Both modals: a native , opened imperatively from the actions menu. Width is the caller's, everything else is shared. */ .dialog-panel { - @apply border-dough-200 rounded-2xl border bg-white p-0 text-sm text-stone-700 shadow-xl backdrop:bg-stone-950/40 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-200; + @apply border-line bg-plane text-ink rounded-[1.25rem] border p-0 text-sm shadow-xl backdrop:bg-[rgba(20,28,16,0.45)]; } - /* The way out of a dialog: quieter than the action beside it. */ - .btn-quiet { - @apply rounded-full px-3 py-2 text-xs text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200; + /* The app's name, in the masthead of every view. */ + .wordmark { + @apply font-display text-ink text-lg font-medium tracking-[-0.03em] whitespace-nowrap; + } + + /* One of a small set of mutually exclusive answers, sized to be hit with a + thumb. The radio itself stays visible: a tile that hides its own control + has to reinvent the selected state, and reinventing it is how these end + up unreadable to anyone not going by colour. */ + .tile { + @apply border-line hover:border-ink-soft has-checked:border-tomato-500 has-checked:bg-tomato-50 dark:has-checked:bg-tomato-900/25 flex cursor-pointer items-center gap-3 rounded-xl border px-4 py-3.5; + } + + /* The minus/plus on the pizza-count answer. */ + .stepper { + @apply border-line text-ink hover:border-ink-soft flex h-14 w-14 shrink-0 items-center justify-center rounded-full border text-2xl; + } + + /* One question on the ask flow's progress rail. The visible mark is drawn by + ::after so the button itself can stay 24 px square — the dot is 10 px, and + a 10 px target passes WCAG 2.5.8 only by the spacing exception, which is a + property of the current layout rather than of the control. */ + .dot { + @apply flex h-6 w-6 items-center justify-center rounded-full; + } + + .dot::after { + content: ''; + @apply bg-line h-2.5 w-2.5 rounded-full transition-all; + } + + .dot-on::after { + @apply bg-tomato-500 w-6; + } + + /* The fermentation-window control. Named, because five e2e specs address it + and because it is the one place in the app where a rail, two markers, a + tick row and a thumb all have to share one coordinate system. */ + .window-card { + @apply border-line bg-plane/60 rounded-2xl border p-4; + } +} + +/* The one piece of non-user-triggered motion in the app: moving between two + questions slides the whole question block, so the flow reads as one surface + travelling rather than as five pages loading. `--kt-dir` is +1 going forward + and −1 going back, set by whichever control moved us. Nothing else animates + on its own; everything below responds to something the reader did. */ +@keyframes kt-enter { + from { + opacity: 0; + transform: translate3d(calc(var(--kt-dir, 1) * 2rem), 0, 0); + } + to { + opacity: 1; + transform: none; + } +} + +.kt-enter { + animation: kt-enter 320ms cubic-bezier(0.22, 0.8, 0.3, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .kt-enter { + animation: none; } } diff --git a/src/app.html b/src/app.html index 09b89727..252a5bcd 100644 --- a/src/app.html +++ b/src/app.html @@ -4,7 +4,8 @@ - + + %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/lib/components/ActionsMenu.svelte b/src/lib/components/ActionsMenu.svelte index 6eb8994f..ec25c3b3 100644 --- a/src/lib/components/ActionsMenu.svelte +++ b/src/lib/components/ActionsMenu.svelte @@ -76,9 +76,12 @@ } -
+ +
@@ -89,10 +92,7 @@ {t.actions.menu} -