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
2 changes: 1 addition & 1 deletion .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
"e2e": 97
}
25 changes: 18 additions & 7 deletions CLAUDE.md

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ src/
│ ├── +layout.ts ← prerender + ssr=false (fully client-side)
│ ├── +page.svelte ← the entire calculator UI
│ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog)
├── app.css ← Tailwind v4 entrypoint + @theme palette
├── app.css ← Tailwind v4 entrypoint: palette, semantic surface/ink tokens, component classes
└── app.html ← shell

e2e/ ← Playwright browser tests (the parts vitest cannot reach)
Expand All @@ -103,19 +103,19 @@ playwright.config.ts ← Playwright (builds and serves the real static outp

### npm scripts

| Command | What it does |
| ----------------------- | ---------------------------------------------------------- |
| `npm run dev` | Vite dev server on port 5173 with HMR |
| `npm test` | Run vitest once (`npm run test:watch` for watch mode) |
| `npm run test:coverage` | Run vitest with v8 coverage → `./coverage/` |
| `npm run test:e2e` | Browser tests (Playwright, Chromium) against a real build |
| `npm run test:e2e:ui` | The same suite in Playwright's debugger |
| `npm run test:baseline` | Refuse a change that removes tests or relaxes coverage |
| `npm run check` | `svelte-kit sync` + `svelte-check` (type & template check) |
| `npm run lint` | Prettier check + ESLint |
| `npm run format` | Prettier write |
| `npm run build` | Production build → `./build/` (static site) |
| `npm run preview` | Serve the built site locally |
| Command | What it does |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `npm run dev` | Vite dev server on port 5173 with HMR |
| `npm test` | Run vitest once (`npm run test:watch` for watch mode) |
| `npm run test:coverage` | Run vitest with v8 coverage → `./coverage/` |
| `npm run test:e2e` | Browser tests (Playwright, Chromium) against a real build; `E2E_PORT` overrides the preview port |
| `npm run test:e2e:ui` | The same suite in Playwright's debugger |
| `npm run test:baseline` | Refuse a change that removes tests or relaxes coverage |
| `npm run check` | `svelte-kit sync` + `svelte-check` (type & template check) |
| `npm run lint` | Prettier check + ESLint |
| `npm run format` | Prettier write |
| `npm run build` | Production build → `./build/` (static site) |
| `npm run preview` | Serve the built site locally |

### Pre-commit hooks

Expand All @@ -125,7 +125,7 @@ Husky + lint-staged are configured (`.husky/pre-commit`). The hook runs lint-sta

1. **Math/logic first.** Add or extend a module in `src/lib/dough/`. Keep it pure (no Svelte imports). Add a `*.test.ts` next to it. Run `npm test` until green.
2. **Wire to state.** If new inputs are needed, extend `FormState` in `src/lib/state.svelte.ts`, then `SerializableInputs` in `src/lib/dough/urlState.ts` (encode + decode + round-trip test).
3. **UI.** Add fields to `src/lib/components/InputForm.svelte`; render results in the existing components or add a new one. Use Svelte 5 runes (`$state`, `$derived`, `$effect`).
3. **UI.** Add fields to `src/lib/components/InputForm.svelte`; render results in the existing components or add a new one. Use Svelte 5 runes (`$state`, `$derived`, `$effect`). Reach for the semantic classes in `src/app.css` (`.card`, `.well`, `.chip`, `.field-label`, `bg-surface`, `text-ink-soft`, …) rather than picking a grey by hand — they carry both themes, and a hand-picked pair is how the dark theme drifts out of contrast.
4. **i18n.** Every new user-facing string goes into `src/lib/i18n/messages.ts` for all five locales. The parity test will fail loudly if a key is missing.
5. **Verify.** `npm run test:coverage && npm run check && npm run build`. The CI workflow runs `npm run lint`, `npm run check`, `npm run test:coverage` (the 100 % coverage gate — plain `npm test` skips it), and `npm run build`. A second CI job runs `npm run test:e2e`: Playwright drives a real build for the parts that live in components and so cannot be reached by vitest. First run locally needs `npx playwright install chromium`.

Expand Down
Binary file added docs/redesign/impasto/after-biga-poolish.jpg
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/impasto/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/impasto/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/impasto/after-full-page-light.jpg
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/impasto/after-phone-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/impasto/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/impasto/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/impasto/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/impasto/before-phone-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/impasto/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.
141 changes: 141 additions & 0 deletions e2e/bake-board.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { expect, test } from '@playwright/test';
import { card, formCard, openRecipe } from './helpers';

// A cold two-day plan whose first step is already running at the pinned clock,
// so the board has a "Now" to report and the schedule has one to agree with.
const RUNNING =
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-02T17%3A00%3A00.000Z&sa=2026-09-01T09%3A00%3A00.000Z';

// Everything still ahead of the clock, so the board reports the next step.
const AHEAD =
'v=6&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z';

function board(page: import('@playwright/test').Page) {
return page.getByRole('region', { name: 'Ready to bake' });
}

// The complaint this redesign answers: the page opened with twelve
// label-over-input rows, and the thing the app is FOR sat below the fold on a
// phone. The board is the fix, so its position is the rule — a future tidy-up
// that files it under the form would put the inputs back in front of the
// answer without breaking anything else.
test.describe('phone', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('the answer is on screen before any input', async ({ page }) => {
await openRecipe(page, RUNNING);

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

expect(bake!.y).toBeLessThan(form!.y);
expect(bake!.y).toBeLessThan(schedule!.y);
// ...and wholly inside the first screen, not merely first in the column.
expect(bake!.y + bake!.height).toBeLessThanOrEqual(844);
});
});

