@datajaddah/course 0.0.2

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.
Files changed (26) hide show
  1. package/README.md +31 -0
  2. package/bin/datajaddah-course.js +238 -0
  3. package/lib/init.js +375 -0
  4. package/lib/preview.js +1617 -0
  5. package/lib/repo-contract.js +1291 -0
  6. package/lib/slide-markdown.js +505 -0
  7. package/package.json +41 -0
  8. package/preview-dist/assets/index-CJUgarn8.css +1 -0
  9. package/preview-dist/assets/index-CavDNP3d.js +49 -0
  10. package/preview-dist/index.html +13 -0
  11. package/scaffold/.agents/rules/general.md +91 -0
  12. package/scaffold/.agents/skills/course-coding-exercises/SKILL.md +22 -0
  13. package/scaffold/.agents/skills/course-coding-exercises/references/coding-exercises.md +111 -0
  14. package/scaffold/.agents/skills/course-platform-overview/SKILL.md +36 -0
  15. package/scaffold/.agents/skills/course-platform-overview/references/platform-overview.md +105 -0
  16. package/scaffold/.agents/skills/course-quizzes/SKILL.md +23 -0
  17. package/scaffold/.agents/skills/course-quizzes/references/quizzes.md +121 -0
  18. package/scaffold/.agents/skills/course-repo-contract/SKILL.md +24 -0
  19. package/scaffold/.agents/skills/course-repo-contract/references/repo-contract.md +169 -0
  20. package/scaffold/.agents/skills/course-slides-v2/SKILL.md +28 -0
  21. package/scaffold/.agents/skills/course-slides-v2/references/fit-guidance.md +31 -0
  22. package/scaffold/.agents/skills/course-slides-v2/references/slides-v2.md +138 -0
  23. package/scaffold/.agents/skills/course-spreadsheet-labs/SKILL.md +23 -0
  24. package/scaffold/.agents/skills/course-spreadsheet-labs/references/spreadsheet-labs.md +239 -0
  25. package/scaffold/.agents/skills/course-video-lessons/SKILL.md +22 -0
  26. package/scaffold/.agents/skills/course-video-lessons/references/video-lessons.md +83 -0
