feat(electrobun): wire persistence — SQLite, 17 themes, font system

Persistence:
- bun:sqlite at ~/.config/agor/settings.db (WAL mode, 500ms busy_timeout)
- 4 tables: schema_version, settings, projects, custom_themes
- 5 RPC handlers: settings.get/set/getAll, projects get/set

Theme system (LIVE switching):
- All 17 themes ported from Tauri (4 Catppuccin + 7 Editor + 6 Deep Dark)
- applyCssVars() sets 26 --ctp-* vars on document.documentElement
- Parallel xterm ITheme mapping per theme
- theme-store.svelte.ts: Svelte 5 rune store, persists to SQLite

Font system:
- font-store.svelte.ts: UI/terminal font family + size
- Live CSS var application (--ui-font-family/size, --term-font-family/size)
- onTermFontChange() callback registry for terminal instances
- Persists all 4 font settings to SQLite

AppearanceSettings wired: 17-theme grouped dropdown, font steppers
Init on startup: restores saved theme + fonts from SQLite
This commit is contained in:
Hibryda 2026-03-20 05:29:03 +01:00
parent 0b9e8b305a
commit 6002a379e4
13 changed files with 1043 additions and 53 deletions

View file

@ -231861,6 +231861,105 @@ class PtyClient extends EventEmitter2 {
}
}
// src/bun/settings-db.ts
import { Database as Database2 } from "bun:sqlite";
import { homedir as homedir3 } from "os";
import { mkdirSync as mkdirSync2 } from "fs";
import { join as join7 } from "path";
var CONFIG_DIR = join7(homedir3(), ".config", "agor");
var DB_PATH = join7(CONFIG_DIR, "settings.db");
var SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 500;
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
config TEXT NOT NULL -- JSON blob
);
CREATE TABLE IF NOT EXISTS custom_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
palette TEXT NOT NULL -- JSON blob
);
`;
class SettingsDb {
db;
constructor() {
mkdirSync2(CONFIG_DIR, { recursive: true });
this.db = new Database2(DB_PATH);
this.db.exec(SCHEMA);
const version = this.db.query("SELECT version FROM schema_version LIMIT 1").get();
if (!version) {
this.db.exec("INSERT INTO schema_version (version) VALUES (1)");
}
}
getSetting(key) {
const row = this.db.query("SELECT value FROM settings WHERE key = ?").get(key);
return row?.value ?? null;
}
setSetting(key, value) {
this.db.query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
}
getAll() {
const rows = this.db.query("SELECT key, value FROM settings").all();
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
getProject(id) {
const row = this.db.query("SELECT config FROM projects WHERE id = ?").get(id);
if (!row)
return null;
try {
return JSON.parse(row.config);
} catch {
console.error(`[settings-db] Failed to parse project config for id=${id}`);
return null;
}
}
setProject(id, config) {
const json = JSON.stringify(config);
this.db.query("INSERT INTO projects (id, config) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET config = excluded.config").run(id, json);
}
listProjects() {
const rows = this.db.query("SELECT config FROM projects").all();
return rows.flatMap((r) => {
try {
return [JSON.parse(r.config)];
} catch {
return [];
}
});
}
getCustomThemes() {
const rows = this.db.query("SELECT id, name, palette FROM custom_themes").all();
return rows.flatMap((r) => {
try {
return [{ id: r.id, name: r.name, palette: JSON.parse(r.palette) }];
} catch {
return [];
}
});
}
saveCustomTheme(id, name531, palette) {
const json = JSON.stringify(palette);
this.db.query("INSERT INTO custom_themes (id, name, palette) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, palette = excluded.palette").run(id, name531, json);
}
close() {
this.db.close();
}
}
var settingsDb = new SettingsDb;
// src/bun/index.ts
var DEV_SERVER_PORT = 9760;
var DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
@ -231932,6 +232031,53 @@ var rpc = BrowserView.defineRPC({
ptyClient.closeSession(sessionId);
} catch {}
return { ok: true };
},
"settings.get": ({ key }) => {
try {
return { value: settingsDb.getSetting(key) };
} catch (err) {
console.error("[settings.get]", err);
return { value: null };
}
},
"settings.set": ({ key, value }) => {
try {
settingsDb.setSetting(key, value);
return { ok: true };
} catch (err) {
console.error("[settings.set]", err);
return { ok: false };
}
},
"settings.getAll": () => {
try {
return { settings: settingsDb.getAll() };
} catch (err) {
console.error("[settings.getAll]", err);
return { settings: {} };
}
},
"settings.getProjects": () => {
try {
const projects = settingsDb.listProjects().map((p) => ({
id: p.id,
config: JSON.stringify(p)
}));
return { projects };
} catch (err) {
console.error("[settings.getProjects]", err);
return { projects: [] };
}
},
"settings.setProject": ({ id, config }) => {
try {
const parsed = JSON.parse(config);
settingsDb.setProject(id, { id, ...parsed });
return { ok: true };
} catch (err) {
console.error("[settings.setProject]", err);
return { ok: false };
}
}
},
messages: {}

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte App</title>
<script type="module" crossorigin src="/assets/index-I2iZIyVf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-x9Y0o9Mz.css">
<script type="module" crossorigin src="/assets/index-CZZnRPP5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSZtflYD.css">
</head>
<body>
<div id="app"></div>

View file

@ -1,6 +1,7 @@
import path from "path";
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
@ -96,6 +97,60 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
} catch { /* ignore */ }
return { ok: true };
},
// ── Settings handlers ─────────────────────────────────────────────────
"settings.get": ({ key }) => {
try {
return { value: settingsDb.getSetting(key) };
} catch (err) {
console.error("[settings.get]", err);
return { value: null };
}
},
"settings.set": ({ key, value }) => {
try {
settingsDb.setSetting(key, value);
return { ok: true };
} catch (err) {
console.error("[settings.set]", err);
return { ok: false };
}
},
"settings.getAll": () => {
try {
return { settings: settingsDb.getAll() };
} catch (err) {
console.error("[settings.getAll]", err);
return { settings: {} };
}
},
"settings.getProjects": () => {
try {
const projects = settingsDb.listProjects().map((p) => ({
id: p.id,
config: JSON.stringify(p),
}));
return { projects };
} catch (err) {
console.error("[settings.getProjects]", err);
return { projects: [] };
}
},
"settings.setProject": ({ id, config }) => {
try {
const parsed = JSON.parse(config);
settingsDb.setProject(id, { id, ...parsed });
return { ok: true };
} catch (err) {
console.error("[settings.setProject]", err);
return { ok: false };
}
},
},
messages: {

View file

@ -0,0 +1,173 @@
/**
* SQLite-backed settings store for the Bun process.
* Uses bun:sqlite (built-in, synchronous, zero external deps).
* DB path: ~/.config/agor/settings.db
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
// ── DB path ──────────────────────────────────────────────────────────────────
const CONFIG_DIR = join(homedir(), ".config", "agor");
const DB_PATH = join(CONFIG_DIR, "settings.db");
// ── Schema ───────────────────────────────────────────────────────────────────
const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 500;
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
config TEXT NOT NULL -- JSON blob
);
CREATE TABLE IF NOT EXISTS custom_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
palette TEXT NOT NULL -- JSON blob
);
`;
// ── SettingsDb class ─────────────────────────────────────────────────────────
export interface ProjectConfig {
id: string;
name: string;
cwd: string;
accent?: string;
provider?: string;
profile?: string;
model?: string;
[key: string]: unknown;
}
export interface CustomTheme {
id: string;
name: string;
palette: Record<string, string>;
}
export class SettingsDb {
private db: Database;
constructor() {
// Ensure config dir exists before opening DB
mkdirSync(CONFIG_DIR, { recursive: true });
this.db = new Database(DB_PATH);
this.db.exec(SCHEMA);
// Seed schema_version row if missing
const version = this.db
.query<{ version: number }, []>("SELECT version FROM schema_version LIMIT 1")
.get();
if (!version) {
this.db.exec("INSERT INTO schema_version (version) VALUES (1)");
}
}
// ── Settings ──────────────────────────────────────────────────────────────
getSetting(key: string): string | null {
const row = this.db
.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")
.get(key);
return row?.value ?? null;
}
setSetting(key: string, value: string): void {
this.db
.query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(key, value);
}
getAll(): Record<string, string> {
const rows = this.db
.query<{ key: string; value: string }, []>("SELECT key, value FROM settings")
.all();
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
// ── Projects ──────────────────────────────────────────────────────────────
getProject(id: string): ProjectConfig | null {
const row = this.db
.query<{ config: string }, [string]>("SELECT config FROM projects WHERE id = ?")
.get(id);
if (!row) return null;
try {
return JSON.parse(row.config) as ProjectConfig;
} catch {
console.error(`[settings-db] Failed to parse project config for id=${id}`);
return null;
}
}
setProject(id: string, config: ProjectConfig): void {
const json = JSON.stringify(config);
this.db
.query("INSERT INTO projects (id, config) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET config = excluded.config")
.run(id, json);
}
listProjects(): ProjectConfig[] {
const rows = this.db
.query<{ config: string }, []>("SELECT config FROM projects")
.all();
return rows.flatMap((r) => {
try {
return [JSON.parse(r.config) as ProjectConfig];
} catch {
return [];
}
});
}
// ── Custom Themes ─────────────────────────────────────────────────────────
getCustomThemes(): CustomTheme[] {
const rows = this.db
.query<{ id: string; name: string; palette: string }, []>(
"SELECT id, name, palette FROM custom_themes"
)
.all();
return rows.flatMap((r) => {
try {
return [{ id: r.id, name: r.name, palette: JSON.parse(r.palette) }];
} catch {
return [];
}
});
}
saveCustomTheme(id: string, name: string, palette: Record<string, string>): void {
const json = JSON.stringify(palette);
this.db
.query(
"INSERT INTO custom_themes (id, name, palette) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, palette = excluded.palette"
)
.run(id, name, json);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
close(): void {
this.db.close();
}
}
// Singleton — one DB handle for the process lifetime
export const settingsDb = new SettingsDb();

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import ProjectCard from './ProjectCard.svelte';
import SettingsDrawer from './SettingsDrawer.svelte';
import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte';
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { appRpc } from './main.ts';
// ── Types ─────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
@ -78,6 +82,12 @@
},
];
// ── Init theme + fonts on mount ────────────────────────────────
onMount(() => {
themeStore.initTheme(appRpc).catch(console.error);
fontStore.initFonts(appRpc).catch(console.error);
});
// ── Reactive state ─────────────────────────────────────────────
let settingsOpen = $state(false);
let paletteOpen = $state(false);

