Files
jason.woltje 7fdb488270 Themes as installed plugins, chosen per organization (S4, plan C5)
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.
2026-11-07 12:18:43 -06:00

31 lines
1.9 KiB
JavaScript

// GET /api/themes (stream S4, PLAN C5): the installed, valid colour themes, found by listing
// packages/plugins/theme/ on each request, so removing a module's directory takes its theme
// out of the answer at once and putting it back returns it. The answer is data the Settings
// picker and the page's theme code use: { themes: [{ id, displayName, tokens }] } with tokens
// { light, dim, dark } of #rrggbb values, checked by plugins/src/theme.mjs. A module that is
// refused is not in the answer; the refusal goes to the Console's log, once per distinct reason
// so a poll does not repeat it. Nothing here reads a request body, a header or a query.
import { installedThemes } from '../../plugins/src/theme.mjs';
const json = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
export async function themesHTTP(req, res, { log = () => {}, list = installedThemes, seen = themesHTTP.seen } = {}) {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
try {
const { themes } = await list({
log: entry => {
const key = `${entry.name}\0${entry.code}\0${entry.message}`;
if (seen.has(key)) return;
seen.add(key);
log(`${new Date().toISOString()} theme refused name=${JSON.stringify(entry.name)} code=${JSON.stringify(entry.code)} message=${JSON.stringify(entry.message)}`);
},
});
return json(res, 200, { themes: themes.map(({ id, displayName, tokens }) => ({ id, displayName, tokens })) });
} catch (err) {
log(`${new Date().toISOString()} theme list failed error=${JSON.stringify(String(err?.message ?? err).slice(0, 200))}`);
return json(res, 500, { error: 'themes-unavailable', message: 'the installed themes could not be read' });
}
}
// What this process has already logged; a test passes its own set.
themesHTTP.seen = new Set();