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:
root
2026-06-09 23:01:48 +10:00
parent 359ae21d59
commit 792431f65f
8 changed files with 163 additions and 3 deletions

View File

@@ -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),