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 arcs as arc, i (arc.slice.label)} + (hovered = i)} + onpointerleave={() => (hovered = null)} + /> + {/each} + + +
+ {#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}