@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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
- package/package.json +92 -0
- package/scripts/check-course-coverage.mts +32 -0
- package/scripts/fix-static-image-extensions.mjs +78 -0
- package/scripts/generate-readme-toc.mts +32 -0
- package/scripts/resolve-course-paths.mjs +28 -0
- package/scripts/sync-images.mjs +88 -0
- package/src/components/AppShell/AppShell.module.css +40 -0
- package/src/components/AppShell/AppShell.tsx +135 -0
- package/src/components/AppShell/index.ts +1 -0
- package/src/components/Callout/Callout.module.css +68 -0
- package/src/components/Callout/Callout.tsx +83 -0
- package/src/components/Callout/index.ts +1 -0
- package/src/components/CodeBlock/CodeBlock.module.css +68 -0
- package/src/components/CodeBlock/CodeBlock.tsx +65 -0
- package/src/components/CodeBlock/index.ts +1 -0
- package/src/components/GateProvider/GateProvider.tsx +207 -0
- package/src/components/GateProvider/index.ts +1 -0
- package/src/components/Header/Breadcrumbs.tsx +50 -0
- package/src/components/Header/Header.module.css +131 -0
- package/src/components/Header/Header.tsx +26 -0
- package/src/components/Header/HeaderLessonNav.tsx +118 -0
- package/src/components/Header/index.ts +1 -0
- package/src/components/HomePage/HomePage.module.css +538 -0
- package/src/components/HomePage/HomePage.tsx +295 -0
- package/src/components/HomePage/index.ts +1 -0
- package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
- package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
- package/src/components/LessonAwareLink/index.ts +1 -0
- package/src/components/LessonLayout/LessonLayout.module.css +35 -0
- package/src/components/LessonLayout/LessonLayout.tsx +18 -0
- package/src/components/LessonLayout/index.ts +1 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
- package/src/components/LessonLockedInterstitial/index.ts +1 -0
- package/src/components/LessonNav/LessonNav.module.css +84 -0
- package/src/components/LessonNav/LessonNav.tsx +64 -0
- package/src/components/LessonNav/index.ts +1 -0
- package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
- package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
- package/src/components/LessonPageLayout/index.ts +1 -0
- package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
- package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
- package/src/components/LessonSideMeta/index.ts +1 -0
- package/src/components/ModulePage/ModulePage.module.css +693 -0
- package/src/components/ModulePage/ModulePage.tsx +301 -0
- package/src/components/ModulePage/index.ts +1 -0
- package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
- package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
- package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
- package/src/components/ProgramDrawer/index.ts +1 -0
- package/src/components/ProgressBar/ProgressBar.module.css +46 -0
- package/src/components/ProgressBar/ProgressBar.tsx +45 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
- package/src/components/ProgressModeProvider/index.ts +1 -0
- package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
- package/src/components/ReadingPrefsProvider/index.ts +1 -0
- package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
- package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
- package/src/components/ReadingProgress/index.ts +1 -0
- package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
- package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
- package/src/components/SettingsToggle/index.ts +1 -0
- package/src/components/Sidebar/Sidebar.module.css +157 -0
- package/src/components/Sidebar/Sidebar.tsx +63 -0
- package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
- package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
- package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
- package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
- package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/index.ts +6 -0
- package/src/components/Sidebar/index.ts +1 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
- package/src/components/ThemeProvider/index.ts +1 -0
- package/src/components/Toc/Toc.module.css +78 -0
- package/src/components/Toc/Toc.tsx +92 -0
- package/src/components/Toc/index.ts +1 -0
- package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
- package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
- package/src/components/TranslationBanner/index.ts +1 -0
- package/src/config.d.mts +12 -0
- package/src/config.mjs +110 -0
- package/src/index.ts +62 -0
- package/src/layout/lang.tsx +44 -0
- package/src/layout/root.tsx +223 -0
- package/src/lib/course-loader.ts +33 -0
- package/src/lib/course.ts +429 -0
- package/src/lib/coverage.ts +141 -0
- package/src/lib/description.ts +43 -0
- package/src/lib/extract-toc.ts +59 -0
- package/src/lib/format.ts +55 -0
- package/src/lib/frontier-link.ts +37 -0
- package/src/lib/gate-init-script.ts +40 -0
- package/src/lib/gate-mark-script.ts +324 -0
- package/src/lib/i18n.ts +474 -0
- package/src/lib/lang.ts +90 -0
- package/src/lib/lesson-gate.ts +79 -0
- package/src/lib/lesson.ts +66 -0
- package/src/lib/markdown-components.tsx +51 -0
- package/src/lib/markdown.ts +180 -0
- package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
- package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
- package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
- package/src/lib/paths.ts +36 -0
- package/src/lib/program-drawer.ts +8 -0
- package/src/lib/progress-mode.ts +69 -0
- package/src/lib/progress.ts +182 -0
- package/src/lib/reading-prefs.ts +127 -0
- package/src/lib/readme-toc.ts +69 -0
- package/src/lib/site-url.ts +33 -0
- package/src/lib/sitemap.ts +112 -0
- package/src/lib/slug.ts +15 -0
- package/src/lib/theme.ts +78 -0
- package/src/lib/use-i18n.ts +25 -0
- package/src/og/icon.tsx +40 -0
- package/src/og/opengraph-image.tsx +126 -0
- package/src/pages/home.tsx +66 -0
- package/src/pages/lesson.tsx +260 -0
- package/src/pages/module.tsx +80 -0
- package/src/pages/not-found-lang.tsx +51 -0
- package/src/pages/not-found-root.tsx +48 -0
- package/src/pages/root.tsx +44 -0
- package/src/seo/robots.ts +16 -0
- package/src/seo/sitemap.ts +10 -0
- package/src/styles/globals.css +139 -0
- package/src/styles/markdown.css +265 -0
- package/src/styles/reset.css +89 -0
- 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
|
+
}
|