Files
budget2.0/docs/specs/2026-09-02-design-foundation-design.md
T
Lucas WintherandClaude Opus 5 824c2f5f59 docs: make AGENTS.md the only agent guide, restructured into five sections
AGENTS.md now holds the instructions and CLAUDE.md and GEMINI.md are
pointers to it. Three files of instructions become three files that
disagree; one file every agent reads cannot.

The content is reorganised rather than rewritten, into: the invariants,
the guards that fail the build, the validation this codebase deliberately
does not perform, the environment, and where to read next. Every guard
now names the test that catches it, and the two rules with no test behind
them are called out as such, because "only review catches this" is
information the reader needs.

Restructuring surfaced one rule that was missing here entirely — sw.js
must stay unhashed and be served no-cache — which had only ever been
written down in ARCHITECTURE.md.

Dropped: the `bun init` boilerplate code samples. Every rule they carried
is still stated; the hello-world test and the three frontend snippets are
not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:57:38 +02:00

7.6 KiB
Executable File

Design foundation

Sub-project 1 of the visual redesign. Owner's brief: "right now it looks a bit boring, simple and ugly. We want sleek, modern, and nice to use." Owner's answers to the four scoping questions: both themes with a toggle, a full rethink of every screen, purchases compact and grouped by date, and a self-hosted webfont. Owner then delegated the specifics: "Override everything you want for this step. This needs to look good."

The diagnosis that shaped everything else

A design system already existed and was never wired up. src/index.css defined --paper (annotated "cool statement-paper ground, NOT cream"), --ink, a muted teal, and surplus/overspend colours, with a design-contract document behind them. The app painted itself with shadcn's defaults — bg-background text-foreground, i.e. white and near-black. Those tokens reached the screen in exactly two places: the carry meter, and the sign colours on figures.

So "boring, simple and ugly" was not a missing design. It was a design that was specified and then bypassed. That is why the foundation comes first in the decomposition, and why it re-themes shadcn rather than styling screens: doing it screen by screen is how the bypass happened.

Decomposition

The redesign is four sub-projects, each its own spec → plan → implementation:

  1. Design foundation (this document) — tokens, themes, type, shared furniture.
  2. Month view — income and totals, post rows and carry meter, purchases grouped by date.
  3. Year, Summary, Post detail — the reporting screens, including whether the 12-month matrix stays a scrolling table.
  4. Dialogs and fast entry — purchase dialog, split editor, plan editor, bulk entry.

A single spec covering all four would have meant dozens of layout decisions made on the owner's behalf under one approval.

Token architecture

Two layers, deliberately separate.

Layer 1, --budget-*: the app's own vocabulary — paper, raised, ink, ink-muted, rule, rule-strong, accent, accent-ink, tint — plus --surplus and --overspend, which shadcn has no concept of. Defined once per theme. The only colours app components may name.

Layer 2, shadcn: its entire variable set points at layer 1. Every primitive (dialog, select, badge, button) inherits both themes for free.

The brand teal is --primary, never --accent

AGENTS.md's hardest-won rule is "never define a bare --accent", paid for with an app-wide contrast bug. The mechanism matters: shadcn uses --accent as a hover background with --accent-foreground as the text on it. Setting one without the other repaints the background and leaves the text behind.

Two conclusions follow, and both are enforced rather than remembered:

  • Every shadcn pair is redefined together, in both themes. src/cssPairs.test.ts fails the build if either half of a pair appears without the other, in any block.
  • The saturated teal belongs on --primary. Putting a brand colour on --accent — which is what "override --accent" invites — turns every hover into a coloured slab with dark text on it. --accent stays a tint of the surface.

Contrast was computed, not chosen

Weakest text pair: 5.50:1 (muted on paper, light) against a 4.5:1 requirement. Field borders were iterated until they cleared the 3:1 non-text requirement on both backgrounds they appear against — the first two candidates measured 2.17 and 2.55 and were rejected. The final palette was then read back out of the live browser in both themes, so the check is on what actually rendered rather than on the arithmetic.

Typography

Inter for text, JetBrains Mono for figures, both variable, both self-hosted, latin + latin-ext only. SIL OFL 1.1; licences committed beside the files. Self-hosted rather than CDN-linked because docs/PRODUCT.md's position is that the data never leaves the browser and the app needs no third party — a font request to another origin on every cold load quietly contradicts that.

.font-money carries tabular figures and a slashed zero, so columns align on the decimal and 0 cannot be misread as O.

Rejected: @font-face in the bundled stylesheet

The obvious home. Bun's CSS bundler resolves every url() from disk and inlines woff2 as base64 — relative or absolute, and loader: { ".woff2": "file" } does not change it. Measured cost: index.css went from 70 KB to 303 KB of render-blocking bytes, and the unicode-range subsetting became pointless, since a subset embedded in the stylesheet downloads whether or not one character needs it.

Also rejected: a <link rel="stylesheet" href="/fonts.css">. It works in a production build (external: ["*fonts.css"]), but the dev server's HTML bundler resolves every <link href> from disk and has no external option, so dev would serve a placeholder and screenshots would lie about typography. Making the manifest and icon hrefs root-absolute for the same reason 500'd the dev server outright.

Chosen: src/fontCss.ts is the one definition, injected as a <style> at boot by frontend.tsx. Identical in dev and in a subpath build, no placeholder file, no href rewriting. The cost is that faces are requested after the JS parses rather than from the HTML; font-display: swap keeps text readable throughout, and the service worker precaches all four files.

Theme switching

Cycles system → light → dark. Three states because "follow the system" is a real answer and, once you have chosen otherwise, there must be a way back to it. While set to "system" the app keeps listening, so it follows the OS switching at sunset without a reload.

Stored in localStorage, not in the dataset: the dataset is the owner's budget, which is exported, imported and schema-versioned, and importing a backup taken on a desktop must not flip a phone to dark. Every storage access is wrapped — localStorage throws on access in some configurations rather than returning null, and a display preference must never be why the app fails to boot.

A tiny inline script in index.html applies the saved theme before first paint, so a dark-theme user never sees a flash of the light palette. It cannot import the module (it has to run before any module loads), so it repeats the key and the fallback logic; src/ui/theme.test.ts asserts the two agree.

The regression this produced, and the guard it earned

Renaming --rule to --budget-rule silently killed the carry meter — the month view's signature element. An undefined custom property inside linear-gradient() does not warn, does not throw, and does not fall back: the whole declaration is invalid, so the element paints nothing. It looked like a missing feature rather than a bug, and no test noticed. Pixel-sampling a screenshot found it.

src/ui/cssTokens.test.ts now fails if any TypeScript file references a custom property src/index.css does not define. This is the second guard of its kind in the repo, after eventCapture.test.ts, and for the same reason: the failure is invisible in review and invisible in a green suite.

Verification, and its limits

scripts/screenshot.ts drives headless Chrome over CDP, which is how any of this was seen at all. Both themes were rendered with real data on every route at desktop and mobile widths; the resolved tokens were read out of the browser; the carry meter's presence was confirmed by pixel sampling rather than by eye.

Not verified, and not verifiable here: how it feels, real offline behaviour, the install prompt, and typography on the owner's actual platform — this container has no Inter installed for the fallback stack and its fontconfig maps every generic family to a monospace face, so screenshots need a FONTCONFIG_FILE override to be representative at all.