feat: continuous reader mode with persisted preference

Add a vertical-scroll continuous mode to the reader alongside the
existing single-page mode. A segmented toggle in the reader top bar
switches between them; in continuous mode a gap selector
(None/Small/Medium/Large → 0/12/32/64px) controls the spacing
between stacked pages. Settings page mirrors the same controls.

Backend: new user_preferences table (one row per user, lazily
inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences
gated by the existing CurrentUser extractor. Allowed values are
enforced both by API validation and table-level CHECK constraints.
Eight integration tests cover defaults, persistence, partial
updates, validation errors, auth, per-user isolation, and cascade.

Frontend: a new preferences store mirrors the theme-store pattern
with a localStorage shadow so anonymous browsers get a consistent
experience and logged-in users don't flash defaults while the
server response is in flight. Server values that the frontend
doesn't recognize (forward-compat) are ignored rather than poisoning
the UI; non-401 PATCH errors revert the optimistic local update;
logout clears the shadow so user A's settings don't follow user B
on a shared browser.

In continuous mode native scrolling handles Space/PageDown/arrows;
Home/End remain wired and call scrollIntoView() so jumping to chapter
bounds stays one keystroke. Single-page mode (chevrons, arrow-key
pagination, next-page preload) is unchanged.

Versions bumped 0.13.0 → 0.14.0 in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 13:15:03 +02:00
parent 567d56bfa1
commit 60cc7712fa
18 changed files with 1287 additions and 6 deletions

View File

@@ -0,0 +1,98 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance
} from 'vitest';
import { getPreferences, updatePreferences, GAP_PX } from './preferences';
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
const prefsFixture = {
reader_mode: 'continuous' as const,
reader_page_gap: 'medium' as const,
updated_at: '2026-05-17T12:00:00Z'
};
describe('preferences api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('GAP_PX exposes the four named options with increasing values', () => {
expect(GAP_PX.none).toBe(0);
expect(GAP_PX.small).toBeGreaterThan(GAP_PX.none);
expect(GAP_PX.medium).toBeGreaterThan(GAP_PX.small);
expect(GAP_PX.large).toBeGreaterThan(GAP_PX.medium);
});
it('getPreferences GETs /v1/auth/me/preferences and returns the row', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
await expect(getPreferences()).resolves.toEqual(prefsFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
});
it('getPreferences returns null on 401 (anonymous browsing)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
await expect(getPreferences()).resolves.toBeNull();
});
it('getPreferences re-throws non-401 errors', async () => {
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
await expect(getPreferences()).rejects.toMatchObject({ status: 500 });
});
it('updatePreferences PATCHes JSON and returns the new row', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
const r = await updatePreferences({
reader_mode: 'continuous',
reader_page_gap: 'medium'
});
expect(r).toEqual(prefsFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({
reader_mode: 'continuous',
reader_page_gap: 'medium'
});
});
it('updatePreferences sends only the keys it was given (partial)', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
await updatePreferences({ reader_mode: 'single' });
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'single' });
});
it('updatePreferences surfaces 400 invalid_input via ApiError', async () => {
fetchSpy.mockResolvedValueOnce(
envelope(400, 'invalid_input', 'reader_mode must be one of ...')
);
await expect(
updatePreferences({ reader_mode: 'continuous' })
).rejects.toMatchObject({ status: 400, code: 'invalid_input' });
});
});

View File

