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": 961,
"e2e": 99
}
30 changes: 22 additions & 8 deletions CLAUDE.md

Large diffs are not rendered by default.

19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@

A time-anchored Neapolitan pizza dough calculator — [try it live](https://kneadtime.pizza). You enter **when you want to bake**; the app schedules every step backwards from that moment, auto-switches between cold and room fermentation based on available time, and gives you an on-screen schedule, an `.ics` you can drop into a calendar, a print-to-PDF recipe sheet for the kitchen counter, and a [TRMNL](https://trmnl.com/) e-ink view for the counter clock.

New in 6.11: **the bake dial**. The whole plan is drawn on a 24-hour face — one
revolution per day, so a 32 h bake is a turn and a bit and a 72 h bake is three
coils, and you see how long the dough takes before you read a number. The bake
sits on the rim and the dough unwinds inward from it; the 22:00–08:00 night is a
shaded wedge every coil passes through; warm phases are amber, fridge phases
blue. Pick any arc (click, tap or arrow keys) and the panel beside it reads that
step out in full; drag the coil's tail to lengthen the fermentation window. The
step list is still there underneath, and the two always agree.

New in v6: **flour strength (W)** and a **fermentation-window slider**.

Pick your flour and the schedule paints the window that flour actually tolerates. Twelve presets are shelved by what each strength is for — same-day, ~24 h, ~48 h, 48–72 h, plus a too-weak and a too-strong shelf, with the AVPN spec's W 220–380 as the outer edges — covering Caputo (Doppio Zero, Pizzeria, Nuvola, Saccorosso, Cuoco, Nuvola Super), Dallagiovanna (Classica Oro, La Napoletana, Uniqua Blu), Le 5 Stagioni Pizza Napoletana, Polselli Classica and a generic supermarket tipo 00. Or type a W yourself.
Expand Down Expand Up @@ -66,12 +75,16 @@ src/
│ │ ├── types.ts shared types
│ │ └── *.test.ts colocated tests
│ ├── components/ ← Svelte 5 UI (uses runes)
│ │ ├── Dial.svelte the instrument: the schedule as a coil on a 24-hour face
│ │ ├── StepReadout.svelte the selected step in full, beside the dial
│ │ └── ScheduleTable.svelte the same plan as a day-grouped list
│ ├── i18n/ ← messages (en/de/it/fr/nl), locale detection, runtime interpolation
│ ├── community/ ← community.md (data) + parser, rendered as a table at the bottom of the page
│ ├── pizzerias/ ← pizzerias.md (50 Top Pizza recipes) + parser, rendered below the community table
│ ├── trmnl/ ← TRMNL Private-Plugin webhook payload + client
│ ├── dial.ts ← bake-dial geometry: angles, coil radii, night wedge, hit areas (pure)
│ ├── state.svelte.ts ← form state as a $state class (window re-pick, startAt/readyBy floors)
│ ├── warningSlots.ts ← which card each schedule warning is rendered in
│ ├── warningSlots.ts ← which region each schedule warning is rendered in
│ ├── mode.svelte.ts / storedMode.ts ← beginner/expert view mode (+ localStorage)
│ ├── verbosity.svelte.ts / storedVerbosity.ts ← schedule short/detailed switch (+ localStorage)
│ ├── storedRecipes.ts ← last-recipe restore + named recipe book (localStorage)
Expand All @@ -80,9 +93,9 @@ src/
├── routes/
│ ├── +layout.svelte ← global styles, language bootstrap
│ ├── +layout.ts ← prerender + ssr=false (fully client-side)
│ ├── +page.svelte ← the entire calculator UI
│ ├── +page.svelte ← the whole app: masthead, settings rail, dial + readout, plan, weights
│ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog)
├── app.css ← Tailwind v4 entrypoint + @theme palette
├── app.css ← Tailwind v4 entrypoint, --kt-* theme roles, component classes
└── app.html ← shell

e2e/ ← Playwright browser tests (the parts vitest cannot reach)
Expand Down
Binary file added docs/redesign/quadrante/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/quadrante/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.
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/quadrante/dial-room-mode.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/quadrante/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.
228 changes: 228 additions & 0 deletions e2e/dial.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { expect, test } from '@playwright/test';
import { card, chosenWindow, dragTo, openRecipe, slider } from './helpers';

