Files
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

71 lines
4.4 KiB
JavaScript

// Stream S4 (PLAN C5): GET /api/themes lists the installed, valid themes and nothing else.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { themesHTTP } from '../src/themes-http.mjs';
import { close } from './fixture.mjs';
const IDS = ['harbor', 'carmine', 'atlantic', 'terracotta', 'aubergine', 'mineral', 'cobalt', 'rosewood', 'graphite', 'grove'].sort();
const TOKENS = ['--canvas', '--surface', '--raised', '--line', '--border', '--text', '--muted', '--action', '--onAction', '--accent', '--focus', '--success', '--warning', '--danger'];
const reply = () => {
const out = { status: null, headers: null, text: '' };
return { out, res: { writeHead(status, headers) { out.status = status; out.headers = headers; }, end(text) { out.text = text; } } };
};
test('S4 GET /api/themes on a running Console returns the ten shipped themes, each with three modes of fourteen colours', async t => {
const server = await startServer({ port: 0 });
t.after(() => close(server));
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/themes`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /^application\/json/);
assert.equal(response.headers.get('cache-control'), 'no-store');
const body = await response.json();
assert.deepEqual(Object.keys(body), ['themes']);
assert.deepEqual(body.themes.map(x => x.id), IDS);
for (const theme of body.themes) {
assert.deepEqual(Object.keys(theme).sort(), ['displayName', 'id', 'tokens'], theme.id);
assert.deepEqual(Object.keys(theme.tokens), ['light', 'dim', 'dark'], theme.id);
for (const mode of ['light', 'dim', 'dark']) {
assert.deepEqual(Object.keys(theme.tokens[mode]), TOKENS, `${theme.id} ${mode}`);
assert.ok(Object.values(theme.tokens[mode]).every(v => /^#[0-9a-f]{6}$/.test(v)), `${theme.id} ${mode}`);
}
}
assert.equal(body.themes.find(x => x.id === 'harbor').displayName, 'Harbor');
// Only a read: other methods are refused, and a query string changes nothing.
assert.equal((await fetch(`http://127.0.0.1:${server.address().port}/api/themes`, { method: 'POST', body: '{}' })).status, 405);
assert.deepEqual((await (await fetch(`http://127.0.0.1:${server.address().port}/api/themes?theme=../../etc`)).json()).themes.map(x => x.id), IDS);
});
test('S4 a refused module is left out of the answer and logged once per distinct reason, not once per request', async () => {
const refusal = { event: 'plugin-refused', kind: 'theme', name: 'broken', code: 'bad-theme', message: 'theme broken: tokens.light --canvas "url(x)" must be a #rrggbb colour' };
const good = { id: 'ok', displayName: 'Ok', tokens: { light: {}, dim: {}, dark: {} }, extra: 'dropped' };
const lines = [];
const seen = new Set();
const list = async ({ log }) => { log(refusal); return { themes: [good], refused: [refusal] }; };
for (let i = 0; i < 3; i++) {
const { res, out } = reply();
await themesHTTP({ method: 'GET' }, res, { log: line => lines.push(line), list, seen });
assert.equal(out.status, 200);
assert.deepEqual(JSON.parse(out.text), { themes: [{ id: 'ok', displayName: 'Ok', tokens: { light: {}, dim: {}, dark: {} } }] });
}
assert.equal(lines.length, 1);
assert.match(lines[0], /theme refused name="broken" code="bad-theme" message="theme broken: /);
// A different reason for the same module is logged again.
await themesHTTP({ method: 'GET' }, reply().res, { log: line => lines.push(line), seen, list: async ({ log }) => { log({ ...refusal, code: 'load-failed', message: 'import failed' }); return { themes: [], refused: [] }; } });
assert.equal(lines.length, 2);
});
test('S4 a failure to read the themes is a 500 that logs the cause and tells the browser nothing about the machine', async () => {
const lines = [];
const { res, out } = reply();
await themesHTTP({ method: 'GET' }, res, { log: line => lines.push(line), list: async () => { throw new Error('EACCES: /home/someone/secret/path'); } });
assert.equal(out.status, 500);
assert.deepEqual(JSON.parse(out.text), { error: 'themes-unavailable', message: 'the installed themes could not be read' });
assert.ok(!out.text.includes('/home/'));
assert.match(lines[0], /theme list failed/);
const other = reply();
await themesHTTP({ method: 'DELETE' }, other.res, { list: async () => assert.fail('no read on a write method') });
assert.equal(other.out.status, 405);
});