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.
200 lines
16 KiB
JavaScript
200 lines
16 KiB
JavaScript
// Stream S4 (PLAN C5) in a real browser: which theme the page shows, from where, and what it never shows.
|
|
// The pure rules are tested in appearance.test.mjs; here the served page, its storage and its Settings picker are checked.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { installedThemes } from '../../plugins/src/theme.mjs';
|
|
import { w15Fixture, ids } from './w15-fixture.mjs';
|
|
|
|
const { themes: INSTALLED } = await installedThemes();
|
|
const shown = ({ id, displayName, tokens }) => ({ id, displayName, tokens });
|
|
const THEMES = INSTALLED.map(shown);
|
|
const TOKENS = ['--canvas', '--surface', '--raised', '--line', '--border', '--text', '--muted', '--action', '--onAction', '--accent', '--focus', '--success', '--warning', '--danger'];
|
|
const ORG = { id: 'acme' };
|
|
|
|
// The page is served by the fixture's own Console; /api/themes and /api/business are answered per scenario.
|
|
async function open(t, { saved, themes = THEMES, org = ORG, seed = {}, width = 1440, hash = '' } = {}) {
|
|
const scenario = { '/api/themes': { themes } };
|
|
if (org) scenario['/api/business'] = { business: { id: org.id, ...(saved === undefined ? {} : { appearance: saved }) }, projects: [ids.repository] };
|
|
const w = await w15Fixture(t, { scenario });
|
|
// Storage is seeded once per tab, so a reload keeps what the page itself wrote.
|
|
await w.b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try{if(!sessionStorage.getItem('seeded')){sessionStorage.setItem('seeded','1');for(const [k,v] of Object.entries(${JSON.stringify(seed)}))localStorage.setItem(k,v);}}catch{}` });
|
|
await w.b.viewport(width, 900);
|
|
await w.b.navigate(w.f.base + '/' + hash);
|
|
await w.wait('!!window.MosaicAppearance');
|
|
await w.b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
|
return w;
|
|
}
|
|
const state = b => b.evaluate('JSON.parse(JSON.stringify(window.MosaicAppearance.state()))');
|
|
const stored = (b, key) => b.evaluate(`localStorage.getItem(${JSON.stringify(key)})`);
|
|
const inline = b => b.evaluate('document.documentElement.getAttribute("style")');
|
|
|
|
test('the built-in default is drawn by tokens.css alone: Harbor, dark, no inline colour, no warning, no cue, nothing stored', { timeout: 90000 }, async t => {
|
|
const { b } = await open(t, { org: null });
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
|
|
assert.equal(await inline(b), null, 'the default theme sets no inline property');
|
|
assert.equal(await b.evaluate('document.querySelector("#theme-warning").hidden'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#org-cue").hidden'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#palette").value'), 'harbor');
|
|
assert.equal(await b.evaluate('document.querySelector("#mode").value'), 'dark');
|
|
assert.equal(await b.evaluate('Object.keys(localStorage).filter(k => k.startsWith("mosaic-console-appearance")).length'), 0, 'a plain load writes no appearance record');
|
|
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
|
assert.deepEqual(await b.evaluate('window.__errors'), []);
|
|
});
|
|
|
|
test('every installed theme renders exactly the colours its module declares, in each mode', { timeout: 120000 }, async t => {
|
|
const { b } = await open(t, { org: null });
|
|
assert.equal(await b.evaluate('window.MosaicAppearance.themes().length'), 10);
|
|
const bad = await b.evaluate(`(() => {
|
|
const failures = [], names = ${JSON.stringify(TOKENS)}, root = document.documentElement;
|
|
for (const theme of window.MosaicAppearance.themes()) for (const mode of ['light', 'dim', 'dark']) {
|
|
window.MosaicAppearance.set({ theme: theme.id, mode });
|
|
if (root.dataset.palette !== theme.id || root.dataset.mode !== mode) { failures.push('not applied ' + theme.id + '/' + mode); continue; }
|
|
const cs = getComputedStyle(root);
|
|
for (const n of names) {
|
|
const got = cs.getPropertyValue(n).trim().toLowerCase(), want = theme.tokens[mode][n];
|
|
if (got !== want) failures.push(theme.id + '/' + mode + ' ' + n + ' ' + got + ' != ' + want);
|
|
}
|
|
}
|
|
return failures;
|
|
})()`);
|
|
assert.deepEqual(bad, []);
|
|
});
|
|
|
|
test('a saved theme that is not installed shows Harbor with a visible warning and keeps the saved value; reinstalling brings it back', { timeout: 120000 }, async t => {
|
|
const gone = THEMES.filter(x => x.id !== 'grove');
|
|
const away = await open(t, { saved: { theme: 'grove', mode: 'light' }, themes: gone });
|
|
assert.equal(await away.b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
|
assert.equal(await away.b.evaluate('document.documentElement.dataset.mode'), 'light', 'the mode is a separate field and still applies');
|
|
assert.equal(await inline(away.b), null, 'nothing of the missing theme is drawn');
|
|
const warning = await away.b.evaluate('(() => { const e = document.querySelector("#theme-warning"); return { hidden: e.hidden, shown: e.getClientRects().length > 0, text: e.textContent, role: e.getAttribute("role") }; })()');
|
|
assert.equal(warning.hidden, false); assert.equal(warning.shown, true); assert.equal(warning.role, 'status');
|
|
assert.match(warning.text, /grove/); assert.match(warning.text, /not installed or is not valid/); assert.match(warning.text, /Harbor is shown/);
|
|
const s = await state(away.b);
|
|
assert.deepEqual(s.saved, { theme: 'grove', mode: 'light' }, 'the saved value is untouched');
|
|
assert.equal(s.warning.theme, 'grove');
|
|
assert.equal(await stored(away.b, 'mosaic-console-appearance:acme'), null, 'the warning wrote no override');
|
|
assert.equal(await away.b.evaluate('document.querySelector("#theme-clear").hidden'), true, 'an organization value has no browser choice to clear');
|
|
assert.match(await away.b.evaluate('document.querySelector("#org-cue").textContent'), /acme/);
|
|
// Settings names the missing theme and offers it only as a disabled tile; Harbor is the checked one.
|
|
await away.b.evaluate('location.hash = "#/settings/appearance"');
|
|
await away.wait('document.querySelectorAll(".palette-tile").length >= 10');
|
|
assert.equal(await away.b.evaluate('document.querySelectorAll(".palette-missing input:disabled").length'), 1);
|
|
assert.equal(await away.b.evaluate('document.querySelector("input[name=palette]:checked").value'), 'harbor');
|
|
assert.equal(await away.b.evaluate('(() => { const e = document.querySelector("input[name=palette][value=grove]"); return e.disabled && !e.checked; })()'), true, 'an uninstalled theme cannot be picked');
|
|
|
|
const back = await open(t, { saved: { theme: 'grove', mode: 'light' }, themes: THEMES });
|
|
assert.equal(await back.b.evaluate('document.documentElement.dataset.palette'), 'grove');
|
|
assert.equal(await back.b.evaluate('document.querySelector("#theme-warning").hidden'), true);
|
|
const grove = THEMES.find(x => x.id === 'grove').tokens.light;
|
|
assert.equal(await back.b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--canvas").trim().toLowerCase()'), grove['--canvas']);
|
|
assert.equal(await back.b.evaluate('document.documentElement.style.getPropertyValue("--canvas")'), grove['--canvas'], 'drawn from the module through the CSSOM');
|
|
});
|
|
|
|
test('a module whose colours are not plain colours is never drawn: no inline value, no request, Harbor with a warning', { timeout: 90000 }, async t => {
|
|
const hostile = { id: 'evil', displayName: 'Evil', tokens: Object.fromEntries(['light', 'dim', 'dark'].map(m => [m, Object.fromEntries(TOKENS.map(n => [n, n === '--canvas' ? 'url(https://example.invalid/x.png)' : '#123456']))])) };
|
|
const { b } = await open(t, { saved: { theme: 'evil' }, themes: [...THEMES, hostile] });
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
|
assert.equal(await inline(b), null);
|
|
assert.equal(await b.evaluate('window.MosaicAppearance.themes().some(x => x.id === "evil")'), false, 'a module that fails the page check is not listed');
|
|
assert.equal(await b.evaluate('document.querySelector("#theme-warning").hidden'), false);
|
|
assert.deepEqual(await b.evaluate('window.__unexpected'), [], 'nothing outside the Console was asked for');
|
|
});
|
|
|
|
test('precedence per field: this browser, then the organization, then the default; clearing the override restores the organization choice', { timeout: 120000 }, async t => {
|
|
const { b } = await open(t, { saved: { theme: 'grove', mode: 'dim' }, seed: { 'mosaic-console-appearance:acme': JSON.stringify({ theme: 'cobalt' }) } });
|
|
let s = await state(b);
|
|
assert.deepEqual([s.theme, s.themeSource, s.mode, s.modeSource], ['cobalt', 'browser', 'dim', 'organization'], 'theme from the browser, mode from the organization');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'cobalt');
|
|
assert.match(await b.evaluate('document.querySelector("#org-cue").textContent'), /Cobalt.*your choice in this browser/);
|
|
// Settings says where each value comes from and offers the way back.
|
|
await b.evaluate('location.hash = "#/settings/appearance"');
|
|
await b.evaluate('(async()=>{for(let i=0;i<100&&!document.querySelector("[data-theme-clear]");i++)await new Promise(r=>setTimeout(r,40))})()');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("[data-theme-clear]").length'), 1);
|
|
await b.evaluate('document.querySelector("[data-theme-clear]").click()');
|
|
s = await state(b);
|
|
assert.deepEqual([s.theme, s.themeSource, s.mode, s.modeSource, s.override], ['grove', 'organization', 'dim', 'organization', null]);
|
|
assert.equal(await stored(b, 'mosaic-console-appearance:acme'), null, 'the override is gone from storage');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'grove');
|
|
// Picking again writes the override under this organization only, and a mode choice leaves the theme alone.
|
|
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
|
|
s = await state(b);
|
|
assert.deepEqual([s.theme, s.themeSource, s.mode, s.modeSource], ['grove', 'organization', 'light', 'browser']);
|
|
assert.deepEqual(JSON.parse(await stored(b, 'mosaic-console-appearance:acme')), { mode: 'light' });
|
|
assert.deepEqual(s.saved, { theme: 'grove', mode: 'dim' }, 'the organization file value is never written by the browser');
|
|
assert.deepEqual(await b.evaluate('window.__writes'), [], 'no request writes anything');
|
|
});
|
|
|
|
test('the old browser-wide preference seeds the first organization once and is not copied to another', { timeout: 90000 }, async t => {
|
|
const { b } = await open(t, { saved: { theme: 'cobalt' }, seed: { 'mosaic-console-appearance': JSON.stringify({ palette: 'grove', mode: 'light' }) } });
|
|
let s = await state(b);
|
|
assert.deepEqual([s.theme, s.mode, s.themeSource], ['grove', 'light', 'browser'], 'the first organization keeps what the person chose');
|
|
assert.equal(await stored(b, 'mosaic-console-appearance'), null, 'the browser-wide record is consumed');
|
|
assert.deepEqual(JSON.parse(await stored(b, 'mosaic-console-appearance:acme')), { theme: 'grove', mode: 'light' });
|
|
await b.evaluate('window.MosaicAppearance.organization("beta", { theme: "atlantic", mode: "dim" }, "Beta")');
|
|
s = await state(b);
|
|
assert.deepEqual([s.theme, s.mode, s.themeSource, s.modeSource, s.override], ['atlantic', 'dim', 'organization', 'organization', null], 'another organization starts from its own saved theme');
|
|
assert.equal(await stored(b, 'mosaic-console-appearance:beta'), null);
|
|
assert.match(await b.evaluate('document.querySelector("#org-cue").textContent'), /Beta · Atlantic theme · set by the organization/);
|
|
await b.evaluate('window.MosaicAppearance.organization("acme", { theme: "cobalt" }, "Acme")');
|
|
s = await state(b);
|
|
assert.deepEqual([s.theme, s.mode], ['grove', 'light'], 'switching back finds the first organization\'s own choice');
|
|
});
|
|
|
|
test('colour is never the only cue to the organization: its name and the theme are written out, in a visible element', { timeout: 90000 }, async t => {
|
|
const { b } = await open(t, { saved: { theme: 'mineral', mode: 'dark' } });
|
|
const cue = await b.evaluate('(() => { const e = document.querySelector("#org-cue"); const r = e.getBoundingClientRect(); return { text: e.textContent, hidden: e.hidden, visible: e.getClientRects().length > 0 && r.width > 0 && r.height > 0, inView: r.right <= innerWidth }; })()');
|
|
assert.equal(cue.hidden, false); assert.equal(cue.visible, true); assert.equal(cue.inView, true);
|
|
assert.equal(cue.text, 'acme · Mineral theme · set by the organization');
|
|
// The default theme needs no cue: nothing changed from today.
|
|
const plain = await open(t, { saved: { theme: 'harbor' } });
|
|
assert.equal(await plain.b.evaluate('document.querySelector("#org-cue").hidden'), true);
|
|
});
|
|
|
|
test('the Settings picker lists what /api/themes answers, Harbor first, and a choice applies and is kept per organization', { timeout: 120000 }, async t => {
|
|
const { b, wait } = await open(t, { saved: { theme: 'cobalt' }, hash: '#/settings/appearance' });
|
|
await wait('document.querySelectorAll("input[name=palette]").length === 10');
|
|
const listed = await b.evaluate('[...document.querySelectorAll("input[name=palette]")].map(e => e.value)');
|
|
// The ten that ship keep the order the page has always shown (Brand.palettes), not the server's order by id.
|
|
assert.deepEqual(listed, await b.evaluate('Brand.palettes.map(p => p.id)'));
|
|
assert.deepEqual([...listed].sort(), THEMES.map(x => x.id).sort());
|
|
assert.equal(await b.evaluate('document.querySelector("input[name=palette]:checked").value'), 'cobalt');
|
|
await b.evaluate('document.querySelector("input[name=palette][value=grove]").click()');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'grove');
|
|
assert.deepEqual(JSON.parse(await stored(b, 'mosaic-console-appearance:acme')), { theme: 'grove' });
|
|
await b.call('Page.reload');
|
|
await wait('!!window.MosaicAppearance');
|
|
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'grove', 'the choice survives a reload');
|
|
assert.deepEqual((await state(b)).saved, { theme: 'cobalt' });
|
|
});
|
|
|
|
test('Settings in the default state looks as it always has: the shipped order, no source note, no clear button', { timeout: 90000 }, async t => {
|
|
const { b, wait } = await open(t, { org: null, hash: '#/settings/appearance' });
|
|
await wait('document.querySelectorAll("input[name=palette]").length === 10');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("input[name=palette]")].map(e => e.value)'), await b.evaluate('Brand.palettes.map(p => p.id)'));
|
|
assert.equal(await b.evaluate('document.querySelector("input[name=palette]:checked").value'), 'harbor');
|
|
for (const sel of ['.settings-source', '[data-notes]', '[data-theme-clear]']) assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(sel)})`), null, `${sel} is absent while everything is the built-in default`);
|
|
});
|
|
|
|
test('a choice adds the source note and the way back; clearing it removes both; a theme the Console has not heard of is listed after the ten', { timeout: 120000 }, async t => {
|
|
const extra = { id: 'zz-local', displayName: 'Aa Local', tokens: THEMES[0].tokens };
|
|
const { b, wait } = await open(t, { org: null, themes: [...THEMES, extra], hash: '#/settings/appearance' });
|
|
await wait('document.querySelectorAll("input[name=palette]").length === 11');
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll("input[name=palette]")].at(-1).value'), 'zz-local');
|
|
await b.evaluate('document.querySelector("input[name=palette][value=grove]").click()');
|
|
await wait('document.querySelector(".settings-source") && document.querySelector("[data-theme-clear]")');
|
|
assert.match(await b.evaluate('document.querySelector(".settings-source").textContent'), /Showing Grove \(your choice in this browser\) in dark mode \(the built-in default\)/);
|
|
await b.evaluate('document.querySelector("[data-theme-clear]").click()');
|
|
await wait('!document.querySelector(".settings-source") && !document.querySelector("[data-theme-clear]")');
|
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
|
});
|
|
|
|
test('at phone width the warning, cue and picker stay inside the page', { timeout: 90000 }, async t => {
|
|
const { b, wait } = await open(t, { saved: { theme: 'grove' }, themes: THEMES.filter(x => x.id !== 'grove'), width: 390, hash: '#/settings/appearance' });
|
|
await wait('document.querySelectorAll(".palette-tile").length >= 10');
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
|
const out = await b.evaluate('[...document.querySelectorAll("#theme-warning, #org-cue")].filter(e => !e.hidden).filter(e => e.getBoundingClientRect().right > innerWidth).map(e => e.id)');
|
|
assert.deepEqual(out, []);
|
|
});
|