docs: move the long-form docs into docs/, without the superpowers nesting

ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.

No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 21:56:59 +02:00
1 parent 88bf001bff
commit d83edccc79
21 files changed
+32 -32

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
# Architecture
Orientation for anyone — human or agent — changing this codebase. It explains
where things live and, more usefully, *why* several non-obvious choices are the
way they are. Most of them were paid for with a bug.
## Shape
A personal budgeting PWA. No backend. IndexedDB in the owner's browser is the
only copy of their data. One static artifact deploys to both GitHub Pages (under
a subpath) and a self-hosted Docker image.
Bun is the runtime, package manager, bundler and test runner. React 19,
Tailwind 4, shadcn/ui. No Vite, no npm, no jest.
## Layers
```
src/domain/ pure. no React, no IndexedDB, no clock. testable with no DOM.
src/store/ persistence, mutations, and the ONLY place a clock is read.
src/export/ JSON and ODS generation. pure.
src/ui/ React. reads through hooks, writes through store actions.
build.ts static build: publicPath, hashed names, SW precache, 404.html.
server.ts the Docker image's static file server.
```
The dependency rule is one-directional: `ui -> store -> domain`. `domain` imports
nothing from the others; `store` never imports from `ui`.
`src/domain/smoke.test.ts` asserts the domain is importable with no DOM. That is
why happy-dom is registered **per test file** rather than as a global bunfig
preload — a global DOM would make that assertion vacuously true and silently
retire the check.
## The fold is the single source of every number
`src/domain/fold.ts` holds the one implementation of envelope rollover:
```
remaining = carriedIn + allocation − charges (carried forward, monthly)
```
Unspent carries; overspend carries as debt. **Nothing else may recompute this.**
The month view, year matrix, summary, per-post views and the ODS export all
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
`byMonth[11].remaining`, never re-summed.
If you find yourself writing `carriedIn + allocation - charges` outside
`fold.ts`, that is the invariant breaking. It matters because two screens
silently disagreeing about someone's money is the worst failure this app has.
Editing any past month recomputes everything after it. That is correct: the fold
is a left fold, and history is causal.
## Data flow for a single edit
```
onChange
└─ capture event.target.value into a const ← synchronously, see Gotchas
└─ store.mutate(draft => action(draft))
└─ serialization queue (one mutation at a time)
└─ structuredClone(snapshot) → apply → await persistence.write()
└─ snapshot = next; notify()
└─ useSyncExternalStore re-renders
```
Three deliberate properties in that chain:
- **Write before the snapshot moves.** If the write fails, the UI keeps showing
the last *saved* state, and `useMutate` surfaces the rejection. The user is
never shown data that was not persisted.
- **Serialized.** Two edits inside one write window would otherwise both clone
the same base and the later commit would silently drop the earlier one.
- **Cloned.** `useSyncExternalStore` detects change by reference identity;
mutating in place makes the UI silently stop re-rendering.
The two whole-dataset writes take the same queue and the same rules:
`store.replace()` behind JSON import, and `store.reset()` behind Settings'
"Reset everything". `reset()` calls the same `createSeedDataset` that `load()`
uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
Both are preceded in the UI by a JSON backup download that must succeed before
the destructive write is attempted.
## Money
A float, deliberately, with two rules that make it safe:
1. **Round at every boundary** via `roundMoney` — after any division, after any
FX conversion, and at the `src/store/actions.ts` mutation boundary. The UI
adds no rounding of its own.
2. **Never round the last distributed part independently.** Compute it as
`total − sum(others)` so the parts sum exactly to the whole. `withRemainder`
in `money.ts` is the only place that logic lives.
**`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals
and are not money; quantizing one to cents is wrong in kind.
**Decimal places are data, not a constant.** `Dataset.currencies` records each
currency's code, name, symbol and digits, because the owner can define their
own. `roundMoney` therefore takes a digit count and every call site resolves it
with `digitsFor` — required rather than defaulted, so a missed site is a
compile error instead of something that works for every 2-decimal currency and
breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives
a zero-decimal currency through the whole stack, which is the only test that can
tell a real implementation from one that assumes 2.
## Splits and finance plans compose
A purchase can be split across posts *and* spread over months at once. The trick
is in `charges.ts`: first compute how the **whole** purchase divides across posts
(`splitPartsOfTotal`), then distribute each month's slice proportionally *to
those parts*. Both split modes collapse to one code path per slice.
Applying a split's raw value per-slice looks simpler and passes every percentage
test — and charges a fixed "2000 to Video Games" in full on every slice.
## Things the app deliberately does NOT validate
Adding a "missing" guard here is a regression, not a fix:
- going over budget, ever, 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).
## Gotchas paid for with real bugs
- **Never read `event.target.value` inside a `mutate()` callback.** `mutate`
defers behind the queue and a write; React resets a controlled input's DOM
value long before the callback runs, so you read the *old* value and commit
it. This froze the income field at 0. `src/ui/eventCapture.test.ts` fails the
build if it reappears.
- **Read `process.env.BUN_PUBLIC_*` through a `try/catch` accessor.** Bun only
inlines the literal when the variable is *set*; unset, the reference survives
into the browser bundle and throws `ReferenceError: process is not defined`
before the app boots. A `typeof process` guard does NOT work — after inlining
it still runs in the browser and discards the inlined value, silently breaking
subpath deploys. See `readBasePathEnv` in `src/ui/basePath.ts`.
- **Do not define a bare `--accent` CSS variable.** shadcn owns it as a
near-white hover *background* paired with near-black text; overriding it drops
hover contrast below WCAG AA app-wide. Project tokens are namespaced
`--budget-*`.
- **`sw.js` must stay unhashed and be served `no-cache`.** A cached or hashed
service worker can never be replaced, pinning users to an old build forever.
- **A React error boundary does not catch throws from event handlers.** Domain
functions that throw (`MissingRateError`, `remainderIndexOf`, `monthOf`,
`equalSlices`) must be guarded at the call site when called from a handler.
## Testing, and its limits
Most logic is pure and tested directly with `bun test`. The domain carries the
bulk of the coverage; the UI is thin by design.
**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome
driven over CDP, because Chrome's own `--screenshot` fires on the load event
and captures an empty page before this app's async IndexedDB read resolves.
That script is how the month view, the carry meter and the rule editors were
first seen by anyone. It reports console errors too, which is the point: a page
that renders blank because of a thrown error looks identical to an empty one.
Its limits are real. The container's fontconfig maps every generic family to a
monospace face, so screenshots need a `FONTCONFIG_FILE` override to be
representative; Chrome starts with an empty profile each run; and no screenshot
can judge how a design feels. Real offline behaviour, the install and update
prompts, and the `.ods` opening in a spreadsheet remain unverifiable here.
Where a check could not be run, the honest substitute was used and recorded —
the `.ods` is validated structurally by unzipping it and asserting `mimetype`
is first and stored, every XML part parses, and numeric cells carry
`office:value`.
`src/ui/eventCapture.test.ts` is a *source-level* guard rather than a
behavioural one, because the bug it prevents depends on real browser render
timing that happy-dom does not reproduce.
## Where the reasoning lives
- `docs/PRODUCT.md` — durable product truth: who it is for, what must never break.
- `docs/TODO.md` — queued work, with the reasoning behind each decision already made.
- `docs/specs/` — design decisions and the alternatives rejected.
- `docs/plans/` — task-by-task implementation plans.
+111
View File
@@ -0,0 +1,111 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
One person: the owner of the money, managing their own budget. They are leaving
a spreadsheet, so they already know their own categories, already think in
months, and value speed of entry over guidance. They tolerate rough edges in
exchange for the tool doing exactly what they mean.
Confirmed permanently single-user. One person, one browser, one dataset. There
are no accounts, no second role, and no shared or household use planned.
## Product Purpose
Envelope budgeting for a single person, replacing a spreadsheet.
Money is divided each month into named envelopes ("posts"). Unspent allocation
carries into the next month; overspending carries forward as debt. The app
answers two questions at a glance: how much is left in this envelope, and did I
overspend.
Success is that the owner stops maintaining a spreadsheet, and that a month's
purchases can be entered fast enough not to be a chore.
## Positioning
Two things a general budgeting app does not do:
- **Envelope rollover is the whole model, not a feature.** Every figure derives
from one left-fold across months, so a balance is always the honest
consequence of every prior month.
- **The data never leaves the browser and never needs an account.** IndexedDB is
the only store. That is a deliberate product position, not a missing backend.
## Operating Context
The owner records spending after the fact, often in batches — a grocery trip is
twenty lines entered in one sitting, not twenty separate visits. Income arrives
monthly and varies, so it is entered per month rather than assumed.
Purchases frequently belong to more than one envelope (food bought at a social
event), and larger purchases are spread across several months deliberately
rather than absorbed by one.
Money is handled in DKK, USD and EUR, with DKK as the base.
## Capabilities and Constraints
Confirmed and in place:
- Posts (envelopes) with monthly allocation, created and archived by the owner.
Posts are archived, never deleted, because purchases reference them.
- Allocation is either a fixed amount or a percentage of that month's income.
- A purchase can be split across several posts, by percentage or by fixed
amounts, and can also be spread across future months as a payment plan. The
two compose.
- Per-month income, entered directly, varying month to month.
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
with an optional on-demand fetch that fails soft and is never required.
- JSON export/import and ODS (spreadsheet) export, both generated in the
browser.
- Offline-capable installable PWA. Currently deploys as one static artifact to
both GitHub Pages (under a subpath) and a self-hosted Docker image.
Terminology: an envelope is called a **post**. A **finance plan** is a purchase
spread across months. An **override** is a one-month exception to a post's
allocation.
Undecided: nothing outstanding at the product level.
## Brand Commitments
The product is called **Budget 2.0**. No logo, wordmark, or brand assets exist,
and none have been commissioned.
## Evidence on Hand
None, and none may be invented. This is a personal tool: there are no
customers, testimonials, case studies, usage statistics, press, pricing, or
third-party integrations. Any future surface must not manufacture them.
The only real content is the owner's own budget data, which never leaves their
browser and is therefore never available as example material. Illustrative data
must be visibly synthetic.
## Product Principles
1. **Over-budget is always allowed.** The app reports the truth and never
blocks, clamps, or hides overspending. A negative balance is a normal value.
2. **Changing an allocation must never destroy history.** Adjusting what a post
gets going forward leaves what it got before intact and still visible.
3. **Splitting a purchase across posts must stay easy.** It is a routine act,
not an advanced feature, and must not be buried behind extra steps.
4. **The data stays portable and in the owner's hands.** Export is always
available and always complete; nothing traps a budget inside the app.
5. **Entry speed is a feature.** Recording a month of spending is the most
repeated act in the product, and friction there is the reason spreadsheets
get abandoned.
## Accessibility & Inclusion
No condition-specific requirement was established by the owner. The existing
implementation holds itself to WCAG AA contrast, visible keyboard focus, and
respecting reduced-motion preferences; keyboard-first entry is a stated goal of
the fast-entry flow rather than only an accessibility accommodation.
Executable
+215
View File
@@ -0,0 +1,215 @@
# TODO
Work queued for whoever picks this up next, human or agent. Ordered. Each item
says enough to act on without re-deriving the reasoning.
**Current state:** merged to `main`. 443 tests passing, `bunx tsc --noEmit`
clean, `bun run build` succeeds on both the live-fetch and offline paths.
Sections 2, 4 and 7 are DONE. Section 3 (redesign) is 2 of 4 sub-projects in.
Section 6 (currencies) is DONE. Every screen has now actually been looked at —
see section 5.
**Schema is at version 3.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset. Both migrations are
behaviour-preserving by construction and tested as such.
---
## 1. ~~Time-versioned allocation rules~~ — DONE, all six tasks
**Spec:** `docs/specs/2026-09-02-versioned-allocation-rules-design.md`
**Plan:** `docs/plans/2026-09-02-versioned-allocation-rules.md`
**Ledger + extracted briefs:** `.superpowers/sdd/2026-09-02-versioned-allocation-rules/`
| Task | What | Commit |
|---|---|---|
| 1 | `Post.rules: RuleVersion[]` replaces `standingRule` | `68712e7` |
| 2 | v1 -> v2 migration | `15dcf93`, `1214d2b` |
| 3 | `setRuleFrom` / `removeRuleFrom` actions | `de128e3` |
| 4 | JSON import validation for the rule series | `1cb91d3` |
| 5 | Rule-history editor in Settings | `e3023d0` |
| 6 | "Change from here" control in the month view | `f94e458` |
Tasks 3-6 were executed by hand rather than dispatched. Every deviation from
the briefs is recorded in the ledger's task log with its reasoning; four of
them fixed real defects in the brief code (JSX that would not compile, a
component that pre-filled the previous post's rule, a number field that could
not be cleared, and missing validation that let a NaN allocation into the
fold). Worth a read before reviewing the diff.
Not yet done for this feature: nobody has independently reviewed tasks 3-6, and
no browser has seen the two new editing surfaces.
## 2. ~~Baked FX rates~~ — DONE (`b4e1933`)
So a fresh install can convert a EUR purchase on day one instead of erroring.
Built as designed, all three build paths verified (live fetch, unreachable
endpoint, `SKIP_FX_FETCH=1`), and confirmed in the real UI: Settings shows
USD 6.449532 / EUR 7.474959 as `manual · 2026-09-01` on a fresh dataset.
The reasoning below is kept because it is the reasoning, not a plan.
- `build.ts` fetches DKK/USD/EUR at build time and embeds them via `define`;
on any network failure it falls back to **committed constants** rather than
failing the build. Fetched rates get `source: "api"` with the build date;
the fallback constants get `source: "manual"`, so Settings never claims a
hardcoded number came from an API.
- They **seed a new dataset only**. They must NOT act as a fallback whenever a
rate is missing — that would undo a deliberate decision: clearing a rate
removes the row so `MissingRateError` surfaces, instead of silently
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).
## 3. Visual redesign — sub-projects 1 and 2 of 4 DONE
The owner's words: *"right now it looks a bit boring, simple and ugly. We want
sleek, modern, and nice to use."* Then, on scoping: both themes with a toggle,
a full rethink of every screen, purchases compact and grouped by date, ship a
webfont — and *"override everything you want for this step."*
**Spec:** `docs/specs/2026-09-02-design-foundation-design.md`
**The diagnosis, which should shape the rest:** a design system already existed
and was never wired up. `src/index.css` defined the paper/ink/teal palette with
a contract document behind it, while the app painted shadcn's default white and
near-black. The tokens reached the screen in two places only. Doing the
foundation last is how that happened; do not repeat it.
| # | Sub-project | State |
|---|---|---|
| 1 | Design foundation — two-theme tokens, theme toggle, self-hosted Inter + JetBrains Mono, one Section/Stat card, table furniture | DONE (`bb3c661`) |
| 2 | Month view | DONE. Date-grouped purchases (`a159189`), then the header, the post rows and the carry meter |
| 3 | Year, Summary, Post detail | on the new foundation but not rethought. The 12-month matrix only fits by scrolling — decide whether compact number formatting replaces that |
| 4 | Dialogs and fast entry | untouched. Native `<select>` elements are still browser-default, and `up`/`down`/`archive` are bare text buttons |
**Sub-project 2, for whoever does 3 and 4.** Spec:
`docs/specs/2026-09-02-month-view-design.md`. Three things it
settled that the reporting screens should follow rather than re-decide:
- **Meter geometry is `src/ui/meterSegments.ts`**, pure and tested, with
`<Meter>` as the only thing that paints it. A third meter belongs there too,
not inline in a component. Negatives clamp rather than invert, because a
segment of negative width puts the gradient's stops out of order and paints
something arbitrary instead of failing.
- **Two markups, not one reflowing grid.** The post table is a real `<table>`
from `sm:` up and a list of two-line blocks below it. At 390px the layout
genuinely stops being a table, and `role="table"` on it would describe rows
and columns that no longer exist on screen. The year matrix faces the same
question and has more columns to lose.
- **State the currency once per table, not per cell.** Every figure in the post
table is base currency, so `formatSignedAmount` prints bare numbers and the
legend names DKK once. This is what stopped "+4,219.61 DKK" wrapping on a
phone. `formatMoney` is still right wherever an amount can be in some other
currency — every purchase row, for one.
Also worth knowing: `src/ui/cssTokens.test.ts` matches source *text*, so it
flags a token named in a code comment as readily as one in a `style` — which is
exactly what it did to the comment explaining it.
**Watch for:** the brand teal is `--primary`, never `--accent` — shadcn's
`--accent` is a hover *background* whose paired foreground must move with it.
`src/cssPairs.test.ts` enforces the pairing, and `src/ui/cssTokens.test.ts`
fails if a component names a token `index.css` does not define. That second
guard exists because renaming `--rule` silently made the carry meter paint
nothing: an undefined custom property inside `linear-gradient()` invalidates
the whole declaration without warning.
## 4. ~~Smaller items~~ — ALL DONE
All six, `b9be882` / `71e8fc9` / `f50c65b`. Three were worse than recorded:
- `confirmImport` now guards its write, and a source-level check covers the
whole UI (`b9be882`).
- The PNG icons **were** generatable here — Chrome is on PATH and rasterizes
the SVG exactly. `scripts/make-icons.ts` produces them; they are committed,
precached, and pixel-verified against the source (`f50c65b`).
- Split values now round by what they MEAN — money in "fixed" mode, a
percentage in "percent" mode. Narrower than the note claimed: `round2` was
always right for `Rule.percent`, which is not money (`b9be882`).
- `EMPTY_FIGURES` is frozen (`b9be882`).
- The ODS export was **not** "fine at personal-budget scale": 30 posts over 10
years took 7983 ms synchronously on the main thread. Now 135 ms, with
byte-identical output (`71e8fc9`).
- The dev manifest did not merely hardcode `scope: "/"` — it was never served
at all; the catch-all answered with the HTML shell. One definition now feeds
the build and the dev server (`b9be882`).
## 6. ~~User-defined currencies, and amounts that carry their currency~~ — DONE
Both `cdece63`. `Currency` is an open string code and `Dataset.currencies` is
the authority on validity, symbols and decimal places; the purchase total
field reads "30$" as USD 30.
**The part worth knowing before touching money again:** `roundMoney(amount,
digits)` takes a digit count, and its ~29 call sites each resolve digits from
the dataset with `digitsFor`. The argument is required deliberately — every
currency the app shipped with uses two decimals, so a default of 2 would leave
a missed call site passing every test and silently wrong for the first
zero-decimal currency added. `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 currency through rounding, both FX directions, both split modes,
plan division, the fold and the month view.
Left undone, deliberately:
- **The income field and the split editor's fixed-amount fields do not parse a
currency yet** — only the purchase total does. Same helper
(`parseMoneyInput`), same three-line pattern; the income field is arguably
wrong to make multi-currency at all, since a month has one income.
- **No currency picker convenience.** Adding one means typing the code, name,
symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and is
pure data.
- **Symbol collisions resolve to the first currency defined with that symbol.**
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 7. ~~Reset to a fresh install~~ — DONE
Settings > Your data > "Reset everything…". The owner asked for a reset that
includes the currencies and the seed categories, and chose fresh-install state
over an empty dataset: the three starter posts, the DKK/USD/EUR table, the
baked rates, this month at zero income, no purchases.
- `store.reset()` calls the same `createSeedDataset(currentMonth, seedRates)`
that `load()` uses on a first run, through the same write queue. Seeding
lives in the store, not the UI, so "reset" and "a brand-new browser" cannot
drift apart.
- The confirm panel reuses the import flow's shape and its **order of
operations**: the JSON backup downloads BEFORE the destructive write, and a
backup that fails aborts the reset. This is the user's only copy.
- Verified in Chrome by clicking through and reading IndexedDB back: 3 seed
posts, DKK/USD/EUR, both baked manual rates, 0 purchases, one month at zero
income.
## 5. Still needs a human
`scripts/screenshot.ts` drives headless Chrome, so the list here is much
shorter than it was. **Seen and confirmed working:** the carry meter (it
renders, and shows red where a post is overspent), the rule-history editor in
Settings, the "change from here" control, the baked FX rates arriving in a
fresh dataset, and every route at desktop and mobile widths.
Genuinely outstanding:
- **How it looks and feels.** A screenshot proves a layout is not broken. It
cannot tell you the design is good, and the container's fonts are not the
owner's fonts.
- **Offline behaviour** and the **install prompt**. The service worker itself
is no longer unverified: it demonstrably installs, activates, claims the
page, precaches all 11 assets, and applies an update cleanly (`e6da85e`) —
before that fix it had never installed once, in any environment, because
`cache.addAll` rejects on a duplicate request. What is still unverified is
a real offline load with the network actually cut, and Chrome's install
prompt appearing.
- the generated **`.ods` opening in a real spreadsheet** (validated
structurally: mimetype first and stored, well-formed XML, numeric cells).
- **the pre-destructive backup actually landing as a file.** Both the import
and the reset flow download a JSON backup before replacing the dataset, and
the code path demonstrably runs (the reset proceeds only when it returns
success). Whether headless Chrome wrote the file is not observable here, so
nobody has yet confirmed the backup is openable — only that it was issued.
- **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
focus placement after a row auto-appends. Drivable over CDP in principle;
not yet done.
- **GitHub Actions** has never been executed.
@@ -15,7 +15,7 @@
**Tech Stack:** Bun 1.3+ (runtime, package manager, bundler, test runner), React 19, Tailwind 4, shadcn/ui, `fflate` for ODS zip generation, hand-written service worker. No Vite. No Electron.
**Spec:** `docs/superpowers/specs/2026-09-01-budget-app-design.md`
**Spec:** `docs/specs/2026-09-01-budget-app-design.md`
## Global Constraints
@@ -177,7 +177,7 @@ The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to th
reimplement the math.
- Over-budget is always allowed. Never add validation that blocks it.
- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath.
- Read the spec at `docs/superpowers/specs/2026-09-01-budget-app-design.md`.
- Read the spec at `docs/specs/2026-09-01-budget-app-design.md`.
```
- [ ] **Step 8: Ignore the IDE directory**
@@ -7357,8 +7357,8 @@ your current data first.
## Documentation
- Design: `docs/superpowers/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/superpowers/plans/2026-09-01-budget-app.md`
- Design: `docs/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/plans/2026-09-01-budget-app.md`
```
- [ ] **Step 7: Final verification**
@@ -8,7 +8,7 @@
**Tech Stack:** Bun (runtime, package manager, test runner), TypeScript strict, React 19, Tailwind 4, shadcn/ui. No new dependencies.
**Spec:** `docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md`
**Spec:** `docs/specs/2026-09-02-versioned-allocation-rules-design.md`
## Global Constraints
@@ -315,5 +315,5 @@ migrations are mandatory rather than optional.
invariants: the domain layer stays free of React and IndexedDB, money
rounds at every boundary, the last distributed part is computed by
subtraction, and the rollover fold has exactly one implementation.
- `docs/superpowers/specs/` — this document
- `docs/superpowers/plans/` — the implementation plan, written next
- `docs/specs/` — this document
- `docs/plans/` — the implementation plan, written next
@@ -76,7 +76,7 @@ rather than on the arithmetic.
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 PRODUCT.md's position is that the
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.
@@ -19,7 +19,7 @@ Three problems, found by looking at the rendered page in Chrome at 1280px and
Income, Allocated, Spent and Unallocated sat in one row at one weight. The
income *input* — the only editable thing on the screen — was in the same visual
register as three numbers derived from it. And nothing at the month level
answered the second of PRODUCT.md's two questions, *did I overspend*: with
answered the second of docs/PRODUCT.md's two questions, *did I overspend*: with
Transport at −3,200 DKK, you had to read down the Remaining column to find out.
**The carry meter belonged to the wrong row.** It is the design contract's
@@ -41,7 +41,7 @@ Three zones in reading order, in the one `Section` card, on the paper ground
below the unchanged month `<header>`.
1. **Income** — the field. Unchanged in behaviour: still `type="number"`, still
base-currency only. Section 6 of TODO.md records the reasoning — a month has
base-currency only. Section 6 of docs/TODO.md records the reasoning — a month has
one income, so making the field parse a currency the way the purchase total
does is arguably wrong rather than merely undone. It keeps field affordance
so it stays visibly the one editable thing on the screen.