diff --git a/DESIGN.md b/DESIGN.md
index 3d7306e..a1047ce 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -54,12 +54,23 @@ data demands them.
Red (`--neg`) appears in chrome only when money or data is at risk; an expense
figure in a table is plain ink, not red.
-### Chart categoricals (colorblind-safe ordering, muted chroma)
-
-`--chart-1` `oklch(0.55 0.13 180)` (validated ≥0.1 chroma vs light surface; dark: `oklch(0.65 0.12 190)`) · `--chart-2` `oklch(0.65 0.11 75)` ·
-`--chart-3` `oklch(0.55 0.10 300)` · `--chart-4` `oklch(0.68 0.10 130)` ·
-`--chart-5` `oklch(0.58 0.11 25)` · `--chart-6` `oklch(0.62 0.08 250)` ·
-`--chart-7` `oklch(0.70 0.09 55)`. Dark theme: same hues, lightness +0.10.
+### Chart colors
+
+- `--chart-1` `oklch(0.55 0.13 180)` light / `oklch(0.65 0.12 190)` dark — the
+ single-series mark color (net worth line). Validated ≥0.1 chroma and 3:1
+ contrast per surface. (`--chart-2…7` remain as spare series tokens.)
+- **Per-category palette** `--cat-0` … `--cat-11`: each category owns a stable
+ palette index (`categories.color_index`, assigned at creation, never
+ recomputed from position). Generated with
+ [poline](https://github.com/meodai/poline) via
+ `scripts/generate-category-palette.ts` — a closed-loop hue journey anchored
+ at spruce/amber/plum, normalized into OKLCH L 0.54–0.62, C ≥ 0.10 so one set
+ passes the lightness band, chroma floor, and 3:1 contrast on BOTH surfaces
+ (no dark override). Neighboring slots are interleaved to maximize hue
+ distance; charts additionally separate fills with 2px surface gaps and
+ always carry a legend, so identity never rests on hue alone. Neutral
+ (`--text-faint`) is reserved for "Other" and "Uncategorized" — never a
+ category.
## Typography
diff --git a/deno.lock b/deno.lock
index 586bb14..9eac1f2 100644
--- a/deno.lock
+++ b/deno.lock
@@ -11,6 +11,7 @@
"npm:@sveltejs/kit@^2.63.0": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1",
"npm:@sveltejs/vite-plugin-svelte@^7.1.2": "7.2.0_svelte@5.56.4_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1",
"npm:@types/node@^26.1.1": "26.1.1",
+ "npm:poline@~0.13.1": "0.13.1",
"npm:svelte-check@*": "4.7.2_svelte@5.56.4_typescript@6.0.3",
"npm:svelte-check@^4.6.0": "4.7.2_svelte@5.56.4_typescript@6.0.3",
"npm:svelte@^5.56.1": "5.56.4",
@@ -832,6 +833,9 @@
"picomatch@4.0.5": {
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="
},
+ "poline@0.13.1": {
+ "integrity": "sha512-CCK9R8mSBoaxlbVDfeTAmWbw2or6WEpHL1uNHyJH9CYppVWlcLUUPAOf40mHcKGLLBmGb6NdWse+ZEeArpbG8Q=="
+ },
"postcss@8.5.17": {
"integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==",
"dependencies": [
@@ -1048,6 +1052,7 @@
"npm:@sveltejs/kit@^2.63.0",
"npm:@sveltejs/vite-plugin-svelte@^7.1.2",
"npm:@types/node@^26.1.1",
+ "npm:poline@~0.13.1",
"npm:svelte-check@^4.6.0",
"npm:svelte@^5.56.1",
"npm:typescript@^6.0.3",
diff --git a/migrations/002_category_colors.sql b/migrations/002_category_colors.sql
new file mode 100644
index 0000000..7d8e9b4
--- /dev/null
+++ b/migrations/002_category_colors.sql
@@ -0,0 +1,6 @@
+-- Stable per-category chart color: an index into the --cat-N palette tokens.
+-- Color follows the entity — assigned once, never recomputed from position.
+
+ALTER TABLE categories ADD COLUMN color_index INTEGER;
+
+UPDATE categories SET color_index = (id - 1) % 12;
diff --git a/package.json b/package.json
index f645d38..45bd235 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
+ "poline": "^0.13.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
diff --git a/scripts/generate-category-palette.ts b/scripts/generate-category-palette.ts
new file mode 100644
index 0000000..9370272
--- /dev/null
+++ b/scripts/generate-category-palette.ts
@@ -0,0 +1,157 @@
+///
+// Design-time generator for the per-category chart palette (--cat-0 … --cat-11).
+//
+// Poline (meodai/poline) draws the hue journey between anchors; each stop is
+// then normalized into a fixed OKLCH band (L 0.54–0.62, C 0.10–0.14, reduced
+// only if out of sRGB gamut) so a single palette passes the dataviz checks —
+// chroma floor, lightness band, and 3:1 contrast — on BOTH the light and dark
+// surfaces. Category colors are assigned by persisted index (color follows the
+// entity), so regenerate only with intent: existing assignments keep their
+// index but change appearance.
+//
+// Usage: deno run -A scripts/generate-category-palette.ts
+
+import { Poline } from 'poline';
+
+const COUNT = 12;
+const L_MIN = 0.54;
+const L_MAX = 0.62;
+const C_MIN = 0.1;
+const C_MAX = 0.14;
+
+// Anchors tour the wheel starting in Quantum's spruce family, through warm
+// amber, into plum — wide hue coverage for CVD-distinguishable neighbors.
+const poline = new Poline({
+ numPoints: 10,
+ closedLoop: true, // tour the whole wheel: spruce → amber → plum → back
+ anchorColors: [
+ [178, 0.62, 0.42], // spruce
+ [42, 0.72, 0.5], // amber
+ [292, 0.45, 0.55] // plum
+ ]
+});
+
+// --- OKLab <-> sRGB (Björn Ottosson's reference math) ---
+
+function srgbToLinear(c: number): number {
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+}
+
+function linearToSrgb(c: number): number {
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
+}
+
+function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
+ const [lr, lg, lb] = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)];
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
+ return [L, Math.hypot(a, bb), (Math.atan2(bb, a) * 180) / Math.PI];
+}
+
+function oklchToRgb(L: number, C: number, hDeg: number): [number, number, number] | null {
+ const h = (hDeg * Math.PI) / 180;
+ const a = C * Math.cos(h);
+ const b = C * Math.sin(h);
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
+ const rgb = [lr, lg, lb].map(linearToSrgb);
+ if (rgb.some((c) => c < -0.001 || c > 1.001)) return null;
+ return rgb.map((c) => Math.min(1, Math.max(0, c))) as [number, number, number];
+}
+
+function hslToRgb(h: number, s: number, l: number): [number, number, number] {
+ const k = (n: number) => (n + h / 30) % 12;
+ const a = s * Math.min(l, 1 - l);
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
+ return [f(0), f(8), f(4)];
+}
+
+function toHex(rgb: [number, number, number]): string {
+ return '#' + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
+}
+
+// --- normalize each Poline stop into the validated band ---
+
+// All candidate OKLCH hues from the Poline journey.
+const candidateHues = (poline.colors as [number, number, number][]).map(([h, s, l]) => {
+ const [, , hue] = rgbToOklch(...hslToRgb(h, s, l));
+ return (hue + 360) % 360;
+});
+
+// Greedy selection of COUNT hues maximizing minimum pairwise hue distance,
+// seeded from the first stop (the spruce family).
+function hueDist(a: number, b: number): number {
+ const d = Math.abs(a - b) % 360;
+ return Math.min(d, 360 - d);
+}
+const selected: number[] = [candidateHues[0]];
+while (selected.length < COUNT) {
+ let best = -1;
+ let bestScore = -1;
+ for (const hue of candidateHues) {
+ const score = Math.min(...selected.map((s) => hueDist(hue, s)));
+ if (score > bestScore) {
+ bestScore = score;
+ best = hue;
+ }
+ }
+ selected.push(best);
+}
+
+// Interleave sorted hues (0,6,1,7,…) so *adjacent palette slots* are far
+// apart in hue — that adjacency is what the CVD check exercises.
+selected.sort((a, b) => a - b);
+const half = Math.ceil(COUNT / 2);
+const ordered = Array.from({ length: COUNT }, (_, i) =>
+ i % 2 === 0 ? selected[i / 2] : selected[half + (i - 1) / 2]
+);
+
+const results: { oklch: string; hex: string }[] = [];
+
+for (let i = 0; i < COUNT; i++) {
+ const hue = ordered[i];
+ const preferredL = L_MIN + ((L_MAX - L_MIN) * (i % 3)) / 2; // slight in-band variation
+
+ // Find the highest chroma (≥ floor) that stays in sRGB gamut, searching the
+ // preferred lightness first, then the rest of the band.
+ const lCandidates = [preferredL, 0.54, 0.56, 0.58, 0.6, 0.62, 0.52, 0.5];
+ let found: { L: number; C: number; rgb: [number, number, number] } | null = null;
+ for (const L of lCandidates) {
+ for (let C = C_MAX; C >= C_MIN - 0.001; C -= 0.005) {
+ const rgb = oklchToRgb(L, C, hue);
+ if (rgb) {
+ found = { L, C: Math.max(C, C_MIN), rgb };
+ break;
+ }
+ }
+ if (found) break;
+ }
+ if (!found) {
+ // pathological hue: take the floor chroma at mid lightness, sub-floor if forced
+ for (let C = C_MIN; C >= 0.02; C -= 0.005) {
+ const rgb = oklchToRgb(0.56, C, hue);
+ if (rgb) {
+ found = { L: 0.56, C, rgb };
+ break;
+ }
+ }
+ }
+ const final = found!;
+ results.push({
+ oklch: `oklch(${final.L.toFixed(2)} ${final.C.toFixed(3)} ${((hue + 360) % 360).toFixed(0)})`,
+ hex: toHex(final.rgb)
+ });
+}
+
+console.log('/* CSS tokens (same palette both themes — validated for both surfaces) */');
+results.forEach((r, i) => console.log(`\t--cat-${i}: ${r.oklch};`));
+console.log('\n# hex list for validate_palette.js');
+console.log(results.map((r) => r.hex).join(','));
diff --git a/src/app.css b/src/app.css
index f17dc8a..f0bb09a 100644
--- a/src/app.css
+++ b/src/app.css
@@ -34,6 +34,21 @@
--shadow-overlay: 0 4px 16px oklch(0 0 0 / 0.1);
+ /* per-category palette (poline-generated, validated for BOTH surfaces —
+ single set, no dark override; regenerate: deno run -A scripts/generate-category-palette.ts) */
+ --cat-0: oklch(0.54 0.14 16);
+ --cat-1: oklch(0.58 0.1 218);
+ --cat-2: oklch(0.62 0.14 61);
+ --cat-3: oklch(0.54 0.12 240);
+ --cat-4: oklch(0.58 0.115 83);
+ --cat-5: oklch(0.62 0.14 275);
+ --cat-6: oklch(0.54 0.11 104);
+ --cat-7: oklch(0.58 0.14 308);
+ --cat-8: oklch(0.62 0.14 143);
+ --cat-9: oklch(0.54 0.14 328);
+ --cat-10: oklch(0.6 0.1 191);
+ --cat-11: oklch(0.62 0.14 354);
+
/* typography */
--font-ui: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'Cascadia Mono', Consolas, monospace;
diff --git a/src/lib/components/SpendingPie.svelte b/src/lib/components/SpendingPie.svelte
new file mode 100644
index 0000000..57e2b40
--- /dev/null
+++ b/src/lib/components/SpendingPie.svelte
@@ -0,0 +1,154 @@
+
+
+{#if slices.length > 0 && total > 0}
+
+
+
+
+ {#each slices as slice, i (slice.label)}
+
(hovered = i)}
+ onpointerleave={() => (hovered = null)}
+ >
+
+ {#if slice.href}
+
{slice.label}
+ {:else}
+
{slice.label}
+ {/if}
+
{formatCents(slice.cents)}
+
{pct(slice.cents)}
+
+ {/each}
+
+
+{/if}
+
+
diff --git a/src/lib/server/services/categories.ts b/src/lib/server/services/categories.ts
index 05bd239..5244120 100644
--- a/src/lib/server/services/categories.ts
+++ b/src/lib/server/services/categories.ts
@@ -2,12 +2,16 @@ import type { DatabaseSync } from 'node:sqlite';
export type CategoryKind = 'income' | 'expense' | 'transfer';
+export const CATEGORY_PALETTE_SIZE = 12;
+
export interface Category {
id: number;
name: string;
kind: CategoryKind;
builtin: boolean;
active: boolean;
+ /** Index into the --cat-N palette tokens; assigned once at creation. */
+ colorIndex: number;
}
export function listCategories(
@@ -16,7 +20,7 @@ export function listCategories(
): Category[] {
const rows = db
.prepare(
- `SELECT id, name, kind, builtin, active FROM categories
+ `SELECT id, name, kind, builtin, active, color_index FROM categories
${options.activeOnly ? 'WHERE active = 1' : ''}
ORDER BY kind, name`
)
@@ -26,17 +30,38 @@ export function listCategories(
name: r.name as string,
kind: r.kind as CategoryKind,
builtin: r.builtin === 1,
- active: r.active === 1
+ active: r.active === 1,
+ colorIndex: (r.color_index as number | null) ?? ((r.id as number) - 1) % CATEGORY_PALETTE_SIZE
}));
}
+/** Least-used palette slot (lowest index wins ties) so colors stay distinct as long as possible. */
+function nextColorIndex(db: DatabaseSync): number {
+ const rows = db
+ .prepare('SELECT color_index, COUNT(*) AS n FROM categories GROUP BY color_index')
+ .all() as { color_index: number | null; n: number }[];
+ const usage = new Array(CATEGORY_PALETTE_SIZE).fill(0);
+ for (const row of rows) {
+ if (row.color_index != null) usage[row.color_index % CATEGORY_PALETTE_SIZE] += row.n;
+ }
+ return usage.indexOf(Math.min(...usage));
+}
+
export function createCategory(db: DatabaseSync, name: string, kind: CategoryKind): Category {
const trimmed = name.trim();
if (!trimmed) throw new Error('Category name is required.');
+ const colorIndex = nextColorIndex(db);
const result = db
- .prepare('INSERT INTO categories (name, kind, created_at) VALUES (?, ?, ?)')
- .run(trimmed, kind, new Date().toISOString());
- return { id: Number(result.lastInsertRowid), name: trimmed, kind, builtin: false, active: true };
+ .prepare('INSERT INTO categories (name, kind, color_index, created_at) VALUES (?, ?, ?, ?)')
+ .run(trimmed, kind, colorIndex, new Date().toISOString());
+ return {
+ id: Number(result.lastInsertRowid),
+ name: trimmed,
+ kind,
+ builtin: false,
+ active: true,
+ colorIndex
+ };
}
export function renameCategory(db: DatabaseSync, id: number, name: string): void {
diff --git a/src/lib/server/services/reports.ts b/src/lib/server/services/reports.ts
index a0a9f7aa38a60bfb9613f4e208089e0e1791f966..70404bf191818e8d304a73a92a4b5664f84691c5 100644
GIT binary patch
delta 128
zcmaDS|4?CrDib$na(+&Jk!M~?YQ<)0CT1qyWIeD*d?r{(hQ*4>Ux$kmNtu;`MiH_a
yg~VcoywcpH)FOo%g}l<798Cp#dj$=RBE8HMgq)^=u7aVaf~ta{(dIC=8Jqx((I`v+
delta 22
ecmaE;@J@b%D$`~wrk{+PomecGHlJjh!3h9nQV1mg
diff --git a/src/routes/(app)/reports/+page.svelte b/src/routes/(app)/reports/+page.svelte
index ba76c73..b66add3 100644
--- a/src/routes/(app)/reports/+page.svelte
+++ b/src/routes/(app)/reports/+page.svelte
@@ -1,8 +1,51 @@
Reports
@@ -38,6 +81,11 @@
+ {#if spendSlices.length > 0}
+ Spending
+
+ {/if}
+
{#each [{ title: 'Income', rows: data.report.income }, { title: 'Expenses', rows: data.report.expense }] as section (section.title)}
{#if section.rows.length > 0}
{section.title}