Skip to content
Merged
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ The app is a service that prints its own paperwork. It asks a short sequence of
- **The place lives in the URL fragment, never in the query.** `#ask/<step>`, `#plan`, `#library`. The query stays the recipe and nothing else — no `view` key, no schema bump, and `hasRecipeParams` never has to learn to ignore a piece of interface state. A fragment is linkable, survives a reload and gives back/forward for free. `go()` pushes; the recipe effect replaces — and it must keep appending `viewHash(...)`, or the next recipe edit swallows the view.
- **`initialLocation()` is the rule that matters.** An explicit fragment wins; then anyone arriving with a recipe — a share link, or the recipe this device was last working on — goes **straight to the plan**. Walking a returning baker through five questions again is the failure this structure exists to prevent; pinned in `e2e/views.spec.ts`.
- **Ask** (`AskFlow.svelte`): one decision per sheet, each answerable in one gesture and each skippable — "Skip to the plan" from question one, plus a progress rule that jumps to any of the five. `PlanGlance.svelte` sits beside the question as the **ticket stub**, showing the consequence forming (start moment, window, flour, mode, step count) on dotted leaders above a perforation. Without it, a sequence of questions is a survey.
- **Plan** (`PlanView.svelte`): the job ticket, and the calm screen — the questions shout, the plan is read at 07:00 with flour on your hands. **Nothing on it is an input.** The bake moment is the largest thing on the page and is itself the control that changes it; every other editable value is a `.chip-field` — a blank on a printed order form, its name stamped above a ruled line — that opens the sheet **with that field focused**, via `AdjustPanel.open('field-…')` against the ids in `InputForm.svelte`.
- **Plan** (`PlanView.svelte`): the job ticket, and the calm screen — the questions shout, the plan is read at 07:00 with flour on your hands. **Nothing on it is an input.** The bake moment is the largest thing on the page and is itself the control that changes it; every other editable value is a `.chip-field` — a blank on a printed order form, its name stamped above a ruled line — that opens the sheet **with that field focused**, via `AdjustPanel.open('field-…')` against the ids in `InputForm.svelte`. Above `xl` it is two columns — the **ingredient ticket on the left** in the narrow one (sticky, so it stays with you down a two-day schedule) and the **schedule on the right** in the wide one; on a phone the same two stack, **ticket first**. The weights are what you reach for when you get to the counter; the schedule is what you come back to between steps. **Source order is the order at every width** — no `order` or `col-start` anywhere in that grid, so what the eye meets first is also what a screen reader and the tab key meet first. Both halves pinned in `e2e/layout.spec.ts`.
- **Adjust** (`AdjustPanel.svelte`): the order pad. A native modal `<dialog>` — focus trap, inert background and Escape all for free — laid out as a bottom sheet under `lg` and a right-hand drawer above it, so on a desktop the plan stays visible and live behind it. It carries **every** field in `DoughInputs` on one dense surface: that is the expert's door, one press to twenty numbers with no wizard in the way. Its header band repeats the three live facts, because on a phone the ticket is behind the sheet.
- **Library** (`LibraryView.svelte`): a rack of tickets — My recipes, Community and 50 Top Pizza, one press from the first question and one from the plan. They are entry points to a recipe, not an appendix; they used to sit at the bottom of a 5600 px column. Each is its own `.card` shipping closed, so the rack reads as an index of three rather than as a wall of tables. Each is wrapped in a bare `<section>` so it stays a landmark the browser suite can reach by heading (`e2e/helpers.ts` `region`).

