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:
parent
c1149561c7
commit
0953395423
5 changed files with 454 additions and 8 deletions
227
src/lib/settings/ThemeEditor.svelte
Normal file
227
src/lib/settings/ThemeEditor.svelte
Normal 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>
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
|
||||||
import { getSetting, setSetting } from '../../adapters/settings-bridge';
|
import { getSetting, setSetting } from '../../adapters/settings-bridge';
|
||||||
import { handleError } from '../../utils/handle-error';
|
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 = [
|
const UI_FONTS = [
|
||||||
{ value: '', label: 'System Default' },
|
{ value: '', label: 'System Default' },
|
||||||
|
|
@ -41,6 +43,9 @@
|
||||||
let themeOpen = $state(false);
|
let themeOpen = $state(false);
|
||||||
let uiFontOpen = $state(false);
|
let uiFontOpen = $state(false);
|
||||||
let termFontOpen = $state(false);
|
let termFontOpen = $state(false);
|
||||||
|
let customThemes = $state<CustomTheme[]>([]);
|
||||||
|
let editorOpen = $state(false);
|
||||||
|
let editingTheme = $state<CustomTheme | null>(null);
|
||||||
|
|
||||||
let themeGroups = $derived.by(() => {
|
let themeGroups = $derived.by(() => {
|
||||||
const map = new Map<string, typeof THEME_LIST>();
|
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[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 (results[6].status === 'fulfilled' && results[6].value) scrollbackLines = results[6].value; else failCount++;
|
||||||
if (failCount === results.length) loadError = 'Could not load settings. Displaying defaults.';
|
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 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) {
|
async function save(key: string, value: string) {
|
||||||
try { await setSetting(key, value); } catch (e) { handleError(e, `settings.save.${key}`, 'save your settings'); }
|
try { await setSetting(key, value); } catch (e) { handleError(e, `settings.save.${key}`, 'save your settings'); }
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +167,27 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h3>UI Font</h3>
|
||||||
<div class="field row" id="setting-ui-font">
|
<div class="field row" id="setting-ui-font">
|
||||||
<div class="custom-dropdown flex1">
|
<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"] { 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; }
|
input[type="number"]:focus { border-color: var(--ctp-blue); outline: none; }
|
||||||
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: 0.5rem; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,18 @@ import { getSetting, setSetting } from '../adapters/settings-bridge';
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
import {
|
import {
|
||||||
type ThemeId,
|
type ThemeId,
|
||||||
|
type ThemePalette,
|
||||||
type CatppuccinFlavor,
|
type CatppuccinFlavor,
|
||||||
ALL_THEME_IDS,
|
ALL_THEME_IDS,
|
||||||
buildXtermTheme,
|
buildXtermTheme,
|
||||||
|
buildXtermThemeFromPalette,
|
||||||
applyCssVariables,
|
applyCssVariables,
|
||||||
|
applyPaletteDirect,
|
||||||
type XtermTheme,
|
type XtermTheme,
|
||||||
} from '../styles/themes';
|
} from '../styles/themes';
|
||||||
|
|
||||||
let currentTheme = $state<ThemeId>('mocha');
|
let currentTheme = $state<ThemeId>('mocha');
|
||||||
|
let customPalette = $state<ThemePalette | null>(null);
|
||||||
|
|
||||||
/** Registered theme-change listeners */
|
/** Registered theme-change listeners */
|
||||||
const themeChangeCallbacks = new Set<() => void>();
|
const themeChangeCallbacks = new Set<() => void>();
|
||||||
|
|
@ -36,9 +40,47 @@ export function getCurrentFlavor(): CatppuccinFlavor {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getXtermTheme(): XtermTheme {
|
export function getXtermTheme(): XtermTheme {
|
||||||
|
if (customPalette) return buildXtermThemeFromPalette(customPalette);
|
||||||
return buildXtermTheme(currentTheme);
|
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 */
|
/** Change theme, apply CSS variables, and persist to settings DB */
|
||||||
export async function setTheme(theme: ThemeId): Promise<void> {
|
export async function setTheme(theme: ThemeId): Promise<void> {
|
||||||
currentTheme = theme;
|
currentTheme = theme;
|
||||||
|
|
@ -68,15 +110,25 @@ export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
|
||||||
export async function initTheme(): Promise<void> {
|
export async function initTheme(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const saved = await getSetting('theme');
|
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;
|
currentTheme = saved as ThemeId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to default (mocha) — catppuccin.css provides Mocha defaults
|
// Fall back to default (mocha) — catppuccin.css provides Mocha defaults
|
||||||
}
|
}
|
||||||
// Always apply to sync CSS vars with current theme
|
// Always apply to sync CSS vars with current theme (skip if custom already applied)
|
||||||
// (skip if mocha — catppuccin.css already has Mocha values)
|
if (!customPalette && currentTheme !== 'mocha') {
|
||||||
if (currentTheme !== 'mocha') {
|
|
||||||
applyCssVariables(currentTheme);
|
applyCssVariables(currentTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
98
src/lib/styles/custom-themes.ts
Normal file
98
src/lib/styles/custom-themes.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -361,13 +361,34 @@ const CSS_VAR_MAP: [string, keyof ThemePalette][] = [
|
||||||
|
|
||||||
/** Apply a theme's CSS custom properties to document root */
|
/** Apply a theme's CSS custom properties to document root */
|
||||||
export function applyCssVariables(theme: ThemeId): void {
|
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;
|
const style = document.documentElement.style;
|
||||||
for (const [varName, key] of CSS_VAR_MAP) {
|
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 */
|
/** @deprecated Use THEME_LIST instead */
|
||||||
export const FLAVOR_LABELS: Record<CatppuccinFlavor, string> = {
|
export const FLAVOR_LABELS: Record<CatppuccinFlavor, string> = {
|
||||||
latte: 'Latte (Light)',
|
latte: 'Latte (Light)',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue