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>
78 lines
3.3 KiB
JavaScript
78 lines
3.3 KiB
JavaScript
// 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;
|
|
}
|