// The board and the schedule read the same steps but decide "what is happening"
// separately — the board scans for the running step, the table tags rows. Two
// clocks in one page is exactly the sort of thing that drifts silently, so pin
// that they name the same step.
test('the board names the step the schedule marks as now', async ({ page }) => {
await openRecipe(page, RUNNING);

const nowRow = card(page, 'Schedule')
.locator('li')
.filter({ has: page.locator('.chip-now') });
await expect(nowRow).toHaveCount(1);
const title = (await nowRow.locator('h4').innerText()).trim();

await expect(board(page).locator('.chip-now')).toHaveText('Now');
await expect(board(page)).toContainText(title);
});

test('with nothing running the board reports the next step, not a blank', async ({ page }) => {
await openRecipe(page, AHEAD);

await expect(board(page)).toContainText('Next');
// The countdown is the point of the line — an empty one would still contain
// the word "Next".
await expect(board(page)).toContainText(/in \d/);
});

// Depth in this design comes from light: a panel is lighter than the ground and
// casts a soft shadow. That trick does nothing on a dark ground, so every
// raised surface also carries a hairline that is transparent in light and warm
// in dark. Drop the dark half and the panels dissolve into the page — visible
// only to someone who looks at the dark theme, which is the half nobody checks.
for (const theme of ['light', 'dark'] as const) {
test(`a panel is bounded in the ${theme} theme`, async ({ page }) => {
await openRecipe(page, AHEAD);
if (theme === 'dark') {
await page.evaluate(() => document.documentElement.classList.add('dark'));
}

const style = await card(page, 'Schedule').evaluate((el) => {
const cs = getComputedStyle(el);
return { shadow: cs.boxShadow, border: cs.borderTopColor };
});

expect(style.shadow, 'panels are lifted by a shadow in both themes').not.toBe('none');
// The border is the dark theme's half of the job. rgba(...) with a
// non-zero alpha, or an opaque rgb(); fully transparent means unbounded.
const alpha = Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(style.border)?.[1] ?? '1');
if (theme === 'dark') expect(alpha, 'dark panels need a visible edge').toBeGreaterThan(0);
});
}

// The neutral chip carries a duration in the schedule and a 50 Top Pizza
// ranking in the table, and it lands on three different grounds: the lifted
// panel, the flat card the collections sit in, and the tomato wash under the
// running step. A fixed fill matched the quiet card closely enough in the dark
// theme that the ranking pills read as plain text — so the chip is a
// translucent wash, which cannot collide with whatever it happens to sit on.
for (const theme of ['light', 'dark'] as const) {
test(`a chip is visible against what it sits on in the ${theme} theme`, async ({ page }) => {
await openRecipe(page, RUNNING);
if (theme === 'dark') {
await page.evaluate(() => document.documentElement.classList.add('dark'));
}

// The neutral chip only. `.chip-now`, `.chip-action` and `.chip-time`
// carry their own fill and are separated from their ground by hue as much
// as by lightness, which this measurement cannot see.
const chips = card(page, 'Schedule').locator(
'.chip:not(.chip-now):not(.chip-action):not(.chip-time)'
);
expect(await chips.count()).toBeGreaterThan(0);

// Polled, not read once: the running step's card transitions its own
// background, so switching theme and measuring in the same tick catches
// the colour it is on its way from rather than the one it lands on.
await expect
.poll(async () =>
chips.evaluateAll((els) =>
els.map((el) => {
const paint = (n: Element | null): number[] => {
for (let e = n; e; e = e.parentElement) {
const bg = getComputedStyle(e).backgroundColor;
if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
return (bg.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
}
}
return [255, 255, 255];
};
// The chip's fill is translucent, so composite it over its
// ground the way the browser paints it before comparing.
const own = (getComputedStyle(el).backgroundColor.match(/[\d.]+/g) ?? []).map(Number);
const under = paint(el.parentElement);
const alpha = own.length === 4 ? own[3] : 1;
// 8/255 is about where a fill stops reading as a shape at all.
return under.every((u, i) => Math.abs(own[i] * alpha + u * (1 - alpha) - u) <= 8);
})
)
)
// The chip inside the running step is the hard case: that card has a
// wash of its own, so a fill tuned to the plain panel vanishes on it.
.toEqual(Array.from({ length: await chips.count() }, () => false));
});
}
10 changes: 8 additions & 2 deletions e2e/cascade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ test('a utility can restyle a heading', async ({ page }) => {
return { font: cs.fontFamily.split(',')[0].trim(), tracking: cs.letterSpacing };
});

