feat(web): switch to the Alpine CSP build; drop 'unsafe-eval' from CSP

Standard Alpine evaluates every attribute expression through
new Function(), which a strict Content-Security-Policy cannot allow.
Swap to the official CSP-safe build (@alpinejs/csp), whose parser
evaluates expressions against registered component scope without
eval. script-src now carries only 'self' and 'unsafe-inline' (the
latter still required by the pre-paint theme flash guard).

Bindings rewritten out of the unsupported expression shapes:
- 21 multi-statement sequences across admin, audio_reader, docs,
  header, index, and reader became single component methods
  (init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed,
  pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode,
  toggleFxInvert, openPageThumbs, submitNote, clearSearch,
  pickReadingTheme)
- the header sidebar's inline x-data (openPanel/themeMode IIFE)
  became a registered sidebarShell component; openPanel itself moved
  to the header root so every sidebar panel shares one open state
- the appearance-panel  moved into the component init()
- header init() now also seeds openPanel from localStorage (or the
  admin section on /admin pages)

Alpine magics (, , , ) remain available —
the CSP evaluator injects them into scope.

The Playwright suite covers the converted surfaces end to end.
This commit is contained in:
John O'Keefe committed 2026-10-25 16:58:28 -04:00
1 parent a730a62280
commit eb57b7208b
15 files changed
+154 -23

No files matched your search

