diff --git a/lib/api/index.js b/lib/api/index.js index 4730b16..88bc540 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -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); diff --git a/lib/api/routes/theme.js b/lib/api/routes/theme.js new file mode 100644 index 0000000..5365e64 --- /dev/null +++ b/lib/api/routes/theme.js @@ -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 -- 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)); +})); diff --git a/package-lock.json b/package-lock.json index 2ac2a00..6ca6c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f1f24dd..73e70ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.9.0", + "version": "2.10.0", "type": "module", "private": true, "scripts": { diff --git a/public/app.js b/public/app.js index 4747277..1eb94d8 100644 --- a/public/app.js +++ b/public/app.js @@ -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')); diff --git a/public/style.css b/public/style.css index 3fcb1de..f8fac5f 100644 --- a/public/style.css +++ b/public/style.css @@ -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 */ diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 0000000..4761401 --- /dev/null +++ b/public/theme.js @@ -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; +} diff --git a/public/views/settings.js b/public/views/settings.js index 17f7318..7d64488 100644 --- a/public/views/settings.js +++ b/public/views/settings.js @@ -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),