Commit Graph
29 Commits
Author SHA1 Message Date
Lucas Winther 67c77eb247 docs: plan budget folders and recurring cost placement 2026-09-29 20:18:25 +02:00
Lucas Winther f1e42d9d77 docs: design budget folders and recurring cost placement 2026-09-29 20:13:45 +02:00
Lucas Winther c93ff9908c fix(rules): review and harden rule-history editing 2026-09-29 19:01:57 +02:00
Lucas Winther 8c3dbafc86 docs(plans): task-by-task plan for the anchored model
Nine tasks. One through three are the domain and run in strict order: the
clamped month step, the type and signature changes, then the split walk.
Four (migration), five (validation) and six (the band) each depend on three.
Seven (the split editor) is independent. Eight (the purchase dialog) needs
seven and five. Nine is docs and demo data.

Task three carries two deletions that belong tidily to tasks five and six.
They are done early because deleting wouldAdvancePast breaks compilation in
both files, and a task that cannot run its own suite cannot be reviewed.

Every expected value was checked by hand rather than derived from the
implementation: the 30-day step from 2026-09-12 lands on 2026-10-12 then
2026-11-11; 31 Aug + 6 months clamps to 28 Feb 2027; and 31 Jan + 2 months is
31 March, which only holds if the clamp measures from the original day instead
of stepping month by month. That last one is the trap in Task 1 and it has its
own test.

Flagged as the known risk: making SplitEditor generic touches the purchase
dialog, which is unrelated to recurring costs. The claim that PurchaseDraft
satisfies SplitTarget structurally is checkable by tsc alone and should be
verified before the rest of that task is written.
2026-09-05 00:45:38 +02:00
Lucas Winther c2b3d00381 docs(specs): the anchored model for recurring costs
The first version modelled the phone bill as "confirm a projected slot early",
and that was wrong at the root. A charge is not a slot claimed ahead of
schedule — it is an event that can happen at any moment, any number of times
per cycle, each one restarting the clock. There is no grid to claim; there is a
moving anchor.

Confirming twice in one September pushed the next due date to November, because
each confirmation consumed a future slot instead of resetting the anchor.
Reproduced under BOTH anchorings, which is what proves the defect is in the
slot model rather than in the rebase rule.

So the two anchorings get two projections, and the asymmetry is the design.
Paying September AND October's rent really does mean nothing is due until
November — for a calendar bill, consuming two slots is correct. Only the
anchored case is wrong, so only the anchored case changes.

The anchored walk cannot fail to advance: it steps forward from a real date
with n >= 1. That makes the entire class of "confirmation dated behind its
slot" defects unreachable — the class that produced three Criticals in round 1
— so wouldAdvancePast, the band's offerability guard and PATH A's
updatePurchase guard are all deleted. This change removes more code than it
adds.

everyNMonths becomes day-granular under lastCharge, since a plan refreshing on
20 September is next due 20 October, not "October". That finally forces a
month-clamp rule (31 Jan + 1 month is 28 Feb), confined to the anchored path.

Also closes the two additive requests: a cost's splits become editable through
the same SplitEditor purchases use, and the purchase dialog can tag a purchase
as a charge for a cost or create one from the purchase being typed. Tagging is
the refresh mechanism, so the data cap stops needing a flow of its own.

Self-review caught the band's Confirm being removed for anchored costs, which
would have made paying a bill on time slower to serve the exceptional case.
2026-09-05 00:38:10 +02:00
Lucas Winther 92e6b763f2 docs(recurring): record the coherence rule, and catch up §10-11 to what shipped
- AGENTS.md §1: reworded the "no horizon" invariant now that a parameter
  literally named horizonMonths exists. Nothing is stored — ExpectedBand's
  horizon only widens how far one read-only walk looks before the result is
  thrown away — but the old wording read as a contradiction to the next
  reader. The no-materialisation rule itself is unchanged.
- DECISIONS.md: added the "archived mirrors endedFrom" entry that was
  missing, including the previously-undocumented corollary that a
  future-dated cancellation dims the row immediately even though the bill
  keeps projecting until endedFrom.
- docs/specs/2026-09-03-recurring-costs-design.md §10-11: updated to
  describe the shipped UI — the "Coming up" group, the Ends column, the two
  coherence checks that gate a coming-up row (X1, X3), and the tests that
  now cover all of it.
