diff --git a/AGENTS.md b/AGENTS.md index e002bc6..c239fa3 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,25 +1,206 @@ -# Agent guide +# Budget 2.0 — agent guide -This repository's agent instructions live in **`CLAUDE.md`** — read it first. -It carries the project invariants, the gotchas that cost real bugs, and the -validation this codebase deliberately does not perform. +The instructions for changing this codebase, for any agent or any human. This +is the only copy: `CLAUDE.md` and `GEMINI.md` are pointers to this file, so it +cannot drift into three disagreeing versions. -Then, depending on what you are doing: +Most of the rules below are here because breaking one cost real time. This file +is the short, actionable form; the *reasoning* lives in `docs/ARCHITECTURE.md` +and `docs/specs/`, and is not repeated here. -- **Changing anything structural** → `docs/ARCHITECTURE.md` (layers, data flow, and - why the non-obvious choices are what they are). -- **Picking up queued work** → `docs/TODO.md` (ordered, each item carrying the - reasoning already settled). -- **Making a product decision** → `docs/PRODUCT.md` (who this is for, what must never - break, and what must not be invented). -- **Wondering why something is the way it is** → `docs/specs/` - records the decisions *and the alternatives that were rejected*. +## 1. Invariants -Two rules worth knowing before your first edit: +- **`src/domain/` is pure.** No React, no IndexedDB, no `Date.now()`, no + `new Date()` without an explicit argument. It must be testable with no DOM + (`src/domain/smoke.test.ts` imports it without one). +- **Money is a float, rounded at every boundary.** Use `roundMoney` from + `src/domain/money.ts` after any division, after any FX conversion, and before + persisting. +- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.** + Decimal places live in `Dataset.currencies`, because the owner defines + currencies; resolve them with `digitsFor(dataset.currencies, code)`. The + argument is required on purpose — a default of 2 is right for every currency + the app shipped with, so a missed call site would pass every test and be + silently wrong for the first zero-decimal currency added. +- **When splitting an amount, never round the last part independently.** Compute + it as `total - sum(others)` so the parts sum exactly to the whole. +- **`Currency` is an open string code; `Dataset.currencies` is the authority.** + There is no global list of valid currencies. A code is IDENTITY — it keys the + FX table and every stored `Money` — so codes are normalised uppercase, are + never editable, and a currency still referenced cannot be removed + (`currencyUsage` names what refers to it). `parseDatasetJson` validates the + table and every `Money` against it; that boundary check is what replaced the + compile-time safety of the old closed union. +- **The baked FX rates seed a new dataset only.** They must NOT act as a + fallback when a rate is missing: clearing a rate removes the row so + `MissingRateError` surfaces, instead of silently converting money at a number + the owner never chose. +- **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date` + objects. +- **The envelope rollover fold has exactly one implementation**, in + `src/domain/fold.ts`. Year and summary views aggregate over it — they never + reimplement the math. +- **A post's allocation is a dated series**, `Post.rules: RuleVersion[]`, + resolved by `ruleAt(post, monthId)`. `Post.standingRule` no longer exists. A + post with no applicable version allocates zero; a per-month override still + wins outright. +- **Posts are archived, never deleted**, because purchases reference them. + Archived posts still fold. +- **An amount field parses its own currency:** "30$" is thirty dollars. Use + `parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` — + a number input discards the "$" before the parser ever sees it. +- **`sw.js` must stay unhashed and be served `no-cache`.** A cached or hashed + service worker can never be replaced, which pins users to an old build + forever. +- **A destructive whole-dataset write downloads a backup first.** Both JSON + import and "Reset everything" export the current dataset *before* replacing + it, and abort if that export fails. This is the owner's only copy. -1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite. -2. **There IS a browser here** — `bun scripts/screenshot.ts ` - drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's - "Verification limits" first: the container's fonts lie, and offline, the - install prompt, the `.ods` in a spreadsheet, and how anything *feels* still - need a human. Say so rather than claiming it works. +## 2. Guards that fail the build + +Each of these shipped as a bug once. The test named beside it is why it cannot +ship twice — re-introducing one is a regression, not a style choice. + +- **Never read `event.target.value` inside a `mutate()` callback.** Capture it + into a `const` in the handler. `mutate` defers behind the write queue, and + React resets the input's DOM value first, so you commit the old value. + → `src/ui/eventCapture.test.ts`, source-level because reproducing it needs + real browser render timing that happy-dom does not have. +- **Never define a bare `--accent`.** shadcn owns it as a hover BACKGROUND + paired with `--accent-foreground`; setting one without the other drops hover + contrast below WCAG AA app-wide. Redefine shadcn tokens only in pairs, and put + brand colour on `--primary`, never `--accent`. The app's own tokens are + `--budget-*`. → `src/cssPairs.test.ts` +- **A `var()` naming a token `src/index.css` does not define paints NOTHING, + silently.** An undefined custom property invalidates the whole declaration: + no warning, no throw, no fallback. Renaming `--rule` this way killed the carry + meter and no test noticed. → `src/ui/cssTokens.test.ts`, which matches source + *text* — so it flags a token named in a comment just as readily. +- **Every awaited `store.*` call in `src/ui/` must be guarded** by a `try/catch` + or a `.catch`. IndexedDB fails for reasons no code can prevent (full disk, + evicted storage, private-mode quota), and unguarded it is an unhandled + rejection: the button appears to do nothing and the user is told nothing. + `useMutate` is the guard for most call sites. + → `src/ui/storeWriteErrors.test.ts` +- **`roundMoney` call sites must resolve real digits**, per §1. + → `src/domain/currencyDigits.test.ts` is the only test that can tell a real + implementation from one that assumes 2: it drives a JPY-style zero-decimal + currency through rounding, both FX directions, both split modes, plan + division, the fold and the month view. +- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see + `readBasePathEnv`. Bun inlines the literal only when the variable is set; + unset, the bare reference reaches the browser and throws before the app boots. + A `typeof process` guard does *not* work: it survives inlining and discards + the inlined value, silently breaking subpath deploys. + → `src/ui/basePath.test.ts` covers that accessor. Nothing stops a *new* bare + reference elsewhere, so this one still needs your attention. +- **The pre-paint theme script in `index.html` and `src/ui/theme.ts` must + agree.** The script cannot import the module — it has to run before any module + loads — so it repeats the storage key and the fallback logic. + → `src/ui/theme.test.ts` +- **Anything derived from the base path is asserted for both a root and a + subpath deploy:** the manifest (`src/manifest.test.ts`), the font URLs + (`src/fontCss.test.ts`), the icons (`src/icons.test.ts`), and the service + worker's precache list (`src/swPrecache.test.ts`) — which must contain no + duplicate, because `cache.addAll` rejects on one, which is why the service + worker had never once installed in any environment. + +Two rules with no test behind them. Only review catches these: + +- **A React error boundary does not catch event-handler throws.** Domain + functions that throw must be guarded at the call site when called from one. +- **Do not "fix" a domain throw by making it return a fallback.** Silently wrong + numbers are worse than a visible error in a budgeting app. + +## 3. Deliberately absent validation + +Adding a "missing" guard here is a regression: + +- going over budget, anywhere; +- allocation percentages summing past 100%; +- splits that do not sum to the total (the remainder-absorbing split reconciles + them by design); +- negative amounts — a refund is a normal line. + +## 4. Environment + +**Bun only.** `bun ` (never `node` or `ts-node`), `bun test` (never jest +or vitest), `bun install` (never npm/yarn/pnpm), `bun run - - -``` - -With the following `frontend.tsx`: - -```tsx#frontend.tsx -import React from "react"; -import { createRoot } from "react-dom/client"; - -// import .css files directly and it works -import './index.css'; - -const root = createRoot(document.body); - -export default function Frontend() { - return

