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) <[email protected]>
setRecurringCostEndedFrom is the only place that keeps archived and
endedFrom paired, and endRecurringCost/restoreRecurringCost/the Ends column
already delegate to it — but updateRecurringCost still typed its `changes`
as Partial<Omit<RecurringCost, "id">> and Object.assign'd it, so a caller
passing either field alone would desynchronise the pair. No current call
site does this, so it was latent; excluding both keys from the accepted
type makes it impossible rather than merely unexercised.
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.
occurrencesOf's "did not advance" throw is correct and permanent once a bad
purchase is written, so a caller planning to record a confirmation on a date
other than its slot needs to ask, up front, whether that pairing survives the
walk. Extract the rebase-from-a-confirmation rule (previously inlined once)
into `rebasedFrom`, reuse it from the walk itself, and build `wouldAdvancePast`
on top of it and `stepFrom` rather than re-deriving the rule a third time.
No behavioural change to occurrencesOf itself.
Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it
"cancellation", but Settings only ever wrote currentMonth — backdating or
forward-dating a cancellation was import-only.
Add an "Ends" column beside "Starts" in the Recurring costs table, edited and
validated the same way (isValidEndedFrom — no granularity rule, per §10, but
still refuses a calendar-impossible date like "2026-02-30"). Empty means "not
ended" and clears the cancellation.
Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom
is the one place that now enforces it — endRecurringCost and
restoreRecurringCost (the one-click row buttons) delegate to it, so the column
and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed.
Also closes the same calendar-impossible gap in the JSON importer, which
checked endedFrom's shape but not its calendar validity, unlike startDate.
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.
requireStartDateGranularity (store/actions.ts) and its import-path twin
(export/json.ts) checked date SHAPE with a regex, not date VALIDITY, so
"2026-09-31" (September has 30 days) passed both and threw later out of
foldBalances instead of at the write boundary. isValidStartDate
(RecurringSection.tsx) had the same hole for a day-shaped value under
everyNMonths. Separately, monthOf's regex (domain/months.ts) was
unanchored at the end, so "2026-091" was silently read as September 2026.
Route day-shaped values through toDayOrdinal and month-shaped values
through monthOf (now anchored to accept only "YYYY-MM" or "YYYY-MM-DD" in
full, never a prefix) at all three write boundaries, converting whatever
they throw into each file's own error style.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`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.
hasActivity only checked carriedIn/allocation/charges, so an archived post
whose sole activity this month was an unconfirmed recurring occurrence
(zero remaining, nonzero expected/projected) was dropped from
monthView.rows — its Projected figure never reached the month view even
though ExpectedBand listed the same occurrence. remaining and projected
are both fully derived from five raw fields; the check now covers all
five.
Under lastCharge anchoring, an everyNMonths cost stepped from the
confirmation's exact date even when that date fell earlier in the same
month as the slot it claims (e.g. rent for 2026-09 paid 2026-08-28). Since
stepFrom normalises through monthOf, that produced the same month as the
cursor, which the walk read as "did not advance" and threw — for a
completely ordinary early payment.
The month-granular step now runs from whichever is later, the cursor or
the confirmation's month, so a payment within the same month simply does
not rebase. A payment recorded in a LATER month still rebases the series
forward, symmetric with the day-granular cap-hit-early behaviour.
Also corrects the termination error's message, which claimed a purchase
was dated "more than one step earlier" regardless of how early it
actually was.
The unit dropdown could rewrite a cost's recurrence kind to a day-granular
one without touching its month-only startDate, committing exactly the
pairing the store now refuses. It now expands startDate to the first of
the month in the SAME mutate call that changes the kind.
The Starts field itself was free text committed on every keystroke,
including an emptied field ("" reaches monthOf() and throws with the same
blast radius). It is now an uncontrolled input that only commits on blur,
and only when the text parses as an IsoDate of the granularity the cost's
recurrence kind requires; an invalid edit is discarded rather than stored.
isValidStartDate and normalizedStartDateFor are exported and covered
directly in RecurringSection.test.ts, since this is what stands between
this field and a repeat of C1/C2.
non-array recurring field (C1, M7)
parseDatasetJson now enforces the same rule as the store: everyNDays and
everyNWeeks require a day-granular startDate, since occurrencesOf hands it
straight to addDays. Without this, a hand-edited or exported-then-mutated
v7 file could import a cost that bricks every route touching the fold.
Updates the module docstring's claim that it "validates far enough that
the fold cannot throw" to actually cover this case.
Also switches the recurring field to requireArray, matching its four
sibling collections (posts/months/purchases/fxRates) — a non-array
`recurring` used to silently import as empty instead of being rejected.
addRecurringCost and updateRecurringCost now reject a day-granular
recurrence (everyNDays/everyNWeeks) paired with a month-only startDate.
occurrencesOf hands startDate straight to addDays for those kinds, which
throws "Not a day-granular IsoDate" — reachable in two clicks by adding a
cost (always created as everyNMonths, startDate "YYYY-MM") and then
switching its unit in the dropdown without touching Starts.
updateRecurringCost checks the resolved pair (changes merged onto the
stored cost) on every call, not just one that touches recurrence or
startDate, since either field alone can break the pairing.
Also corrects endRecurringCost's doc comment: archiving does not hide a
recurring cost from the list, it dims it — the code already matches
PostsSection, only the comment was wrong (M6).
The zero-decimals guard drove every money path through rounding except
expectedForMonth and fold.ts's projected balance, added by the recurring
costs feature with the fixture's recurring array still empty. Adds an
unconfirmed, non-base-currency, fixed-split recurring cost so the FX
conversion and the split division both land on a non-whole amount at
zero digits, and extends the fold/month-view/two-decimals assertions to
cover it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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) <[email protected]>
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.
Follows PostsSection: same table, same xs ghost row actions, same add row.
Definition is the rare act, so it lives here rather than in the month view.
Placed after Posts because a cost's splits reference posts, and before the
data section so the destructive import controls stay last.
'end' sets endedFrom to the current month and archives in one action, which is
what the owner means by cancelling a subscription. Past occurrences are
untouched, so no historical figure moves.
I3: purchase.source: null (and a null entry in recurring[]) reached a bare
property access and threw a raw TypeError instead of ImportValidationError.
null is not undefined, so the existing `!== undefined` guard let it through;
source: null is a plausible hand-edit since the sibling field on the same
purchase literal is schedule: null. Both paths now check for null/non-object
explicitly and refuse with the file's own wording, matching the defensive
style already used two lines away (cost.amount?.currency,
schedule?.slices ?? []).
I1/I2: splitMode and splits[].value were unchecked on both purchases and
recurring costs. An unrecognised splitMode silently takes charges.ts's
"fixed" branch instead of "percent" (or vice versa), turning an intended
percentage into an absolute amount with no error. A non-finite split value
propagates as NaN through distributeByWeight/distributeByAmount, and
roundMoney(NaN, digits) returns NaN rather than throwing. Both are now
checked in one new requireSplits() helper shared by the purchases loop and
the recurring-costs loop, replacing each loop's separate copy of the
no-splits/one-absorber/unknown-post checks rather than adding a third copy.
Per AGENTS.md §3, requireSplits only rejects non-numbers: negative values,
values over 100, and splits that don't sum to the total stay deliberately
unchecked.
The one check that is load-bearing rather than tidy: a recurrence interval
below 1, or a fractional one, is REFUSED rather than clamped. The projection
walk terminates only if every step strictly advances, and a clamped zero would
hand the owner a schedule their file does not describe — silently.
Purchase.source is checked too: the cost it names must exist, and the
occurrence date must parse. PURCHASE_DATE already accepts both YYYY-MM and
YYYY-MM-DD, which is exactly the range a slot date can take.
confirmOccurrence writes an ordinary Purchase: the slot it claims goes in
source.occurrenceDate, the date the money moved goes on the purchase, and the
two differ whenever a bill is paid off schedule. Splits are copied rather than
shared, so editing the purchase cannot rewrite the cost.
endRecurringCost sets endedFrom AND archived, because they do different jobs —
one stops the projection, the other only hides the row. Archiving alone would
leave a cancelled bill projecting forever. Neither touches a past occurrence,
so no historical figure moves.
requireRecurrence rejects n below 1 and non-integers rather than clamping.
The walk terminates only if every step advances, and the importer is not the
only write path.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
remaining is untouched: carriedIn + allocation - charges, exactly as before, so
no figure the owner has reconciled against their bank can move when they add a
recurring cost. projected runs beside it on its own carry, subtracting
unconfirmed occurrences as well.
Two invariants pin it, both tested. With no recurring costs the tracks are
identical in every month — that is the behaviour-preservation guard for every
dataset that exists today. Once a month's occurrences are all confirmed they
reconverge there, because a confirmation is an ordinary purchase counted in
charges and skipped by expected.
An unconfirmed occurrence in a PAST month therefore holds projected below
remaining forever. That is the honest reading — an unreconciled commitment —
and the month view surfaces the count so it is not silent.
Also required to typecheck once PostMonthFigures gains these fields:
- src/store/migrations.test.ts: delete the two @ts-expect-error directives
that stood over the "NO figure changes" guard test since Task 2. That test
is now green — the point of this task.
- src/ui/meterSegments.test.ts: the figures() test helper builds a complete
PostMonthFigures literal; default expected to 0, projectedCarriedIn to
carriedIn, and projected to remaining, matching its existing partial.X ??
style.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
expectedForMonth mirrors chargesForMonth for money that has not moved yet, and
skips confirmed occurrences — those are ordinary purchases that chargesForMonth
has already counted, so counting both would double every settled bill.
The split machinery is extracted rather than duplicated. Two implementations
could round differently or absorb the remainder differently, and the gap would
show up as a penny of drift between the fold's two tracks with no cause the
owner could find. remainderIndexOf and splitPartsOfTotal keep their signatures
as wrappers, so no existing caller changes.
Smaller than planned: 8d2b2ca already made these functions take digits rather
than a currency table, so the shared helper needs no currency plumbing.
A walk rather than a per-month query: under lastCharge anchoring, where a
series sits in a given month depends on every confirmation before it, so the
projection starts at startDate and steps forward. Bounded by upToMonth, which
the caller supplies — the domain still reads no clock.
The data-cap behaviour is one line. `from` is the confirmation's own date
under lastCharge, so confirming a slot early moves every later occurrence with
it. Confirm the 2026-01-31 slot with a purchase dated the 12th and the next
lands on 2026-02-11, not 2026-03-02.
The walk throws if a step fails to advance. n >= 1 is enforced at both write
boundaries so this should be unreachable, but a hang is worse than a throw and
dropping a bill silently is worse than both.
Rewrote the "deleting the confirming purchase" test per controller ruling: the
brief's version was byte-identical to the unconfirmed-series test above it and
asserted nothing new. It now walks the phone cost with the capHit
confirmations, then walks the same cost with an empty map, and asserts the
dates differ and match the unconfirmed series — which is what "deleting the
purchase restores the series" actually means.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Recurrence is two orthogonal fields, not a list of special cases: a kind
(everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar /
lastCharge). The phone bill that recharges early when its data cap is hit is
everyNDays(30) anchored to lastCharge — the domain never learns what a data cap
is, because confirming the occurrence early already records the date.
Purchase.source pairs occurrenceDate (identity: which slot this claims) with
Purchase.date (truth: when the money moved). They differ whenever a bill is
paid off schedule, and lastCharge measures from the latter.
RecurringCost.amount is a single mutable Money with no RuleVersion-style
series. Safe only because confirmed purchases hold what was actually paid;
under projection-only it would destroy history.
The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
Recurrences of 28 and 30 days do not align to months, so the domain needs
day-level maths for the first time. It gets it the way months.ts does: an
integer ordinal, no Date anywhere. A Date would make 2026-03-29 a different
instant per timezone while being the same day everywhere, and a 28-day step
must not gain or lose a day at a DST boundary.
Hinnant's days_from_civil, which shifts the year to start in March so the leap
day is the last day rather than an insertion, removing every special case.
No isDayGranular export: nothing in the plan consumes it, later tasks branch
on a recurrence kind and import validation uses a regex directly.
`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) <[email protected]>
Two fixes that looked like one. The horizontal scrollbar under the currencies
table was not a width problem, and widening alone would never have removed it.
The column: `max-w-4xl` here was 256px narrower than the shell's `main`, which
is already `max-w-6xl`, so the card edges stepped inward whenever you arrived
from the month view. The cap is simply gone rather than raised to `max-w-6xl` —
`main` caps the width and pads it, so a 6xl cap on the child could never bind,
and two caps that agree are still two places to change.
The scrollbar: the row's remove button carries `-mr-2`, so its label sits flush
with the card's content edge rather than 8px inside it. To a scroll container
that overhang IS overflow, and because the table is `w-full` it scaled with the
column — 862 in 854 before, 1070 in 1062 after widening. Exactly 8px at every
width, which is why no amount of widening helped. The wrapper now bleeds 8px
right and pads it back, so the overhang lands inside the padding box: same
table width, same button position, no scrollbar. The same `-mx-2 px-2` shape as
the purchase rows in acb4d97.
A first attempt at diagnosis blamed the table's natural width and was wrong; a
second, zeroing the margin in the page, appeared to disprove the margin too,
because a transition was still interpolating it (-7.73662px when measured).
Waiting out the transition is what made the cause visible.
Verified in Chrome across every route: /settings, /month and /summary have no
scrolling container whose content overflows it. /year still does, by 282px,
which is YearMatrix being genuinely wider than the column — that scrollbar is
doing its job and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dark Reader looks for `<meta name="color-scheme">` first. Finding none, it
waits for a stylesheet and then samples text and background colours at
several points of the screen, plus some further heuristics, to decide
whether the page needs darkening. On the dark palette that verdict is
wrong: it inverts an app that is already dark, a second time.
So the tag now states which scheme is actually live. It ships in the markup
as `light dark`, before any script runs; the pre-paint script narrows it to
the one in use, and `applyResolvedTheme` keeps it there when the toggle
moves — the same two-signal write as the `.dark` class, in both places.
`var dark` moved out of the `try` in that script. It has to be readable
after a throwing localStorage in order to set the meta, and false is the
right value there: it is what `readThemePreference` -> "system" ->
`prefersDark` resolves to on the module side, so the two still agree in the
one case that cannot be read.
Both setters create the tag when it is absent rather than skipping the
write. A silent no-op leaves Dark Reader guessing, which is the bug.
The drift guard now runs the real script, lifted out of the HTML, instead
of matching its text — it asserts class and meta together for a stored
dark, a stored light, and no preference at all. Text matching would have
passed a script that declared the tag and never set it.
Not `<meta name="darkreader-lock">`: that switches the extension off
wholesale, including in light mode, where a user who installed it wants it
working. Only the dark palette asks it to stand down.
Verified in Chrome across stored dark, stored light, system-dark and a live
toggle with no reload: one tag, content and computed `color-scheme`
agreeing each time. Whether the extension honours it needs a human with
Dark Reader installed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three defects in the purchase list, plus NativeSelect for the rule-kind
picker.
Alignment: `px-1` on the row indented every description 4px past its own
date heading, so the list had two left edges 4px apart. `-mx-2 px-2` puts
the text exactly under the heading and bleeds the hover wash outward, so the
highlight has an inset instead of stopping flush against the first
character.
The hover-action reservation: a fixed `w-[7.5rem]` kept the amounts from
shifting when edit/delete appeared, which is right on a desktop. On a phone
it was doubly wrong — there is no hover, so `opacity-0` made these the
hardest controls in the app to reach, and the reservation still spent 120px
of a 350px row hiding them, truncating descriptions to "Weeken…", then
"Bik…", and the financed row to nothing but its badge.
So the row is one line from `sm:` up and two below it: description on its
own line, amount and actions on the next. `sm:contents` dissolves the
wrapper above `sm:`, so the desktop row is the same flat flex line it always
was. This is the call PostTable already makes at that width, for the same
reason.
Also: the month steps wrapped mid-label into "← 2026-" / "08" on a phone,
and the error alert's "dismiss" was an unpadded underlined word.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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) <[email protected]>
Replaces the two-loose-buttons pattern with Segmented on both, so
spend/balance and by-post/by-month read as two states of one control rather
than a filled primary action beside an unrelated outline button.
Summary also had two row-height defects: "All time" was `sm` in an
`items-end` row of default-height fields, so it sat 4px off their baseline;
and the by-month branch's first cell was `py-2` where every other cell in
the table is `py-2.5`, making those rows 4px shorter than the by-post ones.
The rest of the diff is indentation — both Sections had their children at
the parent's own indent level.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
DialogContent is a grid whose single implicit column is `auto`, so it sized
to the widest child's min-content — and one row of the split editor (a
select, an amount, a unit, a radio and a remove button) has a min-content
width of 480px against the 464px the padding leaves.
The column grew past the padding box and every stretched child grew with it,
so measured, every field in the purchase dialog sat 6.6px from the right
edge and 25px from the left. One crowded row was setting the padding for the
whole dialog.
`grid-cols-[minmax(0,1fr)]` lets the column shrink to the space actually
available, which puts the element's own `p-6` back in charge.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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) <[email protected]>
Two surfaces the browser draws for itself, both shipping a default that
belongs to no theme in this app.
`accent-color` is what tints a native radio or checkbox. Unset, the split
editor's "rounding" radio was platform blue — the one colour on screen
picked by neither theme. It now takes the brand teal in both.
`::selection` likewise: dragging over a figure gave the OS highlight. It now
uses the accent and its paired ink, so selecting text in the dark theme
stays readable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
src/index.css already declared "one visible, consistent focus treatment",
but shadcn's primitives ship a `focus-visible:ring-[3px]` of their own, so
every button and input actually drew two: a soft translucent halo hugging
the border, then a hard 2px outline 2px further out.
The global rule is the one that stays, because it also covers the links and
bare table buttons that never go through a primitive. The rings come out of
button.tsx and input.tsx instead.
Its `border-radius: 2px` goes too. That was squaring the corners of every
control with no radius utility of its own — the rule-history triggers in
Settings measured `rad=0`, so a focused one drew a sharp rectangle. The
outline now follows each element's own radius.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
GBP joins the currencies the app comes with: £, two decimals, and a
committed fallback rate of 8.704735 DKK to the pound. build.ts now
fetches it too, so a build with a network bakes a live GBP rate and one
without still covers it — the coverage check derives from the seed table,
so it demanded GBP as soon as the table listed it.
The seed table alone would not have delivered this. Baked currencies and
rates seed a NEW dataset and nothing else, so an existing dataset would
have been left typing the code, name, symbol and decimals by hand. A
4 -> 5 migration adds sterling to a dataset that lacks it, with its rate.
Supplying a rate is the one thing the "rates never backstop a cleared
rate" rule permits here: it forbids re-supplying a number the owner
deleted, not introducing one for a currency they have never had. A GBP
the owner already defined, or a GBP rate they already hold, is left
exactly as it is — asserted, including the case where their definition is
"wrong" (zero decimals), because it is theirs.
This exposed a real latent bug. The 2 -> 3 step seeded the live
seed-currency table BY REFERENCE, so adding sterling there would have
retroactively changed what an old dataset received from a step it had
already been through — silently, and only for whoever had not migrated
yet. Every step now carries its own frozen copy, and a source-level guard
fails the build if a live default is imported again. Scoping that guard
took two goes: matching the whole file flagged the comment explaining the
rule, and then flagged the frozen local copy that is the fix.
Two test fixtures used GBP as their example of an UNSUPPORTED currency
and now use CHF, which no seed table lists.
Verified in a browser by writing a schemaVersion 4 dataset into IndexedDB
and reloading: the currency table gains "British pound / £ / 2" with
"manual · 2026-09-02", the post currency selector offers GBP, and the
income and post survive untouched. Note for the next person — a migration
applies in memory on load and is written back on the next mutation, so
reading IndexedDB straight after a reload still shows the OLD version.
That is recorded in docs/TODO.md, because it made this verification look
like a failure at first.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
pages.yml passes BUILD_VERSION: ${{ github.sha }}, so the colophon
shipped in 61e7093 was printing all forty characters of the sha. It now
shortens a long hex string to seven, the way git does.
Found in the deployed service worker, which embeds the same version
constant: o="61e7093c36f9a0111a5f1c8776aba4894c88ebc4".
The pattern requires at least one a-f, so a long run of digits that is
not a valid date can never be mistaken for a sha and truncated. Anything
that is not a timestamp or a sha is still printed exactly as the builder
gave it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Fetch rates now" failed on the deployed site with nothing but a CORS
error. api.frankfurter.app now answers every request with a 301 to
api.frankfurter.dev/v1, and a redirect response must itself carry
Access-Control-Allow-Origin for a browser to follow it cross-origin. That
301 carries no CORS headers at all, so the request died before the
redirect could be taken.
Measured rather than guessed, with an Origin header set:
api.frankfurter.app -> 301, no access-control-* headers
api.frankfurter.dev -> 200, access-control-allow-origin: *
The response shape is unchanged, so parseFxResponse needed no edit.
Nothing was wrong at build time, which is why this went unnoticed: a
server-side fetch follows the 301 freely, so the baked rates were always
correct. Only the browser path was broken.
Two changes, because a new default alone fixes only some datasets:
- DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the
redirecting host ever comes back.
- A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the
dead default, which is the case for anyone who edited that field. It is
DROPPED rather than rewritten, so the dataset follows whatever the
current default is and the next move of the service needs no second
migration for the same user; a URL the owner chose is left strictly
alone. No stored figure changes, so it cannot alter a number in the
budget.
Verified by clicking the button in a real browser: "Updated 2 rate(s)",
USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD
figure differs from the baked constant, so it is a genuine live fetch.
Two migration tests asserted the chain ends at 3; they now assert
SCHEMA_VERSION, since what they mean is "all the way through".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Production notes at the foot of Settings: where this came from, what it
was built with, what it is set in, and what it stores where.
The schema version is real, read from the dataset rather than written
into the copy, because it is the number that matters when someone opens a
backup and wonders what will read it.
The build stamp is new plumbing, and small: build.ts already computed a
`version` for the service worker, so the app bundle now gets the same
value. Read through a try/catch accessor, never a `typeof process` guard
(AGENTS.md §2) — and genuinely absent in development, which is the normal
case and reads as "development" rather than as an error. A caller's own
BUILD_VERSION is printed as given; only build.ts's default timestamp is
translated, because "1788379707862" tells a reader nothing and
"2026-09-02" tells them what they wanted. ISO, not a locale format: this
string ends up in bug reports and should not vary by who reads it.
rel="noreferrer" on the link is deliberate rather than boilerplate. An
app whose whole position is that the data never leaves the browser should
not hand this page's URL to another origin on the way out.
No licence is claimed for the app: there is no LICENSE file in the
repository, and asserting one in the UI would be worse than saying
nothing. The font licences are committed beside the files, so those are
named.
Verified: both build paths inline the value and leave no bare process.env
reference in the bundle (BUILD_VERSION=v1.4.0-test appears literally, the
default appears as a timestamp), and the section was read in both themes.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI failed on the guard added in 0c3729b, which passed locally. Both were
right: docs/TODO.md sent readers to
`.superpowers/sdd/2026-09-02-versioned-allocation-rules/`, a tool-managed
ledger whose own .gitignore is `*`. It existed on the machine that wrote
the reference and has never been in the repository, so the guard saw a
real file locally and nothing at all in a fresh checkout.
Two fixes, because there were two faults:
- TODO.md now points at the four commit messages that actually record why
those surfaces work as they do — de128e3, 1cb91d3, e3023d0, f94e458 —
and says plainly that the fuller ledger was never committed. Three of
the four brief defects it described are in those messages.
- The guard now checks a path is git-TRACKED, not merely present. That is
the invariant it was always meant to enforce: a path only the author
can follow is exactly the reference a reader cannot. It degrades to a
filesystem check when git cannot answer, and `.superpowers/` joins
docs/superpowers/specs/ as a declared exception — both are named in the
docs precisely because they are not in the repository.
A fresh clone is now the standard the check measures against, so it means
the same thing on a laptop and in CI.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Moving the docs rewrote about thirty cross-references in one go. A
reference left pointing at nothing fails no build, fails no test, and
does not look wrong in review — it is found by the next person who
follows it. That is the same silence eventCapture.test.ts and
cssTokens.test.ts exist for.
Checks markdown links and backticked paths in AGENTS.md, CLAUDE.md,
GEMINI.md, README.md and docs/*.md. A path counts as resolved from either
the file naming it or the repository root, since both conventions are in
use and both are unambiguous to a reader.
Scoped to the docs a reader NAVIGATES. docs/specs/ and docs/plans/ are
excluded on purpose: they are dated records of a moment, and the plan
that names src/App.tsx is describing scaffolding it went on to delete.
Holding history to today's tree would force rewriting the past or
deleting the guard.
One declared exception, with its reason in the source: AGENTS.md §4 names
docs/superpowers/specs/ precisely because it must NOT exist — it is the
tooling default that has to be overridden, or the next spec silently
recreates the old tree.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>