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 0000000..479d010 Binary files /dev/null and b/src/fonts/inter-latin-ext.woff2 differ diff --git a/src/fonts/inter-latin.woff2 b/src/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/fonts/inter-latin.woff2 differ diff --git a/src/fonts/jetbrains-mono-latin-ext.woff2 b/src/fonts/jetbrains-mono-latin-ext.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/src/fonts/jetbrains-mono-latin-ext.woff2 differ diff --git a/src/fonts/jetbrains-mono-latin.woff2 b/src/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/src/fonts/jetbrains-mono-latin.woff2 differ 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"); +}