feat(theme): add Theme Editor with live preview, import/export

- ThemeEditor.svelte: 26 color pickers (14 accents + 12 neutrals) with
  native <input type="color"> and hex text input, live CSS preview
- custom-themes.ts: persistence layer (SQLite JSON blob), validation,
  import/export as JSON files, clone from any built-in theme
- theme.svelte.ts: previewPalette/clearPreview for live editing,
  setCustomTheme for persistence, initTheme loads custom themes on startup
- themes.ts: applyPaletteDirect + buildXtermThemeFromPalette + PALETTE_KEYS
- AppearanceSettings.svelte: custom themes list with edit/delete, "New
  Custom Theme" button, ThemeEditor toggle
- All files under 300 lines (296 + 227 + 98)
This commit is contained in:
Hibryda 2026-03-18 01:43:22 +01:00
parent c1149561c7
commit 0953395423
5 changed files with 454 additions and 8 deletions

View file

@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import { type ThemeId, type ThemePalette, THEME_LIST, PALETTE_KEYS } from '../styles/themes';
import { previewPalette, clearPreview, setCustomTheme } from '../stores/theme.svelte';
import {
type CustomTheme, createFromBase, exportThemeJson, importThemeJson,
loadCustomThemes, saveCustomThemes
} from '../styles/custom-themes';
import { handleError } from '../utils/handle-error';
let { editingTheme = $bindable<CustomTheme | null>(null), onclose }: {
editingTheme?: CustomTheme | null;
onclose: () => void;
} = $props();
let name = $state('');
let baseTheme = $state<ThemeId>('mocha');
let palette = $state<ThemePalette>({} as ThemePalette);
let baseOpen = $state(false);
const ACCENT_KEYS: (keyof ThemePalette)[] = [
'rosewater','flamingo','pink','mauve','red','maroon','peach',
'yellow','green','teal','sky','sapphire','blue','lavender',
];
const NEUTRAL_KEYS: (keyof ThemePalette)[] = [
'text','subtext1','subtext0','overlay2','overlay1','overlay0',
'surface2','surface1','surface0','base','mantle','crust',
];
onMount(() => {
if (editingTheme) {
name = editingTheme.label;
baseTheme = editingTheme.baseTheme;
palette = { ...editingTheme.palette };
} else {
name = 'My Theme';
palette = { ...createFromBase('', baseTheme).palette };
}
previewPalette(palette);
});
function updateColor(key: keyof ThemePalette, value: string) {
palette[key] = value;
previewPalette({ ...palette });
}
function handleHexInput(key: keyof ThemePalette, value: string) {
const v = value.startsWith('#') ? value : `#${value}`;
if (/^#[0-9a-fA-F]{6}$/.test(v)) updateColor(key, v);
}
function cloneFrom(themeId: ThemeId) {
baseTheme = themeId;
const fresh = createFromBase(name, themeId);
palette = { ...fresh.palette };
baseOpen = false;
previewPalette({ ...palette });
}
async function handleSave() {
if (!name.trim()) return;
const customs = await loadCustomThemes();
const theme: CustomTheme = {
id: editingTheme?.id ?? `custom:${Date.now()}`,
label: name.trim(),
baseTheme,
isDark: true,
palette: { ...palette },
};
const idx = customs.findIndex(c => c.id === theme.id);
if (idx >= 0) customs[idx] = theme; else customs.push(theme);
await saveCustomThemes(customs);
await setCustomTheme(theme.id, theme.palette);
editingTheme = null;
onclose();
}
function handleCancel() {
clearPreview();
editingTheme = null;
onclose();
}
function handleExport() {
const theme: CustomTheme = {
id: editingTheme?.id ?? 'export',
label: name, baseTheme, isDark: true, palette: { ...palette },
};
const json = exportThemeJson(theme);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${name.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const imported = importThemeJson(text);
name = imported.label;
palette = { ...imported.palette };
previewPalette({ ...palette });
} catch (e) {
handleError(e, 'themeEditor.import', 'import the theme');
}
};
input.click();
}
</script>
<div class="editor">
<div class="header">
<h3>Theme Editor</h3>
<div class="name-row">
<label class="lbl">Name</label>
<input type="text" class="name-input" bind:value={name} placeholder="Theme name" />
</div>
<div class="base-row">
<label class="lbl">Base</label>
<div class="dropdown" class:open={baseOpen}>
<button class="dropdown-btn" onclick={(e) => { e.stopPropagation(); baseOpen = !baseOpen; }}>
{THEME_LIST.find(t => t.id === baseTheme)?.label ?? baseTheme}
</button>
{#if baseOpen}
<div class="dropdown-menu">
{#each THEME_LIST as t}
<button class="dropdown-item" onclick={() => cloneFrom(t.id)}>{t.label}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="colors-scroll">
<details class="group" open>
<summary>Accents ({ACCENT_KEYS.length})</summary>
<div class="color-grid">
{#each ACCENT_KEYS as key}
<div class="color-row">
<label class="color-label">{key}</label>
<input type="color" value={palette[key] ?? '#000000'}
oninput={(e) => updateColor(key, (e.target as HTMLInputElement).value)} />
<input type="text" class="hex-input" value={palette[key] ?? ''}
onchange={(e) => handleHexInput(key, (e.target as HTMLInputElement).value)} />
</div>
{/each}
</div>
</details>
<details class="group" open>
<summary>Neutrals ({NEUTRAL_KEYS.length})</summary>
<div class="color-grid">
{#each NEUTRAL_KEYS as key}
<div class="color-row">
<label class="color-label">{key}</label>
<input type="color" value={palette[key] ?? '#000000'}
oninput={(e) => updateColor(key, (e.target as HTMLInputElement).value)} />
<input type="text" class="hex-input" value={palette[key] ?? ''}
onchange={(e) => handleHexInput(key, (e.target as HTMLInputElement).value)} />
</div>
{/each}
</div>
</details>
</div>
<div class="footer">
<div class="footer-left">
<button class="btn" onclick={handleImport}>Import</button>
<button class="btn" onclick={handleExport}>Export</button>
</div>
<div class="footer-right">
<button class="btn" onclick={handleCancel}>Cancel</button>
<button class="btn primary" onclick={handleSave} disabled={!name.trim()}>Save</button>
</div>
</div>
</div>
<style>
.editor { display: flex; flex-direction: column; height: 100%; gap: 0.75rem; }
.header { display: flex; flex-direction: column; gap: 0.5rem; }
h3 { margin: 0; color: var(--ctp-text); font-size: 1rem; }
.name-row, .base-row { display: flex; align-items: center; gap: 0.5rem; }
.lbl { color: var(--ctp-subtext0); font-size: 0.75rem; text-transform: uppercase; width: 3rem; flex-shrink: 0; }
.name-input { flex: 1; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
color: var(--ctp-text); padding: 0.35rem 0.5rem; border-radius: 0.25rem; font-size: 0.85rem; }
.name-input:focus { outline: none; border-color: var(--ctp-blue); }
.dropdown { position: relative; flex: 1; }
.dropdown-btn { width: 100%; text-align: left; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
color: var(--ctp-text); padding: 0.35rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.85rem; }
.dropdown-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 50;
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
max-height: 12rem; overflow-y: auto; }
.dropdown-item { display: block; width: 100%; text-align: left; padding: 0.3rem 0.5rem;
background: none; border: none; color: var(--ctp-text); cursor: pointer; font-size: 0.8rem; }
.dropdown-item:hover { background: var(--ctp-surface1); }
.colors-scroll { flex: 1; overflow-y: auto; min-height: 0; }
.group { margin-bottom: 0.5rem; }
.group summary { color: var(--ctp-subtext0); font-size: 0.75rem; text-transform: uppercase;
cursor: pointer; padding: 0.25rem 0; user-select: none; }
.color-grid { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.25rem 0; }
.color-row { display: flex; align-items: center; gap: 0.5rem; }
.color-label { width: 5.5rem; color: var(--ctp-subtext1); font-size: 0.8rem; flex-shrink: 0; }
input[type="color"] { width: 2rem; height: 1.5rem; border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem; cursor: pointer; padding: 0; background: none; }
input[type="color"]::-webkit-color-swatch-wrapper { padding: 1px; }
input[type="color"]::-webkit-color-swatch { border: none; border-radius: 0.15rem; }
.hex-input { width: 5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
color: var(--ctp-text); padding: 0.2rem 0.35rem; border-radius: 0.2rem; font-family: monospace; font-size: 0.8rem; }
.hex-input:focus { outline: none; border-color: var(--ctp-blue); }
.footer { display: flex; justify-content: space-between; gap: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--ctp-surface0); }
.footer-left, .footer-right { display: flex; gap: 0.35rem; }
.btn { padding: 0.35rem 0.75rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1);
background: var(--ctp-surface0); color: var(--ctp-text); cursor: pointer; font-size: 0.8rem; }
.btn:hover { background: var(--ctp-surface1); }
.btn.primary { background: var(--ctp-blue); color: var(--ctp-crust); border-color: var(--ctp-blue); }
.btn.primary:hover { opacity: 0.9; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
import { getCurrentTheme, setTheme, setCustomTheme } from '../../stores/theme.svelte';
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
import { getSetting, setSetting } from '../../adapters/settings-bridge';
import { handleError } from '../../utils/handle-error';
import { type CustomTheme, loadCustomThemes, deleteCustomTheme as deleteCustom, saveCustomThemes } from '../../styles/custom-themes';
import ThemeEditor from '../ThemeEditor.svelte';
const UI_FONTS = [
{ value: '', label: 'System Default' },
@ -41,6 +43,9 @@
let themeOpen = $state(false);
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
let customThemes = $state<CustomTheme[]>([]);
let editorOpen = $state(false);
let editingTheme = $state<CustomTheme | null>(null);
let themeGroups = $derived.by(() => {
const map = new Map<string, typeof THEME_LIST>();
@ -72,9 +77,17 @@
if (results[5].status === 'fulfilled') cursorBlink = results[5].value !== 'false'; else failCount++;
if (results[6].status === 'fulfilled' && results[6].value) scrollbackLines = results[6].value; else failCount++;
if (failCount === results.length) loadError = 'Could not load settings. Displaying defaults.';
customThemes = await loadCustomThemes();
});
function pickTheme(id: ThemeId) { selectedTheme = id; themeOpen = false; setTheme(id); }
function pickCustomTheme(ct: CustomTheme) { themeOpen = false; setCustomTheme(ct.id, ct.palette); }
function startNewTheme() { editingTheme = null; editorOpen = true; closeDropdowns(); }
function startEditTheme(ct: CustomTheme) { editingTheme = ct; editorOpen = true; closeDropdowns(); }
async function removeCustomTheme(id: string) {
customThemes = await deleteCustom(customThemes, id);
}
async function closeEditor() { editorOpen = false; customThemes = await loadCustomThemes(); }
async function save(key: string, value: string) {
try { await setSetting(key, value); } catch (e) { handleError(e, `settings.save.${key}`, 'save your settings'); }
}
@ -154,6 +167,27 @@
</div>
</div>
{#if editorOpen}
<ThemeEditor bind:editingTheme onclose={closeEditor} />
{:else}
{#if customThemes.length > 0}
<div class="custom-themes">
{#each customThemes as ct}
<div class="custom-theme-row">
<button class="custom-theme-btn" onclick={() => pickCustomTheme(ct)}>
<span class="color-dot" style="background: {ct.palette.blue};"></span>
<span class="color-dot" style="background: {ct.palette.green};"></span>
{ct.label}
</button>
<button class="icon-btn" onclick={() => startEditTheme(ct)} aria-label="Edit {ct.label}"></button>
<button class="icon-btn danger" onclick={() => removeCustomTheme(ct.id)} aria-label="Delete {ct.label}"></button>
</div>
{/each}
</div>
{/if}
<button class="new-theme-btn" onclick={startNewTheme}>+ New Custom Theme</button>
{/if}
<h3>UI Font</h3>
<div class="field row" id="setting-ui-font">
<div class="custom-dropdown flex1">
@ -245,4 +279,18 @@
input[type="number"] { width: 5rem; padding: 0.375rem 0.5rem; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface0); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; }
input[type="number"]:focus { border-color: var(--ctp-blue); outline: none; }
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: 0.5rem; }
.custom-themes { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; }
.custom-theme-row { display: flex; align-items: center; gap: 0.25rem; }
.custom-theme-btn { flex: 1; display: flex; align-items: center; gap: 0.375rem; padding: 0.3rem 0.5rem;
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface0); border-radius: 0.25rem;
color: var(--ctp-text); font-size: 0.8rem; cursor: pointer; text-align: left; }
.custom-theme-btn:hover { border-color: var(--ctp-surface1); }
.icon-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.85rem;
padding: 0.2rem; border-radius: 0.15rem; }
.icon-btn:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
.icon-btn.danger:hover { color: var(--ctp-red); }
.new-theme-btn { margin-top: 0.25rem; padding: 0.3rem 0.5rem; background: none;
border: 1px dashed var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-overlay1);
font-size: 0.8rem; cursor: pointer; }
.new-theme-btn:hover { border-color: var(--ctp-blue); color: var(--ctp-blue); }
</style>

View file

@ -4,14 +4,18 @@ import { getSetting, setSetting } from '../adapters/settings-bridge';
import { handleInfraError } from '../utils/handle-error';
import {
type ThemeId,
type ThemePalette,
type CatppuccinFlavor,
ALL_THEME_IDS,
buildXtermTheme,
buildXtermThemeFromPalette,
applyCssVariables,
applyPaletteDirect,
type XtermTheme,
} from '../styles/themes';
let currentTheme = $state<ThemeId>('mocha');
let customPalette = $state<ThemePalette | null>(null);
/** Registered theme-change listeners */
const themeChangeCallbacks = new Set<() => void>();
@ -36,9 +40,47 @@ export function getCurrentFlavor(): CatppuccinFlavor {
}
export function getXtermTheme(): XtermTheme {
if (customPalette) return buildXtermThemeFromPalette(customPalette);
return buildXtermTheme(currentTheme);
}
/** Apply an arbitrary palette for live preview (does NOT persist) */
export function previewPalette(palette: ThemePalette): void {
customPalette = palette;
applyPaletteDirect(palette);
for (const cb of themeChangeCallbacks) {
try { cb(); } catch (e) { handleInfraError(e, 'theme.previewCallback'); }
}
}
/** Clear custom palette preview, revert to current built-in theme */
export function clearPreview(): void {
customPalette = null;
applyCssVariables(currentTheme);
for (const cb of themeChangeCallbacks) {
try { cb(); } catch (e) { handleInfraError(e, 'theme.clearPreviewCallback'); }
}
}
/** Set a custom theme as active (persists the custom theme ID) */
export async function setCustomTheme(id: string, palette: ThemePalette): Promise<void> {
customPalette = palette;
applyPaletteDirect(palette);
for (const cb of themeChangeCallbacks) {
try { cb(); } catch (e) { handleInfraError(e, 'theme.customCallback'); }
}
try {
await setSetting('theme', id);
} catch (e) {
handleInfraError(e, 'theme.persistCustom');
}
}
/** Check if current theme is a custom theme */
export function isCustomThemeActive(): boolean {
return customPalette !== null;
}
/** Change theme, apply CSS variables, and persist to settings DB */
export async function setTheme(theme: ThemeId): Promise<void> {
currentTheme = theme;
@ -68,15 +110,25 @@ export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
export async function initTheme(): Promise<void> {
try {
const saved = await getSetting('theme');
if (saved && ALL_THEME_IDS.includes(saved as ThemeId)) {
if (saved) {
if (saved.startsWith('custom:')) {
// Custom theme — load palette from custom_themes storage
const { loadCustomThemes } = await import('../styles/custom-themes');
const customs = await loadCustomThemes();
const match = customs.find(c => c.id === saved);
if (match) {
customPalette = match.palette;
applyPaletteDirect(match.palette);
}
} else if (ALL_THEME_IDS.includes(saved as ThemeId)) {
currentTheme = saved as ThemeId;
}
}
} catch {
// Fall back to default (mocha) — catppuccin.css provides Mocha defaults
}
// Always apply to sync CSS vars with current theme
// (skip if mocha — catppuccin.css already has Mocha values)
if (currentTheme !== 'mocha') {
// Always apply to sync CSS vars with current theme (skip if custom already applied)
if (!customPalette && currentTheme !== 'mocha') {
applyCssVariables(currentTheme);
}

View file

@ -0,0 +1,98 @@
// Custom theme persistence — store, load, validate, import/export
import { getSetting, setSetting } from '../adapters/settings-bridge';
import { handleError, handleInfraError } from '../utils/handle-error';
import { type ThemePalette, type ThemeId, getPalette, PALETTE_KEYS } from './themes';
export interface CustomTheme {
id: string;
label: string;
baseTheme: ThemeId;
isDark: boolean;
palette: ThemePalette;
}
const STORAGE_KEY = 'custom_themes';
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
// --- Persistence ---
export async function loadCustomThemes(): Promise<CustomTheme[]> {
try {
const raw = await getSetting(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(isValidCustomTheme) : [];
} catch (e) {
handleInfraError(e, 'customThemes.load');
return [];
}
}
export async function saveCustomThemes(themes: CustomTheme[]): Promise<void> {
try {
await setSetting(STORAGE_KEY, JSON.stringify(themes));
} catch (e) {
handleError(e, 'customThemes.save', 'save your custom themes');
}
}
export async function deleteCustomTheme(themes: CustomTheme[], id: string): Promise<CustomTheme[]> {
const updated = themes.filter(t => t.id !== id);
await saveCustomThemes(updated);
return updated;
}
// --- Create from base ---
export function createFromBase(name: string, baseTheme: ThemeId): CustomTheme {
const base = getPalette(baseTheme);
return {
id: `custom:${Date.now()}`,
label: name,
baseTheme,
isDark: baseTheme !== 'latte',
palette: { ...base },
};
}
// --- Validation ---
function isValidCustomTheme(obj: unknown): obj is CustomTheme {
if (!obj || typeof obj !== 'object') return false;
const t = obj as Record<string, unknown>;
if (typeof t.id !== 'string' || typeof t.label !== 'string') return false;
if (!t.palette || typeof t.palette !== 'object') return false;
return isValidPalette(t.palette as Record<string, unknown>);
}
export function isValidPalette(p: Record<string, unknown>): boolean {
return PALETTE_KEYS.every(k => typeof p[k] === 'string' && HEX_RE.test(p[k] as string));
}
// --- Import / Export ---
export function exportThemeJson(theme: CustomTheme): string {
return JSON.stringify({
name: theme.label,
version: 1,
isDark: theme.isDark,
palette: theme.palette,
}, null, 2);
}
export function importThemeJson(json: string): CustomTheme {
const data = JSON.parse(json);
if (!data || typeof data !== 'object') throw new Error('Invalid theme JSON');
if (typeof data.name !== 'string' || !data.name.trim()) throw new Error('Missing theme name');
if (!data.palette || !isValidPalette(data.palette)) {
throw new Error('Invalid palette — must have all 26 color keys as #hex values');
}
return {
id: `custom:${Date.now()}`,
label: data.name.trim(),
baseTheme: 'mocha',
isDark: data.isDark !== false,
palette: data.palette,
};
}

View file

@ -361,13 +361,34 @@ const CSS_VAR_MAP: [string, keyof ThemePalette][] = [
/** Apply a theme's CSS custom properties to document root */
export function applyCssVariables(theme: ThemeId): void {
const p = palettes[theme];
applyPaletteDirect(palettes[theme]);
}
/** Apply an arbitrary palette object to CSS vars (used by custom theme editor) */
export function applyPaletteDirect(palette: ThemePalette): void {
const style = document.documentElement.style;
for (const [varName, key] of CSS_VAR_MAP) {
style.setProperty(varName, p[key]);
style.setProperty(varName, palette[key]);
}
}
/** Build xterm.js theme from an arbitrary palette */
export function buildXtermThemeFromPalette(p: ThemePalette): XtermTheme {
return {
background: p.base, foreground: p.text,
cursor: p.rosewater, cursorAccent: p.base,
selectionBackground: p.surface1, selectionForeground: p.text,
black: p.surface1, red: p.red, green: p.green, yellow: p.yellow,
blue: p.blue, magenta: p.pink, cyan: p.teal, white: p.subtext1,
brightBlack: p.surface2, brightRed: p.red, brightGreen: p.green,
brightYellow: p.yellow, brightBlue: p.blue, brightMagenta: p.pink,
brightCyan: p.teal, brightWhite: p.subtext0,
};
}
/** All palette keys for iteration */
export const PALETTE_KEYS: (keyof ThemePalette)[] = CSS_VAR_MAP.map(([, k]) => k);
/** @deprecated Use THEME_LIST instead */
export const FLAVOR_LABELS: Record<CatppuccinFlavor, string> = {
latte: 'Latte (Light)',