feat(ui): design foundation — two themes, shipped type, one card
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system already existed and was never wired up. src/index.css defined --paper, --ink and a muted teal with a contract document behind them, while the app painted itself with shadcn's default white and near-black. Those tokens reached the screen in exactly two places — the carry meter and the +/- number colours. TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per theme, and shadcn's whole variable set points at it, so every primitive inherits both themes rather than being fought. Light is cool paper with ink navy; dark is deep slate, never pure black, because a black ground makes the hairlines and the carry meter float. The brand teal is --primary, NOT --accent. That is precisely what the old bug got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with --accent-foreground, so a saturated colour there turns every hover into a coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule mechanical — redefining either half of a shadcn pair without the other fails the build, in any block. CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on both of their backgrounds. Verified afterwards by reading the tokens back out of the live browser in both themes rather than trusting the arithmetic. TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler resolves every url() from disk and inlines woff2 as base64 — relative or absolute, and a "file" loader override does not stop it — which made the stylesheet 303 KB of render-blocking bytes and defeated the unicode-range subsetting entirely. They are injected at boot from one definition instead, which is the only arrangement identical in dev and in a subpath build. THEME TOGGLE cycles system / light / dark, remembers the choice in localStorage (a per-device display preference does not belong in the exported budget), keeps following the OS while set to "system", and is applied by a tiny inline script before first paint so a dark-theme user never sees a white flash. Every storage access is wrapped: localStorage throws outright in some configurations, and a theme preference must never stop the app booting. A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently killed the carry meter. An undefined custom property inside linear-gradient() does not warn, does not throw and does not fall back — the declaration is invalid, so the element paints nothing. It looked like a missing feature. Only pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any TypeScript file references a token src/index.css does not define. Also: one Section/Stat card treatment applied across month, year, summary and settings; quiet uppercase table headers; row hover; the rule-editing and purchase-row actions revealed on hover instead of six always-on links; a scroll fade on the year matrix so it reads as "more to come" rather than clipped; and "September 2026" instead of "2026-09" as the month heading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
11e9405203
commit
bb3c661ef6
30 files changed
+1387
-288
No files matched your search
@@ -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 <link href> 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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
|
||||
+173
-30
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,30 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Budget 2.0</title>
|
||||
<!-- Relative, NOT root-absolute: the dev server's HTML bundler resolves
|
||||
every <link href> from disk and has no `external` option, so "/x"
|
||||
fails to resolve and the whole page 500s. build.ts rewrites these to
|
||||
the deploy base for production. -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!--
|
||||
Applies the saved theme BEFORE the stylesheet paints, so a dark-theme
|
||||
user never sees a flash of the light palette. It cannot import
|
||||
src/ui/theme.ts (it has to run before any module loads), so it repeats
|
||||
the key and the fallback; src/ui/theme.test.ts fails if the two drift.
|
||||
Wrapped because localStorage throws outright in some configurations.
|
||||
-->
|
||||
<script>
|
||||
try {
|
||||
var saved = localStorage.getItem("budget2:theme");
|
||||
var dark =
|
||||
saved === "dark" ||
|
||||
((saved === null || saved === "system") &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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" && {
|
||||
|
||||
+46
-15
@@ -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 (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"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}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRoutes() {
|
||||
const year = currentMonth.slice(0, 4);
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<nav className="flex gap-4 border-b px-6 py-3 text-sm">
|
||||
<NavLink to={`/month/${currentMonth}`} className="hover:underline">
|
||||
Month
|
||||
</NavLink>
|
||||
<NavLink to={`/year/${year}`} className="hover:underline">
|
||||
Year
|
||||
</NavLink>
|
||||
<NavLink to="/summary" className="hover:underline">
|
||||
Summary
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className="ml-auto hover:underline">
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
{/* 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. */}
|
||||
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
|
||||
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
|
||||
Budget<span className="text-budget-accent"> 2.0</span>
|
||||
</span>
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<NavItem to={`/month/${currentMonth}`}>Month</NavItem>
|
||||
<NavItem to={`/year/${year}`}>Year</NavItem>
|
||||
<NavItem to="/summary">Summary</NavItem>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<RouteErrorBoundaryReset>
|
||||
<Routes>
|
||||
<Route path="/month/:monthId" element={<MonthRoute />} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
<th className="py-2 pl-6 text-right">Carried in</th>
|
||||
<th className="py-2 pl-6 text-right">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right">Spent</th>
|
||||
<th className="py-2 pl-6 text-right">Remaining</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ post, figures, overridden }) => (
|
||||
<Fragment key={post.id}>
|
||||
<tr>
|
||||
<td className="py-2">
|
||||
<tr className="group transition-colors hover:bg-accent/60">
|
||||
<td className="py-2.5">
|
||||
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
|
||||
{post.name}
|
||||
</Link>
|
||||
@@ -111,7 +111,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
{onChangeRule && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
|
||||
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => onChangeRule(post.id)}
|
||||
>
|
||||
change from here
|
||||
@@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 pl-6 text-right ${
|
||||
className={`font-money py-2.5 pl-6 text-right ${
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
@@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 pl-6 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.allocation, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 pl-6 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.charges, baseCurrency)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 pl-6 text-right font-medium ${
|
||||
className={`font-money py-2.5 pl-6 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<section className={`budget-card p-4 sm:p-5 ${className}`}>
|
||||
{(title || action) && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="text-base leading-tight">{title}</h2>}
|
||||
{hint && (
|
||||
<p className="mt-1 text-xs leading-relaxed text-budget-ink-muted">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex shrink-0 items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="min-w-0" title={title}>
|
||||
<dt className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={`font-money mt-0.5 text-lg leading-tight ${toneClass}`}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ThemePreference, string> = {
|
||||
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<ThemePreference>(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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycle}
|
||||
title={`${LABEL[preference]}. Click to change.`}
|
||||
aria-label={`${LABEL[preference]}. Click to change.`}
|
||||
className="grid size-8 place-items-center rounded-md text-budget-ink-muted transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{preference === "system" ? (
|
||||
<MonitorGlyph />
|
||||
) : resolved === "dark" ? (
|
||||
<MoonGlyph />
|
||||
) : (
|
||||
<SunGlyph />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="3.1" />
|
||||
<path d="M8 1.4v1.5M8 13.1v1.5M1.4 8h1.5M13.1 8h1.5M3.3 3.3l1.1 1.1M11.6 11.6l1.1 1.1M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<path d="M13.2 10.1A5.6 5.6 0 0 1 5.9 2.8a5.6 5.6 0 1 0 7.3 7.3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<rect x="1.6" y="2.6" width="12.8" height="8.4" rx="1.3" />
|
||||
<path d="M5.6 13.4h4.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -15,63 +15,72 @@ interface Props {
|
||||
*/
|
||||
export function YearMatrix({ view, mode }: Props) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[84rem] text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 pl-4 text-right font-normal whitespace-nowrap">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pl-4 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{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.
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[84rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 pl-4 text-right font-medium whitespace-nowrap">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pl-4 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-budget-rule text-budget-ink-muted">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-budget-raised to-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
+104
-73
@@ -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 (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center justify-between rounded border border-destructive p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<span>Could not save: {error}</span>
|
||||
<button onClick={clearError} className="underline">
|
||||
<button onClick={clearError} className="underline underline-offset-2">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
|
||||
← {addMonths(monthId, -1)}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{monthId}</h1>
|
||||
<Link to={`/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
|
||||
{addMonths(monthId, 1)} →
|
||||
</Link>
|
||||
{/* 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. */}
|
||||
<header className="flex items-baseline gap-3">
|
||||
<MonthStep to={`/month/${addMonths(monthId, -1)}`} label={addMonths(monthId, -1)} back />
|
||||
<h1 className="text-2xl">{monthLabel(monthId)}</h1>
|
||||
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="income">Income this month ({base})</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money w-40"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// 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 }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalAllocation, base)}</dd>
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
Income this month ({base})
|
||||
</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money h-10 w-44 text-lg"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// 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 }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Unallocated</dt>
|
||||
<dd
|
||||
className={`font-money ${view.unallocated < 0 ? "text-overspend" : ""}`}
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
title={
|
||||
view.unallocated < 0
|
||||
? "Allocations exceed this month's income. This is allowed."
|
||||
@@ -84,17 +82,19 @@ export function MonthRoute() {
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Stat>
|
||||
</dl>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Posts" className="overflow-hidden">
|
||||
<PostTable
|
||||
monthId={monthId}
|
||||
baseCurrency={base}
|
||||
rows={view.rows}
|
||||
onChangeRule={setChangingRuleFor}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{changingPost && (
|
||||
<RuleFromMonth
|
||||
@@ -109,52 +109,83 @@ export function MonthRoute() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Purchases</h2>
|
||||
<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />
|
||||
</div>
|
||||
<ul className="divide-y text-sm">
|
||||
<Section
|
||||
title="Purchases"
|
||||
action={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
|
||||
>
|
||||
<ul className="divide-y divide-budget-rule text-sm">
|
||||
{dataset.purchases
|
||||
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
|
||||
.map((purchase) => {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li key={purchase.id} className="flex items-center gap-3 py-2">
|
||||
<span className="flex-1">
|
||||
<li
|
||||
key={purchase.id}
|
||||
className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{purchase.note}</span>
|
||||
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="text-xs text-muted-foreground">financed</span>
|
||||
<span className="rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
|
||||
financed
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money">
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
{/* Held at a fixed width so revealing them on hover cannot
|
||||
shift the amounts, which are the column being read. */}
|
||||
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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 (
|
||||
<Link
|
||||
to={to}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{back ? `\u2190 ${label}` : `${label} \u2192`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-4xl space-y-10">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="max-w-4xl space-y-5">
|
||||
<h1 className="text-2xl">Settings</h1>
|
||||
<CurrencySection />
|
||||
<PostsSection />
|
||||
<DataSection />
|
||||
|
||||
@@ -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 (
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Summary</h1>
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl">Summary</h1>
|
||||
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="from">From</Label>
|
||||
@@ -41,52 +43,49 @@ export function SummaryRoute() {
|
||||
>
|
||||
All time
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Income</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalIncome, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Difference</dt>
|
||||
<dd className={`font-money ${difference < 0 ? "text-overspend" : ""}`}>
|
||||
{formatMoney(difference, base)}
|
||||
</dd>
|
||||
</div>
|
||||
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
|
||||
{formatMoney(difference, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={groupBy === "post" ? "By post" : "By month"}
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 text-right">Spent</th>
|
||||
<th className="py-2 text-right">Share</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupBy === "post"
|
||||
? view.byPost.map((entry) => (
|
||||
<tr key={entry.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">{entry.post.name}</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2.5">{entry.post.name}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -94,14 +93,14 @@ export function SummaryRoute() {
|
||||
</tr>
|
||||
))
|
||||
: view.byMonth.map((entry) => (
|
||||
<tr key={entry.monthId} className="border-b last:border-0">
|
||||
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/month/${entry.monthId}`} className="hover:underline">
|
||||
{entry.monthId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -110,6 +109,7 @@ export function SummaryRoute() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-23
@@ -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 (
|
||||
<section className="space-y-6">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-baseline gap-3">
|
||||
<Link
|
||||
to={`/year/${numericYear - 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
← {numericYear - 1}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{year}</h1>
|
||||
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
|
||||
<h1 className="text-2xl">{year}</h1>
|
||||
<Link
|
||||
to={`/year/${numericYear + 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{numericYear + 1} →
|
||||
</Link>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Income {formatMoney(view.totalIncome, base)} · spent{" "}
|
||||
{formatMoney(view.totalCharges, base)}
|
||||
</p>
|
||||
<Section>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</section>
|
||||
<Section
|
||||
title="Posts by month"
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Currency</h2>
|
||||
<Section title="Currency">
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
@@ -58,8 +58,8 @@ export function CurrencySection() {
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
@@ -133,6 +133,6 @@ export function CurrencySection() {
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Your data</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything lives in this browser. Export regularly — it is both your
|
||||
backup and how you move your budget to another device.
|
||||
</p>
|
||||
<Section
|
||||
title="Your data"
|
||||
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
|
||||
>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportJson}>
|
||||
@@ -148,6 +147,6 @@ export function DataSection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Posts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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%.
|
||||
</p>
|
||||
<Section
|
||||
title="Posts"
|
||||
hint="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. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
|
||||
>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Display currency</th>
|
||||
<th className="py-2">Rule</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Display currency</th>
|
||||
<th className="py-2 font-medium">Rule</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -121,7 +117,7 @@ export function PostsSection() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
Archiving hides a post from new purchases while keeping its history. Posts
|
||||
are never deleted, because purchases reference them.
|
||||
</p>
|
||||
@@ -144,6 +140,6 @@ export function PostsSection() {
|
||||
Add post
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user