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.
94 lines
9.3 KiB
JavaScript
94 lines
9.3 KiB
JavaScript
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { fixture } from '../fixture.mjs';
|
|
import { browser } from '../browser.mjs';
|
|
|
|
export const THREAD = '20000000-0000-4000-8000-000000000002';
|
|
export const MESSAGE = '40000000-0000-4000-8000-000000000004';
|
|
export const REQUEST = '50000000-0000-4000-8000-000000000005';
|
|
export const DECISION = '60000000-0000-4000-8000-000000000006';
|
|
export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra });
|
|
export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] };
|
|
export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } };
|
|
export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
|
|
export const wait = (kind, code, subject) => ({ kind, code, subject });
|
|
export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] };
|
|
|
|
export async function setup(t, boot, { forwardSend = null } = {}) {
|
|
const thread = boot.thread || THREAD, project = boot.project || thread, requestId = boot.request || REQUEST;
|
|
const f = await fixture(); t.after(() => f.close());
|
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
|
const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8');
|
|
const source = `(() => {
|
|
const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } };
|
|
const realFetch = window.fetch.bind(window);
|
|
const boot = ${JSON.stringify(boot)}; window.__archiveFailure = boot.archiveFailure; window.__archiveRows = boot.archiveRows || []; window.__liveProjects = []; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
|
|
document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail));
|
|
document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; });
|
|
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
|
|
window.fetch = async (url, init = {}) => {
|
|
const path = String(url), method = init.method || 'GET';
|
|
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
|
|
if (method === 'POST' && boot.forwardSend && (path.endsWith('/messages') || path.endsWith('/attach'))) return realFetch(url, init);
|
|
if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); }
|
|
if (path === '/api/config') return json({send:true, requests:true, requestWrites:true});
|
|
if (path === '/api/processing') return json({version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false});
|
|
if (path === '/api/bus-status') return json({down:false});
|
|
if (path === '/api/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'});
|
|
if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'});
|
|
if (path === '/api/business') return json({projects:[]});
|
|
if (path === '/api/themes') return realFetch(url, init); // S4: the installed theme modules, read-only, from the fixture's own server
|
|
if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]});
|
|
if (path === '/api/chat/projects') return json({rows:window.__liveProjects});
|
|
if (path === '/api/chat/projects/archived') {
|
|
if (window.__archiveFailure === 'network') throw new TypeError('fixture network failure');
|
|
if (window.__archiveFailure === 'malformed') return json({rows: 'invalid'});
|
|
if (window.__archiveFailure) return json({error:'unavailable'}, 403);
|
|
return json({rows:window.__archiveRows});
|
|
}
|
|
if (path.startsWith('/api/chat/threads') && path.endsWith('/shares')) return json({rows:boot.shares || []});
|
|
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true,state:'active',revision:1}]});
|
|
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${thread}',project:'${project}',name:'Project',instance:'assistant'}]});
|
|
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${requestId}'}],after:'${MESSAGE}',hasMore:false});
|
|
const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path);
|
|
if (m) { if (m[2] === 'projects') return json(boot.projects || []); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
|
|
window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403);
|
|
};
|
|
})();`;
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] });
|
|
const network = [], consoleErrors = [];
|
|
b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception'));
|
|
b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); });
|
|
b.on('Fetch.requestPaused', e => {
|
|
const url = e.request.url;
|
|
if (forwardSend && e.request.method === 'POST' && (url.endsWith('/messages') || url.endsWith('/attach'))) {
|
|
void forwardSend(e.request).then(result => b.call('Fetch.fulfillRequest', { requestId:e.requestId, responseCode:result.status, responseHeaders:[{name:'content-type',value:'application/json'}], body:Buffer.from(JSON.stringify(result.body)).toString('base64') })).catch(() => b.call('Fetch.failRequest', { requestId:e.requestId, errorReason:'Failed' }));
|
|
return;
|
|
}
|
|
if (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {});
|
|
else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); }
|
|
});
|
|
const route = `#/projects/threads/${thread}`;
|
|
const navigate = async width => {
|
|
await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route);
|
|
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
|
};
|
|
const shot = async (name, width) => {
|
|
if (!process.env.MOSAIC_UX_SHOTS) return [];
|
|
await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true});
|
|
const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`);
|
|
const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`);
|
|
await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true");
|
|
await b.screenshot(viewport);
|
|
const {cssContentSize} = await b.call('Page.getLayoutMetrics');
|
|
const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}});
|
|
await writeFile(full, Buffer.from(data,'base64'));
|
|
const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`);
|
|
await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()");
|
|
await b.screenshot(continuation);
|
|
return [viewport,full,continuation];
|
|
};
|
|
return {f,b,route,navigate,shot,network,consoleErrors};
|
|
}
|