diff --git a/.github/test-baseline.json b/.github/test-baseline.json index 4f8f413a..411058c3 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 + "e2e": 97 } diff --git a/CLAUDE.md b/CLAUDE.md index 48ec71af..666e1db9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Source of truth: `DoughInputs` in `src/lib/dough/types.ts`. - **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. - **`startAt` can never be later than `readyBy`.** `FormState.setStartAt` clamps to the bake time and returns whether it had to, so the form can show the red `form.startAt_clamped` notice; the start date input also carries `max={readyByDate}` for the native picker. `setReadyBy` applies the same floor from the other side for the case where no window re-pick happens (no flour, or nothing reachable). Both are **form-level** rules — `apply()` still writes a decoded link's own values verbatim, and the resulting zero-length window is left to the schedule's honest `too-short` warning. -- **Everything positioned by percentage is inset by half a thumb** (`inset-x-2.5` / `mx-2.5` against a 1.25 rem thumb): a native range thumb's centre travels from `radius` to `width − radius`, so a marker at a plain `left: p%` of the full width drifts from the thumb by up to the radius — zero at the midpoint, worst at the ends. The rail, both marker rows and the tick labels all carry the same inset so they share one coordinate system with the thumb. Don't "tidy" it back to `inset-x-0`. +- **Everything positioned by percentage is inset by half a thumb** (`inset-x-3.5` / `mx-3.5` against a 1.75 rem thumb): a native range thumb's centre travels from `radius` to `width − radius`, so a marker at a plain `left: p%` of the full width drifts from the thumb by up to the radius — zero at the midpoint, worst at the ends. The rail, both marker rows and the tick labels all carry the same inset so they share one coordinate system with the thumb. Don't "tidy" it back to `inset-x-0`. The thumb size lives in three places that must agree: the two vendor pseudo-elements in `FermentWindowSlider.svelte`, the inset utilities, and `thumbCentreX` in `e2e/helpers.ts`, which hard-codes half of it. - Within a marker, the **arrow and its caption are positioned separately**. The caption pivots near the ends (`items-start` / `items-end`) so it cannot hang off the rail; the arrow never pivots, because moving it to keep the caption inside points it away from the moment it names. Both markers had this bug — it shows only past the 12 % / 88 % pivot thresholds, which is why it survived the first alignment fix. - **A "Use best" button restores it.** Sits beside the band caption, the same pill as the start field's "Now", and calls `repickWindow()`. Rendered only when there is an ideal and the window is not already on it — a button already at its destination is noise, and its disappearance is the receipt that the click landed. - **The ideal window is a stop of its own.** `idealWindowHours` = the longest window inside the flour's zones that still fits before the bake, floored to the hour — almost never a canonical figure (Caputo Pizzeria's cold band ends at 40 h). `stopsWithIdeal` splices it into the rail, and **every axis/index helper takes the resulting list** (`windowAxisPercent`, `nearestWindowStopIndex`, `reachableStopIndex`, `bestWindowStopIndex` all default to `WINDOW_STOPS` and are passed the dynamic list by the component and by `setReadyBy`). Without this the app re-picked a value no slider position could express, so a user who dragged away could never get back to it. Marked below the rail with an upward arrow — the bake deadline is flagged from above, so the two can't be confused. Splicing shifts the tick labels slightly; that only happens on a flour or bake-time change, never mid-drag. @@ -75,7 +75,7 @@ Nudges ball weight (0.1 g) so flour lands on a multiple of 100 g — always, for ## Outputs - **Ingredients (grams).** The flour rows are labelled with the **chosen bag's own name** (`flourIngredientName` in `stepCopy.ts` — preset name when `flourW` matches one, generic "Flour" for a hand-typed W or `null`), on the screen table and the print sheet alike; the schedule steps' own ingredient lists keep the generic word, since they read as instructions. Naming a row is presentation — W still never touches a mass. No pre-ferment → flat table. With pre-ferments → one typed pre-dough section per entry (`Biga (pre-dough)`, `Poolish (pre-dough)`) / Main dough / Totals (a single subtracted table reads as a math error). With pre-ferments, main-dough yeast row is hidden — totals row surfaces the yeast. **Oil and sugar rows render only when > 0** (so defaults-only recipes stay unchanged). **What is weighed, and in what order, is `src/lib/ingredientRows.ts`** — one list, rendered by both `Ingredients.svelte` and the print route, so the paper cannot quietly disagree with the screen (`ingredientRows.test.ts` for the rules, `e2e/recipe-output.spec.ts` for the two renderings matching row for row). Under a pre-ferment the main dough has **no** yeast row at all — `computeIngredients` sets that mass to exactly 0, so the old `yeast > 0` guard in both components was a condition that had never been true. -- **Schedule.** Rendered as a **day-grouped vertical timeline** (`ScheduleTable.svelte`): consecutive steps fall under one date header, a rail threads the nodes (filled = baker-action step, hollow = waiting phase, dashed rail segment leaving a fermentation step), and the in-progress step gets a pulsing node + `Now` badge. **Step copy is split** (`stepCopy.ts`): `stepIngredients(step, msgs, schedule)` returns the amounts a step **newly** puts on the scale as a structured `{amount, name}[]` list (rendered as a mini-table, never prose) — each ingredient appears on exactly one step, never repeated. `preferment-mix` and `prep` carry the lists; `mix` lists only oil/sugar and only under a pre-ferment (without one they're weighed at `prep`, so `mix` lists nothing). **Under an autolyse** (`hasAutolyse`, no pre-ferment) the split shifts: `prep` weighs flour+water only, `mix` weighs the held-back salt+yeast (plus any oil/sugar), and the `{water_temp}` note moves to `prep` (where the water first meets the flour); the `autolyse` step itself lists nothing. `stepDescription` is method-only copy; `divide` keeps `{n}`/`{weight}` interpolation; `mix` keeps `{water_temp}`; `prep_desc_autolyse` gains `{water_temp}` and `mix_desc_autolyse` folds in the held-back salt+yeast. Day-two `prep` omits yeast under a pre-ferment. **Separate `mix`/`prep` method templates per pre-ferment shape** (`*_with_biga`, `*_with_poolish`, `*_with_both`, `prep_desc_with_preferment`): biga = stiff/no-knead day-one + day-two fold-in; poolish = whisk-and-pour; both = tear-in + pour-over. Mix bases are method-neutral; the kneading sentence comes from `mix_technique_{spiral,stand,hand}`. `preferment-mix` titles/descs branch on `step.preFermentType` (`preferment_mix_{biga,poolish}`); each row spans its full duration; no separate `preferment-proof` step. Beginner mode adds a `steps._detail` paragraph under every step. **Source-timing badge**: when the form matches a `pizzeriaEntries` row and a step's computed duration falls outside the source range (±15 % tolerance), `ScheduleTable` renders the original value beneath the duration. +- **Schedule.** Rendered as a **day-grouped feed of step cards** (`ScheduleTable.svelte`): consecutive steps fall under one date header, a rail threads the nodes (filled tomato = baker-action step, hollow basil ring = waiting phase, dashed rail segment leaving a fermentation step), each node carries a `StepGlyph.svelte` drawing for its `ScheduleStepKind`, and the in-progress step gets a pulsing node, a `Now` chip and the only raised card in the feed. **Step copy is split** (`stepCopy.ts`): `stepIngredients(step, msgs, schedule)` returns the amounts a step **newly** puts on the scale as a structured `{amount, name}[]` list (rendered as a mini-table, never prose) — each ingredient appears on exactly one step, never repeated. `preferment-mix` and `prep` carry the lists; `mix` lists only oil/sugar and only under a pre-ferment (without one they're weighed at `prep`, so `mix` lists nothing). **Under an autolyse** (`hasAutolyse`, no pre-ferment) the split shifts: `prep` weighs flour+water only, `mix` weighs the held-back salt+yeast (plus any oil/sugar), and the `{water_temp}` note moves to `prep` (where the water first meets the flour); the `autolyse` step itself lists nothing. `stepDescription` is method-only copy; `divide` keeps `{n}`/`{weight}` interpolation; `mix` keeps `{water_temp}`; `prep_desc_autolyse` gains `{water_temp}` and `mix_desc_autolyse` folds in the held-back salt+yeast. Day-two `prep` omits yeast under a pre-ferment. **Separate `mix`/`prep` method templates per pre-ferment shape** (`*_with_biga`, `*_with_poolish`, `*_with_both`, `prep_desc_with_preferment`): biga = stiff/no-knead day-one + day-two fold-in; poolish = whisk-and-pour; both = tear-in + pour-over. Mix bases are method-neutral; the kneading sentence comes from `mix_technique_{spiral,stand,hand}`. `preferment-mix` titles/descs branch on `step.preFermentType` (`preferment_mix_{biga,poolish}`); each row spans its full duration; no separate `preferment-proof` step. Beginner mode adds a `steps._detail` paragraph under every step. **Source-timing badge**: when the form matches a `pizzeriaEntries` row and a step's computed duration falls outside the source range (±15 % tolerance), `ScheduleTable` renders the original value beneath the duration. - **`.ics` export.** One VEVENT per step. `DESCRIPTION` is `stepDetailText` — the ingredient list (one `amount name` line each) followed by the method copy, so the calendar event **matches the on-page step verbatim**; in beginner mode the explanation paragraph is appended too. UIDs include `preFermentType` — two parallel pre-ferment mixes can share a start time. - **Print / Save as PDF.** Print button opens a dedicated `/print/[[locale]]?` route in a new tab (SSR + prerendered, mirrors the TRMNL push pattern). The route auto-triggers `window.print()` on mount with inline styles so the main app's gradient/dark-mode rules don't bleed in. This is the only print path — the legacy `@media print` block on the main route was removed in v3.6 (Cmd-P from the screen now prints the screen layout). Output must read on **B&W** (borders + text colour, no background fills) and **fit one page** on A4/Letter for common shapes (fresh × {no-preferment, biga, poolish, biga+poolish} × {room, cold} — the 9-step biga+poolish cold schedule is the worst case). Print never carries the beginner detail copy. QR of the share URL via `src/lib/qr.ts` (wraps `qrcode-generator`). - **TRMNL e-ink view** is **pushed** to a Private Plugin webhook from the user's browser — see the TRMNL push section. @@ -171,13 +171,24 @@ The recipe is **pushed** to a [TRMNL](https://trmnl.com/) device via a Private P ## Design -Responsive, playful, Italian-warm (tomato / basil / dough). Must read well on a phone on the counter at narrow widths. - +"Impasto": soft, tactile, one thing at a time. Responsive, Italian-warm (tomato / basil / dough), and it has to read at 390 px on a counter with flour on your hands. The look is a modern phone app that happens to run in a browser, so the page opens with the **answer** and puts the inputs behind it. + +- **Answer first.** `BakeBoard.svelte` sits above the grid on every width: the bake moment as a large figure, a "Now / Next" line naming the step the clock is standing in with the time left, and three stat tiles (pizzas, total dough, window). It computes nothing — it reads the schedule the form already produced — but it and `ScheduleTable` decide "what is happening" separately, so `e2e/bake-board.spec.ts` pins that the two name the same step. The board's position is a rule, not a layout accident: the app used to open with twelve label-over-input rows and hide the schedule below the fold on a phone. +- **Depth comes from light, not from lines** — and that only works in one theme, so both halves are designed. A panel is lighter than the ground and casts a soft warm shadow (`--kt-lift-1/2`); a soft shadow on a dark ground is invisible, so every raised surface also carries `--kt-edge`, transparent in light and a warm hairline in dark. Drop the dark half and the panels dissolve into the page — pinned in `bake-board.spec.ts`, because it is the half nobody looks at. +- **Colour means something now.** Tomato is ACTION (a step the baker performs, a button, a refusal), basil is TIME (fermentation, the flour's tolerance band, the cold leg, every passive step), dough is the SUBSTRATE (surfaces, wells, hairlines). The palette had resolved on screen to cream plus one red; basil has a job, so it earns its place. +- **Semantic surfaces are utilities, not `dark:` twins.** `@theme` maps `--color-{ground,surface,raised,sunk,hairline,ink,ink-soft,ink-faint,accent,leaf}` onto `--kt-*` custom properties that `:root` and `html.dark` both define — and since the theme class sits on ``, which _is_ `:root`, one declaration serves both themes. Write `bg-surface text-ink-soft`, never a hand-picked grey with a `dark:` partner: a hand-picked pair is how the recipe tables ended up at 2.71:1 in dark without anyone seeing it. **`--kt-ink-faint` is held above 4.5:1 against `--kt-sunk`**, not merely against the panel — the wells are the darkest ground it ever lands on, and it sat at 4.29 there until axe caught it. +- **The type is rounded, and the numerals are the voice.** `--font-display` is a rounded geometric stack (`ui-rounded` → SF Pro Rounded → Nunito → …); `--font-sans` stays the neutral UI face. `.figure` and `.stat-value` are the two numeric sizes, both tabular so a ticking value cannot shuffle its own digits. `--text-figure` / `--text-stat` clamp against the viewport. Nothing in this app is set in tracked-out capitals — the schedule's day divider keeps its wide tracking but is sentence case, and `cascade.spec.ts` / `headings.spec.ts` pin both faces because heading level and typeface are coupled through the base rule. +- **The fermentation window is the hero control.** `.window-card` is a dough-tinted slab inside the form — a white slab with a shadow on a near-white panel reads as nothing, which is why it is tinted rather than merely raised — carrying a **1.25 rem groove** with the flour's zones painted in it and a **1.75 rem thumb** sitting in the channel. The half-thumb inset is therefore `inset-x-3.5` / `mx-3.5`, and `e2e/helpers.ts`'s `thumbCentreX` hard-codes half the thumb as its radius: change one and you must change the other. +- **A chip is a translucent wash, never a fixed fill.** `.chip` lands on three grounds — the lifted panel, the flat card the collections sit in, and the tomato wash under the running step — and a fixed colour matched the quiet card closely enough that the 50 Top Pizza ranking pills read as plain text in the dark theme. `--kt-chip` is an alpha wash of the ink colour, so it cannot collide with whatever it happens to sit on; `bake-board.spec.ts` measures the composited difference in both themes. +- **The schedule is a feed, not a table.** Each step is a quiet card — only the running one lifts — with a `StepGlyph.svelte` drawing per `ScheduleStepKind` in the rail node (filled tomato for a baker action, hollow basil for time passing), the time and title on one baseline, and the duration as a `.chip`. Nine identical raised boxes would be the card soup this replaced. +- **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`, `.card-quiet`, `.window-card`, `.well`, `.popover-panel`, `.figure`, `.stat-value`/`.stat-label`, `.chip` + `.chip-{action,time,now}`, `.btn-tomato{,-sm}`, `.btn-quiet`, `.menu-item`, `.notice` + `.notice-{danger,info}`, `.pill-group`/`.pill`/`.pill-{on,off}`, `.input`, `.field-label`, `.fieldset-legend`, `.section-title`, `.eyebrow`, `.link-quiet`, `.link-action`, `.dialog-panel`, `.row-divider`, `.text-accent`. Hand-copied lists drift silently. One-off styling stays inline; the rule is about the second occurrence, not the first. + - `.card` and `.card-quiet` are the two region weights and the difference is the point: the form, schedule and ingredients are lifted panels; My recipes, Community and 50 Top Pizza are flat and hairlined, because browsing other people's recipes is a detour. +- **No component transition may name `outline-color`.** `transition-colors` includes it, so any control carrying that utility fades its own focus ring into existence — 150 ms of no visible focus after a Tab. Every transition in the components layer therefore lists its properties. `cascade.spec.ts` reads the outline immediately after focus, which is exactly when a transitioned ring is still the colour it was before. +- **Motion is answer-only and opt-out.** The current step's node pulses, the slider's bands ease when the flour changes, disclosures rotate their caret. Nothing animates on first paint, and a global `prefers-reduced-motion` block in `app.css` collapses every duration. - **One dismissal rule for every popover** — `dismissOnOutsideClickOrEscape` in `src/lib/components/dismiss.svelte.ts`, used by the actions menu and the fit-score panel. Outside click closes; Escape closes and hands focus back to the trigger; state is read through callbacks at event time, never snapshotted (a bound copy of a `
`'s `open` is one tick behind the attribute the browser already flipped, which is what made the panel's own copy fail about one browser-test run in four). Both halves are pinned in `e2e/focus-dismissal.spec.ts` — a menu test there waits for **focus** rather than visibility, because `
` opens itself a tick before the effect that attaches the key handler runs. - -- **A repeated Tailwind class list gets a name in `app.css`, not a copy.** The `@layer components` block holds the shapes more than one place needs: `.card`, `.btn-tomato{,-sm}`, `.btn-quiet`, `.menu-item`, `.notice` + `.notice-{danger,info}`, `.pill-group`/`.pill`/`.pill-{on,off}`, `.input`, `.link-quiet`, `.dialog-panel`, `.row-divider`, `.text-accent`. Hand-copied lists drift silently — the window card's status box had ended up a different shade from the warning list directly below it. One-off styling stays inline; the rule is about the second occurrence, not the first. - **The segmented switch is a component**, `SegmentedControl.svelte` — language, theme and schedule verbosity all render through it. The three had drifted apart in markup as well as size: two named themselves with a ``, one with `aria-label` on a `role="group"`. A `
` + sr-only `` needs no ARIA at all, so that is the shape it settled on; an icon strip passes `labelFor` for the spoken name and a snippet for the glyph. -- **e2e specs address the app through these names**, so a class in `app.css` is closer to an API than a style — `windowCard()` finds `form div.rounded-2xl`, `card()` finds `.card`. Renaming one means grepping `e2e/` too. +- **e2e specs address the app through these names**, so a class in `app.css` is closer to an API than a style — `card()` finds `.card, .card-quiet`, `formCard()` finds the `.card` holding the range input, `windowCard()` finds `form .window-card`, `chosenWindow()` reads the **single** `.font-display` inside that card, and `arrowCentreX()` matches the marker paths `M5 0` / `M5 6`. Renaming one means grepping `e2e/` too. +- **The print route is a separate surface.** `/print/[[locale]]` owns its inline styles and none of this reaches it — it must keep fitting one B&W A4/Letter page. ## Git workflow diff --git a/README.md b/README.md index 62b5328d..301b34d5 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ src/ │ ├── +layout.ts ← prerender + ssr=false (fully client-side) │ ├── +page.svelte ← the entire calculator UI │ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog) -├── app.css ← Tailwind v4 entrypoint + @theme palette +├── app.css ← Tailwind v4 entrypoint: palette, semantic surface/ink tokens, component classes └── app.html ← shell e2e/ ← Playwright browser tests (the parts vitest cannot reach) @@ -103,19 +103,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` overrides the preview port | +| `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 +125,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`; render results in the existing components or add a new one. Use Svelte 5 runes (`$state`, `$derived`, `$effect`). Reach for the semantic classes in `src/app.css` (`.card`, `.well`, `.chip`, `.field-label`, `bg-surface`, `text-ink-soft`, …) rather than picking a grey by hand — they carry both themes, and a hand-picked pair is how the dark theme drifts out of contrast. 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`. diff --git a/docs/redesign/impasto/after-biga-poolish.jpg b/docs/redesign/impasto/after-biga-poolish.jpg new file mode 100644 index 00000000..b59ef9db Binary files /dev/null and b/docs/redesign/impasto/after-biga-poolish.jpg differ diff --git a/docs/redesign/impasto/after-desktop-dark.png b/docs/redesign/impasto/after-desktop-dark.png new file mode 100644 index 00000000..38be31f8 Binary files /dev/null and b/docs/redesign/impasto/after-desktop-dark.png differ diff --git a/docs/redesign/impasto/after-desktop-light.png b/docs/redesign/impasto/after-desktop-light.png new file mode 100644 index 00000000..fe3e733b Binary files /dev/null and b/docs/redesign/impasto/after-desktop-light.png differ diff --git a/docs/redesign/impasto/after-full-page-light.jpg b/docs/redesign/impasto/after-full-page-light.jpg new file mode 100644 index 00000000..37237419 Binary files /dev/null and b/docs/redesign/impasto/after-full-page-light.jpg differ diff --git a/docs/redesign/impasto/after-phone-dark.png b/docs/redesign/impasto/after-phone-dark.png new file mode 100644 index 00000000..f7851cf2 Binary files /dev/null and b/docs/redesign/impasto/after-phone-dark.png differ diff --git a/docs/redesign/impasto/after-phone-light.png b/docs/redesign/impasto/after-phone-light.png new file mode 100644 index 00000000..4acfbdf7 Binary files /dev/null and b/docs/redesign/impasto/after-phone-light.png differ diff --git a/docs/redesign/impasto/before-desktop-dark.png b/docs/redesign/impasto/before-desktop-dark.png new file mode 100644 index 00000000..14d47bac Binary files /dev/null and b/docs/redesign/impasto/before-desktop-dark.png differ diff --git a/docs/redesign/impasto/before-desktop-light.png b/docs/redesign/impasto/before-desktop-light.png new file mode 100644 index 00000000..0ff06043 Binary files /dev/null and b/docs/redesign/impasto/before-desktop-light.png differ diff --git a/docs/redesign/impasto/before-phone-dark.png b/docs/redesign/impasto/before-phone-dark.png new file mode 100644 index 00000000..4b3fd2e4 Binary files /dev/null and b/docs/redesign/impasto/before-phone-dark.png differ diff --git a/docs/redesign/impasto/before-phone-light.png b/docs/redesign/impasto/before-phone-light.png new file mode 100644 index 00000000..2419381a Binary files /dev/null and b/docs/redesign/impasto/before-phone-light.png differ diff --git a/e2e/bake-board.spec.ts b/e2e/bake-board.spec.ts new file mode 100644 index 00000000..b99fadb8 --- /dev/null +++ b/e2e/bake-board.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '@playwright/test'; +import { card, formCard, openRecipe } from './helpers'; + +// A cold two-day plan whose first step is already running at the pinned clock, +// so the board has a "Now" to report and the schedule has one to agree with. +const RUNNING = + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T17%3A00%3A00.000Z&sa=2026-09-01T09%3A00%3A00.000Z'; + +// Everything still ahead of the clock, so the board reports the next step. +const AHEAD = + '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'; + +function board(page: import('@playwright/test').Page) { + return page.getByRole('region', { name: 'Ready to bake' }); +} + +// The complaint this redesign answers: the page opened with twelve +// label-over-input rows, and the thing the app is FOR sat below the fold on a +// phone. The board is the fix, so its position is the rule — a future tidy-up +// that files it under the form would put the inputs back in front of the +// answer without breaking anything else. +test.describe('phone', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('the answer is on screen before any input', async ({ page }) => { + await openRecipe(page, RUNNING); + + const bake = await board(page).boundingBox(); + const form = await formCard(page).boundingBox(); + const schedule = await card(page, 'Schedule').boundingBox(); + + expect(bake!.y).toBeLessThan(form!.y); + expect(bake!.y).toBeLessThan(schedule!.y); + // ...and wholly inside the first screen, not merely first in the column. + expect(bake!.y + bake!.height).toBeLessThanOrEqual(844); + }); +}); + +// The board and the schedule read the same steps but decide "what is happening" +// separately — the board scans for the running step, the table tags rows. Two +// clocks in one page is exactly the sort of thing that drifts silently, so pin +// that they name the same step. +test('the board names the step the schedule marks as now', async ({ page }) => { + await openRecipe(page, RUNNING); + + const nowRow = card(page, 'Schedule') + .locator('li') + .filter({ has: page.locator('.chip-now') }); + await expect(nowRow).toHaveCount(1); + const title = (await nowRow.locator('h4').innerText()).trim(); + + await expect(board(page).locator('.chip-now')).toHaveText('Now'); + await expect(board(page)).toContainText(title); +}); + +test('with nothing running the board reports the next step, not a blank', async ({ page }) => { + await openRecipe(page, AHEAD); + + await expect(board(page)).toContainText('Next'); + // The countdown is the point of the line — an empty one would still contain + // the word "Next". + await expect(board(page)).toContainText(/in \d/); +}); + +// Depth in this design comes from light: a panel is lighter than the ground and +// casts a soft shadow. That trick does nothing on a dark ground, so every +// raised surface also carries a hairline that is transparent in light and warm +// in dark. Drop the dark half and the panels dissolve into the page — visible +// only to someone who looks at the dark theme, which is the half nobody checks. +for (const theme of ['light', 'dark'] as const) { + test(`a panel is bounded in the ${theme} theme`, async ({ page }) => { + await openRecipe(page, AHEAD); + if (theme === 'dark') { + await page.evaluate(() => document.documentElement.classList.add('dark')); + } + + const style = await card(page, 'Schedule').evaluate((el) => { + const cs = getComputedStyle(el); + return { shadow: cs.boxShadow, border: cs.borderTopColor }; + }); + + expect(style.shadow, 'panels are lifted by a shadow in both themes').not.toBe('none'); + // The border is the dark theme's half of the job. rgba(...) with a + // non-zero alpha, or an opaque rgb(); fully transparent means unbounded. + const alpha = Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(style.border)?.[1] ?? '1'); + if (theme === 'dark') expect(alpha, 'dark panels need a visible edge').toBeGreaterThan(0); + }); +} + +// The neutral chip carries a duration in the schedule and a 50 Top Pizza +// ranking in the table, and it lands on three different grounds: the lifted +// panel, the flat card the collections sit in, and the tomato wash under the +// running step. A fixed fill matched the quiet card closely enough in the dark +// theme that the ranking pills read as plain text — so the chip is a +// translucent wash, which cannot collide with whatever it happens to sit on. +for (const theme of ['light', 'dark'] as const) { + test(`a chip is visible against what it sits on in the ${theme} theme`, async ({ page }) => { + await openRecipe(page, RUNNING); + if (theme === 'dark') { + await page.evaluate(() => document.documentElement.classList.add('dark')); + } + + // The neutral chip only. `.chip-now`, `.chip-action` and `.chip-time` + // carry their own fill and are separated from their ground by hue as much + // as by lightness, which this measurement cannot see. + const chips = card(page, 'Schedule').locator( + '.chip:not(.chip-now):not(.chip-action):not(.chip-time)' + ); + expect(await chips.count()).toBeGreaterThan(0); + + // Polled, not read once: the running step's card transitions its own + // background, so switching theme and measuring in the same tick catches + // the colour it is on its way from rather than the one it lands on. + await expect + .poll(async () => + chips.evaluateAll((els) => + els.map((el) => { + const paint = (n: Element | null): number[] => { + for (let e = n; e; e = e.parentElement) { + const bg = getComputedStyle(e).backgroundColor; + if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { + return (bg.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + } + } + return [255, 255, 255]; + }; + // The chip's fill is translucent, so composite it over its + // ground the way the browser paints it before comparing. + const own = (getComputedStyle(el).backgroundColor.match(/[\d.]+/g) ?? []).map(Number); + const under = paint(el.parentElement); + const alpha = own.length === 4 ? own[3] : 1; + // 8/255 is about where a fill stops reading as a shape at all. + return under.every((u, i) => Math.abs(own[i] * alpha + u * (1 - alpha) - u) <= 8); + }) + ) + ) + // The chip inside the running step is the hard case: that card has a + // wash of its own, so a fill tuned to the plain panel vanishes on it. + .toEqual(Array.from({ length: await chips.count() }, () => false)); + }); +} diff --git a/e2e/cascade.spec.ts b/e2e/cascade.spec.ts index 5a2ad752..84d31759 100644 --- a/e2e/cascade.spec.ts +++ b/e2e/cascade.spec.ts @@ -19,8 +19,8 @@ test('a utility can restyle a heading', async ({ page }) => { return { font: cs.fontFamily.split(',')[0].trim(), tracking: cs.letterSpacing }; }); - // Untouched headings keep the display serif from the base rule... - expect((await face(card(page, 'Schedule').locator('h2'))).font).toBe('ui-serif'); + // Untouched headings keep the display face from the base rule... + expect((await face(card(page, 'Schedule').locator('h2'))).font).toBe('ui-rounded'); // ...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')); @@ -28,6 +28,12 @@ test('a utility can restyle a heading', async ({ page }) => { expect(day.tracking).toBe('1.68px'); }); +// This also guards a second, less obvious way to lose the ring: `outline-color` +// is one of the properties `transition-colors` animates, so a control carrying +// that utility fades its own focus indicator in over 150 ms. The assertion +// reads the outline immediately after focus, which is exactly when a +// transitioned ring is still the colour it was before. +// // The TRMNL uuid field carried `focus:outline-none`. It never took effect — // the unlayered focus rule outranked it — so the field has always shown the // ring. Layering would have handed that class its wish and left the input with diff --git a/e2e/headings.spec.ts b/e2e/headings.spec.ts index d8d7d874..a881b8bc 100644 --- a/e2e/headings.spec.ts +++ b/e2e/headings.spec.ts @@ -60,10 +60,11 @@ test('no heading level is skipped', async ({ page }) => { // 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. +// @layer utilities. Turning the day label into an h3 silently made it the +// display face and dropped its wide tracking; turning the step title into an h4 +// silently dropped the display face it had been inheriting. Both faces are +// pinned here because the markup gives no hint that levels and fonts are +// coupled. test('changing a heading level does not change its typeface', async ({ page }) => { await openRecipe(page, RECIPE); @@ -82,6 +83,6 @@ test('changing a heading level does not change its typeface', async ({ 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'); + // Step titles have always been in the display face. + expect(step).toBe('ui-rounded'); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index dffd639f..73635dac 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -24,9 +24,16 @@ export async function waitForHydration(page: Page) { .not.toBeNull(); } -/** A top-level card, addressed by its heading. */ +/** + * A top-level region, addressed by its heading. Two classes, because the + * regions are no longer all the same box: `.card` is a lifted panel (form, + * schedule, ingredients) and `.card-quiet` is the flat kind the reference + * collections sit in. + */ export function card(page: Page, heading: string) { - return page.locator('.card').filter({ has: page.getByRole('heading', { name: heading }) }); + return page + .locator('.card, .card-quiet') + .filter({ has: page.getByRole('heading', { name: heading }) }); } /** The card holding the form (it has no heading of its own). */ @@ -36,7 +43,10 @@ export function formCard(page: Page) { /** The fermentation-window card. */ export function windowCard(page: Page) { - return page.locator('form div.rounded-2xl').filter({ has: page.locator('input[type="range"]') }); + // Addressed by its own component class rather than by "the rounded div in + // the form": the form now has several rounded surfaces, so that description + // stopped identifying anything in particular. + return page.locator('form .window-card'); } /** The big duration readout, e.g. "40 h". */ @@ -90,7 +100,7 @@ export async function setBakeDate(page: Page, value: string) { export async function thumbCentreX(page: Page): Promise { return slider(page).evaluate((el: HTMLInputElement) => { const r = el.getBoundingClientRect(); - const radius = 10; // half the 1.25rem thumb + const radius = 14; // half the 1.75rem thumb const frac = Number(el.value) / Number(el.max); return r.left + radius + frac * (r.width - 2 * radius); }); diff --git a/e2e/window-slider.spec.ts b/e2e/window-slider.spec.ts index 060ceb6a..73fb932f 100644 --- a/e2e/window-slider.spec.ts +++ b/e2e/window-slider.spec.ts @@ -30,7 +30,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('form .window-card')!; const spans = [...card.querySelectorAll('span')] .filter((s) => /^\d+\s*h$/.test(s.textContent!.trim()) && s.checkVisibility()) .map((s) => { 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..72b50b35 100644 --- a/src/app.css +++ b/src/app.css @@ -2,9 +2,35 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* --------------------------------------------------------------------------- + Impasto: the design system. + + Two ideas hold it together. + + 1. Depth comes from LIGHT, not from lines. A panel is lighter than the + ground it sits on and casts a soft warm shadow; it does not draw a box + around itself. In the dark theme that trick alone would leave surfaces + unbounded (a soft shadow on a dark ground is invisible), so every raised + surface also carries a hairline that is transparent in light and faintly + warm in dark. Both halves of `--kt-lift-*` and `--kt-hairline` are + designed, never derived from one another. + + 2. Colour carries meaning. Tomato is ACTION — a step the baker performs, a + button, a refusal. Basil is TIME — fermentation, the flour's tolerance + band, the cold leg, anything that happens while you are not there. Dough + is the SUBSTRATE — every surface, well and hairline. The palette had been + cream plus one red; basil now has a job, so it earns its place. + --------------------------------------------------------------------------- */ + @theme { --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --font-display: ui-serif, Georgia, serif; + /* Rounded, not serif. The app is a thing you hold at a counter with flour on + your hands, and the numbers are the point — a soft geometric face reads as + an instrument rather than a document. Falls back through the widest + rounded stack the platforms offer before landing on the UI sans. */ + --font-display: + ui-rounded, 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Nunito, 'Varela Round', + 'Segoe UI Variable Display', system-ui, sans-serif; --color-dough-50: #fdf8ef; --color-dough-100: #f8ecd0; @@ -38,39 +64,121 @@ --color-basil-700: #2c5722; --color-basil-800: #20401a; --color-basil-900: #142810; + + /* Semantic surfaces and ink, as utilities. Each resolves to a --kt-* custom + property that :root and html.dark both define — and since the theme class + sits on , which IS :root, one declaration serves both themes. This + is what lets `bg-surface text-ink-soft` be written once instead of as a + pair with a dark: twin, which is how the community tables ended up at + 2.71:1 in the dark theme without anyone seeing it. */ + --color-ground: var(--kt-ground); + --color-surface: var(--kt-surface); + --color-raised: var(--kt-raised); + --color-sunk: var(--kt-sunk); + --color-hairline: var(--kt-hairline); + --color-ink: var(--kt-ink); + --color-ink-soft: var(--kt-ink-soft); + --color-ink-faint: var(--kt-ink-faint); + --color-accent: var(--kt-accent); + --color-leaf: var(--kt-leaf); + + /* The type scale. Two sizes did the work of a system before; these are the + steps the app actually needs, largest first, roughly a major third apart + at the top and settling to a fourth by the time it reaches body copy. */ + --text-figure: clamp(2.5rem, 11vw, 3.5rem); + --text-stat: clamp(1.5rem, 6vw, 1.875rem); + --text-title: 1.375rem; + --text-subtitle: 1.0625rem; } -/* 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 { + :root { + /* Warm off-white ground; panels sit LIGHTER than it, so they lift. */ + --kt-ground: #f6efe4; + --kt-surface: #fffdfa; + --kt-raised: #ffffff; + --kt-sunk: #f0e7d9; + --kt-hairline: rgba(93, 59, 22, 0.1); + --kt-ink: #291c11; + /* Both are held above 4.5:1 against the SUNK surface, not just against + the panel: the wells are the darkest ground either of them ever lands + on, and #7d6857 sat at 4.29 there — caught by the axe suite. */ + --kt-ink-soft: #5c4838; + --kt-ink-faint: #75604f; + --kt-accent: var(--color-tomato-600); + --kt-leaf: var(--color-basil-700); + /* A wash of dough over the panel — the one tint the system uses to say + "this surface matters more than the ones around it". */ + --kt-tint: #fdf6e7; + /* The one step that is happening right now. Tomato, not dough: a dough + wash would be the same beige as the ingredient wells inside the other + steps, and "now" has to be unmistakable across a nine-step feed. */ + --kt-now: #fdeee8; + /* A chip is a translucent wash, not a fixed colour: it sits on the panel, + on the quiet card, and inside the tomato-washed "now" step, and a fixed + fill matched one of those grounds closely enough to disappear on it — + the ranking pills in the 50 Top Pizza table vanished in dark. */ + --kt-chip: rgba(93, 59, 22, 0.09); + + --kt-lift-1: 0 1px 2px rgba(74, 45, 18, 0.05), 0 4px 14px -6px rgba(74, 45, 18, 0.12); + --kt-lift-2: + 0 1px 2px rgba(74, 45, 18, 0.05), 0 10px 24px -12px rgba(74, 45, 18, 0.22), + 0 28px 56px -32px rgba(74, 45, 18, 0.24); + --kt-press: inset 0 1px 3px rgba(74, 45, 18, 0.13), inset 0 -1px 0 rgba(255, 255, 255, 0.6); + /* Transparent in light: the shadow already bounds the panel. */ + --kt-edge: transparent; + } + + html.dark { + /* Warm dusk, not black. Every value keeps a red-brown cast so the dark + theme reads as the same room after sundown. */ + --kt-ground: #15100c; + --kt-surface: #211a15; + --kt-raised: #291f19; + --kt-sunk: #191310; + --kt-hairline: rgba(255, 238, 214, 0.11); + --kt-ink: #f3e8dc; + --kt-ink-soft: #c8b6a4; + --kt-ink-faint: #a89684; + --kt-accent: var(--color-tomato-300); + --kt-leaf: var(--color-basil-300); + --kt-tint: #2b2119; + --kt-now: #33211a; + --kt-chip: rgba(255, 238, 214, 0.1); + + --kt-lift-1: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 18px -10px rgba(0, 0, 0, 0.6); + --kt-lift-2: 0 2px 4px rgba(0, 0, 0, 0.45), 0 18px 40px -20px rgba(0, 0, 0, 0.7); + --kt-press: inset 0 1px 3px rgba(0, 0, 0, 0.5), inset 0 -1px 0 rgba(255, 238, 214, 0.04); + /* Visible here, and load-bearing: a soft shadow on a dark ground does + nothing, so without this every panel would bleed into the page. */ + --kt-edge: rgba(255, 238, 214, 0.09); + } + 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); + color: var(--kt-ink); } html.dark { - background: radial-gradient(ellipse at top, #2a1f17 0%, #15110d 60%) fixed; color-scheme: dark; } + /* 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. */ h1, h2, h3, .font-display { font-family: var(--font-display); - letter-spacing: -0.01em; + letter-spacing: -0.015em; } input[type='number'] { @@ -95,28 +203,173 @@ } @layer components { + /* ---- surfaces -------------------------------------------------------- */ + + /* The primary regions — form, schedule, ingredients. Lifted off the ground + by light. e2e/helpers.ts addresses regions through this class, so it is + closer to an API than a style; renaming it means grepping e2e/ too. */ .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; + background: var(--kt-surface); + border: 1px solid var(--kt-edge); + border-radius: 1.5rem; + box-shadow: var(--kt-lift-2); + @apply p-5 sm:p-7; + } + + /* The reference collections — My recipes, Community, 50 Top Pizza. They are + a detour from the calculate-my-dough flow, so they sit ON the ground + rather than above it: no lift, a hairline only. Card soup was six identical + boxes; this is the half of the fix that says "these three matter less". */ + .card-quiet { + background: color-mix(in oklab, var(--kt-surface) 55%, transparent); + border: 1px solid var(--kt-hairline); + border-radius: 1.25rem; + @apply p-5 sm:p-6; + } + + /* The fermentation window is the one decision that changes everything, so it + gets its own surface inside the form: raised above the fields around it, + with the slider's groove cut into it. e2e/helpers.ts finds the card by + this class — it used to be "the div in the form that happens to be + rounded-2xl", which no longer identifies anything once the form has more + than one rounded surface in it. */ + .window-card { + /* Tinted, not just raised: sitting on the form's near-white panel, a + white slab with a shadow reads as nothing at all. A wash of dough + colour is what separates it from the fields above and below. */ + background: var(--kt-tint); + border: 1px solid var(--kt-hairline); + box-shadow: var(--kt-lift-1); + @apply p-4 sm:p-5; } + /* An inset surface: the groove the slider runs in, the ingredient lists + inside a step, the info panel. Pressed into the panel, not stacked on it. */ + .well { + background: var(--kt-sunk); + border-radius: 1rem; + box-shadow: var(--kt-press); + } + + /* ---- the numeric voice ----------------------------------------------- */ + + /* The one big number on a surface: the bake time, the chosen window. Tabular + so a ticking value does not shuffle its own digits sideways. */ + .figure { + font-family: var(--font-display); + font-size: var(--text-figure); + font-weight: 600; + line-height: 0.95; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + color: var(--kt-ink); + } + + /* A stat tile: value over label, repeated across the bake board and anywhere + else a single number has to be read at a glance. */ + .stat-value { + font-family: var(--font-display); + font-size: var(--text-stat); + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + color: var(--kt-ink); + } + + .stat-label { + @apply mt-0.5 block text-xs leading-tight; + color: var(--kt-ink-faint); + } + + /* ---- small parts ------------------------------------------------------ */ + + /* One rounded token, three jobs: a duration, a mode, a state. Neutral by + default; the two modifiers below carry the tomato/basil meaning. */ + .chip { + @apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold whitespace-nowrap; + background: var(--kt-chip); + color: var(--kt-ink-soft); + } + + .chip-action { + @apply bg-tomato-100 text-tomato-800 dark:bg-tomato-900/50 dark:text-tomato-200; + } + + .chip-time { + @apply bg-basil-100 text-basil-800 dark:bg-basil-900/50 dark:text-basil-200; + } + + /* "Now" — the only chip that is a solid fill, because it marks exactly one + row in the whole schedule. */ + .chip-now { + @apply bg-tomato-500 px-2 text-[0.6875rem] tracking-wide text-white; + } + + /* Every transition below names its properties instead of using + `transition-colors`, which includes `outline-color` — a control carrying + it animates its own focus ring into existence, leaving 150 ms of no + visible focus after a Tab. e2e/cascade.spec.ts is what catches it. */ .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-4 py-2 text-sm font-semibold text-white shadow-sm disabled:opacity-50; + transition: background-color 150ms ease; } .btn-tomato-sm { @apply bg-tomato-500 hover:bg-tomato-600 rounded-full px-3 py-1 text-xs font-semibold text-white; + transition: background-color 150ms ease; } .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; + @apply block w-full px-4 py-2.5 text-left text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50; + color: var(--kt-ink-soft); + transition: + background-color 150ms ease, + color 150ms ease; + } + + .menu-item:hover:not(:disabled) { + background: var(--kt-sunk); + color: var(--kt-ink); } .text-accent { - @apply text-tomato-700 dark:text-tomato-300; + color: var(--kt-accent); } .row-divider { - @apply border-dough-200/70 border-b last:border-0 dark:border-stone-700/70; + border-bottom: 1px solid var(--kt-hairline); + } + + .row-divider:last-child { + border-bottom: 0; + } + + /* Every field's name. Ten places wrote this list out by hand. */ + .field-label { + @apply block text-sm font-medium; + color: var(--kt-ink-soft); + } + + /* The name of a group of fields — "When", "Recipe". Quieter than a region + title but still in the display face, so the form reads as chapters. */ + .fieldset-legend { + font-family: var(--font-display); + @apply text-accent text-[1.0625rem] font-semibold; + } + + /* A region's name — schedule, ingredients, the recipe collections. */ + .section-title { + font-family: var(--font-display); + @apply text-[1.375rem] leading-tight font-semibold; + color: var(--kt-ink); + } + + /* The quiet line above or beside a figure. Sentence case on purpose: this + app has no tracked-out capitals anywhere. */ + .eyebrow { + @apply text-xs font-medium; + color: var(--kt-ink-faint); } /* A boxed message under the control that caused it. Danger is the same red @@ -124,39 +377,59 @@ info is the dough colour, for a consequence worth reporting rather than a refusal. Four components wrote these two class lists out by hand. */ .notice { - @apply rounded-lg border px-3 py-2 text-sm; + @apply rounded-xl border px-3 py-2 text-sm; } .notice-danger { - @apply border-tomato-300 bg-tomato-50 text-tomato-800 dark:border-tomato-700 dark:bg-tomato-900/40 dark:text-tomato-200; + @apply border-tomato-300 bg-tomato-50 text-tomato-800 dark:border-tomato-700/70 dark:bg-tomato-900/40 dark:text-tomato-200; } .notice-info { - @apply border-dough-300 bg-dough-100 text-dough-900 dark:border-dough-700 dark:bg-dough-900/40 dark:text-dough-100; + @apply border-dough-300 bg-dough-100 text-dough-900 dark:border-dough-700/70 dark:bg-dough-900/40 dark:text-dough-100; } /* The rounded switch strip: language, theme, schedule verbosity. Rendered by - SegmentedControl.svelte, which owns the markup as well as the look. */ + SegmentedControl.svelte, which owns the markup as well as the look. It is + a well with buttons in it — the selected one lifts out of the groove. */ .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 m-0 inline-flex overflow-hidden rounded-full p-0.5 text-xs font-semibold; + background: var(--kt-sunk); + box-shadow: var(--kt-press); } .pill { - @apply inline-flex items-center justify-center px-3 py-1.5 transition-colors; + @apply inline-flex items-center justify-center rounded-full px-3 py-1.5; + transition: + background-color 150ms ease, + color 150ms ease; } .pill-on { - @apply bg-tomato-500 text-white; + @apply bg-tomato-500 text-white shadow-sm; } .pill-off { - @apply hover:bg-dough-100 text-stone-700 dark:text-stone-200 dark:hover:bg-stone-700; + color: var(--kt-ink-soft); } - /* Every box the form types into. Size and width stay with the caller — the - date pair splits a row, the rest fill it. */ + .pill-off:hover { + color: var(--kt-ink); + background: color-mix(in oklab, var(--kt-surface) 70%, transparent); + } + + /* Every box the form types into: a well you write in, not a bordered box. + Size and width stay with the caller — the date pair splits a row. */ .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 rounded-xl border px-3 py-2; + background: var(--kt-raised); + border-color: var(--kt-hairline); + color: var(--kt-ink); + box-shadow: var(--kt-lift-1); + transition: border-color 150ms ease; + } + + .input:focus { + border-color: var(--color-tomato-500); } /* A link in running text — the footer, the setup hint in the TRMNL dialog. @@ -179,11 +452,54 @@ /* 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 rounded-3xl border p-0 text-sm backdrop:bg-stone-950/50; + background: var(--kt-surface); + border-color: var(--kt-edge); + color: var(--kt-ink-soft); + box-shadow: var(--kt-lift-2); + } + + /* The two floating panels — the actions menu and the fit-score breakdown. + They sit over content, so they need the highest lift and an opaque fill + in both themes; the two had written the same six classes out separately. */ + .popover-panel { + @apply overflow-hidden rounded-2xl border; + background: var(--kt-raised); + border-color: var(--kt-hairline); + box-shadow: var(--kt-lift-2); + color: var(--kt-ink-soft); } /* 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; + @apply rounded-full px-3 py-2 text-xs; + color: var(--kt-ink-faint); + transition: color 150ms ease; + } + + .btn-quiet:hover { + color: var(--kt-ink); + } + + /* The disclosure marker every collapsible section uses. A drawn caret rather + than the ▶ glyph, which fell back to a different font — and so a different + size and baseline — depending on the platform. */ + .disclosure-caret { + @apply mt-1.5 size-4 shrink-0 transition-transform duration-200; + color: var(--kt-accent); + } + + details[open] > summary .disclosure-caret { + transform: rotate(90deg); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/src/app.html b/src/app.html index 09b89727..4b2d7166 100644 --- a/src/app.html +++ b/src/app.html @@ -27,10 +27,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/lib/components/ActionsMenu.svelte b/src/lib/components/ActionsMenu.svelte index 6eb8994f..874bb604 100644 --- a/src/lib/components/ActionsMenu.svelte +++ b/src/lib/components/ActionsMenu.svelte @@ -89,10 +89,7 @@ {t.actions.menu} -