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.
58 lines
6.0 KiB
JavaScript
58 lines
6.0 KiB
JavaScript
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
import { fixture } from '../fixture.mjs';
|
|
import { browser } from '../browser.mjs';
|
|
|
|
export const oldId = 'ec3c0ac1-030b-495c-b2de-3711acc0646d';
|
|
export const requestId = 'b1869a13-b328-408a-b4f7-50ded79aeb15';
|
|
export const currentId = '89cc5c95-31e8-4d0d-a2ca-25f5c3f3253e';
|
|
export const old = { id: oldId, at: '2026-11-03T22:17:22.717Z', raised_by_role: 'pm', raised_by_run: 'rcfdc22434131', class: 'within-role', action: 'prd.approve', route_to: 'human', question: 'Historic decision: choose a path', options: [{ key: 'keep', text: 'Keep original' }], blocking: false, console: { kind: 'decision', digest: 'a'.repeat(64), options: [{ key: 'keep', text: 'Keep original' }] } };
|
|
export const current = { ...old, id: currentId, at: '2026-11-06T12:00:00.000Z', raised_by_role: 'chief', raised_by_run: 'current-run', question: 'Current request: choose a mode', options: [{ key: 'guided', text: 'Guided' }], console: { kind: 'decision', digest: 'b'.repeat(64), options: [{ key: 'guided', text: 'Guided' }] } };
|
|
export async function setup(t) {
|
|
const f = await fixture(), b = await browser({ machine: false });
|
|
t.after(async () => { await b.close(); await f.close(); });
|
|
const script = await readFile(new URL('../../src/public/inbox.js', import.meta.url), 'utf8');
|
|
const css = await readFile(new URL('../../src/public/inbox.css', import.meta.url), 'utf8');
|
|
const source = `(() => {
|
|
${script}
|
|
document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style'); style.textContent = ${JSON.stringify(css)}; document.head.append(style); });
|
|
const rows = ${JSON.stringify([old, current])};
|
|
window.__request = { id: ${JSON.stringify(requestId)}, currentWaitSubject: ${JSON.stringify(currentId)} };
|
|
window.__posts = []; window.__outcome = 'success';
|
|
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: {'content-type':'application/json'} });
|
|
const real = window.fetch.bind(window);
|
|
window.fetch = async (url, init = {}) => {
|
|
const u = new URL(String(url), location.href);
|
|
if (u.origin !== location.origin) throw new Error('External network refused');
|
|
if (!u.pathname.startsWith('/api/')) return real(url, init);
|
|
if (u.pathname === '/api/processing' || u.pathname === '/api/bus-status') return json({error:'unavailable', message:'Fixture service unavailable'}, 503);
|
|
if (u.pathname === '/api/config') return json({sso:true, send:true, decide:true});
|
|
if (u.pathname === '/api/themes') return real(url, init); // S4: the installed theme modules, read-only, from the fixture's own server
|
|
if (u.pathname === '/api/business') return json({projects:[]});
|
|
if (u.pathname === '/api/chat/projects' || u.pathname === '/api/chat/threads') return json({rows:[]});
|
|
if (u.pathname === '/api/bus/inbox') return json({rows, at:'2026-11-06T13:00:00.000Z'});
|
|
if (u.pathname === '/api/bus/agents' || u.pathname === '/api/bus/messages') return json({rows:[], unread:0, at:'2026-11-06T13:00:00.000Z'});
|
|
if (u.pathname === '/api/bus/trail') { const row = rows.find(x => x.id === u.searchParams.get('subject')); return json({ rows: row ? [{ ...row, table:'decisions' }] : [], at:'2026-11-06T13:00:00.000Z' }); }
|
|
if (u.pathname.startsWith('/api/decisions/')) {
|
|
window.__posts.push({path:u.pathname, body:JSON.parse(init.body)});
|
|
const id = u.pathname.split('/')[3], row = rows.find(x => x.id === id), choice = JSON.parse(init.body).choice, option = row?.options.find(x => x.key === choice);
|
|
if (!option) return json({error:'refused',message:'Unknown choice'}, 403);
|
|
if (u.pathname.endsWith('/confirmation')) return json({id,kind:'decision',action:'answer',question:row.question,choice,text:option.text,confirmation:'c'.repeat(64)});
|
|
if (window.__outcome === 'unknown') return json({error:'outcome-unknown',message:'The answer may have reached the bus. Refresh before trying again.'}, 503);
|
|
return json({id,op:'resolved',choice});
|
|
}
|
|
window.__unexpected = (window.__unexpected || []).concat(u.pathname); return json({error:'unexpected',message:'Fixture refused'}, 403);
|
|
};
|
|
})();`;
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
await b.call('Network.enable');
|
|
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
|
const failures = [], consoleErrors = [];
|
|
b.on('Runtime.exceptionThrown', p => consoleErrors.push(p.exceptionDetails.text));
|
|
b.on('Runtime.consoleAPICalled', p => { if (p.type === 'error') consoleErrors.push(p.args.map(a => a.value ?? a.description ?? '').join(' ')); });
|
|
b.on('Fetch.requestPaused', p => { const url = p.request.url; const allowed = url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:'); if (!allowed) failures.push(url); b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? {requestId:p.requestId} : {requestId:p.requestId,errorReason:'BlockedByClient'}).catch(() => {}); });
|
|
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
|
|
async function page(route, width) { await b.viewport(width,1000); await b.navigate(f.base + '/#' + route); await wait('document.querySelector("#bus-view h1") && !document.querySelector("#bus-view [aria-busy]")'); await b.evaluate('document.fonts.ready'); }
|
|
async function shot(state, width) { if (!process.env.MOSAIC_UX_SHOTS) return; const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir,{recursive:true}); const name = `inbox-${state}-${width}`; await b.screenshot(`${dir}/${name}.png`); const {contentSize} = await b.call('Page.getLayoutMetrics'); const {data} = await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(contentSize.width),height:Math.ceil(contentSize.height),scale:1}}); await writeFile(`${dir}/${name}-full.png`,Buffer.from(data,'base64')); }
|
|
return { f,b,wait,page,shot,failures,consoleErrors };
|
|
}
|