Hello, world!

; -} - -root.render(); -``` - -Then, run index.ts - -```sh -bun --hot ./index.ts -``` - -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. - -## Project invariants (Budget 2.0) - -- `src/domain/` is pure: no React, no IndexedDB, no `Date.now()`, no `new Date()` - without an explicit argument. It must be testable with no DOM. -- Money is a float. Always round with `roundMoney` from `src/domain/money.ts` - after any division, after any FX conversion, and before persisting. -- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.** - Decimal places live in `Dataset.currencies`, because the owner defines - currencies; resolve them with `digitsFor(dataset.currencies, code)`. The - argument is required on purpose — a default of 2 is right for every currency - the app shipped with, so a missed call site would pass every test and be - silently wrong for the first zero-decimal currency added. -- When splitting an amount, never round the last part independently. Compute it - as `total - sum(others)` so the parts sum exactly to the whole. -- `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects. -- **`Currency` is an open string code; `Dataset.currencies` is the authority.** - There is no global list of valid currencies any more. A code is IDENTITY — it - keys the FX table and every stored `Money` — so codes are normalised - uppercase, are never editable, and a currency still referenced cannot be - removed (`currencyUsage` names what refers to it). `parseDatasetJson` - validates the table and every `Money` against it; that boundary check is - what replaced the compile-time safety of the old closed union. -- The envelope rollover fold has exactly one implementation, in - `src/domain/fold.ts`. Year and summary views aggregate over it — they never - reimplement the math. -- Over-budget is always allowed. Never add validation that blocks it. -- An amount field parses its own currency: "30$" is thirty dollars. Use - `parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` — - a number input discards the "$" before the parser ever sees it. -- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath, and - read it through a `try/catch` accessor — see the gotcha below. -- A post's allocation is a dated series, `Post.rules: RuleVersion[]`, resolved by - `ruleAt(post, monthId)`. `Post.standingRule` no longer exists. A post with no - applicable version allocates zero; a per-month override still wins outright. -- Posts are archived, never deleted, because purchases reference them. Archived - posts still fold. - -## Start here - -- `docs/ARCHITECTURE.md` — layers, data flow, and why the non-obvious choices are - what they are. Read it before changing anything structural. -- `docs/TODO.md` — queued work, each item carrying the reasoning already settled. -- `docs/PRODUCT.md` — who this is for and what must never break. -- `docs/specs/` — design decisions, including alternatives rejected. - -## Gotchas that cost real bugs - -Each of these shipped once. Re-introducing one is a regression, not a style -choice. - -- **Never read `event.target.value` inside a `mutate()` callback.** `mutate` - defers behind the write queue; React resets the input's DOM value first, so - you commit the old value. Capture into a `const` in the handler. - `src/ui/eventCapture.test.ts` fails the build otherwise. -- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** (see - `readBasePathEnv`). Bun inlines the literal only when the variable is set; - unset, the bare reference reaches the browser and throws before the app boots. - A `typeof process` guard does not work — it survives inlining and discards the - inlined value, silently breaking subpath deploys. -- **Never define a bare `--accent` CSS variable.** shadcn owns it as a hover - BACKGROUND paired with `--accent-foreground`; setting one without the other - drops hover contrast below WCAG AA app-wide. Redefine shadcn tokens only in - pairs — `src/cssPairs.test.ts` enforces it — and put brand colour on - `--primary`, never `--accent`. Project tokens are `--budget-*`. -- **A `var(--token)` naming a token `src/index.css` does not define paints - NOTHING, silently.** An undefined custom property invalidates the whole - declaration: no warning, no throw, no fallback. Renaming `--rule` this way - killed the carry meter and no test noticed. `src/ui/cssTokens.test.ts` guards - it. -- **A React error boundary does not catch event-handler throws.** Domain - functions that throw must be guarded at the call site when called from one. -- **Do not "fix" a domain throw by making it return a fallback.** Silently wrong - numbers are worse than a visible error in a budgeting app. - -## Deliberately absent validation - -Adding a "missing" guard here is a regression: - -- going over budget, anywhere; -- allocation percentages summing past 100%; -- splits that do not sum to the total (the remainder-absorbing split reconciles - them by design); -- negative amounts — a refund is a normal line. - -## Verification limits - -**There IS a browser here.** `google-chrome` is on PATH, and -`scripts/screenshot.ts` drives it over CDP — it waits past the async IndexedDB -read (which Chrome's own `--screenshot` does not, which is why the docs used to -claim no browser existed), and it reports console errors and uncaught -exceptions. Read the PNG it writes. Use it before claiming anything about -layout. - -The whole recipe, from a running `bun run dev`: - -```sh -bun scripts/demo-data.ts /tmp/seed.js -bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \ - --eval-file=/tmp/seed.js --reload -``` - -Then read `/tmp/shot.png`. Other flags: `--dark`, `--full`, `--w/--h`, -`--click=`, `--profile=` (keeps a service worker and IndexedDB -across runs — required for anything stateful), `--eval-after-file` (observe a -flow that ends in a navigation from the far side of it). - -Two things that would otherwise waste your time: - -- **`--reload` is not optional.** Chrome starts from a fresh profile each run, - so the app re-seeds itself empty; seeding IndexedDB only takes effect on the - next boot. Without it every screenshot shows an app full of zeros. -- **This container's fontconfig resolves every generic family to Fira Code, a - monospace font.** The UI then renders monospaced in a way no real user sees, - and the last agent to look nearly "fixed" a font bug that did not exist. - `scripts/screenshot-fonts.conf` corrects it and the script applies it - automatically — do not undo that, and do not trust a screenshot taken with - `FONTCONFIG_FILE` set to something else. - -Still **not** verifiable here, so do not claim it: real offline behaviour, the -install and update prompts, opening the generated `.ods` in a spreadsheet, and -anything about how the design *feels*. A screenshot proves a layout exists and -is not broken; it does not prove it is good. Substitute a real check where one -exists — structural validation of the `.ods`, curl against the dev server, -pure-function tests — and state plainly what still needs a human. +This is a pointer, not a copy. Three files of instructions would become three +files that disagree. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..40edcc5 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,7 @@ +# GEMINI.md + +The instructions for this repository live in **[`AGENTS.md`](AGENTS.md)**. Read +that file. + +This is a pointer, not a copy. Three files of instructions would become three +files that disagree. diff --git a/docs/TODO.md b/docs/TODO.md index 1121292..50d3e94 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -59,7 +59,7 @@ The reasoning below is kept because it is the reasoning, not a plan. converting money at a number the user never chose. - Domain purity forces the shape: `createSeedDataset` takes the rates as an **argument**; the env read lives in a new `src/store/bakedRates.ts`, in the - store layer, using the `try/catch` accessor pattern (see CLAUDE.md gotchas). + store layer, using the `try/catch` accessor pattern (see AGENTS.md gotchas). ## 3. Visual redesign — sub-projects 1 and 2 of 4 DONE diff --git a/docs/plans/2026-09-01-budget-app.md b/docs/plans/2026-09-01-budget-app.md index aff9e55..fbf4882 100755 --- a/docs/plans/2026-09-01-budget-app.md +++ b/docs/plans/2026-09-01-budget-app.md @@ -72,7 +72,7 @@ Tests are co-located: `src/domain/money.test.ts` beside `src/domain/money.ts`. **Files:** - Create: `package.json`, `tsconfig.json`, `bunfig.toml`, `build.ts`, `src/index.ts`, `src/index.html`, `src/frontend.tsx`, `src/index.css` (all via `bun init`) -- Modify: `CLAUDE.md` (append project invariants), `.gitignore` +- Modify: `AGENTS.md` (append project invariants), `.gitignore` - Delete: `src/APITester.tsx`, `src/logo.svg`, `src/react.svg` - Test: `src/domain/smoke.test.ts` @@ -158,9 +158,9 @@ Expected: PASS, 1 test. Run: `bun --hot src/index.ts` Expected: server starts, and the printed URL shows "Budget 2.0". Stop it with Ctrl-C. -- [ ] **Step 7: Append project invariants to CLAUDE.md** +- [ ] **Step 7: Append project invariants to AGENTS.md** -The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to this project: +The scaffolded `AGENTS.md` documents Bun idioms. Append the rules specific to this project: ```markdown ## Project invariants (Budget 2.0) @@ -196,7 +196,7 @@ git add -A git commit -m "chore: scaffold Bun + React + shadcn project bun init --react=shadcn, with the demo API routes removed since this app -has no backend. Adds project invariants to CLAUDE.md and a smoke test +has no backend. Adds project invariants to AGENTS.md and a smoke test proving the domain layer imports with no DOM." ``` @@ -253,7 +253,7 @@ export const CURRENCY_DIGITS: Record = { EUR: 2, }; -/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */ +/** A float amount paired with its currency. See AGENTS.md for rounding rules. */ export interface Money { amount: number; currency: Currency; diff --git a/docs/specs/2026-09-01-budget-app-design.md b/docs/specs/2026-09-01-budget-app-design.md index b039fad..c96a73c 100755 --- a/docs/specs/2026-09-01-budget-app-design.md +++ b/docs/specs/2026-09-01-budget-app-design.md @@ -310,7 +310,7 @@ migrations are mandatory rather than optional. ## Agent support files -- `CLAUDE.md` — the `bun init` scaffold provides Bun idioms ("use `bun test`", +- `AGENTS.md` — the `bun init` scaffold provides Bun idioms ("use `bun test`", "don't use vite", HTML imports). This project extends it with its own invariants: the domain layer stays free of React and IndexedDB, money rounds at every boundary, the last distributed part is computed by diff --git a/docs/specs/2026-09-02-design-foundation-design.md b/docs/specs/2026-09-02-design-foundation-design.md index 34febd7..2722f05 100755 --- a/docs/specs/2026-09-02-design-foundation-design.md +++ b/docs/specs/2026-09-02-design-foundation-design.md @@ -48,7 +48,7 @@ colours app components may name. ### The brand teal is `--primary`, never `--accent` -CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with +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. diff --git a/docs/specs/2026-09-02-month-view-design.md b/docs/specs/2026-09-02-month-view-design.md index 6373b66..93cbe28 100755 --- a/docs/specs/2026-09-02-month-view-design.md +++ b/docs/specs/2026-09-02-month-view-design.md @@ -52,7 +52,7 @@ below the unchanged month `
`. ### The allocation meter scales to `max(income, allocated)` -Not to income. Over-budget is always allowed (CLAUDE.md), and allocating past +Not to income. Over-budget is always allowed (AGENTS.md), and allocating past income is the ordinary way that shows up here, so the meter has to render it rather than cap at a full bar: the overflow past the income mark paints `--overspend`, and the label reads past 100%. Capping would make diff --git a/src/cssPairs.test.ts b/src/cssPairs.test.ts index a075562..3dac705 100755 --- a/src/cssPairs.test.ts +++ b/src/cssPairs.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe } from "bun:test"; /** - * CLAUDE.md's hardest-won rule, made mechanical. + * AGENTS.md's hardest-won rule, made mechanical. * * shadcn uses several tokens as a BACKGROUND with a paired FOREGROUND for the * text on it. Redefining one without the other repaints the background and diff --git a/src/domain/types.ts b/src/domain/types.ts index 8d0649e..e4b203e 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -33,7 +33,7 @@ export const SEED_CURRENCIES: readonly CurrencyDef[] = [ /** Used when a currency has no definition. Two places is right almost always. */ export const DEFAULT_CURRENCY_DIGITS = 2; -/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */ +/** A float amount paired with its currency. See AGENTS.md for rounding rules. */ export interface Money { amount: number; currency: Currency; diff --git a/src/index.css b/src/index.css index 1cb5173..bb534b0 100755 --- a/src/index.css +++ b/src/index.css @@ -25,7 +25,7 @@ * than fighting it means every primitive (dialog, select, badge, button) * inherits both themes for free. * - * WHY THE PAIRS MATTER. CLAUDE.md's rule is "never define a bare `--accent`", + * WHY THE PAIRS MATTER. AGENTS.md's rule is "never define a bare `--accent`", * and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as * a hover BACKGROUND and `--accent-foreground` as the text on it, so setting * one without the other repaints the background and leaves the text behind. diff --git a/src/store/actions.ts b/src/store/actions.ts index 6850ffc..fff5338 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -25,7 +25,7 @@ import type { * store.mutate(), which clones before applying and writes through afterwards. * * Every MONEY amount that flows through here is rounded to its currency's - * minor unit (via roundMoney) before it lands in the draft, per CLAUDE.md's + * minor unit (via roundMoney) before it lands in the draft, per AGENTS.md's * "round before persisting" rule. FxRate.baseUnitsPerOne is deliberately * exempted in setFxRate below — an exchange rate is not money, and rounding * it to cents would be wrong in kind (see the comment there). diff --git a/src/ui/components/RuleHistory.tsx b/src/ui/components/RuleHistory.tsx index 2ed3116..e1eddf4 100755 --- a/src/ui/components/RuleHistory.tsx +++ b/src/ui/components/RuleHistory.tsx @@ -14,7 +14,7 @@ interface Props { * Shape AND range, matching the import validator. `setRuleFrom` sorts the * series with `compareMonths`, which THROWS on a malformed MonthId; useMutate * would catch it and show an error, but a disabled button is the better - * answer, and CLAUDE.md requires guarding a throwing domain call at the site + * answer, and AGENTS.md requires guarding a throwing domain call at the site * that invokes it from a handler. */ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;