@dsbasko/cookbook-engine 0.1.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.

Potentially problematic release.


This version of @dsbasko/cookbook-engine might be problematic. Click here for more details.

Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
  4. package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
  5. package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
  6. package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
  7. package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
  8. package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
  9. package/package.json +92 -0
  10. package/scripts/check-course-coverage.mts +32 -0
  11. package/scripts/fix-static-image-extensions.mjs +78 -0
  12. package/scripts/generate-readme-toc.mts +32 -0
  13. package/scripts/resolve-course-paths.mjs +28 -0
  14. package/scripts/sync-images.mjs +88 -0
  15. package/src/components/AppShell/AppShell.module.css +40 -0
  16. package/src/components/AppShell/AppShell.tsx +135 -0
  17. package/src/components/AppShell/index.ts +1 -0
  18. package/src/components/Callout/Callout.module.css +68 -0
  19. package/src/components/Callout/Callout.tsx +83 -0
  20. package/src/components/Callout/index.ts +1 -0
  21. package/src/components/CodeBlock/CodeBlock.module.css +68 -0
  22. package/src/components/CodeBlock/CodeBlock.tsx +65 -0
  23. package/src/components/CodeBlock/index.ts +1 -0
  24. package/src/components/GateProvider/GateProvider.tsx +207 -0
  25. package/src/components/GateProvider/index.ts +1 -0
  26. package/src/components/Header/Breadcrumbs.tsx +50 -0
  27. package/src/components/Header/Header.module.css +131 -0
  28. package/src/components/Header/Header.tsx +26 -0
  29. package/src/components/Header/HeaderLessonNav.tsx +118 -0
  30. package/src/components/Header/index.ts +1 -0
  31. package/src/components/HomePage/HomePage.module.css +538 -0
  32. package/src/components/HomePage/HomePage.tsx +295 -0
  33. package/src/components/HomePage/index.ts +1 -0
  34. package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
  35. package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
  36. package/src/components/LessonAwareLink/index.ts +1 -0
  37. package/src/components/LessonLayout/LessonLayout.module.css +35 -0
  38. package/src/components/LessonLayout/LessonLayout.tsx +18 -0
  39. package/src/components/LessonLayout/index.ts +1 -0
  40. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
  41. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
  42. package/src/components/LessonLockedInterstitial/index.ts +1 -0
  43. package/src/components/LessonNav/LessonNav.module.css +84 -0
  44. package/src/components/LessonNav/LessonNav.tsx +64 -0
  45. package/src/components/LessonNav/index.ts +1 -0
  46. package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
  47. package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
  48. package/src/components/LessonPageLayout/index.ts +1 -0
  49. package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
  50. package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
  51. package/src/components/LessonSideMeta/index.ts +1 -0
  52. package/src/components/ModulePage/ModulePage.module.css +693 -0
  53. package/src/components/ModulePage/ModulePage.tsx +301 -0
  54. package/src/components/ModulePage/index.ts +1 -0
  55. package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
  56. package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
  57. package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
  58. package/src/components/ProgramDrawer/index.ts +1 -0
  59. package/src/components/ProgressBar/ProgressBar.module.css +46 -0
  60. package/src/components/ProgressBar/ProgressBar.tsx +45 -0
  61. package/src/components/ProgressBar/index.ts +1 -0
  62. package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
  63. package/src/components/ProgressModeProvider/index.ts +1 -0
  64. package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
  65. package/src/components/ReadingPrefsProvider/index.ts +1 -0
  66. package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
  67. package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
  68. package/src/components/ReadingProgress/index.ts +1 -0
  69. package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
  70. package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
  71. package/src/components/SettingsToggle/index.ts +1 -0
  72. package/src/components/Sidebar/Sidebar.module.css +157 -0
  73. package/src/components/Sidebar/Sidebar.tsx +63 -0
  74. package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
  75. package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
  76. package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
  77. package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
  78. package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
  79. package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
  80. package/src/components/Sidebar/icons/index.ts +6 -0
  81. package/src/components/Sidebar/index.ts +1 -0
  82. package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
  83. package/src/components/ThemeProvider/index.ts +1 -0
  84. package/src/components/Toc/Toc.module.css +78 -0
  85. package/src/components/Toc/Toc.tsx +92 -0
  86. package/src/components/Toc/index.ts +1 -0
  87. package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
  88. package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
  89. package/src/components/TranslationBanner/index.ts +1 -0
  90. package/src/config.d.mts +12 -0
  91. package/src/config.mjs +110 -0
  92. package/src/index.ts +62 -0
  93. package/src/layout/lang.tsx +44 -0
  94. package/src/layout/root.tsx +223 -0
  95. package/src/lib/course-loader.ts +33 -0
  96. package/src/lib/course.ts +429 -0
  97. package/src/lib/coverage.ts +141 -0
  98. package/src/lib/description.ts +43 -0
  99. package/src/lib/extract-toc.ts +59 -0
  100. package/src/lib/format.ts +55 -0
  101. package/src/lib/frontier-link.ts +37 -0
  102. package/src/lib/gate-init-script.ts +40 -0
  103. package/src/lib/gate-mark-script.ts +324 -0
  104. package/src/lib/i18n.ts +474 -0
  105. package/src/lib/lang.ts +90 -0
  106. package/src/lib/lesson-gate.ts +79 -0
  107. package/src/lib/lesson.ts +66 -0
  108. package/src/lib/markdown-components.tsx +51 -0
  109. package/src/lib/markdown.ts +180 -0
  110. package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
  111. package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
  112. package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
  113. package/src/lib/paths.ts +36 -0
  114. package/src/lib/program-drawer.ts +8 -0
  115. package/src/lib/progress-mode.ts +69 -0
  116. package/src/lib/progress.ts +182 -0
  117. package/src/lib/reading-prefs.ts +127 -0
  118. package/src/lib/readme-toc.ts +69 -0
  119. package/src/lib/site-url.ts +33 -0
  120. package/src/lib/sitemap.ts +112 -0
  121. package/src/lib/slug.ts +15 -0
  122. package/src/lib/theme.ts +78 -0
  123. package/src/lib/use-i18n.ts +25 -0
  124. package/src/og/icon.tsx +40 -0
  125. package/src/og/opengraph-image.tsx +126 -0
  126. package/src/pages/home.tsx +66 -0
  127. package/src/pages/lesson.tsx +260 -0
  128. package/src/pages/module.tsx +80 -0
  129. package/src/pages/not-found-lang.tsx +51 -0
  130. package/src/pages/not-found-root.tsx +48 -0
  131. package/src/pages/root.tsx +44 -0
  132. package/src/seo/robots.ts +16 -0
  133. package/src/seo/sitemap.ts +10 -0
  134. package/src/styles/globals.css +139 -0
  135. package/src/styles/markdown.css +265 -0
  136. package/src/styles/reset.css +89 -0
  137. package/src/styles/tokens.css +270 -0
