// 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('an organization browser override draws system and explicit light from the light colours, including after reload', { timeout: 90000 }, async t => { const { b, f } = await open(t, { saved: { theme: 'rosewood', mode: 'dark' } }); const rgb = hex => `rgb(${hex.slice(1).match(/../g).map(n => parseInt(n, 16)).join(', ')})`; const theme = THEMES.find(t => t.id === 'rosewood'); for (const scheme of ['light', 'dark']) { await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] }); for (const mode of ['system', 'light']) { await b.evaluate(`window.MosaicAppearance.set({ mode: ${JSON.stringify(mode)} })`); // Read the actual scoped storage on a new document, not only the in-memory choice. await b.navigate(`${f.base}/?appearance=${scheme}-${mode}`); await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); const expected = mode === 'system' ? scheme : mode; const drawn = await b.evaluate(`({ palette: document.documentElement.dataset.palette, mode: document.documentElement.getAttribute('data-mode'), canvas: getComputedStyle(document.body).backgroundColor, source: window.MosaicAppearance.state().modeSource, override: JSON.parse(localStorage.getItem('mosaic-console-appearance:acme')), })`); assert.deepEqual(drawn, { palette: 'rosewood', mode: mode === 'system' ? null : mode, canvas: rgb(theme.tokens[expected]['--canvas']), source: 'browser', override: { mode } }, `${scheme}/${mode}`); t.diagnostic(JSON.stringify({ scheme, requested: mode, ...drawn })); } } }); 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, []); });