// The dial is the app's primary control now, and everything about it lives in
// a component: the geometry module underneath is unit-tested to 100 %, but
// whether the drawing and the plan list agree, whether the coil can be worked
// without a mouse, and whether a 390 px face still fits the phone are all
// things only a browser can answer.

// A two-day cold plan: a coil and a bit, with a fridge leg long enough that
// its arc dominates the face.
const COLD =
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z';
// Three coils, two pre-ferments in parallel — the densest thing the dial draws.
const LONG_PREFERMENT =
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=310&p=b30_p20&r=2026-09-04T17%3A00%3A00.000Z&sa=2026-09-01T17%3A00%3A00.000Z';

const dial = (page: import('@playwright/test').Page) => page.locator('section.instrument svg');
const options = (page: import('@playwright/test').Page) => dial(page).getByRole('option');
const readout = (page: import('@playwright/test').Page) => page.locator('.readout');

/** Click a step's arc on the coil, at a point that is really on the stroke. */
async function clickArc(page: import('@playwright/test').Page, step: string) {
// The coordinates below are viewport-relative, and selecting a step in the
// plan list first can scroll the dial off the top of the screen — a click
// at a negative y lands nowhere and reports nothing.
await dial(page).scrollIntoViewIfNeeded();
const at = await dial(page)
.locator(`[data-step="${step}"] path`)
.first()
.evaluate((el: SVGPathElement) => {
const p = el.getPointAtLength(el.getTotalLength() / 2);
const box = el.ownerSVGElement!.getBoundingClientRect();
// The face is drawn in a 100-unit viewBox scaled to the box.
return { x: box.left + (p.x / 100) * box.width, y: box.top + (p.y / 100) * box.height };
});
await page.mouse.click(at.x, at.y);
}

test('the dial and the plan list are two views of one selection', async ({ page }) => {
// They read the same stepCopy functions, so they cannot disagree about what
// a step SAYS — but they each keep their own idea of which step is showing
// unless the selection is lifted out of both, which is what this pins.
await openRecipe(page, COLD);

await card(page, 'Schedule').getByRole('button', { name: 'Divide & ball' }).click();
await expect(readout(page).getByRole('heading')).toHaveText('Divide & ball');
await expect(options(page).and(page.locator('[aria-selected="true"]'))).toHaveAttribute(
'data-step',
'divide-'
);

// ...and back the other way: picking an arc moves the list's marker too.
// Clicked where the arc is actually drawn, not at the centre of its
// bounding box — a 180° arc's box centre is empty face.
await clickArc(page, 'final-proof-');
await expect(readout(page).getByRole('heading')).toHaveText('Final proof');
await expect(card(page, 'Schedule').getByRole('button', { name: 'Final proof' })).toHaveAttribute(
'aria-pressed',
'true'
);
});

test('the whole coil is walkable from the keyboard', async ({ page }) => {
// The dial is one tab stop with a roving selection, not nine — and the
// arrow keys have to move the readout, or the drawing is mouse-only.
await openRecipe(page, COLD);

await options(page).and(page.locator('[tabindex="0"]')).focus();
const first = await readout(page).getByRole('heading').innerText();
await page.keyboard.press('ArrowRight');
await expect(readout(page).getByRole('heading')).not.toHaveText(first);

await page.keyboard.press('End');
await expect(readout(page).getByRole('heading')).toHaveText('Shape & bake');
await page.keyboard.press('Home');
await expect(readout(page).getByRole('heading')).toHaveText('Weigh & prep');
});

test('the window handle moves between the same stops as the slider', async ({ page }) => {
// Two controls for one value: the coil's tail and the rail in the form. A
// handle that landed anywhere else would hand back a window the slider
// could never express again.
await openRecipe(page, COLD);
const before = await chosenWindow(page);

const handle = dial(page).getByRole('slider');
await handle.focus();
await handle.press('ArrowLeft');

await expect.poll(() => chosenWindow(page)).not.toBe(before);
// The rail followed, rather than the two drifting apart.
await expect
.poll(async () => Number(await slider(page).inputValue()))
.toBe(Number(await handle.getAttribute('aria-valuenow')));
await expect(handle).toHaveAttribute('aria-valuetext', await chosenWindow(page));
});

