Skip to the content

Capsid menu

tokens/palette.mjs

517 lines and 76 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. It is in an import cycle. This file on GitHub.

Functions

The functions of palette.mjs
FunctionLinesComplexityCoveredCRAPMutation
arg49 to 491100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:49>49 to 491100%OkCRAP 1No data: mutation testing has not run on this repo yet
hexArg50 to 543100%OkCRAP 3No data: mutation testing has not run on this repo yet
hexToRgb63 to 631100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:63>63 to 631100%OkCRAP 1No data: mutation testing has not run on this repo yet
toLinear64 to 642100%OkCRAP 2No data: mutation testing has not run on this repo yet
toGamma65 to 652100%OkCRAP 2No data: mutation testing has not run on this repo yet
rgbToOklab67 to 791100%OkCRAP 1No data: mutation testing has not run on this repo yet
oklabToRgb81 to 901100%OkCRAP 1No data: mutation testing has not run on this repo yet
inGamut92 to 921100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:92>92 to 922100%OkCRAP 2No data: mutation testing has not run on this repo yet
oklch95 to 1013100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:98>98 to 981100%OkCRAP 1No data: mutation testing has not run on this repo yet
luminance103 to 1061100%OkCRAP 1No data: mutation testing has not run on this repo yet
contrast109 to 1131100%OkCRAP 1No data: mutation testing has not run on this repo yet
tune117 to 1263100%OkCRAP 3No data: mutation testing has not run on this repo yet
darkest136 to 1361100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:136>136 to 1362100%OkCRAP 2No data: mutation testing has not run on this repo yet
lightest137 to 1371100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:137>137 to 1372100%OkCRAP 2No data: mutation testing has not run on this repo yet
seedLch155 to 1581100%OkCRAP 1No data: mutation testing has not run on this repo yet
scale161 to 1711100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:163>163 to 1706100%WatchCRAP 6No data: mutation testing has not run on this repo yet
rgbList173 to 1731100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:173>173 to 1731100%OkCRAP 1No data: mutation testing has not run on this repo yet
elevation178 to 1934100%OkCRAP 4No data: mutation testing has not run on this repo yet
c182 to 1821100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:196>196 to 1961100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:197>197 to 1971100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:198>198 to 1981100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:199>199 to 1991100%OkCRAP 1No data: mutation testing has not run on this repo yet
chartTokens207 to 2201100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:209>209 to 2091100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:211>211 to 2141100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:215>215 to 2181100%OkCRAP 1No data: mutation testing has not run on this repo yet
familyTheme222 to 26821100%WatchCRAP 21No data: mutation testing has not run on this repo yet
<anonymous:264>264 to 2641100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:275>275 to 2751100%OkCRAP 1No data: mutation testing has not run on this repo yet
pairs281 to 3228100%WatchCRAP 8No data: mutation testing has not run on this repo yet
need283 to 2861100%OkCRAP 1No data: mutation testing has not run on this repo yet
failures324 to 328133%OkCRAP 1.3No data: mutation testing has not run on this repo yet
<anonymous:326>326 to 32610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:327>327 to 32710%OkCRAP 2No data: mutation testing has not run on this repo yet
lines332 to 33620%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:333>333 to 33310%OkCRAP 2No data: mutation testing has not run on this repo yet
note338 to 341250%OkCRAP 2.5No data: mutation testing has not run on this repo yet
renderFamily344 to 36415%OkCRAP 1.9No data: mutation testing has not run on this repo yet
renderScoped367 to 39014%OkCRAP 1.9No data: mutation testing has not run on this repo yet
enarratioTheme398 to 412120%OkCRAP 1.5No data: mutation testing has not run on this repo yet
scheme399 to 40810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:405>405 to 40510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:406>406 to 40610%OkCRAP 2No data: mutation testing has not run on this repo yet
renderEnarratioTheme414 to 423110%OkCRAP 1.7No data: mutation testing has not run on this repo yet
renderEmailColours430 to 44538%WatchCRAP 10.1No data: mutation testing has not run on this repo yet
<anonymous:436>436 to 43610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:441>441 to 44110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:442>442 to 44210%OkCRAP 2No data: mutation testing has not run on this repo yet
active448 to 45250%WatchCRAP 30No data: mutation testing has not run on this repo yet
themes455 to 4591100%OkCRAP 1No data: mutation testing has not run on this repo yet
files462 to 47756%WatchCRAP 25.6No data: mutation testing has not run on this repo yet
target479 to 4792100%OkCRAP 2No data: mutation testing has not run on this repo yet
check481 to 510114%CriticalCRAP 118.7No data: mutation testing has not run on this repo yet
<anonymous:484>484 to 48410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:485>485 to 48510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:492>492 to 49210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:505>505 to 50510%OkCRAP 2No data: mutation testing has not run on this repo yet
report513 to 5225100%OkCRAP 5No data: mutation testing has not run on this repo yet
<anonymous:521>521 to 5211100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:521>521 to 5214100%OkCRAP 4No data: mutation testing has not run on this repo yet
<anonymous:521>521 to 5211100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:532>532 to 53210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:535>535 to 53510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:550>550 to 55010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:550>550 to 5501100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:551>551 to 55110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:558>558 to 55810%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports tokens/chart.mjs. Imported by tokens/chart.mjs, tokens/scale.mjs, tokens/sites.mjs.