@@ -0,0 +1,688 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { usePathname, useRouter } from 'next/navigation';
6
+ import { GitHubIcon, SettingsIcon } from '@/components/Sidebar/icons';
7
+ import { useReadingPrefs } from '@/components/ReadingPrefsProvider';
8
+ import { useTheme } from '@/components/ThemeProvider';
9
+ import { useProgressMode } from '@/components/ProgressModeProvider';
10
+ import { getCompletedCount, getFurthestKey, getProgress } from '@/lib/progress';
11
+ import {
12
+ CODE_FONTS,
13
+ PROSE_FONTS,
14
+ type CodeFont,
15
+ type ProseFont,
16
+ type SizeStep,
17
+ } from '@/lib/reading-prefs';
18
+ import {
19
+ LANG_LABELS,
20
+ LANGS,
21
+ stripLangFromPath,
22
+ writeStoredLang,
23
+ type Lang,
24
+ } from '@/lib/lang';
25
+ import { THEME_PREFERENCES, type ThemePreference } from '@/lib/theme';
26
+ import { useT } from '@/lib/use-i18n';
27
+ import type { UIDict } from '@/lib/i18n';
28
+ import styles from './SettingsToggle.module.css';
29
+
30
+ const SETTINGS_REOPEN_KEY = 'kafka-cookbook:settings:reopen-after-lang';
31
+
32
+ const SIZE_STEPS: SizeStep[] = [0, 1, 2, 3, 4, 5];
33
+ const MAX_SIZE_STEP: SizeStep = 5;
34
+
35
+ const PROSE_SIZE_PX: Record<SizeStep, number> = {
36
+ 0: 14,
37
+ 1: 16,
38
+ 2: 18,
39
+ 3: 20,
40
+ 4: 22,
41
+ 5: 24,
42
+ };
43
+
44
+ const CODE_SIZE_PX: Record<SizeStep, number> = {
45
+ 0: 14,
46
+ 1: 16,
47
+ 2: 18,
48
+ 3: 20,
49
+ 4: 22,
50
+ 5: 24,
51
+ };
52
+
53
+ const PROSE_FONT_LABEL_KEYS: Record<ProseFont, keyof UIDict> = {
54
+ serif: 'readingPrefsFontSerif',
55
+ sans: 'readingPrefsFontSans',
56
+ slab: 'readingPrefsFontSlab',
57
+ };
58
+
59
+ const CODE_FONT_LABEL_KEYS: Record<CodeFont, keyof UIDict> = {
60
+ jetbrains: 'readingPrefsFontJetBrains',
61
+ fira: 'readingPrefsFontFira',
62
+ };
63
+
64
+ const THEME_LABEL_KEYS: Record<
65
+ ThemePreference,
66
+ 'themeLight' | 'themeDark' | 'themePaper'
67
+ > = {
68
+ light: 'themeLight',
69
+ dark: 'themeDark',
70
+ paper: 'themePaper',
71
+ };
72
+
73
+ function stepDown(step: SizeStep): SizeStep {
74
+ return (step > 0 ? step - 1 : 0) as SizeStep;
75
+ }
76
+
77
+ function stepUp(step: SizeStep): SizeStep {
78
+ return (step < MAX_SIZE_STEP ? step + 1 : MAX_SIZE_STEP) as SizeStep;
79
+ }
80
+
81
+ function replaceLangInPath(pathname: string | null, next: Lang): string {
82
+ const { rest } = stripLangFromPath(pathname ?? '/');
83
+ if (rest === '/' || rest === '') return `/${next}/`;
84
+ return `/${next}${rest}`;
85
+ }
86
+
87
+ function CloseIcon() {
88
+ return (
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
90
+ <path d="M6 6l12 12M18 6 6 18" />
91
+ </svg>
92
+ );
93
+ }
94
+
95
+ function LightThemeIcon() {
96
+ return (
97
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
98
+ <circle cx="8" cy="8" r="2.6" />
99
+ <path d="M8 1.6v1.6M8 12.8v1.6M1.6 8h1.6M12.8 8h1.6M3.5 3.5l1.1 1.1M11.4 11.4l1.1 1.1M3.5 12.5l1.1-1.1M11.4 4.6l1.1-1.1" />
100
+ </svg>
101
+ );
102
+ }
103
+
104
+ function PaperThemeIcon() {
105
+ return (
106
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
107
+ <path d="M3.5 2.5h6.4l2.6 2.6V13a.5.5 0 0 1-.5.5h-8.5a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" />
108
+ <path d="M9.6 2.6V5.2h2.6" />
109
+ <path d="M5.5 8.4h5M5.5 10.6h3.6" />
110
+ </svg>
111
+ );
112
+ }
113
+
114
+ function DarkThemeIcon() {
115
+ return (
116
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
117
+ <path d="M13.2 9.6A5.6 5.6 0 1 1 6.4 2.8a4.6 4.6 0 0 0 6.8 6.8z" />
118
+ </svg>
119
+ );
120
+ }
121
+
122
+ const THEME_ICONS: Record<ThemePreference, () => JSX.Element> = {
123
+ light: LightThemeIcon,
124
+ paper: PaperThemeIcon,
125
+ dark: DarkThemeIcon,
126
+ };
127
+
128
+ type SizeStepperProps = {
129
+ kind: 'prose' | 'code';
130
+ value: SizeStep;
131
+ pxLabel: string;
132
+ onStep: (next: SizeStep) => void;
133
+ decreaseLabel: string;
134
+ increaseLabel: string;
135
+ };
136
+
137
+ function SizeStepper({ kind, value, pxLabel, onStep, decreaseLabel, increaseLabel }: SizeStepperProps) {
138
+ const isMin = value === 0;
139
+ const isMax = value === MAX_SIZE_STEP;
140
+ const percent = (value / MAX_SIZE_STEP) * 100;
141
+ return (
142
+ <div className={styles.sizeStepper}>
143
+ <button
144
+ type="button"
145
+ className={styles.sizeBtn}
146
+ aria-label={decreaseLabel}
147
+ disabled={isMin}
148
+ onClick={() => onStep(stepDown(value))}
149
+ data-kind={`${kind}-decrease`}
150
+ >
151
+ <span className={styles.sizeBtnGlyphSmall}>A</span>
152
+ </button>
153
+ <div className={styles.sizeTrack}>
154
+ <div className={styles.sizeRail} />
155
+ <div className={styles.sizeRailFill} style={{ width: `${percent}%` }} />
156
+ <div className={styles.sizeKnob} style={{ left: `${percent}%` }} />
157
+ <div className={styles.sizeTicks}>
158
+ {SIZE_STEPS.map((step) => (
159
+ <button
160
+ key={step}
161
+ type="button"
162
+ className={styles.sizeTick}
163
+ data-active={step === value ? 'true' : 'false'}
164
+ onClick={() => onStep(step)}
165
+ aria-label={`${kind === 'prose' ? PROSE_SIZE_PX[step] : CODE_SIZE_PX[step]}px`}
166
+ >
167
+ <span className={styles.sizeTickDot} />
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ <button
173
+ type="button"
174
+ className={styles.sizeBtn}
175
+ aria-label={increaseLabel}
176
+ disabled={isMax}
177
+ onClick={() => onStep(stepUp(value))}
178
+ data-kind={`${kind}-increase`}
179
+ >
180
+ <span className={styles.sizeBtnGlyphLarge}>A</span>
181
+ </button>
182
+ <span className={styles.sizeValue} aria-live="polite" data-kind={`${kind}-value`}>
183
+ {pxLabel.replace(/px\s*$/i, '')}
184
+ <span className={styles.sizeUnit}>px</span>
185
+ </span>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ type SettingsToggleProps = {
191
+ /* Used by the mobile sheet's bottom "Ресурсы" card. Desktop renders no
192
+ GitHub affordance from inside this popover — the rail outside it does. */
193
+ repoUrl?: string;
194
+ };
195
+
196
+ export function SettingsToggle({ repoUrl }: SettingsToggleProps = {}) {
197
+ const t = useT();
198
+ const { prefs, setProseSize, setCodeSize, setProseFont, setCodeFont } = useReadingPrefs();
199
+ const { preference: themePreference, setPreference: setThemePreference } = useTheme();
200
+ const { disabled: progressDisabled, setDisabled: setProgressDisabled } = useProgressMode();
201
+ const router = useRouter();
202
+ const pathname = usePathname();
203
+ const currentLang = stripLangFromPath(pathname ?? '/').lang;
204
+
205
+ const [open, setOpen] = useState(false);
206
+ const [confirmingReset, setConfirmingReset] = useState(false);
207
+ const [renderedSizes, setRenderedSizes] = useState<{ prose: string; code: string } | null>(null);
208
+ const [isMobile, setIsMobile] = useState(false);
209
+ const [mounted, setMounted] = useState(false);
210
+ const wrapperRef = useRef<HTMLDivElement>(null);
211
+ const popoverRef = useRef<HTMLDivElement>(null);
212
+ const popoverId = useId();
213
+
214
+ useEffect(() => {
215
+ setMounted(true);
216
+ // Reopen after a language switch — changing [lang] segment remounts
217
+ // the layout (and this component), which would otherwise drop `open`.
218
+ try {
219
+ if (sessionStorage.getItem(SETTINGS_REOPEN_KEY) === '1') {
220
+ sessionStorage.removeItem(SETTINGS_REOPEN_KEY);
221
+ setOpen(true);
222
+ }
223
+ } catch {
224
+ /* sessionStorage unavailable; ignore. */
225
+ }
226
+ }, []);
227
+
228
+ // The popover stays mounted (toggled via `hidden`), so drop any pending
229
+ // free-reading reset confirmation when it closes — otherwise the destructive
230
+ // prompt would reappear out of context the next time settings is opened.
231
+ useEffect(() => {
232
+ if (!open) setConfirmingReset(false);
233
+ }, [open]);
234
+
235
+ useEffect(() => {
236
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
237
+ const mq = window.matchMedia('(max-width: 1023px)');
238
+ const sync = () => setIsMobile(mq.matches);
239
+ sync();
240
+ mq.addEventListener('change', sync);
241
+ return () => mq.removeEventListener('change', sync);
242
+ }, []);
243
+
244
+ useEffect(() => {
245
+ if (!open) return;
246
+ function handlePointer(event: MouseEvent) {
247
+ const target = event.target as Node | null;
248
+ if (!target) return;
249
+ if (wrapperRef.current?.contains(target)) return;
250
+ if (popoverRef.current?.contains(target)) return;
251
+ setOpen(false);
252
+ }
253
+ function handleKey(event: KeyboardEvent) {
254
+ if (event.key === 'Escape') setOpen(false);
255
+ }
256
+ document.addEventListener('mousedown', handlePointer);
257
+ document.addEventListener('keydown', handleKey);
258
+ return () => {
259
+ document.removeEventListener('mousedown', handlePointer);
260
+ document.removeEventListener('keydown', handleKey);
261
+ };
262
+ }, [open]);
263
+
264
+ useEffect(() => {
265
+ if (typeof window === 'undefined') return;
266
+ function readActual() {
267
+ const cs = window.getComputedStyle(document.documentElement);
268
+ const prose = cs.getPropertyValue('--prose-font-size').trim();
269
+ const code = cs.getPropertyValue('--code-font-size').trim();
270
+ if (prose && code) setRenderedSizes({ prose, code });
271
+ else setRenderedSizes(null);
272
+ }
273
+ readActual();
274
+ if (typeof window.matchMedia !== 'function') return;
275
+ const mq = window.matchMedia('(max-width: 720px)');
276
+ mq.addEventListener('change', readActual);
277
+ return () => mq.removeEventListener('change', readActual);
278
+ }, [prefs.proseSize, prefs.codeSize]);
279
+
280
+ function handleLangSelect(next: Lang) {
281
+ writeStoredLang(next);
282
+ if (next === currentLang) return;
283
+ try {
284
+ sessionStorage.setItem(SETTINGS_REOPEN_KEY, '1');
285
+ } catch {
286
+ /* sessionStorage unavailable; popup will simply close. */
287
+ }
288
+ router.push(replaceLangInPath(pathname, next));
289
+ }
290
+
291
+ // Free reading toggles destructively wipe progress, so enabling it from a
292
+ // non-empty state asks for inline confirmation first. Emptiness is detected
293
+ // through progress.ts helpers — no completed lessons and no furthest pointer.
294
+ function progressIsEmpty(): boolean {
295
+ return getCompletedCount(getProgress()) === 0 && getFurthestKey() === null;
296
+ }
297
+
298
+ function handleFreeReadingToggle() {
299
+ if (progressDisabled) {
300
+ // Turning the mode off never resets anything, so no confirmation.
301
+ setConfirmingReset(false);
302
+ setProgressDisabled(false);
303
+ return;
304
+ }
305
+ if (progressIsEmpty()) {
306
+ setProgressDisabled(true);
307
+ return;
308
+ }
309
+ setConfirmingReset(true);
310
+ }
311
+
312
+ function confirmFreeReading() {
313
+ setConfirmingReset(false);
314
+ setProgressDisabled(true);
315
+ }
316
+
317
+ function cancelFreeReading() {
318
+ setConfirmingReset(false);
319
+ }
320
+
321
+ const proseSizeLabel = renderedSizes?.prose ?? `${PROSE_SIZE_PX[prefs.proseSize]}px`;
322
+ const codeSizeLabel = renderedSizes?.code ?? `${CODE_SIZE_PX[prefs.codeSize]}px`;
323
+
324
+ // ProgressModeProvider seeds `disabled` synchronously from the <html>
325
+ // attribute, so the client's first render can disagree with the server, which
326
+ // always emits `false`. Gate the switch's rendered state on `mounted` so the
327
+ // SSR markup and the initial hydration render match; the real value lands on
328
+ // the post-mount re-render. Without this the toggle stays visually off after a
329
+ // reload despite free-reading being active (React 18 keeps the server attribute
330
+ // on a hydration mismatch and never patches it).
331
+ const freeReadingActive = mounted && progressDisabled;
332
+
333
+ const overlayAndPopover = (
334
+ <>
335
+ {open && (
336
+ <button
337
+ type="button"
338
+ className={styles.overlay}
339
+ aria-label={t.close}
340
+ onClick={() => setOpen(false)}
341
+ />
342
+ )}
343
+ <div
344
+ ref={popoverRef}
345
+ id={popoverId}
346
+ role="dialog"
347
+ aria-label={t.settingsLabel}
348
+ className={styles.popover}
349
+ data-open={open ? 'true' : 'false'}
350
+ hidden={!open}
351
+ >
352
+ <div className={styles.grabber} aria-hidden="true" />
353
+ <header className={styles.head}>
354
+ <div className={styles.headTitles}>
355
+ <div className={styles.eyebrow}>{t.settingsEyebrow}</div>
356
+ <h2 className={styles.title}>{t.settingsLabel}</h2>
357
+ </div>
358
+ <button
359
+ type="button"
360
+ className={styles.closeBtn}
361
+ onClick={() => setOpen(false)}
362
+ aria-label={t.close}
363
+ >
364
+ <CloseIcon />
365
+ </button>
366
+ </header>
367
+
368
+ <div className={styles.body}>
369
+ <div className={styles.row}>
370
+ <span className={styles.rowLabel}>{t.settingsThemeSection}</span>
371
+ <div className={styles.seg} role="radiogroup" aria-label={t.settingsThemeSection}>
372
+ {THEME_PREFERENCES.map((value) => {
373
+ const active = themePreference === value;
374
+ const label = t[THEME_LABEL_KEYS[value]];
375
+ const Icon = THEME_ICONS[value];
376
+ return (
377
+ <button
378
+ key={value}
379
+ type="button"
380
+ role="radio"
381
+ aria-checked={active}
382
+ aria-label={label}
383
+ className={`${styles.segOpt} ${styles.segTheme}`}
384
+ data-active={active ? 'true' : 'false'}
385
+ data-theme-pref={value}
386
+ onClick={() => setThemePreference(value)}
387
+ >
388
+ <span className={styles.segThemeIcon} aria-hidden="true">
389
+ <Icon />
390
+ </span>
391
+ <span>{label}</span>
392
+ </button>
393
+ );
394
+ })}
395
+ </div>
396
+ </div>
397
+
398
+ <div className={styles.row}>
399
+ <span className={styles.rowLabel}>{t.language}</span>
400
+ <div className={styles.seg} role="radiogroup" aria-label={t.language}>
401
+ {LANGS.map((value) => {
402
+ const active = currentLang === value;
403
+ const label = LANG_LABELS[value];
404
+ return (
405
+ <button
406
+ key={value}
407
+ type="button"
408
+ role="radio"
409
+ aria-checked={active}
410
+ aria-label={label}
411
+ className={`${styles.segOpt} ${styles.segLang}`}
412
+ data-active={active ? 'true' : 'false'}
413
+ data-lang-pref={value}
414
+ onClick={() => handleLangSelect(value)}
415
+ >
416
+ <span className={styles.segLangBadge}>{value.toUpperCase()}</span>
417
+ <span>{label}</span>
418
+ </button>
419
+ );
420
+ })}
421
+ </div>
422
+ </div>
423
+
424
+ <div className={styles.divider} />
425
+
426
+ <section className={styles.group}>
427
+ <h3 className={styles.groupTitle}>{t.readingPrefsProseSection}</h3>
428
+ <div
429
+ className={styles.preview}
430
+ style={{
431
+ fontFamily: 'var(--prose-font-active, var(--font-serif))',
432
+ fontSize: 'var(--prose-font-size, 16px)',
433
+ }}
434
+ >
435
+ {t.readingPrefsPreviewProse}
436
+ </div>
437
+
438
+ <div className={styles.row}>
439
+ <span className={styles.rowLabel}>{t.readingPrefsFont}</span>
440
+ <div
441
+ className={styles.seg}
442
+ role="radiogroup"
443
+ aria-label={`${t.readingPrefsProseSection} — ${t.readingPrefsFont}`}
444
+ >
445
+ {PROSE_FONTS.map((value) => {
446
+ const active = prefs.proseFont === value;
447
+ const label = t[PROSE_FONT_LABEL_KEYS[value]];
448
+ return (
449
+ <button
450
+ key={value}
451
+ type="button"
452
+ role="radio"
453
+ aria-checked={active}
454
+ aria-label={label}
455
+ className={styles.segOpt}
456
+ data-active={active ? 'true' : 'false'}
457
+ data-prose-font={value}
458
+ onClick={() => setProseFont(value)}
459
+ >
460
+ {label}
461
+ </button>
462
+ );
463
+ })}
464
+ </div>
465
+ </div>
466
+
467
+ <div className={styles.row}>
468
+ <span className={styles.rowLabel}>{t.readingPrefsSize}</span>
469
+ <div className={styles.sizeControl}>
470
+ <div className={styles.sizeControlDesktop}>
471
+ <SizeStepper
472
+ kind="prose"
473
+ value={prefs.proseSize}
474
+ pxLabel={proseSizeLabel}
475
+ onStep={setProseSize}
476
+ decreaseLabel={t.readingPrefsDecrease}
477
+ increaseLabel={t.readingPrefsIncrease}
478
+ />
479
+ </div>
480
+ <div
481
+ className={`${styles.seg} ${styles.segSizes} ${styles.sizeControlMobile}`}
482
+ role="radiogroup"
483
+ aria-label={`${t.readingPrefsProseSection} — ${t.readingPrefsSize}`}
484
+ >
485
+ {SIZE_STEPS.map((step) => {
486
+ const active = prefs.proseSize === step;
487
+ const px = PROSE_SIZE_PX[step];
488
+ return (
489
+ <button
490
+ key={step}
491
+ type="button"
492
+ role="radio"
493
+ aria-checked={active}
494
+ aria-label={`${px}px`}
495
+ className={`${styles.segOpt} ${styles.segMono}`}
496
+ data-active={active ? 'true' : 'false'}
497
+ onClick={() => setProseSize(step)}
498
+ >
499
+ {px}
500
+ </button>
501
+ );
502
+ })}
503
+ </div>
504
+ </div>
505
+ </div>
506
+ </section>
507
+
508
+ <div className={styles.divider} />
509
+
510
+ <section className={styles.group}>
511
+ <h3 className={styles.groupTitle}>{t.readingPrefsCodeSection}</h3>
512
+ <div
513
+ className={`${styles.preview} ${styles.previewCode}`}
514
+ style={{
515
+ fontFamily: 'var(--code-font-active, var(--font-mono))',
516
+ fontSize: 'var(--code-font-size, 14px)',
517
+ }}
518
+ >
519
+ {t.readingPrefsPreviewCode}
520
+ </div>
521
+
522
+ <div className={styles.row}>
523
+ <span className={styles.rowLabel}>{t.readingPrefsFont}</span>
524
+ <div
525
+ className={styles.seg}
526
+ role="radiogroup"
527
+ aria-label={`${t.readingPrefsCodeSection} — ${t.readingPrefsFont}`}
528
+ >
529
+ {CODE_FONTS.map((value) => {
530
+ const active = prefs.codeFont === value;
531
+ const label = t[CODE_FONT_LABEL_KEYS[value]];
532
+ return (
533
+ <button
534
+ key={value}
535
+ type="button"
536
+ role="radio"
537
+ aria-checked={active}
538
+ aria-label={label}
539
+ className={`${styles.segOpt} ${styles.segMono}`}
540
+ data-active={active ? 'true' : 'false'}
541
+ data-code-font={value}
542
+ onClick={() => setCodeFont(value)}
543
+ >
544
+ {label}
545
+ </button>
546
+ );
547
+ })}
548
+ </div>
549
+ </div>
550
+
551
+ <div className={styles.row}>
552
+ <span className={styles.rowLabel}>{t.readingPrefsSize}</span>
553
+ <div className={styles.sizeControl}>
554
+ <div className={styles.sizeControlDesktop}>
555
+ <SizeStepper
556
+ kind="code"
557
+ value={prefs.codeSize}
558
+ pxLabel={codeSizeLabel}
559
+ onStep={setCodeSize}
560
+ decreaseLabel={t.readingPrefsDecrease}
561
+ increaseLabel={t.readingPrefsIncrease}
562
+ />
563
+ </div>
564
+ <div
565
+ className={`${styles.seg} ${styles.segSizes} ${styles.sizeControlMobile}`}
566
+ role="radiogroup"
567
+ aria-label={`${t.readingPrefsCodeSection} — ${t.readingPrefsSize}`}
568
+ >
569
+ {SIZE_STEPS.map((step) => {
570
+ const active = prefs.codeSize === step;
571
+ const px = CODE_SIZE_PX[step];
572
+ return (
573
+ <button
574
+ key={step}
575
+ type="button"
576
+ role="radio"
577
+ aria-checked={active}
578
+ aria-label={`${px}px`}
579
+ className={`${styles.segOpt} ${styles.segMono}`}
580
+ data-active={active ? 'true' : 'false'}
581
+ onClick={() => setCodeSize(step)}
582
+ >
583
+ {px}
584
+ </button>
585
+ );
586
+ })}
587
+ </div>
588
+ </div>
589
+ </div>
590
+ </section>
591
+
592
+ <div className={styles.divider} />
593
+
594
+ <section className={styles.group} data-free-reading-section>
595
+ <h3 className={styles.groupTitle}>{t.freeReadingSection}</h3>
596
+ <div className={styles.freeReadingRow}>
597
+ <div className={styles.freeReadingText}>
598
+ <span className={styles.freeReadingTitle}>{t.freeReadingTitle}</span>
599
+ <span className={styles.freeReadingDesc}>{t.freeReadingDesc}</span>
600
+ </div>
601
+ <button
602
+ type="button"
603
+ role="switch"
604
+ aria-checked={freeReadingActive}
605
+ aria-label={t.freeReadingTitle}
606
+ className={styles.switch}
607
+ data-free-reading-toggle
608
+ data-active={freeReadingActive ? 'true' : 'false'}
609
+ onClick={handleFreeReadingToggle}
610
+ >
611
+ <span className={styles.switchThumb} aria-hidden="true" />
612
+ </button>
613
+ </div>
614
+ {confirmingReset ? (
615
+ <div className={styles.confirm} role="alert" data-free-reading-confirm-prompt>
616
+ <span className={styles.confirmText}>{t.freeReadingResetWarning}</span>
617
+ <div className={styles.confirmActions}>
618
+ <button
619
+ type="button"
620
+ className={styles.confirmBtn}
621
+ data-free-reading-cancel
622
+ onClick={cancelFreeReading}
623
+ >
624
+ {t.freeReadingResetCancel}
625
+ </button>
626
+ <button
627
+ type="button"
628
+ className={`${styles.confirmBtn} ${styles.confirmBtnDanger}`}
629
+ data-free-reading-confirm
630
+ onClick={confirmFreeReading}
631
+ >
632
+ {t.freeReadingResetConfirm}
633
+ </button>
634
+ </div>
635
+ </div>
636
+ ) : null}
637
+ </section>
638
+
639
+ {repoUrl ? (
640
+ <>
641
+ <div className={`${styles.divider} ${styles.resourcesDivider}`} />
642
+ <div className={`${styles.row} ${styles.resourcesRow}`}>
643
+ <span className={styles.rowLabel}>{t.settingsResourcesLabel}</span>
644
+ <a
645
+ href={repoUrl}
646
+ target="_blank"
647
+ rel="noreferrer noopener"
648
+ className={styles.resourceLink}
649
+ >
650
+ <span className={styles.resourceIcon} aria-hidden="true">
651
+ <GitHubIcon />
652
+ </span>
653
+ <span className={styles.resourceText}>
654
+ <span className={styles.resourceTitle}>{t.githubRepo}</span>
655
+ <span className={styles.resourceSub}>{t.githubRepoSub}</span>
656
+ </span>
657
+ <span className={styles.resourceArrow} aria-hidden="true">
658
+
659
+ </span>
660
+ </a>
661
+ </div>
662
+ </>
663
+ ) : null}
664
+ </div>
665
+ </div>
666
+ </>
667
+ );
668
+
669
+ return (
670
+ <div className={styles.wrapper} ref={wrapperRef}>
671
+ <button
672
+ type="button"
673
+ className={styles.trigger}
674
+ aria-label={t.settingsLabel}
675
+ title={t.settingsLabel}
676
+ aria-haspopup="dialog"
677
+ aria-expanded={open}
678
+ aria-controls={popoverId}
679
+ onClick={() => setOpen((prev) => !prev)}
680
+ >
681
+ <SettingsIcon />
682
+ </button>
683
+ {mounted && isMobile
684
+ ? createPortal(overlayAndPopover, document.body)
685
+ : overlayAndPopover}
686
+ </div>
687
+ );
688
+ }
@@ -0,0 +1 @@
1
+ export { SettingsToggle } from './SettingsToggle';