@@ -0,0 +1,50 @@
import { ApiError, request } from './client';
export type ReaderMode = 'single' | 'continuous';
export type ReaderPageGap = 'none' | 'small' | 'medium' | 'large';
export type Preferences = {
reader_mode: ReaderMode;
reader_page_gap: ReaderPageGap;
updated_at: string;
};
/**
* Maps a named gap option to a CSS pixel value. Centralised here so the
* settings UI, the reader, and any future surfaces all show the same
* spacing.
*/
export const GAP_PX: Record<ReaderPageGap, number> = {
none: 0,
small: 12,
medium: 32,
large: 64
};
/**
* Returns the current user's stored reader preferences, or `null` if there
* is no valid session. Anything other than 401 is re-thrown so the caller
* can surface real errors.
*/
export async function getPreferences(): Promise<Preferences | null> {
try {
return await request<Preferences>('/v1/auth/me/preferences');
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
}
/**
* Partially updates the user's reader preferences. Unspecified fields are
* left unchanged on the server.
*/
export async function updatePreferences(
patch: Partial<Pick<Preferences, 'reader_mode' | 'reader_page_gap'>>
): Promise<Preferences> {
return request<Preferences>('/v1/auth/me/preferences', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
}

View File

@@ -0,0 +1,151 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance
} from 'vitest';
import { preferences } from './preferences.svelte';
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
const flush = () => new Promise((r) => setTimeout(r, 0));
describe('preferences store', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(async () => {
localStorage.clear();
// Default fetch implementation returns 401 so the reset calls below
// don't escape as real network requests (jsdom can't resolve
// /api/...). Each test overrides with mockResolvedValueOnce.
fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(envelope(401, 'unauthenticated', 'reset'));
// Reset the singleton's state for the next test.
preferences.setMode('single');
preferences.setGap('none');
await flush();
fetchSpy.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('init hydrates from localStorage immediately and pulls server values when authenticated', async () => {
localStorage.setItem('mangalord-reader-mode', 'continuous');
localStorage.setItem('mangalord-reader-gap', 'small');
fetchSpy.mockResolvedValueOnce(
ok({
reader_mode: 'single',
reader_page_gap: 'large',
updated_at: '2026-05-17T12:00:00Z'
})
);
const p = preferences.init();
// Synchronous hydration from localStorage happens before the await.
expect(preferences.readerMode).toBe('continuous');
expect(preferences.readerPageGap).toBe('small');
await p;
// Server response overrides the localStorage values.
expect(preferences.readerMode).toBe('single');
expect(preferences.readerPageGap).toBe('large');
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
});
it('init keeps localStorage values when the user is anonymous (401)', async () => {
localStorage.setItem('mangalord-reader-mode', 'continuous');
localStorage.setItem('mangalord-reader-gap', 'medium');
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
await preferences.init();
expect(preferences.readerMode).toBe('continuous');
expect(preferences.readerPageGap).toBe('medium');
});
it('setMode updates state, writes localStorage, and PATCHes the server', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
reader_mode: 'continuous',
reader_page_gap: 'none',
updated_at: '2026-05-17T12:00:00Z'
})
);
preferences.setMode('continuous');
expect(preferences.readerMode).toBe('continuous');
expect(localStorage.getItem('mangalord-reader-mode')).toBe('continuous');
await flush();
expect(fetchSpy).toHaveBeenCalledOnce();
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'continuous' });
});
it('setGap survives a 401 (guest) without throwing', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
preferences.setGap('large');
expect(preferences.readerPageGap).toBe('large');
await flush();
// localStorage still holds the choice — the guest preference persists.
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
});
it('setMode reverts the optimistic update when the server returns 5xx', async () => {
// Suppress the expected console.error so the test output stays clean.
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
preferences.setMode('continuous');
expect(preferences.readerMode).toBe('continuous');
await flush();
expect(preferences.readerMode).toBe('single');
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
errSpy.mockRestore();
});
it('clearForLogout resets state and removes localStorage entries', () => {
localStorage.setItem('mangalord-reader-mode', 'continuous');
localStorage.setItem('mangalord-reader-gap', 'large');
preferences.clearForLogout();
expect(preferences.readerMode).toBe('single');
expect(preferences.readerPageGap).toBe('none');
expect(localStorage.getItem('mangalord-reader-mode')).toBeNull();
expect(localStorage.getItem('mangalord-reader-gap')).toBeNull();
});
it('init ignores an unknown reader_page_gap from the server (forward compat)', async () => {
localStorage.setItem('mangalord-reader-mode', 'continuous');
localStorage.setItem('mangalord-reader-gap', 'medium');
fetchSpy.mockResolvedValueOnce(
ok({
reader_mode: 'continuous',
reader_page_gap: 'huge',
updated_at: '2026-05-17T12:00:00Z'
})
);
await preferences.init();
// Mode comes through (known value); unknown gap is rejected, the
// pre-existing 'medium' value is retained.
expect(preferences.readerMode).toBe('continuous');
expect(preferences.readerPageGap).toBe('medium');
});
});