Source

// The design tokens, generated from one seed colour per family. `node tokens/palette.mjs`
// writes tokens/colour.css; `--check` regenerates it in memory, refuses any difference from
// the committed file, and checks every text and control pair against WCAG 2.2 (4.5:1 for
// text, 3:1 for a control's boundary), in both themes. `npm run check` runs the check, so CI
// refuses a hand-edited hex and a pair that fell below the bar.
//
// The family scale (rulings.md, rule 14, locked 2026-10-01): every brand colour is a
// 12-step OKLCH scale with the same lightness roles, the Radix Colors step roles (1 and 2
// backgrounds, 3 to 5 element backgrounds, 6 to 8 borders, 9 the solid accent, 10 its hover,
// 11 low-contrast text, 12 high-contrast text). Step 9 is the seed exactly, in both themes.
// Step 11 in the light theme may be a fixed anchor: the purple family keeps #4F2D7F there.
// Every other step takes the seed's hue at a fixed lightness and a fixed share of the
// seed's chroma, so two families with the same seed lightness and chroma (purple #8c5fd2
// and Foxing's #cc4f0c, both L 0.586, C 0.172) differ only in hue. Neutrals carry a small
// chroma at the seed's hue; status hues are fixed and tuned by bisection against the
// hardest surface each sits on. The OKLab conversion follows Bjorn Ottosson's reference.
//
//   node tokens/palette.mjs                       colour.css (purple), family-fox.css, family-teal.css, family-navy.css, family-forest.css
//   node tokens/palette.mjs --family=fox --out=f  Foxing's family as a root theme into f
//   node tokens/palette.mjs --seed=#RRGGBB [--anchor=#RRGGBB] --out=f
//   node tokens/palette.mjs --report              every pair, every family, both themes
//
// tokens/colour.css is the purple family on :root (the default). tokens/family-fox.css,
// tokens/family-teal.css, tokens/family-navy.css and tokens/family-forest.css carry the same tokens scoped to
// [data-family="fox"], [data-family="teal"], [data-family="navy"] and [data-family="forest"], so one element (the root, or a wrapper) can switch family for its
// subtree. Both themes follow the same pattern as colour.css (prefers-color-scheme, then
// [data-theme]). Everything inside a family block is a literal value, never a var() chain,
// so a family on a wrapper does not inherit the root family's already-resolved values.
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { chartChecks, companionColours, labelOn, rampColours } from "./chart.mjs";

export const FAMILIES = {
  // defaultTheme is the theme checked first (rule 15). Purple follows the viewer's system
  // setting, so its light theme is checked first as the default with no preference.
  purple: { seed: "#8c5fd2", anchor: "#4F2D7F", defaultTheme: "light", note: "Capsid Portal, Carrel, dustinedwards.info, Germomics (rule 14)" },
  fox: { seed: "#cc4f0c", anchor: null, defaultTheme: "light", note: "Foxing, light by default (rules 14 and 15)" },
  teal: { seed: "#008489", anchor: null, defaultTheme: "dark", note: "Foxhound, dark by default (rule 15)" },
  // txasm's navy: the brand #002868 is too dark to be step 9 (it fails 3:1 as a focus ring on
  // the dark surface), so it is the light theme's step 11 anchor (links, headings, accent text)
  // and step 9 is the same hue at the lightness and strength of purple and fox (OKLCH L 0.587, C 0.172).
  navy: { seed: "#3978e0", anchor: "#002868", defaultTheme: "light", note: "txasm, light by default; the brand navy #002868 is step 11 in light" },
  // julieedwards.info's forest: the Bluff Dale ISD green #15532E is step 11 in light, as navy's
  // #002868 is. Step 9 is its hue at purple and fox's lightness (OKLCH L 0.587); green runs out of
  // sRGB at chroma 0.15 there, so it takes that, the strongest this green can be.
  forest: { seed: "#06954f", anchor: "#15532E", defaultTheme: "light", note: "julieedwards.info, light by default; the school green #15532E is step 11 in light" },
};
const OUT = fileURLToPath(new URL("./colour.css", import.meta.url));
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const hexArg = (name) => {
  const v = arg(name);
  if (v !== undefined && !/^#[0-9a-f]{6}$/i.test(v)) throw new Error(`--${name} must be #RRGGBB, got ${v}`);
  return v;
};
const familyArg = arg("family");
if (familyArg !== undefined && !FAMILIES[familyArg]) throw new Error(`--family must be one of ${Object.keys(FAMILIES).join(", ")}`);
const seedArg = hexArg("seed");
const anchorArg = hexArg("anchor");
const outArg = arg("out");

// ---- colour math ---------------------------------------------------------------------

const hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);

