@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,481 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { useGate } from '@/components/GateProvider';
6
+ import { useProgressMode } from '@/components/ProgressModeProvider';
7
+ import { ProgressBar } from '@/components/ProgressBar';
8
+ import { GitHubIcon, HomeIcon } from '@/components/Sidebar/icons';
9
+ import type { Course, FlatLessonEntry } from '@/lib/course';
10
+ import { formatDurationShort, parseDurationMin } from '@/lib/format';
11
+ import { applyGatePainting } from '@/lib/gate-mark-script';
12
+ import type { Lang } from '@/lib/lang';
13
+ import { isCompleted, lessonKey, markCompletedAndAdvance } from '@/lib/progress';
14
+ import { useLang, useT } from '@/lib/use-i18n';
15
+ import { LockIcon } from './LockIcon';
16
+ import styles from './ProgramDrawer.module.css';
17
+
18
+ // useLayoutEffect on the client, no-op on the server — gate marking touches
19
+ // the DOM and only matters in the browser, but unconditional useLayoutEffect
20
+ // would warn during SSR.
21
+ const useIsomorphicLayoutEffect =
22
+ typeof window === 'undefined' ? useEffect : useLayoutEffect;
23
+
24
+ type ProgramDrawerProps = {
25
+ course: Course;
26
+ currentModuleId?: string;
27
+ currentSlug?: string;
28
+ /* Mobile-only "current lesson" card surfaces these three together — they
29
+ mirror the design's `dln-current` block (eyebrow + title + meta). Kept
30
+ optional because home/module-index pages have no active lesson. */
31
+ currentLessonTitle?: string;
32
+ currentLessonIndex?: number;
33
+ currentModuleTitle?: string;
34
+ isOpen: boolean;
35
+ onClose: () => void;
36
+ /* Mobile-only overlay extras. On desktop the breadcrumb header and the
37
+ bottom sidebar carry these affordances; on mobile both are gone, so the
38
+ drawer becomes the single command surface and renders them inline. */
39
+ prev?: FlatLessonEntry | null;
40
+ next?: FlatLessonEntry | null;
41
+ repoUrl?: string;
42
+ /* `lang` is also available from the i18n provider via useLang(), but the
43
+ prop wins so the drawer paints the right links during SSR before the
44
+ provider initialises on the client. */
45
+ lang?: Lang;
46
+ totalLessons?: number;
47
+ };
48
+
49
+ export function ProgramDrawer({
50
+ course,
51
+ currentModuleId,
52
+ currentSlug,
53
+ currentLessonTitle,
54
+ currentLessonIndex,
55
+ currentModuleTitle,
56
+ isOpen,
57
+ onClose,
58
+ prev,
59
+ next,
60
+ repoUrl,
61
+ lang: langProp,
62
+ totalLessons,
63
+ }: ProgramDrawerProps) {
64
+ const gate = useGate();
65
+ const { disabled } = useProgressMode();
66
+ const t = useT();
67
+ const langCtx = useLang();
68
+ const lang = langProp ?? langCtx;
69
+ // Use the shared progress map from GateProvider (single source of truth) so
70
+ // the drawer agrees with checkmark state elsewhere on the page.
71
+ const progress = gate.hydrated ? gate.progress : null;
72
+
73
+ // Default expanded set:
74
+ // • the module containing the active lesson, if any (so the user lands
75
+ // on their own context),
76
+ // • otherwise the first two modules — enough to communicate the
77
+ // accordion shape without the drawer becoming a wall of text.
78
+ const initialExpanded = useMemo(() => {
79
+ const map: Record<string, boolean> = {};
80
+ course.modules.forEach((m, i) => {
81
+ map[m.id] = currentModuleId ? m.id === currentModuleId : i < 2;
82
+ });
83
+ return map;
84
+ }, [course, currentModuleId]);
85
+
86
+ const [expanded, setExpanded] = useState<Record<string, boolean>>(initialExpanded);
87
+ const asideRef = useRef<HTMLElement | null>(null);
88
+
89
+ // Toggle `inert` on the drawer aside instead of relying on per-element
90
+ // tabindex. The gate-mark script reaches into [data-lesson-key] elements
91
+ // and strips `tabindex` on unlocked items so they regain default focus
92
+ // behavior in HomePage / ModulePage / MDX cross-lesson links — which is
93
+ // correct everywhere except inside a closed offscreen drawer. `inert`
94
+ // removes the entire subtree from the focus order regardless of what
95
+ // individual `tabindex` attributes say, so the two concerns no longer
96
+ // fight each other. useLayoutEffect runs before paint so a freshly
97
+ // closed drawer never leaks focus between render and effect.
98
+ useIsomorphicLayoutEffect(() => {
99
+ const aside = asideRef.current;
100
+ if (!aside) return;
101
+ if (isOpen) {
102
+ aside.removeAttribute('inert');
103
+ } else {
104
+ aside.setAttribute('inert', '');
105
+ }
106
+ }, [isOpen]);
107
+
108
+ // Re-seed expansion when the active module changes — opening the drawer
109
+ // from a different lesson should snap to that module.
110
+ useEffect(() => {
111
+ setExpanded(initialExpanded);
112
+ }, [initialExpanded]);
113
+
114
+ useEffect(() => {
115
+ if (!isOpen) return;
116
+ function handleKey(event: KeyboardEvent) {
117
+ if (event.key === 'Escape') onClose();
118
+ }
119
+ window.addEventListener('keydown', handleKey);
120
+ return () => window.removeEventListener('keydown', handleKey);
121
+ }, [isOpen, onClose]);
122
+
123
+ // Lock the page behind the drawer while it's open (matches the referenced
124
+ // prototype's body.style.overflow = 'hidden' behavior).
125
+ useEffect(() => {
126
+ if (!isOpen) return;
127
+ const prev = document.body.style.overflow;
128
+ document.body.style.overflow = 'hidden';
129
+ return () => {
130
+ document.body.style.overflow = prev;
131
+ };
132
+ }, [isOpen]);
133
+
134
+ const toggle = (id: string) =>
135
+ setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
136
+
137
+ // Drawer-rendered lesson rows are added to the DOM after the initial
138
+ // gate-mark inline script ran, so re-apply marking whenever the expanded
139
+ // set or progress changes. useLayoutEffect runs before paint, so locked
140
+ // rows never flash as "open".
141
+ //
142
+ // Free-reading mode: skip painting entirely. With progress cleared the
143
+ // resolved furthest index is -1, so applyGatePainting would re-lock every
144
+ // row past index 0 (and the row onClick then blocks navigation), defeating
145
+ // the feature. The freshly rendered rows carry no data-locked, so leaving
146
+ // them untouched keeps the whole course reachable — mirrors the early-exit
147
+ // in GateProvider's sync effect.
148
+ useIsomorphicLayoutEffect(() => {
149
+ if (!gate.hydrated || disabled) return;
150
+ applyGatePainting(course, gate.furthestIndex, gate.basePath, lang);
151
+ }, [gate.hydrated, gate.furthestIndex, gate.basePath, course, expanded, isOpen, lang, disabled]);
152
+
153
+ return (
154
+ <>
155
+ <div
156
+ className={styles.overlay}
157
+ data-open={isOpen ? 'true' : 'false'}
158
+ onClick={onClose}
159
+ aria-hidden="true"
160
+ />
161
+ <aside
162
+ ref={asideRef}
163
+ className={styles.drawer}
164
+ data-open={isOpen ? 'true' : 'false'}
165
+ aria-label={t.programCourse}
166
+ aria-hidden={!isOpen}
167
+ role="dialog"
168
+ aria-modal="true"
169
+ // SSR-side `inert` so the closed drawer is non-focusable before
170
+ // hydration runs the layout effect above. The effect keeps it in
171
+ // sync on subsequent open/close transitions.
172
+ {...((isOpen ? {} : { inert: '' }) as Record<string, string>)}
173
+ >
174
+ <header className={styles.header}>
175
+ <div>
176
+ <div className={styles.eyebrow}>/ contents</div>
177
+ <h2 className={styles.title}>{t.programCourse}</h2>
178
+ </div>
179
+ <button
180
+ type="button"
181
+ className={styles.close}
182
+ onClick={onClose}
183
+ aria-label={t.close}
184
+ tabIndex={isOpen ? 0 : -1}
185
+ >
186
+ <CloseIcon />
187
+ </button>
188
+ </header>
189
+
190
+ {/* Mobile-only context strip. Desktop suppresses these blocks via CSS
191
+ because the Sidebar (home / GitHub) and Header (progress / prev /
192
+ next) already carry the same affordances. */}
193
+ {totalLessons ? (
194
+ <div className={styles.contextProgress}>
195
+ <ProgressBar total={totalLessons} lang={lang} />
196
+ </div>
197
+ ) : null}
198
+
199
+ {currentModuleId && currentSlug && (prev || next || currentLessonTitle) ? (
200
+ <div className={styles.contextNav} aria-label={t.lessonNavLabel}>
201
+ {currentLessonTitle ? (
202
+ <div className={styles.currentCard}>
203
+ <div className={styles.currentEyebrow}>{t.currentLessonEyebrow}</div>
204
+ <div className={styles.currentTitle}>{currentLessonTitle}</div>
205
+ {typeof currentLessonIndex === 'number' ? (
206
+ <div className={styles.currentMeta}>
207
+ {t.currentLessonNumberPrefix}{' '}
208
+ {String(currentLessonIndex).padStart(2, '0')}
209
+ {currentModuleTitle ? ` · ${currentModuleTitle}` : ''}
210
+ </div>
211
+ ) : null}
212
+ </div>
213
+ ) : null}
214
+ <div className={styles.navRow}>
215
+ {prev ? (
216
+ <Link
217
+ href={`/${lang}/${prev.moduleId}/${prev.lesson.slug}`}
218
+ className={`${styles.navCard} ${styles.navPrev}`}
219
+ onClick={onClose}
220
+ tabIndex={isOpen ? 0 : -1}
221
+ aria-label={t.prevLessonAria}
222
+ >
223
+ <span className={styles.navChevron} aria-hidden="true">
224
+ <ChevronLeftIcon />
225
+ </span>
226
+ <span className={styles.navMeta}>
227
+ <span className={styles.navLabel}>{t.prevLessonShort}</span>
228
+ <span className={styles.navTitle}>{prev.lesson.title}</span>
229
+ </span>
230
+ </Link>
231
+ ) : (
232
+ <span
233
+ className={`${styles.navCard} ${styles.navPrev} ${styles.navDisabled}`}
234
+ aria-hidden="true"
235
+ >
236
+ <span className={styles.navChevron}>
237
+ <ChevronLeftIcon />
238
+ </span>
239
+ <span className={styles.navMeta}>
240
+ <span className={styles.navLabel}>{t.firstLessonTitle}</span>
241
+ </span>
242
+ </span>
243
+ )}
244
+ {next ? (
245
+ <Link
246
+ href={`/${lang}/${next.moduleId}/${next.lesson.slug}`}
247
+ className={`${styles.navCard} ${styles.navNext}`}
248
+ onClick={() => {
249
+ // Free-reading mode: the card stays visible and navigates,
250
+ // but must never record progress (mirrors the guard in
251
+ // HeaderLessonNav / LessonNav — this is the mobile drawer's
252
+ // equivalent next-lesson write site).
253
+ if (!disabled) {
254
+ markCompletedAndAdvance(
255
+ gate.course,
256
+ lessonKey(currentModuleId, currentSlug),
257
+ );
258
+ }
259
+ onClose();
260
+ }}
261
+ tabIndex={isOpen ? 0 : -1}
262
+ aria-label={t.nextLessonAria}
263
+ >
264
+ <span className={styles.navMeta}>
265
+ <span className={styles.navLabel}>{t.nextLessonShort}</span>
266
+ <span className={styles.navTitle}>{next.lesson.title}</span>
267
+ </span>
268
+ <span className={styles.navChevron} aria-hidden="true">
269
+ <ChevronRightIcon />
270
+ </span>
271
+ </Link>
272
+ ) : (
273
+ <span
274
+ className={`${styles.navCard} ${styles.navNext} ${styles.navDisabled}`}
275
+ aria-hidden="true"
276
+ >
277
+ <span className={styles.navMeta}>
278
+ <span className={styles.navLabel}>{t.lastLessonTitle}</span>
279
+ </span>
280
+ <span className={styles.navChevron}>
281
+ <ChevronRightIcon />
282
+ </span>
283
+ </span>
284
+ )}
285
+ </div>
286
+ </div>
287
+ ) : null}
288
+
289
+ <nav className={styles.body} aria-label={t.moduleListLabel}>
290
+ <ol className={styles.modules}>
291
+ {course.modules.map((mod, mIndex) => {
292
+ const total = mod.lessons.length;
293
+ const doneCount =
294
+ progress === null
295
+ ? 0
296
+ : mod.lessons.filter((l) =>
297
+ isCompleted(progress, lessonKey(mod.id, l.slug)),
298
+ ).length;
299
+ const isComplete = doneCount === total && total > 0;
300
+ const isOpenModule = !!expanded[mod.id];
301
+ return (
302
+ <li key={mod.id} className={styles.module}>
303
+ <button
304
+ type="button"
305
+ className={styles.moduleHead}
306
+ onClick={() => toggle(mod.id)}
307
+ aria-expanded={isOpenModule}
308
+ tabIndex={isOpen ? 0 : -1}
309
+ >
310
+ <span className={styles.moduleNum}>
311
+ {String(mIndex + 1).padStart(2, '0')}
312
+ </span>
313
+ <span className={styles.moduleTitle}>{mod.title}</span>
314
+ <span
315
+ className={styles.moduleBadge}
316
+ data-complete={isComplete ? 'true' : 'false'}
317
+ // Free-reading mode hides this done/total counter via the
318
+ // global `data-progress-disabled` CSS rule. The hook is a
319
+ // stable data attribute (not the hashed CSS-module class),
320
+ // matching how every other progress widget is hidden.
321
+ data-module-badge
322
+ >
323
+ {doneCount}/{total}
324
+ </span>
325
+ <span className={styles.moduleChevron} aria-hidden="true">
326
+ {isOpenModule ? '−' : '+'}
327
+ </span>
328
+ </button>
329
+ {isOpenModule && (
330
+ <ol className={styles.lessons}>
331
+ {mod.lessons.map((lesson, lIndex) => {
332
+ const key = lessonKey(mod.id, lesson.slug);
333
+ const done =
334
+ progress !== null && isCompleted(progress, key);
335
+ const isCurrent =
336
+ mod.id === currentModuleId && lesson.slug === currentSlug;
337
+ const durMin = parseDurationMin(lesson.duration);
338
+ return (
339
+ <li key={lesson.slug} className={styles.lesson}>
340
+ <Link
341
+ href={`/${lang}/${mod.id}/${lesson.slug}`}
342
+ className={styles.lessonLink}
343
+ aria-current={isCurrent ? 'page' : undefined}
344
+ data-completed={done ? 'true' : undefined}
345
+ data-current={isCurrent ? 'true' : undefined}
346
+ data-lesson-key={key}
347
+ onClick={(e) => {
348
+ if (
349
+ e.currentTarget.getAttribute('data-locked') === 'true'
350
+ ) {
351
+ e.preventDefault();
352
+ return;
353
+ }
354
+ onClose();
355
+ }}
356
+ tabIndex={isOpen ? 0 : -1}
357
+ title={t.lessonLockTitle}
358
+ // Why: the gate-mark inline script (runs before
359
+ // hydration) strips `tabindex` from unlocked
360
+ // rows so they pick up the default focus order
361
+ // when the drawer opens. Hydration would
362
+ // otherwise warn that the SSR attribute (-1)
363
+ // disagrees with the post-script DOM.
364
+ suppressHydrationWarning
365
+ >
366
+ <span className={styles.lessonNum}>
367
+ {String(lIndex + 1).padStart(2, '0')}
368
+ </span>
369
+ <span className={styles.lessonTitle}>
370
+ {lesson.title}
371
+ </span>
372
+ <span className={styles.lessonMeta} aria-hidden="true">
373
+ <span className={styles.metaOpen}>
374
+ {done ? (
375
+ <span className={styles.lessonCheck}>✓</span>
376
+ ) : (
377
+ formatDurationShort(durMin, lang)
378
+ )}
379
+ </span>
380
+ <span className={styles.metaLocked}>
381
+ <LockIcon />
382
+ </span>
383
+ </span>
384
+ </Link>
385
+ </li>
386
+ );
387
+ })}
388
+ </ol>
389
+ )}
390
+ </li>
391
+ );
392
+ })}
393
+ </ol>
394
+ </nav>
395
+
396
+ {/* Mobile-only secondary actions. Same hide-on-desktop rule as the
397
+ context strip above. */}
398
+ {repoUrl ? (
399
+ <div className={styles.contextFooter}>
400
+ <Link
401
+ href={`/${lang}`}
402
+ className={styles.footerLink}
403
+ onClick={onClose}
404
+ tabIndex={isOpen ? 0 : -1}
405
+ >
406
+ <HomeIcon />
407
+ <span>{t.home}</span>
408
+ </Link>
409
+ <a
410
+ className={styles.footerLink}
411
+ href={repoUrl}
412
+ target="_blank"
413
+ rel="noreferrer noopener"
414
+ tabIndex={isOpen ? 0 : -1}
415
+ >
416
+ <GitHubIcon />
417
+ <span>{t.githubRepo}</span>
418
+ </a>
419
+ </div>
420
+ ) : null}
421
+ </aside>
422
+ </>
423
+ );
424
+ }
425
+
426
+ function CloseIcon() {
427
+ return (
428
+ <svg
429
+ width="16"
430
+ height="16"
431
+ viewBox="0 0 24 24"
432
+ fill="none"
433
+ stroke="currentColor"
434
+ strokeWidth="1.8"
435
+ strokeLinecap="round"
436
+ strokeLinejoin="round"
437
+ aria-hidden="true"
438
+ focusable="false"
439
+ >
440
+ <path d="M6 6l12 12M18 6 6 18" />
441
+ </svg>
442
+ );
443
+ }
444
+
445
+ function ChevronLeftIcon() {
446
+ return (
447
+ <svg
448
+ width="14"
449
+ height="14"
450
+ viewBox="0 0 24 24"
451
+ fill="none"
452
+ stroke="currentColor"
453
+ strokeWidth="2"
454
+ strokeLinecap="round"
455
+ strokeLinejoin="round"
456
+ aria-hidden="true"
457
+ focusable="false"
458
+ >
459
+ <path d="M15 6l-6 6 6 6" />
460
+ </svg>
461
+ );
462
+ }
463
+
464
+ function ChevronRightIcon() {
465
+ return (
466
+ <svg
467
+ width="14"
468
+ height="14"
469
+ viewBox="0 0 24 24"
470
+ fill="none"
471
+ stroke="currentColor"
472
+ strokeWidth="2"
473
+ strokeLinecap="round"
474
+ strokeLinejoin="round"
475
+ aria-hidden="true"
476
+ focusable="false"
477
+ >
478
+ <path d="M9 6l6 6-6 6" />
479
+ </svg>
480
+ );
481
+ }
@@ -0,0 +1 @@
1
+ export { ProgramDrawer } from './ProgramDrawer';
@@ -0,0 +1,46 @@
1
+ .bar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--space-3);
5
+ flex: 0 0 auto;
6
+ }
7
+
8
+ .label {
9
+ font-family: var(--font-mono);
10
+ font-size: var(--font-size-xs);
11
+ color: var(--content-secondary);
12
+ letter-spacing: 0.02em;
13
+ white-space: nowrap;
14
+ min-width: 14ch;
15
+ text-align: right;
16
+ font-variant-numeric: tabular-nums;
17
+ }
18
+
19
+ .track {
20
+ display: block;
21
+ width: 96px;
22
+ height: 6px;
23
+ background-color: var(--bg-muted);
24
+ border-radius: var(--radius-pill);
25
+ overflow: hidden;
26
+ }
27
+
28
+ .fill {
29
+ display: block;
30
+ height: 100%;
31
+ background-color: var(--accent-main);
32
+ border-radius: inherit;
33
+ transition: width 200ms ease;
34
+ }
35
+
36
+ @media (max-width: 1023px) {
37
+ .track {
38
+ width: 64px;
39
+ }
40
+ }
41
+
42
+ @media (max-width: 767px) {
43
+ .track {
44
+ display: none;
45
+ }
46
+ }
@@ -0,0 +1,45 @@
1
+ import { getDict } from '@/lib/i18n';
2
+ import type { Lang } from '@/lib/lang';
3
+ import styles from './ProgressBar.module.css';
4
+
5
+ type ProgressBarProps = {
6
+ total: number;
7
+ lang: Lang;
8
+ };
9
+
10
+ /**
11
+ * Global course progress bar in the header. Pure server-rendered shape — the
12
+ * gate-paint inline script reads localStorage before first paint and rewrites
13
+ * count / percent / bar-width / ARIA attributes directly via the
14
+ * `data-progress-*` slots. No React state, no useEffect, no hydration flash.
15
+ */
16
+ export function ProgressBar({ total, lang }: ProgressBarProps) {
17
+ const t = getDict(lang);
18
+ return (
19
+ <div
20
+ className={styles.bar}
21
+ role="progressbar"
22
+ aria-label={t.progressBarAriaLabel}
23
+ aria-valuemin={0}
24
+ aria-valuemax={total}
25
+ aria-valuenow={0}
26
+ aria-valuetext={`0 ${t.progressAriaConnector} ${total} (0%)`}
27
+ data-progress-scope="global"
28
+ data-progress-state="not-started"
29
+ suppressHydrationWarning
30
+ >
31
+ <span className={styles.label}>
32
+ <span data-progress-count suppressHydrationWarning>0</span> / {total} (
33
+ <span data-progress-pct suppressHydrationWarning>0</span>%)
34
+ </span>
35
+ <span className={styles.track} aria-hidden="true">
36
+ <span
37
+ className={styles.fill}
38
+ data-progress-bar
39
+ style={{ width: '0%' }}
40
+ suppressHydrationWarning
41
+ />
42
+ </span>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1 @@
1
+ export { ProgressBar } from './ProgressBar';
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import {
12
+ applyProgressDisabled,
13
+ PROGRESS_DISABLED_ATTR,
14
+ PROGRESS_DISABLED_STORAGE_KEY,
15
+ readProgressDisabled,
16
+ writeProgressDisabled,
17
+ } from '@/lib/progress-mode';
18
+ import { clearProgress } from '@/lib/progress';
19
+
20
+ type ProgressModeContextValue = {
21
+ disabled: boolean;
22
+ setDisabled: (value: boolean) => void;
23
+ };
24
+
25
+ const ProgressModeContext = createContext<ProgressModeContextValue | null>(null);
26
+
27
+ /**
28
+ * Reads the current flag from the <html> attribute set synchronously by the
29
+ * inline init script. Unlike ThemeProvider's lazy default-then-sync approach we
30
+ * seed straight from the attribute so a previously-locked lesson opened in
31
+ * free-reading mode never flashes its locked interstitial during hydration.
32
+ */
33
+ function readFromHtml(): boolean {
34
+ if (typeof document === 'undefined') return false;
35
+ return document.documentElement.getAttribute(PROGRESS_DISABLED_ATTR) === 'true';
36
+ }
37
+
38
+ export function ProgressModeProvider({ children }: { children: ReactNode }) {
39
+ const [disabled, setState] = useState<boolean>(readFromHtml);
40
+
41
+ useEffect(() => {
42
+ // Re-sync once mounted in case SSR seeded `false` before the attribute was
43
+ // observable (e.g. lazy hydration); keeps state aligned with <html>.
44
+ setState(readFromHtml());
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ function handleStorage(event: StorageEvent) {
49
+ if (event.key !== PROGRESS_DISABLED_STORAGE_KEY) return;
50
+ // Cross-tab sync only: mirror the flag and the attribute. We deliberately
51
+ // do NOT call clearProgress here — the reset already happened in the tab
52
+ // that flipped the toggle, so re-running it would double-clear.
53
+ const next = readProgressDisabled();
54
+ applyProgressDisabled(next);
55
+ setState(next);
56
+ }
57
+ window.addEventListener('storage', handleStorage);
58
+ return () => window.removeEventListener('storage', handleStorage);
59
+ }, []);
60
+
61
+ const setDisabled = useCallback((value: boolean) => {
62
+ // Order matters: flip `disabled` (attribute + storage + state) BEFORE
63
+ // clearing progress. clearProgress dispatches PROGRESS_CHANGE_EVENT, which
64
+ // GateProvider handles via refresh(); by then `disabled` is already true so
65
+ // the effect takes the cleanup branch instead of repainting (no flash).
66
+ applyProgressDisabled(value);
67
+ writeProgressDisabled(value);
68
+ setState(value);
69
+ if (value) {
70
+ clearProgress();
71
+ }
72
+ }, []);
73
+
74
+ return (
75
+ <ProgressModeContext.Provider value={{ disabled, setDisabled }}>
76
+ {children}
77
+ </ProgressModeContext.Provider>
78
+ );
79
+ }
80
+
81
+ export function useProgressMode(): ProgressModeContextValue {
82
+ const ctx = useContext(ProgressModeContext);
83
+ if (!ctx) {
84
+ throw new Error('useProgressMode must be used inside <ProgressModeProvider>');
85
+ }
86
+ return ctx;
87
+ }
@@ -0,0 +1 @@
1
+ export { ProgressModeProvider, useProgressMode } from './ProgressModeProvider';