2026-09-04 09:15:28 +02:00
Lucas Winther 405cf0bf2d docs+chore: everyNMonths is n-generic too; stop the demo double-charging Rent
PRODUCT.md described recurring costs as repeating "monthly, every N days,
or every N weeks", understating everyNMonths, which supports an interval
like the other two kinds.

demo-data.ts gave "Rent" both an ordinary monthly purchase and a
never-confirmed recurring cost from 2026-06 onward, so the demo's Rent
Projected read as double-charged for June through September. The ordinary
purchase now stops once the recurring cost exists.
2026-09-04 07:58:09 +02:00
Lucas Winther 0d2aa519b7 docs(recurring): record the invariants and what they cost
Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.

§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.

Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
2026-09-03 19:03:35 +02:00
Lucas Winther 94e7a189b6 docs(plans): task-by-task plan for recurring costs
Ten tasks. One through five are the domain and run in strict order: day
arithmetic, types and the 6 -> 7 migration, the occurrence walk, expected
charges, then the fold's second track. Six and seven (actions, import
validation) need only the types. Eight and nine are the two UI surfaces. Ten is
demo data, documents and screenshots.

Task 2 deliberately leaves one test red. The behaviour-preservation guard —
every post in every month folds identically, projected equal to remaining —
is written where the risk is created and goes green in task 5, where the field
it asserts on exists.

Writing it out caught four things the spec could not:

- A fold assertion of -4000 where the accumulators give 4000. Left in, the
  implementer would have changed working code to match a wrong expectation.
- A second reachable cause for the walk's termination throw. Under lastCharge,
  a confirmation dated more than one step before the slot it claims moves the
  cursor backwards; a valid calendar series never can. The message now names
  both causes, because they need different fixes, and there is a test.
- parseMoneyInput takes (text, options, fallback). The options list is what
  makes "30$" resolve to USD rather than the fallback.
- demo-data.ts builds through actions.*, not literals, so the seeding task
  uses addRecurringCost with the ids already in scope.

Task 10 ends by stating what screenshots do NOT prove: how it looks and feels,
the overdue count against a real backlog, and whether two-stage confirmation
matches how the owner actually reconciles.
2026-09-03 03:29:39 +02:00
Lucas Winther 2096e11a85 docs(specs): recurring costs take 7, not 6
The decimals change landed first (8d2b2ca) and took schema 6, so this spec's
migration becomes 6 -> 7 and its undo path 7 -> 8. Both specs were drafted
claiming 6; only the number had to move, because the steps were always
independent — that one changed where digits live, this one adds an array.

Three places the same commit made stale rather than merely renumbered:

- The shared split helper is smaller than designed. chargesForPurchaseInMonth
  already takes digits: number rather than a currency table, so it needs no
  currency plumbing.
- Display is now a rounding boundary. The Expected band gets its formatters
  from useMoneyFormat(), not format.ts, and its amount field parses through
  parseMoneyInput as a text input.
- currencyDigits.test.ts no longer drives one zero-decimal CURRENCY; it drives
  the whole dataset at zero decimals. The expected track joins that sweep.
2026-09-03 03:29:26 +02:00
Lucas WintherandClaude Opus 5 8d2b2ca881 feat(money): one decimals setting for the dataset, not one per currency
`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]>
2026-09-03 03:12:14 +02:00
Lucas WintherandClaude Opus 5 596de45525 docs(specs): recurring costs, expected against actual
Bills that repeat: rent monthly, a subscription every 28 days, a phone bill
every 30 days whose clock restarts when the data cap is hit early.

A recurring cost projects expected charges forward; each occurrence is
confirmed into a real Purchase when the money moves. The fold gains a second
balance, so `remaining` never shifts and `projected` says what is left after
commitments. The two reconverge as a month settles.

The phone bill reads like a third recurrence kind and is not one. It charges
30 days after the PREVIOUS CHARGE, and the data cap only makes a charge happen
early — a date two-stage confirmation already records. So `anchoring` is one
field, `calendar` or `lastCharge`, and the domain never learns what a data cap
is. That collapse is most of why this design is small.

Two consequences worth not re-litigating later:

- `everyNMonths` carries no day. Rent occurs IN September, and `IsoDate`
  already permits a bare "YYYY-MM", so the commonest case needs no day
  arithmetic and no February clamping rule.
- `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.