View File

@@ -0,0 +1,136 @@
// Reader preferences. Stored server-side per user, with a localStorage
// shadow so anonymous browsers still get a consistent experience across
// page loads and so logged-in users don't flash defaults while the
// server response is in flight.
//
// Mutated client-side only — same pattern as session.svelte.ts. SSR
// always sees the post-construct defaults; `init()` is called from the
// root layout's onMount and reads localStorage / pulls the server row.
import {
getPreferences,
updatePreferences,
type Preferences,
type ReaderMode,
type ReaderPageGap
} from './api/preferences';
const MODE_KEY = 'mangalord-reader-mode';
const GAP_KEY = 'mangalord-reader-gap';
const MODES: ReaderMode[] = ['single', 'continuous'];
const GAPS: ReaderPageGap[] = ['none', 'small', 'medium', 'large'];
function readStoredMode(): ReaderMode {
if (typeof localStorage === 'undefined') return 'single';
const v = localStorage.getItem(MODE_KEY);
return (MODES as string[]).includes(v ?? '') ? (v as ReaderMode) : 'single';
}
function readStoredGap(): ReaderPageGap {
if (typeof localStorage === 'undefined') return 'none';
const v = localStorage.getItem(GAP_KEY);
return (GAPS as string[]).includes(v ?? '') ? (v as ReaderPageGap) : 'none';
}
class PreferencesStore {
readerMode: ReaderMode = $state('single');
readerPageGap: ReaderPageGap = $state('none');
loaded = $state(false);
// Bumped before each server fetch so a slow response that resolves
// after a newer one can't clobber the state.
private seq = 0;
/**
* One-shot init: hydrate from localStorage (instant), then ask the
* server (slow but authoritative). Safe to call multiple times — the
* seq guard makes overlapping calls converge on the latest result.
*/
async init(): Promise<void> {
this.readerMode = readStoredMode();
this.readerPageGap = readStoredGap();
await this.refresh();
}
async refresh(): Promise<void> {
if (typeof window === 'undefined') return;
const seq = ++this.seq;
try {
const server = await getPreferences();
if (seq !== this.seq) return;
if (server) this.apply(server);
} finally {
if (seq === this.seq) this.loaded = true;
}
}
/// Reset to defaults and clear the localStorage shadow. Called on
/// logout so user A's settings don't follow user B (or an anonymous
/// browser) on a shared device.
clearForLogout(): void {
this.seq++;
this.readerMode = 'single';
this.readerPageGap = 'none';
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(MODE_KEY);
localStorage.removeItem(GAP_KEY);
}
}
setMode(mode: ReaderMode): void {
const prev = this.readerMode;
this.readerMode = mode;
if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, mode);
this.pushToServer({ reader_mode: mode }, () => {
this.readerMode = prev;
if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, prev);
});
}
setGap(gap: ReaderPageGap): void {
const prev = this.readerPageGap;
this.readerPageGap = gap;
if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, gap);
this.pushToServer({ reader_page_gap: gap }, () => {
this.readerPageGap = prev;
if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, prev);
});
}
private apply(p: Preferences): void {
// Validate against the allowlist so a future server-side value
// we don't yet understand can't poison the UI (e.g. an unknown
// gap would render `style:gap="undefinedpx"` and break layout).
const mode = (MODES as string[]).includes(p.reader_mode)
? (p.reader_mode as ReaderMode)
: this.readerMode;
const gap = (GAPS as string[]).includes(p.reader_page_gap)
? (p.reader_page_gap as ReaderPageGap)
: this.readerPageGap;
this.readerMode = mode;
this.readerPageGap = gap;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(MODE_KEY, mode);
localStorage.setItem(GAP_KEY, gap);
}
}
private pushToServer(
patch: Partial<Pick<Preferences, 'reader_mode' | 'reader_page_gap'>>,
revert: () => void
): void {
// Optimistic local update already happened. 401 (anonymous user)
// is the expected path for guests — the localStorage write keeps
// their choice. Any other failure (network, 5xx) means the
// server doesn't agree with our local state, so roll the
// optimistic change back.
updatePreferences(patch).catch((e) => {
const status = (e as { status?: number })?.status;
if (status === 401) return;
console.error('updatePreferences failed', e);
revert();
});
}
}
export const preferences = new PreferencesStore();

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { logout } from '$lib/api/auth';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import Upload from '@lucide/svelte/icons/upload';
@@ -15,9 +16,17 @@
onMount(() => {
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
});
// Pull fresh server preferences whenever the user changes (login,
// logout, account switch). The store's seq guard keeps the most recent
// response authoritative.
$effect(() => {
if (session.user) preferences.refresh();
});
onDestroy(() => theme.destroy());
async function handleLogout() {
@@ -26,6 +35,10 @@
await logout();
} finally {
session.setUser(null);
// Don't let user A's reader preferences linger for the next
// person who uses this browser (or as guest state for the
// same user). Resets state + localStorage.
preferences.clearForLogout();
loggingOut = false;
goto('/login');
}