test('dragging the handle past the bake deadline is refused, out loud', async ({ page }) => {
// Same rule the rail enforces, said in the same words: a control that
// springs back without a word reads as broken.
await openRecipe(
page,
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T19%3A00%3A00.000Z'
);

const handle = dial(page).getByRole('slider');
await handle.focus();
// Turning the tail back against the clock is what lengthens the window.
for (let i = 0; i < 12; i++) await handle.press('ArrowRight');

const alert = page.locator('section.instrument [role="alert"]');
await expect(alert).toBeVisible();
await expect(alert).toContainText('before now');
});

test('a leg that ends on another day says which day', async ({ page }) => {
// The readout printed "01:00 PM – 02:45 PM" for a 25 h fridge leg, which
// reads as an hour and three quarters. Only the rendered pair shows it.
await openRecipe(page, COLD);

await card(page, 'Schedule').getByRole('button', { name: 'Bulk ferment (fridge)' }).click();
const times = readout(page).locator('p').nth(1);
await expect(times).toContainText('Sep');
});

test('the dial opens on the step that is running', async ({ page }) => {
// "What now?" is the question the app exists to answer, so the readout must
// not start on step one of a plan that began yesterday.
await openRecipe(page, COLD);
// NOW is 2026-09-01T09:00Z and this plan opens on 2026-09-04, so nothing is
// running yet: the first step is the right answer here.
await expect(readout(page).getByRole('heading')).toHaveText('Weigh & prep');

await openRecipe(
page,
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T13%3A00%3A00.000Z&sa=2026-09-01T05%3A00%3A00.000Z'
);
// This one started four hours before NOW, so a middle step is current.
await expect(readout(page).getByRole('heading')).not.toHaveText('Weigh & prep');
});

test.describe('phone', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('the face fits the phone and the page never scrolls sideways', async ({ page }) => {
// A coil is the one element on this page that wants to be square and as
// wide as it can get, which is exactly how a page starts overflowing.
await openRecipe(page, LONG_PREFERMENT);

const box = await dial(page).boundingBox();
expect(box!.width).toBeLessThanOrEqual(390);
// Square, so the geometry the module computes is the geometry drawn.
expect(Math.abs(box!.width - box!.height)).toBeLessThan(2);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
390
);
});

test('the plan comes before the weights, and the settings are past both', async ({ page }) => {
// The schedule is what the app is for; on a phone it used to sit behind
// the form AND the ingredients. The dial answers first now, and the form
// is how you adjust the answer rather than how you reach it.
await openRecipe(page, COLD);

const stage = (await page.locator('section.instrument').boundingBox())!;
const plan = (await card(page, 'Schedule').boundingBox())!;
const weights = (await card(page, 'Ingredients').boundingBox())!;
const settings = (await page.locator('.card', { has: slider(page) }).boundingBox())!;

expect(stage.y).toBeLessThan(plan.y);
expect(plan.y).toBeLessThan(weights.y);
expect(weights.y).toBeLessThan(settings.y);
});
});

test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 1000 } });

test('the settings are a rail beside the instrument, not a column ahead of it', async ({
page
}) => {
await openRecipe(page, COLD);

const settings = (await page.locator('.card', { has: slider(page) }).boundingBox())!;
const stage = (await page.locator('section.instrument').boundingBox())!;
const readoutBox = (await readout(page).boundingBox())!;

// Rail on the left, instrument to its right, both starting at the top.
expect(settings.x).toBeLessThan(stage.x);
expect(Math.abs(settings.y - stage.y)).toBeLessThan(2);
// The step detail sits beside the dial at this width, not under it.
expect(readoutBox.x).toBeGreaterThan(stage.x + stage.width - 2);
});
});

test('the drag that changes the window is not the only way to change it', async ({ page }) => {
// Everything the coil can do has a plain field behind it. The rail is that
// field, and moving it has to move the coil's tail.
await openRecipe(page, COLD);

const tailBefore = await dial(page).getByRole('slider').getAttribute('aria-valuetext');
await dragTo(page, 1);
await expect(dial(page).getByRole('slider')).not.toHaveAttribute('aria-valuetext', tailBefore!);
});