Expand Down
6 changes: 4 additions & 2 deletions e2e/headings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ test('the plan is headed by the bake moment, with its two regions under it', asy
expect(heads[0].level).toBe(1);
expect(heads[0].text).toContain('Ready to bake');
expect(heads.filter((h) => h.level === 1)).toHaveLength(1);
// Source order, and the ticket leads the plan — see the phone test in
// layout.spec.ts, which is the same order at the other width.
expect(heads.filter((h) => h.level === 2).map((h) => h.text)).toEqual([
'Schedule',
'Ingredients'
'Ingredients',
'Schedule'
]);

// Every step title is an h4 introduced by an h3 date, never the other way
Expand Down
37 changes: 27 additions & 10 deletions e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import { openAdjust, openLibrary, openQuestion, openRecipe, region, sheet } from
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. The inputs have left the page entirely now, but the
// schedule-before-weights order is still a rule and still only a browser can
// show that it reaches the phone.
// The plan used to open with the schedule on a phone; it opens with the ticket
// now — the weights are what you reach for first at the counter, and the
// schedule is what you come back to between steps. Both cards used to sit
// behind the form as well, measured at 2.2 screens down; the inputs have left
// the page entirely, so the ticket is genuinely the top of the plan. Only a
// browser can show which one a phone actually meets first.
test.describe('phone', () => {
test.use({ viewport: { width: 390, height: 844 } });

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

const schedule = await region(page, 'Schedule').boundingBox();
Expand All @@ -21,7 +22,7 @@ test.describe('phone', () => {
expect(ingredients).not.toBeNull();

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

Expand All @@ -34,10 +35,26 @@ test.describe('desktop', () => {
const schedule = await region(page, 'Schedule').boundingBox();
const ingredients = await region(page, 'Ingredients').boundingBox();

// Right-hand rail, level with the schedule — both are outputs, and the
// plan is the one screen where nothing competes with them.
expect(ingredients!.x).toBeGreaterThan(schedule!.x + schedule!.width - 2);
// Left-hand rail, level with the schedule — both are outputs, and the
// plan is the one screen where nothing competes with them. The ticket is
// the narrow column, so it reads as the stub beside the ticket rather
// than as a second document.
expect(ingredients!.x + ingredients!.width).toBeLessThanOrEqual(schedule!.x + 2);
expect(Math.abs(ingredients!.y - schedule!.y)).toBeLessThan(20);
expect(ingredients!.width).toBeLessThan(schedule!.width);

// Source order IS the order at both widths — nothing is placed against
// the grid, so the eye and the reading order can never end up on
// different cards. The phone rule above is about what a screen reader
// and the tab key meet first, not only about what is on top.
const ticketFirst = await page.evaluate(() => {
const cards = [...document.querySelectorAll('main section.card-loud, main aside.card-loud')];
return (
cards.findIndex((c) => c.tagName === 'ASIDE') <
cards.findIndex((c) => c.tagName === 'SECTION')
);
});
expect(ticketFirst).toBe(true);

// The whole point of the restructure: the twelve inputs are in a sheet
// that has to be asked for, so the plan carries no visible form at all.
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": "7.0.1",
"version": "7.0.2",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
63 changes: 34 additions & 29 deletions src/lib/components/PlanView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -350,35 +350,13 @@
<Warnings warnings={form.schedule.warnings} place="temperature" />
</div>

<div class="mt-10 grid gap-8 xl:grid-cols-[minmax(0,1fr)_21rem] xl:gap-10">
<!-- The one surface allowed to shout: an offset block of ink behind the
sheet, the way a second pass sits beside the first when the plate is
out of register. The schedule is what the app is FOR. -->
<section class="card card-loud min-w-0">
<!-- Both marks are facts about this schedule, so they are pressed onto
its own band rather than floating in a status row above it. -->
<h2 class="card-header card-header-title">{t.schedule.heading}</h2>
<div class="card-body">
<!-- The lede: what the maths chose and why, directly under the stamp
that names it. It had been left behind in a strip above the card
when the stamp moved onto the band, a sentence with nothing
around it and nothing to attach to. -->
<!-- Two marks, and each opens what it means. The mode's sentence used
to sit beside them as running prose, which made the row a caption
with two icons rather than two seals; it reads the same way the
fit's factors do now. -->
<div class="mb-6 flex flex-wrap items-center gap-x-7 gap-y-4">
<ModeBadge mode={form.schedule.mode} explain />
<FitScore schedule={form.schedule} inputs={form.serializable()} />
</div>
<ScheduleTable
schedule={form.schedule}
{sourceTiming}
verbosity={scheduleVerbosity.current}
/>
</div>
</section>

<!-- The ticket is the narrow column and the schedule the wide one, and
the ticket comes first: on a phone this is one column, and what you
reach for at 07:00 is the weights — the schedule is what you come
back to between steps. Source order IS the order at every width, so
nothing has to be placed against the grid and reading order can
never disagree with what is on screen. -->
<div class="mt-10 grid gap-8 xl:grid-cols-[21rem_minmax(0,1fr)] xl:gap-10">
<aside class="card card-loud min-w-0 self-start xl:sticky xl:top-6">
<div class="card-header justify-between">
<h2 class="card-header-title">{t.ingredients.heading}</h2>
Expand Down Expand Up @@ -409,6 +387,33 @@
</div>
</div>
</aside>
<!-- The one surface allowed to shout: an offset block of ink behind the
sheet, the way a second pass sits beside the first when the plate is
out of register. The schedule is what the app is FOR. -->
<section class="card card-loud min-w-0">
<!-- Both marks are facts about this schedule, so they are pressed onto
its own band rather than floating in a status row above it. -->
<h2 class="card-header card-header-title">{t.schedule.heading}</h2>
<div class="card-body">
<!-- The lede: what the maths chose and why, directly under the stamp
that names it. It had been left behind in a strip above the card
when the stamp moved onto the band, a sentence with nothing
around it and nothing to attach to. -->
<!-- Two marks, and each opens what it means. The mode's sentence used
to sit beside them as running prose, which made the row a caption
with two icons rather than two seals; it reads the same way the
fit's factors do now. -->
<div class="mb-6 flex flex-wrap items-center gap-x-7 gap-y-4">
<ModeBadge mode={form.schedule.mode} explain />
<FitScore schedule={form.schedule} inputs={form.serializable()} />
</div>
<ScheduleTable
schedule={form.schedule}
{sourceTiming}
verbosity={scheduleVerbosity.current}
/>
</div>
</section>
</div>
</div>
</div>
Loading