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:
Lucas WintherandClaude Opus 5 committed 2026-09-02 15:11:43 +02:00
1 parent 11e9405203
commit bb3c661ef6
30 files changed
+1387 -288

No files matched your search

+17 -17
View File
@@ -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" : ""
}`}
>
+69
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+64 -55
View File
@@ -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>
);
}