Files
FlowReader/web/src/stores/prefsStore.ts
T
Antigravity AgentandClaude Opus 5.5 360f3cf012 feat(web): unified reader with reading settings, faster lists, a11y
- New Reader (desktop + mobile): loads full content on open, prefetches
  the next article, reading progress and time, "Aa" settings (light /
  sepia / dark / auto theme, serif / sans / Atkinson Hyperlegible font,
  size, line height, measure), Space-to-next, keyboard shortcuts, focus
  management, inert background, URL-driven (?a=) so Back closes it.
- Dashboard: cursor pagination, unread/all filter, CSS grid (no masonry),
  no re-sorting under the reader, optimistic cache patches instead of
  refetch storms, "new articles" banner, error/offline states, j/k/o/m/s/v
  shortcuts and help dialog.
- Cards: keyboard-openable, lazy <img>, memoized, touch-only swipe.
- Focus mode: framer drag (drop @use-gesture), Read button, undo restores
  unread state, arrow keys.
- WebSocket: single connection, backoff, cache patching, no console logs.
- Contrast fixes (on-nature tokens), 44px targets, lang="fr", reduced
  motion, self-hosted fonts, theme applied before first paint.
- Code splitting + vendor chunk, prompt-based PWA updates, smaller
  precache, offline cache for opened articles and images, caches cleared
  on logout; upgrade react-router (CVE), drop unused deps and assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:34:09 +02:00

91 lines
3.1 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type ThemePref = 'system' | 'light' | 'sepia' | 'dark';
export type ReaderFont = 'serif' | 'sans' | 'legible';
export type ReaderWidth = 'narrow' | 'medium' | 'wide';
interface PrefsState {
theme: ThemePref;
/** Article body font size in px. */
fontSize: number;
lineHeight: number;
width: ReaderWidth;
font: ReaderFont;
/** Dashboard filter: show only unread articles. */
unreadOnly: boolean;
setTheme: (t: ThemePref) => void;
setFontSize: (n: number) => void;
setLineHeight: (n: number) => void;
setWidth: (w: ReaderWidth) => void;
setFont: (f: ReaderFont) => void;
setUnreadOnly: (v: boolean) => void;
resetReader: () => void;
}
// Research-backed defaults: ~65ch measure, 19px body, 1.65 line-height.
const READER_DEFAULTS = { fontSize: 19, lineHeight: 1.65, width: 'medium' as ReaderWidth, font: 'serif' as ReaderFont };
export const FONT_SIZE_MIN = 15;
export const FONT_SIZE_MAX = 26;
const THEME_COLORS: Record<Exclude<ThemePref, 'system'>, string> = {
light: '#F7F4EC',
sepia: '#F4ECD8',
dark: '#121612',
};
function resolveTheme(pref: ThemePref): Exclude<ThemePref, 'system'> {
if (pref !== 'system') return pref;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/** Applies the theme classes on <html>; mirrored by public/theme-init.js to avoid a flash on load. */
export function applyTheme(pref: ThemePref) {
const theme = resolveTheme(pref);
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('sepia', theme === 'sepia');
root.style.colorScheme = theme === 'dark' ? 'dark' : 'light';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLORS[theme]);
try {
if (pref === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', pref);
} catch {
/* storage unavailable (private mode) */
}
}
export const usePrefs = create<PrefsState>()(
persist(
(set) => ({
theme: 'system',
...READER_DEFAULTS,
unreadOnly: true,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
setFontSize: (fontSize) => set({ fontSize: Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, fontSize)) }),
setLineHeight: (lineHeight) => set({ lineHeight }),
setWidth: (width) => set({ width }),
setFont: (font) => set({ font }),
setUnreadOnly: (unreadOnly) => set({ unreadOnly }),
resetReader: () => set(READER_DEFAULTS),
}),
{
name: 'flowreader-prefs',
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.theme);
},
},
),
);
// Follow OS changes while in "system" mode.
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (usePrefs.getState().theme === 'system') applyTheme('system');
});
}