Add a built-in Light theme

Replaces the old Default theme (which followed the OS color scheme) with a
fixed warm, writing-focused Light theme: off-white editor surface, charcoal
text, subtle warm-gray borders, and a restrained deep teal-green accent. The
selector now offers Light and Neonflux. Old saves storing "default" are
migrated to "light" in both the renderer and the main process, and the e2e
scenario now covers Light + Neonflux application, light/dark checks, theme
round-trips, and content preservation.
This commit is contained in:
avi 2026-08-18 17:15:10 -05:00
commit 247f16baa5
5 changed files with 103 additions and 75 deletions

View file

@ -432,10 +432,13 @@ ipcMain.handle("folio:getClipboardText", () => {
}); });
// Theme preference. Stored as a plain string so future themes can be added // Theme preference. Stored as a plain string so future themes can be added
// without touching the main process. // without touching the main process. "default" from older builds maps to
ipcMain.handle("folio:getTheme", () => getSetting("theme", "default")); // "light" in the renderer, so it is normalized here too.
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light"));
ipcMain.handle("folio:setTheme", (_e, id: unknown) => { ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
setSetting("theme", typeof id === "string" && id ? id : "default"); const theme =
typeof id === "string" && id && id !== "default" ? id : "light";
setSetting("theme", theme);
return { ok: true }; return { ok: true };
}); });

View file

@ -30,7 +30,7 @@
<div class="theme-control"> <div class="theme-control">
<label class="theme-label" for="themeSelect">Theme</label> <label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme"> <select id="themeSelect" class="theme-select" title="Interface theme">
<option value="default">Default</option> <option value="light">Light</option>
<option value="neonflux">Neonflux</option> <option value="neonflux">Neonflux</option>
</select> </select>
</div> </div>

View file

