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>
This commit is contained in:
@@ -37,6 +37,7 @@ import { router as clusterRouter } from './routes/cluster.js';
|
||||
import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as backupsRouter } from './routes/backups.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
import { router as themeRouter } from './routes/theme.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -71,6 +72,7 @@ export function mountApi(app) {
|
||||
api.use('/tags', tagsRouter);
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/kutt', kuttRouter);
|
||||
api.use('/theme', themeRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
|
||||
21
lib/api/routes/theme.js
Normal file
21
lib/api/routes/theme.js
Normal 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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { emit, state } from './state.js';
|
||||
import { el, mount } from './dom.js';
|
||||
import { attachDropzone } from './components/dropzone.js';
|
||||
import { initChrome } from './components/chrome.js';
|
||||
import { loadTheme } from './theme.js';
|
||||
|
||||
const VIEWS = {
|
||||
home: () => import('./views/home.js'),
|
||||
@@ -80,6 +81,7 @@ async function init() {
|
||||
try { await api.get('/api/spaces'); }
|
||||
catch { /* api wrapper opens the modal on 401 */ }
|
||||
}
|
||||
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||
renderTopbar(document.getElementById('topbar'));
|
||||
renderSidebar(document.getElementById('sidebar'));
|
||||
renderRightrail(document.getElementById('rightrail'));
|
||||
|
||||
@@ -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.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; }
|
||||
|
||||
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
|
||||
|
||||
77
public/theme.js
Normal file
77
public/theme.js
Normal 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;
|
||||
}
|
||||
@@ -2,6 +2,57 @@
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.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) {
|
||||
return el('div', { class: 'card settings-card' },
|
||||
@@ -120,6 +171,7 @@ export async function render(main) {
|
||||
|
||||
mount(main,
|
||||
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('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),
|
||||
|
||||
Reference in New Issue
Block a user