Files
stack/packages/plugins/tests/theme.test.mjs
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

253 lines
16 KiB
JavaScript

// Stream S4 (PLAN C5): the theme kind. A theme module is data; theme.mjs lets nothing else through.
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PluginError } from "../src/kinds.mjs";
import { CONTRAST, MODES, TOKEN_NAMES, checkTheme, contrastRatio, installedThemes } from "../src/theme.mjs";
import { discoverModules, loadInstalled } from "../src/loader.mjs";
import { tree } from "./helpers.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, "..", "..", "..");
const IDS = ["harbor", "carmine", "atlantic", "terracotta", "aubergine", "mineral", "cobalt", "rosewood", "graphite", "grove"];
const tokensJson = JSON.parse(readFileSync(join(REPO, "docs", "design", "tokens.json"), "utf8"));
const bad = (message, name) => e => e instanceof PluginError && e.code === "bad-theme" && (name === undefined || e.message.includes(name)) && (message === undefined || e.message.includes(message));
const clone = v => JSON.parse(JSON.stringify(v));
const harbor = async () => (await import("../theme/harbor/index.mjs")).theme();
const body = value => `export const interfaceVersion = 1;\nexport function theme() { return ${JSON.stringify(value)}; }\n`;
const themeUrl = t => pathToFileURL(join(t.pkg, "src", "theme.mjs")).href;
// The Console renders Harbor through a hand-written overlay in tokens.css ("Dashboard reference", "Cool Harbor light
// equivalents"), so its light and dark differ from docs/design/tokens.json in exactly these twelve values. The other
// nine palettes, and Harbor's dim, are tokens.json to the letter. packages/webui/tests checks the module against the page.
const HARBOR_OVERLAY = {
light: { "--canvas": "#f1f5fa", "--raised": "#e4edf8", "--line": "#c8d4e3", "--text": "#34445a", "--muted": "#53677e" },
dark: { "--canvas": "#141821", "--surface": "#1d222b", "--raised": "#293446", "--line": "#444f60", "--text": "#d8e0ef", "--muted": "#b5c2d6", "--action": "#64b5ff" },
};
test("S4 the ten shipped palettes are ten theme modules whose tokens equal docs/design/tokens.json, plus Harbor's twelve overlay values", async () => {
const { themes, refused } = await installedThemes();
assert.deepEqual(refused, []);
assert.deepEqual(themes.map(x => x.id), [...IDS].sort());
assert.deepEqual(tokensJson.palettes.map(p => p.id).sort(), [...IDS].sort());
let overlaid = 0;
for (const palette of tokensJson.palettes) {
const found = themes.find(x => x.id === palette.id);
assert.equal(found.displayName, palette.name, palette.id);
for (const mode of MODES) {
const expected = Object.fromEntries(TOKEN_NAMES.map(token => [token, palette.modes[mode][token.slice(2)].toLowerCase()]));
const overlay = palette.id === "harbor" ? HARBOR_OVERLAY[mode] ?? {} : {};
for (const [token, value] of Object.entries(overlay)) {
assert.notEqual(expected[token], value, `${palette.id} ${mode} ${token}: the overlay value must really differ from tokens.json`);
expected[token] = value;
overlaid++;
}
assert.deepEqual({ ...found.tokens[mode] }, expected, `${palette.id} ${mode}`);
}
}
assert.equal(overlaid, 12);
});
test("S4 every palette clears the contrast floor in every mode, measured pair by pair", async () => {
const { themes } = await installedThemes();
const seen = [];
for (const { id, tokens } of themes) {
for (const mode of MODES) {
for (const [fg, bg, minimum] of CONTRAST) {
const ratio = contrastRatio(tokens[mode][fg], tokens[mode][bg]);
seen.push(ratio);
assert.ok(ratio >= minimum, `${id} ${mode} ${fg} on ${bg}: ${ratio.toFixed(2)} < ${minimum}`);
}
}
}
assert.equal(seen.length, IDS.length * MODES.length * CONTRAST.length);
// The measure itself: black on white is 21, a colour on itself is 1.
assert.equal(Math.round(contrastRatio("#000000", "#ffffff")), 21);
assert.equal(contrastRatio("#336699", "#336699"), 1);
});
test("S4 checkTheme returns a frozen copy and lowercases colours", async () => {
const input = clone(await harbor());
input.tokens.light["--canvas"] = input.tokens.light["--canvas"].toUpperCase();
const checked = checkTheme(input, "harbor");
assert.equal(checked.tokens.light["--canvas"], input.tokens.light["--canvas"].toLowerCase());
assert.ok(Object.isFrozen(checked) && Object.isFrozen(checked.tokens) && Object.isFrozen(checked.tokens.light));
input.tokens.light["--text"] = "#000001";
assert.notEqual(checked.tokens.light["--text"], "#000001", "a later change to the module's object can't reach the copy");
});
test("S4 malformed tokens are refused with bad-theme, one reason each", async () => {
const good = await harbor();
const mutate = fn => { const v = clone(good); fn(v); return v; };
const cases = {
"not an object": [null, "must be an object"],
"an array": [[], "must be an object"],
"extra top-level key": [mutate(v => { v.css = "body{}"; }), "unexpected key"],
"no displayName": [mutate(v => { delete v.displayName; }), "lacks displayName"],
"displayName with markup": [mutate(v => { v.displayName = "<b>x</b>"; }), "displayName"],
"displayName too long": [mutate(v => { v.displayName = "x".repeat(41); }), "displayName"],
"empty displayName": [mutate(v => { v.displayName = ""; }), "displayName"],
"displayName not a string": [mutate(v => { v.displayName = 5; }), "displayName"],
"tokens missing a mode": [mutate(v => { delete v.tokens.dim; }), "lacks dim"],
"tokens with an extra mode": [mutate(v => { v.tokens.contrast = v.tokens.dark; }), "unexpected key"],
"mode not an object": [mutate(v => { v.tokens.dark = "#000000"; }), "must be an object"],
"token missing": [mutate(v => { delete v.tokens.dark["--focus"]; }), "lacks --focus"],
"extra token": [mutate(v => { v.tokens.dark["--glow"] = "#ffffff"; }), "isn't a theme token"],
"token name not a custom property": [mutate(v => { v.tokens.dark["canvas"] = "#ffffff"; }), "isn't a CSS custom property name"],
"token name with a brace": [mutate(v => { v.tokens.dark["--a}body{x"] = "#ffffff"; }), "isn't a CSS custom property name"],
"token name with a space": [mutate(v => { v.tokens.dark["--a b"] = "#ffffff"; }), "isn't a CSS custom property name"],
"colour is url()": [mutate(v => { v.tokens.light["--canvas"] = "url(https://example.test/x.png)"; }), "#rrggbb"],
"colour is var()": [mutate(v => { v.tokens.light["--canvas"] = "var(--surface)"; }), "#rrggbb"],
"colour is a 3-digit hex": [mutate(v => { v.tokens.light["--canvas"] = "#fff"; }), "#rrggbb"],
"colour is an 8-digit hex": [mutate(v => { v.tokens.light["--canvas"] = "#ffffffff"; }), "#rrggbb"],
"colour is a name": [mutate(v => { v.tokens.light["--canvas"] = "white"; }), "#rrggbb"],
"colour is rgb()": [mutate(v => { v.tokens.light["--canvas"] = "rgb(255,255,255)"; }), "#rrggbb"],
"colour carries CSS after it": [mutate(v => { v.tokens.light["--canvas"] = "#ffffff;}body{display:none"; }), "#rrggbb"],
"colour carries a newline": [mutate(v => { v.tokens.light["--canvas"] = "#ffffff\n"; }), "#rrggbb"],
"colour is a number": [mutate(v => { v.tokens.light["--canvas"] = 0xffffff; }), "#rrggbb"],
"colour is null": [mutate(v => { v.tokens.light["--canvas"] = null; }), "#rrggbb"],
"text on canvas under the floor": [mutate(v => { v.tokens.light["--text"] = v.tokens.light["--canvas"]; }), "--text on --canvas"],
"accent on canvas under the floor": [mutate(v => { v.tokens.dark["--accent"] = v.tokens.dark["--canvas"]; }), "--accent on --canvas"],
"label on the action colour under the floor": [mutate(v => { v.tokens.dark["--onAction"] = v.tokens.dark["--action"]; }), "--onAction on --action"],
"focus ring under 3:1": [mutate(v => { v.tokens.dim["--focus"] = v.tokens.dim["--surface"]; }), "--focus on --canvas"],
};
for (const [label, [value, message]] of Object.entries(cases)) {
assert.throws(() => checkTheme(value, "x"), bad(message, "theme x"), label);
}
});
test("S4 a module's answer can only be plain data: no getter, symbol key, class instance or prototype trick", async () => {
const good = await harbor();
const withGetter = clone(good);
Object.defineProperty(withGetter.tokens.light, "--canvas", { enumerable: true, get() { throw new Error("ran"); } });
assert.throws(() => checkTheme(withGetter, "x"), bad("plain data"));
const hidden = clone(good);
Object.defineProperty(hidden.tokens.light, "--canvas", { enumerable: false, value: "#ffffff" });
assert.throws(() => checkTheme(hidden, "x"), bad("plain data"));
const symbol = clone(good);
symbol.tokens.dark[Symbol("x")] = "#ffffff";
assert.throws(() => checkTheme(symbol, "x"), bad("symbol key"));
class Tokens { constructor(o) { Object.assign(this, o); } }
const klass = clone(good);
klass.tokens.dark = new Tokens(klass.tokens.dark);
assert.throws(() => checkTheme(klass, "x"), bad("plain object"));
const proto = clone(good);
Object.setPrototypeOf(proto.tokens.dark, { extra: "x" });
assert.throws(() => checkTheme(proto, "x"), bad("plain object"));
// A null-prototype map is plain data and is accepted.
const bare = clone(good);
bare.tokens.dark = Object.assign(Object.create(null), bare.tokens.dark);
assert.equal(checkTheme(bare, "x").tokens.dark["--canvas"], good.tokens.dark["--canvas"]);
});
test("S4 installedThemes: a bad module is refused and logged, never listed; the good ones still list", async t => {
const x = tree(t);
const good = await harbor();
x.write("theme", "fine", body(good));
x.write("theme", "no-tokens", body({ displayName: "No Tokens" }));
const url = x.write("theme", "css", body({ ...clone(good), tokens: { ...clone(good).tokens, light: { ...clone(good).tokens.light, "--canvas": "url(//e.test/a)" } } }));
x.write("theme", "no-export", "export const interfaceVersion = 1;\n");
x.write("theme", "old", "export const interfaceVersion = 2;\nexport function theme() { return null; }\n");
x.write("theme", "throws", "export const interfaceVersion = 1;\nexport function theme() { throw new Error('boom'); }\n");
x.write("theme", "promise", "export const interfaceVersion = 1;\nexport async function theme() { return null; }\n");
x.write("theme", "syntax", "export const interfaceVersion = 1;\nexport function theme( {\n");
assert.ok(url);
const { installedThemes: list } = await import(themeUrl(x));
const log = [];
const { themes, refused } = await list({ log: e => log.push(e) });
assert.deepEqual(themes.map(v => v.id), ["fine"]);
assert.equal(themes[0].displayName, good.displayName);
const by = Object.fromEntries(refused.map(r => [r.name, r.code]));
assert.deepEqual(by, {
"no-tokens": "bad-theme", css: "bad-theme", "no-export": "missing-export", old: "interface-version",
throws: "theme-failed", promise: "bad-theme", syntax: "load-failed",
});
assert.deepEqual(log, refused, "every refusal is logged, in the order it is returned");
assert.ok(log.every(e => e.event === "plugin-refused" && e.kind === "theme" && typeof e.message === "string"));
assert.match(refused.find(r => r.name === "throws").message, /theme\(\) threw/);
// A refusal quotes what it refused, but short and JSON-escaped: a long or multi-line value can't
// flood or forge a log line.
const long = clone(good);
long.tokens.light["--canvas"] = `#ffffff\n${"x".repeat(200)}`;
const message = (() => { try { checkTheme(long, "long"); } catch (e) { return e.message; } return ""; })();
assert.match(message, /#rrggbb colour/);
assert.ok(!message.includes("\n") && message.length < 200, message);
});
test("S4 uninstall and reinstall: a removed theme leaves the list at once and comes back when its directory does", async t => {
const x = tree(t);
const good = await harbor();
x.write("theme", "alpha", body({ ...clone(good), displayName: "Alpha" }));
x.write("theme", "beta", body({ ...clone(good), displayName: "Beta" }));
const { installedThemes: list } = await import(themeUrl(x));
assert.deepEqual((await list()).themes.map(v => v.id), ["alpha", "beta"]);
rmSync(join(x.pkg, "theme", "beta"), { recursive: true });
const after = await list();
assert.deepEqual(after.themes.map(v => v.id), ["alpha"]);
assert.deepEqual(after.refused, [], "an uninstalled theme is absent, not an error");
x.write("theme", "beta", body({ ...clone(good), displayName: "Beta" }));
assert.deepEqual((await list()).themes.map(v => `${v.id}:${v.displayName}`), ["alpha:Alpha", "beta:Beta"]);
rmSync(join(x.pkg, "theme"), { recursive: true });
assert.deepEqual(await list(), { themes: [], refused: [] }, "no theme directory at all is an empty list");
});
test("S4 discoverModules lists directories by name, reports bad names, ignores files, refuses a symlinked kind directory", async t => {
const x = tree(t);
const { discoverModules: discover, loadInstalled: loadAll } = await import(x.loaderUrl);
assert.deepEqual(discover("theme"), { names: [], invalid: [] });
assert.throws(() => discover("made-up"), e => e.code === "unknown-kind");
const good = await harbor();
x.write("theme", "zeta", body(good));
x.write("theme", "alpha", body(good));
x.write("theme", "Bad_Name", body(good));
x.write("theme", "9lives", body(good));
writeFileSync(join(x.pkg, "theme", "README.md"), "not a theme\n");
assert.deepEqual(discover("theme"), { names: ["alpha", "zeta"], invalid: ["9lives", "Bad_Name"] });
const { modules, refused } = await loadAll("theme");
assert.deepEqual(modules.map(m => m.name), ["alpha", "zeta"]);
assert.deepEqual(refused.map(r => [r.name, r.code]), [["9lives", "bad-name"], ["Bad_Name", "bad-name"]]);
// A symlinked module directory is listed so the load refuses it by name.
mkdirSync(join(x.root, "elsewhere"), { recursive: true });
writeFileSync(join(x.root, "elsewhere", "index.mjs"), body(good));
symlinkSync(join(x.root, "elsewhere"), join(x.pkg, "theme", "linked"));
assert.deepEqual(discover("theme").names, ["alpha", "linked", "zeta"]);
const again = await loadAll("theme");
assert.deepEqual(again.modules.map(m => m.name), ["alpha", "zeta"]);
assert.ok(again.refused.some(r => r.name === "linked" && r.code === "symlink"));
// A symlinked kind directory is refused outright.
rmSync(join(x.pkg, "theme"), { recursive: true });
symlinkSync(join(x.root, "elsewhere"), join(x.pkg, "theme"));
// The scratch tree has its own copy of kinds.mjs, so its PluginError is not this file's class.
assert.throws(() => discover("theme"), e => e.name === "PluginError" && e.code === "symlink");
const refusedKind = await loadAll("theme");
assert.deepEqual(refusedKind.modules, []);
assert.equal(refusedKind.refused[0].code, "symlink");
});
test("S4 loadInstalled leaves createLoader's switched-on modules alone", async t => {
const x = tree(t);
x.write("usage-source", "meter", "export const interfaceVersion = 1;\nexport function readTurn() { return 'meter'; }\n");
x.write("theme", "alpha", body(await harbor()));
const loader = await x.loader();
await loader.load({ "usage-source": ["meter"] });
const { loadInstalled: loadAll } = await import(x.loaderUrl);
await loadAll("theme");
assert.deepEqual(loader.loaded()["usage-source"], ["meter"]);
assert.deepEqual(loader.loaded().theme, []);
assert.deepEqual(loader.logged, []);
});
test("S4 the shipped modules are data only: no import, no network, no filesystem, no URL", () => {
for (const id of IDS) {
const text = readFileSync(join(HERE, "..", "theme", id, "index.mjs"), "utf8");
const code = text.split("\n").filter(line => !line.trimStart().startsWith("//")).join("\n");
assert.doesNotMatch(code, /\bimport\b|\brequire\b|\bfetch\b|\bprocess\b|\beval\b|https?:|url\(|<\w/i, id);
assert.match(text, /^export const interfaceVersion = 1;$/m, id);
}
assert.ok(discoverModules("theme").names.length >= IDS.length);
assert.equal(typeof loadInstalled, "function");
});