Occurrences are computed, never stored — materialised snapshots were rejected
in the original design, and an unbounded recurrence would otherwise need a
horizon. `upToMonth` comes from the caller, so src/domain/ stays clock-free.

Takes schema 6. The global-decimals spec claimed 6 too and is reassigned to 7;
both specs now say so. The steps are independent and compose in either order.

Spec only. The two settings-layout fixes in the tree are unrelated and stay
unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 02:50:05 +02:00
Lucas WintherandClaude Opus 5 f4223573e4 feat(currencies): ship sterling, and migrate it into existing datasets
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]>
2026-09-02 22:27:28 +02:00
Lucas WintherandClaude Opus 5 0fcfdd27cd fix(fx): the rate service moved, and a browser cannot follow its redirect
"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]>
2026-09-02 22:20:43 +02:00
Lucas WintherandClaude Opus 5 61e7093c36 feat(settings): add a colophon, with a link to the source
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]>
2026-09-02 22:09:14 +02:00
Lucas WintherandClaude Opus 5 a73cff13a3 fix(test): the docs guard must ask git, not the disk
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]>
2026-09-02 22:06:48 +02:00
Lucas WintherandClaude Opus 5 56180f77ed docs: TODO.md said the work was unmerged, which the merge made false
Also the test count, which the docs guard added three to.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:59:38 +02:00
Lucas WintherandClaude Opus 5 21021952e7 docs: add DECISIONS.md, an index of what was chosen and over what
"Why is this the way it is" was answered by pointing at a directory of
five specs, which means reading five specs to find out whether the
question is even addressed. DECISIONS.md answers it in one line per
decision, grouped by area, each entry naming what the choice was made
*over* and linking into the spec that argues it.

Around forty entries. Where no spec exists — the currency model, the
baked rates, the reset — the commit message is cited as the record
instead of inventing one.

Written by reading the specs rather than from memory, which surfaced
history worth having in one place: dates were widened to accept "YYYY-MM"
rather than restructured into month plus optional day, specifically to
avoid running a migration in the browser against the user's only copy of
their financial data for an identical visible outcome.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:58:11 +02:00
Lucas WintherandClaude Opus 5 a039176aa3 docs: prune TODO.md, and correct four claims that were no longer true
TODO.md becomes a TODO again: only work that is actually queued. Five of
its seven sections were finished work, and their reasoning has permanent
homes — the rules moved to AGENTS.md (the baked rates seed a new dataset
only, never a fallback), the rest is in the commit messages and the
specs. 215 lines to 131.

The four corrections, all of them things a reader would have believed:

- README said the install prompt could not work because the manifest
  "only ships an SVG icon". It has shipped 192x192 and 512x512 PNGs since
  f50c65b. Now stated as unverified rather than incomplete, which is what
  it actually is.
- TODO said no browser had seen the two rule-editing surfaces, while its
  own section 5 listed both as seen and confirmed.
- TODO said "merged to main" when the last three commits were on a
  branch.
- scripts/screenshot.ts explained its own existence by saying
  ARCHITECTURE.md and TODO.md claim no browser can run here. Both now say
  the opposite — partly because of that script.

ARCHITECTURE.md's gotchas section now says AGENTS.md holds the
enforceable form of those rules and this holds the mechanism, so a reader
hitting the overlap knows which is canonical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:57:58 +02:00
Lucas WintherandClaude Opus 5 824c2f5f59 docs: make AGENTS.md the only agent guide, restructured into five sections
AGENTS.md now holds the instructions and CLAUDE.md and GEMINI.md are
pointers to it. Three files of instructions become three files that
disagree; one file every agent reads cannot.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:57:38 +02:00
Lucas WintherandClaude Opus 5 d83edccc79 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) <[email protected]>
2026-09-02 21:56:59 +02:00
Lucas WintherandClaude Opus 5 7b71811262 feat(month): rethink the header, post rows and carry meter
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) <[email protected]>
2026-09-02 21:37:23 +02:00
Lucas WintherandClaude Opus 5 bd5a01e03e chore(tooling): make the screenshot recipe work with no setup
Two things this session depended on lived only in a scratchpad, so the next
agent would have had to reinvent them from a prose hint:

- scripts/screenshot-fonts.conf, now applied by screenshot.ts itself. Without
  it this container renders every generic font family as Fira Code and the UI
  looks monospaced in a way no user sees — a trap that nearly produced a "fix"
  for a font bug that does not exist.