@ -6,28 +6,37 @@ export interface ThemeApi {
setTheme: (id: string) => Promise<unknown>; setTheme: (id: string) => Promise<unknown>;
} }
function selector(): HTMLSelectElement { export function selector(): HTMLSelectElement {
return document.getElementById("themeSelect") as HTMLSelectElement; return document.getElementById("themeSelect") as HTMLSelectElement;
} }
// Older builds stored the theme as "default"; that now maps to the Light
// theme, which replaced it as Folio's default light appearance.
const LIGHT = "light";
function normalizeTheme(id: unknown): string {
if (typeof id !== "string" || !id || id === "default") return LIGHT;
return id;
}
export function applyTheme(id: string): void { export function applyTheme(id: string): void {
const theme = typeof id === "string" && id ? id : "default"; const theme = normalizeTheme(id);
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute("data-theme", theme);
const sel = selector(); const sel = selector();
if (sel && sel.value !== theme) sel.value = theme; if (sel && sel.value !== theme) sel.value = theme;
} }
export function currentTheme(): string { export function currentTheme(): string {
return document.documentElement.getAttribute("data-theme") || "default"; return normalizeTheme(document.documentElement.getAttribute("data-theme"));
} }
// Load the saved theme and wire the selector. Unknown stored values fall back // 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. // to Light (the :root base), so adding a theme later is safe for old saves.
export async function initTheme(api: ThemeApi): Promise<void> { export async function initTheme(api: ThemeApi): Promise<void> {
let saved = "default"; let saved = LIGHT;
try { try {
const value = await api.getTheme(); const value = await api.getTheme();
if (typeof value === "string" && value) saved = value; if (typeof value === "string" && value) saved = normalizeTheme(value);
} catch { } catch {
// keep the default on any IPC failure // keep the default on any IPC failure
} }

View file

@ -4,9 +4,8 @@
just a block of variable values — adding one means dropping a just a block of variable values — adding one means dropping a
`:root[data-theme="..."]` block here plus an <option> in the theme selector. `:root[data-theme="..."]` block here plus an <option> in the theme selector.
Default mirrors Folio's original appearance: light, with an automatic dark Light is Folio's default: a warm, writing-focused light scheme. Neonflux is
variant that follows the OS color scheme. Neonflux is a fixed dark, a fixed dark, futuristic theme. */
futuristic theme. */
/* ---- shared (non-color) tokens ---- */ /* ---- shared (non-color) tokens ---- */
:root { :root {
@ -17,58 +16,35 @@
--gap: 16px; --gap: 16px;
} }
/* ---- Default (light) ---- */ /* ---- Light (default) ----
A clean, professional writing theme. Warm off-white chrome keeps the app
soft instead of harsh, while a warm-white editor surface and charcoal text
stay comfortable for long-form reading. Borders and dividers are subtle
warm grays; the accent is a restrained deep teal-green so interactive and
selected elements stay clearly visible without shouting. */
:root, :root,
:root[data-theme="default"] { :root[data-theme="light"] {
--color-bg: #ffffff; --color-bg: #fffdf9;
--color-panel: #f7f7f8; --color-panel: #f5f1e9;
--color-border: #e2e2e7; --color-border: #e4e0d5;
--color-text: #1a1a1d; --color-text: #2b2925;
--color-muted: #6b6b76; --color-muted: #7c786d;
--color-accent: #2563eb; --color-accent: #2f6b5f;
--color-accent-hover: #1d4ed8; --color-accent-hover: #26584e;
--color-on-accent: #ffffff; --color-on-accent: #ffffff;
--color-on-accent-soft: rgba(255, 255, 255, 0.15); --color-on-accent-soft: rgba(255, 255, 255, 0.18);
--color-on-accent-border: rgba(255, 255, 255, 0.3); --color-on-accent-border: rgba(255, 255, 255, 0.35);
--color-selection: rgba(37, 99, 235, 0.25); --color-selection: rgba(47, 107, 95, 0.22);
--color-selection-strong: rgba(37, 99, 235, 0.5); --color-selection-strong: rgba(47, 107, 95, 0.45);
--color-shadow: rgba(0, 0, 0, 0.25); --color-shadow: rgba(45, 40, 32, 0.18);
--color-shadow-strong: rgba(0, 0, 0, 0.3); --color-shadow-strong: rgba(45, 40, 32, 0.26);
--color-backdrop: rgba(0, 0, 0, 0.45); --color-backdrop: rgba(45, 40, 32, 0.4);
--color-scrollbar: rgba(0, 0, 0, 0.18); --color-scrollbar: rgba(45, 40, 32, 0.22);
--color-scrollbar-hover: rgba(0, 0, 0, 0.32); --color-scrollbar-hover: rgba(45, 40, 32, 0.38);
}
/* ---- 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 ---- /* ---- Neonflux ----

View file

@ -762,10 +762,16 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
out.themeSelectOptions = themeSelect out.themeSelectOptions = themeSelect
? Array.from(themeSelect.options).map((o) => o.value) ? Array.from(themeSelect.options).map((o) => o.value)
: []; : [];
out.themeHasDefault = out.themeSelectOptions.includes("default"); out.themeHasLight = out.themeSelectOptions.includes("light");
out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux"); out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux");
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
out.themeInitialStored = await window.folio.getTheme(); out.themeInitialStored = await window.folio.getTheme();
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
// Old saves may hold "default", which must resolve to the Light theme
// (migration). Valid saved themes must be restored exactly on startup.
out.themeInitialConsistent =
out.themeInitialStored === "default" || out.themeInitialStored === "light"
? out.themeInitialAttr === "light"
: out.themeInitialAttr === out.themeInitialStored;
// Switch to Neonflux: applies immediately and persists via IPC. // Switch to Neonflux: applies immediately and persists via IPC.
themeSelect.value = "neonflux"; themeSelect.value = "neonflux";
@ -784,26 +790,55 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
neonAccent.toLowerCase() === "#22d3ee"; neonAccent.toLowerCase() === "#22d3ee";
// Neonflux is dark: computed background of the app should be near-black. // Neonflux is dark: computed background of the app should be near-black.
const bodyBg = getComputedStyle(document.body).backgroundColor; const neonBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(bodyBg); out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(neonBodyBg);
// Switching themes must not touch the manuscript content. // Switching themes must not touch the manuscript content.
out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme; out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme;
// Switch back to Default: applies immediately and persists. // Switch to Light: applies immediately and persists.
themeSelect.value = "default"; themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true })); themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200); await navWait(200);
out.themeDefaultAttr = document.documentElement.getAttribute("data-theme"); out.themeLightAttr = document.documentElement.getAttribute("data-theme");
out.themeDefaultStored = await window.folio.getTheme(); out.themeLightStored = await window.folio.getTheme();
const defaultBg = themeVar("--color-bg"); const lightBg = themeVar("--color-bg");
out.themeDefaultBg = defaultBg; const lightAccent = themeVar("--color-accent");
out.themeDefaultApplied = out.themeLightBg = lightBg;
out.themeDefaultAttr === "default" && out.themeLightAccent = lightAccent;
out.themeDefaultStored === "default" && out.themeLightApplied =
(defaultBg.toLowerCase() === "#ffffff" || defaultBg.toLowerCase() === "#0f0f12"); out.themeLightAttr === "light" &&
out.themeLightStored === "light" &&
lightBg.toLowerCase() === "#fffdf9" &&
lightAccent.toLowerCase() === "#2f6b5f";
// Light is light: computed background must be a warm off-white, never the
// near-black used by Neonflux.
const lightBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeLightNotDark = !/rgb\((10|11|12|9|8),/.test(lightBodyBg);
out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme; out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme;
// Round-trip: back to Neonflux restores it exactly.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeRoundTripAttr = document.documentElement.getAttribute("data-theme");
out.themeRoundTripStored = await window.folio.getTheme();
out.themeRoundTripOk =
out.themeRoundTripAttr === "neonflux" &&
out.themeRoundTripStored === "neonflux" &&
themeVar("--color-bg").toLowerCase() === "#0a0d14" &&
themeVar("--color-accent").toLowerCase() === "#22d3ee";
// Leave the app on Light so subsequent runs start deterministically.
themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeFinalAttr = document.documentElement.getAttribute("data-theme");
out.themeFinalStored = await window.folio.getTheme();
out.themeFinalLightOk = out.themeFinalAttr === "light" && out.themeFinalStored === "light";
out.themeContentUnchanged3 = navBook.state.doc.toString() === docBeforeTheme;
const okFlags = [ const okFlags = [
"dndMoved", "dndMoved",
"kbMoved", "kbMoved",
@ -876,13 +911,18 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"navNextAfterReorderOk", "navNextAfterReorderOk",
"navBreaksOk", "navBreaksOk",
"themeSelectPresent", "themeSelectPresent",
"themeHasDefault", "themeHasLight",
"themeHasNeonflux", "themeHasNeonflux",
"themeInitialConsistent",
"themeNeonfluxApplied", "themeNeonfluxApplied",
"themeNeonfluxDark", "themeNeonfluxDark",
"themeContentUnchanged", "themeContentUnchanged",
"themeDefaultApplied", "themeLightApplied",
"themeLightNotDark",
"themeContentUnchanged2", "themeContentUnchanged2",
"themeRoundTripOk",
"themeFinalLightOk",
"themeContentUnchanged3",
]; ];
out.all = okFlags.every((f) => out[f] === true); out.all = okFlags.every((f) => out[f] === true);
} catch (e) { } catch (e) {