@curio-sd/e-module-builder 0.3.1 → 0.5.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.
package/build.mjs CHANGED
@@ -24,8 +24,52 @@ const marked = new Marked(
24
24
  })
25
25
  )
26
26
 
27
+ const SECTION_RE = /^([a-zA-Z]+)(\d+)$/
28
+
29
+ function sectionLabel(prefix, num) {
30
+ return `${prefix.charAt(0).toUpperCase()}${prefix.slice(1)} ${num}`
31
+ }
32
+
27
33
  // ─── helpers ────────────────────────────────────────────────────────────────
28
34
 
35
+ function rewriteAssetPaths(html, basePath) {
36
+ if (!basePath || !html) return html
37
+ const prefix = `../${basePath}/`
38
+ html = html.replace(
39
+ /(<img\s[^>]*\bsrc=")(?!https?:\/\/|\/|data:|\.\.)([^"]+)(")/g,
40
+ `$1${prefix}$2$3`
41
+ )
42
+ html = html.replace(
43
+ /(<a\s[^>]*\bhref=")(?!https?:\/\/|\/|#|mailto:|\.\.)([^"]+)(")/g,
44
+ `$1${prefix}$2$3`
45
+ )
46
+ return html
47
+ }
48
+
49
+ function copyStaticAssets() {
50
+ const PUBLIC_DIR = path.join(PROJECT_DIR, 'public')
51
+
52
+ const pkgPublic = path.join(PKG_DIR, 'public')
53
+ if (fs.existsSync(pkgPublic)) {
54
+ fs.cpSync(pkgPublic, PUBLIC_DIR, { recursive: true })
55
+ }
56
+
57
+ function walkAndCopy(srcDir, relPath) {
58
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
59
+ const srcPath = path.join(srcDir, entry.name)
60
+ const destRel = relPath ? `${relPath}/${entry.name}` : entry.name
61
+ if (entry.isDirectory()) {
62
+ walkAndCopy(srcPath, destRel)
63
+ } else if (!entry.name.endsWith('.md') && !entry.name.endsWith('.html')) {
64
+ const destPath = path.join(PUBLIC_DIR, destRel)
65
+ fs.mkdirSync(path.dirname(destPath), { recursive: true })
66
+ fs.copyFileSync(srcPath, destPath)
67
+ }
68
+ }
69
+ }
70
+ walkAndCopy(CONTENT, '')
71
+ }
72
+
29
73
  function readMd(filePath) {
30
74
  const raw = fs.readFileSync(filePath, 'utf8')
31
75
  return matter(raw)
@@ -70,8 +114,8 @@ const mod = moduleMd.data
70
114
 
71
115
  const weekDirs = fs
72
116
  .readdirSync(CONTENT)
73
- .filter(d => /^week\d+$/.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
74
- .sort((a, b) => parseInt(a.slice(4)) - parseInt(b.slice(4)))
117
+ .filter(d => SECTION_RE.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
118
+ .sort((a, b) => parseInt(a.match(SECTION_RE)[2]) - parseInt(b.match(SECTION_RE)[2]))
75
119
 
76
120
  const weekCount = mod.weeks > 0 ? mod.weeks : weekDirs.length
77
121
  const activeWeeks = weekDirs.slice(0, weekCount)
@@ -81,7 +125,8 @@ const activeWeeks = weekDirs.slice(0, weekCount)
81
125
  const weeksData = []
82
126
 
83
127
  for (const weekDir of activeWeeks) {
84
- const weekNum = parseInt(weekDir.slice(4))
128
+ const [, sectionPrefix, sectionNumStr] = weekDir.match(SECTION_RE)
129
+ const weekNum = parseInt(sectionNumStr)
85
130
  const dir = path.join(CONTENT, weekDir)
86
131
 
87
132
  // theory.md → src/data/theory-weekN.json
@@ -91,18 +136,18 @@ for (const weekDir of activeWeeks) {
91
136
  title: theoryMd.data.title,
92
137
  goal: theoryMd.data.goal,
93
138
  accent: theoryMd.data.accent,
94
- html: marked.parse(theoryMd.content ?? ''),
139
+ html: rewriteAssetPaths(marked.parse(theoryMd.content ?? ''), `week${weekNum}`),
95
140
  }
96
141
  writeJson(SRC_DATA, `theory-week${weekNum}.json`, theoryOut)
97
142
 
98
- // quiz.md → src/data/tussentoets-weekN.json
143
+ // quiz.md → src/data/meetmoment-quiz-weekN.json
99
144
  const quizMd = readMd(path.join(dir, 'quiz.md'))
100
145
  const quizOut = {
101
146
  title: quizMd.data.title,
102
147
  passScore: quizMd.data.passScore ?? 70,
103
148
  questions: quizMd.data.questions ?? [],
104
149
  }
105
- writeJson(SRC_DATA, `tussentoets-week${weekNum}.json`, quizOut)
150
+ writeJson(SRC_DATA, `meetmoment-quiz-week${weekNum}.json`, quizOut)
106
151
 
107
152
  // exercises/ subfolder → src/data/exercises/weekN.json
108
153
  const exDir = path.join(dir, 'exercises')
@@ -111,9 +156,16 @@ for (const weekDir of activeWeeks) {
111
156
  .filter(f => f.endsWith('.md') && f !== '_meta.md')
112
157
  .sort((a, b) => parseInt(a) - parseInt(b))
113
158
  const exercises = exerciseFiles.map(f => {
114
- const ex = readMd(path.join(exDir, f)).data
159
+ const { data: ex, content } = readMd(path.join(exDir, f))
115
160
  if (!ex.type || ex.type === 'text') {
116
- ex.descriptionHtml = marked.parse(ex.description ?? '')
161
+ const src = content?.trim() ? content : (ex.description ?? '')
162
+ ex.descriptionHtml = rewriteAssetPaths(marked.parse(src), `week${weekNum}/exercises`)
163
+ }
164
+ ex.descriptionInlineHtml = marked.parseInline(ex.description ?? '')
165
+ if (ex.type === 'external') {
166
+ if (ex.task) ex.taskHtml = marked.parseInline(ex.task)
167
+ if (ex.hint) ex.hintHtml = marked.parse(ex.hint)
168
+ if (ex.solution) ex.solutionHtml = marked.parse(ex.solution)
117
169
  }
118
170
  return ex
119
171
  })
@@ -132,32 +184,78 @@ for (const weekDir of activeWeeks) {
132
184
  week: hwMd.data.week ?? weekNum,
133
185
  title: hwMd.data.title,
134
186
  subtitle: hwMd.data.subtitle ?? '',
135
- html: marked.parse(hwMd.content ?? ''),
187
+ html: rewriteAssetPaths(marked.parse(hwMd.content ?? ''), `week${weekNum}`),
136
188
  deliverables: hwMd.data.deliverables ?? [],
137
189
  criteria: hwMd.data.criteria ?? [],
138
190
  maxPoints: hwMd.data.maxPoints ?? 0,
139
191
  tips: hwMd.data.tips ?? [],
192
+ ...(hwMd.data.linked_theory ? { linked_theory: hwMd.data.linked_theory } : {}),
140
193
  }
141
194
  writeJson(SRC_DATA, `inleveropdracht-week${weekNum}.json`, hwOut)
142
195
 
143
196
  weeksData.push({
144
197
  week: weekNum,
198
+ dirName: weekDir,
199
+ prefix: sectionPrefix,
145
200
  title: theoryMd.data.title,
146
- summary: theoryMd.data.summary ?? '',
201
+ summary: marked.parseInline(theoryMd.data.summary ?? ''),
147
202
  goal: theoryMd.data.goal,
148
203
  leeruitkomsten: theoryMd.data.leeruitkomsten ?? [],
149
204
  color: theoryMd.data.accent,
150
205
  pages: [
151
- { key: 'theorie', href: `/pages/week${weekNum}-theorie.html`, label: 'Theorie' },
152
- { key: 'oefeningen', href: `/pages/week${weekNum}-oefeningen.html`, label: 'Oefeningen' },
153
- { key: 'toets', href: `/pages/week${weekNum}-toets.html`, label: 'Tussentoets' },
154
- { key: 'oefening', href: `/pages/week${weekNum}-oefening.html`, label: 'Oefening' },
155
- { key: 'inleveropdracht', href: `/pages/week${weekNum}-inleveropdracht.html`, label: 'Inleveropdracht' },
206
+ { key: 'theorie', href: `/pages/${weekDir}-theorie.html`, label: 'Theorie' },
207
+ { key: 'oefeningen', href: `/pages/${weekDir}-oefeningen.html`, label: 'Oefeningen' },
208
+ { key: 'meetmoment', href: `/pages/${weekDir}-meetmoment.html`, label: 'Meetmoment' },
209
+ { key: 'oefening', href: `/pages/${weekDir}-oefening.html`, label: 'Oefening' },
210
+ { key: 'inleveropdracht', href: `/pages/${weekDir}-inleveropdracht.html`, label: 'Inleveropdracht' },
156
211
  ],
157
212
  })
158
213
  }
159
214
 
160
- // ─── 4. manifest.json ────────────────────────────────────────────────────────
215
+ // ─── 4. assessment data (parsed early so navLabel is available for manifest) ───────
216
+
217
+ const ASSESSMENTS_DIR = path.join(CONTENT, 'assessments')
218
+
219
+ function buildAssessmentData(filePath, fallbackTitle, fallbackNavLabel, fallbackDescription) {
220
+ if (fs.existsSync(filePath)) {
221
+ const md = readMd(filePath)
222
+ return {
223
+ title: md.data.title ?? fallbackTitle,
224
+ navLabel: md.data.navLabel ?? fallbackNavLabel,
225
+ description: marked.parseInline(md.data.description ?? fallbackDescription),
226
+ passScore: md.data.passScore ?? 70,
227
+ questions: md.data.questions ?? [],
228
+ }
229
+ }
230
+ return { title: fallbackTitle, navLabel: fallbackNavLabel, description: marked.parseInline(fallbackDescription), passScore: 70, questions: [] }
231
+ }
232
+
233
+ function buildPracticalAssessmentData(filePath, fallbackTitle, fallbackNavLabel) {
234
+ if (fs.existsSync(filePath)) {
235
+ const md = readMd(filePath)
236
+ return {
237
+ title: md.data.title ?? fallbackTitle,
238
+ navLabel: md.data.navLabel ?? fallbackNavLabel,
239
+ description: marked.parseInline(md.data.description ?? ''),
240
+ html: marked.parse(md.content ?? ''),
241
+ }
242
+ }
243
+ return { title: fallbackTitle, navLabel: fallbackNavLabel, description: '', html: '' }
244
+ }
245
+
246
+ const theoryAssessmentData = buildAssessmentData(
247
+ path.join(ASSESSMENTS_DIR, 'theory-assessment.md'),
248
+ `Meetmoment theorie — ${mod.name}`,
249
+ 'Meetmoment Theorie',
250
+ 'Meerkeuzevragen over de module. Minimaal 70% om te slagen.'
251
+ )
252
+ const practicalAssessmentData = buildPracticalAssessmentData(
253
+ path.join(ASSESSMENTS_DIR, 'practical-assessment.md'),
254
+ `Meetmoment praktijk — ${mod.name}`,
255
+ 'Meetmoment Praktijk'
256
+ )
257
+
258
+ // ─── 5. manifest.json ────────────────────────────────────────────────────────
161
259
 
162
260
  const manifest = {
163
261
  module: {
@@ -166,7 +264,7 @@ const manifest = {
166
264
  youtube: mod.youtube ?? null,
167
265
  weeks: weekCount,
168
266
  language: mod.language ?? 'nl',
169
- description: mod.description ?? '',
267
+ description: marked.parseInline(mod.description ?? ''),
170
268
  logoAlt: mod.logoAlt ?? mod.name,
171
269
  exerciseMode: mod.exerciseMode ?? 'external',
172
270
  },
@@ -174,34 +272,37 @@ const manifest = {
174
272
  nav: {
175
273
  home: { href: '/index.html', label: 'Home' },
176
274
  weeks: weeksData.map(wk => ({
177
- label: `Week ${wk.week}`,
275
+ label: sectionLabel(wk.prefix, wk.week),
178
276
  title: wk.title,
179
277
  children: [
180
- { href: `/pages/week${wk.week}-theorie.html`, label: 'Theorie' },
181
- { href: `/pages/week${wk.week}-oefeningen.html`, label: 'Oefeningen' },
182
- { href: `/pages/week${wk.week}-toets.html`, label: 'Tussentoets' },
183
- { href: `/pages/week${wk.week}-inleveropdracht.html`, label: 'Inleveropdracht' },
278
+ { href: `/pages/${wk.dirName}-theorie.html`, label: 'Theorie' },
279
+ { href: `/pages/${wk.dirName}-oefeningen.html`, label: 'Oefeningen' },
280
+ { href: `/pages/${wk.dirName}-meetmoment.html`, label: 'Quiz' },
281
+ { href: `/pages/${wk.dirName}-inleveropdracht.html`, label: 'Inleveropdracht' },
184
282
  ],
185
283
  })),
186
- examPages: [
187
- { href: '/pages/checklist.html', label: 'Checklist' },
188
- { href: '/pages/toets-theorie.html', label: 'Eindtoets theorie' },
189
- { href: '/pages/toets-praktijk.html', label: 'Eindtoets praktijk' },
190
- ],
284
+ assessmentSection: {
285
+ label: mod.assessmentSectionLabel ?? 'Afronding',
286
+ children: [
287
+ { href: '/pages/checklist.html', label: 'Checklist' },
288
+ { href: '/pages/meetmoment-theorie.html', label: theoryAssessmentData.navLabel },
289
+ { href: '/pages/meetmoment-praktijk.html', label: practicalAssessmentData.navLabel },
290
+ ],
291
+ },
191
292
  },
192
293
  pages: {
193
294
  static: [
194
295
  'index.html',
195
296
  'pages/checklist.html',
196
- 'pages/toets-theorie.html',
197
- 'pages/toets-praktijk.html',
297
+ 'pages/meetmoment-theorie.html',
298
+ 'pages/meetmoment-praktijk.html',
198
299
  ],
199
300
  week: weeksData.flatMap(wk => [
200
- `pages/week${wk.week}-theorie.html`,
201
- `pages/week${wk.week}-oefeningen.html`,
202
- `pages/week${wk.week}-toets.html`,
203
- `pages/week${wk.week}-oefening.html`,
204
- `pages/week${wk.week}-inleveropdracht.html`,
301
+ `pages/${wk.dirName}-theorie.html`,
302
+ `pages/${wk.dirName}-oefeningen.html`,
303
+ `pages/${wk.dirName}-meetmoment.html`,
304
+ `pages/${wk.dirName}-oefening.html`,
305
+ `pages/${wk.dirName}-inleveropdracht.html`,
205
306
  ]),
206
307
  },
207
308
  content: {
@@ -215,12 +316,13 @@ writeJson(SRC_DATA, 'manifest.json', manifest)
215
316
  // ─── 5. checklist.json ───────────────────────────────────────────────────────
216
317
 
217
318
  const checklistGroups = weeksData.map(wk => ({
218
- id: `week${wk.week}`,
219
- title: `Week ${wk.week} — ${wk.title}`,
319
+ id: wk.dirName,
320
+ title: `${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
220
321
  color: wk.color,
221
322
  items: (wk.leeruitkomsten ?? []).map((text, i) => ({
222
- id: `week${wk.week}-item-${i}`,
323
+ id: `${wk.dirName}-item-${i}`,
223
324
  text,
325
+ textHtml: marked.parseInline(text),
224
326
  })),
225
327
  }))
226
328
 
@@ -229,37 +331,16 @@ if (mod.algemeen?.length) {
229
331
  id: 'algemeen',
230
332
  title: 'Algemeen',
231
333
  color: 'slate',
232
- items: mod.algemeen.map((text, i) => ({ id: `algemeen-item-${i}`, text })),
334
+ items: mod.algemeen.map((text, i) => ({ id: `algemeen-item-${i}`, text, textHtml: marked.parseInline(text) })),
233
335
  })
234
336
  }
235
337
 
236
338
  writeJson(SRC_DATA, 'checklist.json', { groups: checklistGroups })
237
339
 
238
- // ─── 5b. exam data files (optional content/exams/*.md, else empty placeholder) ─
239
-
240
- function buildExamData(filePath, fallbackTitle) {
241
- if (fs.existsSync(filePath)) {
242
- const md = readMd(filePath)
243
- return {
244
- title: md.data.title ?? fallbackTitle,
245
- passScore: md.data.passScore ?? 70,
246
- questions: md.data.questions ?? [],
247
- }
248
- }
249
- return { title: fallbackTitle, passScore: 70, questions: [] }
250
- }
340
+ // ─── 5b. write assessment data JSON files ──────────────────────────────────────────
251
341
 
252
- const EXAMS_DIR = path.join(CONTENT, 'exams')
253
- writeJson(
254
- SRC_DATA,
255
- 'toets-theorie.json',
256
- buildExamData(path.join(EXAMS_DIR, 'theory-exam.md'), `Eindtoets theorie — ${mod.name}`)
257
- )
258
- writeJson(
259
- SRC_DATA,
260
- 'toets-praktijk.json',
261
- buildExamData(path.join(EXAMS_DIR, 'practical-exam.md'), `Eindtoets praktijk — ${mod.name}`)
262
- )
342
+ writeJson(SRC_DATA, 'meetmoment-theorie.json', theoryAssessmentData)
343
+ writeJson(SRC_DATA, 'meetmoment-praktijk.json', practicalAssessmentData)
263
344
 
264
345
  // ─── 6. generate per-week page stubs ─────────────────────────────────────────
265
346
 
@@ -267,27 +348,27 @@ const PAGE_TYPES = [
267
348
  {
268
349
  tplFile: 'theorie.html',
269
350
  suffix: 'theorie',
270
- pageTitle: wk => `Theorie Week ${wk.week} — ${wk.title}`,
351
+ pageTitle: wk => `Theorie ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
271
352
  },
272
353
  {
273
- tplFile: 'toets.html',
274
- suffix: 'toets',
275
- pageTitle: wk => `Tussentoets Week ${wk.week} — ${wk.title}`,
354
+ tplFile: 'meetmoment.html',
355
+ suffix: 'meetmoment',
356
+ pageTitle: wk => `Meetmoment ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
276
357
  },
277
358
  {
278
359
  tplFile: 'oefeningen.html',
279
360
  suffix: 'oefeningen',
280
- pageTitle: wk => `Oefeningen Week ${wk.week} — ${wk.title}`,
361
+ pageTitle: wk => `Oefeningen ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
281
362
  },
282
363
  {
283
364
  tplFile: 'oefening.html',
284
365
  suffix: 'oefening',
285
- pageTitle: wk => `Oefening — Week ${wk.week}`,
366
+ pageTitle: wk => `Oefening — ${sectionLabel(wk.prefix, wk.week)}`,
286
367
  },
287
368
  {
288
369
  tplFile: 'inleveropdracht.html',
289
370
  suffix: 'inleveropdracht',
290
- pageTitle: wk => `Inleveropdracht Week ${wk.week} — ${wk.title}`,
371
+ pageTitle: wk => `Inleveropdracht ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
291
372
  },
292
373
  ]
293
374
 
@@ -302,21 +383,31 @@ for (const { tplFile, suffix, pageTitle } of PAGE_TYPES) {
302
383
  weekTitle: wk.title,
303
384
  pageTitle: pageTitle(wk),
304
385
  })
305
- fs.writeFileSync(path.join(PAGES, `week${wk.week}-${suffix}.html`), out)
386
+ fs.writeFileSync(path.join(PAGES, `${wk.dirName}-${suffix}.html`), out)
306
387
  }
307
388
  }
308
389
 
309
- // ─── 7. copy static pages (checklist, exams) with title substitution ─────────
390
+ // ─── 7. copy static pages (checklist, assessments) with title substitution ─────────
310
391
 
311
392
  const STATIC_PAGES = [
312
393
  { src: 'checklist.html', pageTitle: `Checklist — ${mod.name}` },
313
- { src: 'toets-theorie.html', pageTitle: `Eindtoets theorie — ${mod.name}` },
314
- { src: 'toets-praktijk.html', pageTitle: `Eindtoets praktijk — ${mod.name}` },
394
+ {
395
+ src: 'meetmoment-theorie.html',
396
+ pageTitle: `${theoryAssessmentData.navLabel} — ${mod.name}`,
397
+ assessmentTitle: theoryAssessmentData.navLabel,
398
+ assessmentDescription: theoryAssessmentData.description,
399
+ },
400
+ {
401
+ src: 'meetmoment-praktijk.html',
402
+ pageTitle: `${practicalAssessmentData.navLabel} — ${mod.name}`,
403
+ assessmentTitle: practicalAssessmentData.navLabel,
404
+ assessmentDescription: practicalAssessmentData.description,
405
+ },
315
406
  ]
316
407
 
317
- for (const { src, pageTitle } of STATIC_PAGES) {
408
+ for (const { src, pageTitle, assessmentTitle, assessmentDescription } of STATIC_PAGES) {
318
409
  const tpl = fs.readFileSync(path.join(TEMPLATES, src), 'utf8')
319
- const out = applyTemplate(tpl, { pageTitle })
410
+ const out = applyTemplate(tpl, { pageTitle, assessmentTitle, assessmentDescription })
320
411
  fs.writeFileSync(path.join(PAGES, src), out)
321
412
  }
322
413
 
@@ -332,4 +423,6 @@ for (const f of htmlFiles) {
332
423
  const indexTpl = fs.readFileSync(path.join(PKG_DIR, 'templates/index.html'), 'utf8')
333
424
  fs.writeFileSync(path.join(PROJECT_DIR, 'index.html'), applyTemplate(indexTpl, { pageTitle: mod.name }))
334
425
 
426
+ copyStaticAssets()
427
+
335
428
  console.log(`Build complete: ${weekCount} weeks → src/data/ and pages/`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curio-sd/e-module-builder",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "A tool for building e-modules for Curio SD",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "templates",
12
12
  "public",
13
13
  "build.mjs",
14
+ "build-pdf.mjs",
14
15
  "vite.config.js",
15
16
  "vite-plugin-html-includes.js"
16
17
  ],
@@ -30,6 +31,7 @@
30
31
  "marked": "^18.0.5",
31
32
  "marked-highlight": "^2.2.4",
32
33
  "monaco-editor": "^0.53.0",
34
+ "pdfkit": "^0.19.1",
33
35
  "tailwindcss": "^4.1.8",
34
36
  "vite": "^8.0.16",
35
37
  "yaml": "^2.9.0"
package/src/css/main.css CHANGED
@@ -23,39 +23,6 @@
23
23
  body {
24
24
  @apply text-zinc-800;
25
25
  }
26
-
27
- /* Given that markdown is placed inside main, we want some basic styling for headings */
28
- main h2 {
29
- @apply mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400;
30
- }
31
-
32
- main h3 {
33
- @apply mt-4 text-base font-bold text-zinc-900
34
- }
35
-
36
- main h4 {
37
- @apply mt-3 text-sm font-semibold text-zinc-900
38
- }
39
-
40
- main h5 {
41
- @apply mt-2 text-sm font-normal uppercase text-zinc-900
42
- }
43
-
44
- main h6 {
45
- @apply mt-2 text-sm font-light uppercase text-zinc-900
46
- }
47
-
48
- main p {
49
- @apply mt-2 text-sm leading-relaxed text-zinc-700;
50
- }
51
-
52
- main ul {
53
- @apply mt-2 text-sm list-inside list-disc space-y-1.5 pl-1;
54
- }
55
-
56
- main ol {
57
- @apply mt-2 text-sm list-inside list-decimal space-y-1.5 pl-1;
58
- }
59
26
  }
60
27
 
61
28
  @layer components {
@@ -189,73 +156,103 @@
189
156
  @apply mx-3 mb-2 mt-1 flex items-center gap-2 border border-zinc-600 bg-zinc-900 px-3 py-2.5 text-[13px] font-medium text-zinc-100 transition hover:border-zinc-500 hover:bg-zinc-800 hover:text-white;
190
157
  }
191
158
 
159
+ .nav-group-toggle {
160
+ @apply flex w-full items-start justify-between px-3 pb-1 pt-4 text-left;
161
+ }
162
+
163
+ .nav-group-chevron {
164
+ @apply mt-0.5 h-3 w-3 shrink-0 text-zinc-500 transition-transform duration-200;
165
+ }
166
+
167
+ .nav-group-children {
168
+ display: grid;
169
+ grid-template-rows: 1fr;
170
+ transition: grid-template-rows 0.2s ease;
171
+ }
172
+
173
+ .nav-group-children.collapsed {
174
+ grid-template-rows: 0fr;
175
+ }
176
+
177
+ .nav-group-inner {
178
+ overflow: hidden;
179
+ }
180
+
192
181
  /* ── Theory prose typography ─────────────────────────────────────────────── */
193
182
 
194
- .prose-theory {
183
+ main {
195
184
  @apply space-y-6 text-zinc-700;
196
185
  }
197
186
 
198
- .prose-theory h2 {
187
+ main h2 {
199
188
  @apply mt-8 text-xl font-semibold text-zinc-900 first:mt-0;
200
189
  }
201
190
 
202
- .prose-theory h3 {
191
+ main h3 {
203
192
  @apply mt-4 text-base font-semibold text-zinc-900;
204
193
  }
205
194
 
206
- .prose-theory p {
195
+ main p {
207
196
  @apply leading-relaxed;
208
197
  }
209
198
 
210
- .prose-theory ul {
199
+ main ul {
211
200
  @apply list-inside list-disc space-y-1.5 pl-1;
212
201
  }
213
202
 
214
- .prose-theory ol {
203
+ main ol {
215
204
  @apply list-inside list-decimal space-y-1.5 pl-1;
216
205
  }
217
206
 
218
- .prose-theory li {
207
+ main li {
219
208
  @apply leading-relaxed;
220
209
  }
221
210
 
222
- .prose-theory a {
211
+ main a {
223
212
  @apply font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
224
213
  }
225
214
 
226
- .prose-theory code {
215
+ main code {
227
216
  @apply rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800;
228
217
  }
229
218
 
230
- .prose-theory pre {
219
+ main pre {
231
220
  @apply overflow-x-auto bg-zinc-950 p-5 font-mono text-[13px] leading-relaxed text-zinc-300;
232
221
  }
233
222
 
234
- .prose-theory pre code {
223
+ main pre code {
235
224
  @apply bg-transparent p-0 text-inherit;
236
225
  }
237
226
 
238
- .prose-theory blockquote {
227
+ main blockquote {
239
228
  @apply border-l-4 border-zinc-300 pl-4 italic text-zinc-500;
240
229
  }
241
230
 
242
- .prose-theory table {
231
+ main table {
243
232
  @apply w-full text-left text-sm;
244
233
  }
245
234
 
246
- .prose-theory thead tr {
235
+ main thead tr {
247
236
  @apply border-b border-zinc-200 text-zinc-500;
248
237
  }
249
238
 
250
- .prose-theory tbody tr {
239
+ main tbody tr {
251
240
  @apply border-b border-zinc-100 last:border-0;
252
241
  }
253
242
 
254
- .prose-theory th,
255
- .prose-theory td {
243
+ main th,
244
+ main td {
256
245
  @apply py-2 pr-4;
257
246
  }
258
247
 
248
+ main details {
249
+ @apply rounded bg-zinc-100 p-4;
250
+ }
251
+
252
+ main details summary {
253
+ @apply cursor-pointer font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
254
+ }
255
+
259
256
  /* ── Custom components ───────────────────────────────────────────────────── */
260
257
 
261
258
  x-card {
@@ -313,4 +310,81 @@
313
310
  x-nav>p>a:last-child {
314
311
  @apply border-transparent bg-zinc-900 text-white hover:border-transparent hover:bg-zinc-800 focus-visible:ring-zinc-900;
315
312
  }
313
+
314
+ /* ── Theory side panel ───────────────────────────────────────────────────── */
315
+
316
+ .theory-panel {
317
+ @apply fixed inset-y-0 right-0 z-40 flex w-[480px] max-w-[90vw] flex-col border-l border-zinc-200 bg-white shadow-xl;
318
+ }
319
+
320
+ .theory-panel.panel-ready {
321
+ @apply transition-transform duration-300 ease-out;
322
+ }
323
+
324
+ .theory-panel.panel-hidden {
325
+ @apply translate-x-full;
326
+ }
327
+
328
+ .theory-panel-header {
329
+ @apply flex shrink-0 items-center border-b border-zinc-200 bg-zinc-50;
330
+ }
331
+
332
+ .theory-panel-tabs {
333
+ @apply flex flex-1 overflow-x-auto;
334
+ scrollbar-width: none;
335
+ }
336
+
337
+ .theory-panel-tabs::-webkit-scrollbar {
338
+ display: none;
339
+ }
340
+
341
+ .theory-tab {
342
+ @apply whitespace-nowrap border-b-2 border-transparent px-4 py-3 text-sm font-medium text-zinc-500 transition hover:text-zinc-900;
343
+ }
344
+
345
+ .theory-tab-active {
346
+ @apply border-zinc-900 text-zinc-900;
347
+ }
348
+
349
+ .theory-panel-close {
350
+ @apply shrink-0 p-3 text-zinc-400 transition hover:text-zinc-700;
351
+ }
352
+
353
+ .theory-panel-body {
354
+ @apply relative min-h-0 flex-1 flex flex-col;
355
+ }
356
+
357
+ .theory-panel-loader {
358
+ @apply absolute inset-0 z-10 flex items-center justify-center bg-white/80 backdrop-blur-sm;
359
+ }
360
+
361
+ .theory-panel-loader-hidden {
362
+ @apply hidden;
363
+ }
364
+
365
+ .theory-panel-spinner {
366
+ @apply h-8 w-8 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700;
367
+ }
368
+
369
+ .theory-panel-iframe {
370
+ @apply min-h-0 flex-1 w-full border-0;
371
+ }
372
+
373
+ .theory-panel-toggle {
374
+ @apply fixed bottom-6 right-6 shadow z-50 flex items-center gap-2 bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white shadow-lg transition hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2;
375
+ }
376
+
377
+ /* ── Embedded theory mode (inside iframe) ────────────────────────────────── */
378
+
379
+ [data-embedded] {
380
+ @apply bg-white;
381
+ }
382
+
383
+ [data-embedded] [data-theory] {
384
+ @apply px-6 py-8;
385
+ }
386
+
387
+ [data-embedded] main {
388
+ @apply !p-0;
389
+ }
316
390
  }
@@ -20,7 +20,7 @@ export function initChecklist() {
20
20
  class="mt-0.5 h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900"
21
21
  ${checked ? 'checked' : ''}
22
22
  />
23
- <span class="text-sm leading-relaxed ${checked ? 'text-zinc-400 line-through' : 'text-zinc-700'}">${item.text}</span>
23
+ <span class="text-sm leading-relaxed ${checked ? 'text-zinc-400 line-through' : 'text-zinc-700'}">${item.textHtml ?? item.text}</span>
24
24
  </label>
25
25
  `
26
26
  })