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.
138 lines
9.1 KiB
JavaScript
138 lines
9.1 KiB
JavaScript
// Stream S4 (PLAN C5): which theme and mode the page shows. brand.js runs in Node, so this is the code the browser runs.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import '../src/public/brand.js';
|
|
import { installedThemes } from '../../plugins/src/theme.mjs';
|
|
|
|
const { Brand } = globalThis;
|
|
const { themes: installed } = await installedThemes();
|
|
const without = id => installed.filter(t => t.id !== id);
|
|
const resolve = input => Brand.resolveAppearance(input);
|
|
const frozen = v => Object.freeze(structuredClone(v));
|
|
|
|
test('S4 with nothing set the page is Harbor in the dark, from tokens.css alone', () => {
|
|
for (const themes of [null, installed, []]) {
|
|
const r = resolve({ themes });
|
|
assert.deepEqual([r.theme, r.mode, r.themeSource, r.modeSource, r.warning, r.entry, r.provisional], ['harbor', 'dark', 'default', 'default', null, null, false]);
|
|
}
|
|
// Harbor never needs its module: it is built in, so an empty or failed list gives no warning for it.
|
|
assert.equal(resolve({ themes: [], saved: { theme: 'harbor' } }).warning, null);
|
|
assert.equal(resolve({ themes: [], unavailable: true, saved: { theme: 'harbor' } }).warning, null);
|
|
});
|
|
|
|
test('S4 precedence is the browser override, then the organization, then the default, one field at a time', () => {
|
|
const saved = frozen({ theme: 'grove', mode: 'dim' });
|
|
const override = frozen({ theme: 'cobalt', mode: 'light' });
|
|
assert.deepEqual(pick(resolve({ themes: installed, saved, override })), ['cobalt', 'light', 'browser', 'browser']);
|
|
assert.deepEqual(pick(resolve({ themes: installed, saved })), ['grove', 'dim', 'organization', 'organization']);
|
|
assert.deepEqual(pick(resolve({ themes: installed })), ['harbor', 'dark', 'default', 'default']);
|
|
// An override of one field leaves the other to the organization.
|
|
assert.deepEqual(pick(resolve({ themes: installed, saved, override: { mode: 'system' } })), ['grove', 'system', 'organization', 'browser']);
|
|
assert.deepEqual(pick(resolve({ themes: installed, saved, override: { theme: 'cobalt' } })), ['cobalt', 'dim', 'browser', 'organization']);
|
|
// The organization may save only one field too.
|
|
assert.deepEqual(pick(resolve({ themes: installed, saved: { theme: 'grove' } })), ['grove', 'dark', 'organization', 'default']);
|
|
function pick(r) { return [r.theme, r.mode, r.themeSource, r.modeSource]; }
|
|
});
|
|
|
|
test('S4 clearing the override restores the organization choice, and nothing is written back to the saved value', () => {
|
|
const saved = frozen({ theme: 'grove', mode: 'dim' });
|
|
const chosen = resolve({ themes: installed, saved, override: { theme: 'carmine', mode: 'light' } });
|
|
assert.equal(chosen.theme, 'carmine');
|
|
const cleared = resolve({ themes: installed, saved, override: null });
|
|
assert.deepEqual([cleared.theme, cleared.mode, cleared.themeSource], ['grove', 'dim', 'organization']);
|
|
assert.deepEqual(saved, { theme: 'grove', mode: 'dim' });
|
|
});
|
|
|
|
test('S4 an uninstalled or invalid saved theme renders the default with a warning and keeps the saved value', () => {
|
|
for (const asked of ['no-such-theme', 'Bad Name', '../harbor', '', 7, `a${'b'.repeat(64)}`, '<script>']) {
|
|
const saved = frozen({ theme: asked, mode: 'light' });
|
|
const r = resolve({ themes: installed, saved });
|
|
assert.equal(r.theme, 'harbor', String(asked));
|
|
assert.equal(r.entry, null);
|
|
assert.deepEqual(r.warning, { code: 'missing', theme: asked, source: 'organization' });
|
|
assert.equal(r.requested, asked, 'the requested value is reported, not replaced');
|
|
assert.equal(r.mode, 'light', 'the mode still applies');
|
|
assert.deepEqual(saved.theme, asked);
|
|
}
|
|
// A winning override that cannot be shown is reported as the browser's own, so the page can offer to clear it.
|
|
assert.deepEqual(resolve({ themes: installed, saved: { theme: 'grove' }, override: { theme: 'gone' } }).warning, { code: 'missing', theme: 'gone', source: 'browser' });
|
|
// The themes could not be read at all: say that, instead of "not installed".
|
|
assert.deepEqual(resolve({ themes: [], unavailable: true, saved: { theme: 'grove' } }).warning, { code: 'unavailable', theme: 'grove', source: 'organization' });
|
|
});
|
|
|
|
test('S4 uninstall then reinstall: the same saved value is the default while it is gone and the theme again once it is back', () => {
|
|
const saved = frozen({ theme: 'grove', mode: 'dark' });
|
|
assert.equal(resolve({ themes: installed, saved }).theme, 'grove');
|
|
const gone = resolve({ themes: without('grove'), saved });
|
|
assert.deepEqual([gone.theme, gone.warning?.code], ['harbor', 'missing']);
|
|
const back = resolve({ themes: installed, saved });
|
|
assert.deepEqual([back.theme, back.warning, back.entry?.id], ['grove', null, 'grove']);
|
|
});
|
|
|
|
test('S4 while the list is loading a name-shaped id is shown as asked, provisionally; a malformed one is not', () => {
|
|
const r = resolve({ themes: null, saved: { theme: 'grove' } });
|
|
assert.deepEqual([r.theme, r.provisional, r.warning, r.entry], ['grove', true, null, null]);
|
|
const bad = resolve({ themes: null, saved: { theme: 'Bad Name' } });
|
|
assert.deepEqual([bad.theme, bad.provisional, bad.warning?.code], ['harbor', false, 'missing']);
|
|
});
|
|
|
|
test('S4 a listed theme with a bad value, a missing mode or an extra token is not rendered', () => {
|
|
const grove = installed.find(t => t.id === 'grove');
|
|
const broken = {
|
|
'url() value': t => { t.tokens.dark['--canvas'] = 'url(https://example.test/x)'; },
|
|
'named colour': t => { t.tokens.light['--text'] = 'red'; },
|
|
'short hex': t => { t.tokens.dim['--action'] = '#fff'; },
|
|
'upper-case hex': t => { t.tokens.dark['--line'] = '#AABBCC'; },
|
|
'css text': t => { t.tokens.dark['--muted'] = '#112233;} body{display:none'; },
|
|
'missing mode': t => { delete t.tokens.dim; },
|
|
'missing token': t => { delete t.tokens.dark['--danger']; },
|
|
'extra token': t => { t.tokens.dark['--nav-active'] = '#112233'; },
|
|
'bad id': t => { t.id = 'Grove'; },
|
|
};
|
|
for (const [name, edit] of Object.entries(broken)) {
|
|
const copy = structuredClone(grove); edit(copy);
|
|
assert.equal(Brand.usableTheme(copy), false, name);
|
|
// themeProperties reads one mode; the theme as a whole is refused if any of its three is bad.
|
|
assert.ok(['light', 'dim', 'dark'].some(m => Brand.themeProperties(copy, m, false) === null), name);
|
|
const asked = copy.id === 'grove' ? 'grove' : copy.id;
|
|
const r = resolve({ themes: [copy], saved: { theme: asked } });
|
|
assert.equal(r.theme, 'harbor', name);
|
|
assert.equal(r.entry, null, name);
|
|
}
|
|
assert.equal(Brand.usableTheme(grove), true);
|
|
assert.equal(Brand.themeProperties(null, 'dark', false), null);
|
|
});
|
|
|
|
test('S4 a theme becomes fourteen colours plus the two values the stylesheet derives, and System follows the device', () => {
|
|
for (const theme of installed) {
|
|
for (const mode of ['light', 'dim', 'dark']) {
|
|
const props = Brand.themeProperties(theme, mode, false);
|
|
assert.deepEqual(props.slice(0, 14).map(([n]) => n), Brand.TOKEN_NAMES, `${theme.id} ${mode}`);
|
|
assert.deepEqual(props.slice(0, 14).map(([, v]) => v), Brand.TOKEN_NAMES.map(n => theme.tokens[mode][n]), `${theme.id} ${mode}`);
|
|
assert.deepEqual(props.slice(14), [['--strong', mode === 'light' ? 'color-mix(in srgb, var(--text) 72%, #000)' : 'color-mix(in srgb, var(--text) 70%, #fff)'], ['color-scheme', mode === 'light' ? 'light' : 'dark']], `${theme.id} ${mode}`);
|
|
assert.ok(props.every(([n, v]) => /^(--[A-Za-z-]+|color-scheme)$/.test(n) && !/[;{}<>]|url\(/.test(v)), 'only property names and values, no CSS text');
|
|
}
|
|
assert.deepEqual(Brand.themeProperties(theme, 'system', true), Brand.themeProperties(theme, 'dark', false));
|
|
assert.deepEqual(Brand.themeProperties(theme, 'system', false), Brand.themeProperties(theme, 'light', false));
|
|
}
|
|
});
|
|
|
|
test('S4 a stored mode outside the four is skipped; fields are read from objects only', () => {
|
|
assert.equal(resolve({ themes: installed, saved: { mode: 'dim' }, override: { mode: 'night' } }).mode, 'dim');
|
|
assert.equal(resolve({ themes: installed, override: { mode: 'Dark' } }).mode, 'dark');
|
|
assert.deepEqual(Brand.appearanceOf({ theme: 'grove', mode: 'dim', extra: 1 }), { theme: 'grove', mode: 'dim' });
|
|
assert.deepEqual(Brand.appearanceOf({ mode: 'dim' }), { mode: 'dim' });
|
|
for (const v of [null, undefined, 'grove', 3, [], {}, { other: 1 }]) assert.equal(Brand.appearanceOf(v), null, JSON.stringify(v));
|
|
assert.equal(resolve({ themes: installed, saved: 'grove', override: [] }).theme, 'harbor');
|
|
});
|
|
|
|
test('S4 an old browser-wide record seeds only what differs from the old defaults, so it is not an unchosen Harbor', () => {
|
|
assert.deepEqual(Brand.legacySeed({ palette: 'grove', mode: 'dim' }), { theme: 'grove', mode: 'dim' });
|
|
assert.deepEqual(Brand.legacySeed({ palette: 'harbor', mode: 'dim' }), { mode: 'dim' });
|
|
assert.deepEqual(Brand.legacySeed({ palette: 'grove', mode: 'dark' }), { theme: 'grove' });
|
|
assert.deepEqual(Brand.legacySeed({ theme: 'cobalt', mode: 'system' }), { theme: 'cobalt', mode: 'system' });
|
|
// What the old code wrote on every load carries no choice.
|
|
assert.equal(Brand.legacySeed({ palette: 'harbor', mode: 'dark' }), null);
|
|
for (const v of [null, 'x', 3, {}, { palette: 'Bad Name', mode: 'night' }]) assert.equal(Brand.legacySeed(v), null, JSON.stringify(v));
|
|
});
|