Something went wrong. Try again.
A self-hosted household ledger.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182/// <reference lib="deno.ns" />// 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(","));