Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/test-baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 16 additions & 7 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Binary file added docs/redesign/notte/after-band-bigapoolish.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/after-desktop-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/after-desktop-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/after-phone-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/before-desktop-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/before-desktop-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/redesign/notte/before-phone-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions e2e/cascade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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 <summary>; Playwright does not expose it as a button.
await page.locator('summary').filter({ hasText: 'Actions' }).click();
Expand All @@ -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');
});
2 changes: 1 addition & 1 deletion e2e/focus-dismissal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 10 additions & 7 deletions e2e/headings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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"');
});
6 changes: 4 additions & 2 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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". */
Expand Down
127 changes: 127 additions & 0 deletions e2e/now-band.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof page.locator>) =>
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<typeof page.locator>) =>
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);
});
4 changes: 2 additions & 2 deletions e2e/window-slider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "knead-time",
"version": "6.10.10",
"version": "6.11.0",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading