npx agentspec-cli install 701fbb8d-d44e-4019-bf49-290e66d337c3 --file-type claude-md→ .claude/rules/
# Fuelr
Save your recipes and plan your meals for the week.
## Design System
Applies to `frontend/` — respect these tokens and reuse `app/components/ui/`
instead of inventing new colours or writing one-off markup when building UI.
The living reference is the `/design-system` page
(`app/[locale]/design-system/page.tsx`); it renders from the real tokens, so
if a change looks wrong there, it is wrong.
**Three principles.** Dark by default — the near-black ground is what lets the
lime carry the action, and light is a mirror, not a second system. Food first —
the UI stays neutral so dish photos are the only uncontrolled colour. One
accent per view — lime = action, mint = progress, coral = alert; never two
roles for one colour.
### Colour
Defined in `frontend/app/globals.css`. A component never hardcodes a hex: it
uses a token. That is what lets the light theme exist without duplicating a
single component.
| Token | Dark | Light | Use |
| ----------------- | ----------- | ----------- | -------------------------------- |
| `--bg` | `#121212` | `#F7F5EF` | Page ground |
| `--bg-raised` | `#191919` | `#FFFFFF` | Cards, panels, nav bar |
| `--bg-raised-2` | `#212121` | `#ECE9DF` | Fields, inactive chips, tracks |
| `--text` | `#F5F5F0` | `#15150F` | Primary text |
| `--text-dim` | `#B9B9B4` | `#5B5A50` | Descriptions, metadata |
| `--gray` | `#9CA3AF` | `#64635A` | Labels, inactive icons, borders |
| `--line` | `f5f5f0/8%` | `15150f/12%`| Separators, card outlines |
| `--lime` | `#C4F135` | `#C4F135` | Primary action (flat) |
| `--lime-ink` | `#C4F135` | `#4B5E12` | Lime as text or border |
| `--mint` | `#2DD4BF` | `#2DD4BF` | Progress, focus (flat) |
| `--mint-ink` | `#2DD4BF` | `#0E6B5F` | Mint as text or link |
| `--coral` | `#FF6B4A` | `#FF6B4A` | Alert, badge (flat) |
| `--coral-ink` | `#FF6B4A` | `#9C321B` | Coral as text or border |
| `--on-accent` | `#121212` | `#121212` | Text on a flat accent fill |
`--accent` / `--accent-ink` alias the current action colour (lime). Behind
these sit the full 100→1000 ramps (`--lime-500`, `--mint-800`, …): step 500 is
the flat brand colour in both themes, and the `*-ink` steps are what carry
text on light grounds. Use a ramp step directly only when a semantic token
genuinely doesn't cover the case.
All of it is wired into the Tailwind theme via `@theme inline`, so it is
consumed as `bg-bg-raised`, `text-text-dim`, `border-line`, `text-accent-ink`,
`bg-accent/14`, never as inline styles. Adding a colour means declaring it in
`:root` **and** `.light`, then mapping it in `@theme inline`.
### Type, space, shape, motion
- **Type** — Poppins ExtraBold (`font-display`) for brand voice and headings,
Manrope (`font-sans`) for everything else, JetBrains Mono (`font-mono`) for
quantities and units. Add `.tnum` to any changing figure so it doesn't jump.
Scale: Display 44/40·800, H1 32/28·800, H2 22/20·800, H3 16·700, Body
15·500·1.5, Meta 13·600, Label 11·700·.02em, Data 13·mono. Body lines cap at
68ch, heading leading 1.1–1.2, nothing below 11px, Poppins only from 14px,
uppercase for labels only.
- **Space** — 4px base, and nothing off the scale. Tailwind's default spacing
is already this scale, so `p-4` = 16px, `gap-6` = 24px. If a spacing "doesn't
land", revisit the composition, not the scale.
- **Shape** — `rounded-sm` 12px (fields, tags), `rounded-md` 20px (cards,
list rows), `rounded-lg` 24px (panels, dialogs), `rounded-full` (buttons,
chips). A card never mixes two surface radii, and the three steps climb
together: a field at 8px beside a card at 20 read as an accident rather
than as a hierarchy.
- **Elevation** — e0 is border-only (in-flow cards), then `shadow-e1` (card
hover), `shadow-e2` (menus, toasts), `shadow-e3` (modals, sheets). In dark
elevation reads through the surface, in light through the shadow. They are
wide and soft rather than short and contrasted: an elevation sets an object
down, it does not cut it out.
- **On-state** — a filled accent surface, never a tinted border. A 14% wash
reads as "on" on a screen you are looking straight at and as nothing on a
worktop in daylight, so the chip, the segmented control, the site nav and
the phone tab bar all fill. A count riding on an accent fill inverts, or it
disappears into it.
- **Motion** — `var(--dur-fast)` 120ms, `var(--dur)` 180ms,
`var(--dur-control)` 160ms, `var(--dur-sheet)` 240ms, all on `var(--ease)`
= `cubic-bezier(.2,.8,.2,1)`. Nothing lasts longer than 240ms: a cooking app
gets read with dirty hands. Reduced motion is honoured globally in
`globals.css`.
- **Focus** — always the mint ring: `focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-[var(--mint-ink)]`.
### Dark / light
`next-themes` with `attribute="class"`, `defaultTheme="dark"` and
`enableSystem={false}` (dark is a brand decision, not a preference). It puts
`dark` or `light` on `<html>`; `:root` holds the dark values and `.light`
overrides them.
Because every token already carries both themes, components need **no** `dark:`
classes — `bg-bg-raised` / `text-text` is enough. The toggle lives in
`app/components/theme-toggle.tsx`.
### Component library
`frontend/app/components/ui/` (aliased `@ui/*`; `@app/*` maps to `app/`).
| File | Exports |
| ----------------- | -------------------------------------------------------------- |
| `button.tsx` | `Button` (8 variants × 4 sizes + `none`, `loading`, `fullWidth`), `IconButton` (`size` md/xl), `buttonClasses` |
| `input.tsx` | `Input` (label + hint, `status` default/error/success) |
| `checkbox.tsx` | `Checkbox` (`indeterminate`, `error`) |
| `radio.tsx` | `Radio` |
| `switch.tsx` | `Switch` |
| `chip.tsx` | `Chip` (`active`, `count`, `onRemove`) — a filter you stack |
| `segmented.tsx` | `Segmented` (one among few, `orientation`), `SegmentedCount` |
| `stepper.tsx` | `Stepper` (`min`/`max` enforced here, `size` md/xl) |
| `tabs.tsx` | `TabList`, `Tab` |
| `card.tsx` | `Card` (`as="card"\|"panel"`, `interactive`, `selected`), `CardTitle`, `CardBody` |
| `recipe-card.tsx` | `RecipeCard` (favourite, selected, unavailable) |
| `list-row.tsx` | `ListRow` (`leading`/`trailing`, `selected`, `interactive`), `ListRowTitle`, `ListRowMeta`, `ListRowActions` |
| `section-head.tsx`| `SectionHead` (`as`, `action`, `hint`) |
| `menu.tsx` | `Menu` (a disclosure of secondary actions; an item with `href` renders as a link) |
| `pagination.tsx` | `Pagination` (previous / position / next — no numbered pages) |
| `icons.tsx` | `Icon` (`name`, `size`, `filled`) — one 24-unit grid, one stroke |
| `badge.tsx` | `Badge` (accent / mint / coral / neutral / solid) |
| `banner.tsx` | `Banner` (error / success / info, `title`, `action`, `onDismiss`, `position="inline"\|"fixed"`) |
| `empty-state.tsx` | `EmptyState` (neutral / error tone) |
| `spinner.tsx` | `Spinner` |
| `cn.ts` | class-name join helper |
Button variants are `primary` (one per view), `secondary`, `tertiary`, `text`,
`danger`, `soft`, `dangerText` and `quiet` — the last one is neutral and
surface-less, for a rail of icon controls at the end of a row, because `text`
is mint and five mint icons read as five links to five different places. New
UI extends or reuses these rather than writing one-off markup.
**A row shows what it is for, and hides the rest.** The recipe rail carries
cook, plan and a `Menu`; edit, duplicate, reorder and delete live inside it.
Seven controls in a rail is 308px of buttons on a 360px screen, and the two
somebody actually opens the library to use would have been the ones to wrap.
The fixed-position rule is unchanged — the menu trigger is in the same place
on every row, and an item that does not apply is disabled inside the menu
rather than missing from it. The library's own header follows the same shape:
one primary action, and importing and exporting behind one press.
**A menu's stacking order is decided by the menu.** A rail's controls sit at
`z-10`, and `position: relative` with a z-index creates a stacking context —
so a panel at `z-40` *inside* a `z-10` wrapper still loses to the next row's
controls, which then show through it. `Menu` raises its own wrapper when open;
callers pass no z-index.
**A component picks a pattern, it does not redraw it.** Choosing one among few
is `Segmented`, not chips — a chip is a filter you stack, and these are
exclusive. A line in a list is `ListRow`, whose surface *is* the line: a row
separated by a hairline has no visible target to aim at. Fewer/more is
`Stepper`, and the bounds live in it, because four copies clamped in four
places and one of them clamped on the label rather than on the click. An icon
is `Icon`: a glyph borrowed from a font renders at whatever face resolves and
says nothing — `⧉` is not read as "duplicate" by anybody, and there was no
glyph at all for "edit", which is why that action had no button for a year.
Every icon is `aria-hidden`; the control around it carries the name.
**`icon.tsx` is a reserved filename.** Anywhere under `app/`, Next.js treats
it as the metadata convention and turns it into a route expecting a default
export — the build fails with `Export default doesn't exist in target module`
pointing at a file nobody wrote. Hence `icons.tsx`.
Every component takes a `className` merged last through `cn`, so
callers adjust layout without forking the component. Each ships a colocated
`*.test.tsx`; keep that up as new ones are added, and add the component to the
`/design-system` page so the reference stays complete.
**Watch out for competing Tailwind utilities.** When two variants set the same
property (`bg-transparent` vs `peer-checked:bg-accent` vs
`peer-disabled:bg-bg-raised-2`), the winner is decided by Tailwind's stylesheet
order and specificity, not by the order you list the classes. Where a state is
known in JS, branch in JS and emit only one of them — see `checkbox.tsx`. Where
it isn't, chain the variants (`peer-checked:peer-disabled:…`) so each
combination sets the property exactly once, and verify the computed style
rather than trusting the class list.
This also catches passing a display utility to a component that already sets
one. `<Button className="hidden sm:inline-flex">` never hid anything: `Button`
sets `inline-flex` itself and `.hidden` is emitted earlier in the stylesheet,
so the CTA stayed visible at 375px and pushed every marketing page 88px
sideways. Put the toggle on a wrapper, or use `buttonClasses()` on an element
of your own.
A size behaves the same way. `<Button className="h-14">` did nothing: the
size class already in the string won, and every 56 px target in cooking mode
was quietly 46 — and 44 on `IconButton`, which was overriding the height a
second time. Sizes are therefore chosen, never patched: `size="xl"` is the
56 px control, and `IconButton` passes `size="none"` so the button emits no
height for it to fight. If a caller needs a box the library does not have,
add it to the library.
## Internationalization
`next-intl`, locales `fr` (default), `en`, `de`. Messages live in
`frontend/app/messages/<locale>/`: `common.json` for the application and
`site.json` for the public pages. Both are filled in for all three locales —
`site.json` was French in every one of them for a while, so an English visitor
read French on the front page, and `e2e/site.spec.ts` now asserts the rendered
copy rather than only the localised slug. A translated URL over French words
is exactly the failure that hid it.
Every new route needs an entry in `frontend/i18n/routing.ts` under `pathnames`
with a translated slug per locale. Always import `Link`, `redirect`,
`usePathname` and `useRouter` from `@/i18n/navigation`, never from `next/link`
or `next/navigation`, or locale prefixes get lost.
The `/design-system` page is an internal reference and is deliberately not
translated — its copy is English like the rest of the codebase.
**The German is Swiss German.** No eszett — `gross`, `Schliessen` — and the
guillemets Switzerland uses (`« … »`) rather than the German low-9 pair. The
catalogue held both spellings for a while, which is how a product billed in
francs ends up reading as though it were written elsewhere. `e2e/site.spec.ts`
asserts the rendered page, because this drifts back one string at a time.
**Translation is an acceptance criterion.** Whenever a story displays text —
labels, headings, buttons, hints, errors, empty states, emails, page titles and
metadata — the story is not done until every string goes through the message
catalogues and all three locales (`fr`, `en`, `de`) are filled in, along with
the `pathnames` slugs for any new route. Write it into the story's acceptance
criteria explicitly; a hardcoded string is a failed criterion, not a follow-up.
## Backend
Spring Boot (`ch.celestin.fuelr`) + PostgreSQL, with Flyway migrations in
`backend/src/main/resources/db/migration/`. Schema changes go in a new
`V<n>__<name>.sql` file — never edit an applied migration.
`account/SecurityConfig.java` denies anything not explicitly listed as public.
Two rules that are easy to get wrong there:
- **Errors on public endpoints need `DispatcherType.ERROR` permitted.** Spring
re-dispatches to `/error`, which is a fresh, unauthenticated request; without
that line every failure on a public endpoint comes back as 401, including a
wrong password. MockMvc does not replay the ERROR dispatch, so a green test
suite proves nothing — check it over real HTTP.
- **Anything answering about an account must not differ by whether it exists.**
Not in status, not in body, and not in *timing*: `MailService.send` is
`@Async` precisely so a reset request does not take measurably longer for a
real address once a remote relay is doing the sending.
Backend tests must not depend on a writable `/var/lib` — `MediaStorage` creates
its directory at startup, so a missing one fails **every** Spring context, not
just the upload tests. `src/test/resources/application.properties` points
`app.media.dir` at the temp directory for that reason.
## Running things
Everything runs in Docker — there is no host-Node or host-Maven workflow:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Frontend checks run inside the frontend container
(`docker compose -f docker-compose.dev.yml exec frontend npm run <script>`):
`lint`, `typecheck`, `test`, `test:coverage`. `typecheck` runs `next typegen`
first, because `LayoutProps`/`PageProps` are generated route types — plain
`tsc --noEmit` fails on a clean checkout without it.
**Run the backend suite as your own uid, never as root.** The dev backend has
no source bind mount, so its tests run in a throwaway Maven container over the
repository — and one launched without `--user` writes root-owned files into
`backend/target/`, which the next run then cannot overwrite. The failure looks
nothing like its cause: `Operation not permitted` copying a migration into
`target/classes`, and a `rm -rf target` that cannot remove its own build
output. Pass `--user "$(id -u):$(getent group docker | cut -d: -f3)"`, and
clear a poisoned `target` with a root container before blaming Maven.
**Importing fetches a URL a stranger chose, from inside the network.** That is
server-side request forgery in one sentence, so `SafePageFetcher` only speaks
http(s), re-checks every redirect hop, and refuses any host whose addresses are
private — `http://backend:8080` and `169.254.169.254` are pages like any other
without that. `app.import.allow-private-hosts` exists so tests can serve their
own fixtures; it must stay false everywhere else.
**Parse formats, not sites.** `RecipePageParser` implementations are found by
Spring and tried in `@Order`, so a site publishing nothing standard can get its
own parser later without touching a registry. But the two that exist read
*schema.org* — as JSON-LD and as microdata — which is why Fooby and Cookidoo
worked without anyone writing a line for them. Site-specific parsers rot at the
first redesign; format parsers do not.
**The imported photo is a bonus, and its URL is as untrusted as the page.**
An import now fetches what the page announced as its `image` — a string, an
array, an `ImageObject`, or an `@id` pointing at one elsewhere in the `@graph`,
all four of which are in the fixtures. That address was written by the same
stranger as the page, so the download goes through `SafePageFetcher` and no
further: same schemes, same re-check on every redirect hop, same refusal of any
host resolving to a private address. On top of that `MediaStorage.sniff` reads
the first bytes rather than the content type or the extension — a `.jpg` can be
a login page, and `image/svg+xml` is a document that executes script.
Every refusal ends the same way: a draft with no photo. A page that publishes
none, an image behind a 404, one that is too heavy, one that is not an image —
none of them is an import failure, because the cook asked for the recipe. The
suite stays hermetic by having its own server rewrite the captures' remote
image addresses to its own; nothing in it reaches the network.
**One unreadable row must never empty a screen.** An import wrote `piece`
where this app knows `pcs`, and from then on `GET /api/recipes` answered 400
for that account: the library reported itself empty, permanently, with every
recipe still in the database. Two things were wrong and both are fixed. The
reader now speaks the app's six units — `g`, `ml`, `pcs`, `c.à.s`, `c.à.c`, `sachet` —
and checks what comes back against them, because a schema is a request and not
a promise. And `NutritionService.computeForDisplay` is what a *display* calls:
it answers "no figures" where `compute` throws, so a library, a week or a card
loses its numbers rather than its existence. `compute` still throws for the
editor, where somebody typed the unit and can correct it — and that path
answers 400, not 500.
A missing unit is not an error at all: every import produces such lines on
purpose — "sel, poivre", "une poignée de coriandre" — as a line it could not
split, marked for review and left whole. They count as nothing and the recipe
keeps its figures, because denying figures to most imported recipes is a worse
answer than figures that ignore a pinch.
**What a model writes has to be in the app's vocabulary.** Units, and tags too:
the library's chips are a fixed list, so a recipe tagged "soupe" is a recipe no
filter will ever find. The reader offers the model that list and drops anything
outside it. Seasons are deliberately not asked for — `Season` is a closed
domain on purpose, and having a model guess one is a product decision nobody
has made.
**Three doors into the editor, and the screen asks before it offers.**
`GET /api/recipes/import/sources` answers what this account may import from — a
link, a photo, a screenshot — and the two refusals are named apart, because
they are two different conversations: `PLAN` is answered by subscribing, `SOON`
only by us. That is the whole of the story "no AI feature is presented as
available and then refused", and it is the same rule the plan already follows
with `canOrder`.
**The launch opens everything, and money is capped rather than gated.**
`Feature` carries whether using a capability costs money outside our own
servers — `AI_IMPORT` is the first that does — but that flag no longer decides
whether it opens. It held metered features back for a while, on the grounds
that giving away something billed per call is not a gesture but a bill. That
was the wrong instrument: nobody can subscribe yet, so the exception protected
no margin and only made the feature unreachable for the people the launch is
for. What bounds it is `AiBudget`, and it does so twice. A ceiling in money is
the right shape for this risk: it bounds the loss from a stranger without
standing in front of a cook. `app.ai.budget.launch-cents` is one budget for
everybody while nothing is charged — during the launch the tier does not
decide, since an account that ordered nothing and one that granted itself a
plan are in the same position. And `total-cents` bounds every account together,
which is the one that actually bounds the invoice: this app is public, so a
per-account budget multiplied by however many strangers register is not a bound
at all. Whichever is reached first refuses, with the same message — from where
somebody stands, a spent month is a spent month.
`RecipeIntelligence` is the seam behind it — one interface for the three inputs
no parser can read, written in nobody's SDK so the suite still touches no
network, with `NoRecipeIntelligence` ordered last while no provider is wired.
`AnthropicRecipeIntelligence` is the one that reads: plain HTTP like every
other outbound call here, and a key is the whole of `available()`, so an
environment without one offers nothing rather than failing once somebody has
chosen their photos.
Two rules bind it. The model may only answer through a declared tool schema,
so what comes back is a shape we asked for rather than prose to be parsed —
and that is what makes the second rule enforceable: **the images are written
by a stranger.** A cookbook page can be photographed with "ignore your
instructions" written across it. The system prompt says so, the tool is the
only exit, and nothing that returns is ever treated as an instruction. It is
the import's SSRF lesson in a new costume. What it produces is a `ParsedRecipe`
like any other, so a guess arrives flagged and a line it could not split keeps
`needsReview` — the editor has known how to show both since the first import.
**The library answers before a model does, and often instead.** Asked what to
cook from a bag of groceries, `MenuSuggestionService` searches the cook's own
recipes first — matched on normalised words, ranked by how much of the bag they
use — and asks a model only when fewer than three came back. Three of your own
recipes is a choice; one is a coincidence. Suggesting a dish somebody already
wrote beats inventing one: they know they like it, the quantities are theirs,
it has a photograph, and it costs nothing to find.
**Every suggestion is illustrated, and none of them lies about it.** A recipe
from the library shows its own photograph. An idea has none, and gets a tile
drawn from its own title instead — deterministic, so the same idea looks the
same twice and two ideas never look alike. Generating a picture was the
alternative and it is the one thing this application does not do with a guess:
a photograph of a dish nobody cooked, sitting beside photographs of dishes
somebody did.
**The paid reading is a last resort, never a first one.** An import runs the
two schema.org parsers first, always, and they are free. Only a page they
cannot read at all gets one more chance from a model reading the page's own
words — and only for an account entitled to it with budget left. A page that
publishes its recipe properly never costs anybody a cent, and every reason to
decline the assisted attempt becomes the same answer it has always given: this
page holds nothing, here is manual entry. Offering to sell somebody a plan in
the middle of a failed import would be reading the room badly.
**A page is photographed from the app, and cropped before it is paid for.**
`capture="environment"` hands off to the *system* camera rather than opening a
`getUserMedia` stream: the native app brings autofocus, exposure and the whole
sensor, which on small print is the difference between a reading that works
and one that fails — and a failed reading is billed like a successful one. It
also means no camera permission to ask for and no stream to release, and on a
machine with no camera the browser falls back to the file picker instead of
breaking.
The crop is kept beside the file as fractions, never applied to it, until
somebody presses "read": that is what lets a frame dragged over a 320px
preview apply to a 4000px photograph, and what keeps a photo re-croppable. It
is applied exactly once, at submit — a 12-megapixel redraw per pointer move
freezes the thread the drag runs on. And it is not only framing: what goes to
a model is billed by its size, so cutting the facing page removes tokens as
well as noise.
Two things that cost an afternoon there. **A handle that hangs outside an
`overflow-hidden` container is not merely clipped, it stops being
hit-testable** — the drag silently did nothing, with no error anywhere. And a
hand-written base64 "PNG" is not a PNG: `createImageBitmap` rejects it and the
failure surfaces three steps later as `unreadable`. Generate the fixture.
**An estimate is offered, never written.** `POST /api/log/estimate` reads a
photographed plate and hands back figures for a form; what lands in the diary
is what somebody looked at and accepted. A model that cannot recognise the
plate says so — 422 — rather than returning zeroes dressed as numbers, and
anything logged from it carries `estimated` like every other typed meal,
because a camera does not turn a guess into a measurement.
**The budget is in money, and it is decremented by what was actually spent.**
`ai_usage` keeps one row per call — never a running total, which cannot be
re-derived when a price changes — and `AiBudget` sums the month. A count of
calls would have been wrong three ways: a two-page recipe costs twice a
one-page one and should say so, a price change would silently double what
everybody gets, and a new kind of read would need a new counter. The provider
returns the tokens it counted, so nothing is estimated before the call; the
check before it is on what is already spent, which lets one read carry the
month slightly over rather than guess a price. `record` runs in its own
transaction on purpose: a draft that fails afterwards rolls back, and the money
does not. A spent month is **429**, not 402 — the plan is paid for, and the
wait has a date.
`/total-costs` is where those rows are read: an operator's page, admin only,
answering `notFound` rather than 403 in both the page and the endpoint — a
screen that exists only for operators has no reason to confirm to anybody else
that it exists. It shows the month beside the whole of it, because they answer
different questions, and totals are summed from the rows on screen so the
headline and the table cannot disagree. Figures are in dollars, as the provider
bills them. Like `/design-system` it is internal, so its copy is English and it
is deliberately not translated.
**A logged meal must copy its values, never reference the recipe.** Recipes get
edited after they have been used, so a meal log pointing at the live recipe
would silently rewrite someone's nutritional history every time they fix a typo
in the ingredients. Whoever builds the meal log copies title, quantities and
computed nutrition into the log row at the moment of logging. The recipe editor
already warns the author that edits apply to future uses only — that promise is
currently made by the copy and enforced by nothing, because there is no meal
log yet.
**The week plan is the opposite: it points at the recipe.** A planned meal is
an intention for a day that has not happened, so a recipe corrected on Tuesday
should be the one cooked on Thursday — `planned_meals` therefore holds a
`recipe_id`, and deleting a recipe takes its planned meals with it, because
nothing was cooked and there is no history to protect. The one thing the row
does store is `servings`, which belong to that evening rather than to the
recipe: they default to the household size at the moment of planning and never
move again, so changing the household does not silently rewrite what a dinner
for eight needs bought. `GET /api/plan/ingredients` is where the shopping list
will read those scaled quantities from; it exists now so that "the shopping
list updates" is provable before the list itself does.
**A day in the plan is a calendar day, not an instant.** All of `app/lib/week.ts`
computes in UTC and formats with `timeZone: "UTC"`, and the backend normalises
any date to its Monday with the same rule the client uses. Local `Date`
arithmetic gets this wrong twice a year — 2026-03-29 is 23 hours long in Zurich,
so "add one day" lands on the 29th again and Sunday's dinner becomes Saturday's
— and a date formatted in the runtime's own zone renders one day on the server
and another in the browser, which surfaces as a hydration error rather than as
the timezone bug it is. `todayIso` is the deliberate exception: which day *today*
is can only be answered locally, so the server answers it once and passes it down.
**The plan belongs to a household, not to a person.** Everyone owns exactly
one, created the first time they plan anything, and `planned_meals.household_id`
is the whole of sharing: everyone looking at the same household sees the same
rows, with no query anywhere having to remember to widen itself.
`created_by` says who put a meal there — it survives that account being deleted
(`ON DELETE SET NULL`), because the point of a shared plan is knowing somebody
already thought about Thursday. `households.size` is how many people it cooks
for, which is not how many accounts are in it: children eat without having one.
**One rule decides which plan you are looking at.**
`HouseholdService.activeHouseholdFor` returns the household you are a member
of *while its owner is entitled to share*, and otherwise your own. That single
condition is what makes cancelling safe: the member falls back to their own
plan, which was there and untouched the whole time, the owner keeps theirs, and
re-subscribing puts everyone back without anything having to be rebuilt.
Nothing is deleted on a lapse — the pricing page promises exactly that, and a
shared household is the easiest place to break the promise.
**Sharing a plan is not sharing a library.** A member may read a recipe that is
*on the shared plan* — otherwise the plan is a list of titles nobody can open —
and nothing else. That is `RecipeAudience`, an interface the recipe package
owns and the plan package implements, because the fact lives in planning and
recipes reaching into planning would put the two in a circle. It is read
access only: every write still goes through `owned()`.
**One enum holds the paid boundary.** `Feature` maps each paid capability to
the tier that opens it, `Entitlements.require` is the only check, and a refusal
is **402**, not 403 — nothing is wrong with the account, and the way past it is
a plan rather than a permission. Nothing else may compare tiers, or the pricing
page becomes a claim instead of a description.
**Nobody can pay yet, and the schema is not pretending otherwise.** Asking for
a plan writes a `subscription_orders` row that stays PENDING; a subscription is
only ever written by `SubscriptionService.confirm`, which is the method a
payment webhook will call and is idempotent because providers retry. The screen
asks `canOrder` before offering anything — a button that takes an order nobody
can settle is worse than a screen saying the plan is not open yet.
**Everything is free right now, and that is one flag.**
`app.subscription.enforce` is off, so `Entitlements.has` answers yes to
everything and no screen shows a wall. It is a single condition in a single
method because a second place deciding what is free is a second place to
disagree with the pricing page. Turning it on takes nothing away that was paid
for: an account that ordered a plan keeps it, one that never did falls back to
what the free plan has always included, and a member whose owner is no longer
entitled falls back to their own untouched plan.
The screens do not stay silent about it. `openPeriod` travels on the
subscription, the week and `/api/plans`, and where a paywall used to be there
is now a `LaunchNote` saying the feature will be paid for later — a target
somebody will pay for is not a target they own, and finding that out on the day
it stops being free is the version of this that breaks trust.
Everything else in the suite runs with the flag **on**: the backend test
properties set it, so every 402 path, the 30-day window and the household
fallback are still proven. The e2e suite runs with it off, like production, and
describes what a visitor actually meets. Both halves are covered; neither can
be checked twice in one process, since the flag is read once per boot.
**One interface stands where a payment provider will.** `PaymentProvider` is
written in the vocabulary all of them share — send the customer to a checkout,
be told over a webhook that they paid — and in nobody's SDK, so choosing Stripe
or Payrexx later is adding one `@Component`. `NoPaymentProvider` is ordered
last and is what is left while none exists: no checkout, so `canOrder` is
false, and no delivery accepted, so `/api/subscription/webhook` — public by
necessity, because a provider has no session — answers 501 and reads nothing.
When a real one arrives, its signature check is the whole of the security
there; a delivery it cannot vouch for must come back empty.
**Prices live in configuration, not in the message catalogues.** They were
three strings in three languages, which is three places to change a price and
three chances for the pricing page to disagree with what a checkout charges.
`PlanCatalogue` holds them as numbers, `GET /api/plans` serves them without a
session (the pricing page is read by people who have no account), and each
locale formats them for its own reader.
**Filling a week invents dishes; it never proposes what is already there.**
That is a product decision, and it is the opposite of the screen next door:
`MenuSuggestionService` answers "what do I cook with this bag" and searches the
cook's own recipes first, because they know they like them. Filling a week is
the other question — somebody wants dishes they have not had, and handing back
the four recipes they wrote last month is not an answer to it. So
`POST /api/plan/suggest` and `POST /api/plan/suggest/batch` ask a model for
everything, write nothing, and lay the answers on days.
Two consequences, both deliberate. **Every fill is billed**, so `AiBudget` is
what bounds these screens rather than the library. And **there is no second
source**: when a model cannot answer — no entitlement, nothing wired, a spent
month, a call that failed — the answer carries `declined` with one of `PLAN`,
`BUDGET`, `UNAVAILABLE`, `FAILED`, and the screen says which. A screen that
refuses without saying which teaches somebody to stop pressing the button. It
is the distinction `/api/recipes/import/sources` already makes.
**What a model invents is a recipe with a provenance, not a tag.** Accepting a
proposal calls `POST /api/recipes/from-idea`, which writes a DRAFT with
`origin = AI` in one call — one rather than a create followed by an update,
because the two-step dance leaves a titleless recipe behind whenever the second
half fails, and the provenance has to be set by the same hand that creates the
row. `Recipe.Origin` is `TYPED`, `IMPORTED` or `AI`; the importer sets the
second, nothing else sets any of them, and the editor never sends the field. A
cook can correct an AI recipe from top to bottom and it stays an AI recipe: that
is a fact about where it came from, not about what it says now. It could not be
one of the six tags — those describe the dish and are ticked by hand, and the
day somebody can tick "created by AI" on a recipe they typed it stops meaning
anything. The library shows it as a badge and offers it as a filter, for the
same reason: a fact the code wrote is one worth looking for.
**An answer cut short looks exactly like an empty one.** The first evening in
production, "Remplir la semaine" said *aucune proposition n'est revenue*: seven
dinners were asked of a model at a flat `max_tokens` of 2 500, the seventh was
cut mid-sentence, the tool block never closed, and `read()` found nothing. The
library had hidden this for a week by asking for one or two dishes at a time.
`AnthropicMenuIntelligence` now sizes the answer to the ask — `tokensFor` and
`answerTimeFor`, per dish, floored and capped — and logs `stop_reason:
max_tokens` by name, because from the screen a truncated answer and a refusal
are indistinguishable and only one of them is our fault.
**A dish is fetched the moment it is written, and shown when they all are.**
`DishScanner` finds each closed dish object in the tool's input as the
fragments arrive — depth three, strings and escapes tracked — and
`Progress.completed` hands it on, shaped by the controller the way the
final answer will shape it (placed on its slot, checked against the
allergens, keyed for its picture), as a `dish` event on the stream. The
screens do not draw those rows one at a time — a list that fills in under
the reader is read while it moves. They use the event to start **fetching**
each picture (`preloadIllustration`, a GET whose body is read so the
browser has the bytes) while the model writes the next dish, and lay
everything out at once, dishes and pictures together, once the `result` and
the last picture are both in. The bar runs over both halves of every dish,
written and drawn, so it never sits still, and carries a sheen because a
real count that has stopped for ten seconds reads as a hang. A one-piece answer tells its titles and its
dishes after reading, late but told. **The tool is declared with
`eager_input_streaming: true`**: without it the provider buffers the tool's
input and hands it over in a few large pieces, so three short dishes arrive
together at the end and nothing on the screen moves until then — it
streamed fine through every stand-in and batched only against the real
API. The panel says the step in words ("2.1 Nous concoctons votre deuxième
recette", "2.2 Nous réalisons votre deuxième image"), and the bag's button
never spins: while an ask is in flight it becomes *Annuler*, which aborts
the fetch and shows what had been written, since it was paid for either
way.
The correction loop is unchanged. The request is per *slot*, not per day:
`keep` carries what is already decided so a second round replaces exactly what
was turned down, and what was refused travels back by title, because an idea
has no id. Refusals come from a closed list and only "trop long" does anything
— it adds `quick` to the next ask, and the chip lighting up says so. The
free-text `note` reaches a model and nothing else, quoted as something somebody
said, never as an instruction, quotes flattened, 200 characters. Four rounds is
the ceiling.
**The shopping list is stored, not derived.** A ticked box is a fact about
somebody standing in a shop, so it has to survive the plan changing under it.
Reading the list regenerates it — quantities recomputed, lines the week no
longer needs dropped, new ones added — and the merge leaves every tick and
every free item exactly where they were. That is why a GET writes, and why
there is no "regenerate" button: a list that only followed the plan when
somebody remembered to press one would be wrong most of the time.
**A meal somebody already has everything for buys nothing.**
`planned_meals.in_shopping` is a fact about a cupboard, not about a dinner: the
meal stays on the plan, keeps its figures, is still cooked and still counted —
only `PlanService.ingredients` stops reporting it, and the list regenerates
without those lines on the next read. It had to live on the meal rather than on
a list line, because the list is rebuilt from the week every time it is opened
and a decision stored on a line would be taken again next Monday. Ticks and
hand-added items survive it untouched, which is the same merge that has always
run.
**A line is identified by its name and its unit, and by one function.**
`ShoppingService.key` is the only definition. There were two once — a space on
one side and a nul byte on the other — and the cupboard silently stopped
covering anything while both keys printed identically in the output. Anything
that matches a list line against a shelf goes through that function.
**The aisle lives on the reference food table, and the matcher takes the
longest key.** `food_nutrition` already turns "200 g de lentilles corail" into
a known food, so the aisle belongs there: one lookup, one place to be wrong.
The matcher used to take the first row that matched, which made "lentilles
corail" resolve to *ail* the day an unrelated `UPDATE` changed the physical row
order — 700 kcal of lentils quietly became 300 of garlic. Longest match wins,
ties broken by the key, so the answer no longer depends on anything unspecified.
**Nothing asks `navigator.onLine`; the request answers.** Every tick is
attempted against the server, and whatever fails is kept on the device and sent
when a later attempt succeeds. Each tick carries the instant it happened, not
the instant it syncs, so a phone coming back from a basement cannot undo a tick
made after it — `shopping_items.checked_updated_at` is what makes that
comparison possible for unticks too. The offline page shows the stored list, so
the aisle with no signal is not a dead end, and sign-out clears it along with
the cooking session: the list belongs to a household, and the next person to
sign in on that machine is not in it.
**A row created on demand is created with `ON CONFLICT`, never read-then-insert.**
The household and the week's list are both made the first time something needs
them, and a very ordinary page fires two requests at once — for a new account,
both found nothing, both inserted, and one came back 500 on the first visit.
The unique constraint is the arbiter; consulting it takes one statement and no
exception handling.
**`app.subscription.self-activate` hands out paid plans for free.** With it on,
ordering a plan grants it immediately; it exists because the tests and the dev
environment have to exercise a paid feature and there is no provider to
exercise it through. It is set in `docker-compose.dev.yml`, in the backend test
properties and on the CI e2e container — and it must stay false everywhere a
real person can reach, exactly like `app.import.allow-private-hosts`.
**An unhydrated page cannot report its own failure.** When React does not
hydrate, every control renders perfectly and does nothing: a form falls back to
a native submit, so the page reloads and the fields empty, with a clean console
and a clean terminal. No client-side handler can say so, because none are
running. `HydrationBanner` is the answer — server-rendered, removed the instant
React takes over, and held back four seconds by `.reveal-late` so a healthy page
never flashes it. If a control ever seems inert, that banner appearing is the
diagnosis.
**The library is a list of six, and it is where a recipe is put to use.**
Six rows a page: a library of two hundred recipes is not a decision anybody
makes by scrolling, so the filters narrow it and the pages are how what is
left gets read. From a row, a recipe can be cooked or put on the week without
opening it — planning asks two questions and pre-answers both, today and
dinner, because opening the planner, finding the week, finding the day and
dragging a card is four decisions for something already decided when the
recipe was picked. Every row carries a small picture at its left
(`RecipeThumb`): the recipe's photograph when it has one, otherwise a tile
drawn from its own title — a hue hashed from the words and the first food the
title names — the same every time. `/app/idees` uses the same rows.
**A dish a model wrote gets a picture drawn for it, and the picture says
so.** Anthropic's models read images and draw nothing, so `RecipeIllustrator`
is a second seam for an image model: `HuggingFaceIllustrator` speaks the
OpenAI images shape to one pinned provider behind Hugging Face's router
(FLUX.1-schnell on nscale: a second per picture, a fraction of a cent, an
Apache licence), `NoIllustrator` draws nothing while no `HF_TOKEN` is set.
**A dish is drawn while it is still a proposal.** The three screens that
ask a model for dishes wrap the stream's `Progress` so that, as each title
closes, `IllustrationService.illustrateIdea` starts drawing it on its own
pool while the model is still writing the next dish; the picture lands in
`idea_illustrations` (V34), keyed by account and normalised title
(`keyOf`), because an idea has no id. The proposal carries that key
(`illustrationKey`), `GET /api/ideas/illustrations/{key}` answers 404
until the picture is there — the screen polls it (`useIllustration`,
`IdeaThumb`) — and only the account it was drawn for can see it. Keeping
the dish (`from-idea`) calls `attach` first: the file changes hands, the
draft opens with its photo, nothing is drawn twice; a dish with no picture
yet gets `illustrate` behind the answer as before. What nobody kept is
swept after a week. Every picture goes through the same gates as every paid
call (the plan, the month's budget), records a flat `ILLUSTRATION` row the
moment the provider answers, and stores the bytes exactly as an upload is
stored: sniffed and capped. The bag screen asks for three ideas rather than
five for that reason. The prompt is
the title and the first ingredient names and nothing about the person; the
privacy page names Hugging Face and nscale. `recipes.photo_origin` (V33) is
the provenance — `UPLOADED`, `IMPORTED`, `GENERATED` — written by the code
and never by the editor, and `photoGenerated` is what lets the thumbnail and
the editor say *illustration*: a picture an image model drew is not a
photograph of a dish somebody cooked, and the screen must not let it pass
for one. Any upload replaces it and takes the origin with it.
**A backup that has not been restored is a hypothesis.** `scripts/backup.sh`
dumps Postgres, archives the `recipe_media` volume, and then restores the dump
into a throwaway Postgres and counts what came back before keeping it — an
empty database restores perfectly, so "it restored" is not the claim worth
making. The database is dumped *first* and the photos second: a photo uploaded
between the two is an orphan file in the archive, where the other order gives a
row pointing at a file that is not in the backup. `pg_dump` runs inside the
database container so its version can never trail the server's. The whole
procedure — the timer, the off-host copy, the drill, the destructive restore —
is `BACKUPS.md`, and `scripts/restore.sh` defaults to a drill because a restore
nobody has run is a document rather than a procedure.
**`ADMIN_EMAIL` names the operator, at every boot.** `AdminAccountInitializer`
used to create an account and then never look again, so pointing the variable
at the address somebody actually uses did nothing and the only answer to "make
my account the operator" was a hand-run `UPDATE` on the production database —
which nobody can replay, nobody reviews, and a restore forgets. It now
promotes the account holding that address if one exists. Two things it will
not do: it never *demotes*, because the last admin vanishing on a redeploy is
how an installation locks its owner out; and it never touches a password, so
this cannot be used to take an account over — `ADMIN_PASSWORD` is only ever
used for an account being created here. An address nobody holds creates an
account only when there is no admin at all, so a typo cannot add a second one.
**One door, one role, and it answers 404.** `/admin` is the operator's panel
— accounts, subscriptions, usage, AI costs — and `AdminAccess.require` is the
only place the role is checked, on the claim in the verified token. It throws
`NOT_FOUND`, never 403: a panel that exists only for the operator has no
reason to confirm to anybody else that it exists, and 403 answers a question
nobody should be able to ask. The layout does the same for the pages, so the
two cannot disagree. Internal like `/design-system`: English copy, not
translated.
**The account page is four forms, and two of them are not edits.** `/app/account`
saves a name on blur and a language on click — the language follows the
account (`users.locale`) and the page follows the language, which is the only
honest confirmation there is. The other two are handled as what they are. **The
email is the login**: `POST /api/account/email` needs the password, mails a
link to the *new* address and a notice to the *old* one, and nothing moves
until the link is clicked (`POST /api/auth/verify-email-change`, public like
the other token flows) — the new address arrives verified because the click
was the proof. Whether the address is taken is never confessed: 202 either
way, and what differs is in the mail, as at registration. **A password change
that leaves the other sessions open has changed nothing** for whoever holds
the previous one, so `PUT /api/account/password` closes them and keeps the
caller's, and a mail says so with the forgot-password link for the case where
it was not them. The six figures of the profile have no save button: a
field left or a card pressed is the write (`PUT /api/profile`), and the target
shown under them follows each one, because the person just changed the figure
it is computed from — a target is shown, never sprung. Nothing is written while
a figure is still missing. Neither password field is ever pre-filled.
**There is one place to say what you weigh.** `weight_entries` is the
history — one figure a day, weighing twice replaces — and `profiles.weight_kg`
follows the *latest* weigh-in, so the journal's target is computed on a
weight somebody actually has rather than the one typed the day they signed
up; writing last month's weigh-in in afterwards does not roll it back. The
profile's weight field *is* a weigh-in — typing 60 there records today's
figure — so the two screens that show a weight write the same row, and a
question asked in two places still has one answer. And nothing is said about the curve:
one series, one accent, a day without a weigh-in left as a gap rather than
drawn as zero, no streak and no verdict — a curve of weight is the field where
an application most easily starts to judge, and this one draws a figure and
its date and stops. Free, because it is the person's own number; what is paid
for is measuring a week against a target, and that is elsewhere.
**An allergy is told to the model and checked by the code.** `dietary_preferences`
holds a diet and up to fourteen allergens — both closed lists, for the reason
the seasons are: "without peanuts" has to be computable — and one free line.
`Constraints` carries them to every ask (`suggest`, `suggestFor`,
`suggestBatch`) so the answer is likely to comply, and then reads the
ingredient lines that come back against the same word lists and drops a dish
that names one, whatever the model was told; the library's `?compatible=true`
runs the same check on the recipes' own lines. An allergy filtered by the
model alone is not filtered — it is the one place in this application where
being wrong makes somebody ill, and `PreferencesTest` proves it with a stand-in
that ignores the instruction on purpose. The free line is the refusal note's
rule again: quoted as something a person said, never obeyed by anything. The
card says which box protects you, because "no peanuts" typed in the free line
would not.
**A device is named in words, and this one never closes from the list.**
`GET /api/auth/sessions` says where the account is signed in as "Chrome ·
Linux" and a last-seen time — `DeviceLabel` reduces the agent string to the
browser family and the platform, and nothing more is stored for it, because
the point is recognising your own phone, not fingerprinting it. The session
making the request is marked `current` and `DELETE /api/auth/sessions/{id}`
answers 409 for it: that one closes through `logout`, so the button that
removes a session can never remove the one it is pressed from. A cookie that
no longer opens anything sends the app layout to `/login?reason=closed`, and
the login page says the session was closed elsewhere or ran out rather than
looking like a first visit.
**The archive is built in the background and fetched once.** `POST
/api/account/export` answers 202 and `DataExportService.build` runs `@Async`:
a library with photos is not built inside a click. A mail carries a link to
`/{locale}/export?token=`, a page with one button — a page rather than a
direct download, because a browser that prefetches links would spend the one
download on a hover — and `GET /api/account/export/{token}` streams the zip
and removes it in `finally`; a second fetch is 410, and `sweep` removes at the
hour what nobody took within the day. What is inside is what is *mine*: the
meals I put on the plan (`planned_meals.created_by`), the lists of the
household I own, never a shared plan I am only a member of — and the README
says what is deliberately not there. **Deleting is the same class the
operator's panel calls**, `AccountDeletion`, with the password re-entered; a
second way to delete an account is the one that leaves photos behind. The
dialog says what it will do from what the server reports.
**Recommending is a link and a count, not a programme.** `users.referral_code`
is minted the first time the link is looked at; a visitor arriving with
`?via=` gets a thirty-day `fuelr_via` cookie from `proxy.ts`, and the register
route folds it into the one request that can keep it as `users.referred_by`
(`ON DELETE SET NULL`). Nothing reads it yet: no plan is paid for, so there is
nothing to thank anybody with, and promising it would be the pricing page in
reverse. The account sees how many came, never who, and `/admin` counts it
like everything else. No pixel, no third party — the privacy page names the
one cookie.
**The weekly reminder is the only email that is not transactional, and it is
off by default** — or it is spam. `WeeklyReminderJob` runs on the hour in
Zurich time for the accounts that chose that day and hour, and decides what
to say at the moment of sending: an empty coming week, or a list that is
ready; when there is nothing worth saying, nothing is sent. Every reminder
carries a one-click stop (`POST /api/auth/reminder/unsubscribe`, public by
token), and the card says the verification and password mails cannot be
turned off, because those are not preferences.
**Deleting an account would have deleted somebody else's week.** This is the
trap the schema sets, and it is a chain: `households.owner_user_id` cascades
from `users`, `planned_meals.household_id` cascades from `households`. Erasing
one person on their own request would silently take away every meal every
*member* had put on that shared week. `AccountDeletion` hands the household to
its longest-standing member instead — nothing that belongs to somebody else is
deleted, and sharing then simply stops if the new owner is not entitled, which
is the rule `activeHouseholdFor` already applies. The dialog says so, from
what the server reports rather than from a sentence in the component.
The photographs go too, one file at a time, before the rows that name them.
Nothing on disk has a foreign key. (Deleting a single *recipe* still leaks its
photo — a separate defect.)
**A tier granted by hand goes through `SubscriptionService.grant`**, which
makes an order and calls `confirm` — the same path a payment webhook takes. A
subscription row written directly would be a second way for one to exist, and
the second way is the one that ends up missing a field. Who granted it and why
is in `admin_actions`, which is a trace and not a feature: nothing reads it to
decide anything, and it keeps the subject's email as text because the id stops
resolving the day that account is gone.
**The panel counts, it does not measure people.** Usage comes from rows the
database already holds — `recipes.source_url` separates an import from a typed
recipe, `meal_log.source` a cooked meal from a written one — so the privacy
page's claim that this application runs no analytics stays true. Nothing in
that section is nominative. And the subscription figures say *committed*,
never *revenue*: no provider is wired, every plan was granted, and a
theoretical number shown as received is how a dashboard starts lying.
**`/total-costs` has a link, and only an admin sees it.** It was reachable
only by typing a URL nobody had written down. The link sits in the app header
and follows the same rule as the page and the endpoint: it appears for `ADMIN`
and for nobody else. It points at `/admin`, of which the costs are now a
section; the old address redirects, because an operator has it in a bookmark.
Who the operator is comes from `ADMIN_EMAIL`, above.
**Cooking mode reads the recipe and never writes to it.** That is what makes
it safe to scale the quantities on screen for six people without touching a
recipe written for four, and what lets it work with no network at all. The
session it keeps — step, servings, ticked ingredients, running timers, and a
copy of the recipe — lives in `localStorage` under one key, so it survives a
dead network and dies on sign-out. It does not follow the cook to another
device; that would need the backend and is a story of its own.
Two rules there are worth keeping. Timers store the wall-clock instant they
are due and recompute from it, because an interval alone is throttled to a
crawl in a hidden tab and comes back wrong by exactly the minutes the cook was
away. And the wake lock has to be re-acquired on `visibilitychange`: the
browser drops it every time the tab hides, so taking it once is the bug that
looks like it works.
**`navigator.onLine` is not a network check.** It reports that an interface
exists, not that anything is reachable — under Playwright's offline emulation
it says "online" while every request fails. Where the app needs to know, it is
told: the service worker serving the offline shell is a fact, that flag is a
guess.
**The service worker caches two things and refuses everything else.**
Content-hashed assets under `/_next/static`, which cannot be stale in a way
that is wrong, and one offline page per locale. No page carrying a session is
ever cached, because a worker that cached pages will eventually serve one
person's signed-in HTML to the next and nothing in a test suite would say so —
`e2e/cooking-offline.spec.ts` walks the caches and asserts it. It registers in
production only, and unregisters itself anywhere else: the dev server rebuilds
chunks under the very paths a worker would be holding. Its version comes from
the build id on the script URL, which is what makes a release install a new
worker and drop the previous one's caches.
**The reference food table is imported, not typed.** 1 216 foods from the
Swiss Food Composition Database (FSVO), names and synonyms in fr/en/de, macros
and twenty-odd micronutrients per 100 g. `tools/food-table/build.py` joins the
published workbooks into two CSVs under `resources/food/`, and
`FoodTableImporter` re-imports them whenever their checksum changes — so
following an upstream release is a regenerate, a diff and a boot, never a
hand-edited migration. The terms and the attribution live in
`tools/food-table/SOURCE.md`; the product is paid, so that file is the one to
keep in step with theirs.
**More data made matching harder, not easier, and that was the real work.**
A substring search over two dozen rows is fine; over twelve hundred it is
actively wrong, and the answer used to depend on the order rows came back in.
`FoodMatcher` normalises (lowercase, unaccented, ligatures opened — NFD does
not take "œ" apart), cuts the written name into words, and tries every run of
consecutive words longest-first against three indexes: the whole published
name, its head before the first comma, and the head's first word. A published
table says "Oignon, cru" where a cook writes "oignon", so the head index is
what makes the common case work at all; whole words are what stop "ail"
matching "corail"; and where several foods answer to one key the shortest full
name wins, because fewest qualifiers is the plainest version of the thing.
`FoodMatchingCorpusTest` holds it to 90% over 288 real ingredient lines — it
sits at 91.3%, and the number is printed on every run.
**The fallback and the "estimated" flag stay.** No published table covers a
brand, a grandmother's preparation or a regional name. What changed is that
the guess became rare instead of usual. A guessed ingredient contributes no
micronutrients at all: inventing them is the one thing the detail screen must
not do.
**A logged meal copies its values — now enforced, not just promised.**
`meal_log` stores the figures as they were at the moment of logging and keeps
the recipe id without a foreign key, so correcting a recipe in June cannot
rewrite what was eaten in March, and deleting one leaves the history standing.
Marking a planned meal cooked logs one serving, not the whole pot, and does so
once however many times it is clicked.
**Writing the diary is free; measuring yourself against it is not.**
`Feature.NUTRITION_TRACKING` gates the target, the findings and the charts;
`FULL_HISTORY` gates anything older than the 30-day window. The window is a
clamp on the start date, never a filter on the rows — the data is all still
there, and it comes back the moment the plan does.
**The findings are codes and numbers; the words live in the catalogues.**
Nothing on that screen congratulates, blames, or counts a streak, and the
rules are deliberately dull: a gap against a target, a macro that is short, a
week that was only half written down. An average is taken over the days that
were logged, never over seven — a blank day is a day nobody wrote down, not a
day of eating nothing, and treating it as zero would flatter every figure on
the screen. Every finding carries something to do about it; a finding with no
action is just a verdict.
**One series per chart, because this palette has two chromatic families.**
Protein, carbohydrate and fat in one three-colour chart needs three hues that
stay apart for a colourblind reader, and lime and mint are neighbours — the
validator puts their worst adjacent pair below the readable floor whichever
steps are chosen. The fix is not a worse palette but fewer series per chart:
three small charts side by side, each in the one accent, each with its own
target line. Nothing then depends on telling two colours apart. A day nobody
logged is drawn as a baseline tick, not a zero bar.
**Seasons are a closed domain, and that is the point.** Four values in
`recipe_seasons`, not a tag: "show me what is in season" has to be derivable
from the date, and it cannot be if the value is whatever somebody typed. A
recipe carries zero, one or several — a squash soup is autumn *and* winter,
and most dishes are of no season at all. Filtering by two seasons asks for
*either*, unlike tags, which are cumulative. `Season.of` and `seasonOf` are
the only two places that turn a date into a season, and both say out loud that
they assume the northern hemisphere: the day Fuelr ships south of the equator
that has to follow the account rather than the calendar.
**The step suggestions are a fixed list, not a generator.** Typing `/` in a
step offers ready-made phrasings from a catalogue written in advance, in three
languages — which is why the story is free. Suggesting steps *from the
recipe's own contents* would be a different thing entirely and belongs to the
paid epic. What is inserted is ordinary text: a step that cannot be edited
afterwards is a form field pretending to be a sentence.
The trigger only fires at the start of the field or after a space, and a space
closes it again. That is what keeps "1/2 citron" typable — the slash there
follows a digit, so it is a fraction and not a command, which is the failure
everybody meets first with this convention. On a phone the list opens upwards
when `visualViewport` says the keyboard has taken the room below: the viewport
is the part of the page the keyboard has *not* eaten, and `innerHeight` is not.
**What leaves the app is written down on one page.** `/privacy` — reachable
from the footer, which used to say "Conditions · Confidentialité · Cookies" and
link nowhere — names the three doors out: the AI features, which are processed
by Anthropic in the United States and therefore send what they are given
abroad; the mail relay; and the import, which fetches a page from our servers
rather than the visitor's browser. It says only what the code does, so it stays
true by being checked against the code: no hosting country is claimed, no
retention period is invented, and account deletion is described as what it is —
a request handled by hand, since no endpoint does it yet.
**A sheet of paper is a page, not a hidden copy of a screen.** `/app/recipes/[id]/print`
and `/app/shopping/print` render the sheet as their whole content, black on
white, and the browser's own dialog offers both the printer and "Save as PDF" —
no library, no server-side rendering, no fonts to embed. The first attempt kept
the sheet portalled into every screen so `window.print()` could be called in
place; it broke eight tests that had every right to expect one match for a
word, and it put a second copy of every recipe in the DOM. A page of its own
costs two routes and removes the whole class of problem — and it can be looked
at before it is printed, which a dialog cannot.
Two rules there. The chrome is hidden **by element** — `header, nav, footer` —
because the layout wraps everything in one div, so "every child of body except
the sheet's" hides nothing at all. And a print stylesheet is invisible until
somebody prints: `e2e/printing.spec.ts` emulates the medium, which is the only
thing standing between it and a silent rot at the next redesign.
**The week goes on the fridge, and that changes what is on it.** It is the one
sheet read by several people at once, most of whom have no account, from about
a metre away. So no photograph — it says nothing to somebody walking past and
costs a page of ink — and no nutrition totals, which address one person and
would be read by the whole family. `@page { size: A4 landscape }` cannot be
scoped by selector, so it is scoped by route: the rule is a `<style>` in
`week-print.tsx` and exists in the document only when the week sheet is what
the document is. The recipe and the list are read in the hand and stay
portrait.
**A sheet that fits "always" has to say what it does when it does not.** A cell
prints four meals and then counts the rest — "+ 2 autres" — because a sheet
that silently drops a dinner is a lie, and a bounded cell is what turns one
page from a hope into a promise. It is measured rather than argued about: the
spec fills all 28 slots and reads the rendered height, which comes back at
165mm against the 190mm a landscape A4 gives. And paper does not synchronise,
so the sheet says so at the bottom — somebody will find it in three weeks and
take it for the current plan.
**Responsive is an acceptance criterion, not a polish pass.** Every screen has
to hold up from a narrow phone to a wide desktop before a story is done — no
horizontal body scroll, no control pushed off-screen, no label truncated into
meaninglessness. Where space runs out, decide what to drop: the recipe editor's
stepper keeps only the current step's label below `sm`, so three labels cannot
squeeze the connectors to nothing, and the header's sign-out button drops to its
icon there. Check a narrow viewport in the browser, not only a wide one.
**The app's navigation is a bottom tab bar below `sm`, and the header keeps
none of it.** That bar has about 80px to spare and readable labels want four
times that. Wrapping them onto a line of their own was the first answer and
it solved the overflow and nothing else: the navigation ended up three rows
deep at the top of a screen held at the bottom, above the thing somebody
came to read. `AppTabs` is fixed to the bottom, 56px plus
`env(safe-area-inset-bottom)`, and `<main>` reserves that room rather than
sliding under it. Four tabs — recipes, plan, shopping, journal — the four
things done every week; each icon comes *with* its word, because a bar of
icons alone has to be learnt and this one is read by somebody holding a
knife. The household is not a fifth tab: it is set once and read rarely, so
it lives on the account page, and the account is reached from the header on
every screen. Cooking mode is in the `(cook)` route group and never gets the
bar; it has its own footer.
**The account is a hub, and the person chooses the first level.** Nine
forms on one page was a scroll, and five tabs above them was a menu nobody
asked for. `/app/account` shows who this is — name and address — and then
one card per thing somebody might have come to do, grouped (*you*,
*together*, *access and data*), each with a line saying what is behind it
and, where it helps, its state ("3 appareils connectés"). Each card is a page
of its own — `/app/account/{profile,household,preferences,security,data}`,
translated slugs — framed by `AccountSection` with the way back. Inside a
section, what somebody came for is open and what is touched twice a year is
behind a `Disclosure`. The profile is two cards and the rest folded:
*Vous* — first name, birth date (not age, because an age is wrong a year
later and nobody comes back to fix it), height, weight, sex — open by default
and foldable, since once the figures are right there is nothing on it to read
twice; and *Activité et objectif*, the two figures that change and take the
journal's target with them, asked one at a time on two tabs — a goal is three
small cards, like the onboarding, because one stacked block reads as one big
thing when nothing is chosen. Then, folded: the
address, the language, the password, the link to share. The weight typed on
the first card is today's weigh-in, so it is still one fact. Security keeps
the devices. `Disclosure` is a native `<details>` — no script,
keyboard-reachable, state announced — and it is never used for anything that
must be read, because a hidden warning is no warning. The destructive page is
last in the list and alone. `/app/household` stays as an address because
invitation mails carry it, and forwards to the household section with its
token. This is the shape the admin panel of gyoza has, and for the same
reason: a hub reads at a glance, a long page is searched.
When a control loses its visible label that way, give it an explicit
`aria-label`. Hiding the text with `sr-only` alone has bitten this codebase
twice: the button keeps its name, but a button whose label is `hidden` has none
at all.
**Accessible is measured too, and the token table was wrong.** `--gray` is
what carries every label, hint and meta line, and at neutral-500 it was 3.3:1
on `--bg-raised-2` — below the 4.5:1 WCAG asks of text. The design system
*claimed* its ratios in a table nobody computed. It now takes neutral-400 in
dark, for the same reason `--lime-ink` takes a different step per theme: a
ramp step is only readable against a particular ground.
Two things hold it. `app/lib/contrast.test.ts` reads `globals.css` and
computes every text token against every surface — no browser, one second, and
a bad colour fails the moment it is written. `e2e/accessibility.spec.ts` runs
axe over sixteen screens for WCAG 2.1 A/AA, and then does what axe cannot:
Tab actually reaching a row, the focus ring actually drawing an outline,
Escape closing a menu and handing focus back, a dialog opening with focus
inside it. Scoped to `wcag2a`/`wcag2aa` on purpose — "best practice" findings
are opinions, and a suite that fails on an opinion gets switched off.
One thing is deliberately not asserted: a flat accent against its ground.
`--lime` on a white card is 1.08:1 and that is *correct* — WCAG asks 3:1 of a
boundary only where the boundary identifies the control, and a lime button
carries its label at 12:1.
**The first Tab is a skip link.** `SkipLink` is `sr-only` until focused, which
is what makes it invisible to a pointer and reachable by a keyboard, and
`<main>` carries `tabIndex={-1}` so focus can actually land there — an element
with no tab index is a fragment target the browser scrolls to while leaving
focus where it was. It is exempt from the 44px floor in `mobile-360` and
`cooking-a11y`, matched on the *clip* rather than a class name.
**360px is the floor, and it is measured rather than argued about.**
`e2e/mobile-360.spec.ts` opens every screen at 360×640 and asserts three
things: the page does not scroll sideways, every control the browser painted is
at least 44px tall, and the bottom of a dialog is reachable on a 360×480
screen. It measures the rendered box on purpose — which of two competing
Tailwind heights wins is decided by the stylesheet's order, so a class list
proves nothing and jsdom, which loads no stylesheet, cannot tell at all. Add
each new screen to that spec; a control written into a sentence is exempt
(`display: inline`), a control with a box of its own is not.
Three habits come out of it. **A phone is not a small desktop**: below `lg` the
planner offers one add button per day instead of 21 empty slots, and both
the library and the planner open on **one bar, then the content**. The
library's bar is the search field, one *Filtrer* button that counts what is
on, and *Favorites*; the filters — a row of *popular* chips and four doors
(`FilterTrigger`), one per closed domain, whose options open under the row
(`FilterPanel`) — live in a drawer (`Dialog placement="side"`: a panel at
the right edge on a desk, a sheet from the bottom on a phone), and what is
on is named under the bar as removable chips (`ActiveFilters`). The
planner's bar is the week, one *Proposer* button revealing the two proposal
flows, and one menu for what is done once a week or less: the household (a
dialog with the stepper), duplicating, reading, printing. Hiding an option is
only allowed while it still says it is on, and a counting button does. **Order is part
of the layout**: the journal puts the meals somebody came to read before the
targets they set once a month, with `order-*` rather than a second markup.
And **a dialog is one component** — `@ui/dialog` — because the meal sheet that
centred itself put "Retirer du planning" 218px below the fold with nothing to
scroll.
**Destructive actions: `Supprimer` destroys, `Retirer` takes out of a list.**
An ingredient line or a step is deleted; a favourite, a planned meal or a
household member is removed from something and goes on existing. Where the
action cannot be undone and lands on somebody else — showing a member out of
the household — it is asked first, with the name in the question. Where it is
frequent and the row can be recreated exactly — a journal entry, a hand-added
shopping line — there is no confirmation and an undo banner instead: a dialog
on every row is a tax paid by everyone who meant it.
The journal's undo is why `POST /api/log/restore` exists. Logging a meal from a
recipe recomputes its figures from that recipe, so a restore that reused it
would hand back a different meal the moment the recipe had been edited since.
The restore takes every figure instead, which is the same rule as the log
itself: **a logged meal copies its values, never references the recipe.**
**A comment inside a backslash continuation swallows the rest of the
command.** A `#` line among the flags of a multi-line `docker run` makes the
shell drop everything after it, so the command ran with no image and the job
died with `docker run requires at least 1 argument`. It cannot fail locally —
the file only runs in CI — so notes about a flag go *above* the command.
**A green local suite can be testing files the repository does not have.**
`.gitignore` carried a bare `target/` for Maven, which is unanchored and so
swallowed `app/api/nutrition/target/` too: the onboarding preview route lived
on disk, passed every local run, and never existed in CI. `git status` said
nothing, because an ignored file is not untracked. When something works locally
and only fails in CI, check `git check-ignore -v <path>` before anything else.
**`getByRole("alert")` matches two nodes in e2e.** Next.js keeps its own
`__next-route-announcer__` in the DOM with `role="alert"`, so a page-wide query
is always a strict-mode violation. Scope the query to the form or give the
message a `data-testid`. It does not reproduce in unit tests, because jsdom has
no route announcer — so a green Vitest run proves nothing here.
**The e2e run must never share `.next` with the dev container.** It does
`npm run build`, a production build, into the same bind mount the dev server is
serving from — which wipes the chunks the already-loaded pages reference. The
symptom is brutal and silent: pages still render server-side, every script
returns 403, React never hydrates, and *every button in the app does nothing*,
with a clean terminal on both sides. `next.config.ts` reads `NEXT_DIST_DIR` and
`playwright.config.ts` sets it to `.next-e2e` for exactly this reason. If the
app ever goes inert in the browser while the e2e suite passes, check for
refused `/_next/static/chunks/*` before suspecting anything else.
**A dependency added in the dev container is invisible to the e2e run.**
`npm install` inside `frontend` writes to the `frontend_node_modules` volume;
the Playwright container mounts the host directory and reads the host's
`node_modules`. The symptom is a build that fails only under Playwright with
`Cannot find module`. Install in both, or once through the Playwright image.
Playwright browsers are not in the dev image, so `test:e2e` runs through the
official Playwright image instead — see the README for the command. The e2e
suite pins the browser locale to `fr-FR`, since the next-intl proxy negotiates
the redirect from `/` off `Accept-Language`.
Agentic Browser Automation Configuration
Cursor IDE rules and conventions for AI-assisted development.
by AgentHub Crawler
uiux
Dashboard design system — colors, typography, spacing, components, and visual patterns for the session dashboard UI.
by AgentHub Crawler
frontend-design
Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
by AgentHub Crawler
Sign in to leave a comment.
design-taste-frontend
Senior UI/UX Engineer. Architect digital interfaces overriding default LLM biases. Enforces metric-based rules, strict component architecture, CSS hardware acceleration, and balanced design engineering.
by AgentHub Crawler