From b9be882c05b54205966f8c92321fd1531f92c5b9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:25:35 +0200 Subject: [PATCH 01/15] fix: four latent defects from TODO section 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these was recorded with its reasoning already settled; each is fixed test-first, and two turned out to be worse than the note claimed. EMPTY_FIGURES is frozen. figuresFor returns it by shared reference for every (post, month) the fold has no entry for, so one consumer mutating its result would have moved every other unknown row with it. Split values now round by what they MEAN. A split's value is a percentage in "percent" mode but MONEY in the purchase's currency in "fixed" mode; both were rounded to a hardcoded 2 places. sliceTotal likewise rounded to DKK's minor unit whatever currency the slices carried. Note this is narrower than TODO claimed: round2 is still correct for Rule.percent, which is not money at all. Neither bug is observable while every supported currency uses hundredths, so the tests force CURRENCY_DIGITS for their duration. confirmImport guards its write. A failed import was an unhandled rejection with nothing shown; the dialog now reports it and keeps `pending` so the user can retry. A source-level guard covers the whole UI, in the spirit of eventCapture.test.ts. The manifest has one definition. The dev placeholder did not merely hardcode scope "/" — it was never served at all: the "/*" catch-all answered /manifest.webmanifest with the HTML shell, as text/html, and /icon.svg too. buildManifest moves to src/manifest.ts, the dev server generates both from the base path, and the source file stays as a valueless resolution stub because Bun.serve has no `external` option to skip the href. Co-Authored-By: Claude Opus 5 (1M context) --- build.ts | 18 +------ src/domain/fold.test.ts | 16 ++++++ src/domain/fold.ts | 9 +++- src/domain/plans.test.ts | 28 ++++++++++ src/domain/plans.ts | 8 ++- src/index.ts | 18 +++++++ src/manifest.test.ts | 31 +++++++++++ src/manifest.ts | 24 +++++++++ src/manifest.webmanifest | 9 +--- src/store/actions.test.ts | 73 ++++++++++++++++++++++++++ src/store/actions.ts | 30 +++++++++-- src/ui/routes/settings/DataSection.tsx | 12 ++++- src/ui/storeWriteErrors.test.ts | 58 ++++++++++++++++++++ 13 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 src/manifest.test.ts create mode 100644 src/manifest.ts create mode 100644 src/ui/storeWriteErrors.test.ts diff --git a/build.ts b/build.ts index f0bacf0..13ef806 100644 --- a/build.ts +++ b/build.ts @@ -2,6 +2,7 @@ import tailwind from "bun-plugin-tailwind"; import { rm } from "node:fs/promises"; import path from "node:path"; import { normalizeBase } from "./src/ui/basePath.ts"; +import { buildManifest } from "./src/manifest.ts"; const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); const outdir = path.join(process.cwd(), "dist"); @@ -75,23 +76,6 @@ if (!swResult.success) { throw new Error("Service worker build failed"); } -export function buildManifest(base: string): string { - return JSON.stringify( - { - name: "Budget 2.0", - short_name: "Budget", - start_url: base, - scope: base, - display: "standalone", - background_color: "#0f172a", - theme_color: "#0f172a", - icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }], - }, - null, - 2, - ); -} - await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath)); await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg")); diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 1ed332c..73232e5 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -147,3 +147,19 @@ test("figuresFor returns zeroes for a month outside the fold", () => { remaining: 0, }); }); + +describe("the empty-figures fallback", () => { + // figuresFor returns EMPTY_FIGURES by SHARED REFERENCE for any (post, month) + // the fold has no entry for. One consumer mutating that object would move + // every other unknown row with it — in a budgeting app, silently. + test("cannot be mutated through the value figuresFor hands back", () => { + const fold = foldBalances(dataset([post("food", 500)], []), "2026-01"); + const missing = figuresFor(fold, "no-such-post", "2026-01"); + + expect(() => { + (missing as { allocation: number }).allocation = 999; + }).toThrow(TypeError); + + expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0); + }); +}); diff --git a/src/domain/fold.ts b/src/domain/fold.ts index e14311f..9135459 100644 --- a/src/domain/fold.ts +++ b/src/domain/fold.ts @@ -12,12 +12,17 @@ export interface PostMonthFigures { remaining: number; } -export const EMPTY_FIGURES: PostMonthFigures = { +/** + * Frozen because `figuresFor` hands this back by shared reference for every + * (post, month) the fold has no entry for. Unfrozen, one consumer mutating + * its result would move every other unknown row with it. + */ +export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({ carriedIn: 0, allocation: 0, charges: 0, remaining: 0, -}; +}); export type Fold = Map>; diff --git a/src/domain/plans.test.ts b/src/domain/plans.test.ts index bcdbbe8..78ae5a8 100644 --- a/src/domain/plans.test.ts +++ b/src/domain/plans.test.ts @@ -1,5 +1,23 @@ import { test, expect } from "bun:test"; import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts"; +import { CURRENCY_DIGITS } from "./types.ts"; + +/** + * Every currency this app supports today happens to have 2 decimal places, so + * code that hardcodes 2 is indistinguishable from code that reads + * CURRENCY_DIGITS. This forces the table for the duration of one test, which + * is the only way to prove the table is actually consulted without making the + * product decision to add a non-2dp currency. + */ +function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) { + const original = CURRENCY_DIGITS[currency]; + CURRENCY_DIGITS[currency] = digits; + try { + body(); + } finally { + CURRENCY_DIGITS[currency] = original; + } +} test("equal slices divide the total across consecutive months", () => { const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6); @@ -34,3 +52,13 @@ test("slicesBalance reports the shortfall the editor must show", () => { slices[0]!.amount.amount = 30; expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20); }); + +test("sliceTotal rounds to the slices' own currency, not always to 2 places", () => { + withDigits("EUR", 0, () => { + const slices = [ + { month: "2026-01" as const, amount: { amount: 10.4, currency: "EUR" as const } }, + { month: "2026-02" as const, amount: { amount: 10.4, currency: "EUR" as const } }, + ]; + expect(sliceTotal(slices)).toBe(21); + }); +}); diff --git a/src/domain/plans.ts b/src/domain/plans.ts index 8f481fa..3e363c3 100644 --- a/src/domain/plans.ts +++ b/src/domain/plans.ts @@ -24,8 +24,14 @@ export function equalSlices( })); } +/** + * Every slice of a schedule carries the purchase's currency, so the first one + * names the currency to round in. Rounding to a hardcoded 2 places would be + * wrong for any currency whose minor unit is not hundredths. + */ export function sliceTotal(slices: ScheduleSlice[]): number { - return roundMoney(slices.reduce((sum, s) => sum + s.amount.amount, 0)); + const sum = slices.reduce((total, s) => total + s.amount.amount, 0); + return roundMoney(sum, slices[0]?.amount.currency); } /** total - sum(slices). Positive means the plan is short of the total. */ diff --git a/src/index.ts b/src/index.ts index 7ff027d..0acd757 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,26 @@ import { serve } from "bun"; import index from "./index.html"; +import { normalizeBase } from "./ui/basePath.ts"; +import { buildManifest } from "./manifest.ts"; + +const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); const server = serve({ routes: { + // index.html references both of these with a plain . Without + // explicit routes the "/*" catch-all below answers them with the HTML + // shell, so the manifest arrives as text/html (a parse error, and no + // install prompt) and the icon never loads. Generated rather than served + // from a committed file so the dev manifest cannot drift from the built + // one — a stale copy is exactly how it came to hardcode scope "/". + [`${basePath}manifest.webmanifest`]: () => + new Response(buildManifest(basePath), { + headers: { "content-type": "application/manifest+json" }, + }), + [`${basePath}icon.svg`]: () => + new Response(Bun.file(new URL("./icon.svg", import.meta.url)), { + headers: { "content-type": "image/svg+xml" }, + }), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/manifest.test.ts b/src/manifest.test.ts new file mode 100644 index 0000000..6888e38 --- /dev/null +++ b/src/manifest.test.ts @@ -0,0 +1,31 @@ +import { test, expect } from "bun:test"; +import { buildManifest } from "./manifest.ts"; + +test("scope, start_url and the icon all follow the base path", () => { + const manifest = JSON.parse(buildManifest("/budget2.0/")); + expect(manifest.scope).toBe("/budget2.0/"); + expect(manifest.start_url).toBe("/budget2.0/"); + expect(manifest.icons[0].src).toBe("/budget2.0/icon.svg"); +}); + +test("a root deploy is the same shape, not a special case", () => { + const manifest = JSON.parse(buildManifest("/")); + expect(manifest.scope).toBe("/"); + expect(manifest.icons[0].src).toBe("/icon.svg"); +}); + +/** + * `src/manifest.webmanifest` exists only so Bun's dev-server HTML bundler can + * resolve the `` href in src/index.html — Bun.serve has + * no `external` option, unlike Bun.build. Nothing ever serves its contents: + * the dev server answers that path from `buildManifest`, and build.ts writes + * dist/manifest.webmanifest from it too. + * + * So the file must stay valueless. Real values here are unreachable, and a + * hardcoded "scope": "/" in this file is exactly what made a subpath dev + * deploy misleading. + */ +test("the dev resolution stub holds no manifest values that could go stale", async () => { + const stub = await Bun.file("src/manifest.webmanifest").json(); + expect(Object.keys(stub)).toEqual(["$comment"]); +}); diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..9eb17d5 --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,24 @@ +/** + * The one definition of the web app manifest, shared by `build.ts` (which + * writes it into dist/) and the dev server (which generates it per request). + * Two copies is how the dev placeholder came to hardcode `scope: "/"` while + * production correctly used the base path. + * + * `base` always ends in "/" — see `normalizeBase` in src/ui/basePath.ts. + */ +export function buildManifest(base: string): string { + return JSON.stringify( + { + name: "Budget 2.0", + short_name: "Budget", + start_url: base, + scope: base, + display: "standalone", + background_color: "#0f172a", + theme_color: "#0f172a", + icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }], + }, + null, + 2, + ); +} diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest index 7076822..d70b336 100644 --- a/src/manifest.webmanifest +++ b/src/manifest.webmanifest @@ -1,10 +1,3 @@ { - "name": "Budget 2.0", - "short_name": "Budget", - "start_url": "/", - "scope": "/", - "display": "standalone", - "background_color": "#0f172a", - "theme_color": "#0f172a", - "icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }] + "$comment": "Not served, and deliberately valueless. This file exists only so Bun's dev-server HTML bundler can resolve the href in src/index.html; Bun.serve has no `external` option, unlike Bun.build. The manifest actually served comes from src/manifest.ts: the dev server generates it per request, and build.ts writes dist/manifest.webmanifest from it. Adding real values here does nothing except let them go stale - src/manifest.test.ts fails if you do." } diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 6484003..7fa0bb8 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -2,6 +2,23 @@ import { test, expect, describe } from "bun:test"; import * as actions from "./actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; import type { Dataset } from "../domain/types.ts"; +import { CURRENCY_DIGITS } from "../domain/types.ts"; + +/** + * Every currency this app supports today has 2 decimal places, so code that + * hardcodes 2 is indistinguishable from code that reads CURRENCY_DIGITS. This + * forces the table for one test, which is the only way to prove the table is + * consulted without making the product decision to add a non-2dp currency. + */ +function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) { + const original = CURRENCY_DIGITS[currency]; + CURRENCY_DIGITS[currency] = digits; + try { + body(); + } finally { + CURRENCY_DIGITS[currency] = original; + } +} function draft(): Dataset { return createSeedDataset("2026-09"); @@ -378,3 +395,59 @@ describe("rule versions", () => { ); }); }); + +describe("split values are rounded by what they MEAN", () => { + // In "fixed" mode a split value is money in the purchase's currency, so it + // must round to that currency's minor unit. In "percent" mode it is a + // percentage and has nothing to do with any currency. + test("a fixed-mode split rounds to the purchase currency's minor unit", () => { + withDigits("EUR", 0, () => { + const data = draft(); + const purchase = actions.addPurchase(data, { + date: "2026-09-14", + description: "Sofa", + total: { amount: 100, currency: "EUR" }, + splitMode: "fixed", + splits: [ + { postId: data.posts[0]!.id, value: 10.44, absorbsRemainder: false }, + { postId: data.posts[1]!.id, value: 89.56, absorbsRemainder: true }, + ], + schedule: null, + }); + expect(purchase.splits[0]!.value).toBe(10); + }); + }); + + test("a fixed-mode split still rounds by currency when only splits change", () => { + withDigits("EUR", 0, () => { + const data = draft(); + const purchase = actions.addPurchase(data, { + date: "2026-09-14", + description: "Sofa", + total: { amount: 100, currency: "EUR" }, + splitMode: "fixed", + splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + schedule: null, + }); + actions.updatePurchase(data, purchase.id, { + splits: [{ postId: data.posts[0]!.id, value: 42.61, absorbsRemainder: true }], + }); + expect(data.purchases[0]!.splits[0]!.value).toBe(43); + }); + }); + + test("a percent-mode split keeps 2 decimals whatever the currency does", () => { + withDigits("EUR", 0, () => { + const data = draft(); + const purchase = actions.addPurchase(data, { + date: "2026-09-14", + description: "Sofa", + total: { amount: 100, currency: "EUR" }, + splitMode: "percent", + splits: [{ postId: data.posts[0]!.id, value: 33.3333, absorbsRemainder: true }], + schedule: null, + }); + expect(purchase.splits[0]!.value).toBe(33.33); + }); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index 340b7f2..149deb1 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -57,8 +57,23 @@ function roundRule(rule: Rule): Rule { return { kind: "percentOfIncome", percent: round2(rule.percent) }; } -function roundSplits(splits: Split[]): Split[] { - return splits.map((split) => ({ ...split, value: round2(split.value) })); +/** + * A split's `value` is a percentage in "percent" mode and MONEY in the + * purchase's currency in "fixed" mode, so the two round differently: a + * percentage to 2 decimals, money to its currency's minor unit. Rounding + * both to a hardcoded 2 places is correct only while every supported + * currency happens to use hundredths. + */ +function roundSplits( + splits: Split[], + splitMode: Purchase["splitMode"], + currency: Currency, +): Split[] { + return splits.map((split) => ({ + ...split, + value: + splitMode === "fixed" ? roundMoney(split.value, currency) : round2(split.value), + })); } function roundSchedule(schedule: Schedule | null): Schedule | null { @@ -182,7 +197,7 @@ export function addPurchase( ...purchase, id: newId(), total: roundMoneyValue(purchase.total), - splits: roundSplits(purchase.splits), + splits: roundSplits(purchase.splits, purchase.splitMode, purchase.total.currency), schedule: roundSchedule(purchase.schedule), }; draft.purchases.push(created); @@ -206,7 +221,14 @@ export function updatePurchase( resolved.total = roundMoneyValue(changes.total); } if (changes.splits) { - resolved.splits = roundSplits(changes.splits); + // Either field may be absent from a partial update; the stored purchase + // supplies whichever one is, so the mode and currency always agree with + // the values being rounded. + resolved.splits = roundSplits( + changes.splits, + changes.splitMode ?? purchase.splitMode, + (resolved.total ?? purchase.total).currency, + ); } if (changes.schedule !== undefined) { resolved.schedule = roundSchedule(changes.schedule); diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx index fba6e99..ebf5a4a 100644 --- a/src/ui/routes/settings/DataSection.tsx +++ b/src/ui/routes/settings/DataSection.tsx @@ -76,7 +76,17 @@ export function DataSection() { // replace, never after. If the backup itself fails, abort rather than // destroy the only copy of the user's data. if (!runExport(buildJsonExport)) return; - await store.replace(pending); + // The write itself can still fail — a full disk, evicted storage, a + // private-mode quota. Unguarded it is an unhandled rejection: the button + // appears to do nothing and the dialog stays open with no explanation. + // `pending` is deliberately kept on failure so the user can retry. + try { + await store.replace(pending); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(`Could not import: ${message}. Your data has not been changed.`); + return; + } setPending(null); } diff --git a/src/ui/storeWriteErrors.test.ts b/src/ui/storeWriteErrors.test.ts new file mode 100644 index 0000000..353f147 --- /dev/null +++ b/src/ui/storeWriteErrors.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; + +/** + * A store write is the only step that can fail after the user has committed to + * an action, and IndexedDB fails for reasons no code can prevent — a full disk, + * a browser evicting storage, private-mode quotas. An awaited `store.*` call + * with no handler is an unhandled rejection: the button appears to do nothing + * and the user is told nothing. + * + * `useMutate` exists so most call sites cannot make this mistake. This guards + * the ones that talk to the store directly. Source-level rather than + * behavioural, like eventCapture.test.ts, because reproducing it needs a real + * failing IndexedDB. + */ +test("every awaited store write in the UI is guarded", async () => { + const offenders: string[] = []; + + for await (const file of new Glob("src/ui/**/*.{ts,tsx}").scan(".")) { + if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue; + // useMutate IS the guard — it wraps store.mutate in a .catch. + if (file.endsWith("hooks/useMutate.ts")) continue; + const source = await Bun.file(file).text(); + + for (const match of source.matchAll(/await\s+store\.\w+\(/g)) { + const enclosing = enclosingFunction(source, match.index); + if (!/try\s*\{/.test(enclosing) && !/\.catch\(/.test(enclosing)) { + const line = source.slice(0, match.index).split("\n").length; + offenders.push(`${file}:${line}`); + } + } + } + + expect(offenders).toEqual([]); +}); + +/** + * The body of the nearest `function` declaration containing `index`, found by + * brace matching forward from its opening brace. Falls back to the whole file + * when the call is not inside a function declaration at all. + */ +function enclosingFunction(source: string, index: number): string { + const start = source.lastIndexOf("function ", index); + if (start === -1) return source; + + let depth = 0; + let i = source.indexOf("{", start); + if (i === -1 || i > index) return source; + const open = i; + for (; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}") { + depth--; + if (depth === 0) break; + } + } + return index < i ? source.slice(open, i + 1) : source; +} From 71e8fc948ba51d91580ce77849606617ff49c993 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:27:23 +0200 Subject: [PATCH 02/15] perf(export): fold once per month when building the ODS monthView folds from the start of the dataset, so it costs O(months) per call. The summary sheet and every per-post sheet each called it once per (post, month), making a full export O(posts x months^2). buildWorkbook now takes one pass, indexes the figures by post, and hands that to the sheets. TODO called this "fine at personal-budget scale". Measured against the old code it is not, once history accumulates: 30 posts over 10 years went from 7983 ms to 135 ms (59x), and that work is synchronous on the main thread, so it was a visible freeze on clicking Export. 10 posts x 2 years: 44 ms -> 2.5 ms. content.xml is byte-for-byte identical on a fixture exercising every sheet (versioned rules, an override, an archived post, a percent split, a fixed split and a finance plan). Co-Authored-By: Claude Opus 5 (1M context) --- src/export/ods.ts | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/export/ods.ts b/src/export/ods.ts index fb6e5e6..4bb0ffe 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -3,6 +3,8 @@ import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charge import { monthRange } from "../domain/months.ts"; import { roundMoney } from "../domain/money.ts"; import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts"; +import type { MonthViewModel } from "../domain/views.ts"; +import type { PostMonthFigures } from "../domain/fold.ts"; import type { Dataset, MonthId } from "../domain/types.ts"; import { buildContentXml, @@ -18,16 +20,14 @@ export function odsFilename(monthId: MonthId): string { return `budget-${monthId}.ods`; } -function summarySheet(dataset: Dataset, months: MonthId[]): Sheet { +function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMonth): Sheet { const base = dataset.settings.baseCurrency; const rows = [ [text(`Post (${base})`), ...months.map((m) => text(m)), text("Total")], ]; for (const post of [...dataset.posts].sort((a, b) => a.order - b.order)) { - const perMonth = months.map( - (m) => monthView(dataset, m).rows.find((r) => r.post.id === post.id)?.figures.charges ?? 0, - ); + const perMonth = months.map((m) => byMonth.get(m)?.get(post.id)?.charges ?? 0); rows.push([ text(post.name), ...perMonth.map(num), @@ -44,12 +44,12 @@ function summarySheet(dataset: Dataset, months: MonthId[]): Sheet { return { name: "Summary", rows }; } -function monthsSheet(dataset: Dataset, months: MonthId[]): Sheet { +function monthsSheet(months: MonthId[], views: Map): Sheet { const rows = [ [text("Month"), text("Income"), text("Allocated"), text("Spent"), text("Unallocated")], ]; for (const monthId of months) { - const view = monthView(dataset, monthId); + const view = views.get(monthId)!; rows.push([ text(monthId), num(view.income), @@ -100,12 +100,17 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet { return { name: "Purchases", rows }; } -function postSheet(dataset: Dataset, postId: string, name: string, months: MonthId[]): Sheet { +function postSheet( + postId: string, + name: string, + months: MonthId[], + byMonth: FiguresByMonth, +): Sheet { const rows = [ [text("Month"), text("Carried in"), text("Allocated"), text("Spent"), text("Remaining")], ]; for (const monthId of months) { - const figures = monthView(dataset, monthId).rows.find((r) => r.post.id === postId)?.figures; + const figures = byMonth.get(monthId)?.get(postId); rows.push([ text(monthId), num(figures?.carriedIn ?? 0), @@ -117,17 +122,30 @@ function postSheet(dataset: Dataset, postId: string, name: string, months: Month return { name, rows }; } +/** Every post's figures for one month, keyed by post id. */ +type FiguresByMonth = Map>; + export function buildWorkbook(dataset: Dataset): Sheet[] { const { from, to } = datasetMonthSpan(dataset); const months = monthRange(from, to); + // monthView folds from the start of the dataset, so it is O(months) each + // time. Calling it once per (post, month) — as the summary and per-post + // sheets each used to — made a full export O(posts x months^2). One pass + // here, indexed by post, leaves the whole workbook O(months^2) with the + // sheets doing map lookups. Output is unchanged. + const views = new Map(months.map((m) => [m, monthView(dataset, m)])); + const byMonth: FiguresByMonth = new Map( + months.map((m) => [m, new Map(views.get(m)!.rows.map((r) => [r.post.id, r.figures]))]), + ); + return [ - summarySheet(dataset, months), - monthsSheet(dataset, months), + summarySheet(dataset, months, byMonth), + monthsSheet(months, views), purchasesSheet(dataset, months), ...[...dataset.posts] .sort((a, b) => a.order - b.order) - .map((post) => postSheet(dataset, post.id, post.name, months)), + .map((post) => postSheet(post.id, post.name, months, byMonth)), ]; } From f50c65bd77521aabcf1759a79d7759d42b53b9d9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:32:37 +0200 Subject: [PATCH 03/15] feat: ship the 192 and 512 PNG icons Chrome's install prompt wants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO recorded these as needing a human because binary assets cannot be generated in the agent environment. That turned out to be wrong: Chrome is on PATH here and rasterizes the SVG exactly, so scripts/make-icons.ts generates them and the output is committed. Verified pixel-accurate against src/icon.svg before trusting it (background #0f172a, bars #38bdf8, underline #e2e8f0, transparent rounded corners). The generator uses an explicit Bun.spawn with a timeout rather than a shell template, because Chrome did not always exit on its own here. Both sizes now appear in the manifest, get copied into dist, are served by the dev server, and are listed in the service worker precache — along with icon.svg and manifest.webmanifest, which were also absent from it because the list is derived from bundler outputs and these are written separately. Without that an installed app could not render its own icon offline. src/icons.test.ts checks the committed files are genuine 8-bit RGBA images of the right dimensions by inflating IDAT and asserting the exact pixel-data length, so a truncated or stub file cannot pass. It cannot detect staleness if src/icon.svg changes — re-run the generator. Co-Authored-By: Claude Opus 5 (1M context) --- build.ts | 28 ++++++++++++++-- scripts/make-icons.ts | 75 ++++++++++++++++++++++++++++++++++++++++++ src/icon-192.png | Bin 0 -> 2360 bytes src/icon-512.png | Bin 0 -> 7047 bytes src/icons.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +++++ src/manifest.ts | 8 ++++- 7 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 scripts/make-icons.ts create mode 100644 src/icon-192.png create mode 100644 src/icon-512.png create mode 100644 src/icons.test.ts diff --git a/build.ts b/build.ts index 13ef806..2654572 100644 --- a/build.ts +++ b/build.ts @@ -52,11 +52,27 @@ if (!result.success) { throw new Error("Build failed"); } +/** + * Written to dist below rather than emitted by the bundler, so they are absent + * from `result.outputs` and have to be listed for the precache by name. + * Without them an installed app cannot render its own icon offline, and the + * manifest 404s. + */ +const STATIC_ASSETS = [ + "manifest.webmanifest", + "icon.svg", + "icon-192.png", + "icon-512.png", +]; + // The precache list comes from the build's own outputs, so hashed filenames can // never drift out of sync with the service worker. -const precache = result.outputs - .filter((output) => !output.path.endsWith(".map")) - .map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")); +const precache = [ + ...result.outputs + .filter((output) => !output.path.endsWith(".map")) + .map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")), + ...STATIC_ASSETS.map((file) => basePath + file), +]; const swResult = await Bun.build({ entrypoints: ["src/sw.ts"], @@ -78,6 +94,12 @@ if (!swResult.success) { await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath)); await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg")); +for (const size of [192, 512]) { + await Bun.write( + path.join(outdir, `icon-${size}.png`), + Bun.file(`src/icon-${size}.png`), + ); +} // This is an SPA: every route serves the same shell, so the *document* URL // can be several segments deep (e.g. /budget2.0/month/2026-09). Relative diff --git a/scripts/make-icons.ts b/scripts/make-icons.ts new file mode 100644 index 0000000..96bf6c6 --- /dev/null +++ b/scripts/make-icons.ts @@ -0,0 +1,75 @@ +/** + * Rasterizes src/icon.svg into the PNG sizes Chrome's install prompt wants. + * + * bun scripts/make-icons.ts + * + * The output is COMMITTED rather than produced by build.ts, because this needs + * a Chrome binary on PATH and the build must not. Re-run it whenever + * src/icon.svg changes; src/icons.test.ts checks the committed files are real + * images of the right dimensions, but it cannot tell you they are STALE. + * + * Chrome is the rasterizer because it is the only one present in this + * environment — no ImageMagick, no rsvg-convert, no cairosvg. It renders the + * SVG in a page sized exactly to the target, on a transparent backdrop, so the + * icon's own rounded corners stay transparent. + */ +import { rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const SIZES = [192, 512]; +const CHROME = process.env.CHROME ?? "google-chrome"; +/** Chrome has been seen not to exit on its own here, so never wait forever. */ +const TIMEOUT_MS = 60_000; + +const work = await mkdtemp(path.join(tmpdir(), "budget-icons-")); +try { + await Bun.write(path.join(work, "icon.svg"), Bun.file("src/icon.svg")); + await Bun.write( + path.join(work, "wrap.html"), + ` + +`, + ); + + for (const size of SIZES) { + const out = path.join(work, `icon-${size}.png`); + const chrome = Bun.spawn( + [ + CHROME, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--no-first-run", + "--hide-scrollbars", + "--force-device-scale-factor=1", + "--default-background-color=00000000", + `--screenshot=${out}`, + `--window-size=${size},${size}`, + `file://${path.join(work, "wrap.html")}`, + ], + { stdout: "ignore", stderr: "ignore" }, + ); + const timer = setTimeout(() => chrome.kill(), TIMEOUT_MS); + await chrome.exited; + clearTimeout(timer); + if (!(await Bun.file(out).exists())) { + throw new Error(`${CHROME} wrote no screenshot for ${size}px`); + } + + const bytes = new Uint8Array(await Bun.file(out).arrayBuffer()); + const view = new DataView(bytes.buffer); + const width = view.getUint32(16); + const height = view.getUint32(20); + if (width !== size || height !== size) { + throw new Error(`Expected ${size}x${size}, got ${width}x${height}`); + } + + const dest = `src/icon-${size}.png`; + await Bun.write(dest, bytes); + console.log(`${dest} ${width}x${height} ${bytes.length} bytes`); + } +} finally { + await rm(work, { recursive: true, force: true }); +} diff --git a/src/icon-192.png b/src/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..12d105a6e5461d1da8523b734ce0e7d37b02179f GIT binary patch literal 2360 zcmai03pmtS8~@LY)EL&CNK0%ep~2Lk#Ii2A%v1`44-G~#aw&41N$DcF3mL|)nyhO| zO+@29GS&#Qp>Y?=Z773g%&hkLzTM~9ufFd*&pGFL&ii}MdCz-(&+ojq&`vfIV#;Cw z07%%`TAkdQ8^81&qFduhcF=YJ*s)+|W#N38G(Q{~l!e>fxIFriEusCX@|tIry?3!x zQ3FGPV%-Ma)#{fZWhD{VF+otCJl5`@5j@eMX)T+nB#Q`r?W@1q9%;3fH3Kvc{dUNLj6aHb(8x@GP?d`e--3N@+VFhqO3>BSh2c2NlmI~d;T*928(^N9j7@tgng4epU$2vzx zkG>&b{L|9k<4z5ZNb+okd<%wWX_azg{v>{jF%?xlUq;g9w_SkxDm%IZ3xgv}S9t!W zM$o~tCffqiWDidX2bXY3qi43ms$LldL;#dxLmEqItsd^52slpQ63u8H0xqX#*`-Bz zJkv;*$aPr4LeKw;bAEjddeD(y&N<`Q#f9Ngg(vQfr9g7uX!iNd~^IK}oGI^SIW0FfIIr z9q~n%{%!8*z@|g{a!PxPu0w47qFr{L=dR;6zXz!|Fa40Cmzt}kCzx6P)p`-Pt2Q)- zf-4|E=BmWwtsIe>xZlF)&G4a)O-j9FTIV$RaXY{B7&;%2p30dD-&~Iki-^IomK0SA zhTxdC35Hl8M0QyM}?7LZnx`rY2M zFKP`eeG>F5Z;$}pF8$FQW-zmB&9p6ZQPKfp9Ycx4tNV;fsc8m2^&Z{`TkdIH$ke-1 z{*(!m(ztbP(0Gu$3vBBb?#{2N#t>cMsSiwJen+tbwFWsP0PgEB!IPzWDw>;Q^6F_U zOK@v?dUi23mb46V%}iT@N?`>JS3C`Z* z0+SRGbBGoel-u8+6OA`;lQPd6>&*7()hC>R7Nx85>}aBOC?K)9`a%Ih1juf#Cd>H) zvzNSjrGPAhVtX|kpl6>7uM0rysPJBK@ID};6)ah&!2$s$28sZz9YCox;H&|ZSOe%g zK#?-Y{NIL*1WMJH~O2~{KJ4@cq!hTSurxG-C^gZvE++sI{NFL42@q7`{k+Im8u#SuhGk>{JfqUS3WX}ni z+PyvZ5UM^LdQb0z;m5%X#WIL=nD-`KRk5}21FP(ZKy~>>lJOJ2~2w!I#kv`IDv>ApU^1 zd`g^-MTj$Shd)4N5!U1j!uNvYp!8TImp%eL6lM}C46z`OhBK>2 zcMr1D2sq-Ll)8XaCN8O9P5;cgI&Q7PP}3f#e-@OD3WWhZLjzer>&WwJGr^=ukc~^_ z=mm_rc~C9kJF;$h)@v%L@|dbx2qFqY+zBJir>%(+X0J;I9mjpVpsZ20?WU4&NSNN~ zJzhLX9S*T}l@c)(x%snN2-ATbVF}DI+AOX$KYW(P8N*N%847Bnp={+ z8XUoZVNWw2WRR_&ZQKKcFpUGQm)vzM6J@D&_#Y`_y#$8Vbk}blT+_Bxm{e$c?ZX>4 zlJp(lKkFqryDy9qjx1`rP| zR<`iI{F)degE9uWVbva+5F(znBe9K3lT<&J>U~CJJ5hfFwX)iZ| z-UTho*Ivc(Z{6G7h}imkQSNZ^ihf>v^RW&vNO{U;!&u0S*IHzK@bzFSiULs6hao{QDr!4hVsorRK8t063NpY zo^YT-X-RX_Lssy^(H?n99Ofb<-p+80Dvs2GTrq?wT7CALU60DM5JCVzD5TWl3%BYY e(JzG$1epEKN2I^L+F@%2u=~l$s`5wggg*eH@JiGG literal 0 HcmV?d00001 diff --git a/src/icon-512.png b/src/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..07fab0f895c0eac111d837af786d5ff8f4eb5c52 GIT binary patch literal 7047 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 bX9W1JA3x{idk2DG09-V-xKQ-F%k6&!)iS$V literal 0 HcmV?d00001 diff --git a/src/icons.test.ts b/src/icons.test.ts new file mode 100644 index 0000000..fc60930 --- /dev/null +++ b/src/icons.test.ts @@ -0,0 +1,73 @@ +import { test, expect, describe } from "bun:test"; +import { inflateSync } from "node:zlib"; +import { buildManifest } from "./manifest.ts"; + +/** + * Chrome's install prompt wants raster icons at 192 and 512; an SVG alone + * leaves it incomplete. These are generated from src/icon.svg by + * `bun scripts/make-icons.ts` — see that file for why they are committed + * rather than built. + */ +const SIZES = [192, 512] as const; + +/** Width and height straight out of the IHDR chunk, which is always first. */ +function pngHeader(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const signature = [...bytes.slice(0, 8)]; + return { + isPng: String.fromCharCode(...signature.slice(1, 4)) === "PNG", + width: view.getUint32(16), + height: view.getUint32(20), + bitDepth: bytes[24], + colorType: bytes[25], + }; +} + +/** Concatenated IDAT payloads, inflated. */ +function pixelBytes(bytes: Uint8Array): Buffer { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const chunks: Uint8Array[] = []; + let i = 8; + while (i < bytes.length) { + const length = view.getUint32(i); + const type = String.fromCharCode(...bytes.slice(i + 4, i + 8)); + if (type === "IDAT") chunks.push(bytes.slice(i + 8, i + 8 + length)); + i += 12 + length; + } + return inflateSync(Buffer.concat(chunks)); +} + +describe("the app icons", () => { + for (const size of SIZES) { + test(`icon-${size}.png is a real ${size}x${size} image, not a placeholder`, async () => { + const bytes = new Uint8Array(await Bun.file(`src/icon-${size}.png`).arrayBuffer()); + const header = pngHeader(bytes); + + expect(header.isPng).toBe(true); + expect(header.width).toBe(size); + expect(header.height).toBe(size); + expect(header.bitDepth).toBe(8); + expect(header.colorType).toBe(6); // RGBA + + // One filter byte plus 4 channels per pixel, per row. Proves the pixel + // data is really present and really that size, so a truncated or + // stub file cannot pass. + expect(pixelBytes(bytes).length).toBe(size * (1 + size * 4)); + }); + } + + test("the manifest offers both raster sizes as well as the SVG", () => { + const icons = JSON.parse(buildManifest("/budget/")).icons as Array<{ + src: string; + sizes: string; + type: string; + }>; + expect(icons.map((i) => i.sizes)).toEqual(["any", "192x192", "512x512"]); + expect(icons.map((i) => i.src)).toEqual([ + "/budget/icon.svg", + "/budget/icon-192.png", + "/budget/icon-512.png", + ]); + expect(icons.every((i) => i.src.startsWith("/budget/"))).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 0acd757..0f6d41e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,14 @@ const server = serve({ new Response(Bun.file(new URL("./icon.svg", import.meta.url)), { headers: { "content-type": "image/svg+xml" }, }), + [`${basePath}icon-192.png`]: () => + new Response(Bun.file(new URL("./icon-192.png", import.meta.url)), { + headers: { "content-type": "image/png" }, + }), + [`${basePath}icon-512.png`]: () => + new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), { + headers: { "content-type": "image/png" }, + }), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/manifest.ts b/src/manifest.ts index 9eb17d5..6937783 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -16,7 +16,13 @@ export function buildManifest(base: string): string { display: "standalone", background_color: "#0f172a", theme_color: "#0f172a", - icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }], + // The SVG first for anything that will scale it; the raster sizes + // because Chrome's install prompt wants 192 and 512 specifically. + icons: [ + { src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }, + { src: `${base}icon-192.png`, sizes: "192x192", type: "image/png" }, + { src: `${base}icon-512.png`, sizes: "512x512", type: "image/png" }, + ], }, null, 2, From b4e193376166b942d3b9dd47d38a3295bb2d4954 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:37:39 +0200 Subject: [PATCH 04/15] feat: bake FX rates in at build time so a fresh install can convert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO section 2, designed and approved, now built. A brand-new dataset arrives with DKK/USD/EUR rates instead of erroring on the first EUR purchase. build.ts fetches at build time and embeds the result via `define`. A network failure does NOT fail the build — it bakes committed constants instead, and an incomplete response is treated as a failure too, since one unconvertible currency is the problem this solves. Fetched rates carry source "api" and the BUILD date, which is why parseFxResponse now takes an explicit updatedAt; the constants carry source "manual" and their own date, so Settings never claims a hardcoded number came from a rate service. SKIP_FX_FETCH=1 and FX_API_URL are there for offline, CI and reproducible builds. They seed a NEW dataset and nothing else. They are NOT a fallback for a missing rate: clearing a rate removes its row so MissingRateError surfaces, rather than silently converting money at a number the user never chose. A test asserts a cleared rate still throws, and loading an existing dataset never touches its rates. Domain purity forced the shape. createSeedDataset takes the rates as an argument (and copies the rows, so shared constants cannot be edited out from under the caller); the env read lives in the new src/store/bakedRates.ts behind the try/catch accessor, because an unset BUN_PUBLIC_* leaves a bare process.env in the browser bundle; and createSnapshotStore takes seed rates as a parameter so it stays free of the ambient environment, with src/store/index.ts wiring it. Verified all three build paths: live fetch bakes api-sourced rates with today's date, an unreachable endpoint and SKIP_FX_FETCH both bake manual constants and still build, and the payload is present in the bundle in each case. Verified at runtime that a fresh install converts EUR 100 -> 747.50 DKK and USD 100 -> 644.95 DKK with the variable both set and unset. Co-Authored-By: Claude Opus 5 (1M context) --- build.ts | 62 ++++++++++++++++++++++ src/domain/seed.ts | 17 +++++-- src/store/bakedRates.test.ts | 76 +++++++++++++++++++++++++++ src/store/bakedRates.ts | 99 ++++++++++++++++++++++++++++++++++++ src/store/fxApi.test.ts | 8 +++ src/store/fxApi.ts | 8 ++- src/store/index.ts | 4 ++ src/store/snapshot.test.ts | 25 +++++++++ src/store/snapshot.ts | 11 +++- 9 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 src/store/bakedRates.test.ts create mode 100644 src/store/bakedRates.ts diff --git a/build.ts b/build.ts index 2654572..8acaacf 100644 --- a/build.ts +++ b/build.ts @@ -3,11 +3,70 @@ import { rm } from "node:fs/promises"; import path from "node:path"; import { normalizeBase } from "./src/ui/basePath.ts"; import { buildManifest } from "./src/manifest.ts"; +import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts"; +import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts"; +import type { Currency, FxRate } from "./src/domain/types.ts"; const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); const outdir = path.join(process.cwd(), "dist"); const version = process.env.BUILD_VERSION ?? String(Date.now()); +const FX_BASE: Currency = "DKK"; +const FX_TARGETS: Currency[] = ["USD", "EUR"]; +const FX_TIMEOUT_MS = 8_000; + +/** + * Exchange rates embedded at build time so a fresh install can convert a EUR + * purchase on day one instead of erroring. They seed a new dataset only — see + * src/store/bakedRates.ts for why they must never backstop a missing rate. + * + * A network failure must NOT fail the build: it falls back to the committed + * constants, which are labelled "manual" so Settings never claims a hardcoded + * number came from a rate service. Fetched rates carry the build date rather + * than the date the browser happens to run, which is why parseFxResponse + * takes an explicit `updatedAt`. + * + * SKIP_FX_FETCH=1 forces the constants, for offline or reproducible builds. + * FX_API_URL overrides the endpoint. + */ +async function bakeFxRates(): Promise { + const buildDate = new Date().toISOString().slice(0, 10); + const describe = (rates: readonly FxRate[]) => + rates.map((r) => `${r.currency} ${r.baseUnitsPerOne}`).join(", "); + + if (process.env.SKIP_FX_FETCH === "1") { + console.log(`Baking the committed FX constants (SKIP_FX_FETCH=1): ${describe(FALLBACK_FX_RATES)}`); + return FALLBACK_FX_RATES; + } + + const template = process.env.FX_API_URL ?? DEFAULT_FX_API_URL; + try { + const response = await fetch(buildFxUrl(template, FX_BASE, FX_TARGETS), { + signal: AbortSignal.timeout(FX_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`rate service returned ${response.status}`); + + const rates = parseFxResponse(await response.json(), FX_BASE, buildDate); + // A partial response would leave one currency unconvertible on a fresh + // install, which is the whole problem this solves. Prefer the complete + // constants over an incomplete live answer. + const covered = new Set(rates.map((r) => r.currency)); + const missing = FX_TARGETS.filter((c) => !covered.has(c)); + if (missing.length > 0) throw new Error(`response omitted ${missing.join(", ")}`); + + console.log(`Baked live FX rates for ${buildDate}: ${describe(rates)}`); + return rates; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + console.warn( + `Could not fetch FX rates (${message}); baking the committed constants instead: ${describe(FALLBACK_FX_RATES)}`, + ); + return FALLBACK_FX_RATES; + } +} + +const bakedFxRates = await bakeFxRates(); + await rm(outdir, { recursive: true, force: true }); const result = await Bun.build({ @@ -44,6 +103,9 @@ const result = await Bun.build({ define: { "process.env.NODE_ENV": JSON.stringify("production"), "process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath), + // Double-encoded on purpose: the inlined literal must be a JSON *string* + // for parseBakedRates to parse at runtime. + "process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)), }, }); diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 5f32daa..4eadc1d 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,4 +1,4 @@ -import type { Dataset, MonthId, Post } from "./types.ts"; +import type { Dataset, FxRate, MonthId, Post } from "./types.ts"; export const SCHEMA_VERSION = 2; @@ -9,10 +9,15 @@ export function newId(): string { } /** - * The first-run dataset. Takes the start month as an argument because the - * domain layer must not read the ambient clock. + * The first-run dataset. Takes the start month AND the seed exchange rates as + * arguments because the domain layer must read neither the ambient clock nor + * the ambient environment. The store passes `BAKED_FX_RATES`; callers that + * want a bare dataset pass nothing. */ -export function createSeedDataset(startMonth: MonthId): Dataset { +export function createSeedDataset( + startMonth: MonthId, + fxRates: readonly FxRate[] = [], +): Dataset { const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({ id: newId(), name, @@ -30,7 +35,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset { foldStartMonth: startMonth, schemaVersion: SCHEMA_VERSION, }, - fxRates: [], + // Copied, so a caller passing shared constants cannot have them edited + // out from under it the first time the user changes a rate. + fxRates: fxRates.map((rate) => ({ ...rate })), posts, months: [ { id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }, diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts new file mode 100644 index 0000000..c00d295 --- /dev/null +++ b/src/store/bakedRates.test.ts @@ -0,0 +1,76 @@ +import { test, expect, describe } from "bun:test"; +import { FALLBACK_FX_RATES, parseBakedRates } from "./bakedRates.ts"; +import { createSeedDataset } from "../domain/seed.ts"; +import type { FxRate } from "../domain/types.ts"; +import { MissingRateError, toBase } from "../domain/fx.ts"; + +describe("the committed fallback rates", () => { + test("cover every non-base currency and are honestly labelled manual", () => { + expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "USD"]); + // "api" would claim a hardcoded number came from a rate service. Settings + // shows this field to the user. + expect(FALLBACK_FX_RATES.every((r) => r.source === "manual")).toBe(true); + expect(FALLBACK_FX_RATES.every((r) => r.baseUnitsPerOne > 0)).toBe(true); + }); +}); + +describe("reading what the build baked in", () => { + const baked: FxRate[] = [ + { currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" }, + { currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" }, + ]; + + test("an unset variable falls back to the committed constants", () => { + expect(parseBakedRates(undefined)).toEqual(FALLBACK_FX_RATES); + }); + + test("a well-formed payload is used as-is", () => { + expect(parseBakedRates(JSON.stringify(baked))).toEqual(baked); + }); + + test("six decimal places survive — a rate is not money", () => { + const [usd] = parseBakedRates(JSON.stringify(baked)); + expect(usd!.baseUnitsPerOne).toBe(6.812345); + }); + + test.each([ + ["not JSON at all", "{oh dear"], + ["a non-array", JSON.stringify({ USD: 6.8 })], + ["an unsupported currency", JSON.stringify([{ currency: "GBP", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])], + ["a non-positive rate", JSON.stringify([{ currency: "USD", baseUnitsPerOne: 0, updatedAt: "2026-09-02", source: "api" }])], + ["a missing rate value", JSON.stringify([{ currency: "USD", updatedAt: "2026-09-02", source: "api" }])], + ["an empty array", JSON.stringify([])], + ])("%s falls back rather than booting with junk", (_label, payload) => { + expect(parseBakedRates(payload)).toEqual(FALLBACK_FX_RATES); + }); +}); + +describe("what baked rates are allowed to do", () => { + test("they seed a new dataset", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + expect(data.fxRates).toEqual([...FALLBACK_FX_RATES]); + }); + + test("seeding without them still yields no rates at all", () => { + expect(createSeedDataset("2026-09").fxRates).toEqual([]); + }); + + test("the seeded rows are copies, so editing one cannot rewrite the constants", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + data.fxRates[0]!.baseUnitsPerOne = 999; + expect(FALLBACK_FX_RATES[0]!.baseUnitsPerOne).not.toBe(999); + }); + + /** + * The decision this guards: clearing a rate REMOVES the row so + * MissingRateError surfaces, instead of silently converting money at a + * number the user never chose. Baked rates seed; they never backstop. + */ + test("they do NOT backstop a rate the user has cleared", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR"); + expect(() => + toBase({ amount: 100, currency: "EUR" }, data.settings.baseCurrency, data.fxRates), + ).toThrow(MissingRateError); + }); +}); diff --git a/src/store/bakedRates.ts b/src/store/bakedRates.ts new file mode 100644 index 0000000..c114e25 --- /dev/null +++ b/src/store/bakedRates.ts @@ -0,0 +1,99 @@ +import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; + +/** + * Exchange rates baked in at build time so a fresh install can convert a EUR + * purchase on day one instead of erroring. + * + * They seed a NEW dataset and nothing else. They deliberately do NOT act as a + * fallback whenever a rate is missing: clearing a rate removes its row so + * `MissingRateError` surfaces, rather than silently converting money at a + * number the user never chose. `src/store/bakedRates.test.ts` pins that. + * + * This lives in the store layer, not the domain, because reading the ambient + * environment is exactly what `src/domain/` may not do. `createSeedDataset` + * takes the rates as an argument instead. + */ + +/** + * The last-resort rates, committed so a build with no network still produces a + * working app. DKK per one unit, six decimals, never rounded to money. + * + * Labelled "manual" rather than "api" on purpose: Settings shows this field, + * and a hardcoded constant must not claim it came from a rate service. The + * values are real (frankfurter.app, DKK base, 2026-09-01); EUR barely moves + * because DKK is pegged to it, so it ages well. USD does move — a stale USD + * number here is the reason the user can always edit or re-fetch a rate. + */ +export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([ + Object.freeze({ + currency: "USD" as Currency, + baseUnitsPerOne: 6.449532, + updatedAt: "2026-09-01", + source: "manual" as const, + }), + Object.freeze({ + currency: "EUR" as Currency, + baseUnitsPerOne: 7.474959, + updatedAt: "2026-09-01", + source: "manual" as const, + }), +]); + +/** The currencies a baked payload must cover: everything but the base. */ +const REQUIRED = CURRENCIES.filter((c) => c !== "DKK"); + +function isUsableRate(value: unknown): value is FxRate { + if (typeof value !== "object" || value === null) return false; + const rate = value as Record; + return ( + CURRENCIES.includes(rate.currency as Currency) && + typeof rate.baseUnitsPerOne === "number" && + Number.isFinite(rate.baseUnitsPerOne) && + rate.baseUnitsPerOne > 0 && + typeof rate.updatedAt === "string" && + (rate.source === "api" || rate.source === "manual") + ); +} + +/** + * Parses what `build.ts` embedded, falling back to the committed constants on + * anything unusable — an unset variable (a plain `bun --hot`), a build whose + * fetch failed, or a malformed payload. Falling back beats throwing: this runs + * during first-run seeding, and an app that will not boot is worse than an app + * with a slightly stale USD rate the user can edit. + */ +export function parseBakedRates(raw: string | undefined): readonly FxRate[] { + if (!raw) return FALLBACK_FX_RATES; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return FALLBACK_FX_RATES; + } + + if (!Array.isArray(parsed) || !parsed.every(isUsableRate)) return FALLBACK_FX_RATES; + + // A partial payload would leave one currency unconvertible on a fresh + // install, which is the whole problem this feature exists to solve. + const covered = new Set(parsed.map((rate) => rate.currency)); + if (!REQUIRED.every((currency) => covered.has(currency))) return FALLBACK_FX_RATES; + + return parsed; +} + +/** + * try/catch, not a `typeof process` guard. Bun inlines the literal only when + * the variable is set; unset, the bare reference reaches the browser and + * throws before the app boots. A `typeof` test survives inlining and discards + * the inlined value. See `readBasePathEnv` in src/ui/basePath.ts. + */ +export function readBakedRatesEnv(): string | undefined { + try { + return process.env.BUN_PUBLIC_BAKED_FX_RATES; + } catch { + return undefined; + } +} + +export const BAKED_FX_RATES = parseBakedRates(readBakedRatesEnv()); diff --git a/src/store/fxApi.test.ts b/src/store/fxApi.test.ts index 5cf17a4..0b01ce9 100644 --- a/src/store/fxApi.test.ts +++ b/src/store/fxApi.test.ts @@ -38,3 +38,11 @@ describe("parseFxResponse", () => { expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/); }); }); + +test("an explicit updatedAt is used instead of the clock", () => { + // The build bakes rates in with the BUILD date, not the date the browser + // happens to run parseFxResponse. + const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", "2026-09-01"); + expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]); + expect(rates.every((r) => r.source === "api")).toBe(true); +}); diff --git a/src/store/fxApi.ts b/src/store/fxApi.ts index 3eda3e3..0b6abc9 100644 --- a/src/store/fxApi.ts +++ b/src/store/fxApi.ts @@ -13,7 +13,12 @@ export function buildFxUrl( .replace("{targets}", targets.join(",")); } -export function parseFxResponse(body: unknown, base: Currency): FxRate[] { +export function parseFxResponse( + body: unknown, + base: Currency, + /** Defaults to now. The build passes its own date when baking rates in. */ + updatedAt: string = new Date().toISOString(), +): FxRate[] { const rates = typeof body === "object" && body !== null ? (body as Record).rates @@ -22,7 +27,6 @@ export function parseFxResponse(body: unknown, base: Currency): FxRate[] { throw new Error("Exchange rate response has no `rates` object"); } - const updatedAt = new Date().toISOString(); const out: FxRate[] = []; for (const [currency, value] of Object.entries(rates as Record)) { diff --git a/src/store/index.ts b/src/store/index.ts index 452b2ae..dd98131 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ import { monthOf } from "../domain/months.ts"; import { readDataset, writeDataset } from "./db.ts"; import { createSnapshotStore } from "./snapshot.ts"; +import { BAKED_FX_RATES } from "./bakedRates.ts"; /** The current month, read at module load. The UI owns the clock; the domain does not. */ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); @@ -8,6 +9,9 @@ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); export const store = createSnapshotStore( { read: readDataset, write: writeDataset }, currentMonth, + // First run only, so a fresh install can convert a EUR purchase on day one. + // Never a backstop for a rate the user has cleared. + BAKED_FX_RATES, ); export * from "./snapshot.ts"; diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index e91d324..c9869dc 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -180,3 +180,28 @@ test("get before load throws rather than returning empty data", () => { const store = createSnapshotStore(fake.persistence, "2026-09"); expect(() => store.get()).toThrow(/not loaded/i); }); + +describe("seed rates", () => { + test("a first run seeds the rates it was given", async () => { + const fake = fakePersistence(null); + const rates = [ + { currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const }, + ]; + const store = createSnapshotStore(fake.persistence, "2026-09", rates); + await store.load(); + expect(store.get().fxRates).toEqual(rates); + }); + + test("an existing dataset is never given seed rates", async () => { + // Loading stored data must not touch fxRates — the user may have + // deliberately cleared one, and re-adding it would convert money at a + // number they never chose. + const stored = createSeedDataset("2026-01"); + const fake = fakePersistence(stored); + const store = createSnapshotStore(fake.persistence, "2026-09", [ + { currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" }, + ]); + await store.load(); + expect(store.get().fxRates).toEqual([]); + }); +}); diff --git a/src/store/snapshot.ts b/src/store/snapshot.ts index 43fd5e1..94f6e84 100644 --- a/src/store/snapshot.ts +++ b/src/store/snapshot.ts @@ -1,5 +1,5 @@ import { createSeedDataset } from "../domain/seed.ts"; -import type { Dataset, MonthId } from "../domain/types.ts"; +import type { Dataset, FxRate, MonthId } from "../domain/types.ts"; export interface Persistence { read(): Promise; @@ -19,6 +19,13 @@ export interface SnapshotStore { export function createSnapshotStore( persistence: Persistence, currentMonth: MonthId, + /** + * Rates for a FIRST RUN only. Passed in rather than read here so this module + * stays free of the ambient environment; src/store/index.ts supplies + * BAKED_FX_RATES. Loading an existing dataset never touches its rates — the + * user may have cleared one deliberately. + */ + seedRates: readonly FxRate[] = [], ): SnapshotStore { let snapshot: Dataset | null = null; const listeners = new Set(); @@ -64,7 +71,7 @@ export function createSnapshotStore( notify(); return; } - await commit(createSeedDataset(currentMonth)); + await commit(createSeedDataset(currentMonth, seedRates)); }, get, From 53707b14cac4f085988daade2092e7712872cd9a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:46:59 +0200 Subject: [PATCH 05/15] feat(tooling): drive headless Chrome, and see the app for the first time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 7 +- ARCHITECTURE.md | 22 +++- CLAUDE.md | 28 ++++- TODO.md | 29 +++-- scripts/screenshot.ts | 201 ++++++++++++++++++++++++++++++++ src/index.ts | 10 ++ src/ui/components/PostTable.tsx | 16 +-- 7 files changed, 279 insertions(+), 34 deletions(-) create mode 100644 scripts/screenshot.ts diff --git a/AGENTS.md b/AGENTS.md index b5183a2..d935c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,8 @@ Then, depending on what you are doing: Two rules worth knowing before your first edit: 1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite. -2. **There is no browser here.** Anything visual, offline, or focus-related - cannot be verified in this environment. Say so rather than claiming it works. +2. **There IS a browser here** — `bun scripts/screenshot.ts ` + drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's + "Verification limits" first: the container's fonts lie, and offline, the + install prompt, the `.ods` in a spreadsheet, and how anything *feels* still + need a human. Say so rather than claiming it works. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e5b2d55..6a1a11e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,12 +136,22 @@ Adding a "missing" guard here is a regression, not a fix: Most logic is pure and tested directly with `bun test`. The domain carries the bulk of the coverage; the UI is thin by design. -**There is no browser in the agent environment.** Nothing has ever verified -visual appearance, real offline behaviour, the install or update prompts, -keyboard focus traversal, or the `.ods` opening in a spreadsheet. Where a check -could not be run, the honest substitute was used and recorded — for example the -`.ods` is validated structurally by unzipping it and asserting `mimetype` is -first and stored, every XML part parses, and numeric cells carry `office:value`. +**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome +driven over CDP, because Chrome's own `--screenshot` fires on the load event +and captures an empty page before this app's async IndexedDB read resolves. +That script is how the month view, the carry meter and the rule editors were +first seen by anyone. It reports console errors too, which is the point: a page +that renders blank because of a thrown error looks identical to an empty one. + +Its limits are real. The container's fontconfig maps every generic family to a +monospace face, so screenshots need a `FONTCONFIG_FILE` override to be +representative; Chrome starts with an empty profile each run; and no screenshot +can judge how a design feels. Real offline behaviour, the install and update +prompts, and the `.ods` opening in a spreadsheet remain unverifiable here. +Where a check could not be run, the honest substitute was used and recorded — +the `.ods` is validated structurally by unzipping it and asserting `mimetype` +is first and stored, every XML part parses, and numeric cells carry +`office:value`. `src/ui/eventCapture.test.ts` is a *source-level* guard rather than a behavioural one, because the bug it prevents depends on real browser render diff --git a/CLAUDE.md b/CLAUDE.md index 1981b78..ff40d3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,9 +167,25 @@ Adding a "missing" guard here is a regression: ## Verification limits -There is no browser in this environment. Visual appearance, offline behaviour, -install and update prompts, keyboard focus traversal, and opening the generated -`.ods` in a spreadsheet **cannot be verified here**. Do not claim you checked -them. Substitute a real check where one exists — structural validation of the -`.ods`, curl against the dev server, pure-function tests — and state plainly -what still needs a human. +**There IS a browser here.** `google-chrome` is on PATH, and +`bun scripts/screenshot.ts ` drives it over CDP: it waits past +the async IndexedDB read (which plain `--screenshot` does not), can seed data +with `--eval-file`, click with `--click`, and reports console errors and +uncaught exceptions. Read the PNG it writes — you can see the app. Use it +before claiming anything about layout. + +Two things to know before trusting a screenshot: + +- **This container's fontconfig resolves every generic family to Fira Code, a + monospace font**, so the whole UI renders monospaced in a way no real user + sees. Point `FONTCONFIG_FILE` at an override that maps the generics to + installed sans/mono faces, or you will "fix" a font bug that does not exist. +- Chrome launches with a fresh profile each run, so IndexedDB starts empty and + the app re-seeds. Pass `--eval-file= --reload` for populated data. + +Still **not** verifiable here, so do not claim it: real offline behaviour, the +install and update prompts, opening the generated `.ods` in a spreadsheet, and +anything about how the design *feels*. A screenshot proves a layout exists and +is not broken; it does not prove it is good. Substitute a real check where one +exists — structural validation of the `.ods`, curl against the dev server, +pure-function tests — and state plainly what still needs a human. diff --git a/TODO.md b/TODO.md index b017dfa..9ab2ee4 100644 --- a/TODO.md +++ b/TODO.md @@ -82,20 +82,25 @@ evidence and anti-reference, not as something to preserve. - **The dev-only `src/manifest.webmanifest` placeholder hardcodes `scope: "/"`.** Harmless in production, misleading if you dev-serve under a subpath. -## 5. Needs a human with a browser +## 5. Still needs a human -Nothing in the agent environment can run a browser, so these have never been -seen by anyone: +`scripts/screenshot.ts` drives headless Chrome, so the list here is much +shorter than it was. **Seen and confirmed working:** the carry meter (it +renders, and shows red where a post is overspent), the rule-history editor in +Settings, the "change from here" control, the baked FX rates arriving in a +fresh dataset, and every route at desktop and mobile widths. -- the **carry meter** under each post row in the month view — the signature - design element, verified only as a CSS gradient expression; -- the **rule-history editor** in Settings and the **"change from here"** control - in the month view. Their arithmetic is verified through the real fold, and - both badge predicates were driven as pure functions, but nobody has seen the - expand/collapse, the layout, the disabled states, or the focus order; -- **offline behaviour**, the **install prompt**, and the **update prompt**; +Genuinely outstanding: + +- **How it looks and feels.** A screenshot proves a layout is not broken. It + cannot tell you the design is good, and the container's fonts are not the + owner's fonts. +- **Offline behaviour**, the **install prompt**, and the **update prompt**. The + dev server now serves a deliberately inert `sw.js`, so dev exercises + registration but nothing else. - the generated **`.ods` opening in a real spreadsheet** (validated - structurally: mimetype first and stored, well-formed XML, numeric cells); + structurally: mimetype first and stored, well-formed XML, numeric cells). - **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and - focus placement after a row auto-appends; + focus placement after a row auto-appends. Drivable over CDP in principle; + not yet done. - **GitHub Actions** has never been executed. diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts new file mode 100644 index 0000000..5fe5b36 --- /dev/null +++ b/scripts/screenshot.ts @@ -0,0 +1,201 @@ +/** + * Screenshots a running page with headless Chrome, and reports whatever the + * page logged to the console while doing it. + * + * bun scripts/screenshot.ts [--w=1280] [--h=900] + * [--wait=1500] [--full] [--click=] + * [--eval=|--eval-file=] [--dark] [--reload] + * + * Why this exists: ARCHITECTURE.md and TODO.md both say nothing here can run a + * browser, so appearance has never been verified. Chrome IS available, but its + * plain `--screenshot` flag fires on the load event — before this app's async + * IndexedDB read resolves, which captures an empty page. Driving CDP directly + * lets us wait, act, and read console errors. + * + * This does NOT replace a human: it cannot judge feel, and it renders with one + * font stack on one platform. It does prove a layout exists and is not broken. + */ +const [url, out, ...flags] = process.argv.slice(2); +if (!url || !out) { + console.error("usage: bun scripts/screenshot.ts [--w=] [--h=] [--wait=] [--full] [--click=] [--eval=] [--dark]"); + process.exit(2); +} + +const flag = (name: string, fallback?: string) => + flags.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback; +const has = (name: string) => flags.includes(`--${name}`); + +const width = Number(flag("w", "1280")); +const height = Number(flag("h", "900")); +const waitMs = Number(flag("wait", "1500")); + +const chrome = Bun.spawn( + [ + process.env.CHROME ?? "google-chrome", + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + "--hide-scrollbars", + "--force-device-scale-factor=1", + "--remote-debugging-port=0", + `--window-size=${width},${height}`, + ...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []), + "about:blank", + ], + { stdout: "ignore", stderr: "pipe" }, +); + +/** Chrome announces its debugging endpoint on stderr, once, at startup. */ +async function debuggerUrl(): Promise { + const reader = chrome.stderr.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const match = /ws:\/\/[^\s]+/.exec(buffer); + if (match) return match[0]; + } + throw new Error("Chrome never printed a DevTools endpoint"); +} + +const browserWs = await debuggerUrl(); +const host = new URL(browserWs).host; + +// The freshly launched about:blank tab is the one to drive. +const targets = (await (await fetch(`http://${host}/json/list`)).json()) as Array<{ + type: string; + webSocketDebuggerUrl: string; +}>; +const page = targets.find((t) => t.type === "page"); +if (!page) throw new Error("No page target to attach to"); + +const socket = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + socket.onopen = resolve; + socket.onerror = reject; +}); + +let nextId = 0; +const pending = new Map void>(); +const events: Array<{ method: string; params: any }> = []; + +socket.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (message.id !== undefined) { + const settle = pending.get(message.id); + pending.delete(message.id); + settle?.(message); + } else { + events.push({ method: message.method, params: message.params }); + } +}; + +/** Rejects on a CDP error rather than resolving undefined, which is otherwise + * indistinguishable from a command that legitimately returns nothing. */ +function send(method: string, params: Record = {}): Promise { + const id = ++nextId; + // Register BEFORE sending: a reply that arrives first would otherwise find + // no handler and the promise would never settle. + const reply = new Promise((resolve, reject) => { + pending.set(id, (message: any) => { + if (message.error) reject(new Error(`${method}: ${message.error.message}`)); + else resolve(message.result); + }); + }); + socket.send(JSON.stringify({ id, method, params })); + return reply; +} + +await send("Page.enable"); +await send("Runtime.enable"); +await send("Log.enable"); +await send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: width < 700, +}); +if (has("dark")) { + await send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + // shadcn's dark palette is class-based (`.dark`), not media-query based, so + // emulating the media feature alone changes nothing. Add the class too. + await send("Page.addScriptToEvaluateOnNewDocument", { + source: `document.addEventListener("DOMContentLoaded", () => + document.documentElement.classList.add("dark"));`, + }); +} + +await send("Page.navigate", { url }); +// Wait for the load event, then for the app's own async work (the IndexedDB +// read, which plain --screenshot never waits for). +const loadDeadline = Date.now() + 20_000; +while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < loadDeadline) { + await Bun.sleep(50); +} +await Bun.sleep(waitMs); + +const evalFile = flag("eval-file"); +const evalJs = evalFile ? await Bun.file(evalFile).text() : flag("eval"); +if (evalJs) { + const result = await send("Runtime.evaluate", { expression: evalJs, awaitPromise: true }); + console.log("eval:", JSON.stringify(result?.result?.value ?? result?.result?.description ?? null)); + await Bun.sleep(400); +} + +// --reload exists for --eval scripts that seed IndexedDB: the app reads its +// dataset once at boot, so it has to boot again to see what was written. +if (has("reload")) { + events.length = 0; + await send("Page.navigate", { url }); + const again = Date.now() + 20_000; + while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < again) { + await Bun.sleep(50); + } + await Bun.sleep(waitMs); +} + +const clickSelector = flag("click"); +if (clickSelector) { + const clicked = await send("Runtime.evaluate", { + expression: `(() => { const el = document.querySelector(${JSON.stringify(clickSelector)}); + if (!el) return "not found"; el.click(); return "clicked"; })()`, + }); + console.log(`click ${clickSelector}: ${clicked?.result?.value}`); + await Bun.sleep(700); +} + +const shot = await send("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: has("full"), +}); +await Bun.write(out, Buffer.from(shot.data, "base64")); + +// Console output is the point of doing this over a plain --screenshot: a page +// that renders blank because of a thrown error looks identical to an empty one. +const logs = events + .filter((e) => e.method === "Runtime.consoleAPICalled" || e.method === "Log.entryAdded") + .map((e) => + e.method === "Log.entryAdded" + ? `[${e.params.entry.level}] ${e.params.entry.text}` + : `[${e.params.type}] ${(e.params.args ?? []) + .map((a: any) => a.value ?? a.description ?? a.type) + .join(" ")}`, + ); +const exceptions = events + .filter((e) => e.method === "Runtime.exceptionThrown") + .map((e) => `[uncaught] ${e.params.exceptionDetails?.exception?.description ?? e.params.exceptionDetails?.text}`); + +console.log(`wrote ${out} (${width}x${height})`); +for (const line of [...exceptions, ...logs]) console.log(" " + line); +if (exceptions.length === 0 && logs.length === 0) console.log(" (console clean)"); + +socket.close(); +chrome.kill(); +await chrome.exited; diff --git a/src/index.ts b/src/index.ts index 0f6d41e..f4f12c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,16 @@ const server = serve({ new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), { headers: { "content-type": "image/png" }, }), + // registerServiceWorker() asks for sw.js on every boot. Without this the + // catch-all answers with the HTML shell and the browser logs + // "The script has an unsupported MIME type ('text/html')" — caught and + // harmless, but noise that hides real errors. A worker that claims no + // clients and handles no fetches keeps registration honest in dev without + // caching anything, which would fight HMR. + [`${basePath}sw.js`]: () => + new Response("/* dev: intentionally does nothing */\n", { + headers: { "content-type": "text/javascript", "cache-control": "no-cache" }, + }), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index f541b08..5f979a1 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -67,10 +67,10 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) Post - Carried in - Allocated - Spent - Remaining + Carried in + Allocated + Spent + Remaining @@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) )} 0 ? "text-surplus" : figures.carriedIn < 0 @@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) > {formatSignedMoney(figures.carriedIn, baseCurrency)} - + {formatMoney(figures.allocation, baseCurrency)} - + {formatMoney(figures.charges, baseCurrency)} From 11e9405203b4e71557b729e20da58626f4ceb37d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:48:57 +0200 Subject: [PATCH 06/15] fix(ui): make the year matrix readable 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) --- src/ui/components/YearMatrix.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx index 4b69cb2..f437958 100644 --- a/src/ui/components/YearMatrix.tsx +++ b/src/ui/components/YearMatrix.tsx @@ -16,27 +16,29 @@ interface Props { export function YearMatrix({ view, mode }: Props) { return (
- +
{view.months.map((month) => ( - ))} - + {view.incomeByMonth.map((income, i) => ( - ))} - + {view.rows.map((row) => ( @@ -51,7 +53,7 @@ export function YearMatrix({ view, mode }: Props) { return (
Post + {month.slice(5)} TotalTotal
Income + {formatAmount(income)} {formatAmount(view.totalIncome)} + {formatAmount(view.totalIncome)} +
@@ -60,7 +62,7 @@ export function YearMatrix({ view, mode }: Props) { ); })} From bb3c661ef65d4d3686edef7081fa5723e7095977 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 15:11:43 +0200 Subject: [PATCH 07/15] =?UTF-8?q?feat(ui):=20design=20foundation=20?= =?UTF-8?q?=E2=80=94=20two=20themes,=20shipped=20type,=20one=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.ts | 16 +- scripts/fetch-fonts.ts | 63 +++++++ src/cssPairs.test.ts | 80 ++++++++ src/fontCss.test.ts | 48 +++++ src/fontCss.ts | 78 ++++++++ src/fonts/OFL-Inter.txt | 92 ++++++++++ src/fonts/OFL-JetBrainsMono.txt | 93 ++++++++++ src/fonts/inter-latin-ext.woff2 | Bin 0 -> 85068 bytes src/fonts/inter-latin.woff2 | Bin 0 -> 48256 bytes src/fonts/jetbrains-mono-latin-ext.woff2 | Bin 0 -> 11624 bytes src/fonts/jetbrains-mono-latin.woff2 | Bin 0 -> 31432 bytes src/frontend.tsx | 8 + src/index.css | 203 ++++++++++++++++++--- src/index.html | 21 +++ src/index.ts | 12 ++ src/ui/App.tsx | 61 +++++-- src/ui/components/PostTable.tsx | 34 ++-- src/ui/components/Section.tsx | 69 +++++++ src/ui/components/ThemeToggle.tsx | 97 ++++++++++ src/ui/components/YearMatrix.tsx | 119 ++++++------ src/ui/cssTokens.test.ts | 38 ++++ src/ui/routes/MonthRoute.tsx | 177 ++++++++++-------- src/ui/routes/SettingsRoute.tsx | 10 +- src/ui/routes/SummaryRoute.tsx | 84 ++++----- src/ui/routes/YearRoute.tsx | 61 ++++--- src/ui/routes/settings/CurrencySection.tsx | 10 +- src/ui/routes/settings/DataSection.tsx | 13 +- src/ui/routes/settings/PostsSection.tsx | 28 ++- src/ui/theme.test.ts | 90 +++++++++ src/ui/theme.ts | 70 +++++++ 30 files changed, 1387 insertions(+), 288 deletions(-) create mode 100644 scripts/fetch-fonts.ts create mode 100644 src/cssPairs.test.ts create mode 100644 src/fontCss.test.ts create mode 100644 src/fontCss.ts create mode 100644 src/fonts/OFL-Inter.txt create mode 100644 src/fonts/OFL-JetBrainsMono.txt create mode 100644 src/fonts/inter-latin-ext.woff2 create mode 100644 src/fonts/inter-latin.woff2 create mode 100644 src/fonts/jetbrains-mono-latin-ext.woff2 create mode 100644 src/fonts/jetbrains-mono-latin.woff2 create mode 100644 src/ui/components/Section.tsx create mode 100644 src/ui/components/ThemeToggle.tsx create mode 100644 src/ui/cssTokens.test.ts create mode 100644 src/ui/theme.test.ts create mode 100644 src/ui/theme.ts diff --git a/build.ts b/build.ts index 8acaacf..ef10b75 100644 --- a/build.ts +++ b/build.ts @@ -3,6 +3,7 @@ import { rm } from "node:fs/promises"; import path from "node:path"; import { normalizeBase } from "./src/ui/basePath.ts"; import { buildManifest } from "./src/manifest.ts"; +import { FONT_FILES } from "./src/fontCss.ts"; import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts"; import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts"; import type { Currency, FxRate } from "./src/domain/types.ts"; @@ -125,6 +126,7 @@ const STATIC_ASSETS = [ "icon.svg", "icon-192.png", "icon-512.png", + ...FONT_FILES.map((file) => `fonts/${file}`), ]; // The precache list comes from the build's own outputs, so hashed filenames can @@ -162,6 +164,9 @@ for (const size of [192, 512]) { Bun.file(`src/icon-${size}.png`), ); } +for (const file of FONT_FILES) { + await Bun.write(path.join(outdir, "fonts", file), Bun.file(`src/fonts/${file}`)); +} // This is an SPA: every route serves the same shell, so the *document* URL // can be several segments deep (e.g. /budget2.0/month/2026-09). Relative @@ -173,9 +178,14 @@ for (const size of [192, 512]) { // rather than hardcoded in src/index.html so the dev server (which always // serves from "/") keeps working unchanged. let indexHtml = await Bun.file(path.join(outdir, "index.html")).text(); -indexHtml = indexHtml - .replace('href="manifest.webmanifest"', `href="${basePath}manifest.webmanifest"`) - .replace('href="icon.svg"', `href="${basePath}icon.svg"`); +// src/index.html references these relatively, because the dev server's HTML +// bundler resolves every from disk and cannot be told to skip an +// absolute one. Relative hrefs would 404 on any route deeper than the base +// (/budget2.0/month/2026-09 resolves them against /budget2.0/month/), so +// rewrite them absolute here. At "/" each replacement is a no-op. +for (const asset of ["manifest.webmanifest", "icon.svg"]) { + indexHtml = indexHtml.replace(`href="${asset}"`, `href="${basePath}${asset}"`); +} await Bun.write(path.join(outdir, "index.html"), indexHtml); // GitHub Pages has no rewrite rules, so a deep link on a cold load lands on diff --git a/scripts/fetch-fonts.ts b/scripts/fetch-fonts.ts new file mode 100644 index 0000000..772e2a8 --- /dev/null +++ b/scripts/fetch-fonts.ts @@ -0,0 +1,63 @@ +/** + * Downloads the self-hosted webfonts into src/fonts/. + * + * bun scripts/fetch-fonts.ts + * + * Self-hosted, not linked from a CDN, because PRODUCT.md's position is that the + * app works offline and needs no third party: a font request to another origin + * on every cold load contradicts that, and would leak the fact that the app was + * opened. The files are COMMITTED so neither the build nor a fresh checkout + * needs network. + * + * Only the `latin` and `latin-ext` subsets are taken. Danish needs æ, ø and å + * (U+00E6, U+00F8, U+00E5), all inside `latin`; `latin-ext` covers the rest of + * European diacritics for post names. Cyrillic, Greek and Vietnamese are + * dropped, which is most of the weight. + * + * Both faces are SIL Open Font License 1.1, which permits self-hosting as long + * as the licence travels with them — hence src/fonts/OFL.txt. + */ +import { mkdir } from "node:fs/promises"; + +const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; + +const FAMILIES = [ + { css: "Inter:wght@100..900", slug: "inter" }, + { css: "JetBrains+Mono:wght@400..700", slug: "jetbrains-mono" }, +]; +const WANTED = new Set(["latin", "latin-ext"]); + +await mkdir("src/fonts", { recursive: true }); + +for (const family of FAMILIES) { + const url = `https://fonts.googleapis.com/css2?family=${family.css}&display=swap`; + const css = await (await fetch(url, { headers: { "user-agent": UA } })).text(); + + // Google labels each @font-face block with a /* subset */ comment above it. + const blocks = css.split("/*").slice(1); + let taken = 0; + + for (const block of blocks) { + const subset = block.slice(0, block.indexOf("*/")).trim(); + if (!WANTED.has(subset)) continue; + + const src = /url\((https:\/\/[^)]+\.woff2)\)/.exec(block)?.[1]; + const range = /unicode-range:\s*([^;]+);/.exec(block)?.[1]?.trim(); + if (!src || !range) throw new Error(`${family.slug}/${subset}: no src or unicode-range`); + + const response = await fetch(src, { headers: { "user-agent": UA } }); + if (!response.ok) throw new Error(`${src} returned ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + + const file = `src/fonts/${family.slug}-${subset}.woff2`; + await Bun.write(file, bytes); + console.log(`${file} ${bytes.length} bytes`); + console.log(` unicode-range: ${range}`); + taken++; + } + + if (taken !== WANTED.size) { + throw new Error(`${family.slug}: took ${taken} subsets, expected ${WANTED.size}`); + } +} diff --git a/src/cssPairs.test.ts b/src/cssPairs.test.ts new file mode 100644 index 0000000..a075562 --- /dev/null +++ b/src/cssPairs.test.ts @@ -0,0 +1,80 @@ +import { test, expect, describe } from "bun:test"; + +/** + * CLAUDE.md's hardest-won rule, made mechanical. + * + * shadcn uses several tokens as a BACKGROUND with a paired FOREGROUND for the + * text on it. Redefining one without the other repaints the background and + * leaves the text at the old colour — which is how overriding a bare + * `--accent` once dropped hover contrast below AA across the whole app. The + * damage is invisible in code review and invisible in a passing test suite. + * + * So: in any block that redefines one of these, its pair must be redefined in + * the same block. + */ +const PAIRS = [ + ["--background", "--foreground"], + ["--card", "--card-foreground"], + ["--popover", "--popover-foreground"], + ["--primary", "--primary-foreground"], + ["--secondary", "--secondary-foreground"], + ["--muted", "--muted-foreground"], + ["--accent", "--accent-foreground"], +] as const; + +/** Top-level `selector { ... }` blocks. Nested at-rules are not used here. */ +function blocks(css: string): { selector: string; body: string }[] { + const found: { selector: string; body: string }[] = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + for (const match of css.matchAll(re)) { + found.push({ selector: match[1]!.trim(), body: match[2]! }); + } + return found; +} + +export function findUnpairedTokens(css: string): string[] { + const problems: string[] = []; + for (const { selector, body } of blocks(css)) { + const defines = (token: string) => + new RegExp(`(^|[;\\s])${token}\\s*:`).test(body); + for (const [background, foreground] of PAIRS) { + if (defines(background) && !defines(foreground)) { + problems.push(`${selector} sets ${background} without ${foreground}`); + } + if (defines(foreground) && !defines(background)) { + problems.push(`${selector} sets ${foreground} without ${background}`); + } + } + } + return problems; +} + +describe("the paired-token guard itself", () => { + test("catches a background redefined without its foreground", () => { + expect(findUnpairedTokens(":root { --accent: #eee; }")).toEqual([ + ":root sets --accent without --accent-foreground", + ]); + }); + + test("catches a foreground redefined without its background", () => { + expect(findUnpairedTokens(".dark { --primary-foreground: #fff; }")).toEqual([ + ".dark sets --primary-foreground without --primary", + ]); + }); + + test("accepts a pair defined together", () => { + expect( + findUnpairedTokens(":root { --accent: #eee; --accent-foreground: #111; }"), + ).toEqual([]); + }); + + test("is not fooled by a token whose name merely ends the same way", () => { + // --sidebar-accent must not be read as --accent. + expect(findUnpairedTokens(":root { --sidebar-accent: #eee; }")).toEqual([]); + }); +}); + +test("src/index.css never redefines a shadcn token without its pair", async () => { + const css = await Bun.file("src/index.css").text(); + expect(findUnpairedTokens(css)).toEqual([]); +}); diff --git a/src/fontCss.test.ts b/src/fontCss.test.ts new file mode 100644 index 0000000..cf7afd9 --- /dev/null +++ b/src/fontCss.test.ts @@ -0,0 +1,48 @@ +import { test, expect, describe } from "bun:test"; +import { FONT_FILES, buildFontCss } from "./fontCss.ts"; + +describe("the font stylesheet", () => { + test("every url is absolute from the deploy base", () => { + const css = buildFontCss("/budget2.0/"); + const urls = [...css.matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]); + expect(urls).toEqual(FONT_FILES.map((f) => `/budget2.0/fonts/${f}`)); + }); + + test("a root deploy is not a special case", () => { + const urls = [...buildFontCss("/").matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]); + expect(urls).toEqual(FONT_FILES.map((f) => `/fonts/${f}`)); + }); + + /** + * Relative urls are what this must never emit. The stylesheet is served from + * the deploy root but the DOCUMENT can be several segments deep + * (/budget2.0/month/2026-09), and a relative font url resolves against + * whichever of the two the browser used — silently 404ing on deep routes. + */ + test("no url is relative", () => { + const css = buildFontCss("/budget2.0/"); + expect(css).not.toContain('url("./'); + expect(css).not.toContain('url("fonts/'); + }); + + test("each face declares a weight range, not a single weight", () => { + // Both faces are variable; one file must cover the whole scale. + const css = buildFontCss("/"); + expect(css).toContain("font-weight: 100 900"); + expect(css).toContain("font-weight: 400 700"); + expect(css.match(/@font-face/g)).toHaveLength(FONT_FILES.length); + }); + + test("every declared file is actually committed", async () => { + for (const file of FONT_FILES) { + expect(await Bun.file(`src/fonts/${file}`).exists()).toBe(true); + } + }); + + test("the licences travel with the fonts, as the OFL requires", async () => { + for (const licence of ["OFL-Inter.txt", "OFL-JetBrainsMono.txt"]) { + const text = await Bun.file(`src/fonts/${licence}`).text(); + expect(text).toContain("SIL Open Font License"); + } + }); +}); diff --git a/src/fontCss.ts b/src/fontCss.ts new file mode 100644 index 0000000..2cfc6e7 --- /dev/null +++ b/src/fontCss.ts @@ -0,0 +1,78 @@ +/** + * The one definition of the app's @font-face rules, shared by `build.ts` + * (which writes it to dist/fonts.css) and the dev server (which generates it + * per request) — the same shape as src/manifest.ts, and for the same reason: + * two copies is how the manifest came to hardcode `scope: "/"`. + * + * Why this is NOT in src/index.css, where it belongs on the face of it: Bun's + * CSS bundler inlines every woff2 it finds behind a `url()` as base64, + * unconditionally — `loader: { ".woff2": "file" }` does not change it. That + * turned the stylesheet into 303 KB of render-blocking bytes AND defeated the + * `unicode-range` subsetting, since a subset embedded in the stylesheet is + * downloaded whether or not a single character needs it. Served as files, the + * browser fetches only the subsets a glyph actually requires. + */ + +/** Ordered, and the order is asserted by src/fontCss.test.ts. */ +export const FONT_FILES = [ + "inter-latin.woff2", + "inter-latin-ext.woff2", + "jetbrains-mono-latin.woff2", + "jetbrains-mono-latin-ext.woff2", +] as const; + +/** Google's subset ranges, kept verbatim so the browser can skip a whole file. */ +const LATIN = + "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, " + + "U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, " + + "U+2212, U+2215, U+FEFF, U+FFFD"; +const LATIN_EXT = + "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, " + + "U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, " + + "U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"; + +interface Face { + family: string; + weight: string; + file: (typeof FONT_FILES)[number]; + range: string; +} + +const FACES: Face[] = [ + { family: "Inter", weight: "100 900", file: "inter-latin.woff2", range: LATIN }, + { family: "Inter", weight: "100 900", file: "inter-latin-ext.woff2", range: LATIN_EXT }, + { family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin.woff2", range: LATIN }, + { family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin-ext.woff2", range: LATIN_EXT }, +]; + +/** + * `base` always ends in "/" (see normalizeBase). Urls must be absolute from + * it: this stylesheet lives at the deploy root but the document can be several + * segments deep, and a relative url would resolve against the document and + * 404 on every route deeper than the base. + * + * `font-display: swap` because the first paint is a table of figures the owner + * wants to read now; blocking that on a font download is the wrong trade. + */ +export function buildFontCss(base: string): string { + const header = + "/* Generated by src/fontCss.ts. Do not edit: build.ts writes this file\n" + + " and the dev server serves the same output. SIL OFL 1.1 — see\n" + + " src/fonts/OFL-Inter.txt and src/fonts/OFL-JetBrainsMono.txt. */\n"; + + return ( + header + + FACES.map( + (face) => ` +@font-face { + font-family: "${face.family}"; + font-style: normal; + font-weight: ${face.weight}; + font-display: swap; + src: url("${base}fonts/${face.file}") format("woff2"); + unicode-range: ${face.range}; +}`, + ).join("\n") + + "\n" + ); +} diff --git a/src/fonts/OFL-Inter.txt b/src/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/src/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/fonts/OFL-JetBrainsMono.txt b/src/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/src/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/fonts/inter-latin-ext.woff2 b/src/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..479d010d2902fa3a5b48e84a973db1978b2e9949 GIT binary patch literal 85068 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 literal 0 HcmV?d00001 diff --git a/src/fonts/inter-latin.woff2 b/src/fonts/inter-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d15208de03cd1ad7c5199f0a0ce915fe841e4722 GIT binary patch literal 48256 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∾Na#*TBTU(!jF?9zmR-!C*+RdvGrd0&{`| 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 literal 0 HcmV?d00001 diff --git a/src/frontend.tsx b/src/frontend.tsx index 60d7c39..eedffda 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -13,6 +13,14 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import { App } from "./ui/App.tsx"; import { store } from "./store/index.ts"; +import { buildFontCss } from "./fontCss.ts"; +import { BASE_PATH } from "./ui/basePath.ts"; + +// Injected rather than imported: see the note in src/index.css. Done before +// the store loads so the faces are already in flight while IndexedDB is read. +const fontStyle = document.createElement("style"); +fontStyle.textContent = buildFontCss(BASE_PATH); +document.head.appendChild(fontStyle); const container = document.getElementById("root") ?? document.body; diff --git a/src/index.css b/src/index.css index 258f1f5..1cb5173 100644 --- a/src/index.css +++ b/src/index.css @@ -1,41 +1,163 @@ @import "../styles/globals.css"; -/* - * Design contract tokens (Tasks 18-23). - * See .superpowers/sdd/2026-09-01-budget-app/design-contract.md - * - * Namespaced as --budget-accent (not --accent): shadcn already defines a - * bare --accent/--accent-foreground pair in styles/globals.css for hover - * backgrounds on outline/ghost buttons, select items, badges, and the - * dialog close button. A same-named override here would repaint that - * background without repainting its paired foreground, breaking contrast - * on every one of those controls. None of the other five tokens below - * collide with a shadcn token name (checked against styles/globals.css). - * - * Unlayered (not inside @layer base) so these predictably win the cascade - * over anything layered added later, without needing !important. - */ -:root { - --paper: #f2f4f6; /* cool statement-paper ground, NOT cream */ - --ink: #16212b; /* ink navy, primary text */ - --rule: #d8dee4; /* hairlines, table borders */ - --budget-accent: #0f6e63; /* muted teal: links, primary actions, spend fill */ - --surplus: #2f6f4e; /* carried-forward surplus */ - --overspend: #a6402b; /* ochre-red: negative balances only */ +/* The @font-face rules are deliberately NOT here. Bun's CSS bundler resolves + every url() from disk and inlines woff2 as base64 — relative OR absolute, + and `loader: { ".woff2": "file" }` does not change it. That made this file + 303 KB of render-blocking bytes and defeated the unicode-range subsetting, + since a subset embedded in the stylesheet downloads whether or not one + character needs it. They live in src/fontCss.ts and are injected at boot by + src/frontend.tsx, which is the only arrangement that is identical in dev and + in a subpath production build without a placeholder file. The cost is that + the faces are requested after the JS parses rather than from the HTML; + `font-display: swap` keeps text readable throughout. */ - --font-display: ui-sans-serif, "Segoe UI Variable Display", system-ui, sans-serif; - --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --font-money: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", monospace; +/* + * ============================================================================ + * Budget 2.0 design foundation + * ============================================================================ + * + * Two layers, deliberately separate: + * + * 1. `--budget-*` semantic tokens, defined once per theme below. These are + * the app's own vocabulary — paper, ink, rule, surplus, overspend — and + * are the ONLY colours the app's own components should name. + * 2. shadcn's variable set, pointed at layer 1. Re-theming shadcn rather + * than fighting it means every primitive (dialog, select, badge, button) + * inherits both themes for free. + * + * WHY THE PAIRS MATTER. CLAUDE.md's rule is "never define a bare `--accent`", + * and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as + * a hover BACKGROUND and `--accent-foreground` as the text on it, so setting + * one without the other repaints the background and leaves the text behind. + * Every shadcn pair below is therefore redefined TOGETHER, in both themes. + * + * The brand teal is `--primary`, NOT `--accent`. That is the mapping the + * earlier bug got wrong: shadcn's `--accent` is a subtle hover tint, and a + * saturated teal there turns every hover into a coloured slab with dark text + * on it. `--accent` stays a tint of the surface. + * + * Every text pair here was measured, not eyeballed: the weakest is 5.50:1 + * (muted on paper, light theme) against a 4.5:1 requirement, and field + * borders clear the 3:1 non-text requirement on both of their backgrounds. + */ + +:root { + /* --- Layer 1: the app's own vocabulary (light) --- */ + --budget-paper: #f3f5f7; /* page ground: cool statement paper, NOT cream */ + --budget-raised: #ffffff; /* cards and rows sitting above the paper */ + --budget-ink: #16212b; /* primary text: ink navy, not black */ + --budget-ink-muted: #546575; /* labels, captions, secondary figures */ + --budget-rule: #dce3e9; /* hairlines between rows */ + --budget-rule-strong: #768899; /* field borders; needs 3:1, hairlines do not */ + --budget-accent: #0d6b60; /* muted teal: links, primary actions, spend fill */ + --budget-accent-ink: #ffffff; /* text ON the teal */ + --budget-tint: #e8edf1; /* hover wash, table header fill */ + --surplus: #1f6640; /* carried-forward surplus */ + --overspend: #a5382a; /* negative balances only */ + + /* Elevation is light and cool, never a grey drop shadow. */ + --budget-shadow-card: 0 1px 2px rgb(22 33 43 / 0.04), 0 8px 24px -12px rgb(22 33 43 / 0.12); + --budget-shadow-raised: 0 1px 1px rgb(22 33 43 / 0.05), 0 2px 6px -2px rgb(22 33 43 / 0.08); + + --font-display: "Inter", ui-sans-serif, system-ui, sans-serif; + --font-body: "Inter", ui-sans-serif, system-ui, sans-serif; + --font-money: "JetBrains Mono", ui-monospace, "SF Mono", monospace; + + /* --- Layer 2: shadcn, pointed at layer 1. Pairs, always. --- */ + --radius: 0.5rem; /* crisper than shadcn's 0.625rem default */ + + --background: var(--budget-paper); + --foreground: var(--budget-ink); + --card: var(--budget-raised); + --card-foreground: var(--budget-ink); + --popover: var(--budget-raised); + --popover-foreground: var(--budget-ink); + --primary: var(--budget-accent); + --primary-foreground: var(--budget-accent-ink); + --secondary: var(--budget-tint); + --secondary-foreground: var(--budget-ink); + --muted: var(--budget-tint); + --muted-foreground: var(--budget-ink-muted); + --accent: var(--budget-tint); /* a TINT. see the note above. */ + --accent-foreground: var(--budget-ink); /* its pair, never omitted. */ + --destructive: var(--overspend); + --border: var(--budget-rule); + --input: var(--budget-rule-strong); + --ring: var(--budget-accent); +} + +.dark { + /* --- Layer 1 (dark). Deep slate, never pure black: a pure-black ground + makes the hairlines and the carry meter float. --- */ + --budget-paper: #0c1319; + --budget-raised: #141e28; + --budget-ink: #e8eef4; + --budget-ink-muted: #94a7b8; + --budget-rule: #22303e; + --budget-rule-strong: #647c92; + --budget-accent: #3ab5a4; /* lifted: the light teal is unreadable on slate */ + --budget-accent-ink: #08110f; /* dark text on the lifted teal, not white */ + --budget-tint: #1c2836; + --surplus: #57bb83; + --overspend: #f0866a; + + /* Dark elevation reads as a lighter surface plus a hairline, not a shadow: + shadows are invisible on a dark ground. */ + --budget-shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px -16px rgb(0 0 0 / 0.7); + --budget-shadow-raised: 0 1px 0 rgb(255 255 255 / 0.03) inset; + + /* --- Layer 2 (dark). Same pairs, same rule. --- */ + --background: var(--budget-paper); + --foreground: var(--budget-ink); + --card: var(--budget-raised); + --card-foreground: var(--budget-ink); + --popover: var(--budget-raised); + --popover-foreground: var(--budget-ink); + --primary: var(--budget-accent); + --primary-foreground: var(--budget-accent-ink); + --secondary: var(--budget-tint); + --secondary-foreground: var(--budget-ink); + --muted: var(--budget-tint); + --muted-foreground: var(--budget-ink-muted); + --accent: var(--budget-tint); + --accent-foreground: var(--budget-ink); + --destructive: var(--overspend); + --border: var(--budget-rule); + --input: var(--budget-rule-strong); + --ring: var(--budget-accent); +} + +/* Makes `font-sans` / `font-mono` utilities and shadcn primitives inherit the + shipped faces instead of the OS stack. */ +@theme inline { + --font-sans: var(--font-body); + --font-mono: var(--font-money); + --color-budget-accent: var(--budget-accent); + --color-budget-paper: var(--budget-paper); + --color-budget-raised: var(--budget-raised); + --color-budget-rule: var(--budget-rule); + --color-budget-ink: var(--budget-ink); + --color-budget-ink-muted: var(--budget-ink-muted); + --color-surplus: var(--surplus); + --color-overspend: var(--overspend); } @layer base { :root { - @apply font-sans; + color-scheme: light; + } + + .dark { + color-scheme: dark; } body { - @apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground; + /* Was `grid place-items-center`, which centred short pages VERTICALLY — + the month view floated in the middle of the viewport on an empty month. + The app shell owns horizontal centring now, with a max width. */ + @apply m-0 min-w-[320px] min-h-screen bg-background text-foreground antialiased; font-family: var(--font-body); + font-feature-settings: "cv11"; /* Inter's single-storey a-alternates off */ } h1, @@ -45,16 +167,28 @@ h5, h6 { font-family: var(--font-display); - font-weight: 650; - letter-spacing: -0.02em; + font-weight: 640; + letter-spacing: -0.019em; + text-wrap: balance; + } + + /* One visible, consistent focus treatment. Keyboard users get a ring on the + accent colour, which clears 3:1 on every surface in both themes. */ + :focus-visible { + outline: 2px solid var(--budget-accent); + outline-offset: 2px; + border-radius: 2px; } } @layer utilities { - /* Applied to every money figure: mono face, tabular digits. */ + /* Applied to every money figure: mono face, tabular digits so columns of + numbers align on the decimal without a monospaced layout everywhere. */ .font-money { font-family: var(--font-money); font-variant-numeric: tabular-nums; + font-feature-settings: "zero"; /* slashed zero: 0 vs O at a glance */ + letter-spacing: -0.01em; } .text-overspend { @@ -64,6 +198,14 @@ .text-surplus { color: var(--surplus); } + + /* The one card treatment, so elevation cannot drift screen to screen. */ + .budget-card { + background: var(--budget-raised); + border: 1px solid var(--budget-rule); + border-radius: var(--radius); + box-shadow: var(--budget-shadow-card); + } } @keyframes spin { @@ -80,5 +222,6 @@ ::before, ::after { animation: none !important; + transition: none !important; } } diff --git a/src/index.html b/src/index.html index 8616dfb..232f5c6 100644 --- a/src/index.html +++ b/src/index.html @@ -4,9 +4,30 @@ Budget 2.0 + + + diff --git a/src/index.ts b/src/index.ts index f4f12c2..dde826c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { serve } from "bun"; import index from "./index.html"; import { normalizeBase } from "./ui/basePath.ts"; import { buildManifest } from "./manifest.ts"; +import { FONT_FILES } from "./fontCss.ts"; const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); @@ -39,6 +40,17 @@ const server = serve({ new Response("/* dev: intentionally does nothing */\n", { headers: { "content-type": "text/javascript", "cache-control": "no-cache" }, }), + // The @font-face rules are injected by frontend.tsx; these serve the + // files those rules point at. + ...Object.fromEntries( + FONT_FILES.map((file) => [ + `${basePath}fonts/${file}`, + () => + new Response(Bun.file(new URL(`./fonts/${file}`, import.meta.url)), { + headers: { "content-type": "font/woff2", "cache-control": "no-cache" }, + }), + ]), + ), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 920e97c..8abec1a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -9,26 +9,57 @@ import { SummaryRoute } from "./routes/SummaryRoute.tsx"; import { SettingsRoute } from "./routes/SettingsRoute.tsx"; import { UpdatePrompt } from "./components/UpdatePrompt.tsx"; import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx"; +import { ThemeToggle } from "./components/ThemeToggle.tsx"; + +/** + * One nav item. The active route is marked with weight and an accent + * underline rather than colour alone, so it does not depend on colour vision, + * and the underline is drawn on a fixed-height pseudo-row so switching tabs + * never shifts the header's layout. + */ +function NavItem({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + [ + "relative rounded-md px-2.5 py-1.5 transition-colors", + "after:absolute after:inset-x-2.5 after:-bottom-px after:h-0.5 after:rounded-full", + isActive + ? "font-semibold text-budget-ink after:bg-budget-accent" + : "text-budget-ink-muted hover:text-budget-ink hover:bg-accent after:bg-transparent", + ].join(" ") + } + > + {children} + + ); +} export function AppRoutes() { const year = currentMonth.slice(0, 4); return (
- -
+ {/* Sticky because the month view is long and the nav is how you leave + it. Translucent with a blur so the paper ground still reads as one + continuous sheet scrolling underneath. */} +
+
+ + Budget 2.0 + + +
+ + Settings +
+
+
+
} /> diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 5f979a1..fec70f1 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -26,7 +26,7 @@ interface Props { * - --surplus : carried-in surplus (omitted when carriedIn <= 0) * - --budget-accent : spend within this month's allocation, filled from * the track's left edge - * - --rule : the unspent remainder of the allocation track + * - --budget-rule : the unspent remainder of the allocation track * - --overspend : spend beyond the allocation, past the track's end */ function carryMeterBackground(figures: PostMonthFigures): string { @@ -34,7 +34,7 @@ function carryMeterBackground(figures: PostMonthFigures): string { const scale = surplus + Math.max(figures.allocation, figures.charges); if (scale <= 0) { - return "var(--rule)"; + return "var(--budget-rule)"; } const pSurplus = (surplus / scale) * 100; @@ -45,7 +45,7 @@ function carryMeterBackground(figures: PostMonthFigures): string { `linear-gradient(to right, ` + `var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` + `var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` + - `var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` + + `var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` + `var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)` ); } @@ -64,20 +64,20 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) return (
- - - - - - - + + + + + + + {rows.map(({ post, figures, overridden }) => ( - - + - -
PostCarried inAllocatedSpentRemaining
PostCarried inAllocatedSpentRemaining
+
{post.name} @@ -111,7 +111,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) {onChangeRule && ( 0 ? "text-surplus" : figures.carriedIn < 0 @@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) > {formatSignedMoney(figures.carriedIn, baseCurrency)} + {formatMoney(figures.allocation, baseCurrency)} + {formatMoney(figures.charges, baseCurrency)} diff --git a/src/ui/components/Section.tsx b/src/ui/components/Section.tsx new file mode 100644 index 0000000..bed3c45 --- /dev/null +++ b/src/ui/components/Section.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +interface Props { + /** Omitted when the card's contents are self-explanatory (a toolbar). */ + title?: string; + /** Sits opposite the title: a primary action, a mode switch. */ + action?: ReactNode; + /** One line under the title. Long explanations belong in the body. */ + hint?: ReactNode; + children: ReactNode; + className?: string; +} + +/** + * The one card in the app. Every screen is a stack of these on the paper + * ground, which is what stops elevation, radius and padding drifting apart + * screen by screen — the previous design defined a card treatment in a + * contract document and then never applied it anywhere. + * + * The `.budget-card` class carries the visual treatment (see src/index.css) so + * light and dark elevation differ where they must: a drop shadow is invisible + * on a dark ground, where a lighter surface plus a hairline does the work. + */ +export function Section({ title, action, hint, children, className = "" }: Props) { + return ( +
+ {(title || action) && ( +
+
+ {title &&

{title}

} + {hint && ( +

{hint}

+ )} +
+ {action &&
{action}
} +
+ )} + {children} +
+ ); +} + +/** + * A single figure with its label. Used for the month's totals, where the + * numbers matter more than the words: the label is small and quiet, the figure + * is large and mono so a column of them lines up. + */ +export function Stat({ + label, + children, + tone = "default", + title, +}: { + label: string; + children: ReactNode; + tone?: "default" | "surplus" | "overspend"; + title?: string; +}) { + const toneClass = + tone === "overspend" ? "text-overspend" : tone === "surplus" ? "text-surplus" : ""; + return ( +
+
+ {label} +
+
{children}
+
+ ); +} diff --git a/src/ui/components/ThemeToggle.tsx b/src/ui/components/ThemeToggle.tsx new file mode 100644 index 0000000..e8a794b --- /dev/null +++ b/src/ui/components/ThemeToggle.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { + applyResolvedTheme, + prefersDark, + readThemePreference, + resolveTheme, + storeThemePreference, + type ThemePreference, +} from "../theme.ts"; + +const ORDER: ThemePreference[] = ["system", "light", "dark"]; + +const LABEL: Record = { + system: "Theme: follows your system", + light: "Theme: light", + dark: "Theme: dark", +}; + +/** + * Cycles system -> light -> dark. Three states rather than a two-way switch + * because "follow the system" is a real answer and the only way to get back to + * it, once you have chosen, is to have somewhere to choose it. + * + * While the preference is "system" this keeps listening, so the app follows + * the OS switching to dark at sunset without a reload. + */ +export function ThemeToggle() { + const [preference, setPreference] = useState(readThemePreference); + + useEffect(() => { + applyResolvedTheme(resolveTheme(preference, prefersDark())); + if (preference !== "system") return; + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const follow = () => applyResolvedTheme(resolveTheme("system", media.matches)); + media.addEventListener("change", follow); + return () => media.removeEventListener("change", follow); + }, [preference]); + + function cycle() { + const next = ORDER[(ORDER.indexOf(preference) + 1) % ORDER.length]!; + setPreference(next); + storeThemePreference(next); + } + + const resolved = resolveTheme(preference, prefersDark()); + + return ( + + ); +} + +/* Inline so the app ships no icon library for three glyphs. currentColor + throughout, so they follow the theme without a second set of tokens. */ + +function SunGlyph() { + return ( + + ); +} + +function MoonGlyph() { + return ( + + ); +} + +function MonitorGlyph() { + return ( + + ); +} diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx index f437958..d1631fa 100644 --- a/src/ui/components/YearMatrix.tsx +++ b/src/ui/components/YearMatrix.tsx @@ -15,63 +15,72 @@ interface Props { */ export function YearMatrix({ view, mode }: Props) { return ( -
- - - - - {view.months.map((month) => ( - - ))} - - - - - - - {view.incomeByMonth.map((income, i) => ( - - ))} - - - - {view.rows.map((row) => ( - - - {row.byMonth.map((figures, i) => { - const value = mode === "spend" ? figures.charges : figures.remaining; - return ( - - ); - })} - + + {row.byMonth.map((figures, i) => { + const value = mode === "spend" ? figures.charges : figures.remaining; + return ( + + ); + })} + + + ))} + +
Post - {month.slice(5)} - Total
Income - {formatAmount(income)} - - {formatAmount(view.totalIncome)} -
- - {row.post.name} - - - {formatAmount(value)} - - {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} + // Thirteen columns of figures cannot fit any realistic card, so the table + // scrolls. The fade tells you that: without it the matrix just appears to + // end mid-number, which reads as clipping rather than as more to come. +
+
+ + + + + {view.months.map((month) => ( + + ))} + + + + + + + {view.incomeByMonth.map((income, i) => ( + + ))} + - ))} - -
Post + {month.slice(5)} + Total
Income + {formatAmount(income)} + + {formatAmount(view.totalIncome)}
+ + {view.rows.map((row) => ( +
+ + {row.post.name} + + + {formatAmount(value)} + + {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} +
+
+ ); } diff --git a/src/ui/cssTokens.test.ts b/src/ui/cssTokens.test.ts new file mode 100644 index 0000000..4826458 --- /dev/null +++ b/src/ui/cssTokens.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; + +/** + * Every custom property referenced from TypeScript through a CSS var() + * expression must name a token that actually exists in src/index.css. + * + * This exists because renaming `--rule` to `--budget-rule` silently broke the + * carry meter — the signature element of the month view. An undefined custom + * property inside a `linear-gradient()` does not warn, does not throw and does + * not fall back: the whole declaration is invalid, so the element simply + * paints nothing. It looked like a missing feature, not a bug, and only pixel + * sampling a screenshot found it. + * + * Tokens set by a third party at runtime are exempt — Radix writes + * `--radix-*` onto its own elements, and they are never in our stylesheet. + */ +const RUNTIME_PROVIDED = /^--radix-/; + +test("no TypeScript file references a CSS token that does not exist", async () => { + const css = await Bun.file("src/index.css").text(); + const defined = new Set( + [...css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]!), + ); + + const missing: string[] = []; + for await (const file of new Glob("src/**/*.{ts,tsx}").scan(".")) { + const source = await Bun.file(file).text(); + for (const match of source.matchAll(/var\((--[a-z0-9-]+)\)/g)) { + const token = match[1]!; + if (RUNTIME_PROVIDED.test(token) || defined.has(token)) continue; + const line = source.slice(0, match.index).split("\n").length; + missing.push(`${file}:${line} references ${token}`); + } + } + + expect(missing).toEqual([]); +}); diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index dd6215b..6adf240 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -14,6 +14,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts"; import { formatMoney, formatSignedMoney } from "../format.ts"; import { PostTable } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; +import { Section, Stat } from "../components/Section.tsx"; export function MonthRoute() { const { monthId = "" } = useParams(); @@ -27,56 +28,53 @@ export function MonthRoute() { : null; return ( -
+
{error && ( -
+
Could not save: {error} -
)} -
- - ← {addMonths(monthId, -1)} - -

{monthId}

- - {addMonths(monthId, 1)} → - + {/* The month is the app's primary coordinate, so it gets the largest + type on the page and the two ways of moving sit either side of it. */} +
+ +

{monthLabel(monthId)}

+
-
-
- - { - // Read the DOM value NOW: mutate() defers behind the write queue, - // by which time React has reset this input to the committed value. - const amount = Number(event.target.value) || 0; - mutate((draft) => setIncome(draft, monthId, { amount, currency: base })); - }} - /> -
-
-
-
Allocated
-
{formatMoney(view.totalAllocation, base)}
+
+
+
+ + { + // Read the DOM value NOW: mutate() defers behind the write queue, + // by which time React has reset this input to the committed value. + const amount = Number(event.target.value) || 0; + mutate((draft) => setIncome(draft, monthId, { amount, currency: base })); + }} + />
-
-
Spent
-
{formatMoney(view.totalCharges, base)}
-
-
-
Unallocated
-
+ {formatMoney(view.totalAllocation, base)} + {formatMoney(view.totalCharges, base)} + {formatSignedMoney(view.unallocated, base)} -
-
-
-
+ + +
+
+
+
{changingPost && ( )} -
-
-

Purchases

- Add purchase} /> -
-
    +
    Add purchase} />} + > +
      {dataset.purchases .filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null) .map((purchase) => { const slice = sliceAmountForMonth(purchase, monthId)!; return ( -
    • - +
    • + {purchase.description} {purchase.note && ( - {purchase.note} + {purchase.note} )} {purchase.schedule && ( - financed + + financed + )} - + {formatMoney(slice.amount, slice.currency)} - - edit - - } - /> - + {/* Held at a fixed width so revealing them on hover cannot + shift the amounts, which are the column being read. */} + + + edit + + } + /> + +
    • ); })}
    -
    -
+ + + ); +} + +/** "2026-09" reads as a key; "September 2026" reads as a month. */ +function monthLabel(monthId: MonthId): string { + const [year, month] = monthId.split("-"); + const names = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ]; + const name = names[Number(month) - 1]; + return name ? `${name} ${year}` : monthId; +} + +function MonthStep({ to, label, back = false }: { to: string; label: string; back?: boolean }) { + return ( + + {back ? `\u2190 ${label}` : `${label} \u2192`} + ); } diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index e0cb091..e3c6e35 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -2,10 +2,16 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; +/** + * Each section is its own card. They are unrelated settings — money, posts, + * and the export/import of everything — and running them together down one + * column, as before, made the destructive import controls look like a + * continuation of the harmless ones. + */ export function SettingsRoute() { return ( -
-

Settings

+
+

Settings

diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx index d4a9991..4f0ee44 100644 --- a/src/ui/routes/SummaryRoute.tsx +++ b/src/ui/routes/SummaryRoute.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { useDataset } from "../hooks/useDataset.ts"; import { datasetMonthSpan, summaryView } from "../../domain/views.ts"; import { formatAmount, formatMoney } from "../format.ts"; +import { Section, Stat } from "../components/Section.tsx"; export function SummaryRoute() { const dataset = useDataset(); @@ -19,9 +20,10 @@ export function SummaryRoute() { const difference = Number((view.totalIncome - view.totalCharges).toFixed(2)); return ( -
-

Summary

+
+

Summary

+
@@ -41,52 +43,49 @@ export function SummaryRoute() { > All time -
- {(["post", "month"] as const).map((option) => ( - - ))} -
-
-
-
Income
-
{formatMoney(view.totalIncome, base)}
-
-
-
Spent
-
{formatMoney(view.totalCharges, base)}
-
-
-
Difference
-
- {formatMoney(difference, base)} -
-
+
+ {formatMoney(view.totalIncome, base)} + {formatMoney(view.totalCharges, base)} + + {formatMoney(difference, base)} +
+
+
+ {(["post", "month"] as const).map((option) => ( + + ))} +
+ } + > - - - - - + + + + + {groupBy === "post" ? view.byPost.map((entry) => ( - - - - + + + )) : view.byMonth.map((entry) => ( - + - - +
{groupBy === "post" ? "Post" : "Month"}SpentShare
{groupBy === "post" ? "Post" : "Month"}SpentShare
{entry.post.name}{formatAmount(entry.charges)} +
{entry.post.name}{formatAmount(entry.charges)} {view.totalCharges === 0 ? "—" : `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`} @@ -94,14 +93,14 @@ export function SummaryRoute() {
{entry.monthId} {formatAmount(entry.charges)} + {formatAmount(entry.charges)} {view.totalCharges === 0 ? "—" : `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`} @@ -110,6 +109,7 @@ export function SummaryRoute() { ))}
-
+ +
); } diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx index a2bde23..9b8cdb1 100644 --- a/src/ui/routes/YearRoute.tsx +++ b/src/ui/routes/YearRoute.tsx @@ -5,6 +5,7 @@ import { useDataset } from "../hooks/useDataset.ts"; import { yearView } from "../../domain/views.ts"; import { formatMoney } from "../format.ts"; import { YearMatrix } from "../components/YearMatrix.tsx"; +import { Section, Stat } from "../components/Section.tsx"; export function YearRoute() { const { year = "" } = useParams(); @@ -15,35 +16,49 @@ export function YearRoute() { const base = dataset.settings.baseCurrency; return ( -
-
- +
+
+ ← {numericYear - 1} -

{year}

- +

{year}

+ {numericYear + 1} → -
- {(["spend", "balance"] as const).map((option) => ( - - ))} -
-

- Income {formatMoney(view.totalIncome, base)} · spent{" "} - {formatMoney(view.totalCharges, base)} -

+
+
+ {formatMoney(view.totalIncome, base)} + {formatMoney(view.totalCharges, base)} +
+
- -
+
+ {(["spend", "balance"] as const).map((option) => ( + + ))} +
+ } + > + + + ); } diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index e1509a8..d19aa8f 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Section } from "../../components/Section.tsx"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -35,8 +36,7 @@ export function CurrencySection() { } return ( -
-

Currency

+
@@ -58,8 +58,8 @@ export function CurrencySection() {
- - + + @@ -133,6 +133,6 @@ export function CurrencySection() { {status &&

{status}

} - + ); } diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx index ebf5a4a..82a8ef3 100644 --- a/src/ui/routes/settings/DataSection.tsx +++ b/src/ui/routes/settings/DataSection.tsx @@ -11,6 +11,7 @@ import { } from "../../../export/json.ts"; import { buildOds, odsFilename } from "../../../export/ods.ts"; import type { Dataset } from "../../../domain/types.ts"; +import { Section } from "../../components/Section.tsx"; export function DataSection() { const dataset = useDataset(); @@ -93,12 +94,10 @@ export function DataSection() { const counts = describeDataset(dataset); return ( -
-

Your data

-

- Everything lives in this browser. Export regularly — it is both your - backup and how you move your budget to another device. -

+
)} -
+
); } diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 32ebdf5..7e979ef 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -8,6 +8,7 @@ import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts"; import { ruleAt } from "../../../domain/allocation.ts"; import { currentMonth } from "../../../store/index.ts"; import { RuleHistory } from "../../components/RuleHistory.tsx"; +import { Section } from "../../components/Section.tsx"; export function PostsSection() { const dataset = useDataset(); @@ -34,22 +35,17 @@ export function PostsSection() { } return ( -
-

Posts

-

- A post's allocation is a dated series: each rule applies from its own - month onward until the next one takes over, so changing it never - rewrites what earlier months got. Click a rule to see and edit that - history. Any single month can still override its own allocation from - the month view. Percentages may total more than 100%. -

+
Currency 1 unit = ? {base} Source
- - - - - + + + + + @@ -121,7 +117,7 @@ export function PostsSection() { ))}
NameDisplay currencyRule
NameDisplay currencyRule
-

+

Archiving hides a post from new purchases while keeping its history. Posts are never deleted, because purchases reference them.

@@ -144,6 +140,6 @@ export function PostsSection() { Add post -
+
); } diff --git a/src/ui/theme.test.ts b/src/ui/theme.test.ts new file mode 100644 index 0000000..d37adfc --- /dev/null +++ b/src/ui/theme.test.ts @@ -0,0 +1,90 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +// Bun can run several test files in one process and other files register +// happy-dom too — guard so they can all run together. +if (!GlobalRegistrator.isRegistered) { + GlobalRegistrator.register(); +} + +import { test, expect, describe, beforeEach } from "bun:test"; +import { + THEME_KEY, + applyResolvedTheme, + isThemePreference, + readThemePreference, + resolveTheme, + storeThemePreference, +} from "./theme.ts"; + +beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); + +describe("resolving a preference to an actual theme", () => { + test("an explicit choice ignores the system setting entirely", () => { + expect(resolveTheme("light", true)).toBe("light"); + expect(resolveTheme("dark", false)).toBe("dark"); + }); + + test("'system' follows the operating system", () => { + expect(resolveTheme("system", true)).toBe("dark"); + expect(resolveTheme("system", false)).toBe("light"); + }); +}); + +describe("reading the stored preference", () => { + test("defaults to following the system when nothing is stored", () => { + expect(readThemePreference()).toBe("system"); + }); + + test("round-trips each preference", () => { + for (const preference of ["light", "dark", "system"] as const) { + storeThemePreference(preference); + expect(readThemePreference()).toBe(preference); + } + }); + + // A value can only be junk if something else wrote it, or the user edited + // it. Following the system beats throwing during boot. + test("junk in storage falls back to following the system", () => { + localStorage.setItem(THEME_KEY, "solarized"); + expect(readThemePreference()).toBe("system"); + }); + + test("only the three real preferences are accepted", () => { + expect(isThemePreference("light")).toBe(true); + expect(isThemePreference("dark")).toBe(true); + expect(isThemePreference("system")).toBe(true); + expect(isThemePreference("Dark")).toBe(false); + expect(isThemePreference(null)).toBe(false); + expect(isThemePreference(undefined)).toBe(false); + }); +}); + +describe("applying a theme", () => { + test("dark adds the class shadcn's palette is keyed on, light removes it", () => { + applyResolvedTheme("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + applyResolvedTheme("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + test("applying the same theme twice is not a toggle", () => { + applyResolvedTheme("dark"); + applyResolvedTheme("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); +}); + +/** + * index.html carries a tiny inline script that applies the stored theme before + * the stylesheet paints, so a dark-theme user never sees a white flash. It + * cannot import this module, so it repeats the key and the logic — and this is + * what stops the two drifting apart. + */ +test("the no-flash script in index.html agrees with this module", async () => { + const html = await Bun.file("src/index.html").text(); + expect(html).toContain(THEME_KEY); + expect(html).toContain("prefers-color-scheme: dark"); + expect(html).toContain("dark"); +}); diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..59f1a52 --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,70 @@ +/** + * Theme preference: light, dark, or follow the system. + * + * Stored in localStorage rather than in the dataset, deliberately. The dataset + * is the owner's budget — it is exported, imported and versioned, and a + * per-device display choice does not belong in it. Importing a backup taken on + * a desktop should not flip a phone to dark. + * + * Every storage access is wrapped: localStorage THROWS on access in some + * configurations (a browser set to block site data, some private modes), not + * merely returning null, and a theme preference must never be the reason the + * app fails to boot. + */ + +export const THEME_KEY = "budget2:theme"; + +/** What the user chose. */ +export type ThemePreference = "light" | "dark" | "system"; + +/** What that resolves to right now. Only these two reach the DOM. */ +export type ResolvedTheme = "light" | "dark"; + +const PREFERENCES: readonly string[] = ["light", "dark", "system"]; + +export function isThemePreference(value: unknown): value is ThemePreference { + return typeof value === "string" && PREFERENCES.includes(value); +} + +export function readThemePreference(): ThemePreference { + try { + const stored = localStorage.getItem(THEME_KEY); + return isThemePreference(stored) ? stored : "system"; + } catch { + return "system"; + } +} + +export function storeThemePreference(preference: ThemePreference): void { + try { + localStorage.setItem(THEME_KEY, preference); + } catch { + // A preference that cannot be remembered is a small loss; a crash is not. + } +} + +export function prefersDark(): boolean { + try { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch { + return false; + } +} + +export function resolveTheme( + preference: ThemePreference, + systemPrefersDark: boolean, +): ResolvedTheme { + if (preference === "system") return systemPrefersDark ? "dark" : "light"; + return preference; +} + +/** + * shadcn's dark palette is keyed on a `.dark` class rather than a media query + * (`@custom-variant dark (&:is(.dark *))` in styles/globals.css), so this + * class is the single switch for both shadcn's variables and the app's own + * `--budget-*` tokens. + */ +export function applyResolvedTheme(theme: ResolvedTheme): void { + document.documentElement.classList.toggle("dark", theme === "dark"); +} From d8ee579fe74c3b7b7c4595c216fd60628402e34a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 15:12:35 +0200 Subject: [PATCH 08/15] 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) --- .../2026-09-02-design-foundation-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-design-foundation-design.md diff --git a/docs/superpowers/specs/2026-09-02-design-foundation-design.md b/docs/superpowers/specs/2026-09-02-design-foundation-design.md new file mode 100644 index 0000000..5cb6f91 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-design-foundation-design.md @@ -0,0 +1,152 @@ +# Design foundation + +Sub-project 1 of the visual redesign. Owner's brief: *"right now it looks a bit +boring, simple and ugly. We want sleek, modern, and nice to use."* Owner's +answers to the four scoping questions: both themes with a toggle, a full +rethink of every screen, purchases compact and grouped by date, and a +self-hosted webfont. Owner then delegated the specifics: *"Override everything +you want for this step. This needs to look good."* + +## The diagnosis that shaped everything else + +A design system already existed and was never wired up. `src/index.css` +defined `--paper` (annotated "cool statement-paper ground, NOT cream"), +`--ink`, a muted teal, and surplus/overspend colours, with a design-contract +document behind them. The app painted itself with shadcn's defaults — +`bg-background text-foreground`, i.e. white and near-black. Those tokens +reached the screen in exactly two places: the carry meter, and the sign +colours on figures. + +So "boring, simple and ugly" was not a missing design. It was a design that was +specified and then bypassed. That is why the foundation comes first in the +decomposition, and why it re-themes shadcn rather than styling screens: doing +it screen by screen is how the bypass happened. + +## Decomposition + +The redesign is four sub-projects, each its own spec → plan → implementation: + +1. **Design foundation** (this document) — tokens, themes, type, shared furniture. +2. **Month view** — income and totals, post rows and carry meter, purchases grouped by date. +3. **Year, Summary, Post detail** — the reporting screens, including whether the 12-month matrix stays a scrolling table. +4. **Dialogs and fast entry** — purchase dialog, split editor, plan editor, bulk entry. + +A single spec covering all four would have meant dozens of layout decisions +made on the owner's behalf under one approval. + +## Token architecture + +Two layers, deliberately separate. + +**Layer 1, `--budget-*`**: the app's own vocabulary — paper, raised, ink, +ink-muted, rule, rule-strong, accent, accent-ink, tint — plus `--surplus` and +`--overspend`, which shadcn has no concept of. Defined once per theme. The only +colours app components may name. + +**Layer 2, shadcn**: its entire variable set points at layer 1. Every primitive +(dialog, select, badge, button) inherits both themes for free. + +### The brand teal is `--primary`, never `--accent` + +CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with +an app-wide contrast bug. The mechanism matters: shadcn uses `--accent` as a +hover **background** with `--accent-foreground` as the text on it. Setting one +without the other repaints the background and leaves the text behind. + +Two conclusions follow, and both are enforced rather than remembered: + +- Every shadcn pair is redefined **together**, in both themes. + `src/cssPairs.test.ts` fails the build if either half of a pair appears + without the other, in any block. +- The saturated teal belongs on `--primary`. Putting a brand colour on + `--accent` — which is what "override `--accent`" invites — turns every hover + into a coloured slab with dark text on it. `--accent` stays a tint of the + surface. + +### Contrast was computed, not chosen + +Weakest text pair: 5.50:1 (muted on paper, light) against a 4.5:1 requirement. +Field borders were iterated until they cleared the 3:1 non-text requirement on +**both** backgrounds they appear against — the first two candidates measured +2.17 and 2.55 and were rejected. The final palette was then read back out of +the live browser in both themes, so the check is on what actually rendered +rather than on the arithmetic. + +## Typography + +Inter for text, JetBrains Mono for figures, both variable, both self-hosted, +latin + latin-ext only. SIL OFL 1.1; licences committed beside the files. +Self-hosted rather than CDN-linked because PRODUCT.md's position is that the +data never leaves the browser and the app needs no third party — a font request +to another origin on every cold load quietly contradicts that. + +`.font-money` carries tabular figures and a slashed zero, so columns align on +the decimal and `0` cannot be misread as `O`. + +### Rejected: @font-face in the bundled stylesheet + +The obvious home. Bun's CSS bundler resolves every `url()` from disk and inlines +woff2 as base64 — **relative or absolute**, and `loader: { ".woff2": "file" }` +does not change it. Measured cost: `index.css` went from 70 KB to 303 KB of +render-blocking bytes, and the `unicode-range` subsetting became pointless, +since a subset embedded in the stylesheet downloads whether or not one +character needs it. + +Also rejected: a ``. It works in a +production build (`external: ["*fonts.css"]`), but the dev server's HTML +bundler resolves every `` from disk and has no `external` option, so +dev would serve a placeholder and screenshots would lie about typography. +Making the manifest and icon hrefs root-absolute for the same reason 500'd the +dev server outright. + +**Chosen**: `src/fontCss.ts` is the one definition, injected as a `