function rgbToOklab([r, g, b]) {
  r = toLinear(r);
  g = toLinear(g);
  b = toLinear(b);
  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);
  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ];
}

function oklabToRgb([L, a, b]) {
  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,
  ].map(toGamma);
}

const inGamut = (rgb) => rgb.every((c) => c >= -0.0005 && c <= 1.0005);

// A hex colour for oklch(L C H); the chroma is pulled in until the colour fits sRGB.
export function oklch(L, C, H) {
  for (let c = C; c >= 0; c -= 0.002) {
    const rgb = oklabToRgb([L, c * Math.cos((H * Math.PI) / 180), c * Math.sin((H * Math.PI) / 180)]);
    if (inGamut(rgb)) return "#" + rgb.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("");
  }
  throw new Error(`no sRGB colour for oklch(${L} ${C} ${H})`);
}

const luminance = (hex) => {
  const [r, g, b] = hexToRgb(hex).map(toLinear);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

// WCAG 2.x contrast ratio between two hex colours.
export function contrast(a, b) {
  const x = luminance(a);
  const y = luminance(b);
  return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}

// The lightness closest to `from` at which oklch(L C H) reaches `target` against `bg`.
// `to` is the high-contrast end; the search keeps a lightness that meets the target.
function tune(C, H, bg, target, from, to) {
  let lo = from;
  let hi = to;
  for (let i = 0; i < 40; i++) {
    const mid = (lo + hi) / 2;
    if (contrast(oklch(mid, C, H), bg) >= target) hi = mid;
    else lo = mid;
  }
  return oklch(hi, C, H);
}


// ---- the scales ----------------------------------------------------------------------

const NEUTRAL_CHROMA = 0.012;
// Status hues are kept; only lightness is tuned. info also colours a running job.
const STATUS = { ok: 150, warn: 78, crit: 26, info: 255 };
const TEXT = 4.6; // a little above 4.5, so rounding to hex cannot fall under it
const CONTROL = 3.0;
const darkest = (...hexes) => hexes.reduce((a, b) => (luminance(b) < luminance(a) ? b : a));
const lightest = (...hexes) => hexes.reduce((a, b) => (luminance(b) > luminance(a) ? b : a));

// ---- the family scale (rule 14) ----------------------------------------------------------

// The same lightness roles for every family: [L, share of the seed's chroma]. Step 9 is the
// seed; step 10 is the seed darkened in light and lightened in dark; step 11 in light may be
// the family's anchor.
const ROLES = {
  light: [
    [0.993, 0.02], [0.982, 0.06], [0.96, 0.14], [0.935, 0.22], [0.905, 0.3], [0.865, 0.36],
    [0.81, 0.46], [0.735, 0.62], null, [-0.05, 1], [0.42, 0.75], [0.22, 0.25],
  ],
  dark: [
    [0.16, 0.06], [0.19, 0.08], [0.24, 0.18], [0.28, 0.26], [0.32, 0.32], [0.37, 0.36],
    [0.44, 0.44], [0.53, 0.6], null, [0.05, 1], [0.8, 0.55], [0.94, 0.12],
  ],
};

function seedLch(hex) {
  const [L, a, b] = rgbToOklab(hexToRgb(hex));
  return { L, C: Math.hypot(a, b), H: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 };
}

// The 12 steps of one family in one theme, as hex, index 0 being step 1.
export function scale(seed, anchor = null, scheme = "light") {
  const { L, C, H } = seedLch(seed);
  return ROLES[scheme].map((role, i) => {
    const step = i + 1;
    if (step === 9) return seed.toLowerCase();
    if (step === 11 && scheme === "light" && anchor) return anchor.toLowerCase();
    const [l, share] = role;
    const lightness = step === 10 ? L + l : l;
    return oklch(lightness, C * share, H);
  });
}

const rgbList = (hex) => hexToRgb(hex).map((v) => Math.round(v * 255)).join(", ");

// Elevation in the theme's own ink: Tailwind's xs, sm, md and lg shadows (the shadow scale
// shadcn uses), tinted from --text in light and heavier on a dark ground, where a shadow
// reads less. `ring` is the translucent focus glow (shadcn's ring-3 at 50%).
function elevation(t, scheme) {
  const dark = scheme === "dark";
  const ink = dark ? "0, 0, 0" : rgbList(t.text);
  const a = dark ? [0.3, 0.4, 0.45, 0.55] : [0.05, 0.1, 0.1, 0.1];
  const c = (n) => `rgba(${ink}, ${a[n]})`;
  return {
    "shadow-xs": `0 1px 2px 0 ${c(0)}`,
    "shadow-s": `0 1px 3px 0 ${c(1)}, 0 1px 2px -1px ${c(1)}`,
    "shadow-m": `0 4px 6px -1px ${c(2)}, 0 2px 4px -2px ${c(2)}`,
    "shadow-l": `0 10px 15px -3px ${c(3)}, 0 4px 6px -4px ${c(3)}`,
    "ring-soft": `rgba(${rgbList(t.ring)}, 0.5)`,
    "shadow-ring": `0 0 0 var(--ring-width) rgba(${rgbList(t.ring)}, 0.5)`,
    shadow: "var(--shadow-l)",
    scrim: `rgba(${ink}, ${dark ? "0.55" : "0.28"})`,
  };
}

export const CHART_ORDER = [
  ...Array.from({ length: 8 }, (_, i) => `series-${i + 1}`),
  ...Array.from({ length: 8 }, (_, i) => `series-text-${i + 1}`),
  ...Array.from({ length: 5 }, (_, i) => `ramp-${i + 1}`),
  ...Array.from({ length: 5 }, (_, i) => `ramp-text-${i + 1}`),
];

export const ELEVATION_ORDER = ["shadow-xs", "shadow-s", "shadow-m", "shadow-l", "ring-soft", "shadow-ring", "shadow", "scrim"];

// The chart palette (tokens/chart.mjs): eight series colours, the family's five-step ramp, and
// the label colour that reads best on each. The three seeds are step 9 of the three families,
// so a series colour is the family colour itself.
function chartTokens(t, scheme, { C, H }) {
  const out = {};
  const series = [FAMILIES.purple.seed, FAMILIES.fox.seed, FAMILIES.teal.seed, ...companionColours(scheme)].map((h) => h.toLowerCase());
  const ramp = rampColours(H, C, scheme);
  series.forEach((hex, i) => {
    out[`series-${i + 1}`] = hex;
    out[`series-text-${i + 1}`] = labelOn(hex, t.text, t.surface);
  });
  ramp.forEach((hex, i) => {
    out[`ramp-${i + 1}`] = hex;
    out[`ramp-text-${i + 1}`] = labelOn(hex, t.text, t.surface);
  });
  return out;
}

function familyTheme(seed, anchor, scheme) {
  const { H } = seedLch(seed);
  const s = scale(seed, anchor, scheme);
  const t = {};
  const dark = scheme === "dark";
  // Neutrals at the seed's hue.
  t.ground = oklch(dark ? 0.16 : 0.985, NEUTRAL_CHROMA, H);
  t.surface = oklch(dark ? 0.205 : 0.995, dark ? NEUTRAL_CHROMA : 0.003, H);
  t.raised = oklch(dark ? 0.245 : 0.975, dark ? NEUTRAL_CHROMA : 0.008, H);
  t.sunken = oklch(dark ? 0.135 : 0.93, NEUTRAL_CHROMA, H);
  t.line = oklch(dark ? 0.32 : 0.9, NEUTRAL_CHROMA, H);
  t["line-strong"] = tune(NEUTRAL_CHROMA, H, t.raised, CONTROL, dark ? 0.2 : 0.9, dark ? 0.9 : 0.3);
  t.text = oklch(dark ? 0.95 : 0.22, dark ? 0.008 : 0.03, H);
  t.muted = dark ? tune(0.015, H, t.surface, 6.0, 0.2, 0.95) : tune(0.02, H, t.ground, 5.5, 0.9, 0.2);
  t.dim = dark ? tune(0.015, H, t.raised, TEXT, 0.2, 0.95) : tune(0.02, H, t.sunken, TEXT, 0.9, 0.2);
  // The accent family, from the scale's roles. Step 9 is the brand colour (fills, the focus
  // ring, large shapes and icons, held to 3:1); step 11 is accent text (4.5:1); the primary
  // button fills with step 10, hovers to step 11, and carries --primary-fg: the surface tone
  // in light, the ground tone in dark (a lighter fill takes a dark label).
  t.accent = s[8];
  t["accent-hover"] = s[11]; // accent text on hover: step 12
  t["accent-text"] = s[10];
  t["accent-soft"] = s[3];
  t.sel = s[2];
  t["accent-line"] = tune(0.1, H, t.sel, CONTROL, dark ? 0.2 : 0.9, dark ? 0.9 : 0.3);
  t.primary = s[9];
  t["primary-hover"] = s[10];
  t["primary-fg"] = dark ? t.ground : t.surface;
  // The on state: a checked box or radio, an on switch. The primary fill by default; a site may
  // set its own (Foxing's sky blue, its Rule 05), checked by the pairs below with the tick
  // (--primary-fg) and the switch's thumb (--surface) on it.
  t.on = t.primary;
  t["on-hover"] = t["primary-hover"];
  t.ring = t.accent;
  for (const [k, hue] of Object.entries(STATUS)) {
    t[`${k}-soft`] = oklch(dark ? 0.28 : 0.945, 0.05, hue);
    t[k] = dark
      ? tune(0.13, hue, lightest(t[`${k}-soft`], t["accent-soft"], t.sel), TEXT, 0.3, 0.95)
      : tune(0.14, hue, darkest(t[`${k}-soft`], t["accent-soft"], t.sel), TEXT, 0.9, 0.2);
  }
  t.nodata = t.dim;
  t["nodata-soft"] = oklch(dark ? 0.27 : 0.94, 0.006, H);
  s.forEach((hex, i) => (t[`accent-${i + 1}`] = hex));
  Object.assign(t, elevation(t, scheme));
  Object.assign(t, chartTokens(t, scheme, seedLch(seed)));
  return t;
}

export const FAMILY_ORDER = [
  "ground", "surface", "raised", "sunken", "line", "line-strong", "text", "muted", "dim",
  "accent", "accent-hover", "accent-text", "accent-soft", "accent-line", "sel",
  "primary", "primary-hover", "primary-fg", "on", "on-hover", "ring",
  "ok", "ok-soft", "warn", "warn-soft", "crit", "crit-soft", "info", "info-soft", "nodata", "nodata-soft",
  ...Array.from({ length: 12 }, (_, i) => `accent-${i + 1}`),
];

// ---- the pairs every theme must pass ---------------------------------------------------

// Every pair a theme must pass, with its ratio: { fg, bg, min, what, ratio, pass }.
export function pairs(t) {
  const out = [];
  const need = (fg, bg, min, what) => {
    const ratio = contrast(t[fg], t[bg]);
    out.push({ fg, bg, min, what, ratio, pass: ratio >= min });
  };
  // Rulings rule 18: accent text is step 11 (--accent-text) and its hover step 12; the
  // primary button fill is step 10 with its label --primary-fg; step 9 (--accent, the brand
  // colour) is held to 3:1 for the focus ring and large fills and never carries text.
  for (const bg of ["surface", "ground", "raised"]) {
    for (const fg of ["text", "muted", "dim", "accent-text", "accent-hover"]) need(fg, bg, 4.5, "text");
    for (const s of Object.keys(STATUS)) need(s, bg, 4.5, "status word");
  }
  need("text", "sel", 4.5, "selected row title");
  need("muted", "sel", 4.5, "selected row detail");
  need("text", "sunken", 4.5, "command block");
  need("dim", "sunken", 4.5, "command block note");
  for (const bg of ["surface", "raised"]) need("line-strong", bg, 3, "control border");
  need("accent-line", "sel", 3, "selection ring");
  for (const bg of ["surface", "ground", "raised"]) {
    need("ring", bg, 3, "focus ring");
    need("accent", bg, 3, "brand colour as a large fill or icon");
    need("primary", bg, 3, "primary button fill against its surroundings");
  }
  need("primary-hover", "surface", 3, "primary button fill (hover) against its surroundings");
  for (const bg of ["surface", "ground", "raised"]) need("on", bg, 3, "checked box or on switch against its surroundings");
  need("on-hover", "surface", 3, "on switch (hover) against its surroundings");
  need("primary-fg", "on", 3, "tick on a checked box");
  need("surface", "on", 3, "thumb of an on switch");
  need("surface", "on-hover", 3, "thumb of an on switch (hover)");
  for (const s of Object.keys(STATUS)) {
    need(s, `${s}-soft`, 4.5, "pill");
    need(s, "accent-soft", 4.5, "count on the active menu item");
    need(s, "sel", 4.5, "status word on a selected row");
  }
  need("accent-text", "accent-soft", 4.5, "active item");
  need("accent-text", "sel", 4.5, "deep accent text");
  need("primary-fg", "primary", 4.5, "primary button label");
  need("primary-fg", "primary-hover", 4.5, "primary button label (hover)");
  need("surface", "crit", 4.5, "badge label");
  return out;
}

export function failures(t) {
  return pairs(t)
    .filter((p) => !p.pass)
    .map((p) => `${p.what}: --${p.fg} ${t[p.fg]} on --${p.bg} ${t[p.bg]} is ${p.ratio.toFixed(2)}:1, needs ${p.min}:1`);
}

// ---- the files ---------------------------------------------------------------------------

function lines(t, indent, scheme, withScheme) {
  const out = [...FAMILY_ORDER, ...CHART_ORDER, ...ELEVATION_ORDER].map((k) => `${indent}--${k}: ${t[k]};`);
  if (withScheme) out.push(`${indent}color-scheme: ${scheme};`);
  return out.join("\n");
}

const note = (seed, anchor) => {
  const { L, C, H } = seedLch(seed);
  return `step 9 ${seed.toLowerCase()} (oklch ${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})${anchor ? `, step 11 anchored at ${anchor.toLowerCase()}` : ""}`;
};

// The default family on the document root: colour.css.
export function renderFamily(seed, anchor = null) {
  const l = familyTheme(seed, anchor, "light");
  const d = familyTheme(seed, anchor, "dark");
  return `/* Generated by tokens/palette.mjs: ${note(seed, anchor)}.
   Do not edit: change the script and run \`npm run tokens\`. \`npm run check\` refuses a file
   that differs from what the script writes, and any pair below WCAG 2.2's bar. */
:root {
${lines(l, "  ", "light", true)}
  --ui: "Schibsted Grotesk", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  --mono: "Martian Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
${lines(d, "    ", "dark", true)}
  }
}
:root[data-theme="dark"] {
${lines(d, "  ", "dark", true)}
}
`;
}

// A family scoped to [data-family="name"], on the root or on any wrapper, in both themes.
export function renderScoped(name, seed, anchor = null) {
  const l = familyTheme(seed, anchor, "light");
  const d = familyTheme(seed, anchor, "dark");
  const f = `[data-family="${name}"]`;
  return `/* Generated by tokens/palette.mjs: the ${name} family, ${note(seed, anchor)}.
   Put data-family="${name}" on the root or on any element to switch its subtree.
   Import after tokens.css. Do not edit: change the script and run \`npm run tokens\`. */
${f} {
${lines(l, "  ", "light", false)}
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) ${f},
  :root:not([data-theme="light"])${f} {
${lines(d, "    ", "dark", false)}
  }
}
:root[data-theme="dark"] ${f},
:root[data-theme="dark"]${f},
[data-cap-theme="dark"] ${f},
[data-cap-theme="dark"]${f} {
${lines(d, "  ", "dark", false)}
}
`;
}

// The Enarratio theme (0.3): the purple family's tokens, as the hex values Enarratio's
// checkTheme measures, written as a plain module so an app can `import { capsomerTheme } from
// "capsomer/enarratio-theme"` and hand it to Enarratio's stylesheet(), with no dependency of
// Capsomer on Enarratio (and none of Enarratio on Capsomer). The same tokens feed
// css/enarratio.css, which maps the custom properties for an app that loads Capsomer's CSS.
const FONT_STACK = '"Schibsted Grotesk", "Segoe UI", "Helvetica Neue", Arial, sans-serif';
export function enarratioTheme(family = FAMILIES.purple) {
  const scheme = (t) => ({
    background: t.surface,
    text: t.text,
    mutedText: t.muted,
    grid: t.line,
    focus: t.ring,
    series: Array.from({ length: 8 }, (_, i) => t[`series-${i + 1}`]),
    sequential: Array.from({ length: 5 }, (_, i) => t[`ramp-${i + 1}`]),
    status: { good: t.ok, warning: t.warn, bad: t.crit, unknown: t.nodata },
  });
  const l = familyTheme(family.seed, family.anchor, "light");
  const d = familyTheme(family.seed, family.anchor, "dark");
  return { name: "capsomer", fonts: { body: FONT_STACK, numeric: FONT_STACK }, gridlines: "y", light: scheme(l), dark: scheme(d) };
}

export function renderEnarratioTheme() {
  const theme = enarratioTheme();
  return `// Generated by tokens/palette.mjs from Capsomer's tokens (the purple family, both themes).
// Do not edit: change the script and run \`npm run tokens\`. \`npm run check\` refuses a file that
// differs, and CI runs Enarratio's own checkTheme on it (test/unit/enarratio-theme.test.mjs).
// Hand it to Enarratio: \`stylesheet(capsomerTheme)\`, or \`baseStylesheet()\` with css/enarratio.css.
/** @type {import("./enarratio-theme.d.mts").EnarratioTheme} */
export const capsomerTheme = ${JSON.stringify(theme, null, 2)};
`;
}

// The colours an email uses, for every family in both themes, as literal hexes. Email clients
// ignore custom properties, so components/email/colours.ts carries the values (the same ones
// as the site, from the same generator) and the email code stays free of node:fs.
const EMAIL_KEYS = ["ground", "surface", "line", "text", "muted", "accent-text", "primary", "primary-fg", "ok", "ok-soft", "warn", "warn-soft", "crit", "crit-soft", "info", "info-soft"];

export function renderEmailColours() {
  const out = {};
  for (const [name, f] of Object.entries(FAMILIES)) {
    out[name] = {};
    for (const scheme of ["light", "dark"]) {
      const t = familyTheme(f.seed, f.anchor, scheme);
      out[name][scheme] = Object.fromEntries(EMAIL_KEYS.map((k) => [k, t[k]]));
    }
  }
  return `// Generated by tokens/palette.mjs from Capsomer's tokens (every family, both themes).
// Do not edit: change the script and run \`npm run tokens\`. \`npm run check\` refuses a file that differs.
export type EmailFamily = ${Object.keys(FAMILIES).map((n) => JSON.stringify(n)).join(" | ")};
export type EmailColours = Record<${EMAIL_KEYS.map((n) => JSON.stringify(n)).join(" | ")}, string>;
export const emailColours: Record<EmailFamily, { light: EmailColours; dark: EmailColours }> = ${JSON.stringify(out, null, 2)};
`;
}

// The family this run uses: --seed and --anchor, else --family, else purple.
function active() {
  if (seedArg) return { seed: seedArg, anchor: anchorArg ?? null };
  const f = FAMILIES[familyArg ?? "purple"];
  return { seed: f.seed, anchor: anchorArg ?? f.anchor };
}

// Both themes' tokens and every asserted pair with its ratio, for the site's contrast grid.
export function themes({ seed, anchor } = active()) {
  const l = familyTheme(seed, anchor, "light");
  const d = familyTheme(seed, anchor, "dark");
  return { seed, anchor, order: FAMILY_ORDER, elevation: ELEVATION_ORDER, light: { tokens: l, pairs: pairs(l) }, dark: { tokens: d, pairs: pairs(d) } };
}

// The files this script owns: [path relative to tokens/, text].
export function files() {
  if (outArg || seedArg || familyArg) {
    const a = active();
    return [[outArg ?? "colour.css", renderFamily(a.seed, a.anchor)]];
  }
  const p = FAMILIES.purple;
  return [
    ["colour.css", renderFamily(p.seed, p.anchor)],
    ["family-fox.css", renderScoped("fox", FAMILIES.fox.seed, FAMILIES.fox.anchor)],
    ["family-teal.css", renderScoped("teal", FAMILIES.teal.seed, FAMILIES.teal.anchor)],
    ["family-navy.css", renderScoped("navy", FAMILIES.navy.seed, FAMILIES.navy.anchor)],
    ["family-forest.css", renderScoped("forest", FAMILIES.forest.seed, FAMILIES.forest.anchor)],
    ["enarratio-theme.mjs", renderEnarratioTheme()],
    ["../components/email/colours.ts", renderEmailColours()],
  ];
}

const target = (rel) => (outArg ? outArg : fileURLToPath(new URL(`./${rel}`, import.meta.url)));

export function check() {
  const problems = [];
  const t = themes();
  problems.push(...failures(t.light.tokens).map((p) => `light: ${p}`));
  problems.push(...failures(t.dark.tokens).map((p) => `dark: ${p}`));
  // The chart palette, for every family and both themes (tokens/chart.mjs): contrast against
  // the chart's background, every pair apart under the colour-vision deficiencies, clear of
  // the status colours, the ramp ordered.
  for (const [name, f] of Object.entries(FAMILIES)) {
    const ft = themes(f);
    for (const scheme of ["light", "dark"]) {
      for (const c of chartChecks(ft[scheme].tokens).filter((x) => !x.pass)) problems.push(`${name} ${scheme} chart: ${c.what} is ${c.value.toFixed(2)}, needs ${c.min} (${c.detail})`);
    }
  }
  for (const [rel, want] of files()) {
    let committed = null;
    try {
      committed = readFileSync(target(rel), "utf8");
    } catch (e) {
      problems.push(`tokens/${rel} cannot be read (${e instanceof Error ? e.message : String(e)}); run npm run tokens`);
    }
    if (committed !== null && committed !== want) {
      const a = committed.split("\n");
      const b = want.split("\n");
      const i = a.findIndex((line, n) => line !== b[n]);
      problems.push(`tokens/${rel} differs from what the script writes at line ${i + 1}: "${a[i] ?? ""}" (committed) vs "${b[i] ?? ""}" (script); run npm run tokens`);
    }
  }
  return problems;
}

// Every asserted pair for every family in both themes, failures first.
export function report() {
  const rows = [];
  for (const [name, f] of Object.entries(FAMILIES)) {
    const t = themes(f);
    const schemes = f.defaultTheme === "dark" ? ["dark", "light"] : ["light", "dark"];
    for (const scheme of schemes) for (const p of t[scheme].pairs) rows.push({ family: name, scheme, isDefault: scheme === f.defaultTheme, ...p, fgHex: t[scheme].tokens[p.fg], bgHex: t[scheme].tokens[p.bg] });
  }
  // Each family in turn, its default theme first, failures first within each theme.
  return rows.map((r, i) => ({ r, i })).sort((a, b) => (a.r.family === b.r.family && a.r.scheme === b.r.scheme ? Number(a.r.pass) - Number(b.r.pass) || a.i - b.i : a.i - b.i)).map((x) => x.r);
}

const invoked = process.argv[1] && fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase() === process.argv[1].replace(/\\/g, "/").toLowerCase();
if (invoked) {
  if (process.argv.includes("--check")) {
    const problems = check();
    if (problems.length) {
      console.error(`palette: ${problems.length} problem(s)\n  ${problems.join("\n  ")}`);
      process.exit(1);
    }
    console.log(`palette: ${files().map(([r]) => `tokens/${r}`).join(", ")} match the script and every pair meets WCAG 2.2 in both themes`);
  } else if (process.argv.includes("--report")) {
    const rows = report();
    const failed = rows.filter((r) => !r.pass);
    for (const r of process.argv.includes("--summary") ? failed : rows) console.log(`${r.pass ? "pass" : "FAIL"}  ${r.family.padEnd(6)} ${(r.scheme + (r.isDefault ? "*" : "")).padEnd(6)} ${r.what.padEnd(32)} --${r.fg} ${r.fgHex} on --${r.bg} ${r.bgHex}  ${r.ratio.toFixed(2)}:1 (needs ${r.min})`);
    console.log("(* the family's default theme, checked first)");
    console.log(`palette: ${rows.length} pairs across ${Object.keys(FAMILIES).length} families and both themes; ${failed.length} fail`);
    if (failed.length) process.exit(1);
  } else if (process.argv.includes("--table")) {
    for (const [name, t] of Object.entries(themes())) if (name === "light" || name === "dark") {
      console.log(`\n${name}`);
      for (const k of [...FAMILY_ORDER, ...ELEVATION_ORDER]) console.log(`  --${k}: ${t.tokens[k]}`);
    }
  } else if (process.argv.includes("--chart")) {
    for (const [name, f] of Object.entries(FAMILIES)) {
      const ft = themes(f);
      for (const scheme of ["light", "dark"]) {
        const rows = chartChecks(ft[scheme].tokens);
        const worst = rows.filter((r) => r.what.includes("differ")).sort((a, b) => a.value - b.value)[0];
        console.log(`${name} ${scheme}: ${rows.length} checks, ${rows.filter((r) => !r.pass).length} fail; closest pair ${worst?.value.toFixed(1)} (${worst?.what})`);
      }
    }
  } else if (process.argv.includes("--pairs")) {
    console.log(JSON.stringify(themes(), null, 2));
  } else {
    for (const [rel, text] of files()) writeFileSync(target(rel), text);
    console.log(`palette: wrote ${files().map(([r]) => `tokens/${r}`).join(", ")}`);
  }
}