@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,367 @@
1
+ /* Locked-lesson interstitial.
2
+ Built on the same editorial-technical chassis as ModulePage / HomePage hero:
3
+ asymmetric two-column hero, mono eyebrows, brutalist accent buttons, mono
4
+ numeric stats. The user lands here when navigating directly to a lesson
5
+ they haven't unlocked yet — by mirroring the rest of the course the page
6
+ feels like a continuation, not an error screen. */
7
+
8
+ .page {
9
+ --li-space-7: 32px;
10
+ --li-space-8: 40px;
11
+ --li-space-9: 56px;
12
+ --li-space-10: 72px;
13
+ --li-space-11: 96px;
14
+
15
+ --li-fs-meta: 12px;
16
+ --li-fs-small: 13px;
17
+ --li-fs-body: 15px;
18
+ --li-fs-lead: 18px;
19
+ --li-fs-display: 64px;
20
+
21
+ --li-r-1: 2px;
22
+ --li-r-2: 4px;
23
+ --li-r-3: 8px;
24
+
25
+ padding: var(--li-space-9) var(--li-space-7) var(--li-space-11);
26
+ max-width: 1280px;
27
+ width: 100%;
28
+ margin: 0 auto;
29
+ font-size: var(--li-fs-body);
30
+ line-height: 1.55;
31
+ }
32
+
33
+ /* ---------- Hero ---------- */
34
+
35
+ .hero {
36
+ display: grid;
37
+ grid-template-columns: 1fr 320px;
38
+ gap: var(--li-space-9);
39
+ align-items: start;
40
+ }
41
+
42
+ .heroText {
43
+ min-width: 0;
44
+ }
45
+
46
+ .eyebrow {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: var(--space-3);
50
+ font-family: var(--font-mono), ui-monospace, monospace;
51
+ font-size: var(--li-fs-meta);
52
+ color: var(--content-secondary);
53
+ text-transform: uppercase;
54
+ letter-spacing: 0.06em;
55
+ margin-bottom: var(--space-5);
56
+ flex-wrap: wrap;
57
+ }
58
+
59
+ /* Locked-state chip — borrows the boxed-eyebrow look from ModulePage's
60
+ "01 / 09" badge but flips it to the notice palette so the gate is the
61
+ first thing the eye finds. */
62
+ .eyebrowBadge {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ gap: 6px;
66
+ font-size: 11px;
67
+ color: var(--accent-notice);
68
+ font-weight: 600;
69
+ padding: 3px 8px;
70
+ background: var(--accent-notice-soft);
71
+ border: 1px solid var(--accent-notice);
72
+ border-radius: var(--li-r-1);
73
+ }
74
+
75
+ .eyebrowDot {
76
+ color: var(--bg-stroke-strong);
77
+ }
78
+
79
+ .title {
80
+ font-size: var(--li-fs-display);
81
+ line-height: 1;
82
+ letter-spacing: -0.03em;
83
+ font-weight: 700;
84
+ margin: 0 0 var(--space-5);
85
+ color: var(--content-primary);
86
+ text-wrap: balance;
87
+ max-width: 640px;
88
+ }
89
+
90
+ .desc {
91
+ font-size: var(--li-fs-lead);
92
+ line-height: 1.5;
93
+ color: var(--content-secondary);
94
+ margin: 0 0 var(--li-space-7);
95
+ max-width: 580px;
96
+ text-wrap: pretty;
97
+ }
98
+
99
+ /* "/ вы пытались открыть" — a thin editorial card pinned under the lead,
100
+ echoing the way ModulePage labels its lesson list. Helps the user
101
+ confirm they landed where they intended even though it's locked. */
102
+ .targetCard {
103
+ margin: 0 0 var(--li-space-7);
104
+ padding: var(--space-4) var(--space-5);
105
+ background: var(--bg-subtle);
106
+ border: 1px solid var(--bg-stroke);
107
+ border-left: 2px solid var(--accent-notice);
108
+ border-radius: var(--li-r-2);
109
+ max-width: 580px;
110
+ }
111
+
112
+ .targetLabel {
113
+ font-family: var(--font-mono), ui-monospace, monospace;
114
+ font-size: 11px;
115
+ color: var(--content-tertiary);
116
+ text-transform: uppercase;
117
+ letter-spacing: 0.08em;
118
+ margin: 0 0 var(--space-2);
119
+ }
120
+
121
+ .targetTitle {
122
+ margin: 0;
123
+ font-size: var(--li-fs-body);
124
+ color: var(--content-primary);
125
+ }
126
+
127
+ .targetModule {
128
+ color: var(--content-secondary);
129
+ }
130
+
131
+ .targetSep {
132
+ color: var(--content-tertiary);
133
+ }
134
+
135
+ .targetLesson {
136
+ color: var(--content-primary);
137
+ font-weight: 600;
138
+ }
139
+
140
+ .ctaRow {
141
+ display: flex;
142
+ gap: var(--space-3);
143
+ flex-wrap: wrap;
144
+ align-items: center;
145
+ }
146
+
147
+ /* ---------- Buttons (mirror ModulePage/HomePage) ---------- */
148
+
149
+ .btn {
150
+ display: inline-flex;
151
+ align-items: center;
152
+ gap: var(--space-3);
153
+ padding: 12px var(--space-5);
154
+ border-radius: var(--li-r-2);
155
+ font-size: var(--li-fs-body);
156
+ font-weight: 600;
157
+ border: 1px solid transparent;
158
+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
159
+ white-space: nowrap;
160
+ text-decoration: none;
161
+ font-family: inherit;
162
+ cursor: pointer;
163
+ }
164
+
165
+ .btnPrimary {
166
+ background: var(--accent-main);
167
+ color: var(--content-inverse);
168
+ border-color: var(--accent-main);
169
+ }
170
+
171
+ /* Pin the foreground on hover — the missing color override was the bug:
172
+ --content-inverse (cream) was inheriting from the parent and falling onto
173
+ --accent-main-hover (deep moss), wiping out the label. */
174
+ .btnPrimary:hover {
175
+ background: var(--accent-main-hover);
176
+ border-color: var(--accent-main-hover);
177
+ color: var(--content-inverse);
178
+ }
179
+
180
+ .btnGhost {
181
+ background: transparent;
182
+ color: var(--content-secondary);
183
+ padding: 12px var(--space-3);
184
+ }
185
+
186
+ .btnGhost:hover {
187
+ color: var(--content-primary);
188
+ }
189
+
190
+ .btnArrow {
191
+ font-family: var(--font-mono), ui-monospace, monospace;
192
+ font-weight: 400;
193
+ transition: transform 150ms ease;
194
+ }
195
+
196
+ .btn:hover .btnArrow {
197
+ transform: translateX(3px);
198
+ }
199
+
200
+ /* ---------- Side card (sticky readout) ---------- */
201
+
202
+ .sideCard {
203
+ background: var(--bg-surface);
204
+ border: 1px solid var(--bg-stroke);
205
+ border-radius: var(--li-r-3);
206
+ padding: var(--space-6);
207
+ position: sticky;
208
+ top: var(--li-space-7);
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: var(--space-3);
212
+ }
213
+
214
+ .sideRow {
215
+ display: flex;
216
+ justify-content: space-between;
217
+ align-items: baseline;
218
+ }
219
+
220
+ .sideLabel {
221
+ font-family: var(--font-mono), ui-monospace, monospace;
222
+ font-size: var(--li-fs-meta);
223
+ color: var(--content-tertiary);
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.06em;
226
+ }
227
+
228
+ .sideVal {
229
+ font-family: var(--font-mono), ui-monospace, monospace;
230
+ font-size: var(--li-fs-small);
231
+ color: var(--content-primary);
232
+ font-weight: 600;
233
+ }
234
+
235
+ .sideBar {
236
+ height: 6px;
237
+ background: var(--bg-subtle);
238
+ border-radius: 999px;
239
+ overflow: hidden;
240
+ }
241
+
242
+ .sideFill {
243
+ display: block;
244
+ height: 100%;
245
+ background: var(--accent-main);
246
+ border-radius: 999px;
247
+ transition: width 400ms ease;
248
+ }
249
+
250
+ .sidePct {
251
+ font-family: var(--font-mono), ui-monospace, monospace;
252
+ font-size: 32px;
253
+ font-weight: 600;
254
+ letter-spacing: -0.02em;
255
+ margin-top: var(--space-1);
256
+ color: var(--content-primary);
257
+ line-height: 1;
258
+ }
259
+
260
+ .sideDivider {
261
+ height: 1px;
262
+ background: var(--bg-stroke);
263
+ margin: var(--space-3) 0;
264
+ }
265
+
266
+ .sideBlock {
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: 4px;
270
+ }
271
+
272
+ .frontierLine {
273
+ font-family: var(--font-mono), ui-monospace, monospace;
274
+ font-size: var(--li-fs-meta);
275
+ color: var(--content-secondary);
276
+ text-transform: uppercase;
277
+ letter-spacing: 0.04em;
278
+ }
279
+
280
+ .frontierModule {
281
+ color: var(--content-secondary);
282
+ }
283
+
284
+ .frontierLesson {
285
+ font-size: var(--li-fs-body);
286
+ font-weight: 600;
287
+ color: var(--content-primary);
288
+ line-height: 1.35;
289
+ }
290
+
291
+ .stepsValue {
292
+ font-family: var(--font-mono), ui-monospace, monospace;
293
+ font-size: 22px;
294
+ font-weight: 600;
295
+ color: var(--content-primary);
296
+ letter-spacing: -0.01em;
297
+ }
298
+
299
+ .stepsUnit {
300
+ font-weight: 400;
301
+ color: var(--content-tertiary);
302
+ font-size: var(--li-fs-small);
303
+ }
304
+
305
+ /* Gate-paint flips data-hint-state on the frontier-hint sideBlock and
306
+ data-steps-state on the steps-until slot. Pre-hydration baseline =
307
+ hidden, so SSR markup stays stable for users with no progress (where
308
+ these blocks really shouldn't appear). */
309
+ .sideBlock[data-hint-state='hidden'] {
310
+ display: none;
311
+ }
312
+
313
+ /* Hide the entire "До этого урока" block when steps == 0 — uses :has on the
314
+ slot's data-steps-state. */
315
+ .sideBlock:has([data-steps-state='hidden']) {
316
+ display: none;
317
+ }
318
+
319
+ /* CTA variants. Gate-paint sets data-cta-state on the row, CSS picks the
320
+ variant. Same convention as HomePage / ModulePage. */
321
+ .ctaRow [data-cta-variant] {
322
+ display: none;
323
+ }
324
+ .ctaRow[data-cta-state='not-started'] [data-cta-variant='not-started'],
325
+ .ctaRow[data-cta-state='in-progress'] [data-cta-variant='in-progress'] {
326
+ display: inline-flex;
327
+ }
328
+
329
+ /* Success palette for the bar fill once the course is complete. */
330
+ .sideCard[data-progress-state='complete'] .sideFill {
331
+ background: var(--accent-success);
332
+ }
333
+
334
+ /* ---------- Responsive ---------- */
335
+
336
+ @media (max-width: 1100px) {
337
+ .hero {
338
+ grid-template-columns: 1fr;
339
+ gap: var(--li-space-7);
340
+ }
341
+ .sideCard {
342
+ position: static;
343
+ }
344
+ .title {
345
+ font-size: 52px;
346
+ }
347
+ }
348
+
349
+ @media (max-width: 720px) {
350
+ .page {
351
+ padding: var(--space-6) var(--space-5) var(--li-space-9);
352
+ }
353
+ .title {
354
+ font-size: 36px;
355
+ }
356
+ .desc {
357
+ font-size: var(--li-fs-body);
358
+ }
359
+ .ctaRow {
360
+ flex-direction: column;
361
+ align-items: stretch;
362
+ }
363
+ .ctaRow .btn {
364
+ justify-content: space-between;
365
+ width: 100%;
366
+ }
367
+ }
@@ -0,0 +1,256 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useRouter } from 'next/navigation';
5
+ import { useGate } from '@/components/GateProvider';
6
+ import {
7
+ flattenLessons,
8
+ getLessonIndex,
9
+ getTotalLessons,
10
+ } from '@/lib/course';
11
+ import { navigateToFrontierHref } from '@/lib/frontier-link';
12
+ import { openProgramDrawer } from '@/lib/program-drawer';
13
+ import { useLang, useT } from '@/lib/use-i18n';
14
+ import styles from './LessonLockedInterstitial.module.css';
15
+
16
+ type LessonLockedInterstitialProps = {
17
+ attemptedModuleId?: string;
18
+ attemptedSlug?: string;
19
+ };
20
+
21
+ /**
22
+ * Locked-lesson interstitial. Pure pre-hydrated baseline — the dynamic bits
23
+ * (frontier title, current-step count, "steps until" counter, side card
24
+ * progress) are slots filled by the gate-paint inline script before first
25
+ * paint. No useGate-driven JSX, so SSR markup and post-hydration markup are
26
+ * identical and React's hydration phase never re-renders the panel.
27
+ */
28
+ export function LessonLockedInterstitial({
29
+ attemptedModuleId,
30
+ attemptedSlug,
31
+ }: LessonLockedInterstitialProps) {
32
+ // useGate access is kept only for the static course shape (modules + total
33
+ // lessons); none of these values change on hydration so they don't drive
34
+ // flash. Reading from gate avoids drilling course through props.
35
+ const gate = useGate();
36
+ const { course, basePath } = gate;
37
+ const router = useRouter();
38
+ const t = useT();
39
+ const lang = useLang();
40
+
41
+ const attemptedLesson =
42
+ attemptedModuleId && attemptedSlug
43
+ ? course.modules
44
+ .find((m) => m.id === attemptedModuleId)
45
+ ?.lessons.find((l) => l.slug === attemptedSlug)
46
+ : undefined;
47
+ const attemptedModule = attemptedModuleId
48
+ ? course.modules.find((m) => m.id === attemptedModuleId)
49
+ : undefined;
50
+ const attemptedModuleIndex = attemptedModuleId
51
+ ? course.modules.findIndex((m) => m.id === attemptedModuleId)
52
+ : -1;
53
+ const attemptedIndex =
54
+ attemptedModuleId && attemptedSlug
55
+ ? getLessonIndex(course, attemptedModuleId, attemptedSlug)
56
+ : -1;
57
+
58
+ const totalLessons = getTotalLessons(course);
59
+ const firstEntry = flattenLessons(course)[0] ?? null;
60
+ // Why: the inline gate-mark script (runs before hydration) writes the
61
+ // frontier module/lesson titles into the hint slots. SSR must match the
62
+ // no-progress default the script picks (frontier index = 0) so freshly
63
+ // arrived users — by far the common case — don't see an empty-vs-text
64
+ // hydration mismatch. Returning users with progress get a different
65
+ // title from the script and rely on `suppressHydrationWarning` below.
66
+ const firstEntryModule = firstEntry
67
+ ? course.modules.find((m) => m.id === firstEntry.moduleId)
68
+ : null;
69
+ // Bare path — Next `<Link>` prepends basePath; pre-baking it here would
70
+ // produce `/kafka-cookbook/kafka-cookbook/...` on client-side navigation.
71
+ const firstHref = firstEntry
72
+ ? `/${lang}/${firstEntry.moduleId}/${firstEntry.lesson.slug}`
73
+ : '#';
74
+
75
+ return (
76
+ <section className={styles.page} role="status" aria-live="polite">
77
+ <div className={styles.hero}>
78
+ <div className={styles.heroText}>
79
+ <div className={styles.eyebrow}>
80
+ <span className={styles.eyebrowBadge} aria-hidden="true">
81
+ <SmallLockIcon />
82
+ <span>{t.locked}</span>
83
+ </span>
84
+ {attemptedModuleIndex >= 0 && (
85
+ <>
86
+ <span className={styles.eyebrowDot}>·</span>
87
+ <span>
88
+ {t.moduleNumberPrefix}{' '}
89
+ {String(attemptedModuleIndex + 1).padStart(2, '0')}
90
+ </span>
91
+ </>
92
+ )}
93
+ </div>
94
+
95
+ <h1 className={styles.title}>{t.lockedTitle}</h1>
96
+ <p className={styles.desc}>{t.lockedDesc}</p>
97
+
98
+ {attemptedLesson && (
99
+ <dl className={styles.targetCard} aria-label={t.attemptedLessonLabel}>
100
+ <dt className={styles.targetLabel}>{t.attemptedYouTried}</dt>
101
+ <dd className={styles.targetTitle}>
102
+ {attemptedModule && (
103
+ <>
104
+ <span className={styles.targetModule}>
105
+ {attemptedModule.title}
106
+ </span>
107
+ <span className={styles.targetSep}> / </span>
108
+ </>
109
+ )}
110
+ <span className={styles.targetLesson}>{attemptedLesson.title}</span>
111
+ </dd>
112
+ </dl>
113
+ )}
114
+
115
+ <div
116
+ className={styles.ctaRow}
117
+ data-cta-frontier="global"
118
+ data-cta-state="not-started"
119
+ suppressHydrationWarning
120
+ >
121
+ {/* The "Open outline" button is always visible; the
122
+ "Continue" link is the gate-paint-driven variant. */}
123
+ <Link
124
+ href={firstHref}
125
+ className={`${styles.btn} ${styles.btnPrimary}`}
126
+ data-cta-variant="in-progress"
127
+ data-cta-frontier-link
128
+ suppressHydrationWarning
129
+ onClick={(e) => navigateToFrontierHref(e, router, basePath)}
130
+ >
131
+ {t.continueAction} ·{' '}
132
+ <span data-cta-frontier-title suppressHydrationWarning>
133
+ {firstEntry?.lesson.title ?? ''}
134
+ </span>
135
+ <span className={styles.btnArrow}>→</span>
136
+ </Link>
137
+ <Link
138
+ href={firstHref}
139
+ className={`${styles.btn} ${styles.btnPrimary}`}
140
+ data-cta-variant="not-started"
141
+ >
142
+ {t.startFromFirst}
143
+ <span className={styles.btnArrow}>→</span>
144
+ </Link>
145
+ <button
146
+ type="button"
147
+ onClick={openProgramDrawer}
148
+ className={`${styles.btn} ${styles.btnSecondary}`}
149
+ >
150
+ {t.openProgram}
151
+ </button>
152
+ </div>
153
+ </div>
154
+
155
+ <aside
156
+ className={styles.sideCard}
157
+ aria-label={t.courseProgress}
158
+ data-progress-scope="global"
159
+ data-progress-state="not-started"
160
+ suppressHydrationWarning
161
+ >
162
+ <div className={styles.sideRow}>
163
+ <span className={styles.sideLabel}>{t.progress}</span>
164
+ <span className={styles.sideVal}>
165
+ <span data-progress-count suppressHydrationWarning>
166
+ 0
167
+ </span>{' '}
168
+ / {totalLessons}
169
+ </span>
170
+ </div>
171
+ <div className={styles.sideBar} aria-hidden="true">
172
+ <span
173
+ className={styles.sideFill}
174
+ data-progress-bar
175
+ style={{ width: '0%' }}
176
+ suppressHydrationWarning
177
+ />
178
+ </div>
179
+ <div className={styles.sidePct}>
180
+ <span data-progress-pct suppressHydrationWarning>
181
+ 0
182
+ </span>
183
+ %
184
+ </div>
185
+
186
+ <div className={styles.sideDivider} />
187
+
188
+ <div
189
+ className={styles.sideBlock}
190
+ data-frontier-hint
191
+ data-hint-state="hidden"
192
+ suppressHydrationWarning
193
+ >
194
+ <span className={styles.sideLabel}>{t.nextStep}</span>
195
+ <div className={styles.frontierLine}>
196
+ <span
197
+ className={styles.frontierModule}
198
+ data-frontier-hint-module
199
+ suppressHydrationWarning
200
+ >
201
+ {firstEntryModule?.title ?? ''}
202
+ </span>
203
+ </div>
204
+ <div
205
+ className={styles.frontierLesson}
206
+ data-frontier-hint-lesson
207
+ suppressHydrationWarning
208
+ >
209
+ {firstEntry?.lesson.title ?? ''}
210
+ </div>
211
+ </div>
212
+
213
+ {attemptedIndex >= 0 && (
214
+ <>
215
+ <div className={styles.sideDivider} />
216
+ <div className={styles.sideBlock}>
217
+ <span className={styles.sideLabel}>{t.untilThisLesson}</span>
218
+ <div
219
+ className={styles.stepsValue}
220
+ data-steps-until
221
+ data-steps-target-index={String(attemptedIndex)}
222
+ data-steps-state="hidden"
223
+ suppressHydrationWarning
224
+ >
225
+ {/* SSR matches the gate-mark script's no-progress default
226
+ (frontier index = 0), so diff = attemptedIndex. */}
227
+ {String(Math.max(0, attemptedIndex))}
228
+ </div>
229
+ </div>
230
+ </>
231
+ )}
232
+ </aside>
233
+ </div>
234
+ </section>
235
+ );
236
+ }
237
+
238
+ function SmallLockIcon() {
239
+ return (
240
+ <svg
241
+ width="11"
242
+ height="11"
243
+ viewBox="0 0 24 24"
244
+ fill="none"
245
+ stroke="currentColor"
246
+ strokeWidth="2.4"
247
+ strokeLinecap="round"
248
+ strokeLinejoin="round"
249
+ aria-hidden="true"
250
+ focusable="false"
251
+ >
252
+ <rect x="4" y="11" width="16" height="10" rx="2" />
253
+ <path d="M8 11V7a4 4 0 0 1 8 0v4" />
254
+ </svg>
255
+ );
256
+ }
@@ -0,0 +1 @@
1
+ export { LessonLockedInterstitial } from './LessonLockedInterstitial';
@@ -0,0 +1,84 @@
1
+ .row {
2
+ display: grid;
3
+ grid-template-columns: 1fr 1fr;
4
+ gap: var(--space-4);
5
+ }
6
+
7
+ .card {
8
+ display: grid;
9
+ grid-template-rows: auto auto;
10
+ gap: 2px;
11
+ padding: var(--space-3) var(--space-4);
12
+ border: 1px solid var(--bg-stroke);
13
+ border-radius: var(--radius-md);
14
+ background-color: var(--bg-surface);
15
+ text-decoration: none;
16
+ color: inherit;
17
+ transition: background-color 120ms ease, border-color 120ms ease;
18
+ }
19
+
20
+ .card:hover {
21
+ border-color: var(--accent-main);
22
+ background-color: var(--bg-subtle);
23
+ }
24
+
25
+ .card:focus-visible {
26
+ outline: 2px solid var(--accent-main);
27
+ outline-offset: 2px;
28
+ }
29
+
30
+ .next {
31
+ text-align: right;
32
+ }
33
+
34
+ .label {
35
+ font-family: var(--font-mono), ui-monospace, monospace;
36
+ font-size: 11px;
37
+ color: var(--content-tertiary);
38
+ text-transform: uppercase;
39
+ letter-spacing: 0.06em;
40
+ }
41
+
42
+ .title {
43
+ font-size: 15px;
44
+ font-weight: var(--font-weight-semibold);
45
+ letter-spacing: -0.01em;
46
+ color: var(--content-primary);
47
+ }
48
+
49
+ .placeholder {
50
+ display: block;
51
+ opacity: 0;
52
+ pointer-events: none;
53
+ }
54
+
55
+ @media (max-width: 720px) {
56
+ /* Single-column stack with the next-card promoted to the top so the
57
+ dominant flow ("finished reading → next lesson") lands as the primary
58
+ CTA. Prev becomes a secondary card below — same affordance, lower
59
+ emphasis. */
60
+ .row {
61
+ grid-template-columns: 1fr;
62
+ gap: var(--space-3);
63
+ }
64
+
65
+ .next {
66
+ text-align: left;
67
+ order: 1;
68
+ padding: var(--space-5) var(--space-5);
69
+ border-color: var(--accent-main);
70
+ background-color: var(--accent-main-soft);
71
+ }
72
+
73
+ .next .label {
74
+ color: var(--accent-main);
75
+ }
76
+
77
+ .next .title {
78
+ font-size: 18px;
79
+ }
80
+
81
+ .prev {
82
+ order: 2;
83
+ }
84
+ }