@hutusi/amytis 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
4
+ import ImmersiveReaderTopBar from '@/components/ImmersiveReaderTopBar';
5
+ import {
6
+ useImmersiveReading,
7
+ type ReadingColumnWidth,
8
+ type ReadingFontSize,
9
+ } from '@/components/ImmersiveReadingProvider';
10
+
11
+ const FONT_SIZE_REM: Record<ReadingFontSize, string> = {
12
+ s: '1rem',
13
+ m: '1.125rem',
14
+ l: '1.25rem',
15
+ xl: '1.5rem',
16
+ };
17
+
18
+ const COLUMN_WIDTH_CLASS: Record<ReadingColumnWidth, string> = {
19
+ narrow: 'max-w-2xl',
20
+ medium: 'max-w-3xl',
21
+ wide: 'max-w-4xl',
22
+ full: 'max-w-none',
23
+ };
24
+
25
+ const COLUMN_PADDING_CLASS: Record<ReadingColumnWidth, string> = {
26
+ narrow: 'px-6 sm:px-8',
27
+ medium: 'px-6 sm:px-8',
28
+ wide: 'px-6 sm:px-8',
29
+ full: 'px-6 sm:px-10',
30
+ };
31
+
32
+ interface ImmersiveReaderProps {
33
+ /** Breadcrumb root link — book detail page for books, series index for series. */
34
+ rootHref: string;
35
+ /** Left side of the top-bar breadcrumb (book title or series title). */
36
+ rootTitle: string;
37
+ /** Right side of the breadcrumb (chapter or post title). */
38
+ currentTitle: string;
39
+ /** Pre-rendered sidebar element. Caller passes `<BookSidebar mode="fill" ...>`
40
+ * or `<SeriesList mode="fill" ...>` so the overlay stays content-type-agnostic. */
41
+ sidebar: ReactNode;
42
+ children: ReactNode;
43
+ }
44
+
45
+ export default function ImmersiveReader({
46
+ rootHref,
47
+ rootTitle,
48
+ currentTitle,
49
+ sidebar,
50
+ children,
51
+ }: ImmersiveReaderProps) {
52
+ const { fontSize, readingTheme, columnWidth, sidebarOpen } = useImmersiveReading();
53
+
54
+ const mainRef = useRef<HTMLElement>(null);
55
+ const [progress, setProgress] = useState(0);
56
+
57
+ useEffect(() => {
58
+ const main = mainRef.current;
59
+ if (!main) return;
60
+ let rafId = 0;
61
+ const compute = () => {
62
+ const scrollable = main.scrollHeight - main.clientHeight;
63
+ const pct =
64
+ scrollable > 0
65
+ ? Math.min(100, Math.max(0, (main.scrollTop / scrollable) * 100))
66
+ : 0;
67
+ setProgress(pct);
68
+ };
69
+ const onScroll = () => {
70
+ cancelAnimationFrame(rafId);
71
+ rafId = requestAnimationFrame(compute);
72
+ };
73
+ compute();
74
+ main.addEventListener('scroll', onScroll, { passive: true });
75
+ return () => {
76
+ cancelAnimationFrame(rafId);
77
+ main.removeEventListener('scroll', onScroll);
78
+ };
79
+ }, []);
80
+
81
+ const overlayStyle: CSSProperties = {
82
+ ['--reading-font-size' as keyof CSSProperties]: FONT_SIZE_REM[fontSize],
83
+ } as CSSProperties;
84
+
85
+ return (
86
+ <div
87
+ data-reader-overlay
88
+ data-reading-theme={readingTheme}
89
+ style={overlayStyle}
90
+ // `dark` is added when readingTheme === 'dark' so Tailwind's `dark:`
91
+ // variants (notably `dark:prose-invert` in MarkdownRenderer) activate
92
+ // inside the overlay even when the site itself is in light mode.
93
+ className={`fixed inset-0 z-40 flex flex-col bg-background text-foreground ${
94
+ readingTheme === 'dark' ? 'dark' : ''
95
+ }`}
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-label={rootTitle}
99
+ >
100
+ <ImmersiveReaderTopBar
101
+ rootHref={rootHref}
102
+ rootTitle={rootTitle}
103
+ currentTitle={currentTitle}
104
+ />
105
+
106
+ {progress > 0 && (
107
+ <div className="h-0.5 w-full bg-muted/10 shrink-0">
108
+ <div
109
+ className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
110
+ style={{ width: `${progress}%` }}
111
+ />
112
+ </div>
113
+ )}
114
+
115
+ <div className="flex-1 min-h-0 flex">
116
+ {sidebarOpen && (
117
+ <aside className="w-[280px] shrink-0 border-r border-muted/15 bg-background/60">
118
+ {sidebar}
119
+ </aside>
120
+ )}
121
+
122
+ <main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto">
123
+ <article className={`${COLUMN_WIDTH_CLASS[columnWidth]} mx-auto ${COLUMN_PADDING_CLASS[columnWidth]} py-10`}>
124
+ {children}
125
+ </article>
126
+ </main>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useRef } from 'react';
5
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
6
+ import { useLanguage } from '@/components/LanguageProvider';
7
+ import ImmersiveReadingPrefsPopover from '@/components/ImmersiveReadingPrefsPopover';
8
+
9
+ interface ImmersiveReaderTopBarProps {
10
+ /** Breadcrumb root link — caller computes the URL (book URL or series URL). */
11
+ rootHref: string;
12
+ /** Left side of the breadcrumb. */
13
+ rootTitle: string;
14
+ /** Right side of the breadcrumb. */
15
+ currentTitle: string;
16
+ }
17
+
18
+ export default function ImmersiveReaderTopBar({
19
+ rootHref,
20
+ rootTitle,
21
+ currentTitle,
22
+ }: ImmersiveReaderTopBarProps) {
23
+ const { t } = useLanguage();
24
+ const { sidebarOpen, prefsPanelOpen, toggleSidebar, togglePrefsPanel, exit } = useImmersiveReading();
25
+
26
+ const prefsButtonRef = useRef<HTMLButtonElement>(null);
27
+
28
+ return (
29
+ <header
30
+ // `relative z-30` is load-bearing: `backdrop-blur-md` creates a stacking
31
+ // context on the header, which would otherwise paint at "block-in-flow"
32
+ // (step 3) of the overlay's stacking context — BELOW positioned
33
+ // descendants of <main>, e.g. code blocks (cb-root is `position: relative`).
34
+ // Promoting the header to a positioned descendant with z-index pushes it
35
+ // above those, so the Aa popover (which renders inside the header and
36
+ // visually overflows down into the article area) stays on top and
37
+ // clickable when it overlaps a code block.
38
+ className="relative z-30 h-12 flex items-center gap-3 px-3 border-b border-muted/15 bg-background/95 backdrop-blur-md shrink-0 select-none"
39
+ >
40
+ <button
41
+ type="button"
42
+ onClick={toggleSidebar}
43
+ aria-pressed={sidebarOpen}
44
+ aria-label={sidebarOpen ? t('collapse_sidebar') : t('expand_sidebar')}
45
+ title={sidebarOpen ? t('collapse_sidebar') : t('expand_sidebar')}
46
+ className="h-8 w-8 inline-flex items-center justify-center rounded-md text-foreground/80 hover:text-accent hover:bg-muted/10 transition-colors"
47
+ >
48
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
49
+ <line x1="3" y1="6" x2="21" y2="6" />
50
+ <line x1="3" y1="12" x2="21" y2="12" />
51
+ <line x1="3" y1="18" x2="21" y2="18" />
52
+ </svg>
53
+ </button>
54
+
55
+ <div className="min-w-0 flex-1 flex items-center gap-2 text-sm">
56
+ <Link
57
+ href={rootHref}
58
+ className="font-serif font-semibold text-heading hover:text-accent truncate no-underline"
59
+ title={rootTitle}
60
+ >
61
+ {rootTitle}
62
+ </Link>
63
+ <span className="text-muted/50 hidden sm:inline" aria-hidden="true">/</span>
64
+ <span className="text-muted truncate hidden sm:inline" title={currentTitle}>
65
+ {currentTitle}
66
+ </span>
67
+ </div>
68
+
69
+ <div className="relative shrink-0">
70
+ <button
71
+ ref={prefsButtonRef}
72
+ type="button"
73
+ onClick={togglePrefsPanel}
74
+ aria-expanded={prefsPanelOpen}
75
+ aria-haspopup="dialog"
76
+ aria-label={t('reading_preferences')}
77
+ title={t('reading_preferences')}
78
+ className={`h-8 w-9 inline-flex items-center justify-center rounded-md transition-colors ${
79
+ prefsPanelOpen
80
+ ? 'bg-muted/10 text-accent'
81
+ : 'text-foreground/80 hover:text-accent hover:bg-muted/10'
82
+ }`}
83
+ >
84
+ <span aria-hidden="true" className="font-serif leading-none">
85
+ <span className="text-[15px] font-bold">A</span>
86
+ <span className="text-[10px]">a</span>
87
+ </span>
88
+ </button>
89
+ <ImmersiveReadingPrefsPopover toggleButtonRef={prefsButtonRef} />
90
+ </div>
91
+
92
+ <button
93
+ type="button"
94
+ onClick={exit}
95
+ aria-label={t('exit_reading_mode')}
96
+ title={t('exit_reading_mode')}
97
+ className="h-8 w-8 inline-flex items-center justify-center rounded-md text-foreground/80 hover:text-accent hover:bg-muted/10 transition-colors shrink-0"
98
+ >
99
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
100
+ <line x1="18" y1="6" x2="6" y2="18" />
101
+ <line x1="6" y1="6" x2="18" y2="18" />
102
+ </svg>
103
+ </button>
104
+ </header>
105
+ );
106
+ }
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
6
+
7
+ /**
8
+ * Watches the URL for `?immersive=1` (set by the "Immersive reading" CTA on
9
+ * the book index page) and, when present, enters the reader and strips the
10
+ * flag so back-navigation doesn't loop it open.
11
+ *
12
+ * Lives in its own component so the `useSearchParams` bailout to client
13
+ * rendering is contained — the Suspense boundary in the parent layout only
14
+ * wraps this null-rendering handler, not the chapter content. Keeps the rest
15
+ * of the book sub-tree statically prerenderable.
16
+ *
17
+ * No one-shot ref guard: router.replace strips the flag, which updates
18
+ * useSearchParams reactively, which re-fires this effect with the flag gone
19
+ * (early return). A subsequent explicit visit to another `?immersive=1` URL
20
+ * in the same tab (e.g. browser back, or clicking the CTA again) re-triggers
21
+ * the entry — a stale ref would silently swallow that.
22
+ */
23
+ export default function ImmersiveReadingFlagHandler() {
24
+ const searchParams = useSearchParams();
25
+ const router = useRouter();
26
+ const pathname = usePathname();
27
+ const { enter } = useImmersiveReading();
28
+
29
+ useEffect(() => {
30
+ if (searchParams?.get('immersive') !== '1') return;
31
+ const activate = () => enter();
32
+ activate();
33
+ const params = new URLSearchParams(searchParams.toString());
34
+ params.delete('immersive');
35
+ const qs = params.toString();
36
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
37
+ }, [searchParams, pathname, router, enter]);
38
+
39
+ return null;
40
+ }
@@ -0,0 +1,249 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
4
+ import {
5
+ useImmersiveReading,
6
+ type ReadingColumnWidth,
7
+ type ReadingFontSize,
8
+ type ReadingTheme,
9
+ } from '@/components/ImmersiveReadingProvider';
10
+ import { useLanguage } from '@/components/LanguageProvider';
11
+ import type { TranslationKey } from '@/i18n/translations';
12
+
13
+ interface ImmersiveReadingPrefsPopoverProps {
14
+ /** The toggle button — its ref is excluded from outside-click closing so the
15
+ * button's onClick is the single source of truth for toggling. */
16
+ toggleButtonRef: RefObject<HTMLElement | null>;
17
+ }
18
+
19
+ const FONT_PREVIEW_PX: Record<ReadingFontSize, number> = {
20
+ s: 12,
21
+ m: 15,
22
+ l: 19,
23
+ xl: 24,
24
+ };
25
+
26
+ const FONT_OPTIONS: Array<{ value: ReadingFontSize; labelKey: TranslationKey }> = [
27
+ { value: 's', labelKey: 'size_small' },
28
+ { value: 'm', labelKey: 'size_medium' },
29
+ { value: 'l', labelKey: 'size_large' },
30
+ { value: 'xl', labelKey: 'size_xl' },
31
+ ];
32
+
33
+ const THEME_OPTIONS: Array<{ value: ReadingTheme; labelKey: TranslationKey }> = [
34
+ { value: 'auto', labelKey: 'theme_auto' },
35
+ { value: 'light', labelKey: 'theme_light' },
36
+ { value: 'sepia', labelKey: 'theme_sepia' },
37
+ { value: 'dark', labelKey: 'theme_dark' },
38
+ ];
39
+
40
+ const WIDTH_OPTIONS: Array<{ value: ReadingColumnWidth; labelKey: TranslationKey; barWidthPct: number }> = [
41
+ { value: 'narrow', labelKey: 'width_narrow', barWidthPct: 35 },
42
+ { value: 'medium', labelKey: 'width_medium', barWidthPct: 55 },
43
+ { value: 'wide', labelKey: 'width_wide', barWidthPct: 75 },
44
+ { value: 'full', labelKey: 'width_full', barWidthPct: 100 },
45
+ ];
46
+
47
+ // Tailwind classes baked into the theme swatch — concrete hex / vars so the
48
+ // reader sees the actual colours, not an approximation.
49
+ const THEME_SWATCH_STYLE: Record<ReadingTheme, string> = {
50
+ auto: 'bg-gradient-to-br from-stone-50 from-50% to-stone-800 to-50% text-stone-700',
51
+ light: 'bg-stone-50 text-stone-900',
52
+ sepia: 'bg-[#f4ecd8] text-[#3b2f24]',
53
+ dark: 'bg-stone-800 text-stone-100',
54
+ };
55
+
56
+ function GroupHeading({ children }: { children: ReactNode }) {
57
+ return (
58
+ <div className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted/80 mb-2">
59
+ {children}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ function OptionButton({
65
+ active,
66
+ onClick,
67
+ ariaLabel,
68
+ title,
69
+ children,
70
+ className = '',
71
+ }: {
72
+ active: boolean;
73
+ onClick: () => void;
74
+ ariaLabel: string;
75
+ title: string;
76
+ children: ReactNode;
77
+ className?: string;
78
+ }) {
79
+ return (
80
+ <button
81
+ type="button"
82
+ onClick={onClick}
83
+ aria-pressed={active}
84
+ aria-label={ariaLabel}
85
+ title={title}
86
+ className={`relative flex items-center justify-center rounded-lg transition-all duration-150 ${
87
+ active
88
+ ? 'ring-2 ring-accent ring-offset-2 ring-offset-background'
89
+ : 'ring-1 ring-muted/20 hover:ring-muted/40'
90
+ } ${className}`}
91
+ >
92
+ {children}
93
+ </button>
94
+ );
95
+ }
96
+
97
+ export default function ImmersiveReadingPrefsPopover({ toggleButtonRef }: ImmersiveReadingPrefsPopoverProps) {
98
+ const { t } = useLanguage();
99
+ const {
100
+ prefsPanelOpen,
101
+ fontSize,
102
+ readingTheme,
103
+ columnWidth,
104
+ setFontSize,
105
+ setReadingTheme,
106
+ setColumnWidth,
107
+ closePrefsPanel,
108
+ resetPrefs,
109
+ } = useImmersiveReading();
110
+
111
+ const rootRef = useRef<HTMLDivElement>(null);
112
+
113
+ useEffect(() => {
114
+ if (!prefsPanelOpen) return;
115
+ // pointerdown unifies mouse, touch, and pen — needed so taps outside the
116
+ // popover dismiss it on mobile too (mousedown alone doesn't fire there
117
+ // reliably).
118
+ const onPointerDown = (event: PointerEvent) => {
119
+ const target = event.target as Node;
120
+ if (rootRef.current?.contains(target)) return;
121
+ if (toggleButtonRef.current?.contains(target)) return;
122
+ closePrefsPanel();
123
+ };
124
+ document.addEventListener('pointerdown', onPointerDown);
125
+ return () => document.removeEventListener('pointerdown', onPointerDown);
126
+ }, [prefsPanelOpen, closePrefsPanel, toggleButtonRef]);
127
+
128
+ if (!prefsPanelOpen) return null;
129
+
130
+ return (
131
+ <div
132
+ ref={rootRef}
133
+ role="dialog"
134
+ aria-label={t('reading_preferences')}
135
+ className="absolute top-full right-0 mt-2 z-50 min-w-[280px] bg-background/95 backdrop-blur-md border border-muted/20 rounded-xl shadow-xl p-5 animate-slide-down"
136
+ >
137
+ {/* Font Size */}
138
+ <div className="mb-5">
139
+ <GroupHeading>{t('font_size')}</GroupHeading>
140
+ <div className="grid grid-cols-4 gap-2">
141
+ {FONT_OPTIONS.map(opt => {
142
+ const active = opt.value === fontSize;
143
+ return (
144
+ <OptionButton
145
+ key={opt.value}
146
+ active={active}
147
+ onClick={() => setFontSize(opt.value)}
148
+ ariaLabel={`${t('font_size')}: ${t(opt.labelKey)}`}
149
+ title={t(opt.labelKey)}
150
+ className="h-12 bg-background"
151
+ >
152
+ <span
153
+ aria-hidden="true"
154
+ className="font-serif font-semibold text-foreground leading-none"
155
+ style={{ fontSize: `${FONT_PREVIEW_PX[opt.value]}px` }}
156
+ >
157
+ A
158
+ </span>
159
+ </OptionButton>
160
+ );
161
+ })}
162
+ </div>
163
+ </div>
164
+
165
+ {/* Theme */}
166
+ <div className="mb-5">
167
+ <GroupHeading>{t('reading_theme')}</GroupHeading>
168
+ <div className="grid grid-cols-4 gap-2">
169
+ {THEME_OPTIONS.map(opt => {
170
+ const active = opt.value === readingTheme;
171
+ return (
172
+ <OptionButton
173
+ key={opt.value}
174
+ active={active}
175
+ onClick={() => setReadingTheme(opt.value)}
176
+ ariaLabel={`${t('reading_theme')}: ${t(opt.labelKey)}`}
177
+ title={t(opt.labelKey)}
178
+ className="flex-col h-14 overflow-hidden"
179
+ >
180
+ <span
181
+ aria-hidden="true"
182
+ className={`flex-1 w-full flex items-center justify-center text-[13px] font-serif font-semibold ${THEME_SWATCH_STYLE[opt.value]}`}
183
+ >
184
+ Aa
185
+ </span>
186
+ <span className="block w-full text-[9px] text-muted py-0.5 bg-background border-t border-muted/15">
187
+ {t(opt.labelKey)}
188
+ </span>
189
+ </OptionButton>
190
+ );
191
+ })}
192
+ </div>
193
+ </div>
194
+
195
+ {/* Width */}
196
+ <div>
197
+ <GroupHeading>{t('column_width')}</GroupHeading>
198
+ <div className="grid grid-cols-4 gap-2">
199
+ {WIDTH_OPTIONS.map(opt => {
200
+ const active = opt.value === columnWidth;
201
+ return (
202
+ <OptionButton
203
+ key={opt.value}
204
+ active={active}
205
+ onClick={() => setColumnWidth(opt.value)}
206
+ ariaLabel={`${t('column_width')}: ${t(opt.labelKey)}`}
207
+ title={t(opt.labelKey)}
208
+ className="h-12 bg-background"
209
+ >
210
+ <span
211
+ aria-hidden="true"
212
+ className="flex flex-col items-center justify-center gap-1 w-full"
213
+ >
214
+ <span
215
+ className="block h-0.5 bg-foreground/70 rounded-full"
216
+ style={{ width: `${opt.barWidthPct}%` }}
217
+ />
218
+ <span
219
+ className="block h-0.5 bg-foreground/40 rounded-full"
220
+ style={{ width: `${opt.barWidthPct}%` }}
221
+ />
222
+ <span
223
+ className="block h-0.5 bg-foreground/40 rounded-full"
224
+ style={{ width: `${opt.barWidthPct}%` }}
225
+ />
226
+ </span>
227
+ </OptionButton>
228
+ );
229
+ })}
230
+ </div>
231
+ </div>
232
+
233
+ {/* Reset to defaults — non-destructive (re-pickable), no confirmation. */}
234
+ <div className="mt-4 pt-3 border-t border-muted/15 flex justify-end">
235
+ <button
236
+ type="button"
237
+ onClick={resetPrefs}
238
+ className="text-xs font-sans text-muted hover:text-accent transition-colors inline-flex items-center gap-1.5"
239
+ >
240
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
241
+ <path d="M3 12a9 9 0 1 0 3-6.7" />
242
+ <polyline points="3 4 3 10 9 10" />
243
+ </svg>
244
+ {t('reset_to_defaults')}
245
+ </button>
246
+ </div>
247
+ </div>
248
+ );
249
+ }
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
4
+ import { DEFAULT_PREFS, readStoredPrefs, writeStoredPrefs } from '@/lib/immersive-reading-prefs';
5
+
6
+ export type ReadingFontSize = 's' | 'm' | 'l' | 'xl';
7
+ export type ReadingTheme = 'auto' | 'light' | 'sepia' | 'dark';
8
+ export type ReadingColumnWidth = 'narrow' | 'medium' | 'wide' | 'full';
9
+
10
+ interface ImmersiveReadingContextValue {
11
+ enabled: boolean;
12
+ fontSize: ReadingFontSize;
13
+ readingTheme: ReadingTheme;
14
+ columnWidth: ReadingColumnWidth;
15
+ sidebarOpen: boolean;
16
+ prefsPanelOpen: boolean;
17
+ toggle: () => void;
18
+ enter: () => void;
19
+ exit: () => void;
20
+ setFontSize: (size: ReadingFontSize) => void;
21
+ setReadingTheme: (theme: ReadingTheme) => void;
22
+ setColumnWidth: (width: ReadingColumnWidth) => void;
23
+ toggleSidebar: () => void;
24
+ setSidebarOpen: (open: boolean) => void;
25
+ togglePrefsPanel: () => void;
26
+ closePrefsPanel: () => void;
27
+ resetPrefs: () => void;
28
+ }
29
+
30
+ const ImmersiveReadingContext = createContext<ImmersiveReadingContextValue | undefined>(undefined);
31
+
32
+ export function ImmersiveReadingProvider({ children }: { children: ReactNode }) {
33
+ // Initial state intentionally matches DEFAULT_PREFS so SSR/CSR render
34
+ // identically — localStorage is read in an effect after mount.
35
+ const [enabled, setEnabled] = useState(false);
36
+ const [fontSize, setFontSize] = useState<ReadingFontSize>(DEFAULT_PREFS.fontSize);
37
+ const [readingTheme, setReadingTheme] = useState<ReadingTheme>(DEFAULT_PREFS.readingTheme);
38
+ const [columnWidth, setColumnWidth] = useState<ReadingColumnWidth>(DEFAULT_PREFS.columnWidth);
39
+ const [sidebarOpen, setSidebarOpen] = useState(DEFAULT_PREFS.sidebarOpen);
40
+ const [prefsPanelOpen, setPrefsPanelOpen] = useState(false);
41
+
42
+ const enter = useCallback(() => setEnabled(true), []);
43
+ const exit = useCallback(() => {
44
+ setEnabled(false);
45
+ setPrefsPanelOpen(false);
46
+ }, []);
47
+ const toggle = useCallback(() => {
48
+ setEnabled(prev => {
49
+ if (prev) setPrefsPanelOpen(false);
50
+ return !prev;
51
+ });
52
+ }, []);
53
+ const toggleSidebar = useCallback(() => setSidebarOpen(prev => !prev), []);
54
+ const togglePrefsPanel = useCallback(() => setPrefsPanelOpen(prev => !prev), []);
55
+ const closePrefsPanel = useCallback(() => setPrefsPanelOpen(false), []);
56
+ const resetPrefs = useCallback(() => {
57
+ setFontSize(DEFAULT_PREFS.fontSize);
58
+ setReadingTheme(DEFAULT_PREFS.readingTheme);
59
+ setColumnWidth(DEFAULT_PREFS.columnWidth);
60
+ setSidebarOpen(DEFAULT_PREFS.sidebarOpen);
61
+ }, []);
62
+
63
+ // Hydrate prefs from localStorage on mount, then persist on every change.
64
+ // The persist effect itself flips `hydratedRef` on its first run and
65
+ // no-ops — without this, the initial commit's persist run would race
66
+ // ahead of the hydration setters (state hasn't re-rendered yet, so it'd
67
+ // read the React defaults from the closure and clobber the stored blob
68
+ // with them before the next render restores the correct values).
69
+ const hydratedRef = useRef(false);
70
+ useEffect(() => {
71
+ const stored = readStoredPrefs();
72
+ const applyStored = () => {
73
+ setFontSize(stored.fontSize);
74
+ setReadingTheme(stored.readingTheme);
75
+ setColumnWidth(stored.columnWidth);
76
+ setSidebarOpen(stored.sidebarOpen);
77
+ };
78
+ applyStored();
79
+ }, []);
80
+ useEffect(() => {
81
+ if (!hydratedRef.current) {
82
+ hydratedRef.current = true;
83
+ return;
84
+ }
85
+ writeStoredPrefs({ fontSize, readingTheme, columnWidth, sidebarOpen });
86
+ }, [fontSize, readingTheme, columnWidth, sidebarOpen]);
87
+
88
+ useEffect(() => {
89
+ if (typeof document === 'undefined') return;
90
+ const root = document.documentElement;
91
+ if (enabled) {
92
+ root.dataset.immersive = 'true';
93
+ const previousOverflow = document.body.style.overflow;
94
+ document.body.style.overflow = 'hidden';
95
+ return () => {
96
+ delete root.dataset.immersive;
97
+ document.body.style.overflow = previousOverflow;
98
+ };
99
+ }
100
+ delete root.dataset.immersive;
101
+ }, [enabled]);
102
+
103
+ useEffect(() => {
104
+ if (!enabled) return;
105
+ const onKey = (event: KeyboardEvent) => {
106
+ if (event.key !== 'Escape') return;
107
+ event.preventDefault();
108
+ if (prefsPanelOpen) {
109
+ setPrefsPanelOpen(false);
110
+ } else {
111
+ setEnabled(false);
112
+ }
113
+ };
114
+ window.addEventListener('keydown', onKey);
115
+ return () => window.removeEventListener('keydown', onKey);
116
+ }, [enabled, prefsPanelOpen]);
117
+
118
+ // Auto-collapse the sidebar on narrow viewports when entering immersive mode
119
+ // (or when resizing into the narrow range). Without this, the sidebar
120
+ // overlaps the article on tablets / phones. Deliberately one-directional:
121
+ // we never auto-open on a wide-resize, so a user who manually closed the
122
+ // sidebar on desktop keeps that preference instead of having it overridden.
123
+ useEffect(() => {
124
+ if (!enabled || typeof window === 'undefined') return;
125
+ const mq = window.matchMedia('(max-width: 1023px)');
126
+ const collapseIfNarrow = (matches: boolean) => {
127
+ if (matches) setSidebarOpen(false);
128
+ };
129
+ collapseIfNarrow(mq.matches);
130
+ const onChange = (e: MediaQueryListEvent) => collapseIfNarrow(e.matches);
131
+ mq.addEventListener('change', onChange);
132
+ return () => mq.removeEventListener('change', onChange);
133
+ }, [enabled]);
134
+
135
+ return (
136
+ <ImmersiveReadingContext.Provider
137
+ value={{
138
+ enabled,
139
+ fontSize,
140
+ readingTheme,
141
+ columnWidth,
142
+ sidebarOpen,
143
+ prefsPanelOpen,
144
+ toggle,
145
+ enter,
146
+ exit,
147
+ setFontSize,
148
+ setReadingTheme,
149
+ setColumnWidth,
150
+ toggleSidebar,
151
+ setSidebarOpen,
152
+ togglePrefsPanel,
153
+ closePrefsPanel,
154
+ resetPrefs,
155
+ }}
156
+ >
157
+ {children}
158
+ </ImmersiveReadingContext.Provider>
159
+ );
160
+ }
161
+
162
+ export function useImmersiveReading(): ImmersiveReadingContextValue {
163
+ const ctx = useContext(ImmersiveReadingContext);
164
+ if (!ctx) {
165
+ throw new Error('useImmersiveReading must be used within an ImmersiveReadingProvider');
166
+ }
167
+ return ctx;
168
+ }