test.describe('the settings rail', () => {
test.use({ viewport: { width: 1280, height: 700 } });

test('scrolls on its own, so a long form is not pinned out of reach', async ({ page }) => {
// The rail is sticky, and the expert form is taller than any laptop
// screen. A sticky box taller than the viewport sticks immediately and
// then scrolls with the page, so everything below its first screenful
// becomes unreachable — the fridge-temperature field simply could not be
// clicked. It gets its own scroll region instead.
await openRecipe(page, COLD);

const rail = page.locator('.card', { has: slider(page) });
const reachable = await rail.evaluate(
(el) => el.scrollHeight <= el.clientHeight + 1 || getComputedStyle(el).overflowY === 'auto'
);
expect(reachable).toBe(true);

const fridge = page.locator('form label', { hasText: 'Fridge temperature' }).locator('input');
await fridge.fill('6');
await expect(fridge).toHaveValue('6');
});
});
6 changes: 5 additions & 1 deletion e2e/focus-dismissal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ test('links and disclosure triggers get the same focus ring as the rest', async
test('the fit-score panel closes on Escape and on an outside click', async ({ page }) => {
await openRecipe(page, RECIPE);

const details = card(page, 'Schedule').locator('details').filter({ hasText: 'fit' });
// Filtered on the SUMMARY's text: the "Get nerdy" panel's body happens to
// contain the word too, and `hasText` reads a details' whole content.
const details = page
.locator('main details')
.filter({ has: page.locator('summary', { hasText: 'fit' }) });
const trigger = details.locator('summary');

await trigger.click();
Expand Down
7 changes: 5 additions & 2 deletions e2e/headings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ test('every card is reachable by heading, and the steps sit under their day', as
await openRecipe(page, RECIPE);
const heads = await outline(page);

// The input card is named, even though its heading is visually hidden.
// Every region is named, including the two whose headings are visually
// hidden (the dial's stage, which needs no title on screen, and the form,
// whose own group legends already label it).
expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([
'Your recipe',
'Bake dial',
'Schedule',
'Ingredients',
'Your recipe',
'My recipes',
'Community recipes',
'50 Top Pizza recipes'
Expand Down
49 changes: 8 additions & 41 deletions e2e/layout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,16 @@
import { expect, test } from '@playwright/test';
import { card, formCard, openRecipe } from './helpers';
import { openRecipe } from './helpers';

const RECIPE =
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z';

// The schedule is what the app is for, and on a phone it used to sit behind
// BOTH the form and the ingredients — measured at 2.2 screens down in beginner
// view and 3.7 in expert, against a stated design goal of reading well on a
// phone on the counter. All three cards carry explicit lg: col/row placement,
// so DOM order is free to put the schedule second; only a browser can show
// that the reorder actually reaches the phone and leaves the desktop alone.
test.describe('phone', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('the schedule comes before the ingredients on a phone', async ({ page }) => {
await openRecipe(page, RECIPE);

const schedule = await card(page, 'Schedule').boundingBox();
const ingredients = await card(page, 'Ingredients').boundingBox();
expect(schedule).not.toBeNull();
expect(ingredients).not.toBeNull();

// Single column here, so "before" is purely vertical.
expect(schedule!.y).toBeLessThan(ingredients!.y);
});
});

test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 1000 } });

test('the schedule still shares the top row with the form at lg+', async ({ page }) => {
await openRecipe(page, RECIPE);

const form = await formCard(page).boundingBox();
const schedule = await card(page, 'Schedule').boundingBox();
const ingredients = await card(page, 'Ingredients').boundingBox();

// Right-hand column, top row — beside the form, not under it.
expect(schedule!.x).toBeGreaterThan(form!.x);
expect(Math.abs(schedule!.y - form!.y)).toBeLessThan(2);
// Ingredients stays in the left column, below the form.
expect(ingredients!.x).toBeCloseTo(form!.x, 0);
expect(ingredients!.y).toBeGreaterThan(form!.y + form!.height - 2);
});
});
// The two placement rules that used to live here — "the schedule comes before
// the ingredients on a phone" and "the schedule shares the top row with the
// form at lg+" — were about the form/schedule/ingredients grid that the dial
// replaced. They are not gone: e2e/dial.spec.ts pins the same two questions
// against the structure that exists now ("the plan comes before the weights,
// and the settings are past both", "the settings are a rail beside the
// instrument, not a column ahead of it").

// A <label> names its FIRST labelable descendant, so wrapping a date box and a
// time box in one label left both time boxes with no accessible name at all —
Expand Down
Loading
Loading