+1
View File
@@ -25,6 +25,7 @@
"turndown-plugin-gfm": "^1.0.2" "turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@alpinejs/csp": "^3.15.8",
"@playwright/test": "^1.64.1", "@playwright/test": "^1.64.1",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
+1 -1
View File
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class={ "theme-" + user.Theme }> <body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin") @Header(user, "/admin")
<main class="p-8"> <main class="p-8">
<div class="max-w-4xl"> <div class="max-w-4xl">
+6 -6
View File
@@ -246,7 +246,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
:value="scrubbing ? scrubValue : currentTime" :value="scrubbing ? scrubValue : currentTime"
class="relative w-full" class="relative w-full"
@input="scrubbing = true; scrubValue = Number($event.target.value)" @input="scrubbing = true; scrubValue = Number($event.target.value)"
@change="seekTo(scrubValue); scrubbing = false" @change="commitScrub()"
aria-label="Seek position" aria-label="Seek position"
/> />
<!-- Hover tooltip: time + chapter under the pointer --> <!-- Hover tooltip: time + chapter under the pointer -->
@@ -395,7 +395,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<template x-for="secs in skipChoices" :key="'skip' + secs"> <template x-for="secs in skipChoices" :key="'skip' + secs">
<button <button
type="button" type="button"
@click="setSkip(secs); skipOpen = false" @click="pickSkip(secs)"
class="w-full text-left px-3 py-1.5 rounded-md text-sm" class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(skipSeconds) === secs ? 'font-semibold' : ''" :class="Number(skipSeconds) === secs ? 'font-semibold' : ''"
:style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'" :style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
@@ -425,7 +425,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<template x-for="rate in speeds" :key="'speed' + rate"> <template x-for="rate in speeds" :key="'speed' + rate">
<button <button
type="button" type="button"
@click="setSpeed(rate); speedOpen = false" @click="pickSpeed(rate)"
class="w-full text-left px-3 py-1.5 rounded-md text-sm" class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''" :class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'" :style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
@@ -437,7 +437,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<button <button
type="button" type="button"
x-show="!isDefaultSpeed(speed)" x-show="!isDefaultSpeed(speed)"
@click="setDefaultSpeed(speed); speedOpen = false" @click="pickDefaultSpeed(speed)"
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t" class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
style="border-color: var(--border); color: var(--text-secondary);" style="border-color: var(--border); color: var(--text-secondary);"
x-text="'Make ' + speed + '× my default'" x-text="'Make ' + speed + '× my default'"
@@ -481,7 +481,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<template x-for="opt in sleepChoices" :key="opt.key"> <template x-for="opt in sleepChoices" :key="opt.key">
<button <button
type="button" type="button"
@click="chooseSleep(opt); sleepOpen = false" @click="chooseSleepOption(opt)"
class="w-full text-left px-3 py-1.5 rounded-md text-sm" class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''" :class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'" :style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
@@ -500,7 +500,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
/> />
<button <button
type="button" type="button"
@click="chooseSleepClock(); sleepOpen = false" @click="chooseSleepClockMode()"
class="text-xs px-2.5 py-1 rounded-md font-semibold" class="text-xs px-2.5 py-1 rounded-md font-semibold"
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'" :style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
>Arm</button> >Arm</button>
+1 -1
View File
@@ -177,7 +177,7 @@ templ BookShelf(
innerHTML templating). Rows are a flat projection of innerHTML templating). Rows are a flat projection of
the tree (depth = indent); groups nest via + group. --> the tree (depth = indent); groups nest via + group. -->
<div x-data="filterBuilderExtra" x-cloak <div x-data="filterBuilderExtra" x-cloak
x-effect="if (filtersOpen === true) { ensureReadyRule(); }" x-effect="filtersOpen === true && ensureReadyRule()"
> >
<template x-for="row in builderRows" :key="row.key"> <template x-for="row in builderRows" :key="row.key">
<div> <div>
+1 -1
View File
@@ -23,7 +23,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="docs" x-init="scrollSidebarToActive(); initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }> <body x-data="docs" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
@DocsTopbar(user) @DocsTopbar(user)
<!-- Mobile drawer backdrop --> <!-- Mobile drawer backdrop -->
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div> <div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div>
+5 -7
View File
@@ -3,7 +3,6 @@ package templates
templ Header(user User, currentPath string) { templ Header(user User, currentPath string) {
<div <div
x-data="header" x-data="header"
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
> >
<!-- Mobile backdrop --> <!-- Mobile backdrop -->
<div <div
@@ -84,8 +83,7 @@ templ Header(user User, currentPath string) {
</nav> </nav>
<!-- Bottom: theme picker + user --> <!-- Bottom: theme picker + user -->
<div <div
x-data={ "{ openPanel: (function () { try { var v = localStorage.getItem('sidebar-open-panel'); if (v !== null) { return v === '' ? null : v; } } catch (e) {} return window.location.pathname.startsWith('/admin') ? 'admin' : null; })(), themeMode: '" + string(ThemeModeOf(user.Theme)) + "' }" } x-data={ "sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')" }
x-init="$watch('openPanel', function (v) { try { localStorage.setItem('sidebar-open-panel', v === null ? '' : v); } catch (e) {} })"
class="shrink-0 px-3 py-3 space-y-2 border-t" class="shrink-0 px-3 py-3 space-y-2 border-t"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
> >
@@ -98,7 +96,7 @@ templ Header(user User, currentPath string) {
<div class="sidebar-panel"> <div class="sidebar-panel">
<button <button
type="button" type="button"
@click={ "openPanel = openPanel === 'appearance' ? null : 'appearance'; if (openPanel === 'appearance') $nextTick(() => revealCurrentTheme(themeMode))" } @click="toggleAppearancePanel()"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover" class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
> >
@Icon("palette", "h-5 w-5 shrink-0") @Icon("palette", "h-5 w-5 shrink-0")
@@ -112,7 +110,7 @@ templ Header(user User, currentPath string) {
<div class="grid grid-cols-2 gap-1 px-2 pt-1"> <div class="grid grid-cols-2 gap-1 px-2 pt-1">
<button <button
type="button" type="button"
@click="themeMode = 'dark'; $nextTick(() => revealCurrentTheme('dark'))" @click="setThemeMode('dark')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors" class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" } :class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
> >
@@ -122,7 +120,7 @@ templ Header(user User, currentPath string) {
</button> </button>
<button <button
type="button" type="button"
@click="themeMode = 'light'; $nextTick(() => revealCurrentTheme('light'))" @click="setThemeMode('light')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors" class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" } :class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
> >
@@ -136,7 +134,7 @@ templ Header(user User, currentPath string) {
for _, opt := range ThemeOptions { for _, opt := range ThemeOptions {
<button <button
type="button" type="button"
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" } @click="selectTheme(opt.Name)"
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
x-show={ "themeMode === '" + string(opt.Mode) + "'" } x-show={ "themeMode === '" + string(opt.Mode) + "'" }
data-theme={ opt.Name } data-theme={ opt.Name }
+1 -1
View File
@@ -13,7 +13,7 @@ templ Index(loggedIn bool, registrationEnabled bool) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="index" x-init="initIndexTheme(); checkAuthRedirect()"> <body x-data="index">
<div class="absolute top-4 right-4 z-10"> <div class="absolute top-4 right-4 z-10">
<select <select
id="theme-select" id="theme-select"
+5 -5
View File
@@ -554,7 +554,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
</div> </div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'"> <div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Night mode</span> <span>Night mode</span>
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button> <button @click="toggleFxInvert()" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
</div> </div>
<div class="reader-tools-row" x-show="isPDF && !treatAsComic"> <div class="reader-tools-row" x-show="isPDF && !treatAsComic">
<span>Pointer</span> <span>Pointer</span>
@@ -593,7 +593,7 @@ templ ReaderTOCDrawer() {
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'"> <button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span> Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
</button> </button>
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()"> <button :class="tocTab === 'pages' ? 'active' : ''" @click="openPageThumbs()">
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span> Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
</button> </button>
</div> </div>
@@ -691,7 +691,7 @@ templ ReaderAnnotationsDrawer() {
x-text="noteCountLabel(newNoteText)" x-text="noteCountLabel(newNoteText)"
></div> ></div>
<button <button
@click="addNote(newNoteText); newNoteText = ''" @click="submitNote()"
:disabled="noteOverLimit(newNoteText)" :disabled="noteOverLimit(newNoteText)"
class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
@@ -820,7 +820,7 @@ templ ReaderSearchDrawer() {
/> />
<button <button
x-show="searchQuery || searchGroups.length" x-show="searchQuery || searchGroups.length"
@click="searchQuery = ''; clearSearchResults()" @click="clearSearch()"
class="p-2 rounded-lg hover:bg-gray-700 shrink-0" class="p-2 rounded-lg hover:bg-gray-700 shrink-0"
title="Clear results" title="Clear results"
aria-label="Clear results" aria-label="Clear results"
@@ -908,7 +908,7 @@ templ ReaderSettingsDrawer() {
<template x-for="sw in themeSwatches" :key="sw.value"> <template x-for="sw in themeSwatches" :key="sw.value">
<button <button
type="button" type="button"
@click="readingTheme = sw.value; applyTheme()" @click="pickReadingTheme(sw.value)"
class="theme-swatch" class="theme-swatch"
:class="readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''" :class="readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''"
:style="'background-color:' + sw.bg + '; color:' + sw.fg" :style="'background-color:' + sw.bg + '; color:' + sw.fg"
+2
View File
@@ -596,6 +596,8 @@ function bindDeleteLibraryModalClose(): void {
Alpine.data("admin", () => ({ Alpine.data("admin", () => ({
init() { init() {
bindDeleteLibraryModalClose(); bindDeleteLibraryModalClose();
this.loadWatchStatus();
this.checkActiveScans();
}, },
cancelAllScans, cancelAllScans,
checkActiveScans, checkActiveScans,
+1 -1
View File
@@ -1,4 +1,4 @@
import Alpine from "alpinejs"; import Alpine from "@alpinejs/csp";
// Extend Window interface to include Alpine. The `htmx` member is declared // Extend Window interface to include Alpine. The `htmx` member is declared
// once in shims.d.ts — redeclaring it here with a different shape is a // once in shims.d.ts — redeclaring it here with a different shape is a
+25
View File
@@ -678,6 +678,31 @@ Alpine.data("audioReader", () => ({
}) })
.catch(() => showToast("Default speed not saved", "error")); .catch(() => showToast("Default speed not saved", "error"));
}, },
// Popover option handlers (CSP-safe: single method calls from templ).
commitScrub() {
this.seekTo(this.scrubValue);
this.scrubbing = false;
},
pickSkip(seconds: number) {
this.setSkip(seconds);
this.skipOpen = false;
},
pickSpeed(rate: number) {
this.setSpeed(rate);
this.speedOpen = false;
},
pickDefaultSpeed(rate: number) {
this.setDefaultSpeed(rate);
this.speedOpen = false;
},
chooseSleepOption(choice: SleepChoice) {
this.chooseSleep(choice);
this.sleepOpen = false;
},
chooseSleepClockMode() {
this.chooseSleepClock();
this.sleepOpen = false;
},
isDefaultSpeed(rate: number): boolean { isDefaultSpeed(rate: number): boolean {
return Number(this.defaultSpeed) === rate; return Number(this.defaultSpeed) === rate;
+5
View File
@@ -153,6 +153,11 @@ function initializeCodeCopyButtons(): void {
} }
Alpine.data("docs", () => ({ Alpine.data("docs", () => ({
init() {
this.scrollSidebarToActive();
this.initializeSearch();
this.initializeCodeCopyButtons();
},
toggleSidebar, toggleSidebar,
toggleCollapsible, toggleCollapsible,
initializeSearch: initializeDocsSearch, initializeSearch: initializeDocsSearch,
+75
View File
@@ -121,7 +121,60 @@ const initializeScanListener = function (this: any) {
export { logout, restoreLibrarySelection }; export { logout, restoreLibrarySelection };
Alpine.data("header", () => ({ Alpine.data("header", () => ({
init() {
// Shared panel state: one open panel across all sidebar panels. Default
// to the persisted panel (or the admin section on /admin pages).
try {
const v = localStorage.getItem("sidebar-open-panel");
this.openPanel =
v !== null
? v === ""
? null
: v
: window.location.pathname.startsWith("/admin")
? "admin"
: null;
} catch {
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
}
initializeSearch();
this.initializeTheme();
this.loadWoodPaneling();
this.updateWoodPanelingIndicators();
this.restoreLibrarySelection();
this.initializeScanListener();
// Persist the sidebar's open panel across navigations (was an inline
// x-init $watch on the panel element).
this.$watch("openPanel", (v: unknown) => {
try {
localStorage.setItem("sidebar-open-panel", v === null ? "" : String(v));
} catch {
/* private-mode localStorage failures are non-fatal */
}
});
},
openPanel: null as string | null,
// Shadow copy for the root-level toggleAppearancePanel: the sidebar shell's
// own themeMode (initialized from the server) is the one that matters at
// runtime — the merged Alpine scope reads the nearest declaration.
themeMode: "",
mobileMenuOpen: false, mobileMenuOpen: false,
// Shared panel toggles: invoked from the sidebar shell's scope, so `this`
// is the merged data stack (shell themeMode + root openPanel).
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
logout, logout,
initializeSearch, initializeSearch,
initializeTheme, initializeTheme,
@@ -137,3 +190,25 @@ Alpine.data("header", () => ({
scanNewItems: 0, scanNewItems: 0,
scanFilesScanned: 0, scanFilesScanned: 0,
})); }));
// Sidebar shell: owns the appearance panel's theme-mode state. The shared
// openPanel lives on the header root (one open panel across all panels).
// Registered (not inline x-data) so its methods work under the CSP-safe
// Alpine build, which cannot evaluate arbitrary inline sentences.
Alpine.data("sidebarShell", (initialThemeMode: string) => ({
themeMode: initialThemeMode,
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
}));
+4
View File
@@ -44,6 +44,10 @@ async function checkAuthRedirect(): Promise<void> {
export { changeTheme, checkAuthRedirect, initIndexTheme }; export { changeTheme, checkAuthRedirect, initIndexTheme };
Alpine.data("index", () => ({ Alpine.data("index", () => ({
init() {
initIndexTheme();
checkAuthRedirect();
},
changeTheme, changeTheme,
checkAuthRedirect, checkAuthRedirect,
initIndexTheme, initIndexTheme,
+21
View File
@@ -3587,6 +3587,27 @@ document.addEventListener("alpine:init", () => {
this.readingMode = this.readingMode === "dark" ? "light" : "dark"; this.readingMode = this.readingMode === "dark" ? "light" : "dark";
this.applyTheme(); this.applyTheme();
}, },
// Popover/toolbar handlers (CSP-safe: single method calls from templ).
toggleFxInvert() {
this.fxInvert = !this.fxInvert;
this.applyFxFilter(true);
},
openPageThumbs() {
this.tocTab = "pages";
this.initPageThumbs();
},
submitNote() {
this.addNote(this.newNoteText);
this.newNoteText = "";
},
clearSearch() {
this.searchQuery = "";
this.clearSearchResults();
},
pickReadingTheme(value: string) {
this.readingTheme = value;
this.applyTheme();
},
detectChromeDarkMode(): boolean { detectChromeDarkMode(): boolean {
const darkChromeThemes = [ const darkChromeThemes = [
"theme-tokyo-night", "theme-tokyo-night",