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.
71 lines
4.4 KiB
JavaScript
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);
|
|
});
|