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.
67 lines
5.9 KiB
JavaScript
67 lines
5.9 KiB
JavaScript
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { fixture } from './fixture.mjs';
|
|
import { browser } from './browser.mjs';
|
|
|
|
export const ids = Object.freeze({ project: '10000000-0000-4000-8000-000000000001', empty: '10000000-0000-4000-8000-000000000002', archived: '10000000-0000-4000-8000-000000000003', repository: 'fixture-repository', thread: '20000000-0000-4000-8000-000000000001', session: '30000000-0000-4000-8000-000000000001', observed: '2026-11-05T12:00:00.000Z' });
|
|
|
|
// All API responses are explicit, and all network destinations outside the fixture
|
|
// origin are refused. Tests may override a response by assigning window.__scenario.
|
|
export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
|
|
const f = await fixture(); t.after(() => f.close());
|
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
|
const errors = []; const networkErrors = [];
|
|
b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); });
|
|
b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText}));
|
|
await b.call('Network.enable');
|
|
b.on('Fetch.requestPaused', ({ requestId, request }) => {
|
|
const url = new URL(request.url);
|
|
const allowed = url.origin === f.base;
|
|
void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e)));
|
|
});
|
|
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
|
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario=${JSON.stringify(scenario)};
|
|
addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason)));
|
|
const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}});
|
|
const real = fetch.bind(window);
|
|
window.fetch = async (input, init={}) => {
|
|
const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase();
|
|
if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); }
|
|
if (!url.pathname.startsWith('/api/')) return real(input,init);
|
|
if(method!=='GET') __writes.push({method,path,body:init.body||null});
|
|
const key = method+' '+path;
|
|
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
|
|
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
|
|
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
|
|
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status' || path==='/api/queue') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
|
|
if(path==='/api/themes') return real(input,init); // S4: the installed theme modules, read-only, from the fixture's own server
|
|
if(path==='/api/config') return data({send:true,edit:false,decide:false});
|
|
if(path==='/api/business') return data({projects:[ids.repository]});
|
|
if(path==='/api/chat/projects/archived') return data({rows:[]});
|
|
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
|
|
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
|
|
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});
|
|
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
|
|
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
|
|
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
|
|
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
|
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
|
|
};
|
|
})();` });
|
|
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,40));}throw new Error('Timed out: '+${JSON.stringify(expression)})})()`);
|
|
async function capture(state, width, route) {
|
|
if (!process.env.MOSAIC_UX_SHOTS) return;
|
|
const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true });
|
|
await b.evaluate('document.fonts.ready');
|
|
const prefix = `${slice}-${state}-${width}`;
|
|
await b.screenshot(join(dir, `${prefix}.png`));
|
|
const metrics = await b.call('Page.getLayoutMetrics');
|
|
const size = metrics.cssContentSize || metrics.contentSize;
|
|
const { data } = await b.call('Page.captureScreenshot', { format:'png', captureBeyondViewport:true, clip:{x:0,y:0,width:Math.ceil(size.width),height:Math.ceil(size.height),scale:1} });
|
|
await writeFile(join(dir, `${prefix}-full.png`), Buffer.from(data,'base64'));
|
|
return {state,route,width,height:1000,observation:ids.observed,viewport:`${prefix}.png`,full:`${prefix}-full.png`,errors:[...errors,...await b.evaluate('window.__errors')],unexpected:await b.evaluate('window.__unexpected'),networkErrors:[...networkErrors]};
|
|
}
|
|
return { f, b, wait, capture, errors };
|
|
}
|