// Untouched headings keep the display serif from the base rule...
expect((await face(card(page, 'Schedule').locator('h2'))).font).toBe('ui-serif');
// Untouched headings keep the display face from the base rule...
expect((await face(card(page, 'Schedule').locator('h2'))).font).toBe('ui-rounded');
// ...while the day label, which asks for sans and wide tracking with nothing
// but utilities, actually gets them.
const day = await face(card(page, 'Schedule').locator('h3'));
expect(day.font).toBe('ui-sans-serif');
expect(day.tracking).toBe('1.68px');
});

// This also guards a second, less obvious way to lose the ring: `outline-color`
// is one of the properties `transition-colors` animates, so a control carrying
// that utility fades its own focus indicator in over 150 ms. The assertion
// reads the outline immediately after focus, which is exactly when a
// transitioned ring is still the colour it was before.
//
// The TRMNL uuid field carried `focus:outline-none`. It never took effect —
// the unlayered focus rule outranked it — so the field has always shown the
// ring. Layering would have handed that class its wish and left the input with
Expand Down
13 changes: 7 additions & 6 deletions e2e/headings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ test('no heading level is skipped', async ({ page }) => {

// app.css styles `h1, h2, h3, .font-display` from OUTSIDE any cascade layer, so
// that rule beats every Tailwind utility — unlayered always wins over
// @layer utilities. Turning the day label into an h3 silently made it serif and
// dropped its wide tracking; turning the step title into an h4 silently dropped
// the serif it had been inheriting. Both faces are pinned here because the
// markup gives no hint that the levels and the fonts are coupled.
// @layer utilities. Turning the day label into an h3 silently made it the
// display face and dropped its wide tracking; turning the step title into an h4
// silently dropped the display face it had been inheriting. Both faces are
// pinned here because the markup gives no hint that levels and fonts are
// coupled.
test('changing a heading level does not change its typeface', async ({ page }) => {
await openRecipe(page, RECIPE);

Expand All @@ -82,6 +83,6 @@ test('changing a heading level does not change its typeface', async ({ page }) =
.locator('main ol h4')
.first()
.evaluate((el) => getComputedStyle(el).fontFamily.split(',')[0].trim());
// Step titles have always been the display serif.
expect(step).toBe('ui-serif');
// Step titles have always been in the display face.
expect(step).toBe('ui-rounded');
});
18 changes: 14 additions & 4 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ export async function waitForHydration(page: Page) {
.not.toBeNull();
}

/** A top-level card, addressed by its heading. */
/**
* A top-level region, addressed by its heading. Two classes, because the
* regions are no longer all the same box: `.card` is a lifted panel (form,
* schedule, ingredients) and `.card-quiet` is the flat kind the reference
* collections sit in.
*/
export function card(page: Page, heading: string) {
return page.locator('.card').filter({ has: page.getByRole('heading', { name: heading }) });
return page
.locator('.card, .card-quiet')
.filter({ has: page.getByRole('heading', { name: heading }) });
}

/** The card holding the form (it has no heading of its own). */
Expand All @@ -36,7 +43,10 @@ export function formCard(page: Page) {

/** The fermentation-window card. */
export function windowCard(page: Page) {
return page.locator('form div.rounded-2xl').filter({ has: page.locator('input[type="range"]') });
// Addressed by its own component class rather than by "the rounded div in
// the form": the form now has several rounded surfaces, so that description
// stopped identifying anything in particular.
return page.locator('form .window-card');
}

/** The big duration readout, e.g. "40 h". */
Expand Down Expand Up @@ -90,7 +100,7 @@ export async function setBakeDate(page: Page, value: string) {
export async function thumbCentreX(page: Page): Promise<number> {
return slider(page).evaluate((el: HTMLInputElement) => {
const r = el.getBoundingClientRect();
const radius = 10; // half the 1.25rem thumb
const radius = 14; // half the 1.75rem thumb
const frac = Number(el.value) / Number(el.max);
return r.left + radius + frac * (r.width - 2 * radius);
});
Expand Down
2 changes: 1 addition & 1 deletion 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
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