Files
stack/packages/plugins/src/theme.mjs
T
jason.woltje 7fdb488270 Themes as installed plugins, chosen per organization (S4, plan C5)
Add a reviewed `theme` plugin kind (interfaceVersion 1, export `theme`, switch
"list"). A module returns data only: a displayName and a flat map of colour
custom properties for light, dim and dark. checkTheme validates names and
#rrggbb colours strictly and checks each text/background pair for contrast; a
bad module is refused and logged, never served.

The ten shipped palettes become the first ten modules under
packages/plugins/theme/<name>/. Modules are found by directory scan
(loader.discoverModules, loader.loadInstalled), not by a list in the Console.
loadModule and createLoader are unchanged.

The organization file takes `appearance: { theme, mode }`, validated in
packages/business/src/appearance.mjs; a missing appearance is valid.
GET /api/themes (themes-http.mjs) serves the installed, valid themes.

Browser: the Settings picker lists /api/themes. Precedence is a per-organization
browser override, then the organization's saved theme, then the built-in default
(Harbor, dark). Clearing the override restores the organization's choice. An
uninstalled or invalid saved theme renders the default with a visible warning
and keeps the saved value. A legacy global browser preference is not copied to
every organization. The organization is named in text, so colour is never the
only cue. A plain load writes no appearance record. The default look is
unchanged: computed styles match the base commit element for element.
2026-11-07 12:18:43 -06:00

136 lines
6.9 KiB
JavaScript

// 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 } = {}) {
const { modules, refused } = await loadInstalled("theme", { barrier });
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 };
}