@@ -0,0 +1,505 @@
1
+ const VALID_LAYOUTS = new Set([
2
+ "default",
3
+ "two-column",
4
+ "sidebar-left",
5
+ "sidebar-right",
6
+ ]);
7
+
8
+ function escapeHtml(value) {
9
+ return String(value)
10
+ .replace(/&/g, "&")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+
17
+ function escapeAttribute(value) {
18
+ return escapeHtml(value).replace(/`/g, "&#96;");
19
+ }
20
+
21
+ function normalizeText(value) {
22
+ return value.replace(/\s+/g, " ").trim();
23
+ }
24
+
25
+ function parseAttributeBlock(raw) {
26
+ const attrs = {};
27
+ const source = raw.trim().replace(/^\{/, "").replace(/\}$/, "");
28
+ const pattern = /([a-zA-Z][a-zA-Z0-9_-]*)=("[^"]*"|'[^']*'|[^\s}]+)/g;
29
+ let match;
30
+
31
+ while ((match = pattern.exec(source))) {
32
+ const key = match[1];
33
+ let value = match[2];
34
+ if (
35
+ (value.startsWith('"') && value.endsWith('"')) ||
36
+ (value.startsWith("'") && value.endsWith("'"))
37
+ ) {
38
+ value = value.slice(1, -1);
39
+ }
40
+ attrs[key] = value;
41
+ }
42
+
43
+ return attrs;
44
+ }
45
+
46
+ function extractTrailingAttributeBlock(value) {
47
+ const match = /^(?<body>.*?)(?:\s*(?<attrs>\{[a-zA-Z][^{}]*\}))$/.exec(value.trim());
48
+ if (!match?.groups) {
49
+ return { body: value.trim(), attrs: {} };
50
+ }
51
+
52
+ return {
53
+ body: match.groups.body.trim(),
54
+ attrs: parseAttributeBlock(match.groups.attrs),
55
+ };
56
+ }
57
+
58
+ function createInlinePlaceholder(index) {
59
+ return `__INLINE_TOKEN_${index}__`;
60
+ }
61
+
62
+ function appendInlineCodePlaceholders(text) {
63
+ const placeholders = [];
64
+ const replaced = text.replace(/`([^`]+)`/g, (_, code) => {
65
+ const placeholder = createInlinePlaceholder(placeholders.length);
66
+ placeholders.push(`<code>${escapeHtml(code)}</code>`);
67
+ return placeholder;
68
+ });
69
+ return { replaced, placeholders };
70
+ }
71
+
72
+ function renderInlineMarkdown(text) {
73
+ const { replaced, placeholders } = appendInlineCodePlaceholders(text);
74
+ let html = replaced.replace(
75
+ /==(?<body>.+?)==(?<attrs>\{[a-zA-Z][^{}]*\})/g,
76
+ (match, _body, _attrs, _offset, _source, groups) => {
77
+ const step = parseAttributeBlock(groups?.attrs ?? "").highlight;
78
+ if (!step) {
79
+ return match;
80
+ }
81
+ const placeholder = createInlinePlaceholder(placeholders.length);
82
+ placeholders.push(
83
+ `<span data-highlight-step="${escapeAttribute(step)}" class="step-highlight-mark">${renderInlineMarkdown(
84
+ groups?.body ?? ""
85
+ )}</span>`
86
+ );
87
+ return placeholder;
88
+ }
89
+ );
90
+
91
+ html = escapeHtml(html);
92
+
93
+ html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
94
+ const safeUrl = escapeAttribute(url);
95
+ return `<a href="${safeUrl}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>`;
96
+ });
97
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
98
+ html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
99
+
100
+ placeholders.forEach((placeholder, index) => {
101
+ html = html.replace(createInlinePlaceholder(index), placeholder);
102
+ });
103
+
104
+ return html;
105
+ }
106
+
107
+ function buildBlockAttributeString(attrs = {}) {
108
+ const rendered = [];
109
+
110
+ if (attrs.delay != null) {
111
+ rendered.push(` data-delay="${escapeAttribute(attrs.delay)}"`);
112
+ }
113
+ if (attrs.highlight != null) {
114
+ rendered.push(` data-highlight="${escapeAttribute(attrs.highlight)}"`);
115
+ }
116
+
117
+ return rendered.join("");
118
+ }
119
+
120
+ function buildImageAttributeString(attrs = {}) {
121
+ const rendered = [buildBlockAttributeString(attrs)];
122
+ const styleFragments = [];
123
+
124
+ if (attrs.width != null) {
125
+ rendered.push(` data-width="${escapeAttribute(attrs.width)}"`);
126
+ styleFragments.push(`width:${/^\d+$/.test(String(attrs.width)) ? `${attrs.width}px` : attrs.width}`);
127
+ }
128
+ if (attrs.height != null) {
129
+ rendered.push(` data-height="${escapeAttribute(attrs.height)}"`);
130
+ styleFragments.push(
131
+ `height:${/^\d+$/.test(String(attrs.height)) ? `${attrs.height}px` : attrs.height}`
132
+ );
133
+ }
134
+ if (attrs.align != null) {
135
+ rendered.push(` data-align="${escapeAttribute(attrs.align)}"`);
136
+ }
137
+
138
+ if (styleFragments.length > 0) {
139
+ rendered.push(` style="${escapeAttribute(styleFragments.join(";"))}"`);
140
+ }
141
+
142
+ return rendered.join("");
143
+ }
144
+
145
+ function isHeadingLine(line) {
146
+ return /^(#{1,6})\s+/.test(line.trim());
147
+ }
148
+
149
+ function isFenceStart(line) {
150
+ return line.trim().startsWith("```");
151
+ }
152
+
153
+ function isBlockQuoteLine(line) {
154
+ return /^>\s?/.test(line.trim());
155
+ }
156
+
157
+ function isHorizontalRule(line) {
158
+ return /^---+$/.test(line.trim());
159
+ }
160
+
161
+ function parseImageLine(line) {
162
+ const trimmed = line.trim();
163
+ const { body, attrs } = extractTrailingAttributeBlock(trimmed);
164
+ const match = /^!\[(?<alt>[^\]]*)\]\((?<src>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)$/.exec(body);
165
+ if (!match?.groups) {
166
+ return null;
167
+ }
168
+
169
+ return {
170
+ alt: match.groups.alt || "",
171
+ src: match.groups.src || "",
172
+ title: match.groups.title || "",
173
+ attrs,
174
+ };
175
+ }
176
+
177
+ function buildImageHtml(image) {
178
+ const extraAttrs = [];
179
+ if (image.alt) {
180
+ extraAttrs.push(` alt="${escapeAttribute(image.alt)}"`);
181
+ }
182
+ if (image.title) {
183
+ extraAttrs.push(` title="${escapeAttribute(image.title)}"`);
184
+ }
185
+ if (image.src.startsWith("slot:")) {
186
+ extraAttrs.push(` data-image-slot="${escapeAttribute(image.src.slice("slot:".length))}"`);
187
+ }
188
+
189
+ return `<img src="${escapeAttribute(image.src)}"${extraAttrs.join("")}${buildImageAttributeString(
190
+ image.attrs
191
+ )} />`;
192
+ }
193
+
194
+ function renderParagraph(lines, lineNumber) {
195
+ const paragraphLines = [...lines];
196
+ const lastLine = paragraphLines.pop() || "";
197
+ const { body: lastBody, attrs } = extractTrailingAttributeBlock(lastLine);
198
+ paragraphLines.push(lastBody);
199
+ const body = normalizeText(paragraphLines.join(" "));
200
+ if (!body) {
201
+ return "";
202
+ }
203
+
204
+ return `<p${buildBlockAttributeString(attrs)}>${renderInlineMarkdown(body)}</p>`;
205
+ }
206
+
207
+ function renderList(lines, startIndex, onError) {
208
+ const ordered = /^\d+\.\s+/.test(lines[startIndex].trim());
209
+ const items = [];
210
+ let index = startIndex;
211
+
212
+ while (index < lines.length) {
213
+ const current = lines[index].trim();
214
+ const currentMatch = ordered
215
+ ? /^(\d+)\.\s+(.*)$/.exec(current)
216
+ : /^([-*+])\s+(.*)$/.exec(current);
217
+ if (!currentMatch) {
218
+ break;
219
+ }
220
+
221
+ const lineNumber = index + 1;
222
+ const itemLines = [currentMatch[2]];
223
+ index += 1;
224
+
225
+ while (index < lines.length) {
226
+ const nextLine = lines[index];
227
+ const trimmed = nextLine.trim();
228
+ if (!trimmed) {
229
+ break;
230
+ }
231
+ if (isHeadingLine(nextLine) || isFenceStart(nextLine) || isBlockQuoteLine(nextLine)) {
232
+ break;
233
+ }
234
+ if (parseImageLine(nextLine)) {
235
+ break;
236
+ }
237
+ if (isHorizontalRule(nextLine)) {
238
+ break;
239
+ }
240
+ if (/^([-*+])\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
241
+ break;
242
+ }
243
+
244
+ itemLines.push(trimmed);
245
+ index += 1;
246
+ }
247
+
248
+ const { body, attrs } = extractTrailingAttributeBlock(normalizeText(itemLines.join(" ")));
249
+ if (!body) {
250
+ onError("List items cannot be empty", lineNumber);
251
+ }
252
+
253
+ items.push(`<li${buildBlockAttributeString(attrs)}>${renderInlineMarkdown(body)}</li>`);
254
+
255
+ if (!lines[index]?.trim()) {
256
+ break;
257
+ }
258
+ }
259
+
260
+ return {
261
+ html: `<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`,
262
+ nextIndex: index,
263
+ };
264
+ }
265
+
266
+ function renderBlocks(lines, onError, lineOffset = 0) {
267
+ const blocks = [];
268
+ let index = 0;
269
+
270
+ while (index < lines.length) {
271
+ const rawLine = lines[index];
272
+ const trimmed = rawLine.trim();
273
+ const lineNumber = lineOffset + index + 1;
274
+
275
+ if (!trimmed) {
276
+ index += 1;
277
+ continue;
278
+ }
279
+
280
+ if (isFenceStart(rawLine)) {
281
+ const language = trimmed.slice(3).trim();
282
+ const codeLines = [];
283
+ index += 1;
284
+ while (index < lines.length && !lines[index].trim().startsWith("```")) {
285
+ codeLines.push(lines[index]);
286
+ index += 1;
287
+ }
288
+ if (index < lines.length) {
289
+ index += 1;
290
+ } else {
291
+ onError("Code fence is missing a closing ```", lineNumber);
292
+ }
293
+ const className = language ? ` class="language-${escapeAttribute(language)}"` : "";
294
+ blocks.push(`<pre><code${className}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
295
+ continue;
296
+ }
297
+
298
+ if (isHeadingLine(rawLine)) {
299
+ const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed);
300
+ const level = headingMatch[1].length;
301
+ const { body, attrs } = extractTrailingAttributeBlock(headingMatch[2]);
302
+ blocks.push(
303
+ `<h${level}${buildBlockAttributeString(attrs)}>${renderInlineMarkdown(body)}</h${level}>`
304
+ );
305
+ index += 1;
306
+ continue;
307
+ }
308
+
309
+ const image = parseImageLine(rawLine);
310
+ if (image) {
311
+ blocks.push(buildImageHtml(image));
312
+ index += 1;
313
+ continue;
314
+ }
315
+
316
+ if (/^([-*+])\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
317
+ const { html, nextIndex } = renderList(lines, index, onError);
318
+ blocks.push(html);
319
+ index = nextIndex;
320
+ continue;
321
+ }
322
+
323
+ if (isBlockQuoteLine(rawLine)) {
324
+ const quoteLines = [];
325
+ while (index < lines.length && isBlockQuoteLine(lines[index])) {
326
+ quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
327
+ index += 1;
328
+ }
329
+ blocks.push(`<blockquote>${renderBlocks(quoteLines, onError)}</blockquote>`);
330
+ continue;
331
+ }
332
+
333
+ if (isHorizontalRule(rawLine)) {
334
+ blocks.push("<hr />");
335
+ index += 1;
336
+ continue;
337
+ }
338
+
339
+ const paragraphLines = [];
340
+ while (index < lines.length) {
341
+ const paragraphLine = lines[index];
342
+ const paragraphTrimmed = paragraphLine.trim();
343
+ if (!paragraphTrimmed) {
344
+ break;
345
+ }
346
+ if (
347
+ isFenceStart(paragraphLine) ||
348
+ isHeadingLine(paragraphLine) ||
349
+ isBlockQuoteLine(paragraphLine) ||
350
+ isHorizontalRule(paragraphLine) ||
351
+ parseImageLine(paragraphLine) ||
352
+ /^([-*+])\s+/.test(paragraphTrimmed) ||
353
+ /^\d+\.\s+/.test(paragraphTrimmed)
354
+ ) {
355
+ break;
356
+ }
357
+ paragraphLines.push(paragraphLine);
358
+ index += 1;
359
+ }
360
+
361
+ blocks.push(renderParagraph(paragraphLines, lineNumber));
362
+ }
363
+
364
+ return blocks.filter(Boolean).join("");
365
+ }
366
+
367
+ function extractLayout(lines, onError) {
368
+ const normalized = [...lines];
369
+ let layout = "default";
370
+ let layoutLine = null;
371
+
372
+ for (let index = 0; index < normalized.length; index += 1) {
373
+ const trimmed = normalized[index].trim();
374
+ if (!trimmed) {
375
+ continue;
376
+ }
377
+ if (trimmed.startsWith("@layout:")) {
378
+ layout = trimmed.slice("@layout:".length).trim();
379
+ layoutLine = index + 1;
380
+ normalized.splice(index, 1);
381
+ }
382
+ break;
383
+ }
384
+
385
+ if (!VALID_LAYOUTS.has(layout)) {
386
+ onError(
387
+ "layout must be one of `default`, `two-column`, `sidebar-left`, or `sidebar-right`",
388
+ layoutLine || 1
389
+ );
390
+ }
391
+
392
+ return { layout, lines: normalized };
393
+ }
394
+
395
+ function splitColumns(lines, onError) {
396
+ const columns = { 1: [], 2: [] };
397
+ let currentColumn = null;
398
+ let seenFirst = false;
399
+ let seenSecond = false;
400
+
401
+ for (let index = 0; index < lines.length; index += 1) {
402
+ const trimmed = lines[index].trim();
403
+ if (trimmed === "@column: 1") {
404
+ if (seenFirst) {
405
+ onError("`@column: 1` can appear only once", index + 1);
406
+ }
407
+ seenFirst = true;
408
+ currentColumn = 1;
409
+ continue;
410
+ }
411
+ if (trimmed === "@column: 2") {
412
+ if (!seenFirst) {
413
+ onError("`@column: 2` must come after `@column: 1`", index + 1);
414
+ }
415
+ if (seenSecond) {
416
+ onError("`@column: 2` can appear only once", index + 1);
417
+ }
418
+ seenSecond = true;
419
+ currentColumn = 2;
420
+ continue;
421
+ }
422
+
423
+ if (currentColumn == null) {
424
+ if (trimmed) {
425
+ onError("Column layouts must start with `@column: 1`", index + 1);
426
+ }
427
+ continue;
428
+ }
429
+
430
+ columns[currentColumn].push(lines[index]);
431
+ }
432
+
433
+ if (!seenFirst || !seenSecond) {
434
+ onError("Column layouts must include both `@column: 1` and `@column: 2`", 1);
435
+ }
436
+
437
+ return columns;
438
+ }
439
+
440
+ export function renderSlideMarkdown(markdown, { onError } = {}) {
441
+ const fail =
442
+ onError ||
443
+ ((message, line) => {
444
+ const error = new Error(message);
445
+ error.line = line;
446
+ throw error;
447
+ });
448
+
449
+ const source = (markdown || "").replace(/\r\n/g, "\n");
450
+ const baseLines = source.split("\n");
451
+ const { layout, lines } = extractLayout(baseLines, fail);
452
+
453
+ if (layout === "default") {
454
+ if (lines.some((line) => line.trim().startsWith("@column:"))) {
455
+ fail("`@column` markers require a non-default `@layout`", 1);
456
+ }
457
+ return {
458
+ layout,
459
+ html: renderBlocks(lines, fail),
460
+ };
461
+ }
462
+
463
+ const columns = splitColumns(lines, fail);
464
+ return {
465
+ layout,
466
+ html:
467
+ `<div data-type="columns" class="layout-${layout}">` +
468
+ `<div data-type="column" data-position="left">${renderBlocks(columns[1], fail)}</div>` +
469
+ `<div data-type="column" data-position="right">${renderBlocks(columns[2], fail)}</div>` +
470
+ `</div>`,
471
+ };
472
+ }
473
+
474
+ export function replaceSlotImagesForPreview(html) {
475
+ return String(html || "").replace(
476
+ /<img\b([^>]*)\bsrc="slot:([^"]+)"([^>]*)>/gi,
477
+ (_match, before, slotRef, after) => {
478
+ const altMatch = /\balt="([^"]*)"/i.exec(`${before} ${after}`);
479
+ const titleMatch = /\btitle="([^"]*)"/i.exec(`${before} ${after}`);
480
+ const widthMatch = /\bdata-width="([^"]*)"/i.exec(`${before} ${after}`);
481
+ const alignMatch = /\bdata-align="([^"]*)"/i.exec(`${before} ${after}`);
482
+ const style = [];
483
+
484
+ if (widthMatch?.[1]) {
485
+ style.push(`width:${/^\d+$/.test(widthMatch[1]) ? `${widthMatch[1]}px` : widthMatch[1]}`);
486
+ }
487
+ if (alignMatch?.[1] === "left") {
488
+ style.push("margin-right:auto");
489
+ } else if (alignMatch?.[1] === "right") {
490
+ style.push("margin-left:auto");
491
+ } else {
492
+ style.push("margin-left:auto", "margin-right:auto");
493
+ }
494
+
495
+ return (
496
+ `<div class="slide-image-slot"${style.length ? ` style="${escapeAttribute(style.join(";"))}"` : ""}>` +
497
+ `<div class="slide-image-slot__badge">Image slot</div>` +
498
+ `<div class="slide-image-slot__ref">${escapeHtml(slotRef)}</div>` +
499
+ `${altMatch?.[1] ? `<div class="slide-image-slot__alt">${escapeHtml(altMatch[1])}</div>` : ""}` +
500
+ `${titleMatch?.[1] ? `<div class="slide-image-slot__title">${escapeHtml(titleMatch[1])}</div>` : ""}` +
501
+ `</div>`
502
+ );
503
+ }
504
+ );
505
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@datajaddah/course",
3
+ "version": "0.0.2",
4
+ "description": "CLI for authoring, validating, and previewing Datajaddah courses from GitHub repositories",
5
+ "license": "UNLICENSED",
6
+ "author": "Datajaddah",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/BasimAlsaedi-analytics/jaddat-al-bayanat.git",
10
+ "directory": "packages/course"
11
+ },
12
+ "keywords": [
13
+ "datajaddah",
14
+ "course",
15
+ "authoring",
16
+ "cli",
17
+ "validation"
18
+ ],
19
+ "type": "module",
20
+ "bin": {
21
+ "datajaddah-course": "./bin/datajaddah-course.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "lib/",
26
+ "scaffold/",
27
+ "preview-dist/"
28
+ ],
29
+ "scripts": {
30
+ "validate": "node ./bin/datajaddah-course.js validate",
31
+ "scaffold": "node ./bin/datajaddah-course.js init",
32
+ "preview": "node ./bin/datajaddah-course.js preview",
33
+ "build": "npm run build:preview",
34
+ "build:preview": "cd preview-app && npm install && npm run build",
35
+ "lint": "node --check ./bin/datajaddah-course.js && node --check ./lib/init.js && node --check ./lib/repo-contract.js && node --check ./lib/preview.js && node --check ./test/repo-contract.test.mjs && node --check ./test/preview.test.mjs",
36
+ "test": "node --test ./test/repo-contract.test.mjs ./test/preview.test.mjs"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ *,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{font-size:15px;-webkit-font-smoothing:antialiased}body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;color:#111827;background:#f9fafb;line-height:1.6}a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}code{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;background:#f3f4f6;padding:.15em .4em;border-radius:4px;font-size:.875em}pre{margin:0}pre code{background:transparent;padding:0;border-radius:0;font-size:.875em}img{max-width:100%}table{border-collapse:collapse;width:100%;font-size:.875rem}th,td{border:1px solid #e5e7eb;padding:.5rem .75rem;text-align:left}th{background:#f9fafb;font-weight:600}blockquote{border-left:3px solid #d1d5db;padding-left:1rem;color:#6b7280;margin:.75rem 0}hr{border:none;border-top:1px solid #e5e7eb;margin:1rem 0}h1,h2,h3,h4,h5,h6{line-height:1.3;margin-bottom:.5rem}h1{font-size:1.5rem;font-weight:700}h2{font-size:1.25rem;font-weight:600}h3{font-size:1.1rem;font-weight:600}p{margin-bottom:.5rem}p:last-child{margin-bottom:0}ul,ol{margin:.5rem 0;padding-left:1.5rem}li{margin-bottom:.25rem}.app{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto;min-height:100vh}.topbar{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 1.25rem;background:#fff;border-bottom:1px solid #e5e7eb}.topbar-title{font-weight:700;font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.topbar-meta{font-size:.8rem;color:#6b7280;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.sidebar{grid-row:2 / 4;border-right:1px solid #e5e7eb;background:#fff;overflow-y:auto;padding:.75rem}.main{grid-row:2;padding:1.25rem;overflow-y:auto}.footer{grid-column:2;padding:.5rem 1.25rem;font-size:.8rem;color:#6b7280;border-top:1px solid #e5e7eb;background:#fff}.loading-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;gap:1rem;color:#6b7280}.loading-spinner{width:32px;height:32px;border:3px solid #e5e7eb;border-top-color:#2563eb;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.error-screen{display:grid;place-items:center;min-height:100vh;padding:2rem;background:#f9fafb}.error-card{max-width:680px;width:100%;background:#fff;border:1px solid #e5e7eb;border-radius:.75rem;padding:2rem}.error-card h1{font-size:1.25rem;margin:.5rem 0}.error-card pre{white-space:pre-wrap;word-break:break-word;background:#1e1e2e;color:#cdd6f4;border-radius:.5rem;padding:1rem;font-size:.85rem;margin:1rem 0}.error-card p{color:#6b7280}.outline-chapter+.outline-chapter{margin-top:.75rem}.outline-chapter-title{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;padding:.25rem .5rem}.outline-chapter-lessons{display:flex;flex-direction:column;gap:2px}.outline-lesson{display:flex;align-items:center;gap:.5rem;width:100%;text-align:left;border:none;background:transparent;border-radius:.5rem;padding:.5rem;cursor:pointer;font:inherit;color:inherit;transition:background .1s}.outline-lesson:hover{background:#f3f4f6}.outline-lesson.is-active{background:#eff6ff;color:#1d4ed8}.outline-lesson-icon{flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px;background:#f3f4f6;color:#6b7280}.outline-lesson.is-active .outline-lesson-icon{background:#dbeafe;color:#2563eb}.outline-lesson-info{display:flex;flex-direction:column;min-width:0}.outline-lesson-title{font-size:.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.outline-lesson-kind{font-size:.7rem;color:#9ca3af}.outline-lesson.is-active .outline-lesson-kind{color:#60a5fa}.card{background:#fff;border:1px solid #e5e7eb;border-radius:.75rem;overflow:hidden}.card-inset{padding:1rem}.card-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:.5rem}.card-value{font-weight:500}.badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.75rem;font-weight:500;padding:.2rem .6rem;border-radius:999px;background:#f3f4f6;color:#374151;white-space:nowrap}.badge-blue{background:#eff6ff;color:#2563eb}.badge-green{background:#ecfdf5;color:#059669}.badge-kind{background:#f0fdf4;color:#16a34a;border:1px solid #bbf7d0}.btn{display:inline-flex;align-items:center;gap:.25rem;font:inherit;font-size:.85rem;font-weight:500;padding:.45rem 1rem;border:1px solid #d1d5db;border-radius:.5rem;background:#fff;color:#111827;cursor:pointer;transition:background .1s}.btn:hover{background:#f9fafb}.btn:disabled{opacity:.4;cursor:not-allowed}.btn-sm{padding:.3rem .7rem;font-size:.8rem}.btn-ghost{border-color:transparent;background:transparent;color:#6b7280}.btn-ghost:hover{background:#f3f4f6}.tab-bar{display:flex;gap:2px;background:#f3f4f6;border-radius:.5rem;padding:2px}.tab{font:inherit;font-size:.8rem;font-weight:500;padding:.35rem .75rem;border:none;border-radius:.375rem;background:transparent;color:#6b7280;cursor:pointer;white-space:nowrap}.tab:hover{color:#111827}.tab-active{background:#fff;color:#111827;box-shadow:0 1px 2px #0000000f}.lesson-header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:1rem}.lesson-title{margin-bottom:.15rem}.lesson-summary{margin-bottom:1rem}.text-muted{color:#6b7280}.text-xs{font-size:.75rem}.slide-container{display:flex;flex-direction:column;gap:.75rem}.slide-toolbar{display:flex;align-items:center;justify-content:space-between}.slide-nav{display:flex;gap:.35rem;align-items:center}.slide-stage{background:#fff;border:1px solid #e5e7eb;border-radius:.75rem;padding:2rem;min-height:280px;aspect-ratio:16 / 9;overflow:auto}.slide-stage h1,.slide-stage h2,.slide-stage h3{margin-top:0}.slide-stage [data-type=columns]{display:grid;gap:1.5rem}.slide-stage [data-type=columns].layout-two-column{grid-template-columns:1fr 1fr}.slide-stage [data-type=columns].layout-sidebar-left{grid-template-columns:40fr 60fr}.slide-stage [data-type=columns].layout-sidebar-right{grid-template-columns:60fr 40fr}.slide-stage img{max-width:100%;border-radius:.5rem;display:block;margin:1rem auto}.slide-stage .slide-image-slot{border:2px dashed #d1d5db;border-radius:.5rem;padding:1rem;background:#f9fafb}.slide-stage .slide-image-slot__badge{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:.25rem}.slide-stage .slide-image-slot__ref{font-weight:600}.slide-stage .slide-image-slot__alt,.slide-stage .slide-image-slot__title{color:#6b7280;margin-top:.25rem}.slide-meta{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.narration-text{font-style:italic;color:#374151;line-height:1.7;white-space:pre-wrap}.video-container{display:flex;flex-direction:column;gap:.75rem}.video-frame{border-radius:.75rem;overflow:hidden;aspect-ratio:16 / 9;background:#111}.video-frame iframe,.video-frame video{width:100%;height:100%;border:0;display:block}.video-link{padding:2rem;background:#fff;border:1px solid #e5e7eb;border-radius:.75rem;text-align:center}.quiz-container{display:flex;flex-direction:column;gap:1rem}.question-card{background:#fff;border:1px solid #e5e7eb;border-radius:.75rem;padding:1.25rem;display:flex;flex-direction:column;gap:.75rem}.question-header{display:flex;align-items:center;gap:.5rem}.question-counter{font-size:.8rem;font-weight:600;color:#6b7280}.question-prompt{color:#374151}.separator{font-size:.7rem;font-weight:700;letter-spacing:.08em;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:.75rem}.choices-grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}.choice{display:flex;align-items:flex-start;gap:.6rem;padding:.75rem;border:1px solid #e5e7eb;border-radius:.5rem;background:#fff;cursor:pointer;transition:border-color .1s}.choice:hover{border-color:#93c5fd}.choice input{margin-top:.2rem;flex-shrink:0}.choice-body{flex:1;min-width:0}.choice-feedback{margin-top:.35rem;font-size:.85rem;color:#6b7280}.choice-correct{border-color:#86efac;background:#f0fdf4}.choice-wrong{border-color:#fca5a5;background:#fef2f2}.question-actions{display:flex;gap:.5rem}.quiz-result{padding:.75rem 1rem;border-radius:.5rem;font-weight:500;font-size:.9rem}.quiz-result-correct{background:#ecfdf5;color:#065f46}.quiz-result-wrong{background:#fef2f2;color:#991b1b}.exercise-split{display:grid;grid-template-columns:1fr 1fr;gap:0;border:1px solid #e5e7eb;border-radius:.75rem;overflow:hidden;min-height:400px}.exercise-left{padding:1rem;overflow-y:auto;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;gap:.75rem}.exercise-right{display:flex;flex-direction:column;background:#fafafa}.exercise-prompt{flex:1}.code-toolbar{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem;border-bottom:1px solid #e5e7eb;background:#fff}.code-block{flex:1;padding:1rem;overflow:auto;background:#1e1e2e;color:#cdd6f4;font-size:.85rem;line-height:1.6;border-radius:0;margin:0}.checks-list{display:flex;flex-direction:column;gap:.5rem}.check-item{padding:.5rem .75rem;border:1px solid #e5e7eb;border-radius:.375rem;background:#fff}.check-label{font-weight:500;margin-bottom:.25rem}.check-meta{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.wb-container{flex:1;overflow:auto;padding:.75rem}.wb-table{font-size:.8rem;border-collapse:collapse}.wb-table th,.wb-table td{border:1px solid #e5e7eb;padding:.25rem .5rem;min-width:60px}.wb-table th{background:#f3f4f6;text-align:center;font-weight:600;color:#6b7280;font-size:.75rem}.wb-table .row-num{background:#f3f4f6;text-align:center;font-weight:600;color:#6b7280;font-size:.75rem;width:32px}.wb-table .cell-formula{color:#2563eb;font-family:ui-monospace,monospace;font-size:.8rem}.sheet-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:.25rem}.sheet-label+.table-scroll{margin-bottom:.75rem}.table-scroll{overflow-x:auto}.warnings-bar{background:#fffbeb;border:1px solid #fde68a;border-radius:.75rem;padding:.75rem 1rem;margin-bottom:1rem;font-size:.85rem;color:#92400e}.warnings-bar ul{margin:.5rem 0 0;padding-left:1.25rem}.task-list{list-style:none;padding-left:0}.task-item{display:flex;align-items:baseline;gap:.4rem}.task-item input{flex-shrink:0}@media(max-width:860px){.app{grid-template-columns:1fr}.sidebar{grid-row:auto;border-right:0;border-bottom:1px solid #e5e7eb;max-height:240px}.footer{grid-column:1}.slide-meta,.exercise-split{grid-template-columns:1fr}.exercise-left{border-right:0;border-bottom:1px solid #e5e7eb}.choices-grid{grid-template-columns:1fr}}@media print{.sidebar,.topbar,.footer{display:none}.app{display:block}.main{padding:0}.card,.question-card,.exercise-split{break-inside:avoid}}