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 <html>, 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.
This commit is contained in:
avi 2026-08-18 12:42:09 -05:00
commit ad0329bbf9
12 changed files with 346 additions and 52 deletions

View file

@ -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"),

View file

@ -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({

View file

@ -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)),

34
src/main/settings.ts Normal file
View file

@ -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<string, unknown> | null = null;
function load(): Record<string, unknown> {
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<string, unknown>;
}
export function getSetting<T>(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");
}

View file

@ -60,6 +60,8 @@ export type FolioAPI = {
importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>;
getTheme: () => Promise<string>;
setTheme: (id: string) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;

View file

@ -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 {

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Folio</title>
<link rel="stylesheet" href="./themes.css" />
<link rel="stylesheet" href="./index.css" />
</head>
<body>
@ -26,6 +27,13 @@
<button class="full-book-btn" id="fullBookBtn" title="View the entire book as one continuous manuscript">Full Book</button>
</div>
<div class="sidebar-footer">
<div class="theme-control">
<label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme">
<option value="default">Default</option>
<option value="neonflux">Neonflux</option>
</select>
</div>
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
</div>
</aside>

View file

@ -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);
});

View file

@ -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)",

41
src/renderer/theme.ts Normal file
View file

@ -0,0 +1,41 @@
// Theme switching. The active theme is stored on <html data-theme="..."> 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<string>;
setTheme: (id: string) => Promise<unknown>;
}
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<void> {
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);
});
}

100
src/renderer/themes.css Normal file
View file

@ -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 <option> in the theme selector.
Default mirrors Folio's original appearance: light, with an automatic dark
variant that follows the OS color scheme. Neonflux is a fixed dark,
futuristic theme. */
/* ---- shared (non-color) tokens ---- */
: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;
}
/* ---- Default (light) ---- */
:root,
:root[data-theme="default"] {
--color-bg: #ffffff;
--color-panel: #f7f7f8;
--color-border: #e2e2e7;
--color-text: #1a1a1d;
--color-muted: #6b6b76;
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-on-accent: #ffffff;
--color-on-accent-soft: rgba(255, 255, 255, 0.15);
--color-on-accent-border: rgba(255, 255, 255, 0.3);
--color-selection: rgba(37, 99, 235, 0.25);
--color-selection-strong: rgba(37, 99, 235, 0.5);
--color-shadow: rgba(0, 0, 0, 0.25);
--color-shadow-strong: rgba(0, 0, 0, 0.3);
--color-backdrop: rgba(0, 0, 0, 0.45);
--color-scrollbar: rgba(0, 0, 0, 0.18);
--color-scrollbar-hover: rgba(0, 0, 0, 0.32);
}
/* ---- Default (dark, follows OS preference) ---- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]),
:root[data-theme="default"] {
--color-bg: #0f0f12;
--color-panel: #18181e;
--color-border: #2a2a32;
--color-text: #e6e6ec;
--color-muted: #9a9aa6;
--color-accent: #4a94f0;
--color-accent-hover: #6ab0ff;
--color-on-accent: #ffffff;
--color-on-accent-soft: rgba(255, 255, 255, 0.15);
--color-on-accent-border: rgba(255, 255, 255, 0.3);
--color-selection: rgba(74, 148, 240, 0.25);
--color-selection-strong: rgba(74, 148, 240, 0.5);
--color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.65);
--color-backdrop: rgba(0, 0, 0, 0.6);
--color-scrollbar: rgba(255, 255, 255, 0.15);
--color-scrollbar-hover: rgba(255, 255, 255, 0.3);
}
}
/* ---- Neonflux ----
Dark, high-contrast, futuristic. A near-black neutral base keeps the
manuscript comfortable for long writing sessions while electric-cyan accent
marks active/selected/focused elements, links and controls. */
:root[data-theme="neonflux"] {
--color-bg: #0a0d14;
--color-panel: #121722;
--color-border: #24304a;
--color-text: #e7ecf6;
--color-muted: #8d97ad;
--color-accent: #22d3ee;
--color-accent-hover: #67e8f9;
--color-on-accent: #06131c;
--color-on-accent-soft: rgba(6, 19, 28, 0.18);
--color-on-accent-border: rgba(6, 19, 28, 0.38);
--color-selection: rgba(34, 211, 238, 0.16);
--color-selection-strong: rgba(34, 211, 238, 0.34);
--color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.65);
--color-backdrop: rgba(4, 8, 16, 0.6);
--color-scrollbar: #263047;
--color-scrollbar-hover: #33425f;
}

View file

@ -714,6 +714,57 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
out.navBreakCount = navBreaks.length;
out.navBreaksOk = navBreaks.length >= 1 && navBreaks.some((el) => el.textContent.startsWith("# Two"));
// --- theme system: selector, immediate switching, persistence ---
const themeSelect = document.getElementById("themeSelect");
const themeVar = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const docBeforeTheme = navBook.state.doc.toString();
out.themeSelectPresent = !!themeSelect;
out.themeSelectOptions = themeSelect
? Array.from(themeSelect.options).map((o) => o.value)
: [];
out.themeHasDefault = out.themeSelectOptions.includes("default");
out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux");
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
out.themeInitialStored = await window.folio.getTheme();
// Switch to Neonflux: applies immediately and persists via IPC.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeNeonfluxAttr = document.documentElement.getAttribute("data-theme");
out.themeNeonfluxStored = await window.folio.getTheme();
const neonBg = themeVar("--color-bg");
const neonAccent = themeVar("--color-accent");
out.themeNeonfluxBg = neonBg;
out.themeNeonfluxAccent = neonAccent;
out.themeNeonfluxApplied =
out.themeNeonfluxAttr === "neonflux" &&
out.themeNeonfluxStored === "neonflux" &&
neonBg.toLowerCase() === "#0a0d14" &&
neonAccent.toLowerCase() === "#22d3ee";
// Neonflux is dark: computed background of the app should be near-black.
const bodyBg = getComputedStyle(document.body).backgroundColor;
out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(bodyBg);
// Switching themes must not touch the manuscript content.
out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme;
// Switch back to Default: applies immediately and persists.
themeSelect.value = "default";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeDefaultAttr = document.documentElement.getAttribute("data-theme");
out.themeDefaultStored = await window.folio.getTheme();
const defaultBg = themeVar("--color-bg");
out.themeDefaultBg = defaultBg;
out.themeDefaultApplied =
out.themeDefaultAttr === "default" &&
out.themeDefaultStored === "default" &&
(defaultBg.toLowerCase() === "#ffffff" || defaultBg.toLowerCase() === "#0f0f12");
out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme;
const okFlags = [
"dndMoved",
"kbMoved",
@ -781,6 +832,14 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"navFollowsNewOrder",
"navNextAfterReorderOk",
"navBreaksOk",
"themeSelectPresent",
"themeHasDefault",
"themeHasNeonflux",
"themeNeonfluxApplied",
"themeNeonfluxDark",
"themeContentUnchanged",
"themeDefaultApplied",
"themeContentUnchanged2",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {