diff --git a/docs/superpowers/plans/2026-04-19-reader-paginated-mode.md b/docs/superpowers/plans/2026-04-19-reader-paginated-mode.md new file mode 100644 index 00000000..eb7da70a --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-reader-paginated-mode.md @@ -0,0 +1,1190 @@ +# Reader Paginated Mode — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add paginated mode to the PDF reader, bring EPUB to feature parity with shared controls (flow / spread / animation / per-input toggles / RTL direction), and unify the settings surface across both. Includes a small bug fix so the EPUB reader doesn't load for books that have no EPUB format (already committed in spec commit). + +**Architecture:** A new `PaginatedViewport.svelte` wraps both readers and owns flow/spread/animation/input handling. A `useReaderInputs.svelte.ts` rune centralizes tap-zone / swipe / keyboard handling. A new `reader-settings.ts` helper owns the shared `ReaderSettings` schema, defaults, and `localStorage` persistence (extending the existing `nexus-reader-settings` key). + +**Tech Stack:** SvelteKit 5 (runes), TypeScript, foliate-js (EPUB), pdfjs-dist (PDF), Vitest, Tailwind. + +**Spec:** `docs/superpowers/specs/2026-04-19-reader-paginated-mode-design.md` + +**Branch:** `feature/reader-paginated-mode` (worktree at `.worktrees/reader-paginated`) + +**Closes:** #61 + +--- + +## File Map + +**Create:** +- `src/lib/components/books/reader-settings.ts` — `ReaderSettings` type, defaults, `loadReaderSettings()`, `persistReaderSettings()`, `resolveSpread()`, theme/font/margin enums. +- `src/lib/components/books/__tests__/reader-settings.test.ts` — vitest covering load/persist/defaults/resolve. +- `src/lib/components/books/useReaderInputs.svelte.ts` — rune that exposes tap-zone / swipe / keyboard handlers, respecting `inputs.*` and `direction`. +- `src/lib/components/books/__tests__/useReaderInputs.test.ts` — vitest covering hit detection, swipe threshold, RTL flip, keyboard mapping, per-input enable/disable. +- `src/lib/components/books/PaginatedViewport.svelte` — wrapper component, owns flow/spread/animation/input wiring, passes `goPrev`/`goNext`/`pageOffset` to children via Svelte snippets and bindable props. +- `src/lib/components/books/ReaderSettingsPanel.svelte` — shared settings UI (theme, font, font-size, line-height, margins, text-align, flow, spread, animation, inputs, direction). + +**Modify:** +- `src/lib/components/books/BookReader.svelte` — adopt `reader-settings.ts`, render `` in its drawer, pass settings to ``, hook foliate-js renderer to viewport's prev/next/flow/direction. +- `src/lib/components/books/PdfReader.svelte` — adopt `reader-settings.ts`, render `` in its drawer, wrap with ``, render only current page(s) when `flow === 'paginated'`, advance via viewport callbacks. Existing single/dual `spreadMode` becomes the `spread` setting. +- `src/lib/components/books/KeyboardShortcuts.svelte` — make format-agnostic (currently EPUB-only); export keymap so PDF reader can adopt the same shortcuts. (If file is too tightly coupled to EPUB, the keyboard half of `useReaderInputs` may obviate this file — in that case Task 9 deletes it.) + +**Already committed (in spec commit `050e489`):** +- `src/routes/books/read/[id]/+page.server.ts` — books-read 500 fix + format default fallback. + +--- + +## Task 1: Reader settings module — schema, defaults, persistence + +**Files:** +- Create: `src/lib/components/books/reader-settings.ts` +- Create: `src/lib/components/books/__tests__/reader-settings.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/lib/components/books/__tests__/reader-settings.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { + DEFAULT_READER_SETTINGS, + loadReaderSettings, + persistReaderSettings, + resolveSpread, + type ReaderSettings +} from '../reader-settings'; + +describe('reader-settings', () => { + beforeEach(() => { + // vitest provides a happy-dom localStorage by default + localStorage.clear(); + }); + + it('returns defaults when nothing is stored', () => { + expect(loadReaderSettings()).toEqual(DEFAULT_READER_SETTINGS); + }); + + it('round-trips persisted settings', () => { + const patch: Partial = { + flow: 'scrolled', + spread: 'dual', + pageAnimation: 'fade', + direction: 'rtl', + inputs: { tapZones: false, swipe: true, keyboard: true } + }; + persistReaderSettings(patch); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe('scrolled'); + expect(loaded.spread).toBe('dual'); + expect(loaded.pageAnimation).toBe('fade'); + expect(loaded.direction).toBe('rtl'); + expect(loaded.inputs.tapZones).toBe(false); + }); + + it('preserves unrelated existing fields when persisting a partial patch', () => { + persistReaderSettings({ fontSize: 22, theme: 'sepia' as ReaderSettings['theme'] }); + persistReaderSettings({ flow: 'scrolled' }); + const loaded = loadReaderSettings(); + expect(loaded.fontSize).toBe(22); + expect(loaded.theme).toBe('sepia'); + expect(loaded.flow).toBe('scrolled'); + }); + + it('falls back to defaults for missing keys in stored JSON', () => { + localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'scrolled' })); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe('scrolled'); + expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); + expect(loaded.inputs).toEqual(DEFAULT_READER_SETTINGS.inputs); + }); + + it('coerces invalid stored values back to defaults', () => { + localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'banana', spread: 42 })); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe(DEFAULT_READER_SETTINGS.flow); + expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); + }); + + describe('resolveSpread', () => { + it('returns single below 768px when auto', () => { + expect(resolveSpread('auto', 600)).toBe('single'); + }); + it('returns dual at or above 768px when auto', () => { + expect(resolveSpread('auto', 1024)).toBe('dual'); + }); + it('respects explicit single regardless of width', () => { + expect(resolveSpread('single', 1920)).toBe('single'); + }); + it('respects explicit dual regardless of width', () => { + expect(resolveSpread('dual', 320)).toBe('dual'); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd .worktrees/reader-paginated +pnpm vitest run src/lib/components/books/__tests__/reader-settings.test.ts +``` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the module** + +```ts +// src/lib/components/books/reader-settings.ts +export type ReaderThemeName = 'light' | 'dark' | 'sepia' | 'night'; +export type FontFamilyName = 'serif' | 'sans' | 'mono' | 'dyslexic'; +export type MarginName = 'narrow' | 'normal' | 'wide'; + +export interface ReaderSettings { + theme: ReaderThemeName; + fontFamily: FontFamilyName; + fontSize: number; + lineHeight: number; + margins: MarginName; + textAlign: 'start' | 'justify'; + flow: 'paginated' | 'scrolled'; + spread: 'auto' | 'single' | 'dual'; + pageAnimation: 'slide' | 'fade' | 'none'; + inputs: { + tapZones: boolean; + swipe: boolean; + keyboard: boolean; + }; + direction: 'ltr' | 'rtl'; +} + +export const DEFAULT_READER_SETTINGS: ReaderSettings = { + theme: 'dark', + fontFamily: 'serif', + fontSize: 18, + lineHeight: 1.5, + margins: 'normal', + textAlign: 'start', + flow: 'paginated', + spread: 'auto', + pageAnimation: 'slide', + inputs: { tapZones: true, swipe: true, keyboard: true }, + direction: 'ltr' +}; + +const STORAGE_KEY = 'nexus-reader-settings'; + +const FLOW_VALUES = new Set(['paginated', 'scrolled'] as const); +const SPREAD_VALUES = new Set(['auto', 'single', 'dual'] as const); +const ANIM_VALUES = new Set(['slide', 'fade', 'none'] as const); +const DIR_VALUES = new Set(['ltr', 'rtl'] as const); +const ALIGN_VALUES = new Set(['start', 'justify'] as const); +const THEME_VALUES = new Set(['light', 'dark', 'sepia', 'night'] as const); +const FONT_VALUES = new Set(['serif', 'sans', 'mono', 'dyslexic'] as const); +const MARGIN_VALUES = new Set(['narrow', 'normal', 'wide'] as const); + +function pick(value: unknown, allowed: Set, fallback: T): T { + return typeof value === 'string' && allowed.has(value as T) ? (value as T) : fallback; +} + +function pickNumber(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + if (value < min || value > max) return fallback; + return value; +} + +function pickBool(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function coerce(raw: unknown): ReaderSettings { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record; + const inputsRaw = (r.inputs && typeof r.inputs === 'object' ? r.inputs : {}) as Record; + return { + theme: pick(r.theme, THEME_VALUES, DEFAULT_READER_SETTINGS.theme), + fontFamily: pick(r.fontFamily, FONT_VALUES, DEFAULT_READER_SETTINGS.fontFamily), + fontSize: pickNumber(r.fontSize, DEFAULT_READER_SETTINGS.fontSize, 10, 36), + lineHeight: pickNumber(r.lineHeight, DEFAULT_READER_SETTINGS.lineHeight, 1.0, 2.5), + margins: pick(r.margins, MARGIN_VALUES, DEFAULT_READER_SETTINGS.margins), + textAlign: pick(r.textAlign, ALIGN_VALUES, DEFAULT_READER_SETTINGS.textAlign), + flow: pick(r.flow, FLOW_VALUES, DEFAULT_READER_SETTINGS.flow), + spread: pick(r.spread, SPREAD_VALUES, DEFAULT_READER_SETTINGS.spread), + pageAnimation: pick(r.pageAnimation, ANIM_VALUES, DEFAULT_READER_SETTINGS.pageAnimation), + inputs: { + tapZones: pickBool(inputsRaw.tapZones, DEFAULT_READER_SETTINGS.inputs.tapZones), + swipe: pickBool(inputsRaw.swipe, DEFAULT_READER_SETTINGS.inputs.swipe), + keyboard: pickBool(inputsRaw.keyboard, DEFAULT_READER_SETTINGS.inputs.keyboard) + }, + direction: pick(r.direction, DIR_VALUES, DEFAULT_READER_SETTINGS.direction) + }; +} + +export function loadReaderSettings(): ReaderSettings { + if (typeof localStorage === 'undefined') return { ...DEFAULT_READER_SETTINGS }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_READER_SETTINGS }; + return coerce(JSON.parse(raw)); + } catch { + return { ...DEFAULT_READER_SETTINGS }; + } +} + +export function persistReaderSettings(patch: Partial): void { + if (typeof localStorage === 'undefined') return; + const current = loadReaderSettings(); + const next: ReaderSettings = { + ...current, + ...patch, + inputs: { ...current.inputs, ...(patch.inputs ?? {}) } + }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* quota or privacy mode — ignore */ + } +} + +const SPREAD_BREAKPOINT_PX = 768; + +export function resolveSpread(spread: ReaderSettings['spread'], viewportWidth: number): 'single' | 'dual' { + if (spread === 'single' || spread === 'dual') return spread; + return viewportWidth >= SPREAD_BREAKPOINT_PX ? 'dual' : 'single'; +} +``` + +- [ ] **Step 4: Run tests, expect green** + +```bash +pnpm vitest run src/lib/components/books/__tests__/reader-settings.test.ts +``` +Expected: 9 passing. + +- [ ] **Step 5: Run typecheck** + +```bash +pnpm check +``` +Expected: 0 errors (warnings unrelated to this change are OK). + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/components/books/reader-settings.ts \ + src/lib/components/books/__tests__/reader-settings.test.ts +git commit -m "feat(reader): shared ReaderSettings module with localStorage persistence (#61) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: useReaderInputs rune — tap zones, swipe, keyboard + +**Files:** +- Create: `src/lib/components/books/useReaderInputs.svelte.ts` +- Create: `src/lib/components/books/__tests__/useReaderInputs.test.ts` + +The rune exposes a small object with three things: a `pointerHandlers` set you spread onto the tap-zone overlay, a `touchHandlers` set you spread onto the swipe surface, and a side-effect for keyboard listeners that runs in an `$effect`. RTL mode flips the prev/next mapping for all three. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/lib/components/books/__tests__/useReaderInputs.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { hitZone, isHorizontalSwipe, mapKeyToAction } from '../useReaderInputs.svelte'; + +describe('useReaderInputs helpers', () => { + describe('hitZone', () => { + it('maps left third to prev', () => { + expect(hitZone(50, 1000)).toBe('prev'); + }); + it('maps middle third to toggleUI', () => { + expect(hitZone(500, 1000)).toBe('toggleUI'); + }); + it('maps right third to next', () => { + expect(hitZone(900, 1000)).toBe('next'); + }); + it('handles small viewports', () => { + expect(hitZone(100, 360)).toBe('prev'); + expect(hitZone(180, 360)).toBe('toggleUI'); + expect(hitZone(300, 360)).toBe('next'); + }); + }); + + describe('isHorizontalSwipe', () => { + it('returns prev for rightward swipe past threshold', () => { + expect(isHorizontalSwipe({ dx: 80, dy: 10 })).toBe('prev'); + }); + it('returns next for leftward swipe past threshold', () => { + expect(isHorizontalSwipe({ dx: -80, dy: 10 })).toBe('next'); + }); + it('returns null when below threshold', () => { + expect(isHorizontalSwipe({ dx: 30, dy: 5 })).toBeNull(); + }); + it('returns null when vertical travel dominates', () => { + expect(isHorizontalSwipe({ dx: 60, dy: 200 })).toBeNull(); + }); + }); + + describe('mapKeyToAction', () => { + it('maps ArrowLeft to prev', () => { + expect(mapKeyToAction('ArrowLeft')).toBe('prev'); + }); + it('maps ArrowRight to next', () => { + expect(mapKeyToAction('ArrowRight')).toBe('next'); + }); + it('maps PageUp to prev, PageDown and Space to next', () => { + expect(mapKeyToAction('PageUp')).toBe('prev'); + expect(mapKeyToAction('PageDown')).toBe('next'); + expect(mapKeyToAction(' ')).toBe('next'); + }); + it('returns null for irrelevant keys', () => { + expect(mapKeyToAction('a')).toBeNull(); + expect(mapKeyToAction('Enter')).toBeNull(); + }); + }); + + describe('flipForRtl', () => { + it('swaps prev and next when direction is rtl', async () => { + const { flipForRtl } = await import('../useReaderInputs.svelte'); + expect(flipForRtl('prev', 'rtl')).toBe('next'); + expect(flipForRtl('next', 'rtl')).toBe('prev'); + expect(flipForRtl('toggleUI', 'rtl')).toBe('toggleUI'); + }); + it('passes through when direction is ltr', async () => { + const { flipForRtl } = await import('../useReaderInputs.svelte'); + expect(flipForRtl('prev', 'ltr')).toBe('prev'); + expect(flipForRtl('next', 'ltr')).toBe('next'); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm vitest run src/lib/components/books/__tests__/useReaderInputs.test.ts +``` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the rune + helpers** + +```ts +// src/lib/components/books/useReaderInputs.svelte.ts +import type { ReaderSettings } from './reader-settings'; + +export type ReaderAction = 'prev' | 'next' | 'toggleUI'; + +const SWIPE_MIN_PX = 50; +const SWIPE_VERTICAL_RATIO = 2; // |dx| must be > 2*|dy| + +export function hitZone(x: number, width: number): ReaderAction { + const third = width / 3; + if (x < third) return 'prev'; + if (x < 2 * third) return 'toggleUI'; + return 'next'; +} + +export function isHorizontalSwipe({ dx, dy }: { dx: number; dy: number }): ReaderAction | null { + if (Math.abs(dx) < SWIPE_MIN_PX) return null; + if (Math.abs(dx) <= Math.abs(dy) * SWIPE_VERTICAL_RATIO) return null; + return dx > 0 ? 'prev' : 'next'; +} + +export function mapKeyToAction(key: string): ReaderAction | null { + switch (key) { + case 'ArrowLeft': + case 'PageUp': + return 'prev'; + case 'ArrowRight': + case 'PageDown': + case ' ': + return 'next'; + default: + return null; + } +} + +export function flipForRtl(action: A, direction: ReaderSettings['direction']): A { + if (direction !== 'rtl') return action; + if (action === 'prev') return 'next' as A; + if (action === 'next') return 'prev' as A; + return action; +} + +interface UseInputsArgs { + getSettings: () => Pick; + onPrev: () => void; + onNext: () => void; + onToggleUI: () => void; +} + +export function useReaderInputs(args: UseInputsArgs) { + const dispatch = (action: ReaderAction) => { + const { direction } = args.getSettings(); + const final = flipForRtl(action, direction); + if (final === 'prev') args.onPrev(); + else if (final === 'next') args.onNext(); + else if (final === 'toggleUI') args.onToggleUI(); + }; + + // Tap-zone handlers (spread onto an absolutely-positioned overlay div). + const tapHandlers = { + onpointerup(e: PointerEvent) { + if (!args.getSettings().inputs.tapZones) return; + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + dispatch(hitZone(e.clientX - rect.left, rect.width)); + } + }; + + // Swipe handlers (spread onto the page surface). + let touchStart: { x: number; y: number } | null = null; + const swipeHandlers = { + ontouchstart(e: TouchEvent) { + if (!args.getSettings().inputs.swipe) return; + const t = e.changedTouches[0]; + touchStart = { x: t.clientX, y: t.clientY }; + }, + ontouchend(e: TouchEvent) { + if (!args.getSettings().inputs.swipe || !touchStart) return; + const t = e.changedTouches[0]; + const dx = t.clientX - touchStart.x; + const dy = t.clientY - touchStart.y; + touchStart = null; + const action = isHorizontalSwipe({ dx, dy }); + if (action) dispatch(action); + } + }; + + // Keyboard listener — caller wires this in an $effect. + function attachKeyboard(target: Window | HTMLElement = window): () => void { + const onKey = (e: KeyboardEvent) => { + if (!args.getSettings().inputs.keyboard) return; + const action = mapKeyToAction(e.key); + if (!action) return; + e.preventDefault(); + dispatch(action); + }; + target.addEventListener('keydown', onKey as EventListener); + return () => target.removeEventListener('keydown', onKey as EventListener); + } + + return { tapHandlers, swipeHandlers, attachKeyboard }; +} +``` + +- [ ] **Step 4: Run tests, expect green** + +```bash +pnpm vitest run src/lib/components/books/__tests__/useReaderInputs.test.ts +``` +Expected: 13 passing. + +- [ ] **Step 5: Typecheck** + +```bash +pnpm check +``` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/components/books/useReaderInputs.svelte.ts \ + src/lib/components/books/__tests__/useReaderInputs.test.ts +git commit -m "feat(reader): useReaderInputs rune for tap/swipe/keyboard (#61) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: PaginatedViewport.svelte — generic wrapper + +**Files:** +- Create: `src/lib/components/books/PaginatedViewport.svelte` + +The wrapper renders an absolutely-positioned tap-zone overlay above its child snippet, owns the swipe surface, attaches the keyboard listener, and surfaces a small reactive context the child can read for `effectiveSpread` (resolved from `auto`). + +- [ ] **Step 1: Implement** + +```svelte + + + +
+ {#key animationKey} +
+ {@render children({ effectiveSpread, animationKey })} +
+ {/key} + + {#if settings.flow === 'paginated' && settings.inputs.tapZones} + + {/if} +
+ + +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm check +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/components/books/PaginatedViewport.svelte +git commit -m "feat(reader): PaginatedViewport wrapper component (#61) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: ReaderSettingsPanel.svelte — shared settings UI + +**Files:** +- Create: `src/lib/components/books/ReaderSettingsPanel.svelte` + +A single component rendering all reader settings controls. Used inside both readers' existing right-side drawers. Uses `bindable` so the parent owns the `settings` object and wires `persistReaderSettings()` in an `$effect` once. + +- [ ] **Step 1: Implement** + +```svelte + + + +
+
+

Page flow

+
+ {#each flowOptions as opt} + + {/each} +
+
+ +
+

Spread

+
+ {#each spreadOptions as opt} + + {/each} +
+

Auto picks dual on tablets/desktop, single on phones.

+
+ +
+

Page animation

+
+ {#each animOptions as opt} + + {/each} +
+
+ +
+

Inputs

+ + + +
+ +
+

Direction

+
+ {#each dirOptions as opt} + + {/each} +
+
+ + {#if variant === 'epub'} +
+

Font size

+ +

{settings.fontSize}px

+
+ {/if} +
+ + +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm check +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/components/books/ReaderSettingsPanel.svelte +git commit -m "feat(reader): ReaderSettingsPanel shared settings UI (#61) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Wire BookReader to shared settings + viewport + +**Files:** +- Modify: `src/lib/components/books/BookReader.svelte` + +The current EPUB reader has `flow`, `theme`, `fontFamily`, etc. as discrete `$state` variables and its own `loadSettings()` / `persistSettings()` using the same `nexus-reader-settings` key. Replace those with `loadReaderSettings()` / `persistReaderSettings()` calls and a single `settings` object. Render `` inside the existing settings drawer (replacing the bespoke flow toggle at lines ~995-1010 and any duplicated controls). Pass `settings`, `onPrev=() => view.prev()`, `onNext=() => view.next()`, `onToggleUI=...` to `` wrapping the foliate-js view container. + +The foliate-js renderer's `setAttribute('flow', ...)` continues to drive the underlying paginated layout — call it inside an `$effect` whenever `settings.flow` changes (replaces lines 687-691). + +- [ ] **Step 1: Read current state** + +```bash +grep -n "loadSettings\|persistSettings\|let flow\|let readerTheme\|let fontFamily\|setAttribute('flow'" src/lib/components/books/BookReader.svelte +``` + +Note the line numbers; you'll be replacing the existing settings state declarations and the existing settings drawer markup. + +- [ ] **Step 2: Add imports + replace settings state with single object** + +Replace the discrete `let theme = $state(...)` etc. block with: + +```ts +import { loadReaderSettings, persistReaderSettings, type ReaderSettings } from './reader-settings'; +import PaginatedViewport from './PaginatedViewport.svelte'; +import ReaderSettingsPanel from './ReaderSettingsPanel.svelte'; + +let settings = $state(loadReaderSettings()); +$effect(() => { persistReaderSettings(settings); }); + +// Convenience aliases for existing template bindings — keep until the template is updated. +const readerTheme = $derived(settings.theme); +const fontFamily = $derived(settings.fontFamily); +const fontSize = $derived(settings.fontSize); +const lineHeight = $derived(settings.lineHeight); +const margins = $derived(settings.margins); +const textAlign = $derived(settings.textAlign); +const flow = $derived(settings.flow); +``` + +Delete the old `loadSettings()` / `persistSettings()` functions (lines ~148-170) and the discrete `$state` declarations. + +- [ ] **Step 3: Wrap the foliate-js host in PaginatedViewport** + +Locate the `
` that hosts the foliate-js view (typically the one bound to `viewEl` or similar). Wrap it: + +```svelte + view?.prev()} + onNext={() => view?.next()} + onToggleUI={() => { settingsOpen = !settingsOpen; }} +> + {#snippet children()} +
+ {/snippet} +
+``` + +(Field names like `view`, `viewEl`, `settingsOpen` may differ slightly — preserve whatever this file already uses.) + +- [ ] **Step 4: Replace the in-drawer settings markup with the shared panel** + +Inside the existing right-side settings drawer (`
+ + +
+

Spread

+
+ {#each spreadOptions as opt} + + {/each} +
+

Auto picks dual on tablets/desktop, single on phones.

+
+ +
+

Page animation

+
+ {#each animOptions as opt} + + {/each} +
+
+ +
+

Inputs

+ + + +
+ +
+

Direction

+
+ {#each dirOptions as opt} + + {/each} +
+
+ + {#if variant === 'epub'} +
+

Font size

+ +

{settings.fontSize}px

+
+ {/if} + + + diff --git a/src/lib/components/books/__tests__/reader-settings.test.ts b/src/lib/components/books/__tests__/reader-settings.test.ts new file mode 100644 index 00000000..ecdf64d3 --- /dev/null +++ b/src/lib/components/books/__tests__/reader-settings.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + DEFAULT_READER_SETTINGS, + loadReaderSettings, + persistReaderSettings, + resolveSpread, + type ReaderSettings +} from '../reader-settings'; + +describe('reader-settings', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns defaults when nothing is stored', () => { + expect(loadReaderSettings()).toEqual(DEFAULT_READER_SETTINGS); + }); + + it('round-trips persisted settings', () => { + const patch: Partial = { + flow: 'scrolled', + spread: 'dual', + pageAnimation: 'fade', + direction: 'rtl', + inputs: { tapZones: false, swipe: true, keyboard: true } + }; + persistReaderSettings(patch); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe('scrolled'); + expect(loaded.spread).toBe('dual'); + expect(loaded.pageAnimation).toBe('fade'); + expect(loaded.direction).toBe('rtl'); + expect(loaded.inputs.tapZones).toBe(false); + }); + + it('preserves unrelated existing fields when persisting a partial patch', () => { + persistReaderSettings({ fontSize: 22, theme: 'sepia' as ReaderSettings['theme'] }); + persistReaderSettings({ flow: 'scrolled' }); + const loaded = loadReaderSettings(); + expect(loaded.fontSize).toBe(22); + expect(loaded.theme).toBe('sepia'); + expect(loaded.flow).toBe('scrolled'); + }); + + it('falls back to defaults for missing keys in stored JSON', () => { + localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'scrolled' })); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe('scrolled'); + expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); + expect(loaded.inputs).toEqual(DEFAULT_READER_SETTINGS.inputs); + }); + + it('coerces invalid stored values back to defaults', () => { + localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'banana', spread: 42 })); + const loaded = loadReaderSettings(); + expect(loaded.flow).toBe(DEFAULT_READER_SETTINGS.flow); + expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); + }); + + describe('resolveSpread', () => { + it('returns single below 768px when auto', () => { + expect(resolveSpread('auto', 600)).toBe('single'); + }); + it('returns dual at or above 768px when auto', () => { + expect(resolveSpread('auto', 1024)).toBe('dual'); + }); + it('respects explicit single regardless of width', () => { + expect(resolveSpread('single', 1920)).toBe('single'); + }); + it('respects explicit dual regardless of width', () => { + expect(resolveSpread('dual', 320)).toBe('dual'); + }); + }); +}); diff --git a/src/lib/components/books/__tests__/useReaderInputs.test.ts b/src/lib/components/books/__tests__/useReaderInputs.test.ts new file mode 100644 index 00000000..3afd373c --- /dev/null +++ b/src/lib/components/books/__tests__/useReaderInputs.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { hitZone, isHorizontalSwipe, mapKeyToAction, flipForRtl } from '../useReaderInputs.svelte'; + +describe('useReaderInputs helpers', () => { + describe('hitZone', () => { + it('maps left third to prev', () => { + expect(hitZone(50, 1000)).toBe('prev'); + }); + it('maps middle third to toggleUI', () => { + expect(hitZone(500, 1000)).toBe('toggleUI'); + }); + it('maps right third to next', () => { + expect(hitZone(900, 1000)).toBe('next'); + }); + it('handles small viewports', () => { + expect(hitZone(100, 360)).toBe('prev'); + expect(hitZone(180, 360)).toBe('toggleUI'); + expect(hitZone(300, 360)).toBe('next'); + }); + }); + + describe('isHorizontalSwipe', () => { + it('returns prev for rightward swipe past threshold', () => { + expect(isHorizontalSwipe({ dx: 80, dy: 10 })).toBe('prev'); + }); + it('returns next for leftward swipe past threshold', () => { + expect(isHorizontalSwipe({ dx: -80, dy: 10 })).toBe('next'); + }); + it('returns null when below threshold', () => { + expect(isHorizontalSwipe({ dx: 30, dy: 5 })).toBeNull(); + }); + it('returns null when vertical travel dominates', () => { + expect(isHorizontalSwipe({ dx: 60, dy: 200 })).toBeNull(); + }); + }); + + describe('mapKeyToAction', () => { + it('maps ArrowLeft to prev', () => { + expect(mapKeyToAction('ArrowLeft')).toBe('prev'); + }); + it('maps ArrowRight to next', () => { + expect(mapKeyToAction('ArrowRight')).toBe('next'); + }); + it('maps PageUp to prev, PageDown and Space to next', () => { + expect(mapKeyToAction('PageUp')).toBe('prev'); + expect(mapKeyToAction('PageDown')).toBe('next'); + expect(mapKeyToAction(' ')).toBe('next'); + }); + it('returns null for irrelevant keys', () => { + expect(mapKeyToAction('a')).toBeNull(); + expect(mapKeyToAction('Enter')).toBeNull(); + }); + }); + + describe('flipForRtl', () => { + it('swaps prev and next when direction is rtl', () => { + expect(flipForRtl('prev', 'rtl')).toBe('next'); + expect(flipForRtl('next', 'rtl')).toBe('prev'); + expect(flipForRtl('toggleUI', 'rtl')).toBe('toggleUI'); + }); + it('passes through when direction is ltr', () => { + expect(flipForRtl('prev', 'ltr')).toBe('prev'); + expect(flipForRtl('next', 'ltr')).toBe('next'); + }); + }); +}); diff --git a/src/lib/components/books/reader-settings.ts b/src/lib/components/books/reader-settings.ts new file mode 100644 index 00000000..0e3301ad --- /dev/null +++ b/src/lib/components/books/reader-settings.ts @@ -0,0 +1,115 @@ +export type ReaderThemeName = 'light' | 'dark' | 'sepia' | 'oled'; +export type FontFamilyName = 'serif' | 'sans' | 'mono' | 'display'; +export type MarginName = 'narrow' | 'medium' | 'wide'; + +export interface ReaderSettings { + theme: ReaderThemeName; + fontFamily: FontFamilyName; + fontSize: number; + lineHeight: number; + margins: MarginName; + textAlign: 'start' | 'justify'; + flow: 'paginated' | 'scrolled'; + spread: 'auto' | 'single' | 'dual'; + pageAnimation: 'slide' | 'fade' | 'none'; + inputs: { + tapZones: boolean; + swipe: boolean; + keyboard: boolean; + }; + direction: 'ltr' | 'rtl'; +} + +export const DEFAULT_READER_SETTINGS: ReaderSettings = { + theme: 'dark', + fontFamily: 'serif', + fontSize: 18, + lineHeight: 1.5, + margins: 'medium', + textAlign: 'start', + flow: 'paginated', + spread: 'auto', + pageAnimation: 'slide', + inputs: { tapZones: true, swipe: true, keyboard: true }, + direction: 'ltr' +}; + +const STORAGE_KEY = 'nexus-reader-settings'; + +const FLOW_VALUES = new Set(['paginated', 'scrolled'] as const); +const SPREAD_VALUES = new Set(['auto', 'single', 'dual'] as const); +const ANIM_VALUES = new Set(['slide', 'fade', 'none'] as const); +const DIR_VALUES = new Set(['ltr', 'rtl'] as const); +const ALIGN_VALUES = new Set(['start', 'justify'] as const); +const THEME_VALUES = new Set(['light', 'dark', 'sepia', 'oled'] as const); +const FONT_VALUES = new Set(['serif', 'sans', 'mono', 'display'] as const); +const MARGIN_VALUES = new Set(['narrow', 'medium', 'wide'] as const); + +function pick(value: unknown, allowed: Set, fallback: T): T { + return typeof value === 'string' && allowed.has(value as T) ? (value as T) : fallback; +} + +function pickNumber(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + if (value < min || value > max) return fallback; + return value; +} + +function pickBool(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function coerce(raw: unknown): ReaderSettings { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record; + const inputsRaw = (r.inputs && typeof r.inputs === 'object' ? r.inputs : {}) as Record; + return { + theme: pick(r.theme, THEME_VALUES, DEFAULT_READER_SETTINGS.theme), + fontFamily: pick(r.fontFamily, FONT_VALUES, DEFAULT_READER_SETTINGS.fontFamily), + fontSize: pickNumber(r.fontSize, DEFAULT_READER_SETTINGS.fontSize, 10, 36), + lineHeight: pickNumber(r.lineHeight, DEFAULT_READER_SETTINGS.lineHeight, 1.0, 2.5), + margins: pick(r.margins, MARGIN_VALUES, DEFAULT_READER_SETTINGS.margins), + textAlign: pick(r.textAlign, ALIGN_VALUES, DEFAULT_READER_SETTINGS.textAlign), + flow: pick(r.flow, FLOW_VALUES, DEFAULT_READER_SETTINGS.flow), + spread: pick(r.spread, SPREAD_VALUES, DEFAULT_READER_SETTINGS.spread), + pageAnimation: pick(r.pageAnimation, ANIM_VALUES, DEFAULT_READER_SETTINGS.pageAnimation), + inputs: { + tapZones: pickBool(inputsRaw.tapZones, DEFAULT_READER_SETTINGS.inputs.tapZones), + swipe: pickBool(inputsRaw.swipe, DEFAULT_READER_SETTINGS.inputs.swipe), + keyboard: pickBool(inputsRaw.keyboard, DEFAULT_READER_SETTINGS.inputs.keyboard) + }, + direction: pick(r.direction, DIR_VALUES, DEFAULT_READER_SETTINGS.direction) + }; +} + +export function loadReaderSettings(): ReaderSettings { + if (typeof localStorage === 'undefined') return { ...DEFAULT_READER_SETTINGS }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_READER_SETTINGS }; + return coerce(JSON.parse(raw)); + } catch { + return { ...DEFAULT_READER_SETTINGS }; + } +} + +export function persistReaderSettings(patch: Partial): void { + if (typeof localStorage === 'undefined') return; + const current = loadReaderSettings(); + const next: ReaderSettings = { + ...current, + ...patch, + inputs: { ...current.inputs, ...(patch.inputs ?? {}) } + }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* quota or privacy mode — ignore */ + } +} + +const SPREAD_BREAKPOINT_PX = 768; + +export function resolveSpread(spread: ReaderSettings['spread'], viewportWidth: number): 'single' | 'dual' { + if (spread === 'single' || spread === 'dual') return spread; + return viewportWidth >= SPREAD_BREAKPOINT_PX ? 'dual' : 'single'; +} diff --git a/src/lib/components/books/useReaderInputs.svelte.ts b/src/lib/components/books/useReaderInputs.svelte.ts new file mode 100644 index 00000000..252dcfea --- /dev/null +++ b/src/lib/components/books/useReaderInputs.svelte.ts @@ -0,0 +1,98 @@ +import type { ReaderSettings } from './reader-settings'; + +export type ReaderAction = 'prev' | 'next' | 'toggleUI'; + +const SWIPE_MIN_PX = 50; +const SWIPE_VERTICAL_RATIO = 2; // |dx| must be > 2*|dy| + +export function hitZone(x: number, width: number): ReaderAction { + const third = width / 3; + if (x < third) return 'prev'; + if (x < 2 * third) return 'toggleUI'; + return 'next'; +} + +export function isHorizontalSwipe({ dx, dy }: { dx: number; dy: number }): ReaderAction | null { + if (Math.abs(dx) < SWIPE_MIN_PX) return null; + if (Math.abs(dx) <= Math.abs(dy) * SWIPE_VERTICAL_RATIO) return null; + return dx > 0 ? 'prev' : 'next'; +} + +export function mapKeyToAction(key: string): ReaderAction | null { + switch (key) { + case 'ArrowLeft': + case 'PageUp': + return 'prev'; + case 'ArrowRight': + case 'PageDown': + case ' ': + return 'next'; + default: + return null; + } +} + +export function flipForRtl
(action: A, direction: ReaderSettings['direction']): A { + if (direction !== 'rtl') return action; + if (action === 'prev') return 'next' as A; + if (action === 'next') return 'prev' as A; + return action; +} + +interface UseInputsArgs { + getSettings: () => Pick; + onPrev: () => void; + onNext: () => void; + onToggleUI: () => void; +} + +export function useReaderInputs(args: UseInputsArgs) { + const dispatch = (action: ReaderAction) => { + const { direction } = args.getSettings(); + const final = flipForRtl(action, direction); + if (final === 'prev') args.onPrev(); + else if (final === 'next') args.onNext(); + else if (final === 'toggleUI') args.onToggleUI(); + }; + + const tapHandlers = { + onpointerup(e: PointerEvent) { + if (!args.getSettings().inputs.tapZones) return; + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + dispatch(hitZone(e.clientX - rect.left, rect.width)); + } + }; + + let touchStart: { x: number; y: number } | null = null; + const swipeHandlers = { + ontouchstart(e: TouchEvent) { + if (!args.getSettings().inputs.swipe) return; + const t = e.changedTouches[0]; + touchStart = { x: t.clientX, y: t.clientY }; + }, + ontouchend(e: TouchEvent) { + if (!args.getSettings().inputs.swipe || !touchStart) return; + const t = e.changedTouches[0]; + const dx = t.clientX - touchStart.x; + const dy = t.clientY - touchStart.y; + touchStart = null; + const action = isHorizontalSwipe({ dx, dy }); + if (action) dispatch(action); + } + }; + + function attachKeyboard(target: Window | HTMLElement = window): () => void { + const onKey = (e: KeyboardEvent) => { + if (!args.getSettings().inputs.keyboard) return; + const action = mapKeyToAction(e.key); + if (!action) return; + e.preventDefault(); + dispatch(action); + }; + target.addEventListener('keydown', onKey as EventListener); + return () => target.removeEventListener('keydown', onKey as EventListener); + } + + return { tapHandlers, swipeHandlers, attachKeyboard }; +} diff --git a/src/routes/books/read/[id]/+page.server.ts b/src/routes/books/read/[id]/+page.server.ts index e05e5ffa..7d8a0d9e 100644 --- a/src/routes/books/read/[id]/+page.server.ts +++ b/src/routes/books/read/[id]/+page.server.ts @@ -57,10 +57,16 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { // Resume position (EPUB CFI or PDF page) lives in play_sessions.position. const savedPosition: string | undefined = sessionRow?.position ?? undefined; - // Determine format to read — default to EPUB, allow ?format=pdf etc + // Determine format to read — default to EPUB, allow ?format=pdf etc. + // Calibre adapter returns metadata.formats as CalibreFormat[] ({name, downloadUrl}), + // not string[]. Other adapters could legitimately produce strings, so accept both. const requestedFormat = (url.searchParams.get('format') ?? 'epub').toLowerCase(); - const availableFormats = (item.metadata?.formats as string[]) ?? []; - const format = availableFormats.map(f => f.toLowerCase()).includes(requestedFormat) ? requestedFormat : 'epub'; + const rawFormats = (item.metadata?.formats as Array | undefined) ?? []; + const availableFormats = rawFormats + .map(f => (typeof f === 'string' ? f : f?.name ?? '')) + .filter(Boolean) + .map(s => s.toLowerCase()); + const format = availableFormats.includes(requestedFormat) ? requestedFormat : 'epub'; const bookUrl = format === 'epub' ? `/api/books/${params.id}/read` : `/api/books/${params.id}/download/${format}?view=true`; @@ -70,7 +76,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { serviceId: calibreConfig.id, bookUrl, format, - availableFormats: availableFormats.map(f => f.toLowerCase()), + availableFormats, savedPosition, progress: sessionRow?.progress ?? 0, bookmarks, diff --git a/src/test-setup/localstorage-shim.ts b/src/test-setup/localstorage-shim.ts new file mode 100644 index 00000000..c477939e --- /dev/null +++ b/src/test-setup/localstorage-shim.ts @@ -0,0 +1,45 @@ +// Node 25 ships a native `localStorage` that requires `--localstorage-file=` +// to function. Inside vitest's jsdom environment, that native stub shadows jsdom's +// own Storage — leaving `setItem`/`clear`/etc. undefined. This setup file installs +// a simple Map-backed Storage polyfill so component tests get the DOM behavior +// they expect without CLI flags. Load order: applied once before each test file +// that opts into the jsdom environment. + +const backing = new Map(); + +const storage: Storage = { + get length() { + return backing.size; + }, + clear() { + backing.clear(); + }, + getItem(key: string) { + return backing.has(key) ? backing.get(key)! : null; + }, + key(index: number) { + return Array.from(backing.keys())[index] ?? null; + }, + removeItem(key: string) { + backing.delete(key); + }, + setItem(key: string, value: string) { + backing.set(key, String(value)); + } +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: storage +}); + +// Keep window.localStorage and globalThis.localStorage pointing at the same object +// so code that uses either sees the same data. +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: storage + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 45b9c508..76d64560 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { include: ['src/**/__tests__/**/*.test.ts'], environment: 'node', + setupFiles: ['src/test-setup/localstorage-shim.ts'], globals: false, alias: { '$lib': resolve('./src/lib'),