View File

@@ -1,16 +1,23 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { fileUrl } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import BookImage from '@lucide/svelte/icons/book-image';
import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text';
let { data } = $props();
const manga = $derived(data.manga);
const chapter = $derived(data.chapter);
const pages = $derived(data.pages);
const mode = $derived(preferences.readerMode);
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
const pageTitle = $derived(
chapter.title
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
@@ -18,6 +25,7 @@
);
let index = $state(0);
let continuousPageEls: HTMLImageElement[] = $state([]);
function next() {
if (index < pages.length - 1) index += 1;
@@ -36,6 +44,21 @@
// Don't hijack keys while the user is typing in an input.
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
// In continuous mode, native scrolling handles Space/PageDown/arrows;
// we still wire Home/End to scrollIntoView so jumping to the chapter
// bounds stays a one-keypress action.
if (mode === 'continuous') {
if (e.key === 'Home') {
e.preventDefault();
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
} else if (e.key === 'End') {
e.preventDefault();
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({
block: 'end'
});
}
return;
}
// Space is deliberately *not* bound — on viewports where a page
// image overflows (portrait phones, narrow desktop windows),
// users expect Space to scroll the page, and stealing it for
@@ -90,14 +113,65 @@
{/if}
<span class="back-text">{manga.title}</span>
</a>
<div class="controls" role="group" aria-label="reader options">
<div class="mode-toggle" role="radiogroup" aria-label="layout">
<button
type="button"
class="seg"
class:active={mode === 'single'}
onclick={() => preferences.setMode('single')}
aria-pressed={mode === 'single'}
title="Single page"
data-testid="reader-mode-single"
>
<FileText size={16} aria-hidden="true" />
<span>Single</span>
</button>
<button
type="button"
class="seg"
class:active={mode === 'continuous'}
onclick={() => preferences.setMode('continuous')}
aria-pressed={mode === 'continuous'}
title="Continuous (scroll)"
data-testid="reader-mode-continuous"
>
<ScrollText size={16} aria-hidden="true" />
<span>Continuous</span>
</button>
</div>
{#if mode === 'continuous'}
<label class="gap-field">
<span class="visually-hidden">Page gap</span>
<select
value={preferences.readerPageGap}
onchange={(e) =>
preferences.setGap((e.currentTarget as HTMLSelectElement).value as ReaderPageGap)}
data-testid="reader-gap"
>
<option value="none">No gap</option>
<option value="small">Small gap</option>
<option value="medium">Medium gap</option>
<option value="large">Large gap</option>
</select>
</label>
{/if}
</div>
<span class="indicator" data-testid="page-indicator">
Page {index + 1} / {pages.length}
{#if mode === 'single'}
Page {index + 1} / {pages.length}
{:else}
{pages.length} {pages.length === 1 ? 'page' : 'pages'}
{/if}
</span>
</nav>
{#if pages.length === 0}
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
{:else}
{:else if mode === 'single'}
<div class="page-wrap">
<button
type="button"
@@ -142,6 +216,19 @@
/>
{/if}
</div>
{:else}
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
{#each pages as p, i (p.id)}
<img
src={fileUrl(p.storage_key)}
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
class="page-image"
loading={i < 2 ? 'eager' : 'lazy'}
data-testid={`reader-page-${i + 1}`}
bind:this={continuousPageEls[i]}
/>
{/each}
</div>
{/if}
<style>
@@ -153,6 +240,7 @@
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-3);
gap: var(--space-3);
flex-wrap: wrap;
}
.back {
@@ -191,6 +279,76 @@
font-weight: var(--weight-medium);
}
.controls {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.seg {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: transparent;
color: var(--text);
border: none;
font-size: var(--font-sm);
cursor: pointer;
transition:
background var(--transition),
color var(--transition);
}
.seg + .seg {
border-left: 1px solid var(--border-strong);
}
.seg:hover:not(.active) {
background: var(--surface-elevated);
}
.seg.active {
background: var(--primary-soft-bg);
color: var(--text);
}
.seg:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.gap-field select {
height: 32px;
padding: 0 var(--space-2);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--font-sm);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.indicator {
color: var(--text-muted);
flex-shrink: 0;
@@ -208,6 +366,12 @@
align-items: center;
}
.continuous {
display: flex;
flex-direction: column;
align-items: center;
}
.page-image {
max-width: 100%;
max-height: 90vh;
@@ -216,6 +380,13 @@
display: block;
}
.continuous .page-image {
/* In continuous mode the user is scrolling — let each page take
its natural height instead of capping at viewport height, so
there are no scroll dead-zones inside a single page. */
max-height: none;
}
.nav {
display: inline-flex;
align-items: center;
@@ -255,5 +426,8 @@
grid-column: 1;
justify-self: center;
}
.seg span {
display: none;
}
}
</style>

View File

@@ -2,11 +2,26 @@
import { goto } from '$app/navigation';
import { changePassword } from '$lib/api/auth';
import { ApiError } from '$lib/api/client';
import type { ReaderMode, ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme, type Theme } from '$lib/theme.svelte';
import Monitor from '@lucide/svelte/icons/monitor';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text';
const READER_MODES: { value: ReaderMode; label: string }[] = [
{ value: 'single', label: 'Single page' },
{ value: 'continuous', label: 'Continuous (scroll)' }
];
const READER_GAPS: { value: ReaderPageGap; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' }
];
function setTheme(next: Theme) {
theme.set(next);
@@ -108,6 +123,55 @@
</fieldset>
</section>
<section class="card" aria-label="Reader">
<h2>Reader</h2>
{#if !session.user}
<p class="hint" data-testid="reader-prefs-guest-hint">
Sign in to sync these settings across devices. Until then they live in this browser.
</p>
{/if}
<fieldset class="theme-picker">
<legend>Layout</legend>
{#each READER_MODES as opt (opt.value)}
<label class="theme-option" class:selected={preferences.readerMode === opt.value}>
<input
type="radio"
name="reader-mode"
value={opt.value}
checked={preferences.readerMode === opt.value}
onchange={() => preferences.setMode(opt.value)}
data-testid={`reader-mode-radio-${opt.value}`}
/>
{#if opt.value === 'single'}
<FileText size={18} aria-hidden="true" />
{:else}
<ScrollText size={18} aria-hidden="true" />
{/if}
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{#if preferences.readerMode === 'continuous'}
<fieldset class="theme-picker gap-picker">
<legend>Page gap</legend>
{#each READER_GAPS as opt (opt.value)}
<label class="theme-option" class:selected={preferences.readerPageGap === opt.value}>
<input
type="radio"
name="reader-gap"
value={opt.value}
checked={preferences.readerPageGap === opt.value}
onchange={() => preferences.setGap(opt.value)}
data-testid={`reader-gap-radio-${opt.value}`}
/>
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{/if}
</section>
{#if !session.loaded}
<p class="status" data-testid="settings-loading">Loading…</p>
{:else if !session.user}
@@ -238,6 +302,10 @@
margin: 0;
}
.gap-picker {
margin-top: var(--space-3);
}
.theme-picker legend {
font-size: var(--font-sm);
color: var(--text);