// the seasons: palettes built from madoromi (vendor/madoromi), samhain's colours measured from
// pilot's iroshizuku inks. each season's day half is madoromi's hiru and its night half its
// yoru, which is the light-dark() pair palette.css already has.
//
// a season keeps haiku's shapes and contrast. every grey keeps its exact lightness from
// palette.css and only takes the season's grey ink as a faint tint, so the bevels stay the same
// steps. the accents are madoromi's inks, but madoromi is tuned soft (2.2:1 at its lowest) and
// docs need more, so each ink keeps its hue and chroma and only moves its lightness until it
// reaches the floors below. a season that can't fails the build.
import fs from "node:fs";
import { DEFAULT_PALETTE } from "./themes";
import path from "node:path";
export type Mode = "light" | "dark";
type Lch = [number, number, number];
interface Flavour { colours: Record<string, { hex: string; oklch: Lch }> }
interface Madoromi { flavours: Record<string, Flavour>; roles: Record<string, string>; seasons: { id: string; kanji: string; english: string }[] }
export interface Palette {
id: string;
label: string;
kanji?: string;
/** the desk and selection of each half, for the picker's chips and the browser's bar colour */
chip: Record<Mode, { desk: string; select: string }>;
}
/** the ink each season's desk and selection come from */
const SEASON_INK: Record<string, string> = { haru: "botan", natsu: "sora", aki: "beni", fuyu: "ai" };
const CODE_ROLES: Record<string, string> = {
comment: "comment", string: "string", constant: "number", keyword: "keyword",
function: "function", type: "type", property: "property", parameter: "parameter",
};
// ---- colour maths: oklch <-> srgb, and wcag contrast ----
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
function hexToLch(hex: string): Lch {
const n = hex.slice(1);
const full = n.length === 3 ? [...n].map((c) => c + c).join("") : n;
const [r, g, b] = [0, 2, 4].map((i) => toLinear(parseInt(full.slice(i, i + 2), 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return [L, Math.hypot(A, B), ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360];
}
function lchToRgb([L, C, h]: Lch): [number, number, number] {
const a = C * Math.cos((h * Math.PI) / 180), b = C * Math.sin((h * Math.PI) / 180);
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;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
];
}
const inGamut = (c: Lch) => lchToRgb(c).every((v) => v >= -1e-4 && v <= 1 + 1e-4);
/** pulls chroma in until the colour fits on screen, so the hue never moves */
function clampToGamut([L, C, h]: Lch): Lch {
if (inGamut([L, C, h])) return [L, C, h];
let lo = 0, hi = C;
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
if (inGamut([L, mid, h])) lo = mid;
else hi = mid;
}
return [L, lo, h];
}
function lchToHex(c: Lch): string {
const rgb = lchToRgb(clampToGamut([Math.min(1, Math.max(0, c[0])), c[1], c[2]]));
return "#" + rgb.map((v) => Math.round(fromLinear(Math.min(1, Math.max(0, v))) * 255).toString(16).padStart(2, "0")).join("");
}
function luminance(hex: string): number {
const n = hex.slice(1);
const full = n.length === 3 ? [...n].map((c) => c + c).join("") : n;
const [r, g, b] = [0, 2, 4].map((i) => toLinear(parseInt(full.slice(i, i + 2), 16) / 255));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrast(a: string, b: string): number {
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
return (x + 0.05) / (y + 0.05);
}
/** the ink's hue and chroma at the first lightness, walking away from the background, that
* reaches the target contrast against every one of them */
function fit(ink: Lch, from: number, against: string[], target: number, mode: Mode, what: string): string {
const step = mode === "light" ? -0.005 : 0.005;
for (let L = from; L >= 0 && L <= 1; L += step) {
const hex = lchToHex([L, ink[1], ink[2]]);
if (against.every((bg) => contrast(hex, bg) >= target)) return hex;
}
throw new Error(`${what} can't reach ${target}:1`);
}
// ---- reading the two sources ----
/** palette.css's light-dark() pairs: haiku, and the base every season tints */
function haiku(): Record<string, Record<Mode, string>> {
const css = fs.readFileSync(path.join(process.cwd(), "src/styles/palette.css"), "utf8");
const out: Record<string, Record<Mode, string>> = {};
for (const [, name, light, dark, both] of css.matchAll(/^\s*(--[\w-]+): (?:light-dark\((#[0-9a-f]{3,6}), (#[0-9a-f]{3,6})\)|(#[0-9a-f]{3,6}));/gim)) {
out[name] = { light: light ?? both, dark: dark ?? both };
}
return out;
}
const madoromi = (): Madoromi =>
JSON.parse(fs.readFileSync(path.join(process.cwd(), "vendor/madoromi/madoromi.json"), "utf8"));
// ---- one season ----
function season(id: string, m: Madoromi, base: Record<string, Record<Mode, string>>): Record<string, Record<Mode, string>> {
const out: Record<string, Record<Mode, string>> = {};
for (const mode of ["light", "dark"] as Mode[]) {
const flavour = m.flavours[`${id}-${mode === "light" ? "hiru" : "yoru"}`];
const ink = (name: string) => flavour.colours[name].oklch;
const grey = ink("kami");
// a token tinted in one half only keeps haiku's colour in the other
const set = (name: string, hex: string) => ((out[name] ??= { ...base[name] })[mode] = hex);
const at = (name: string) => out[name]?.[mode] ?? base[name][mode];
/** the haiku colour's lightness with another hue and chroma */
const relight = (name: string, hue: number, chroma: number) => lchToHex([hexToLch(base[name][mode])[0], chroma, hue]);
// greys: haiku's lightness, the season's grey ink as a tint, fading out at white and black.
// haiku's warm paper edge (0.042) counts as one too
for (const [name, v] of Object.entries(base)) {
const [L, C] = hexToLch(v[mode]);
if (C > 0.045) continue;
const fade = Math.min(1, (1 - L) * 60, L * 6);
set(name, lchToHex([L, (mode === "light" ? 0.012 : 0.016) * fade, grey[2]]));
}
const own = ink(SEASON_INK[id]);
// about as saturated as haiku's own desk, so a night desk stays dark instead of glowing
for (const n of ["--desk", "--desk-glow", "--desk-deep"]) set(n, relight(n, own[2], Math.min(own[1], hexToLch(base[n][mode])[1] * 1.25)));
for (const n of ["--hover", "--press", "--ico-hover"]) set(n, relight(n, own[2], n === "--hover" ? 0.03 : 0.05));
// the selection carries white text, so its middle stop is fitted and the ends follow it
const mid = fit(own, hexToLch(base["--select-mid"][mode])[0], [at("--on-select")], 4.5, "light", `${id} ${mode} selection`);
const midL = hexToLch(mid)[0];
set("--select-mid", mid);
set("--select", lchToHex([midL + 0.12, own[1], own[2]]));
set("--select-end", lchToHex([midL - 0.07, own[1], own[2]]));
const link = ink(m.roles.link);
set("--link", fit(link, hexToLch(base["--link"][mode])[0], [at("--paper")], 7, mode, `${id} ${mode} links`));
set("--link-chrome", fit(link, hexToLch(base["--link-chrome"][mode])[0], [at("--chrome"), at("--chrome-hover")], 7, mode, `${id} ${mode} links on chrome`));
set("--focus", fit(link, hexToLch(base["--focus"][mode])[0], [at("--field")], 3, mode, `${id} ${mode} focus ring`));
const match = ink(m.roles.match), gold = ink("yamabuki");
for (const n of ["--mark", "--mark-edge", "--mark-on-select"]) set(n, relight(n, match[2], Math.min(match[1], 0.12)));
set("--flash", relight("--flash", gold[2], 0.08));
// the tab is a pale wash of the season's own ink, like haiku's yellow is of its gold
for (const n of ["--tab", "--tab-mid", "--tab-end", "--tab-hi", "--tab-lo"]) {
const [L, C] = hexToLch(base[n][mode]);
set(n, lchToHex([L, Math.min(C, own[1]), own[2]]));
}
set("--tab-text", relight("--tab-text", own[2], 0.04));
const tabText = contrast(at("--tab-text"), at("--tab-end"));
if (tabText < 7) throw new Error(`${id} ${mode}: the tab's text is ${tabText.toFixed(2)}:1, under 7:1`);
for (const [token, role] of Object.entries(CODE_ROLES)) {
const c = ink(m.roles[role]);
set(`--code-${token}`, fit(c, c[0], [at("--well")], 4.5, mode, `${id} ${mode} code ${token}`));
}
// and the floors for what came out of the greys
for (const [fg, bg, min] of [["--text", "--paper", 7], ["--text", "--chrome", 7], ["--dim", "--chrome", 3.5], ["--faint", "--sheet", 3]] as const) {
const c = contrast(at(fg), at(bg));
if (c < min) throw new Error(`${id} ${mode}: ${fg} on ${bg} is ${c.toFixed(2)}:1, under ${min}:1`);
}
}
return out;
}
type Tokens = Record<string, Record<Mode, string>>;
let built: { css: string; palettes: Palette[]; tokens: Map<string, Tokens> } | undefined;
/** the seasons' css, one `:root[data-palette]` block each, and the list for the picker */
export function palettes(): { css: string; palettes: Palette[]; tokens: Map<string, Tokens> } {
if (built) return built;
const m = madoromi(), base = haiku();
const list: Palette[] = [{ id: "haiku", label: "haiku", chip: chip(base) }];
const all = new Map([["haiku", base]]);
let css = "";
for (const s of m.seasons) {
const tokens = season(s.id, m, base);
css += `${paletteSelector(s.id)} {\n` + Object.entries(tokens).map(([n, v]) => ` ${n}: light-dark(${v.light}, ${v.dark});`).join("\n") + "\n}\n";
list.push({ id: s.id, label: s.id, kanji: s.kanji, chip: chip({ ...base, ...tokens }) });
all.set(s.id, { ...base, ...tokens });
}
return (built = { css, palettes: list, tokens: all });
}
/** every colour of one palette's half, by token name */
export function colours(id: string, mode: Mode): Record<string, string> {
const tokens = palettes().tokens.get(id);
if (!tokens) throw new Error(`no palette called ${id}`);
return Object.fromEntries(Object.entries(tokens).map(([n, v]) => [n, v[mode]]));
}
/** a palette's rules apply when it's picked, and the default's also when nothing is */
export function paletteSelector(id: string): string {
if (id === "haiku") return ":root";
const picked = `:root[data-palette="${id}"]`;
return id === DEFAULT_PALETTE ? `:root:not([data-palette]), ${picked}` : picked;
}
function chip(t: Record<string, Record<Mode, string>>): Palette["chip"] {
return {
light: { desk: t["--desk"].light, select: t["--select-mid"].light },
dark: { desk: t["--desk"].dark, select: t["--select-mid"].dark },
};
}