From ad0329bbf96b1bd2376418c8d15cdc4865bb88b4 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 12:42:09 -0500 Subject: [PATCH] Add a reusable theme system with a Neonflux theme Introduce a theme layer on top of the existing CSS custom properties. All color tokens now live in themes.css, scoped by a data-theme attribute on , so a theme is just a new token block plus a dropdown option - no code changes needed. The Default theme preserves the existing light/dark behavior; Neonflux adds an electric-cyan dark theme. The active theme is chosen via a Theme selector in the sidebar footer and persisted in a new main-process settings file (userData settings.json) through getTheme/setTheme IPC, so it survives restarts. Hard-coded on-accent, selection, shadow and backdrop colors across the renderer were converted to semantic tokens so every theme stays consistent. --- esbuild.config.mjs | 4 ++ src/main/index.ts | 9 +++ src/main/preload.ts | 4 ++ src/main/settings.ts | 34 ++++++++++ src/renderer/book.ts | 2 + src/renderer/index.css | 131 +++++++++++++++++++++++-------------- src/renderer/index.html | 8 +++ src/renderer/index.ts | 2 + src/renderer/md-editor.ts | 4 +- src/renderer/theme.ts | 41 ++++++++++++ src/renderer/themes.css | 100 ++++++++++++++++++++++++++++ tests/selftest-scenario.js | 59 +++++++++++++++++ 12 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 src/main/settings.ts create mode 100644 src/renderer/theme.ts create mode 100644 src/renderer/themes.css diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 7d9d9c8..7f17720 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -17,6 +17,10 @@ const copyAssets = { path.join("src", "renderer", "index.css"), path.join("dist", "index.css") ); + fs.copyFileSync( + path.join("src", "renderer", "themes.css"), + path.join("dist", "themes.css") + ); fs.mkdirSync(path.join("dist", "icons"), { recursive: true }); fs.copyFileSync( path.join("build", "icons", "256x256.png"), diff --git a/src/main/index.ts b/src/main/index.ts index 190fe1e..d14f367 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import { setChapterContent, } from "./chapters.js"; import { combineMarkdown, buildZip } from "./export.js"; +import { getSetting, setSetting } from "./settings.js"; let mainWindow: BrowserWindow | null = null; let bookPath: string | null = null; @@ -375,6 +376,14 @@ ipcMain.handle("folio:getClipboardText", () => { return clipboard.readText(); }); +// Theme preference. Stored as a plain string so future themes can be added +// without touching the main process. +ipcMain.handle("folio:getTheme", () => getSetting("theme", "default")); +ipcMain.handle("folio:setTheme", (_e, id: unknown) => { + setSetting("theme", typeof id === "string" && id ? id : "default"); + return { ok: true }; +}); + ipcMain.handle("folio:importAttachment", async () => { const bp = requireBook(); const { canceled, filePaths } = await showOpenDialog({ diff --git a/src/main/preload.ts b/src/main/preload.ts index b9b2988..179e06a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -32,6 +32,10 @@ contextBridge.exposeInMainWorld("folio", { copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), + // theme preference + getTheme: () => ipcRenderer.invoke("folio:getTheme"), + setTheme: (id: string) => ipcRenderer.invoke("folio:setTheme", id), + // events onBookOpened: (cb: (data: { path: string; title: string }) => void) => ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..7a45d3f --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import path from "path"; +import { app } from "electron"; +import { APP_ID } from "./project.js"; + +// Tiny per-user settings store (a JSON file in the Electron userData dir). +// Used for preferences that should survive restarts, such as the theme. +const SETTINGS_FILE = (): string => + path.join(app.getPath("userData"), `${APP_ID}-settings.json`); + +let cache: Record | null = null; + +function load(): Record { + if (cache) return cache; + try { + const raw = fs.readFileSync(SETTINGS_FILE(), "utf-8"); + const parsed = JSON.parse(raw); + cache = parsed && typeof parsed === "object" ? parsed : {}; + } catch { + cache = {}; + } + return cache as Record; +} + +export function getSetting(key: string, fallback: T): T { + const settings = load(); + return key in settings ? (settings[key] as T) : fallback; +} + +export function setSetting(key: string, value: unknown): void { + const settings = load(); + settings[key] = value; + fs.writeFileSync(SETTINGS_FILE(), JSON.stringify(settings, null, 2), "utf-8"); +} \ No newline at end of file diff --git a/src/renderer/book.ts b/src/renderer/book.ts index c50060b..2bf0e21 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -60,6 +60,8 @@ export type FolioAPI = { importAttachment: () => Promise; copyText: (text: string) => Promise; getClipboardText: () => Promise; + getTheme: () => Promise; + setTheme: (id: string) => Promise; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookClosed: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void; diff --git a/src/renderer/index.css b/src/renderer/index.css index 4bacadd..4a1ab1d 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1,35 +1,29 @@ -/* System-font stack only — no remote fonts. */ -:root { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; - --font-serif: Georgia, "Times New Roman", serif; - --radius: 8px; - --gap: 16px; - - /* Light theme */ - --color-bg: #ffffff; - --color-panel: #f7f7f8; - --color-border: #e2e2e7; - --color-text: #1a1a1d; - --color-muted: #6b6b76; - --color-accent: #2563eb; - --color-accent-hover: #1d4ed8; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-bg: #0f0f12; - --color-panel: #18181e; - --color-border: #2a2a32; - --color-text: #e6e6ec; - --color-muted: #9a9aa6; - --color-accent: #4a94f0; - --color-accent-hover: #6ab0ff; - } -} - +/* Theme color tokens live in themes.css (`:root[data-theme="..."]` blocks); + this stylesheet only consumes them via var(). System fonts, no remote fonts. */ * { box-sizing: border-box; } +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-scrollbar); + border-radius: 5px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-scrollbar-hover); + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-corner { + background: transparent; +} + html, body { margin: 0; padding: 0; @@ -117,12 +111,12 @@ html, body { .chapter-list li:hover { background: var(--color-accent); - color: #ffffff; + color: var(--color-on-accent); } .chapter-list li.active { background: var(--color-accent); - color: #ffffff; + color: var(--color-on-accent); font-weight: 600; } @@ -131,6 +125,43 @@ html, body { border-top: 1px solid var(--color-border); } +/* ---- theme selector ---- */ +.theme-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.theme-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted); + white-space: nowrap; +} + +.theme-select { + flex: 1; + min-width: 0; + height: 28px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-sans); + font-size: 0.82rem; + padding: 0 6px; + cursor: pointer; + outline: none; +} + +.theme-select:focus { + border-color: var(--color-accent); +} + /* ---- full book nav ---- */ .full-book-nav { margin-top: var(--gap); @@ -154,13 +185,13 @@ html, body { .full-book-btn:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); border-color: var(--color-accent); } .full-book-btn.active { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); font-weight: 600; border-color: var(--color-accent); } @@ -182,7 +213,7 @@ html, body { .button:hover { background: var(--color-accent); - color: #ffffff; + color: var(--color-on-accent); border-color: var(--color-accent); } @@ -255,7 +286,7 @@ html, body { .icon-button:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); border-color: var(--color-accent); } @@ -334,7 +365,7 @@ html, body { .recent-list li:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .muted { @@ -372,7 +403,7 @@ html, body { .chapter-row.drop-target { border-color: var(--color-accent); background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .chapter-row:focus-visible { @@ -383,7 +414,7 @@ html, body { .chapter-row:hover { opacity: 1; background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); border-color: var(--color-accent); } @@ -393,7 +424,7 @@ html, body { .chapter-row.active { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); font-weight: 600; } @@ -411,7 +442,7 @@ html, body { .chapter-row:hover .drag-handle { opacity: 0.9; - color: #fff; + color: var(--color-on-accent); } .row-title { @@ -445,8 +476,8 @@ html, body { .icon-btn:hover { opacity: 1; - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.3); + background: var(--color-on-accent-soft); + border-color: var(--color-on-accent-border); } /* ---- chapter view ---- */ @@ -739,13 +770,13 @@ html, body { .toolbar-btn:active { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); border-color: var(--color-accent); } .toolbar-btn.active { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); border-color: var(--color-accent); } @@ -799,7 +830,7 @@ html, body { background: var(--color-panel); border: 1px solid var(--color-border); border-radius: var(--radius); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + box-shadow: 0 10px 30px var(--color-shadow); padding: 4px; display: flex; flex-direction: column; @@ -823,7 +854,7 @@ html, body { .dropdown-item:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .dropdown-sep { @@ -959,7 +990,7 @@ body.focus-mode .editor { .modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-backdrop); display: flex; align-items: center; justify-content: center; @@ -972,7 +1003,7 @@ body.focus-mode .editor { border-radius: var(--radius); padding: var(--gap); width: min(420px, calc(100vw - 48px)); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); + box-shadow: 0 12px 40px var(--color-shadow-strong); display: flex; flex-direction: column; gap: 12px; @@ -1017,7 +1048,7 @@ body.focus-mode .editor { .button-primary { background: var(--color-accent); border-color: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .button-primary:hover { diff --git a/src/renderer/index.html b/src/renderer/index.html index f1cc3e6..04a7e0b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,6 +4,7 @@ Folio + @@ -26,6 +27,13 @@ diff --git a/src/renderer/index.ts b/src/renderer/index.ts index a5203b2..0993235 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,5 +1,6 @@ import { initBook } from "./book.js"; import type { FolioAPI } from "./book.js"; +import { initTheme } from "./theme.js"; declare global { interface Window { @@ -8,5 +9,6 @@ declare global { } document.addEventListener("DOMContentLoaded", () => { + initTheme(window.folio); initBook(window.folio); }); diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts index 52efc4b..ce60995 100644 --- a/src/renderer/md-editor.ts +++ b/src/renderer/md-editor.ts @@ -86,10 +86,10 @@ const folioTheme = EditorView.theme({ outline: "1px solid var(--color-accent)", }, ".cm-searchMatch": { - backgroundColor: "rgba(37, 99, 235, 0.25)", + backgroundColor: "var(--color-selection)", }, ".cm-searchMatch.cm-searchMatch-selected": { - backgroundColor: "rgba(37, 99, 235, 0.5)", + backgroundColor: "var(--color-selection-strong)", }, ".cm-panels": { backgroundColor: "var(--color-panel)", diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts new file mode 100644 index 0000000..e0afdd0 --- /dev/null +++ b/src/renderer/theme.ts @@ -0,0 +1,41 @@ +// Theme switching. The active theme is stored on and +// drives every color via the CSS custom properties in themes.css. The choice +// is persisted in the main process so it survives restarts. +export interface ThemeApi { + getTheme: () => Promise; + setTheme: (id: string) => Promise; +} + +function selector(): HTMLSelectElement { + return document.getElementById("themeSelect") as HTMLSelectElement; +} + +export function applyTheme(id: string): void { + const theme = typeof id === "string" && id ? id : "default"; + document.documentElement.setAttribute("data-theme", theme); + const sel = selector(); + if (sel && sel.value !== theme) sel.value = theme; +} + +export function currentTheme(): string { + return document.documentElement.getAttribute("data-theme") || "default"; +} + +// Load the saved theme and wire the selector. Unknown stored values fall back +// to Default (the :root base), so adding a theme later is safe for old saves. +export async function initTheme(api: ThemeApi): Promise { + let saved = "default"; + try { + const value = await api.getTheme(); + if (typeof value === "string" && value) saved = value; + } catch { + // keep the default on any IPC failure + } + applyTheme(saved); + const sel = selector(); + if (!sel) return; + sel.addEventListener("change", () => { + applyTheme(sel.value); + void api.setTheme(sel.value); + }); +} \ No newline at end of file diff --git a/src/renderer/themes.css b/src/renderer/themes.css new file mode 100644 index 0000000..a5528ac --- /dev/null +++ b/src/renderer/themes.css @@ -0,0 +1,100 @@ +/* Folio themes. + Every interface color is driven by the semantic variables below; the UI and + CodeMirror styles only ever reference these variables. A theme is therefore + just a block of variable values — adding one means dropping a + `:root[data-theme="..."]` block here plus an