1 Commits

Author SHA1 Message Date
root
792431f65f feat(theming): in-UI theme editor (2.10.0)
Recolour the whole UI from Settings — 12 palette colour pickers with
live preview, presets (Ember/Frost/Verdant/Amethyst), and reset to the
default Blackflame. Overrides persist in app_settings (key 'theme') via
a hex-validated /api/theme route and apply to :root on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:01:48 +10:00
8 changed files with 163 additions and 3 deletions

View File

@@ -37,6 +37,7 @@ import { router as clusterRouter } from './routes/cluster.js';
import { router as storageRouter } from './routes/storage.js'; import { router as storageRouter } from './routes/storage.js';
import { router as backupsRouter } from './routes/backups.js'; import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js'; import { router as kuttRouter } from './routes/kutt.js';
import { router as themeRouter } from './routes/theme.js';
export function mountApi(app) { export function mountApi(app) {
const api = Router(); const api = Router();
@@ -71,6 +72,7 @@ export function mountApi(app) {
api.use('/tags', tagsRouter); api.use('/tags', tagsRouter);
api.use('/links', linksRouter); api.use('/links', linksRouter);
api.use('/kutt', kuttRouter); api.use('/kutt', kuttRouter);
api.use('/theme', themeRouter);
api.use('/pending-changes', pendingChangesRouter); api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter); api.use('/audit', auditRouter);
api.use('/search', searchRouter); api.use('/search', searchRouter);

21
lib/api/routes/theme.js Normal file
View File

@@ -0,0 +1,21 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import * as settings from '../../db/repos/app_settings.js';
export const router = Router();
// Theme = a small map of palette-var overrides, e.g. { accent: '#ff4f2e' }.
// Keys are short slugs (mapped to --<key> on the client); values must be hex,
// so a saved theme can never inject arbitrary CSS.
const themeSchema = z.record(
z.string().regex(/^[a-z0-9-]{1,24}$/),
z.string().regex(/^#[0-9a-fA-F]{3,8}$/)
);
router.get('/', asyncWrap(async (_req, res) => res.json(await settings.get('theme', {}))));
router.put('/', requireOwner, validate({ body: themeSchema }), asyncWrap(async (req, res) => {
res.json(await settings.set('theme', req.body));
}));

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.9.0", "version": "2.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "void-server", "name": "void-server",
"version": "2.9.0", "version": "2.10.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.9.0", "version": "2.10.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -11,6 +11,7 @@ import { emit, state } from './state.js';
import { el, mount } from './dom.js'; import { el, mount } from './dom.js';
import { attachDropzone } from './components/dropzone.js'; import { attachDropzone } from './components/dropzone.js';
import { initChrome } from './components/chrome.js'; import { initChrome } from './components/chrome.js';
import { loadTheme } from './theme.js';
const VIEWS = { const VIEWS = {
home: () => import('./views/home.js'), home: () => import('./views/home.js'),
@@ -80,6 +81,7 @@ async function init() {
try { await api.get('/api/spaces'); } try { await api.get('/api/spaces'); }
catch { /* api wrapper opens the modal on 401 */ } catch { /* api wrapper opens the modal on 401 */ }
} }
await loadTheme(); // apply saved palette overrides before rendering chrome
renderTopbar(document.getElementById('topbar')); renderTopbar(document.getElementById('topbar'));
renderSidebar(document.getElementById('sidebar')); renderSidebar(document.getElementById('sidebar'));
renderRightrail(document.getElementById('rightrail')); renderRightrail(document.getElementById('rightrail'));

View File

@@ -699,6 +699,12 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.st-table td { padding: 5px 10px; border-bottom: 1px solid #ffffff08; } .st-table td { padding: 5px 10px; border-bottom: 1px solid #ffffff08; }
.st-table td.num { text-align: right; } .st-table td.num { text-align: right; }
/* ---- Theming panel ---- */
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 18px; margin-bottom: 14px; }
.theme-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--muted); }
.theme-row input[type=color] { width: 40px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 4px; background: none; cursor: pointer; }
.theme-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.hidden { display: none !important; } .hidden { display: none !important; }
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */ /* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */

77
public/theme.js Normal file
View File

@@ -0,0 +1,77 @@
// Theming: a small map of palette-var overrides persisted in app_settings and
// applied to :root on boot. The whole UI is CSS-custom-property driven, so
// setting these vars recolours everything live. (Canvas-drawn colours — the
// blackflame card — and a few inline rgba() literals don't follow the theme.)
import { api } from './api.js';
export const THEME_VARS = [
{ key: 'accent', css: '--accent', label: 'Accent (flame)' },
{ key: 'accent-dim', css: '--accent-dim', label: 'Accent · dim' },
{ key: 'accent-soft', css: '--accent-soft', label: 'Accent · soft' },
{ key: 'bg', css: '--bg', label: 'Background' },
{ key: 'panel', css: '--panel', label: 'Panel' },
{ key: 'panel-2', css: '--panel-2', label: 'Panel · raised' },
{ key: 'border', css: '--border', label: 'Border' },
{ key: 'text', css: '--text', label: 'Text' },
{ key: 'muted', css: '--muted', label: 'Muted text' },
{ key: 'ok', css: '--ok', label: 'OK / good' },
{ key: 'warn', css: '--warn', label: 'Warning' },
{ key: 'bad', css: '--bad', label: 'Bad / error' }
];
const BY_KEY = Object.fromEntries(THEME_VARS.map(v => [v.key, v]));
// Named alternates. Blackflame = {} (clear overrides → CSS defaults).
export const PRESETS = {
Blackflame: {},
Ember: { accent: '#ff7a1a', 'accent-dim': '#8a3a10', 'accent-soft': '#3a1a0a', bg: '#0c0907', panel: '#171008', 'panel-2': '#20160c' },
Frost: { accent: '#4aa3ff', 'accent-dim': '#1e5a8a', 'accent-soft': '#0e2230', bg: '#070a0e', panel: '#0f141c', 'panel-2': '#161d28', ok: '#5fb0c4' },
Verdant: { accent: '#5fc46a', 'accent-dim': '#2a6a30', 'accent-soft': '#10240f', bg: '#070b08', panel: '#0f160f', 'panel-2': '#161f16' },
Amethyst: { accent: '#a86adf', 'accent-dim': '#5a2e8a', 'accent-soft': '#1e1030', bg: '#0a0810', panel: '#140f1c', 'panel-2': '#1c1528' }
};
export function applyTheme(vars = {}) {
const root = document.documentElement;
for (const [k, val] of Object.entries(vars)) {
const def = BY_KEY[k];
if (def && val) root.style.setProperty(def.css, val);
}
}
export function clearTheme() {
const root = document.documentElement;
for (const v of THEME_VARS) root.style.removeProperty(v.css);
}
// Current effective value of a var (override or CSS default), normalised to #rrggbb.
export function effectiveHex(key) {
const def = BY_KEY[key];
if (!def) return '#000000';
const raw = getComputedStyle(document.documentElement).getPropertyValue(def.css).trim();
return toHex6(raw) || '#000000';
}
export function toHex6(v) {
if (!v) return '';
v = v.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
if (/^#[0-9a-fA-F]{8}$/.test(v)) return v.slice(0, 7).toLowerCase(); // drop alpha
if (/^#[0-9a-fA-F]{3}$/.test(v)) return '#' + v.slice(1).split('').map(c => c + c).join('').toLowerCase();
const m = v.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
if (m) return '#' + [m[1], m[2], m[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
return '';
}
let current = {};
export function currentTheme() { return { ...current }; }
export async function loadTheme() {
try { current = (await api.get('/api/theme')) || {}; applyTheme(current); }
catch { /* defaults */ }
return current;
}
export async function saveTheme(vars) {
current = (await api.put('/api/theme', vars)) || {};
clearTheme(); applyTheme(current);
return current;
}

View File

@@ -2,6 +2,57 @@
import { el, mount } from '../dom.js'; import { el, mount } from '../dom.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js'; import { iconSetsPanel } from './icon_sets_panel.js';
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
// Theming — colour pickers for the palette, live-preview on input, presets +
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
function themingBody() {
const cur = currentTheme(); // saved overrides (subset of vars)
const grid = el('div', { class: 'theme-grid' });
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
function rebuild() {
mount(grid, THEME_VARS.map(v => {
const inp = el('input', { type: 'color', value: cur[v.key] ? toHex6(cur[v.key]) : effectiveHex(v.key) });
inp.addEventListener('input', () => {
cur[v.key] = inp.value;
document.documentElement.style.setProperty(v.css, inp.value); // live preview
});
return el('label', { class: 'theme-row' }, el('span', {}, v.label), inp);
}));
}
rebuild();
const preset = el('select', { class: 'pm-input', style: { maxWidth: '160px' } },
el('option', { value: '' }, 'Apply preset…'),
...Object.keys(PRESETS).map(n => el('option', { value: n }, n)));
preset.addEventListener('change', () => {
if (!preset.value) return;
clearTheme();
for (const k of Object.keys(cur)) delete cur[k];
Object.assign(cur, PRESETS[preset.value]);
applyTheme(cur);
rebuild();
preset.value = '';
});
const save = el('button', { class: 'primary' }, 'Save theme');
save.onclick = async () => {
try { await saveTheme(cur); out.textContent = 'Saved — applies everywhere.'; }
catch { out.textContent = 'Save failed'; }
};
const reset = el('button', { class: 'ghost' }, 'Reset to Blackflame');
reset.onclick = async () => {
for (const k of Object.keys(cur)) delete cur[k];
clearTheme();
try { await saveTheme({}); rebuild(); out.textContent = 'Reset to default.'; }
catch { out.textContent = 'Reset failed'; }
};
return el('div', { class: 'settings-body' },
grid,
el('div', { class: 'theme-actions' }, preset, save, reset, out));
}
function section(title, sub, bodyEl) { function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' }, return el('div', { class: 'card settings-card' },
@@ -120,6 +171,7 @@ export async function render(main) {
mount(main, mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'), el('h1', { class: 'view-h1' }, '◆ Settings'),
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody), section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody), section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap), section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),