diff --git a/.github/test-baseline.json b/.github/test-baseline.json index 4f8f413a..41148e8d 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": 959, + "e2e": 95 } diff --git a/CLAUDE.md b/CLAUDE.md index 48ec71af..e3f18ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 vertical timeline** (`ScheduleTable.svelte`): consecutive steps fall under one date header, a rail threads the nodes (filled ember = baker-action step, hollow = waiting phase, hollow blue = a fridge phase, dashed rail segment leaving a fermentation step, and the rail itself turns blue leaving the fridge), and the in-progress step gets a glowing node + `Now` chip. The band above the page carries the same "now" — see Design. **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,22 @@ 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. - -- **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. +**Notte** (v6.11): the app's subject is elapsed time in the dark — a fridge at 4 °C for a day and a half while you sleep — so it is drawn as an instrument for that night rather than as a form with a table beside it. Dark is the theme that was designed first; daylight is its counterpart, not an inversion. Must read on a phone on the counter at 390 px. + +- **The answer comes before the questions.** `NowBand.svelte` is a full-width band directly under the header, above the two-column grid, in every layout: the state word (`Now` / `Next` / `Done`), the step it applies to, one large countdown, and the whole bake drawn to scale. It is the loudest thing on the page because "what should I be doing and how long is left" is the one thing this app knows that nothing else does — it used to be a 12 px pulsing dot inside a list that sat two screens down on a phone. **The band is a night face in both themes**: it redeclares the whole `--kt-*` surface set for its own subtree, so on a page of warm daylight paper it stays a lit dark panel. Nothing in the component knows about that — every class inside reads the tokens. +- **The overview bar and the step list must agree.** `src/lib/timeline.ts` (pure, 100 % covered, deliberately **not** in `src/lib/dough/` — it is geometry, so it is outside the "Get nerdy" contract) turns the finished step list into one proportional axis: a segment per step running to the **next step's start** rather than to its own stated duration, so the bar can never show a gap the list does not have; the `[22:00, 08:00)` stretches the night-window guard already reasons about, washed over the top; and a playhead at now. `e2e/now-band.spec.ts` pins that the band names the same step the list marks `Now` — the two decide it by different rules and could drift apart at a boundary. + - Segments are **not interactive and never will be**: at an 80 h window a 15-minute step is under two pixels on a phone. They carry a `min-width` floor so the hands-on steps stay visible as marks, and the detailed list below stays the thing you read and click. The bar is `role="img"` with one label. + - The playhead is drawn as a **sibling** of the bar, not a child — the bar clips its contents so the segments keep its corners, and the playhead's cap overhangs. It is ink-coloured, not ember: ember over an ember segment is a line you cannot find. +- **Three ramps, and the third one is reserved.** `notte` is the warm neutral (espresso, never slate) and carries every surface and every piece of text in both themes. `ember` is heat: room temperature, the active step, every filled control. **`frost` is cold and nothing else** — a blue thing on this page is in the fridge. That is what makes temperature legible as colour on the overview bar, on the window rail's two tolerance zones (`.zone-room` / `.zone-cold`, same two colours for the same two meanings) and on the schedule rail, which turns blue leaving a fridge step. Do not spend blue on a link, a success state or an info notice. +- **Surfaces and ink are named once, as `--kt-*` custom properties, and flipped as a set on `.dark`.** Component classes read those names and carry **no `dark:` pairs of their own**. Written out inline, the old palette had drifted into eleven text shades, half of which differed by a step nobody had chosen. Three ink roles — `.ink`, `.ink-soft`, `.ink-faint` — plus `.text-accent` and `.text-cold`, and every component picks one. + - Two token values exist only because axe caught them: `--kt-accent-solid` is a **deeper** ember in daylight under warm white and a **brighter** one at night under near-black, because the ramp's mid-ember carried small white text at 4.09:1; and the focus ring reads `--kt-accent`, which flips, because one fixed ember sat at about 2:1 against the daylight panel. `e2e/now-band.spec.ts` pins that the ring still changes with the theme — axe checks text contrast, not focus indicators. +- **Glow is emissive, so it is spent on exactly two things**: the current step's node in the list and the playhead. Everything at rest is quiet, which is what makes them findable across a kitchen. There is no page-load animation and no hover transition on a card. `.pill` deliberately has **no colour transition** — a 150 ms fade also animates the theme flip, which left axe measuring a selected pill halfway between two palettes. +- **Numbers are set as data.** `--font-display` is a condensed grotesque (Avenir Next Condensed / Bahnschrift / Roboto Condensed — every platform in the matrix ships one, so nothing is downloaded and the app still works with no network) and `.num` adds lining tabular figures and a little tracking. Times, durations, weights and temperatures wear it; prose does not. `.gauge` is a value stated beside its label — a step duration, the source-timing note — and reads as a reading rather than a badge. +- **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` for the collapsed recipe collections, which sit on the page rather than on a panel of their own), `.panel-heading`, `.now-band`, `.night-bar`/`.night-seg{,-warm,-cold}`/`.night-wash`/`.night-wash-swatch`/`.playhead`, `.zone-{room,cold}`, `.window-card`, `.state-chip{,-live,-idle}`, `.gauge`, `.num`, `.ink{,-soft,-faint}`, `.text-{accent,cold}`, `.btn-ember{,-sm}`, `.btn-quiet`, `.menu-item`, `.notice` + `.notice-{danger,info}`, `.popover-panel`, `.pill-group`/`.pill`/`.pill-{on,off}`, `.input`, `.field-label`, `.link-quiet`, `.link-action`, `.dialog-panel`, `.row-divider`. Hand-copied lists drift silently. One-off styling stays inline; the rule is about the second occurrence, not the first. +- **One dismissal rule for every popover** — `dismissOnOutsideClickOrEscape` in `src/lib/components/dismiss.svelte.ts`, used by the actions menu and the fit-score panel (both wear `.popover-panel`, the only raised surface in the app and therefore the only one with a shadow). 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 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 — `windowCard()` finds `form .window-card`, `card()` finds `.card`, and `e2e/now-band.spec.ts` reaches for `.now-band`, `.night-bar`, `.night-seg`, `.night-wash-swatch` and `.playhead`. `cascade.spec.ts` and `headings.spec.ts` pin the two typefaces and the focus-ring colour by their computed values. Renaming any of them means grepping `e2e/` too. +- **The print route keeps its own stylesheet** and is untouched by all of this except the display face, which it has always inherited from `app.css`'s `h1, h2, h3` base rule. It must still be B&W and fit one page — the 9-step biga + poolish cold plan is the worst case. ## Git workflow diff --git a/README.md b/README.md index 62b5328d..ad3d2998 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ src/ │ ├── 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 +│ ├── timeline.ts ← geometry for the overview bar: segments, night bands, where "now" is │ ├── 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) @@ -82,7 +83,8 @@ 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: the Notte palette, the --kt-* surface tokens +│ (flipped as a set on .dark) and the whole @layer components layer └── app.html ← shell e2e/ ← Playwright browser tests (the parts vitest cannot reach) @@ -127,7 +129,7 @@ Husky + lint-staged are configured (`.husky/pre-commit`). The hook runs lint-sta 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`). 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`. +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`. The suite builds and previews on port 4173; set `E2E_PORT` to move it when something else already has that port. ### Print / PDF export diff --git a/docs/redesign/notte/after-band-bigapoolish.png b/docs/redesign/notte/after-band-bigapoolish.png new file mode 100644 index 00000000..f07fa4c9 Binary files /dev/null and b/docs/redesign/notte/after-band-bigapoolish.png differ diff --git a/docs/redesign/notte/after-desktop-dark.png b/docs/redesign/notte/after-desktop-dark.png new file mode 100644 index 00000000..0b40de49 Binary files /dev/null and b/docs/redesign/notte/after-desktop-dark.png differ diff --git a/docs/redesign/notte/after-desktop-light.png b/docs/redesign/notte/after-desktop-light.png new file mode 100644 index 00000000..7695cfeb Binary files /dev/null and b/docs/redesign/notte/after-desktop-light.png differ diff --git a/docs/redesign/notte/after-phone-light.png b/docs/redesign/notte/after-phone-light.png new file mode 100644 index 00000000..757bbb92 Binary files /dev/null and b/docs/redesign/notte/after-phone-light.png differ diff --git a/docs/redesign/notte/before-desktop-dark.png b/docs/redesign/notte/before-desktop-dark.png new file mode 100644 index 00000000..eb025d80 Binary files /dev/null and b/docs/redesign/notte/before-desktop-dark.png differ diff --git a/docs/redesign/notte/before-desktop-light.png b/docs/redesign/notte/before-desktop-light.png new file mode 100644 index 00000000..502b61c7 Binary files /dev/null and b/docs/redesign/notte/before-desktop-light.png differ diff --git a/docs/redesign/notte/before-phone-light.png b/docs/redesign/notte/before-phone-light.png new file mode 100644 index 00000000..df663dec Binary files /dev/null and b/docs/redesign/notte/before-phone-light.png differ diff --git a/e2e/cascade.spec.ts b/e2e/cascade.spec.ts index 5a2ad752..b04e927d 100644 --- a/e2e/cascade.spec.ts +++ b/e2e/cascade.spec.ts @@ -19,13 +19,13 @@ 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('"Avenir Next Condensed"'); // ...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'); + expect(day.tracking).toBe('0.96px'); }); // The TRMNL uuid field carried `focus:outline-none`. It never took effect — @@ -42,8 +42,8 @@ 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(page.locator('form input[type="number"]'))).toBe('rgb(158, 61, 18) solid 2px'); + expect(await ring(page.locator('form select'))).toBe('rgb(158, 61, 18) solid 2px'); // The trigger is a ; Playwright does not expose it as a button. await page.locator('summary').filter({ hasText: 'Actions' }).click(); @@ -52,5 +52,5 @@ test('every control keeps the focus ring, including the TRMNL uuid field', async // in the page now, and "the input inside a dialog" stopped being unique. const uuid = page.getByRole('textbox', { name: 'Plugin UUID' }); await expect(uuid).toBeVisible(); - expect(await ring(uuid)).toBe('rgb(200, 64, 26) solid 2px'); + expect(await ring(uuid)).toBe('rgb(158, 61, 18) solid 2px'); }); diff --git a/e2e/focus-dismissal.spec.ts b/e2e/focus-dismissal.spec.ts index e15190e8..e6e0fe4a 100644 --- a/e2e/focus-dismissal.spec.ts +++ b/e2e/focus-dismissal.spec.ts @@ -4,7 +4,7 @@ import { card, openRecipe } from './helpers'; const RECIPE = 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z'; -const RING = 'rgb(200, 64, 26) solid 2px'; +const RING = 'rgb(158, 61, 18) solid 2px'; // The global focus rule listed input, select, button and textarea. Both of the // element types it left out are used here as primary controls, not as prose: diff --git a/e2e/headings.spec.ts b/e2e/headings.spec.ts index d8d7d874..eb0f2009 100644 --- a/e2e/headings.spec.ts +++ b/e2e/headings.spec.ts @@ -27,6 +27,9 @@ test('every card is reachable by heading, and the steps sit under their day', as // The input card is named, even though its heading is visually hidden. expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([ + // The now band is a region of its own, named but not shown: its state chip + // and step title already say what it is on screen. + 'Where you are in the bake', 'Your recipe', 'Schedule', 'Ingredients', @@ -58,11 +61,11 @@ test('no heading level is skipped', async ({ page }) => { } }); -// app.css styles `h1, h2, h3, .font-display` from OUTSIDE any cascade layer, so +// app.css styles `h1, h2, h3, .font-display` in @layer base, 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 +// utilities. Turning the day label into an h3 silently made it the display face and +// dropped its 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 the levels and the fonts are coupled. test('changing a heading level does not change its typeface', async ({ page }) => { await openRecipe(page, RECIPE); @@ -76,12 +79,12 @@ test('changing a heading level does not change its typeface', async ({ page }) = }); // 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'); + expect(day.tracking).toBe('0.96px'); 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'); + // Step titles have always been the display face. + expect(step).toBe('"Avenir Next Condensed"'); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index dffd639f..1f196bee 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -34,9 +34,11 @@ export function formCard(page: Page) { return page.locator('.card').filter({ has: page.locator('input[type="range"]') }); } -/** The fermentation-window card. */ +/** The fermentation-window card. `.window-card` is a name in app.css rather + * than the `rounded-2xl` it used to share with every other panel: the redesign + * gave the panels different radii, and a shape is not a handle. */ export function windowCard(page: Page) { - return page.locator('form div.rounded-2xl').filter({ has: page.locator('input[type="range"]') }); + return page.locator('form .window-card'); } /** The big duration readout, e.g. "40 h". */ diff --git a/e2e/now-band.spec.ts b/e2e/now-band.spec.ts new file mode 100644 index 00000000..346b3a2f --- /dev/null +++ b/e2e/now-band.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from '@playwright/test'; +import { openRecipe } from './helpers'; + +// The now band is the page's answer: what you should be doing and how long is +// left. Its geometry — a proportional bar, a wash over the night hours, a +// playhead at now — is all paint, so none of it is reachable from a unit test. +// timeline.ts covers the arithmetic; everything here is about what reaches the +// screen. + +// A cold plan that is already under way at the suite's fixed clock +// (2026-09-01T09:00Z), so a step really is running. +const RUNNING = + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&sa=2026-09-01T06%3A00%3A00.000Z&r=2026-09-02T18%3A00%3A00.000Z'; + +function band(page: Page) { + return page.locator('.now-band'); +} + +// The whole point of an overview is that it agrees with the detail. Two +// components decide "which step is now" from the same step list by different +// rules — nowState() partitions the axis, the table compares against each +// step's own duration — so they can disagree at a boundary without either one +// looking wrong on its own. +test('the band names the same step the list marks as now', async ({ page }) => { + await openRecipe(page, RUNNING); + + const inBand = await band(page).locator('h3').innerText(); + const inList = await page + .locator('main ol li') + .filter({ has: page.locator('.state-chip-live') }) + .locator('h4') + .innerText(); + + expect(inList).not.toBe(''); + expect(inBand.trim()).toBe(inList.trim()); +}); + +// The playhead was invisible twice over: it was drawn in the same ember as the +// phase beneath it, and it lived inside the bar, which clips its own contents +// so the segments keep the rounded corners. Both faults are only visible in a +// rendered page — the markup looked correct. +test('the playhead reads against the phase it sits on, and is not clipped away', async ({ + page +}) => { + await openRecipe(page, RUNNING); + + const head = band(page).locator('.playhead'); + await expect(head).toBeVisible(); + + const bar = (await band(page).locator('.night-bar').boundingBox())!; + const marker = (await head.boundingBox())!; + // It overhangs the bar top and bottom, which is what proves it is outside + // the clip rather than merely happening to be painted. + expect(marker.height).toBeGreaterThan(bar.height); + + const colour = (loc: ReturnType) => + loc.first().evaluate((el) => getComputedStyle(el).backgroundColor); + expect(await colour(head)).not.toBe(await colour(band(page).locator('.night-seg-warm'))); + expect(await colour(head)).not.toBe(await colour(band(page).locator('.night-seg-cold'))); +}); + +// The night stretches are a transparency laid over whatever phase runs through +// them. Its legend swatch was that same transparency on the panel, which paints +// as nothing at all: the key said "Night" beside an empty square. +test('the night key shows a colour, not a hole', async ({ page }) => { + await openRecipe(page, RUNNING); + + const swatch = band(page).locator('.night-wash-swatch'); + const room = band(page).locator('li .night-seg-warm'); + + const colour = (loc: ReturnType) => + loc.first().evaluate((el) => { + const cs = getComputedStyle(el); + return { bg: cs.backgroundColor, image: cs.backgroundImage }; + }); + + const night = await colour(swatch); + expect(night.bg).not.toBe('rgba(0, 0, 0, 0)'); + // ...and it is not simply the room colour again: the wash is on top of it. + expect(night.image).not.toBe('none'); + expect(night.image).not.toBe((await colour(room)).image); +}); + +// A 15-minute step inside an 80-hour window is 0.3 % of the bar — under two +// pixels on a phone, which is nothing at all. The bar keeps a floor so the +// hands-on steps at either end stay visible as marks. +test.describe('phone', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('the shortest step is still a mark on the bar at the longest window', async ({ page }) => { + // 80 h, the rail's own ceiling, so the fixed steps are as small as the + // app can ever make them. + await openRecipe( + page, + 'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=310&sa=2026-08-29T09%3A00%3A00.000Z&r=2026-09-01T17%3A00%3A00.000Z' + ); + + const widths = await band(page) + .locator('.night-seg') + .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().width)); + expect(widths.length).toBeGreaterThan(3); + for (const w of widths) expect(w).toBeGreaterThanOrEqual(2); + }); +}); + +// A focus ring has to clear 3:1 against what it is drawn on, and one fixed +// ember cannot do that in both themes — against the daylight panel it sat at +// about 2:1. The ring now reads a token that flips with the theme, and nothing +// else in the suite would notice if it went back to being a constant. +test('the focus ring changes colour with the theme', async ({ page }) => { + await openRecipe(page, RUNNING); + + const ring = () => + page + .locator('form input[type="number"]') + .first() + .evaluate((el: HTMLElement) => { + el.focus(); + return getComputedStyle(el).outlineColor; + }); + + const light = await ring(); + await page.evaluate(() => document.documentElement.classList.add('dark')); + const dark = await ring(); + + expect(light).not.toBe(dark); +}); diff --git a/e2e/window-slider.spec.ts b/e2e/window-slider.spec.ts index 060ceb6a..f4d052ed 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) => { @@ -253,7 +253,7 @@ test('the band caption carries a swatch in the band colour', async ({ page }) => await expect(swatch).toHaveCount(1); const colour = await swatch.evaluate((el) => getComputedStyle(el).backgroundColor); const rail = await windowCard(page) - .locator('div.bg-basil-400, div.bg-basil-300') + .locator('div.zone-cold, div.zone-room') .last() .evaluate((el) => getComputedStyle(el).backgroundColor); expect(colour).toBe(rail); 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..c245c121 100644 --- a/src/app.css +++ b/src/app.css @@ -2,75 +2,165 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* --------------------------------------------------------------------------- + Notte. + + The app's subject is elapsed time in the dark — a fridge at 4 °C for a day + and a half while you sleep — so the dark theme is the one that was designed + first and the light one is its daylight counterpart, not an inversion. + + Three ramps, and only three: + - `notte` is the warm neutral, espresso rather than slate. It carries every + surface and every piece of text in both themes. + - `ember` is heat: room temperature, the active step, every primary control. + - `frost` is cold, and NOTHING else. A blue thing on this page is in the + fridge. Reserving it is what makes temperature legible as colour, so don't + spend it on a link or an info state. + + Surfaces and ink are named once as custom properties below and flipped as a + set on `.dark`. Component classes read those names; they do not carry + `dark:` pairs of their own. Four shades of "nearly the card colour" is how + the previous palette drifted apart. + --------------------------------------------------------------------------- */ + @theme { --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --font-display: ui-serif, Georgia, serif; - - --color-dough-50: #fdf8ef; - --color-dough-100: #f8ecd0; - --color-dough-200: #f0d8a1; - --color-dough-300: #e6bd6e; - --color-dough-400: #dca246; - --color-dough-500: #c98828; - --color-dough-600: #a86d20; - --color-dough-700: #82531c; - --color-dough-800: #5d3b16; - --color-dough-900: #3d270f; - - --color-tomato-50: #fdf2ee; - --color-tomato-100: #fbded2; - --color-tomato-200: #f7b9a4; - --color-tomato-300: #f08a6b; - --color-tomato-400: #e35f3a; - --color-tomato-500: #c8401a; - --color-tomato-600: #a73213; - --color-tomato-700: #842712; - --color-tomato-800: #5d1a0c; - --color-tomato-900: #3a0f07; - - --color-basil-50: #f0f7ed; - --color-basil-100: #dceed1; - --color-basil-200: #b8dca3; - --color-basil-300: #8cc472; - --color-basil-400: #65a94d; - --color-basil-500: #4a8d36; - --color-basil-600: #387029; - --color-basil-700: #2c5722; - --color-basil-800: #20401a; - --color-basil-900: #142810; + /* A condensed grotesque, for the same reason a station board and an oven + timer use one: it holds a long time and a long duration in a narrow column + without shrinking, and its figures line up. Every platform in the support + matrix ships one — macOS/iOS Avenir Next Condensed, Windows Bahnschrift, + Android Roboto Condensed — so nothing is downloaded and the app still + works with the oven door open and no network. */ + --font-display: + 'Avenir Next Condensed', 'Bahnschrift', 'Roboto Condensed', 'Helvetica Neue', ui-sans-serif, + sans-serif; + + --color-notte-50: #fdfaf5; + --color-notte-100: #f4ece0; + --color-notte-200: #e6d9c7; + --color-notte-300: #cdbca8; + --color-notte-400: #a9947e; + --color-notte-500: #6f5a47; + --color-notte-600: #5c4b3a; + --color-notte-700: #3d2e23; + --color-notte-800: #291d16; + --color-notte-900: #1a1310; + --color-notte-950: #100c09; + + --color-ember-50: #fff4e8; + --color-ember-100: #ffe3c4; + --color-ember-200: #ffc68c; + --color-ember-300: #ffa95a; + --color-ember-400: #ff8c33; + --color-ember-500: #f2762a; + --color-ember-600: #ce551a; + --color-ember-700: #9e3d12; + --color-ember-800: #72290c; + --color-ember-900: #461906; + + --color-frost-50: #eef7fc; + --color-frost-100: #d5ebf7; + --color-frost-200: #add7ee; + --color-frost-300: #85c1e3; + --color-frost-400: #5aa6d2; + --color-frost-500: #3287ba; + --color-frost-600: #246b98; + --color-frost-700: #1b5074; + --color-frost-800: #133850; + --color-frost-900: #0c2331; } -/* 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 { - 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; + /* Daylight: paper panels lifted off a warmer work surface. The ground is + deeper than the panels, which is the opposite of the old theme and is what + stops a page of boxes reading as one flat sheet. */ + :root { + --kt-ground: var(--color-notte-100); + --kt-ground-2: #ece0cf; + --kt-panel: var(--color-notte-50); + --kt-raised: #fffdfa; + --kt-inset: #f0e7d9; + --kt-line: var(--color-notte-200); + --kt-line-strong: var(--color-notte-300); + + --kt-ink: #241a13; + --kt-ink-soft: var(--color-notte-600); + --kt-ink-faint: var(--color-notte-500); + + --kt-accent: var(--color-ember-700); + --kt-accent-hover: var(--color-ember-800); + --kt-accent-solid: #ab400f; + --kt-accent-solid-hover: var(--color-ember-800); + --kt-on-accent: #fffaf4; + --kt-cold: var(--color-frost-700); + + --kt-warm-band: var(--color-ember-300); + --kt-cold-band: var(--color-frost-400); + --kt-night-wash: rgb(38 28 52 / 0.2); + + /* Glow is an emissive effect and there is nothing emissive about daylight, + so in this theme the "glow" is a plain ring. */ + --kt-glow: 0 0 0 3px rgb(206 85 26 / 0.22); + --kt-glow-soft: none; } - html.dark { - background: radial-gradient(ellipse at top, #2a1f17 0%, #15110d 60%) fixed; + .dark { + --kt-ground: var(--color-notte-950); + --kt-ground-2: #080605; + --kt-panel: var(--color-notte-900); + --kt-raised: var(--color-notte-800); + --kt-inset: #0b0806; + --kt-line: var(--color-notte-700); + --kt-line-strong: #4c3a2c; + + --kt-ink: #f4ebe0; + --kt-ink-soft: #c5b3a1; + --kt-ink-faint: #9a8877; + + --kt-accent: var(--color-ember-300); + --kt-accent-hover: var(--color-ember-200); + --kt-accent-solid: var(--color-ember-500); + --kt-accent-solid-hover: var(--color-ember-400); + --kt-on-accent: #17100c; + --kt-cold: var(--color-frost-300); + + --kt-warm-band: var(--color-ember-600); + --kt-cold-band: var(--color-frost-600); + --kt-night-wash: rgb(0 0 0 / 0.45); + + --kt-glow: 0 0 0 1px rgb(242 118 42 / 0.45), 0 0 20px -2px rgb(242 118 42 / 0.65); + --kt-glow-soft: 0 0 26px -6px rgb(242 118 42 / 0.5); + color-scheme: dark; } + html { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + color: var(--kt-ink); + /* Two lights low on the horizon rather than a wash from the top: the page + should feel lit by an oven, not by a gradient. */ + background: + radial-gradient(80rem 40rem at 12% -10%, var(--kt-ground-2) 0%, transparent 60%), + radial-gradient(70rem 34rem at 108% 4%, var(--kt-ground-2) 0%, transparent 55%), + var(--kt-ground) fixed; + background-attachment: fixed; + } + + /* 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.01em; } input[type='number'] { @@ -78,112 +168,498 @@ } /* Deliberately still :where() — zero specificity, so a component that needs - its own focus treatment can say so with one utility. Nothing does today: - the one `focus:outline-none` in the tree was dead code that this change - would have brought to life, removing that input's only focus indicator, - so it went with it. - - `a` and `summary` are in the list because both are used here as primary - controls, not as prose links: every disclosure in the app is a styled - , and every "Open" in the recipe tables is an . They were - falling back to the browser's default ring, which is a different shape - and colour on every engine and invisible against a dark card in some. */ + its own focus treatment can say so with one utility. The colour flips with + the theme: one fixed ember sat at 1.97:1 against the daylight panel, below + the 3:1 a focus indicator has to clear. */ :where(a, summary, input, select, button, textarea):focus-visible { - outline: 2px solid var(--color-tomato-500); + outline: 2px solid var(--kt-accent); outline-offset: 2px; } } @layer components { + /* --- Ink -------------------------------------------------------------- */ + + /* Three text roles, and every component picks one. Written out as + `text-stone-600 dark:text-stone-300` they had drifted into eleven shades, + half of which differed only by a step nobody had chosen on purpose. */ + .ink { + color: var(--kt-ink); + } + + .ink-soft { + color: var(--kt-ink-soft); + } + + .ink-faint { + color: var(--kt-ink-faint); + } + + .text-accent { + color: var(--kt-accent); + } + + /* Cold is a temperature, never an emphasis. Used by the fridge phases, the + cold band on the window rail and the cold-mode gauge — nothing else. */ + .text-cold { + color: var(--kt-cold); + } + + /* --- Surfaces --------------------------------------------------------- */ + + /* The base panel. Not a card in the SaaS sense: no shadow, no fill contrast + for its own sake, just a hairline and a ground one step off the page. */ .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; + border: 1px solid var(--kt-line); + border-radius: 1rem; + background: var(--kt-panel); + padding: 1.25rem; + } + + @media (width >= 40rem) { + .card { + padding: 1.5rem; + } + } + + /* A section that is closed most of the time — the recipe collections. It sits + on the page rather than on a panel of its own, so a run of them at the foot + of the page reads as a list, not as three more boxes of equal weight. */ + .card-quiet { + border: 1px solid var(--kt-line); + border-radius: 1rem; + background: transparent; + padding: 1rem 1.25rem; + } + + .panel-heading { + font-family: var(--font-display); + font-size: 1.375rem; + line-height: 1.15; + letter-spacing: 0.01em; + color: var(--kt-ink); + } + + .row-divider { + border-bottom: 1px solid var(--kt-line); + } + + .row-divider:last-child { + border-bottom: 0; + } + + /* --- Numbers ---------------------------------------------------------- */ + + /* Times, durations, weights and temperatures are the app's data, so they are + set as data: the condensed face, lining tabular figures, and enough + tracking that a column of them scans down rather than across. Anything + wearing this class must be a figure — it is the one signal that separates + a value from the prose around it. */ + .num { + font-family: var(--font-display); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; } - .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; + /* --- Controls --------------------------------------------------------- */ + + .btn-ember { + border-radius: 9999px; + background: var(--kt-accent-solid); + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--kt-on-accent); } - .btn-tomato-sm { - @apply bg-tomato-500 hover:bg-tomato-600 rounded-full px-3 py-1 text-xs font-semibold text-white; + .btn-ember:hover { + background: var(--kt-accent-solid-hover); + } + + .btn-ember:disabled { + opacity: 0.5; + } + + .btn-ember-sm { + border-radius: 9999px; + background: var(--kt-accent-solid); + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--kt-on-accent); + } + + .btn-ember-sm:hover { + background: var(--kt-accent-solid-hover); + } + + .btn-quiet { + border-radius: 9999px; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + color: var(--kt-ink-faint); + } + + .btn-quiet:hover { + color: var(--kt-ink); } .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; + display: block; + width: 100%; + padding: 0.5rem 1rem; + text-align: left; + font-size: 0.875rem; + font-weight: 500; + color: var(--kt-ink-soft); } - .text-accent { - @apply text-tomato-700 dark:text-tomato-300; + .menu-item:hover { + background: var(--kt-inset); + color: var(--kt-ink); } - .row-divider { - @apply border-dough-200/70 border-b last:border-0 dark:border-stone-700/70; + .menu-item:disabled { + cursor: not-allowed; + opacity: 0.5; } - /* A boxed message under the control that caused it. Danger is the same red - the schedule's warnings use — something is wrong and the bake is at risk; - 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; + /* Every box the form types into. Inset rather than raised: the form is a + control surface the timeline sits above, so its fields sink into the panel + instead of floating on it. Size and width stay with the caller — the date + pair splits a row, the rest fill it. */ + .input { + border: 1px solid var(--kt-line); + border-radius: 0.5rem; + background: var(--kt-inset); + padding: 0.5rem 0.75rem; + color: var(--kt-ink); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.06); } - .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; + .dark .input { + box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.5); } - .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; + .input:focus { + border-color: var(--kt-accent-solid); + } + + .field-label { + display: block; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--kt-ink-soft); } /* 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; + margin: 0; + display: inline-flex; + overflow: hidden; + border-radius: 9999px; + border: 1px solid var(--kt-line); + background: var(--kt-inset); + padding: 0; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.03em; } + /* No colour transition. A segmented control should switch on the click, and a + 150 ms fade also animates the THEME flip — which left axe measuring the + selected pill halfway between its two palettes and reporting a contrast + ratio that is on screen for a tenth of a second and never at rest. */ .pill { - @apply inline-flex items-center justify-center px-3 py-1.5 transition-colors; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; } .pill-on { - @apply bg-tomato-500 text-white; + background: var(--kt-accent-solid); + color: var(--kt-on-accent); } .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. */ - .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; + .pill-off:hover { + background: var(--kt-raised); + color: var(--kt-ink); + } + + /* --- Messages --------------------------------------------------------- */ + + /* A boxed message under the control that caused it. Danger is ember at full + heat — something is wrong and the bake is at risk. Info is the same hue + held back to a hairline and no fill, for a consequence worth reporting + rather than a refusal. Cold is not available here: blue means fridge. */ + .notice { + border-radius: 0.5rem; + border: 1px solid; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + } + + .notice-danger { + border-color: var(--color-ember-600); + background: color-mix(in srgb, var(--color-ember-500) 12%, var(--kt-panel)); + color: var(--kt-accent); + } + + .notice-info { + border-color: var(--kt-line-strong); + background: var(--kt-inset); + color: var(--kt-ink-soft); } + /* --- Links ------------------------------------------------------------ */ + /* A link in running text — the footer, the setup hint in the TRMNL dialog. Underlined on hover only; the surrounding paragraph carries the colour. */ .link-quiet { - @apply hover:text-tomato-600 dark:hover:text-tomato-300 underline-offset-2 hover:underline; + text-underline-offset: 2px; + } + + .link-quiet:hover { + color: var(--kt-accent); + text-decoration-line: underline; } /* A link that IS the action — "Open", "Source" in the recipe tables, where the row's other columns are all text and the link has to look clickable - without a button's weight. The dark half is deliberate: `text-tomato-600` - alone sat at 2.71:1 on a dark card until the axe suite caught it. */ + without a button's weight. Both halves come from --kt-accent, which is + already the pair of shades that clear AA on their own theme's panel: one + fixed tomato sat at 2.71:1 on the dark card until axe caught it. */ .link-action { - /* The hover colour is spelled out rather than reusing .text-accent: a - component class cannot take a variant inside @apply, and `hover:` on one - only works from a class attribute. Same four colours as before. */ - @apply text-tomato-600 hover:text-tomato-700 dark:text-tomato-300 dark:hover:text-tomato-200 font-semibold underline-offset-2 hover:underline; + font-weight: 600; + color: var(--kt-accent); + text-underline-offset: 2px; + } + + .link-action:hover { + color: var(--kt-accent-hover); + text-decoration-line: underline; } /* 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; + border: 1px solid var(--kt-line); + border-radius: 1rem; + background: var(--kt-panel); + padding: 0; + font-size: 0.875rem; + color: var(--kt-ink-soft); + box-shadow: 0 24px 60px -24px rgb(0 0 0 / 0.55); } - /* 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; + .dialog-panel::backdrop { + background: rgb(16 12 9 / 0.6); + } + + /* A floating layer that must cover what is under it: the actions menu, the + fit panel. Opaque on purpose — these are the only raised things on the + page, so they are also the only ones with a shadow. */ + .popover-panel { + border: 1px solid var(--kt-line); + border-radius: 0.875rem; + background: var(--kt-raised); + box-shadow: 0 18px 40px -20px rgb(0 0 0 / 0.55); + } + + /* --- The instrument --------------------------------------------------- */ + + /* The now band. The loudest element on the page and the only one that is + allowed to glow: it answers "what am I doing and how long is left", which + is the single most valuable thing the app knows. + + It is a night face in BOTH themes — it redeclares the whole surface set for + its own subtree rather than following the page. That is not a decorative + inversion: in daylight the band is the one thing that has to be found + across a kitchen, and on a page of warm paper a lit dark panel is the only + way to say so without shouting in ember. It also means the light theme has + a hero of its own rather than a paler copy of the dark one. Every class + inside reads these names, so nothing in NowBand.svelte knows about it. */ + .now-band { + --kt-panel: #1c1512; + --kt-raised: #291d16; + --kt-inset: #100c09; + --kt-line: #3d2e23; + --kt-line-strong: #52402f; + + --kt-ink: #f4ebe0; + --kt-ink-soft: #c5b3a1; + --kt-ink-faint: #9a8877; + + --kt-accent: var(--color-ember-300); + --kt-accent-solid: var(--color-ember-500); + --kt-on-accent: #17100c; + --kt-cold: var(--color-frost-300); + + --kt-warm-band: var(--color-ember-500); + --kt-cold-band: var(--color-frost-500); + --kt-night-wash: rgb(0 0 0 / 0.5); + --kt-glow-soft: 0 0 26px -6px rgb(242 118 42 / 0.5); + + position: relative; + overflow: hidden; + border: 1px solid var(--kt-line); + border-radius: 1.125rem; + color: var(--kt-ink); + background: + radial-gradient(70rem 24rem at 0% -20%, rgb(242 118 42 / 0.16) 0%, transparent 65%), + var(--kt-panel); + padding: 1rem 1.125rem 1.125rem; + } + + @media (width >= 40rem) { + .now-band { + padding: 1.25rem 1.5rem 1.5rem; + } + } + + /* The state word: what phase of the plan you are in. Ember while a step is + running, quiet before and after — the page should be calm when there is + nothing to do. */ + .state-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 9999px; + padding: 0.2rem 0.6rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .state-chip-live { + background: var(--kt-accent-solid); + color: var(--kt-on-accent); + box-shadow: var(--kt-glow-soft); + } + + .state-chip-idle { + border: 1px solid var(--kt-line-strong); + color: var(--kt-ink-faint); + } + + /* The proportional overview: one bar for the whole bake, warm and cold + phases to scale, the night hours washed over the top, a playhead at now. + Segments are deliberately not interactive — a 15-minute step is a 2 px + sliver at 72 h and would be an unhittable target, so the step list below + stays the thing you read and click. */ + .night-bar { + position: relative; + height: 2.25rem; + width: 100%; + overflow: hidden; + border-radius: 0.5rem; + border: 1px solid var(--kt-line); + background: var(--kt-inset); + } + + .night-seg { + position: absolute; + top: 0; + bottom: 0; + min-width: 2px; + } + + .night-seg-warm { + background: var(--kt-warm-band); + } + + .night-seg-cold { + background: var(--kt-cold-band); + } + + /* The night hours, washed over whatever phase runs through them. A darkening + rather than a third hue: a blue night beside a blue fridge would have made + two different facts the same colour. */ + .night-wash { + position: absolute; + top: 0; + bottom: 0; + background: var(--kt-night-wash); + } + + /* The legend swatch shows the wash doing its job — night over a warm phase — + because on its own the wash is a transparency and paints as nothing. */ + .night-wash-swatch { + background-image: linear-gradient(var(--kt-night-wash), var(--kt-night-wash)); + background-color: var(--kt-warm-band); + } + + /* Now, on the bar. Ink rather than ember: it has to read against a warm + segment and a cold one, and ember over ember is a line you cannot find — + which is exactly what the first version did. It sits outside the bar's + own clip so the cap can overhang. */ + .playhead { + position: absolute; + top: -0.3rem; + bottom: -0.3rem; + width: 2px; + background: var(--kt-ink); + box-shadow: + 0 0 0 1px var(--kt-panel), + var(--kt-glow-soft); + } + + .playhead::before { + content: ''; + position: absolute; + top: -1px; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--kt-ink); + } + + /* The bands the window slider paints behind its thumb: what this flour + tolerates at room temperature, and what it tolerates in the fridge. Same + two colours as the overview bar, for the same two meanings. */ + .zone-room { + background: var(--kt-warm-band); + } + + .zone-cold { + background: var(--kt-cold-band); + } + + /* The window card. Named, because three e2e specs address it and a + `rounded-2xl` they happened to share is not something anyone can safely + restyle. */ + .window-card { + border-radius: 0.875rem; + border: 1px solid var(--kt-line); + background: var(--kt-inset); + padding: 0.875rem; + } + + /* A duration or a temperature stated beside its label — the step durations, + the source-timing note. Reads as a value, not as a badge. */ + .gauge { + display: inline-flex; + align-items: baseline; + gap: 0.25rem; + border-radius: 0.375rem; + border: 1px solid var(--kt-line); + padding: 0.1rem 0.4rem; + font-family: var(--font-display); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--kt-ink-soft); + white-space: nowrap; } } diff --git a/src/app.html b/src/app.html index 09b89727..00304792 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..1fd98afa 100644 --- a/src/lib/components/ActionsMenu.svelte +++ b/src/lib/components/ActionsMenu.svelte @@ -78,7 +78,7 @@
@@ -91,7 +91,7 @@