- scripts/demo-data.ts, which seeds a visibly synthetic dataset. Without it
  every screenshot shows an app full of zeros, because Chrome starts from a
  fresh profile each run.

CLAUDE.md now carries the two-command recipe verbatim instead of "point
FONTCONFIG_FILE at an override", and says why --reload is mandatory. Verified
from a clean shell with no environment set.

Also flags docs/superpowers/plans/2026-09-01-budget-app.md as a historical
record: its snippets still show CURRENCIES and CURRENCY_DIGITS, which no
longer exist, and an agent reading it as current would be misled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 16:07:09 +02:00
Lucas WintherandClaude Opus 5 d8ee579fe7 docs: spec for the design foundation, with the alternatives rejected
Records why the brand teal is --primary rather than --accent, why @font-face
cannot live in the bundled stylesheet (measured: 70 KB -> 303 KB of
render-blocking bytes, and unicode-range subsetting defeated), why the theme
preference is not in the dataset, and the carry-meter regression that earned
the undefined-token guard.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 15:12:35 +02:00
Lucas WintherandClaude Opus 5 c74a66aea9 docs: implementation plan for time-versioned allocation rules
Six tasks. Task 1 is deliberately large because the model change is atomic
under TypeScript: Post.standingRule -> Post.rules will not compile as a partial
change, so everything referencing it moves together, with the settings UI
getting only a read-only summary until Task 5.

Self-review closed two spec requirements that had no test: a version dated
before the fold start is legal but inert, and an archived post still resolves
its rules because it still folds.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 02:08:34 +02:00
Lucas WintherandClaude Opus 5 acf973f322 docs: spec time-versioned allocation rules
A post's allocation rule becomes a dated series: a version takes effect in its
month and continues until the next replaces it. Per-month overrides survive
alongside as one-month exceptions, because collapsing the two loses the
distinction between a permanent change and a temporary one.

A post with no version yet allocates zero rather than applying its earliest
rule backwards, which would invent budget for months never budgeted and let
the rollover fold carry that fiction forward.

Requires the project's first real migration (schemaVersion 1 -> 2). It is a
pure shape change and behaviour-preserving by construction, and the spec makes
that a tested property rather than an assumption: every month's allocation must
be identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 02:02:53 +02:00
Lucas WintherandClaude Opus 5 6924a593fb docs: spec fast-entry refinements (tab flow, optional day, notes)
Three entry-form changes specified together because they share the same tab
order: keyboard-driven bulk rows, purchases that no longer demand a day, and
an optional note per purchase.

Records why the date change widens IsoDate rather than splitting it into
month + optional day: the split is the better model but changes a type every
layer depends on and needs a migration run in the browser against the user's
only copy of their data, for an identical user-visible outcome. Verified that
monthOf() already parses both date shapes and still rejects out-of-range
months.

Time-versioned allocation rules are explicitly out of scope and get their own
cycle.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:53:43 +02:00
Lucas WintherandClaude Opus 5 abd77c58ed docs: add budget app implementation plan
25 TDD tasks across five phases: foundation, pure domain core, IndexedDB
persistence, JSON/ODS export, UI, and shipping (PWA + Docker + CI).

Self-review caught five defects, fixed inline:
- distributeByWeight divided by zero on all-zero weights, which a
  zero-total purchase reaches and which would take the whole fold down
- summaryView's fold binding needed an explicit type or CI typecheck fails
- a duplicated module import in registerSw.ts
- a shell render test that would break once its routes read the store,
  since happy-dom has no IndexedDB
- an unreadable assertion in the ODS numeric-cell test

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 16:13:40 +02:00
Lucas WintherandClaude Opus 5 6833a58c91 docs: add budget app design spec
Design for a personal budgeting PWA replacing a spreadsheet workflow.

Key decisions captured:
- Static SPA, no backend in the data path, so it deploys to both GitHub
  Pages and a self-hosted Docker image from one build artifact
- Bun toolchain (bun init --react=shadcn); no Vite, no Electron
- Pure derivation core over an IndexedDB fact store; envelope rollover
  makes caching worthless, so every figure is folded from raw facts
- One Purchase entity covers simple, split, and financed purchases, which
  is what lets splits and finance plans compose without special-casing
- Money is a float, with mandatory rounding at boundaries and last-part-
  by-subtraction so distributed parts sum exactly to the whole

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 15:50:46 +02:00