feat: add reader settings state and stylesheet generator

Settings are validated on read, not only on write: several go straight onto
foliate's renderer as custom-element attributes, where a bad value wedges the
layout silently rather than throwing. Unknown keys are stripped and missing
ones filled from the defaults, so settings added later do not invalidate what
a reader already has stored.

The stylesheet is built as foliate's [before, after] pair. The paginator
prepends the first to the section's <head> and appends the second, so the
book's own CSS overrides the first and loses to the second. Typography goes
in the first — a book that styles its own headings still gets to, and font
size lands on <html> without !important so rem/em headings scale rather than
being flattened. Colour goes in the second, because most EPUBs set a body
background and would otherwise leave the page white against a dark UI.

Font choices are reused from the app theme's FONT_STACKS rather than
duplicated, so the reader cannot drift from the rest of the UI.

Rename the ambient declaration to foliate-js.d.ts: as foliate.d.ts beside
foliate.ts, TypeScript takes it for that file's emitted declaration, drops it
from the program, and every $foliate import fails with TS2307.
This commit is contained in:
2026-08-11 22:08:58 -04:00
parent c946b6d71c
commit 783f4d226d
5 changed files with 301 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { FONT_STACKS } from '$lib/theme/presets';
import { readerSettingsSchema, type ReaderSettings } from '$lib/schema/reader';
export const READER_SETTINGS_STORAGE_KEY = 'chitai:reader-settings';
/** Georgia — the app's own title face, and a reasonable default for long prose. */
export const DEFAULT_READER_SETTINGS: ReaderSettings = {
fontFamily: FONT_STACKS[1].value,
fontSize: 18,
fontWeight: 400,
lineHeight: 1.6,
letterSpacing: 0,
margin: 48,
gap: 6,
maxInlineSize: 720,
maxColumnCount: 2,
flow: 'paginated',
justify: true,
hyphenate: true
};
/** Reused from the app theme: system stacks only, so nothing silently falls back. */
export const READER_FONT_OPTIONS = FONT_STACKS;
/** Slider bounds, kept next to the schema they mirror. */
export const READER_BOUNDS = {
fontSize: { min: 12, max: 32, step: 1 },
fontWeight: { min: 300, max: 700, step: 100 },
lineHeight: { min: 1, max: 2.5, step: 0.05 },
letterSpacing: { min: -0.05, max: 0.2, step: 0.01 },
margin: { min: 0, max: 120, step: 4 },
gap: { min: 0, max: 15, step: 1 },
maxInlineSize: { min: 480, max: 1400, step: 20 }
} as const;
/**
* Reads stored settings, falling back to defaults on anything unusable.
*
* Unknown keys are stripped and missing ones filled, so settings added in a
* later release do not invalidate what a reader already has stored.
*/
export function loadReaderSettings(): ReaderSettings {
try {
const raw = localStorage.getItem(READER_SETTINGS_STORAGE_KEY);
if (!raw) return DEFAULT_READER_SETTINGS;
const parsed = readerSettingsSchema.safeParse({
...DEFAULT_READER_SETTINGS,
...JSON.parse(raw)
});
return parsed.success ? parsed.data : DEFAULT_READER_SETTINGS;
} catch {
return DEFAULT_READER_SETTINGS;
}
}
export function saveReaderSettings(settings: ReaderSettings) {
try {
localStorage.setItem(READER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
// A full quota must not stop anyone reading.
console.warn('Could not save reader settings', error);
}
}
/**
* The half of the settings that foliate takes as renderer attributes.
*
* The renderer has no JS property API — these must be set with setAttribute.
* Note there is no `margin` shorthand upstream, and no `spread`: on a reflowable
* book a two-page spread is max-column-count 2.
*/
export function toRendererAttributes(s: ReaderSettings): Record<string, string> {
return {
flow: s.flow,
gap: `${s.gap}%`,
'margin-top': `${s.margin}px`,
'margin-bottom': `${s.margin}px`,
'margin-left': `${s.margin}px`,
'margin-right': `${s.margin}px`,
'max-inline-size': `${s.maxInlineSize}px`,
'max-column-count': String(s.maxColumnCount)
};
}