View file

@ -0,0 +1,129 @@
/**
* Svelte 5 rune-based font store.
* Manages UI font and terminal font state.
* Applies CSS custom properties instantly; persists via settings RPC.
* Exposes a callback registry so terminal instances can react to font changes.
*
* Usage:
* import { fontStore } from './font-store.svelte';
* fontStore.setUIFont('Inter', 14);
* fontStore.setTermFont('JetBrains Mono', 13);
* const unsub = fontStore.onTermFontChange((family, size) => terminal.options.fontFamily = family);
* await fontStore.initFonts(rpc);
*/
// ── Minimal RPC interface ─────────────────────────────────────────────────────
interface SettingsRpc {
request: {
"settings.set"(p: { key: string; value: string }): Promise<{ ok: boolean }>;
"settings.getAll"(p: Record<string, never>): Promise<{ settings: Record<string, string> }>;
};
}
// ── Types ─────────────────────────────────────────────────────────────────────
type TermFontCallback = (family: string, size: number) => void;
// ── Defaults ──────────────────────────────────────────────────────────────────
const DEFAULTS = {
uiFontFamily: "", // empty = CSS fallback (system-ui)
uiFontSize: 14,
termFontFamily: "", // empty = CSS fallback (JetBrains Mono, monospace)
termFontSize: 13,
} as const;
// ── Store ─────────────────────────────────────────────────────────────────────
function createFontStore() {
let uiFontFamily = $state<string>(DEFAULTS.uiFontFamily);
let uiFontSize = $state<number>(DEFAULTS.uiFontSize);
let termFontFamily = $state<string>(DEFAULTS.termFontFamily);
let termFontSize = $state<number>(DEFAULTS.termFontSize);
let rpc: SettingsRpc | null = null;
const termCallbacks = new Set<TermFontCallback>();
// ── CSS helpers ────────────────────────────────────────────────────────────
function applyUICss(family: string, size: number): void {
const style = document.documentElement.style;
style.setProperty("--ui-font-family", family || "system-ui, -apple-system, sans-serif");
style.setProperty("--ui-font-size", `${size}px`);
uiFontFamily = family;
uiFontSize = size;
}
function applyTermCss(family: string, size: number): void {
const style = document.documentElement.style;
style.setProperty("--term-font-family", family || "JetBrains Mono, Consolas, monospace");
style.setProperty("--term-font-size", `${size}px`);
termFontFamily = family;
termFontSize = size;
for (const cb of termCallbacks) {
try { cb(family, size); }
catch (err) { console.error("[font-store] termFont callback error:", err); }
}
}
// ── Public API ─────────────────────────────────────────────────────────────
function setUIFont(family: string, size: number): void {
applyUICss(family, size);
if (rpc) {
rpc.request["settings.set"]({ key: "ui_font_family", value: family }).catch(console.error);
rpc.request["settings.set"]({ key: "ui_font_size", value: String(size) }).catch(console.error);
}
}
function setTermFont(family: string, size: number): void {
applyTermCss(family, size);
if (rpc) {
rpc.request["settings.set"]({ key: "term_font_family", value: family }).catch(console.error);
rpc.request["settings.set"]({ key: "term_font_size", value: String(size) }).catch(console.error);
}
}
/** Register a callback invoked whenever the terminal font changes. Returns unsubscribe fn. */
function onTermFontChange(cb: TermFontCallback): () => void {
termCallbacks.add(cb);
return () => termCallbacks.delete(cb);
}
/** Load all persisted font settings on startup. Call once in App.svelte onMount. */
async function initFonts(rpcInstance: SettingsRpc): Promise<void> {
rpc = rpcInstance;
try {
const { settings } = await rpc.request["settings.getAll"]({});
const family = settings["ui_font_family"] ?? DEFAULTS.uiFontFamily;
const rawSize = parseInt(settings["ui_font_size"] ?? "", 10);
const size = isNaN(rawSize) ? DEFAULTS.uiFontSize : rawSize;
const tFamily = settings["term_font_family"] ?? DEFAULTS.termFontFamily;
const rawTSize = parseInt(settings["term_font_size"] ?? "", 10);
const tSize = isNaN(rawTSize) ? DEFAULTS.termFontSize : rawTSize;
applyUICss(family, size);
applyTermCss(tFamily, tSize);
} catch (err) {
console.error("[font-store] Failed to load font settings:", err);
applyUICss(DEFAULTS.uiFontFamily, DEFAULTS.uiFontSize);
applyTermCss(DEFAULTS.termFontFamily, DEFAULTS.termFontSize);
}
}
return {
get uiFontFamily() { return uiFontFamily; },
get uiFontSize() { return uiFontSize; },
get termFontFamily() { return termFontFamily; },
get termFontSize() { return termFontSize; },
setUIFont,
setTermFont,
onTermFontChange,
initFonts,
};
}
export const fontStore = createFontStore();

