This month's row keeps its one-click Confirm for both anchorings — for an
anchored cost that records "paid on time" at the due date, and paying a bill
on schedule is the common case that must not get slower.
A future row is different. Only a calendar cost can be paid early in a way a
button can express, because its grid does not move. Confirming a future
ANCHORED charge is the refresh, and a refresh needs a real date and possibly a
different amount — the purchase dialog's job. A button here would have to
invent a date, and inventing it is what produced the round-1 Critical.
Also rewrites expectedGroups' docstring, which still described the deleted
X1/wouldAdvancePast rule as current behaviour, and drops a stray "X1" label
left over a defensive null-guard it no longer has anything to do with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C1 (Critical): a month-only startDate on an anchored everyNMonths cost with
no charges yet reached addMonthsToDate directly and threw a bare parsing
error naming no cost, several frames below foldBalances. anchoredOccurrences
now checks its starting cursor's granularity before the walk begins and
throws a cost-named, actionable error instead. No new validation at either
write boundary — that stays out of scope here. stepFrom's docstring no longer
asserts a guarantee ("required to carry a day-granular startDate") that
nothing in the code enforces; it now says what actually protects it.
I1 (Important): the anchored walk's own termination throw had zero test
coverage — both surviving "termination" tests use the cost() fixture, whose
default anchoring is calendar, so they only ever exercised gridOccurrences.
Proven by mutation (replacing the anchored throw with break left every test
passing). Added a test that actually walks anchoredOccurrences, and corrected
the inline comment that falsely claimed coverage existed.
I2 (Important): confirmationsFor's throw is reachable from data the JSON
importer currently accepts (a calendar-cost purchase missing occurrenceDate),
not just a hypothetical programming error — its docstring said "programming
error upstream," which isn't true yet. Docstring corrected, and the throw is
now pinned by a test instead of only asserted in prose.
I3 (Important): nothing asserted that editing a charge's date backwards
re-anchors the series instead of throwing — exactly the behaviour deleting
requireConfirmationAdvances made structurally safe, and exactly the gap left
by deleting round-1's "a date more than one cycle before the slot it claims
is refused" test. Added the inverse: same fixture, but asserting the edit now
succeeds, the fold still folds cleanly, and the series re-anchors to the
edited date.
Minor: round-1 deleted ExpectedBand.test.ts's "stays suppressed once X3 no
longer applies" (X1-only), leaving its underlying scenario (day-granular
anchored cost, slot beyond the displayed month, X3 already satisfied)
completely untested even though still reachable. Added a test pinning what
expectedGroups actually does with it now that X1 is gone: the row is offered,
not suppressed — so a future reintroduction of suppression here is a visible,
deliberate change instead of a silent one.
Left untouched, as instructed: ExpectedBand.tsx's stale X1 docstring and the
Settings anchoring dropdown (both belong to later tasks).
A lastCharge cost's next due date is one cycle after the latest actual charge.
So it can be charged any number of times per cycle, each charge restarts the
clock, and the reported bug is gone: two payments in September leave the next
due in October, not November.
calendar is untouched and keeps its grid. Paying September AND October's rent
really does mean nothing is due until November — the bug was only ever a bug
for the anchored case.
occurrencesOf's second parameter changes from a confirmations Map to the raw
purchase list: the anchored walk needs chargesFor's view (every Purchase.date
for the cost, latest wins), the grid walk needs confirmationsFor's view
(purchases keyed by the slot they settle), and only occurrencesOf itself knows
which to build. occurrencesByMonth now passes dataset.purchases straight
through instead of pre-building a map.
stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task
2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored
walk's cursor is always day-granular now (startDate or a real charge date), so
the condition was dead; if a month-only date ever reaches that branch,
addMonthsToDate throwing is correct, not a bug to paper over.
confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding
guard (also Task 2) is now a thrown error instead: the function is calendar-
only, a lastCharge charge should never reach it, and continuing silently would
hide a programming error as a slot that merely looks unpaid.
DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the
tests that existed only to exercise them (6 in occurrences.test.ts describing
the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in
ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the
series" suppression, 5 in actions.test.ts exercising
requireConfirmationAdvances's refusal and its pass-throughs — see each file's
CONTROLLER RULING comment for the line-by-line reasoning). All three existed
to stop a confirmation landing behind the slot it claims; with no slots on the
anchored path and no rebasing on the grid path, that situation cannot arise
any more. The anchored walk's own termination throw stays, as a defensive
assert against n < 1 — never softened into a break.
ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code
removed (the X1 condition and requireConfirmationAdvances respectively) so
this task's suite would compile and run; Task 6/7 own reintroducing whatever
anchored-model-correct guard, if any, replaces them.
This removes more code than it adds.
A confirmation is an ordinary Purchase, and the ordinary purchase dialog can
edit its date. Under lastCharge anchoring that date is what the walk measures
the next occurrence from, so moving it back more than a cycle made the walk
step backwards and throw out of foldBalances -- every month route, the year
view and the summary down at once, from an edit that never mentioned recurring
costs.
updatePurchase is the write that can invalidate an already-valid confirmation,
so it now asks wouldAdvancePast -- the same helper ExpectedBand asks before
offering a row -- and refuses the edit instead. The walk's throw is unchanged:
a step that lands backwards really is bad data, and this stops it being
created. A purchase with no source is not checked at all, and a source naming
a cost that no longer exists falls through.
PurchaseDialog was closing on save regardless of the outcome, with its
useMutate error rendered nowhere, so a refusal looked like a save that did
nothing. mutate now reports whether the write landed, and the dialog stays
open and shows the reason when it did not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Critical (X1): confirming a "Coming up" row records TODAY against a future
slot, and occurrencesOf throws — permanently, since the purchase persists —
if that pairing can't advance the series. Reproduced with the demo's own Car
insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and
confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks
every month route, yearView, summaryView and PostYearRoute. `expectedGroups`
now omits a coming-up row outright (never disabled-with-an-explanation)
unless `wouldAdvancePast` says recording it today would leave the walk able
to advance past that slot.
Important (X3): a cost with an outstanding occurrence in the displayed month
or any earlier foldable month never gets a coming-up row either — that was
the double-counting path into X1 (Car insurance listed under both "Expected"
2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier
stayed pending would double-count the bill). Settle the earlier bill first.
Important (X2): extracted the this-month-vs-coming-up recorded-date decision
into `recordedDateFor(group, today)`, previously inlined as `row(occurrence,
null)` vs `row(occurrence, today)` with nothing testing that the two aren't
swapped. Tested directly.
Minor: the "Coming up" label is now an `<h3>` (matching MonthRoute's own
group-heading convention) instead of a `<p>`, so assistive tech gets the
section divider.
New tests in ExpectedBand.test.ts cover all of the above, including a
regression built from the exact reported shape: confirming everything
expectedGroups offers as "coming up" and then folding must never throw.
Pre-fix, that reproduction throws:
Recurring cost "Car insurance" did not advance past 2026-10-13
(produced 2026-10-04). Purchase <id> claims that slot but is dated
2026-09-04, which does not move the series past 2026-10-13 — correct
its date so the series advances.
Gap 1 (spec §5): ExpectedBand only listed this month's occurrences, so a
lastCharge bill whose pending slot had rolled into next month (the phone-bill
flagship case) was not confirmable without navigating there first.
Extend the walk 12 months past the displayed month (occurrencesByMonth's own
upToMonth bound; foldBalances is untouched) and surface a "Coming up" group:
at most one row per recurring cost, its next unconfirmed occurrence. That row
confirms EARLY, so it records today rather than the future slot date — which
is exactly what rebases a lastCharge series — and shows both dates so the
owner can see what will be written. `today` is exported from src/store/index.ts
next to `currentMonth`, keeping the clock out of src/domain/.
expectedGroups() is the pure, exported core (pending/overdue/comingUp);
ExpectedBand renders it and still returns null when all three are empty.
`parsed ?? undefined` conflated "field never touched" with "field edited
to text that doesn't parse" — both produced null, so typing "30 GBP" in a
DKK/USD dataset silently confirmed the purchase at the cost's own amount
instead, with no feedback. resolveExpectedAmount now distinguishes the
two: untouched still confirms at the cost's amount in one click (the fast
path is unchanged), but edited-and-unparseable disables Confirm and shows
an inline message.
Also excludes months before settings.foldStartMonth from the "earlier"
overdue count: foldBalances never folds those, so an occurrence out there
was never actually holding the projected balance down, and counting it
made the explanatory sentence below the count claim a gap that wasn't in
the number being shown.
PostTable gained a Projected column (both the sm: table and the
mobile block markup) so the fold's second balance — what is left
after unconfirmed recurring bills — is finally visible, muted so
Remaining stays the primary figure. The legend now explains it.
RecurringSection's interval input widened from w-14 to w-20 so
two-digit values like 28 and 30 no longer collide with the native
number spinner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repeated act, so it is a row and a button rather than a dialog — entry
speed is a product principle and a modal per bill would defeat it. The amount
is editable inline because paying a different figure is common; anything more
unusual is edited afterwards like any other purchase.
Confirmation is offered on a pending occurrence whose date has not arrived yet,
deliberately. A bill anchored to its last charge can charge EARLY, and
confirming the pending slot with the real date is how the series rebases.
The overdue count is not decoration. An occurrence nobody ever confirms holds
the projected balance down forever, which is the honest reading of an
unreconciled commitment but is invisible without it.
Returns null when nothing is expected, so a dataset with no recurring costs
sees no change to the month view at all.
`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it,
whatever its currency, and `digitsFor` is gone: the sixteen call sites read the
setting, and the domain functions that took a currency table purely to look
digits up take a `digits: number` instead. Schema 6.
The owner asked for this with the cost in front of them, and the cost is real:
a dataset mixing minor units cannot be described any more. Yen at 0 beside
kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen
cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank.
Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md
argues it in full, including what re-adding a per-currency override would take,
and AGENTS.md §1 now says not to reintroduce one without revisiting the
decision.
The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base
currency's. A dataset holding a three-decimal currency already has
three-decimal amounts in it, and adopting the base's 2 would truncate the next
edit of one; the maximum can only keep more precision, never less. On every
dataset this app has written all four currencies record 2, so nothing rounds
differently than before.
Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and
shows as 50; totals recompute at the new precision. Rewriting each Money is a
destructive whole-dataset write, which §1 owes a backup export first, and it is
lossy one way.
Display turned out to be the hole in the plan. src/ui/format.ts pinned
minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read
the dataset at all — equally broken under per-currency digits, where a JPY
rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a
Settings control that display ignores would look broken at 0 and be lossy at 3,
hiding precision the dataset holds. So format.ts takes digits like roundMoney
does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes
the formatter as an argument because it is a plain helper, not a component.
currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at
ZERO decimals through rounding, both FX directions, both split modes, plan
division, the fold and the month view still tells a real implementation from
one assuming 2 — it just cannot prove per-currency flow, because there is none.
It also asserts that raising the setting to 2 changes what the fold produces.
Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written
straight into IndexedDB migrates to a setting of 3, the table loses its
Decimals column, and editing the control persists schema 6 with digits stripped
from each currency and income left at 20000.55. The same month reads 600/206 at
zero decimals and 600.000/205.561 at three, so the figures recompute rather
than merely reformat.
Not verified here: the .ods in a real spreadsheet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section's hint ran the full width of the card — about 160 characters a line
at 12px on a 1140px card, more than twice a readable measure, and the
Currencies and Posts hints both wrapped to three lines that read as a wall
rather than as one aside. Capped at 75ch.
PostTable's "change from here" was a single line of 11px text with no
padding, so a 15px target. It gets padding cancelled by equal negative
margins, so the hit area grows and no baseline moves.
UpdatePrompt used `rounded` (4px) where the card system is 8px, which made
the one panel that appears over everything else the only one with a tighter
radius than its surroundings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopts NativeSelect for the base-currency, per-post and rule-kind selects.
Spacing. In Posts the table, the "Archiving hides a post…" footnote and the
add-post row were three siblings with no margin between them at all: the
footnote's own line-height was the only gap, so the three read as one
undifferentiated block. The footnote now has room and the add row is its own
band under a rule. Same for the add-currency row and "Fetch rates now",
which sat 8px under the paragraph explaining it.
Controls. The rule-summary trigger — the only way into a post's allocation
history — measured `h=20 pad=0 rad=0`. It gets a 24px box and the row's
hover wash. `up` / `down` / `archive` were three inline-flex siblings with
nothing between them, so their hover backgrounds touched and the cell read
as one wide slab. "Fetch rates now" was `sm` in a page where every other
section-level action is default height, leaving it 4px short of the fields
beside it.
Also: the add-currency row labelled three of its four fields by placeholder
and the fourth with a visible DECIMALS label, directly below a DECIMALS
column heading that already said it — so that label read as a heading for
the whole row. It becomes an aria-label. "Reset everything…" moves out of
the export group it was fourth in and takes a destructive border.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopts NativeSelect for the three date/post/currency selects and the split
editor's post picker, and Segmented for the one/many and
percentages/amounts switches. The currency select now matches the Total
input beside it exactly: same height, radius, border and text indent.
The rest is hit areas and edges:
- "set exact date" / "use the month only" were bare buttons with no padding
at all — a 15px target under a 36px field, unhittable on a phone. Now a
24px box, with a negative margin so the text still lines up with the
field's left edge.
- The split row's `remove` and the plan's `remove plan` ran past the
dialog's right padding; both now end on it.
- The bulk-entry remove was a `×` glyph in a 20px box. It uses
lucide's X, the icon family the dialog's own close button already uses.
- The split editor's post select gains `min-w-0` via NativeSelect, so
`flex-1` can shrink below the widest option — the actual cause of the
dialog padding bug fixed in the previous commit.
- The "rounding" radio label gets a real vertical hit area.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two patterns the app had been rebuilding per call site.
NativeSelect: eight `<select>` elements had each been hand-styled, arriving
at four different answers — `h-8 rounded border px-1 text-xs`, `h-9 rounded
border px-2 text-sm`, `h-9 flex-1 rounded ...`, and one correct `h-9
rounded-md border-input px-2`. Measured, that is two radii (4px against the
system's 6px), two border colours (the row hairline against the 3:1 field
border) and three text indents against Input's 12px. In the purchase dialog
a select sat directly beside the Total input in a visibly different control
language.
It stays a native select on purpose: the platform picker on a phone and
type-to-select on a keyboard are worth more here than a prettier menu, and
entry speed is a stated product principle. The chevron is a background image
from a new `--budget-select-chevron` token rather than a positioned icon,
because that needs no wrapper element — so `className` lands on the select
itself and every call site's existing `w-full`, `flex-1`, `h-8` and `w-40`
keeps behaving as it did. The token is declared per theme since a
background-image stroke cannot be `currentColor`.
Segmented: four screens had built the same view switch from two loose
buttons, the selected one filled and the other outlined, separated by a gap.
That reads as one primary action beside one unrelated button rather than as
two states of a single control. One track, no gap, `aria-pressed` per
segment.
Neither is wired up yet; the call sites follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sub-project 2 of 4 of the visual redesign. Spec in
docs/superpowers/specs/2026-09-02-month-view-design.md; the owner chose
each of the three directions from concrete alternatives.
Three problems, all found by looking at the rendered page rather than at
the code:
- The totals block was four peer figures with no relationship between
them, and the income *input* sat in the same visual register as three
numbers derived from it. Nothing answered PRODUCT.md's second question
at the month level: with Transport at -3,200 you had to read down the
Remaining column to find out. It now reads income -> allocation meter
-> spend, with the overspent-post count stated in both directions.
- The carry meter rendered 21px below its own row and 22px above the
next, so it read as a divider rather than as a property of a post. One
tbody per post now holds the figures AND their meter, which is both
what pulls it tight under its own row and the only way to hover the
pair as one block.
- Phone width was broken and nothing recorded it: the 5-column table
overflowed at 390px with Spent and Remaining off-screen, and every
figure wrapped its currency onto a second line. Below sm: each post is
now a two-line block, and the currency is stated once in the legend
because every figure in that table is base currency.
Meter geometry moves to src/ui/meterSegments.ts, pure and tested, with
<Meter> as the only thing that paints it. The allocation meter scales to
max(income, allocated) so over-allocating overflows instead of capping
at a full bar — over-budget is always allowed, so it has to be drawable.
Negatives clamp rather than invert: a segment of negative width puts the
gradient's stops out of order, which paints something arbitrary instead
of failing.
Rejected: one reflowing CSS grid carrying role="table". It deduplicates
the markup, but at 390px the layout genuinely is not a table any more,
and table roles would describe rows and columns to a screen reader that
no longer exist on screen.
Verified: 443 tests, tsc clean, build clean, and read in both themes at
1280px and 390px. Not verified, because it is not verifiable here —
whether it reads better than what it replaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two requests. The second is small; the first changed the money layer.
TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.
The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.
Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, it groups thousands if exactly
three digits follow and is a decimal point otherwise — so "1,500" is fifteen
hundred and "1,5" is one and a half. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.
USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.
That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.
Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.
Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
it. The same reasoning as posts being archived rather than deleted: stored
Money carries the code, and dangling references would make the import
validator refuse the owner's own exported file. Removing an unused one takes
its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
codes, integer decimals 0-4 — and validates every Money against it. That
boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
rate service returning dozens cannot put unreachable rows in the table.
Migration v2 -> v3 seeds the currency table. Behaviour-preserving by
construction: every currency that existed had two decimal places and the
seeded table records two, so not one stored amount can round differently than
it did before. An existing `currencies` field is kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.
Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.
Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system
already existed and was never wired up. src/index.css defined --paper, --ink
and a muted teal with a contract document behind them, while the app painted
itself with shadcn's default white and near-black. Those tokens reached the
screen in exactly two places — the carry meter and the +/- number colours.
TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per
theme, and shadcn's whole variable set points at it, so every primitive
inherits both themes rather than being fought. Light is cool paper with ink
navy; dark is deep slate, never pure black, because a black ground makes the
hairlines and the carry meter float.
The brand teal is --primary, NOT --accent. That is precisely what the old bug
got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with
--accent-foreground, so a saturated colour there turns every hover into a
coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule
mechanical — redefining either half of a shadcn pair without the other fails
the build, in any block.
CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against
a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on
both of their backgrounds. Verified afterwards by reading the tokens back out
of the live browser in both themes rather than trusting the arithmetic.
TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed
alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler
resolves every url() from disk and inlines woff2 as base64 — relative or
absolute, and a "file" loader override does not stop it — which made the
stylesheet 303 KB of render-blocking bytes and defeated the unicode-range
subsetting entirely. They are injected at boot from one definition instead,
which is the only arrangement identical in dev and in a subpath build.
THEME TOGGLE cycles system / light / dark, remembers the choice in
localStorage (a per-device display preference does not belong in the exported
budget), keeps following the OS while set to "system", and is applied by a
tiny inline script before first paint so a dark-theme user never sees a white
flash. Every storage access is wrapped: localStorage throws outright in some
configurations, and a theme preference must never stop the app booting.
A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently
killed the carry meter. An undefined custom property inside linear-gradient()
does not warn, does not throw and does not fall back — the declaration is
invalid, so the element paints nothing. It looked like a missing feature. Only
pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any
TypeScript file references a token src/index.css does not define.
Also: one Section/Stat card treatment applied across month, year, summary and
settings; quiet uppercase table headers; row hover; the rule-editing and
purchase-row actions revealed on hover instead of six always-on links; a scroll
fade on the year matrix so it reads as "more to come" rather than clipped; and
"September 2026" instead of "2026-09" as the month heading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by screenshotting it. 13 numeric columns of "28,000.00" in a mono face
cannot fit in min-w-[48rem], so the table shrank the cells instead of
scrolling and the figures ran together: the income row read
"28,000.0028,000.0028,000.00", the total read "0.000.00248,500.00", and
columns 11 and 12 were crushed to nothing. Every cell now has horizontal
padding and cannot wrap, and the table is wide enough that the container
scrolls rather than crushing.
Verified by screenshot at 1440px: every figure separated, columns aligned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md, AGENTS.md, ARCHITECTURE.md and TODO.md all said no browser exists
in this environment, so appearance had never been verified by anyone. Chrome
is on PATH. Its own --screenshot flag is what fails: it fires on the load
event, before this app's async IndexedDB read resolves, and captures a blank
page — which is presumably how the claim took hold.
scripts/screenshot.ts drives CDP instead: waits past the load event, seeds
IndexedDB via --eval-file and reboots the app with --reload, clicks with
--click, applies shadcn's class-based dark palette with --dark, and reports
console errors and uncaught exceptions. A page that renders blank from a
thrown error looks identical to an empty one, so the console output is half
the value.
Two defects found by simply looking:
- The month table's numeric columns had no horizontal padding, so figures ran
into each other: "+7,175.00 DKK4,200.00 DKK". Now padded.
- Dev logged "The script has an unsupported MIME type ('text/html')" on every
boot, because registerServiceWorker asks for sw.js and the "/*" catch-all
answered with the HTML shell. Dev now serves an inert worker.
Confirmed working, first time seen: the carry meter (including red where a post
is overspent), the Task 5 rule-history editor, the Task 6 "change from here"
control, and baked FX rates showing as "manual - 2026-09-01" in Settings.
Recorded in the docs, with the limits stated: this container's fontconfig maps
every generic family to a monospace face, so screenshots need a FONTCONFIG_FILE
override or you will chase a font bug that does not exist; Chrome starts from
an empty profile each run; and no screenshot can judge how a design feels.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The natural moment to decide "15% from July" is while looking at July. The
control writes the same setRuleFrom action the history editor uses, so the two
surfaces cannot drift apart. A month where a version begins gets its own badge,
distinct from a one-month override, so a step in the numbers has a visible
cause; a month can carry both.
The editor is keyed on post and month so switching posts remounts it — without
that, React reuses the instance and useState keeps the previous post's values.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each post's allocation over time, as rows of "from this month, this rule".
Adding a rule for a month that already has one replaces it, and the form says
so rather than looking like an append. An unbudgeted post says so explicitly,
since that is a different state from a rule of zero.
The Add button stays disabled until the month parses, because setRuleFrom sorts
with compareMonths, which throws on a malformed MonthId. Also retires the
"Standing rule" wording, which Task 1's model change left stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md.
Three changes shipped together because they share one tab order.
Bulk rows are description -> amount -> note, and nothing else is a tab stop.
The list always ends in exactly one blank row, appended as soon as the last
row is filled, so Tab flows into a new line with no special case and new rows
appear on touch too. The Add-line button is gone; the per-row remove button is
now a hover/focus x with tabIndex={-1}, and Backspace in an empty description
removes the row and lands on the previous row's note.
Purchases no longer demand a day. `date` accepts "2026-09" as well as
"2026-09-14"; entry defaults to the month, with an opt-in "set exact date"
link. No migration: monthOf() already parsed both shapes, verified before
choosing this over splitting the field into month + optional day, which would
have meant a schema migration in the browser against the user's only copy of
their data for an identical outcome.
Notes are an optional field on Purchase, in both entry forms and shown beside
the description in the month lists. Empty notes are omitted rather than stored
as "", so absent stays absent.
Also closes a pre-existing gap the spec surfaced: the JSON importer never
validated purchase.date, so a malformed date passed import and threw later
inside the balance fold. It is now rejected at the boundary, with out-of-range
months and days covered.
A row holding only a note is deliberately NOT blank: it asks for the missing
description and amount rather than being silently discarded.
Keyboard behaviour - tab traversal, Backspace-to-remove, focus after append -
cannot be verified in this environment and needs confirming in a browser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A grocery trip is twenty lines, and opening the full dialog twenty times
is the thing this exists to avoid.
PurchaseDialog gains a One / Many toggle, shown only when adding — editing
an existing purchase edits exactly one, so the toggle is hidden there. In
Many mode the date, post and currency are set once at the top and each line
is just a description and an amount; Enter on an amount starts the next
line, so a receipt can be typed without leaving the keyboard. Blank trailing
rows are ignored rather than flagged.
The whole batch commits in a single mutate: one write, one queue entry, and
no way to half-commit a shopping trip.
Lines are deliberately simple. A line needing a split or a payment plan is
saved plain and then edited from the month view — building per-line split
and plan editors would have been most of the work for a case the grocery
run never hits, and would have slowed the common path.
Line logic lives in a pure module (src/ui/bulkEntry.ts) following the
purchaseForm.ts pattern, so it is testable with no DOM: 20 tests covering
blank-row handling, per-row validation messages, the running total, and
that N lines become N purchases landing wholly in the shared post. Each
line is built through the same toPurchase the single form uses, so there
is one definition of what a purchase is.
Validation mirrors validatePurchase in what it deliberately does NOT
reject: a negative amount is a refund, not an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the three findings from the final whole-branch review:
- SnapshotStore.mutate/replace now serialize behind a promise queue, so
concurrent mutate() calls (e.g. two independent onChange handlers firing
before either write resolves) each clone from the previous commit instead
of racing on a stale snapshot and silently losing an edit. Preserves
write-before-move-snapshot, caller-visible rejections, structuredClone
identity, and non-wedging on a failed write. Adds tests for concurrent
mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
failures through the existing import error state/rendering, since a throw
(e.g. MissingRateError from buildOds) inside a plain onClick is invisible
to the error boundary. The pre-import JSON backup now explicitly aborts
the destructive replace if the backup export fails, rather than relying on
an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
date from a native <input type="date">, and requires an integer month
count (with step={1}) before calling withPlan/equalSlices, preventing
both domain throws from firing inside plain event handlers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A purchase in a currency with no configured exchange rate makes
src/domain/fx.ts throw MissingRateError (correct behavior — never silently
convert at a wrong rate). Nothing caught it: 5 of 6 routes call
monthView/yearView/summaryView and threw during render, and with no error
boundary anywhere in src/, the throw unmounted the entire React tree
including the nav bar. Since the catch-all route redirects to the current
month on first load, one un-rated foreign-currency purchase white-screened
the app immediately on landing, with the only way out (Settings, where the
rate is added) unreachable because the nav was gone too.
Adds RouteErrorBoundary (a class component, as required for
getDerivedStateFromError/componentDidCatch) that renders the thrown
message plus a link to Settings and a "Try again" reset, and
RouteErrorBoundaryReset, a wrapper that keys the boundary on the current
route so it clears on navigation. Wraps only <Routes> in App.tsx, not
<nav>, so navigation stays usable when a route throws.
Also guards the happy-dom per-file GlobalRegistrator.register() call in
registerSw.test.ts and the new ErrorBoundary.test.tsx with
isRegistered, since Bun can run both files in one process.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The precache list is generated from Bun.build's outputs, so hashed asset
names cannot drift. A waiting worker prompts to reload rather than
pinning the user to a stale build.
Two fixes beyond the brief: naming.entry changed from "[dir]/[name].[ext]"
to "[name].[ext]" because [dir] resolved to "src", putting index.html at
dist/src/index.html instead of the dist root; and external:
["*.webmanifest", "*.svg"] was added because Bun's HTML bundler otherwise
tries to resolve <link> hrefs as module imports, which fails outright for
the not-yet-generated manifest.webmanifest and would hash icon.svg,
breaking the fixed paths the manifest and service worker depend on.
happy-dom is registered per-file in src/ui/registerSw.test.ts only, not
as a global test preload, so src/domain/smoke.test.ts still guards the
domain layer's DOM-free invariant.
PNG icons (192px/512px) are not included — binary image generation isn't
available here; a human needs to add them for full Chrome installability.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Future finance-plan slices are surfaced as already-committed so a plan is
visible before it lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plans default to an equal split, each slice is individually editable, and
plans compose with splits. Cancelling keeps past slices.
planBalance delegates to domain slicesBalance rather than recomputing the
same total-minus-slices arithmetic, per the project's one-implementation
rule for shared math.
Validation lives in purchaseForm.ts as pure functions. Unbalanced splits
and over-budget purchases are deliberately not errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bare --accent in src/index.css silently overrode shadcn's own
--accent/--accent-foreground pairing (near-white background paired with
near-black text) with the design contract's dark teal, without touching
the paired foreground. That dropped the computed contrast ratio on every
outline/ghost button, select item, badge link and dialog close button
that uses bg-accent/text-accent-foreground to 3.23:1 -- below WCAG AA's
4.5:1 minimum for text.
Renamed the token to --budget-accent everywhere it's declared and
consumed (src/index.css, src/ui/components/PostTable.tsx's carry meter),
leaving shadcn's own --accent/--accent-foreground pair untouched. The
other five contract tokens (--paper, --ink, --rule, --surplus,
--overspend) don't collide with any shadcn token name in
styles/globals.css, so they are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Negative remaining balances are shown in a warning treatment; nothing
blocks going over budget.
Applies the Tasks 18-23 design contract: --paper/--ink/--rule/--accent/
--surplus/--overspend tokens and the three type-role font variables in
src/index.css, --font-money on every money figure, and the carry meter
(a 3px hard-stopped gradient beneath each PostTable row) encoding
carried-in surplus, allocation track, spend fill and overrun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>