// Theme modules (stream S4, PLAN C5). A theme module is data: theme() takes nothing and returns // { displayName, tokens: { light, dim, dark } } // and each mode is a flat map from a Console colour custom property to a #rrggbb string. checkTheme // is the only way a module's answer reaches the Console, and it accepts exactly that shape: no // extra key, no missing token, no HTML, CSS text, URL, var() or script, and a contrast floor on // the text pairs so an installed theme can't make the Console unreadable. A module that fails is // refused and logged; it is never listed and never rendered. import { PluginError } from "./kinds.mjs"; import { loadInstalled } from "./loader.mjs"; export const MODES = Object.freeze(["light", "dim", "dark"]); // The fourteen semantic colours docs/design/tokens.json defines per mode, as the CSS custom // properties tokens.css sets from them. Nothing else is a theme token: --font, --r, --pad and the // derived --strong stay with the Console. export const TOKEN_NAMES = Object.freeze([ "--canvas", "--surface", "--raised", "--line", "--border", "--text", "--muted", "--action", "--onAction", "--accent", "--focus", "--success", "--warning", "--danger", ]); const TOKEN_NAME = /^--[a-z][a-zA-Z0-9]{0,23}$/; const COLOR = /^#[0-9a-fA-F]{6}$/; const DISPLAY_NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._'()&-]{0,39}$/u; // [foreground, background, minimum ratio]. 4.5 is WCAG AA for text; 3 is its floor for the // non-text marks that must be seen (focus ring, control border). Each of the ten shipped palettes // clears every row in every mode (the test measures them), so the floor refuses a new theme // without moving an old one. --line is a hairline rule, not a mark to read, and has no row. export const CONTRAST = Object.freeze([ ["--text", "--canvas", 4.5], ["--text", "--surface", 4.5], ["--text", "--raised", 4.5], ["--muted", "--canvas", 4.5], ["--muted", "--surface", 4.5], ["--onAction", "--action", 4.5], ["--action", "--canvas", 4.5], ["--action", "--surface", 4.5], ["--accent", "--canvas", 4.5], ["--accent", "--surface", 4.5], ["--success", "--canvas", 4.5], ["--success", "--surface", 4.5], ["--warning", "--canvas", 4.5], ["--warning", "--surface", 4.5], ["--danger", "--canvas", 4.5], ["--danger", "--surface", 4.5], ["--focus", "--canvas", 3], ["--focus", "--surface", 3], ["--border", "--canvas", 3], ["--border", "--surface", 3], ]); function luminance(hex) { const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255) .map(v => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } // WCAG 2 contrast ratio of two #rrggbb colours. export function contrastRatio(a, b) { const x = luminance(a), y = luminance(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); } // What a refusal may quote of a module's value: short, and JSON-escaped. const show = v => { const s = typeof v === "string" ? JSON.stringify(v) : Array.isArray(v) ? "an array" : v === null ? "null" : `a ${typeof v}`; return s.length > 32 ? `${s.slice(0, 31)}…` : s; }; // The theme a module answered with, copied and frozen, or a PluginError "bad-theme" naming the // first thing wrong. `name` is the module's directory name, for the message only. export function checkTheme(value, name = null) { const bad = message => { throw new PluginError("bad-theme", `theme ${name ?? "module"}: ${message}`, name); }; // Plain data only: an object whose own properties are enumerable values, never a getter, a // symbol key or a class instance, so reading it can't run anything. const plain = (v, where) => { if (v === null || typeof v !== "object" || Array.isArray(v)) bad(`${where} must be an object`); const proto = Object.getPrototypeOf(v); if (proto !== Object.prototype && proto !== null) bad(`${where} must be a plain object`); if (Object.getOwnPropertySymbols(v).length > 0) bad(`${where} has a symbol key`); for (const [key, d] of Object.entries(Object.getOwnPropertyDescriptors(v))) { if (!("value" in d) || !d.enumerable) bad(`${where} property ${show(key)} must be plain data`); } }; const keys = (v, expected, where) => { for (const key of Object.keys(v)) if (!expected.includes(key)) bad(`${where} has unexpected key ${show(key)}`); for (const key of expected) if (!Object.hasOwn(v, key)) bad(`${where} lacks ${key}`); }; plain(value, "theme()"); keys(value, ["displayName", "tokens"], "theme()"); if (typeof value.displayName !== "string" || !DISPLAY_NAME.test(value.displayName)) { bad(`displayName ${show(value.displayName)} must be 1 to 40 letters, digits, spaces or . _ ' ( ) & -`); } plain(value.tokens, "tokens"); keys(value.tokens, MODES, "tokens"); const tokens = {}; for (const mode of MODES) { const map = value.tokens[mode]; plain(map, `tokens.${mode}`); for (const key of Object.keys(map)) { if (!TOKEN_NAME.test(key)) bad(`tokens.${mode} key ${show(key)} isn't a CSS custom property name`); if (!TOKEN_NAMES.includes(key)) bad(`tokens.${mode} key ${key} isn't a theme token`); } keys(map, TOKEN_NAMES, `tokens.${mode}`); tokens[mode] = {}; for (const key of TOKEN_NAMES) { if (typeof map[key] !== "string" || !COLOR.test(map[key])) { bad(`tokens.${mode}.${key} ${show(map[key])} must be a #rrggbb colour`); } tokens[mode][key] = map[key].toLowerCase(); } for (const [fg, bg, minimum] of CONTRAST) { const ratio = contrastRatio(tokens[mode][fg], tokens[mode][bg]); if (ratio < minimum) bad(`tokens.${mode} ${fg} on ${bg} is ${ratio.toFixed(2)}:1, under ${minimum}:1`); } Object.freeze(tokens[mode]); } return Object.freeze({ displayName: value.displayName, tokens: Object.freeze(tokens) }); } // Every installed theme module that loads and checks, as [{ id, displayName, tokens }] sorted by // id, and what was refused. Each refusal goes to `log` as the loader's plugin-refused entry. // Nothing names the themes: loadInstalled lists packages/plugins/theme/. export async function installedThemes({ log = () => {}, barrier, dataRoot = null } = {}) { const { modules, refused } = await loadInstalled("theme", { barrier, dataRoot }); const themes = [], problems = [...refused]; for (const { name, module } of modules) { try { const value = module.theme(); if (value !== null && typeof value === "object" && typeof value.then === "function") { Promise.resolve(value).catch(() => {}); throw new PluginError("bad-theme", `theme ${name}: theme() must return the data, not a promise`, name); } themes.push({ id: name, ...checkTheme(value, name) }); } catch (e) { const known = e instanceof PluginError; problems.push({ event: "plugin-refused", kind: "theme", name, code: known ? e.code : "theme-failed", message: known ? e.message : `theme ${name}: theme() threw ${show(String(e?.message ?? e))}`, }); } } for (const problem of problems) log(problem); return { themes, refused: problems }; }