View file

@ -33,6 +33,9 @@ const rpc = Electroview.defineRPC<PtyRPCSchema>({
export const electrobun = new Electroview({ rpc });
/** Exported for use by stores that need RPC access (theme-store, font-store). */
export { rpc as appRpc };
const app = mount(App, {
target: document.getElementById("app")!,
});

View file

@ -1,10 +1,7 @@
<script lang="ts">
const THEMES = [
{ id: 'mocha', label: 'Catppuccin Mocha', group: 'Catppuccin' },
{ id: 'macchiato', label: 'Catppuccin Macchiato', group: 'Catppuccin' },
{ id: 'frappe', label: 'Catppuccin Frappé', group: 'Catppuccin' },
{ id: 'latte', label: 'Catppuccin Latte', group: 'Catppuccin' },
];
import { THEMES, THEME_GROUPS, type ThemeId } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';
const UI_FONTS = [
{ value: '', label: 'System Default' },
@ -25,30 +22,70 @@
{ value: 'monospace', label: 'monospace' },
];
let themeId = $state('mocha');
let uiFont = $state('');
let uiFontSize = $state(14);
let termFont = $state('');
let termFontSize = $state(13);
let cursorStyle = $state('block');
let cursorBlink = $state(true);
let scrollback = $state(1000);
// ── Local reactive state (mirrors store) ───────────────────────────────────
let themeId = $state<ThemeId>(themeStore.currentTheme);
let uiFont = $state(fontStore.uiFontFamily);
let uiFontSize = $state(fontStore.uiFontSize);
let termFont = $state(fontStore.termFontFamily);
let termFontSize = $state(fontStore.termFontSize);
let cursorStyle = $state('block');
let cursorBlink = $state(true);
let scrollback = $state(1000);
// Keep local state in sync when store changes (e.g. after initTheme)
$effect(() => { themeId = themeStore.currentTheme; });
$effect(() => { uiFont = fontStore.uiFontFamily; });
$effect(() => { uiFontSize = fontStore.uiFontSize; });
$effect(() => { termFont = fontStore.termFontFamily; });
$effect(() => { termFontSize = fontStore.termFontSize; });
// ── Dropdown open state ────────────────────────────────────────────────────
let themeOpen = $state(false);
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
// ── Derived labels ─────────────────────────────────────────────────────────
let themeLabel = $derived(THEMES.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let uiFontLabel = $derived(UI_FONTS.find(f => f.value === uiFont)?.label ?? 'System Default');
let termFontLabel = $derived(TERM_FONTS.find(f => f.value === termFont)?.label ?? 'Default (JetBrains Mono)');
function closeAll() { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
// ── Actions ────────────────────────────────────────────────────────────────
function handleOutsideClick(e: MouseEvent) {
function selectTheme(id: ThemeId): void {
themeId = id;
themeOpen = false;
themeStore.setTheme(id);
}
function selectUIFont(value: string): void {
uiFont = value;
uiFontOpen = false;
fontStore.setUIFont(value, uiFontSize);
}
function selectTermFont(value: string): void {
termFont = value;
termFontOpen = false;
fontStore.setTermFont(value, termFontSize);
}
function adjustUISize(delta: number): void {
uiFontSize = Math.max(8, Math.min(24, uiFontSize + delta));
fontStore.setUIFont(uiFont, uiFontSize);
}
function adjustTermSize(delta: number): void {
termFontSize = Math.max(8, Math.min(24, termFontSize + delta));
fontStore.setTermFont(termFont, termFontSize);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function handleOutsideClick(e: MouseEvent): void {
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
}
function handleKey(e: KeyboardEvent) {
function handleKey(e: KeyboardEvent): void {
if (e.key === 'Escape') closeAll();
}
</script>
@ -64,12 +101,16 @@
</button>
{#if themeOpen}
<ul class="dd-list" role="listbox">
{#each THEMES as t}
<li class="dd-item" class:sel={themeId === t.id} role="option" aria-selected={themeId === t.id}
tabindex="0"
onclick={() => { themeId = t.id; themeOpen = false; }}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (themeId = t.id, themeOpen = false)}
>{t.label}</li>
{#each THEME_GROUPS as group}
<li class="dd-group-label" role="presentation">{group}</li>
{#each THEMES.filter(t => t.group === group) as t}
<li class="dd-item" class:sel={themeId === t.id}
role="option" aria-selected={themeId === t.id}
tabindex="0"
onclick={() => selectTheme(t.id)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
>{t.label}</li>
{/each}
{/each}
</ul>
{/if}
@ -86,19 +127,20 @@
{#if uiFontOpen}
<ul class="dd-list" role="listbox">
{#each UI_FONTS as f}
<li class="dd-item" class:sel={uiFont === f.value} role="option" aria-selected={uiFont === f.value}
<li class="dd-item" class:sel={uiFont === f.value}
role="option" aria-selected={uiFont === f.value}
tabindex="0"
onclick={() => { uiFont = f.value; uiFontOpen = false; }}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (uiFont = f.value, uiFontOpen = false)}
onclick={() => selectUIFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectUIFont(f.value)}
>{f.label}</li>
{/each}
</ul>
{/if}
</div>
<div class="stepper">
<button onclick={() => uiFontSize = Math.max(8, uiFontSize - 1)} aria-label="Decrease UI font size"></button>
<button onclick={() => adjustUISize(-1)} aria-label="Decrease UI font size"></button>
<span>{uiFontSize}px</span>
<button onclick={() => uiFontSize = Math.min(24, uiFontSize + 1)} aria-label="Increase UI font size">+</button>
<button onclick={() => adjustUISize(1)} aria-label="Increase UI font size">+</button>
</div>
</div>
@ -112,19 +154,20 @@
{#if termFontOpen}
<ul class="dd-list" role="listbox">
{#each TERM_FONTS as f}
<li class="dd-item" class:sel={termFont === f.value} role="option" aria-selected={termFont === f.value}
<li class="dd-item" class:sel={termFont === f.value}
role="option" aria-selected={termFont === f.value}
tabindex="0"
onclick={() => { termFont = f.value; termFontOpen = false; }}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (termFont = f.value, termFontOpen = false)}
onclick={() => selectTermFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTermFont(f.value)}
>{f.label}</li>
{/each}
</ul>
{/if}
</div>
<div class="stepper">
<button onclick={() => termFontSize = Math.max(8, termFontSize - 1)} aria-label="Decrease terminal font size"></button>
<button onclick={() => adjustTermSize(-1)} aria-label="Decrease terminal font size"></button>
<span>{termFontSize}px</span>
<button onclick={() => termFontSize = Math.min(24, termFontSize + 1)} aria-label="Increase terminal font size">+</button>
<button onclick={() => adjustTermSize(1)} aria-label="Increase terminal font size">+</button>
</div>
</div>
@ -169,9 +212,16 @@
position: absolute; top: calc(100% + 0.125rem); left: 0; right: 0; z-index: 50;
list-style: none; margin: 0; padding: 0.25rem;
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem;
max-height: 12rem; overflow-y: auto;
max-height: 14rem; overflow-y: auto;
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.dd-group-label {
padding: 0.25rem 0.5rem 0.125rem;
font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0);
border-top: 1px solid var(--ctp-surface0);
}
.dd-group-label:first-child { border-top: none; }
.dd-item {
padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8125rem; color: var(--ctp-subtext1);
cursor: pointer; outline: none; list-style: none;

View file

@ -0,0 +1,86 @@
/**
* Svelte 5 rune-based theme store.
* Applies all 26 --ctp-* CSS vars to document.documentElement instantly.
* Persists selection via settings RPC.
*
* Usage:
* import { themeStore } from './theme-store.svelte';
* themeStore.setTheme('tokyo-night');
* await themeStore.initTheme(rpc);
*/
import { applyCssVars, THEME_LIST, type ThemeId } from "./themes.ts";
const SETTING_KEY = "theme";
const DEFAULT_THEME: ThemeId = "mocha";
// ── Minimal RPC interface ─────────────────────────────────────────────────────
// Avoids importing the full Electroview class — just the subset we need.
interface SettingsRpc {
request: {
"settings.get"(p: { key: string }): Promise<{ value: string | null }>;
"settings.set"(p: { key: string; value: string }): Promise<{ ok: boolean }>;
};
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function isValidThemeId(id: string): id is ThemeId {
return THEME_LIST.some((t) => t.id === id);
}
// ── Store ─────────────────────────────────────────────────────────────────────
function createThemeStore() {
let currentThemeId = $state<ThemeId>(DEFAULT_THEME);
let rpc: SettingsRpc | null = null;
/** Apply CSS vars immediately — synchronous, no flash. */
function applyToDocument(id: ThemeId): void {
applyCssVars(id);
currentThemeId = id;
}
/**
* Change the active theme.
* Applies CSS vars immediately and persists asynchronously.
*/
function setTheme(id: ThemeId): void {
applyToDocument(id);
if (rpc) {
rpc.request["settings.set"]({ key: SETTING_KEY, value: id }).catch((err) => {
console.error("[theme-store] Failed to persist theme:", err);
});
}
}
/**
* Load persisted theme from settings on startup.
* Call once in App.svelte onMount.
*/
async function initTheme(rpcInstance: SettingsRpc): Promise<void> {
rpc = rpcInstance;
try {
const result = await rpc.request["settings.get"]({ key: SETTING_KEY });
const saved = result.value;
if (saved && isValidThemeId(saved)) {
applyToDocument(saved);
} else {
// Apply default to ensure vars are set even when nothing was persisted.
applyToDocument(DEFAULT_THEME);
}
} catch (err) {
console.error("[theme-store] Failed to load theme from settings:", err);
applyToDocument(DEFAULT_THEME);
}
}
return {
get currentTheme() { return currentThemeId; },
setTheme,
initTheme,
};
}
export const themeStore = createThemeStore();

View file

@ -0,0 +1,310 @@
/**
* Full 17-theme palette definitions.
* Ported from src/lib/styles/themes.ts kept in sync manually.
* Each theme maps to the same 26 --ctp-* CSS custom property slots.
*/
// ── Types ─────────────────────────────────────────────────────────────────────
export type ThemeId =
| "mocha" | "macchiato" | "frappe" | "latte"
| "vscode-dark" | "atom-one-dark" | "monokai" | "dracula"
| "nord" | "solarized-dark" | "github-dark"
| "tokyo-night" | "gruvbox-dark" | "ayu-dark" | "poimandres"
| "vesper" | "midnight";
export interface ThemePalette {
rosewater: string; flamingo: string; pink: string; mauve: string;
red: string; maroon: string; peach: string; yellow: string;
green: string; teal: string; sky: string; sapphire: string;
blue: string; lavender: string;
text: string; subtext1: string; subtext0: string;
overlay2: string; overlay1: string; overlay0: string;
surface2: string; surface1: string; surface0: string;
base: string; mantle: string; crust: string;
}
export interface XtermTheme {
background: string; foreground: string;
cursor: string; cursorAccent: string;
selectionBackground: string; selectionForeground: string;
black: string; red: string; green: string; yellow: string;
blue: string; magenta: string; cyan: string; white: string;
brightBlack: string; brightRed: string; brightGreen: string;
brightYellow: string; brightBlue: string; brightMagenta: string;
brightCyan: string; brightWhite: string;
}
export interface ThemeMeta {
id: ThemeId;
label: string;
group: string;
isDark: boolean;
}
// ── Metadata list ─────────────────────────────────────────────────────────────
export const THEME_LIST: ThemeMeta[] = [
{ id: "mocha", label: "Catppuccin Mocha", group: "Catppuccin", isDark: true },
{ id: "macchiato", label: "Catppuccin Macchiato",group: "Catppuccin", isDark: true },
{ id: "frappe", label: "Catppuccin Frappé", group: "Catppuccin", isDark: true },
{ id: "latte", label: "Catppuccin Latte", group: "Catppuccin", isDark: false },
{ id: "vscode-dark", label: "VSCode Dark+", group: "Editor", isDark: true },
{ id: "atom-one-dark", label: "Atom One Dark", group: "Editor", isDark: true },
{ id: "monokai", label: "Monokai", group: "Editor", isDark: true },
{ id: "dracula", label: "Dracula", group: "Editor", isDark: true },
{ id: "nord", label: "Nord", group: "Editor", isDark: true },
{ id: "solarized-dark", label: "Solarized Dark", group: "Editor", isDark: true },
{ id: "github-dark", label: "GitHub Dark", group: "Editor", isDark: true },
{ id: "tokyo-night", label: "Tokyo Night", group: "Deep Dark", isDark: true },
{ id: "gruvbox-dark", label: "Gruvbox Dark", group: "Deep Dark", isDark: true },
{ id: "ayu-dark", label: "Ayu Dark", group: "Deep Dark", isDark: true },
{ id: "poimandres", label: "Poimandres", group: "Deep Dark", isDark: true },
{ id: "vesper", label: "Vesper", group: "Deep Dark", isDark: true },
{ id: "midnight", label: "Midnight", group: "Deep Dark", isDark: true },
];
/** Unique group names in display order. */
export const THEME_GROUPS: string[] = [...new Set(THEME_LIST.map((t) => t.group))];
// ── Palettes ──────────────────────────────────────────────────────────────────
const PALETTES: Record<ThemeId, ThemePalette> = {
latte: {
rosewater: "#dc8a78", flamingo: "#dd7878", pink: "#ea76cb", mauve: "#8839ef",
red: "#d20f39", maroon: "#e64553", peach: "#fe640b", yellow: "#df8e1d",
green: "#40a02b", teal: "#179299", sky: "#04a5e5", sapphire: "#209fb5",
blue: "#1e66f5", lavender: "#7287fd",
text: "#4c4f69", subtext1: "#5c5f77", subtext0: "#6c6f85",
overlay2: "#7c7f93", overlay1: "#8c8fa1", overlay0: "#9ca0b0",
surface2: "#acb0be", surface1: "#bcc0cc", surface0: "#ccd0da",
base: "#eff1f5", mantle: "#e6e9ef", crust: "#dce0e8",
},
frappe: {
rosewater: "#f2d5cf", flamingo: "#eebebe", pink: "#f4b8e4", mauve: "#ca9ee6",
red: "#e78284", maroon: "#ea999c", peach: "#ef9f76", yellow: "#e5c890",
green: "#a6d189", teal: "#81c8be", sky: "#99d1db", sapphire: "#85c1dc",
blue: "#8caaee", lavender: "#babbf1",
text: "#c6d0f5", subtext1: "#b5bfe2", subtext0: "#a5adce",
overlay2: "#949cbb", overlay1: "#838ba7", overlay0: "#737994",
surface2: "#626880", surface1: "#51576d", surface0: "#414559",
base: "#303446", mantle: "#292c3c", crust: "#232634",
},
macchiato: {
rosewater: "#f4dbd6", flamingo: "#f0c6c6", pink: "#f5bde6", mauve: "#c6a0f6",
red: "#ed8796", maroon: "#ee99a0", peach: "#f5a97f", yellow: "#eed49f",
green: "#a6da95", teal: "#8bd5ca", sky: "#91d7e3", sapphire: "#7dc4e4",
blue: "#8aadf4", lavender: "#b7bdf8",
text: "#cad3f5", subtext1: "#b8c0e0", subtext0: "#a5adcb",
overlay2: "#939ab7", overlay1: "#8087a2", overlay0: "#6e738d",
surface2: "#5b6078", surface1: "#494d64", surface0: "#363a4f",
base: "#24273a", mantle: "#1e2030", crust: "#181926",
},
mocha: {
rosewater: "#f5e0dc", flamingo: "#f2cdcd", pink: "#f5c2e7", mauve: "#cba6f7",
red: "#f38ba8", maroon: "#eba0ac", peach: "#fab387", yellow: "#f9e2af",
green: "#a6e3a1", teal: "#94e2d5", sky: "#89dceb", sapphire: "#74c7ec",
blue: "#89b4fa", lavender: "#b4befe",
text: "#cdd6f4", subtext1: "#bac2de", subtext0: "#a6adc8",
overlay2: "#9399b2", overlay1: "#7f849c", overlay0: "#6c7086",
surface2: "#585b70", surface1: "#45475a", surface0: "#313244",
base: "#1e1e2e", mantle: "#181825", crust: "#11111b",
},
"vscode-dark": {
rosewater: "#d4a0a0", flamingo: "#cf8686", pink: "#c586c0", mauve: "#c586c0",
red: "#f44747", maroon: "#d16969", peach: "#ce9178", yellow: "#dcdcaa",
green: "#6a9955", teal: "#4ec9b0", sky: "#9cdcfe", sapphire: "#4fc1ff",
blue: "#569cd6", lavender: "#b4b4f7",
text: "#d4d4d4", subtext1: "#cccccc", subtext0: "#b0b0b0",
overlay2: "#858585", overlay1: "#6e6e6e", overlay0: "#5a5a5a",
surface2: "#3e3e42", surface1: "#333338", surface0: "#2d2d30",
base: "#1e1e1e", mantle: "#181818", crust: "#111111",
},
"atom-one-dark": {
rosewater: "#e5c07b", flamingo: "#e06c75", pink: "#c678dd", mauve: "#c678dd",
red: "#e06c75", maroon: "#be5046", peach: "#d19a66", yellow: "#e5c07b",
green: "#98c379", teal: "#56b6c2", sky: "#56b6c2", sapphire: "#61afef",
blue: "#61afef", lavender: "#c8ccd4",
text: "#abb2bf", subtext1: "#9da5b4", subtext0: "#8b92a0",
overlay2: "#7f848e", overlay1: "#636d83", overlay0: "#545862",
surface2: "#474b56", surface1: "#3b3f4c", surface0: "#333842",
base: "#282c34", mantle: "#21252b", crust: "#181a1f",
},
monokai: {
rosewater: "#f8f8f2", flamingo: "#f92672", pink: "#f92672", mauve: "#ae81ff",
red: "#f92672", maroon: "#f92672", peach: "#fd971f", yellow: "#e6db74",
green: "#a6e22e", teal: "#66d9ef", sky: "#66d9ef", sapphire: "#66d9ef",
blue: "#66d9ef", lavender: "#ae81ff",
text: "#f8f8f2", subtext1: "#e8e8e2", subtext0: "#cfcfc2",
overlay2: "#a8a8a2", overlay1: "#90908a", overlay0: "#75715e",
surface2: "#595950", surface1: "#49483e", surface0: "#3e3d32",
base: "#272822", mantle: "#1e1f1c", crust: "#141411",
},
dracula: {
rosewater: "#f1c4e0", flamingo: "#ff79c6", pink: "#ff79c6", mauve: "#bd93f9",
red: "#ff5555", maroon: "#ff6e6e", peach: "#ffb86c", yellow: "#f1fa8c",
green: "#50fa7b", teal: "#8be9fd", sky: "#8be9fd", sapphire: "#8be9fd",
blue: "#6272a4", lavender: "#bd93f9",
text: "#f8f8f2", subtext1: "#e8e8e2", subtext0: "#c0c0ba",
overlay2: "#a0a0a0", overlay1: "#7f7f7f", overlay0: "#6272a4",
surface2: "#555969", surface1: "#44475a", surface0: "#383a4a",
base: "#282a36", mantle: "#21222c", crust: "#191a21",
},
nord: {
rosewater: "#d08770", flamingo: "#bf616a", pink: "#b48ead", mauve: "#b48ead",
red: "#bf616a", maroon: "#bf616a", peach: "#d08770", yellow: "#ebcb8b",
green: "#a3be8c", teal: "#8fbcbb", sky: "#88c0d0", sapphire: "#81a1c1",
blue: "#5e81ac", lavender: "#b48ead",
text: "#eceff4", subtext1: "#e5e9f0", subtext0: "#d8dee9",
overlay2: "#a5adba", overlay1: "#8891a0", overlay0: "#6c7588",
surface2: "#4c566a", surface1: "#434c5e", surface0: "#3b4252",
base: "#2e3440", mantle: "#272c36", crust: "#20242c",
},
"solarized-dark": {
rosewater: "#d33682", flamingo: "#dc322f", pink: "#d33682", mauve: "#6c71c4",
red: "#dc322f", maroon: "#cb4b16", peach: "#cb4b16", yellow: "#b58900",
green: "#859900", teal: "#2aa198", sky: "#2aa198", sapphire: "#268bd2",
blue: "#268bd2", lavender: "#6c71c4",
text: "#839496", subtext1: "#93a1a1", subtext0: "#778a8b",
overlay2: "#657b83", overlay1: "#586e75", overlay0: "#4a6068",
surface2: "#1c4753", surface1: "#143845", surface0: "#073642",
base: "#002b36", mantle: "#00222b", crust: "#001a21",
},
"github-dark": {
rosewater: "#ffa198", flamingo: "#ff7b72", pink: "#f778ba", mauve: "#d2a8ff",
red: "#ff7b72", maroon: "#ffa198", peach: "#ffa657", yellow: "#e3b341",
green: "#7ee787", teal: "#56d4dd", sky: "#79c0ff", sapphire: "#79c0ff",
blue: "#58a6ff", lavender: "#d2a8ff",
text: "#c9d1d9", subtext1: "#b1bac4", subtext0: "#8b949e",
overlay2: "#6e7681", overlay1: "#565c64", overlay0: "#484f58",
surface2: "#373e47", surface1: "#30363d", surface0: "#21262d",
base: "#0d1117", mantle: "#090c10", crust: "#050608",
},
"tokyo-night": {
rosewater: "#f7768e", flamingo: "#ff9e64", pink: "#bb9af7", mauve: "#bb9af7",
red: "#f7768e", maroon: "#db4b4b", peach: "#ff9e64", yellow: "#e0af68",
green: "#9ece6a", teal: "#73daca", sky: "#7dcfff", sapphire: "#7aa2f7",
blue: "#7aa2f7", lavender: "#bb9af7",
text: "#c0caf5", subtext1: "#a9b1d6", subtext0: "#9aa5ce",
overlay2: "#787c99", overlay1: "#565f89", overlay0: "#414868",
surface2: "#3b4261", surface1: "#292e42", surface0: "#232433",
base: "#1a1b26", mantle: "#16161e", crust: "#101014",
},
"gruvbox-dark": {
rosewater: "#d65d0e", flamingo: "#cc241d", pink: "#d3869b", mauve: "#b16286",
red: "#fb4934", maroon: "#cc241d", peach: "#fe8019", yellow: "#fabd2f",
green: "#b8bb26", teal: "#8ec07c", sky: "#83a598", sapphire: "#83a598",
blue: "#458588", lavender: "#d3869b",
text: "#ebdbb2", subtext1: "#d5c4a1", subtext0: "#bdae93",
overlay2: "#a89984", overlay1: "#928374", overlay0: "#7c6f64",
surface2: "#504945", surface1: "#3c3836", surface0: "#32302f",
base: "#1d2021", mantle: "#191b1c", crust: "#141617",
},
"ayu-dark": {
rosewater: "#f07178", flamingo: "#f07178", pink: "#d2a6ff", mauve: "#d2a6ff",
red: "#f07178", maroon: "#f07178", peach: "#ff8f40", yellow: "#ffb454",
green: "#aad94c", teal: "#95e6cb", sky: "#73b8ff", sapphire: "#59c2ff",
blue: "#59c2ff", lavender: "#d2a6ff",
text: "#bfbdb6", subtext1: "#acaaa4", subtext0: "#9b9892",
overlay2: "#73726e", overlay1: "#5c5b57", overlay0: "#464542",
surface2: "#383838", surface1: "#2c2c2c", surface0: "#242424",
base: "#0b0e14", mantle: "#080a0f", crust: "#05070a",
},
poimandres: {
rosewater: "#d0679d", flamingo: "#d0679d", pink: "#fcc5e9", mauve: "#a6accd",
red: "#d0679d", maroon: "#d0679d", peach: "#e4f0fb", yellow: "#fffac2",
green: "#5de4c7", teal: "#5de4c7", sky: "#89ddff", sapphire: "#add7ff",
blue: "#91b4d5", lavender: "#a6accd",
text: "#e4f0fb", subtext1: "#d0d6e0", subtext0: "#a6accd",
overlay2: "#767c9d", overlay1: "#506477", overlay0: "#3e4f5e",
surface2: "#303340", surface1: "#252b37", surface0: "#1e2433",
base: "#1b1e28", mantle: "#171922", crust: "#12141c",
},
vesper: {
rosewater: "#de6e6e", flamingo: "#de6e6e", pink: "#c79bf0", mauve: "#c79bf0",
red: "#de6e6e", maroon: "#de6e6e", peach: "#ffcfa8", yellow: "#ffc799",
green: "#7cb37c", teal: "#6bccb0", sky: "#8abeb7", sapphire: "#6eb4bf",
blue: "#6eb4bf", lavender: "#c79bf0",
text: "#b8b5ad", subtext1: "#a09d95", subtext0: "#878480",
overlay2: "#6e6b66", overlay1: "#55524d", overlay0: "#3d3a36",
surface2: "#302e2a", surface1: "#252320", surface0: "#1c1a17",
base: "#101010", mantle: "#0a0a0a", crust: "#050505",
},
midnight: {
rosewater: "#e8a0bf", flamingo: "#ea6f91", pink: "#e8a0bf", mauve: "#c4a7e7",
red: "#eb6f92", maroon: "#ea6f91", peach: "#f6c177", yellow: "#ebbcba",
green: "#9ccfd8", teal: "#9ccfd8", sky: "#a4d4e4", sapphire: "#8bbee8",
blue: "#7ba4cc", lavender: "#c4a7e7",
text: "#c4c4c4", subtext1: "#a8a8a8", subtext0: "#8c8c8c",
overlay2: "#6e6e6e", overlay1: "#525252", overlay0: "#383838",
surface2: "#262626", surface1: "#1a1a1a", surface0: "#111111",
base: "#000000", mantle: "#000000", crust: "#000000",
},
};
// ── Public API ────────────────────────────────────────────────────────────────
export const THEMES = THEME_LIST;
export function getTheme(id: ThemeId): ThemeMeta {
return THEME_LIST.find((t) => t.id === id) ?? THEME_LIST[0];
}
export function getPalette(id: ThemeId): ThemePalette {
return PALETTES[id] ?? PALETTES.mocha;
}
/** Build xterm.js ITheme from a named theme. */
export function getXtermTheme(id: ThemeId): XtermTheme {
const p = getPalette(id);
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,
};
}
/** CSS custom property name → palette key mapping (26 vars). */
export const CSS_VAR_MAP: [string, keyof ThemePalette][] = [
["--ctp-rosewater", "rosewater"], ["--ctp-flamingo", "flamingo"],
["--ctp-pink", "pink"], ["--ctp-mauve", "mauve"],
["--ctp-red", "red"], ["--ctp-maroon", "maroon"],
["--ctp-peach", "peach"], ["--ctp-yellow", "yellow"],
["--ctp-green", "green"], ["--ctp-teal", "teal"],
["--ctp-sky", "sky"], ["--ctp-sapphire", "sapphire"],
["--ctp-blue", "blue"], ["--ctp-lavender", "lavender"],
["--ctp-text", "text"], ["--ctp-subtext1", "subtext1"],
["--ctp-subtext0", "subtext0"], ["--ctp-overlay2", "overlay2"],
["--ctp-overlay1", "overlay1"], ["--ctp-overlay0", "overlay0"],
["--ctp-surface2", "surface2"], ["--ctp-surface1", "surface1"],
["--ctp-surface0", "surface0"], ["--ctp-base", "base"],
["--ctp-mantle", "mantle"], ["--ctp-crust", "crust"],
];
/** Apply all 26 --ctp-* CSS custom properties to document.documentElement. */
export function applyCssVars(id: ThemeId): void {
const p = getPalette(id);
const style = document.documentElement.style;
for (const [varName, key] of CSS_VAR_MAP) {
style.setProperty(varName, p[key]);
}
}

View file

@ -43,6 +43,34 @@ export type PtyRPCRequests = {
params: { sessionId: string };
response: { ok: boolean };
};
// ── Settings RPC ───────────────────────────────────────────────────────────
/** Get a single setting value by key. Returns null if not set. */
"settings.get": {
params: { key: string };
response: { value: string | null };
};
/** Persist a setting key/value pair. */
"settings.set": {
params: { key: string; value: string };
response: { ok: boolean };
};
/** Return all settings as a flat object. */
"settings.getAll": {
params: Record<string, never>;
response: { settings: Record<string, string> };
};
/** Return all persisted projects. */
"settings.getProjects": {
params: Record<string, never>;
response: { projects: Array<{ id: string; config: string }> };
};
/** Persist a project config (JSON-serialised on the caller side). */
"settings.setProject": {
params: { id: string; config: string };
response: { ok: boolean };
};
};
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────