@graphlearning/shell 0.10.0 → 0.10.1
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/dist/index.js +52 -52
- package/dist/index.js.map +1 -1
- package/dist/styles.css +9 -26
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as t, jsxs as h, Fragment as H } from "react/jsx-runtime";
|
|
2
|
-
import { useRef as
|
|
2
|
+
import { useRef as T, useState as b, useLayoutEffect as O, useEffect as k, useCallback as I, useMemo as M } from "react";
|
|
3
3
|
import { SceneView as R } from "@graphlearning/flow";
|
|
4
4
|
import { Home as Y, Volume2 as Z, VolumeX as J, Moon as Q, Sun as U, ChevronLeft as ee, ChevronRight as te } from "lucide-react";
|
|
5
5
|
import ne from "react-markdown";
|
|
@@ -12,40 +12,40 @@ function D(e) {
|
|
|
12
12
|
}
|
|
13
13
|
const G = 806;
|
|
14
14
|
function re() {
|
|
15
|
-
const e =
|
|
15
|
+
const e = T(null), [a, s] = b(1);
|
|
16
16
|
return O(() => {
|
|
17
17
|
const o = e.current;
|
|
18
18
|
if (!o) return;
|
|
19
|
-
const d = () =>
|
|
19
|
+
const d = () => s(o.clientWidth / G);
|
|
20
20
|
d();
|
|
21
21
|
const g = new ResizeObserver(d);
|
|
22
22
|
return g.observe(o), () => g.disconnect();
|
|
23
23
|
}, []), { ref: e, zoom: a };
|
|
24
24
|
}
|
|
25
25
|
function se({ slide: e, open: a }) {
|
|
26
|
-
const { ref:
|
|
27
|
-
return /* @__PURE__ */ t("aside", { ref:
|
|
26
|
+
const { ref: s, zoom: o } = re();
|
|
27
|
+
return /* @__PURE__ */ t("aside", { ref: s, className: `slide-panel${a ? " slide-panel--open" : ""}`, children: /* @__PURE__ */ t("div", { className: "slide-panel__scaler", style: { width: G, zoom: o }, children: /* @__PURE__ */ t(ne, { remarkPlugins: [ae], children: e }) }) });
|
|
28
28
|
}
|
|
29
29
|
const z = 10;
|
|
30
30
|
function ie({ audio: e }) {
|
|
31
|
-
const [a,
|
|
31
|
+
const [a, s] = b(0), [o, d] = b(0), g = T(null);
|
|
32
32
|
if (k(() => {
|
|
33
33
|
if (!e) return;
|
|
34
34
|
const n = () => {
|
|
35
|
-
|
|
35
|
+
s(e.currentTime), d(Number.isFinite(e.duration) ? e.duration : 0);
|
|
36
36
|
};
|
|
37
37
|
n();
|
|
38
38
|
const u = ["loadedmetadata", "durationchange", "timeupdate", "seeked", "ended", "emptied", "error"];
|
|
39
|
-
return u.forEach((
|
|
39
|
+
return u.forEach((r) => e.addEventListener(r, n)), () => u.forEach((r) => e.removeEventListener(r, n));
|
|
40
40
|
}, [e]), !e || !o) return null;
|
|
41
41
|
const f = (n) => {
|
|
42
42
|
const u = g.current;
|
|
43
43
|
if (!u) return;
|
|
44
|
-
const
|
|
45
|
-
e.currentTime = m * o,
|
|
44
|
+
const r = u.getBoundingClientRect(), m = Math.min(1, Math.max(0, (n - r.left) / r.width));
|
|
45
|
+
e.currentTime = m * o, s(e.currentTime);
|
|
46
46
|
}, l = (n) => {
|
|
47
|
-
const u = (
|
|
48
|
-
n.preventDefault(), n.stopPropagation(), e.currentTime = Math.min(o, Math.max(0, e.currentTime +
|
|
47
|
+
const u = (r) => {
|
|
48
|
+
n.preventDefault(), n.stopPropagation(), e.currentTime = Math.min(o, Math.max(0, e.currentTime + r)), s(e.currentTime);
|
|
49
49
|
};
|
|
50
50
|
n.key === "ArrowLeft" ? u(-z) : n.key === "ArrowRight" ? u(z) : n.key === "Home" ? u(-o) : n.key === "End" && u(o);
|
|
51
51
|
}, p = `${Math.min(100, a / o * 100)}%`;
|
|
@@ -86,7 +86,7 @@ function A(e) {
|
|
|
86
86
|
function oe({
|
|
87
87
|
section: e,
|
|
88
88
|
getScene: a,
|
|
89
|
-
capture:
|
|
89
|
+
capture: s = !1,
|
|
90
90
|
eyebrow: o,
|
|
91
91
|
index: d = 0,
|
|
92
92
|
total: g = 0,
|
|
@@ -95,17 +95,17 @@ function oe({
|
|
|
95
95
|
onNext: p,
|
|
96
96
|
narrating: n = !1,
|
|
97
97
|
onToggleNarration: u,
|
|
98
|
-
narrationAudio:
|
|
98
|
+
narrationAudio: r = null,
|
|
99
99
|
theme: m = "dark",
|
|
100
100
|
onToggleTheme: v
|
|
101
101
|
}) {
|
|
102
102
|
const [_, w] = b(!1), E = a(e.scene);
|
|
103
|
-
return E ? /* @__PURE__ */ h("div", { className:
|
|
103
|
+
return E ? /* @__PURE__ */ h("div", { className: "stage stage--section", children: [
|
|
104
104
|
/* @__PURE__ */ h("header", { className: "reel-head", children: [
|
|
105
105
|
o && /* @__PURE__ */ t("button", { className: "reel-head__eyebrow", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: o }),
|
|
106
106
|
/* @__PURE__ */ t("h1", { className: "reel-head__title", children: e.title })
|
|
107
107
|
] }),
|
|
108
|
-
!
|
|
108
|
+
!s && /* @__PURE__ */ h("footer", { className: "reel-foot reel-foot--controls", children: [
|
|
109
109
|
/* @__PURE__ */ h("span", { className: "reel-foot__grp", children: [
|
|
110
110
|
/* @__PURE__ */ t("button", { className: "reel-foot__ctrl", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: /* @__PURE__ */ t(Y, { size: 17 }) }),
|
|
111
111
|
/* @__PURE__ */ t(
|
|
@@ -129,7 +129,7 @@ function oe({
|
|
|
129
129
|
}
|
|
130
130
|
)
|
|
131
131
|
] }),
|
|
132
|
-
/* @__PURE__ */ t(ie, { audio:
|
|
132
|
+
/* @__PURE__ */ t(ie, { audio: r }),
|
|
133
133
|
/* @__PURE__ */ h("span", { className: "reel-foot__nav", children: [
|
|
134
134
|
/* @__PURE__ */ t("button", { className: "reel-foot__ctrl", onClick: l, "aria-label": "Previous section (Shift+←)", title: "Previous section (Shift+←)", children: /* @__PURE__ */ t(ee, { size: 19 }) }),
|
|
135
135
|
/* @__PURE__ */ h("span", { className: "reel-foot__count reel-foot__count--live", children: [
|
|
@@ -141,7 +141,7 @@ function oe({
|
|
|
141
141
|
] })
|
|
142
142
|
] }),
|
|
143
143
|
/* @__PURE__ */ t("div", { className: "scene-area", children: /* @__PURE__ */ t(R, { scene: E, focusId: e.focus, theme: m }) }),
|
|
144
|
-
!
|
|
144
|
+
!s && /* @__PURE__ */ t(
|
|
145
145
|
"button",
|
|
146
146
|
{
|
|
147
147
|
className: "slide-toggle",
|
|
@@ -173,11 +173,11 @@ function ce() {
|
|
|
173
173
|
function de({
|
|
174
174
|
kind: e = "courses",
|
|
175
175
|
nav: a = le,
|
|
176
|
-
home:
|
|
176
|
+
home: s = "/",
|
|
177
177
|
actions: o
|
|
178
178
|
}) {
|
|
179
179
|
return /* @__PURE__ */ t("header", { className: "site", children: /* @__PURE__ */ h("div", { className: "site__inner", children: [
|
|
180
|
-
/* @__PURE__ */ h("a", { className: "site__brand", href:
|
|
180
|
+
/* @__PURE__ */ h("a", { className: "site__brand", href: s, children: [
|
|
181
181
|
/* @__PURE__ */ t(ce, {}),
|
|
182
182
|
/* @__PURE__ */ t("span", { className: "site__name", children: "GraphL" })
|
|
183
183
|
] }),
|
|
@@ -185,7 +185,7 @@ function de({
|
|
|
185
185
|
"a",
|
|
186
186
|
{
|
|
187
187
|
className: "site__link",
|
|
188
|
-
href: `${
|
|
188
|
+
href: `${s}#${d.id}`,
|
|
189
189
|
...d.id === e ? { "aria-current": "page" } : {},
|
|
190
190
|
children: d.label
|
|
191
191
|
},
|
|
@@ -197,7 +197,7 @@ function de({
|
|
|
197
197
|
function he({
|
|
198
198
|
courses: e,
|
|
199
199
|
subject: a,
|
|
200
|
-
kind:
|
|
200
|
+
kind: s,
|
|
201
201
|
actions: o
|
|
202
202
|
}) {
|
|
203
203
|
const [d, g] = b(() => /* @__PURE__ */ new Set()), f = (l) => g((p) => {
|
|
@@ -209,7 +209,7 @@ function he({
|
|
|
209
209
|
// viewport) while .idx is a 940px centred column. Both use the same width and gutters, so the
|
|
210
210
|
// brand lines up with the card edge below it.
|
|
211
211
|
/* @__PURE__ */ h(H, { children: [
|
|
212
|
-
/* @__PURE__ */ t(de, { kind:
|
|
212
|
+
/* @__PURE__ */ t(de, { kind: s, actions: o }),
|
|
213
213
|
/* @__PURE__ */ h("div", { className: "idx", children: [
|
|
214
214
|
/* @__PURE__ */ t("header", { className: "idx__head", children: /* @__PURE__ */ t("h1", { className: "idx__subject", children: a }) }),
|
|
215
215
|
e.length === 0 ? /* @__PURE__ */ t("p", { className: "idx__empty", children: "No courses authored yet — the app shell is ready." }) : /* @__PURE__ */ t("ol", { className: "idx__grid", children: e.map((l, p) => {
|
|
@@ -232,9 +232,9 @@ function he({
|
|
|
232
232
|
]
|
|
233
233
|
}
|
|
234
234
|
),
|
|
235
|
-
u && /* @__PURE__ */ t("ol", { className: "idx-sec", children: n.map(({ section:
|
|
235
|
+
u && /* @__PURE__ */ t("ol", { className: "idx-sec", children: n.map(({ section: r, slug: m }, v) => /* @__PURE__ */ t("li", { className: "idx-sec__row", children: /* @__PURE__ */ h("a", { href: `#/${m}`, children: [
|
|
236
236
|
/* @__PURE__ */ t("span", { className: "idx-sec__num", children: v + 1 }),
|
|
237
|
-
/* @__PURE__ */ t("span", { className: "idx-sec__title", children:
|
|
237
|
+
/* @__PURE__ */ t("span", { className: "idx-sec__title", children: r.title }),
|
|
238
238
|
/* @__PURE__ */ t("span", { className: "idx-sec__chev", "aria-hidden": "true", children: "›" })
|
|
239
239
|
] }) }, m)) })
|
|
240
240
|
] }, l.id);
|
|
@@ -252,23 +252,23 @@ function he({
|
|
|
252
252
|
);
|
|
253
253
|
}
|
|
254
254
|
function ue(e, a) {
|
|
255
|
-
const
|
|
255
|
+
const s = T(null), [o, d] = b(null), g = T(a);
|
|
256
256
|
g.current = a;
|
|
257
|
-
const [f, l] = b(!1), p =
|
|
257
|
+
const [f, l] = b(!1), p = T(f);
|
|
258
258
|
return p.current = f, k(() => {
|
|
259
|
-
const
|
|
260
|
-
return
|
|
259
|
+
const r = new Audio();
|
|
260
|
+
return r.addEventListener("ended", () => g.current()), r.addEventListener("error", () => l(!1)), s.current = r, d(r), () => r.pause();
|
|
261
261
|
}, []), k(() => {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
})) : (
|
|
262
|
+
const r = s.current;
|
|
263
|
+
r && (r.pause(), e ? (r.src = e, r.currentTime = 0, p.current && r.play().catch(() => {
|
|
264
|
+
})) : (r.removeAttribute("src"), r.load(), l(!1)));
|
|
265
265
|
}, [e]), { playing: f, toggle: () => {
|
|
266
|
-
const
|
|
267
|
-
!
|
|
266
|
+
const r = s.current;
|
|
267
|
+
!r || !r.getAttribute("src") || (p.current ? (r.pause(), l(!1)) : (r.play().catch(() => {
|
|
268
268
|
}), l(!0)));
|
|
269
269
|
}, stop: () => {
|
|
270
|
-
var
|
|
271
|
-
(
|
|
270
|
+
var r;
|
|
271
|
+
(r = s.current) == null || r.pause(), l(!1);
|
|
272
272
|
}, audio: o };
|
|
273
273
|
}
|
|
274
274
|
const B = "graphl:theme";
|
|
@@ -286,9 +286,9 @@ function me(e) {
|
|
|
286
286
|
} catch {
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
|
-
const fe = (e, a,
|
|
289
|
+
const fe = (e, a, s) => s || !a ? e : a;
|
|
290
290
|
function ge(e, a) {
|
|
291
|
-
const [
|
|
291
|
+
const [s, o] = b(() => a ? null : F()), d = fe(e, s, a);
|
|
292
292
|
k(() => (document.documentElement.setAttribute("data-theme", d), () => document.documentElement.removeAttribute("data-theme")), [d]), k(() => {
|
|
293
293
|
if (a) return;
|
|
294
294
|
const f = (l) => {
|
|
@@ -309,13 +309,13 @@ const P = {
|
|
|
309
309
|
light: { glyph: "☀", label: "Light theme" }
|
|
310
310
|
};
|
|
311
311
|
function pe({ theme: e, onToggle: a }) {
|
|
312
|
-
const
|
|
312
|
+
const s = e === "dark" ? "light" : "dark", o = `${P[e].label}. Switch to ${P[s].label.toLowerCase()}.`;
|
|
313
313
|
return /* @__PURE__ */ t("button", { className: "site__icon", type: "button", onClick: a, "aria-label": o, title: o, children: P[e].glyph });
|
|
314
314
|
}
|
|
315
315
|
function ye({
|
|
316
316
|
subject: e,
|
|
317
317
|
eyebrow: a,
|
|
318
|
-
courses:
|
|
318
|
+
courses: s,
|
|
319
319
|
getScene: o,
|
|
320
320
|
audioBase: d = "/",
|
|
321
321
|
kind: g = "courses",
|
|
@@ -326,14 +326,14 @@ function ye({
|
|
|
326
326
|
const i = () => p(location.hash);
|
|
327
327
|
return window.addEventListener("hashchange", i), () => window.removeEventListener("hashchange", i);
|
|
328
328
|
}, []);
|
|
329
|
-
const n = new URLSearchParams(location.search).get("capture") === "1", { theme: u, toggle:
|
|
330
|
-
if (
|
|
331
|
-
for (const i of Object.values(
|
|
329
|
+
const n = new URLSearchParams(location.search).get("capture") === "1", { theme: u, toggle: r } = ge(f, n), m = l.replace(/^#\/?/, ""), v = M(() => {
|
|
330
|
+
if (s[m]) return s[m];
|
|
331
|
+
for (const i of Object.values(s))
|
|
332
332
|
if (i.sections.some((c) => X(i, c) === m)) return i;
|
|
333
|
-
return Object.values(
|
|
334
|
-
}, [m,
|
|
333
|
+
return Object.values(s)[0];
|
|
334
|
+
}, [m, s]), _ = M(() => D(v), [v]), w = M(() => Object.values(s).flatMap(D), [s]);
|
|
335
335
|
k(() => {
|
|
336
|
-
if (n || !
|
|
336
|
+
if (n || !s[m]) return;
|
|
337
337
|
const i = _[0];
|
|
338
338
|
i && (history.replaceState(null, "", `${location.pathname}${location.search}#/${i.slug}`), p(`#/${i.slug}`));
|
|
339
339
|
}, [n, m, _]), k(() => {
|
|
@@ -373,7 +373,7 @@ function ye({
|
|
|
373
373
|
};
|
|
374
374
|
return window.addEventListener("keydown", i), () => window.removeEventListener("keydown", i);
|
|
375
375
|
}, [S]);
|
|
376
|
-
const
|
|
376
|
+
const $ = _.find(({ slug: i }) => i === m), V = $ ? `${d}audio/${v.id}/${$.section.id}.wav` : void 0, { playing: q, toggle: K, stop: W, audio: y } = ue(V, () => {
|
|
377
377
|
const i = location.hash.replace(/^#\/?/, ""), c = w.findIndex(({ slug: L }) => L === i), N = w[c + 1];
|
|
378
378
|
N ? location.hash = `#/${N.slug}` : W();
|
|
379
379
|
});
|
|
@@ -403,19 +403,19 @@ function ye({
|
|
|
403
403
|
return /* @__PURE__ */ t(
|
|
404
404
|
he,
|
|
405
405
|
{
|
|
406
|
-
courses: Object.values(
|
|
406
|
+
courses: Object.values(s),
|
|
407
407
|
subject: e,
|
|
408
408
|
kind: g,
|
|
409
|
-
actions: n ? null : /* @__PURE__ */ t(pe, { theme: u, onToggle:
|
|
409
|
+
actions: n ? null : /* @__PURE__ */ t(pe, { theme: u, onToggle: r })
|
|
410
410
|
}
|
|
411
411
|
);
|
|
412
412
|
let C, j = null;
|
|
413
|
-
if (
|
|
413
|
+
if ($) {
|
|
414
414
|
const i = _.findIndex(({ slug: c }) => c === m);
|
|
415
415
|
C = /* @__PURE__ */ t(
|
|
416
416
|
oe,
|
|
417
417
|
{
|
|
418
|
-
section:
|
|
418
|
+
section: $.section,
|
|
419
419
|
getScene: o,
|
|
420
420
|
capture: n,
|
|
421
421
|
eyebrow: `${a ?? e.toUpperCase()} · ${v.id.toUpperCase()}`,
|
|
@@ -428,7 +428,7 @@ function ye({
|
|
|
428
428
|
onToggleNarration: K,
|
|
429
429
|
narrationAudio: y,
|
|
430
430
|
theme: u,
|
|
431
|
-
onToggleTheme: n ? void 0 :
|
|
431
|
+
onToggleTheme: n ? void 0 : r
|
|
432
432
|
}
|
|
433
433
|
);
|
|
434
434
|
} else {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/types.ts","../src/useSlideScale.ts","../src/SlidePanel.tsx","../src/NarrationBar.tsx","../src/SectionView.tsx","../src/SiteHeader.tsx","../src/CourseIndex.tsx","../src/useNarration.ts","../src/useTheme.ts","../src/ThemeToggle.tsx","../src/ConceptApp.tsx"],"sourcesContent":["// The content model, owned by the shell because the shell is what renders it. A content repo\n// authors data that satisfies these types; it no longer declares them (every repo's copy of\n// content/types.ts was byte-identical, which is the definition of a shared contract).\n//\n// concept ⊃ course ⊃ section. A SECTION is the atomic unit of a video: one slug (course-section),\n// one scene, one slide, one narration clip.\n//\n// The authoring contract: .md (source of truth) → (.slide, .tts)\n// slide = terse markdown shown in the SlidePanel (for the eye)\n// narration = natural-flow spoken script for TTS / Chatterbox (for the ear)\n\nexport interface Section {\n id: string // \"what-is-cloud\" → slug: foundations-what-is-cloud\n title: string\n scene: string // scene id shown behind the panel (may be shared across sections)\n focus?: string // node the slide floats clear of (optional)\n slide: string // markdown → rendered in the SlidePanel\n narration: string // natural-flow TTS script (Chatterbox)\n}\n\nexport interface Course {\n id: string\n title: string\n sections: Section[]\n}\n\n// How a content repo hands its scenes to the shell. The shell never imports a scene registry — it\n// is given this lookup, so scenes stay entirely the repo's business.\nexport type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined\n\n// The slug for a section is `<courseId>-<sectionId>` — section IS the unit (one slide, one\n// narration), so no trailing beat index.\nexport function slugOf(course: Course, section: Section): string {\n return `${course.id}-${section.id}`\n}\n\nexport function allSections(course: Course): { section: Section; slug: string }[] {\n return course.sections.map((section) => ({ section, slug: slugOf(course, section) }))\n}\n","import { useLayoutEffect, useRef, useState } from 'react'\n\n// The slide is authored at a fixed DESIGN width and scaled with CSS `zoom` to the live pane width,\n// so it looks pixel-proportional at any frame — 1440 preview, 1080p, or 4K capture alike. This is\n// what makes the slide's dimensions a function of the TARGET video frame, not the browser window.\n//\n// DESIGN_PANE = 806 = 42% of a 1920-wide design frame (the slide pane's share). zoom = live/806:\n// 1440 window → pane ~605 → zoom 0.75 | 1080p → 806 → 1.0 | 4K → 1613 → 2.0\nexport const DESIGN_PANE = 806\n\nexport function useSlideScale() {\n const ref = useRef<HTMLElement>(null)\n const [zoom, setZoom] = useState(1)\n useLayoutEffect(() => {\n const el = ref.current\n if (!el) return\n const measure = () => setZoom(el.clientWidth / DESIGN_PANE)\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n return { ref, zoom }\n}\n","import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { DESIGN_PANE, useSlideScale } from './useSlideScale'\n\n// The slide (a section's `.slide` markdown) — a full-height surface fixed to the RIGHT, lifted off\n// the scene canvas for figure-ground. Position is CSS-driven (see index.css):\n// landscape (laptop → video) — a 42% column beside the scene.\n// portrait (mobile → reel) — an off-canvas drawer that slides in from the right when `open`.\n// The inner scaler is authored at DESIGN_PANE px and `zoom`-scaled to the live pane, so the slide is\n// proportional to the target video frame at any size (see useSlideScale).\nexport function SlidePanel({ slide, open }: { slide: string; open: boolean }) {\n const { ref, zoom } = useSlideScale()\n return (\n <aside ref={ref} className={`slide-panel${open ? ' slide-panel--open' : ''}`}>\n <div className=\"slide-panel__scaler\" style={{ width: DESIGN_PANE, zoom }}>\n <ReactMarkdown remarkPlugins={[remarkGfm]}>{slide}</ReactMarkdown>\n </div>\n </aside>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// The narration scrub bar — elapsed / position / duration for the current section's clip, in the\n// footer control cluster beside the volume toggle. Interactive ONLY: it lives inside SectionView's\n// `!capture` footer, so it can never appear in a recorded frame (same guarantee as the pager and the\n// theme toggle beside it).\n//\n// WHY IT TAKES THE ELEMENT, NOT STATE: `timeupdate` fires ~4x a second. Lifting that into\n// ConceptApp would re-render the whole section — SceneView (layout + react-flow) and the\n// react-markdown slide — four times a second for a 4px bar. So useNarration hands out the one\n// <audio> element and this component subscribes to it directly; the only thing that re-renders on a\n// tick is the bar. Nothing else in the shell learns that playback has a position.\n//\n// A section whose clip is missing or not generated yet (docker, java) has no duration, so the bar\n// renders NOTHING rather than a dead track — the volume toggle already carries that state.\n// The scrub step, in seconds — shared with ConceptApp's window-level ← / →, which seeks by the same\n// amount when a clip is loaded. One constant so the key does the same thing whether or not the track\n// happens to hold focus.\nexport const SEEK_S = 10\n\nexport function NarrationBar({ audio }: { audio: HTMLAudioElement | null }) {\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n const trackRef = useRef<HTMLDivElement | null>(null)\n\n useEffect(() => {\n if (!audio) return\n const sync = () => {\n setTime(audio.currentTime)\n // A clip still loading (or 404'd, or unloaded between sections) reports NaN/Infinity.\n setDuration(Number.isFinite(audio.duration) ? audio.duration : 0)\n }\n sync()\n // `emptied` is the one that matters on navigation: useNarration removes the src for a section\n // with no clip, and without this the bar would keep showing the previous clip's length.\n const events = ['loadedmetadata', 'durationchange', 'timeupdate', 'seeked', 'ended', 'emptied', 'error'] as const\n events.forEach((e) => audio.addEventListener(e, sync))\n return () => events.forEach((e) => audio.removeEventListener(e, sync))\n }, [audio])\n\n if (!audio || !duration) return null\n\n const seekToClientX = (clientX: number) => {\n const el = trackRef.current\n if (!el) return\n const r = el.getBoundingClientRect()\n const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))\n audio.currentTime = f * duration\n setTime(audio.currentTime) // don't wait for the next timeupdate — the thumb must track the finger\n }\n\n // Arrow keys scrub by the same SEEK_S as the window-level handler; stopping propagation keeps\n // ConceptApp from ALSO seeking the same clip (it would double the step) and keeps Shift+← / →\n // from paging out of a section the reader is actively scrubbing. Every other key (Esc home, Space\n // narration) is left to bubble. Home/End are handled only here, where the track has focus.\n const onKeyDown = (e: React.KeyboardEvent) => {\n const step = (d: number) => {\n e.preventDefault()\n e.stopPropagation()\n audio.currentTime = Math.min(duration, Math.max(0, audio.currentTime + d))\n setTime(audio.currentTime)\n }\n if (e.key === 'ArrowLeft') step(-SEEK_S)\n else if (e.key === 'ArrowRight') step(SEEK_S)\n else if (e.key === 'Home') step(-duration)\n else if (e.key === 'End') step(duration)\n }\n\n const pct = `${Math.min(100, (time / duration) * 100)}%`\n return (\n <span className=\"reel-foot__audio\">\n <span className=\"reel-foot__count reel-foot__time\">{fmt(time)}</span>\n <div\n ref={trackRef}\n className=\"reel-foot__track\"\n role=\"slider\"\n tabIndex={0}\n aria-label=\"Narration position\"\n aria-valuemin={0}\n aria-valuemax={Math.round(duration)}\n aria-valuenow={Math.round(time)}\n aria-valuetext={`${fmt(time)} of ${fmt(duration)}`}\n onKeyDown={onKeyDown}\n onPointerDown={(e) => {\n // Capture so a drag that leaves the 4px track keeps scrubbing (the bar is thin; fingers\n // and mice both wander off it).\n e.currentTarget.setPointerCapture(e.pointerId)\n seekToClientX(e.clientX)\n }}\n onPointerMove={(e) => {\n if (e.currentTarget.hasPointerCapture(e.pointerId)) seekToClientX(e.clientX)\n }}\n >\n <span className=\"reel-foot__fill\" style={{ width: pct }} />\n <span className=\"reel-foot__thumb\" style={{ left: pct }} />\n </div>\n <span className=\"reel-foot__count reel-foot__time\">{fmt(duration)}</span>\n </span>\n )\n}\n\n/** m:ss — clips are seconds-to-minutes, never hours. */\nfunction fmt(s: number) {\n const t = Number.isFinite(s) && s > 0 ? s : 0\n return `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`\n}\n","import { useState } from 'react'\nimport { Home, Volume2, VolumeX, ChevronLeft, ChevronRight, Moon, Sun } from 'lucide-react'\nimport { SceneView, type ThemeKey } from '@graphlearning/flow'\nimport type { Section, SceneLookup } from './types'\nimport { SlidePanel } from './SlidePanel'\nimport { NarrationBar } from './NarrationBar'\n\n// The SECTION view = the final composited output of a slug (course-section): the vertical scene +\n// the fixed-right slide, with an eyebrow + section title header. Layout is responsive (CSS):\n// landscape (laptop → 4K video) — scene fills the left, slide is a right column; the title lives\n// in the slide, so the header shows just the eyebrow.\n// portrait (mobile → reel) — scene fills the frame, slide is a full-width drawer toggled at\n// top-right; the header shows the full title card.\n// Under ?capture=1 the frame is clean for video: just the eyebrow + title + scene — NO footer and NO\n// interactive controls (the drawer toggle and the footer control bar are both suppressed).\n// Interactively, a footer control bar carries Home + narration + prev/next (raised above the drawer\n// so it works while the slide is open), the drawer toggle appears in portrait, and the eyebrow also\n// links back to the catalog. Keyboard works everywhere (← / → seek the narration ±10s, or page when\n// the section has no clip; Shift+← / → always page; Space toggles narration).\nexport function SectionView({\n section,\n getScene,\n capture = false,\n eyebrow,\n index = 0,\n total = 0,\n onHome,\n onPrev,\n onNext,\n narrating = false,\n onToggleNarration,\n narrationAudio = null,\n theme = 'dark',\n onToggleTheme,\n}: {\n section: Section\n getScene: SceneLookup // the repo's scene registry, injected — the shell owns no scenes\n capture?: boolean // ?capture=1: the footer shows GraphL + §n/N branding (video); else live controls\n eyebrow?: string // header eyebrow (e.g. \"PYTHON · SETUP\"); also the back-to-catalog link\n index?: number // 0-based position of this section in the course\n total?: number // total sections in the course\n onHome?: () => void // back to the catalog (wired to the eyebrow)\n onPrev?: () => void // previous section (same as Shift+←) — drives the footer nav\n onNext?: () => void // next section (same as Shift+→)\n narrating?: boolean // is the section clip currently playing (drives the volume icon)\n onToggleNarration?: () => void // play/pause narration (same as Space)\n // The narration channel's <audio>, handed straight to NarrationBar so playback position never\n // becomes React state up here — a 4Hz tick would re-render the scene and the slide. Absent (or a\n // section with no clip) → no bar.\n narrationAudio?: HTMLAudioElement | null\n // Forwarded straight to SceneView. Withheld from the barrel like the rest of SectionView, so the\n // only way a repo sets it is ConceptApp's prop — which is what keeps the scene pane and the\n // shell's chrome on one value.\n theme?: ThemeKey\n // Flips the theme from the section itself. SiteHeader (and its toggle) never renders here — a\n // section is a video frame — but the footer control bar is ALREADY interactive-only and suppressed\n // under ?capture=1, exactly like the pager and the drawer handle beside it. So the control lands in\n // the cluster that exists for precisely this: things a reader may touch and a recording never sees.\n onToggleTheme?: () => void\n}) {\n const [open, setOpen] = useState(false) // drawer state; only affects the portrait layout\n const scene = getScene(section.scene)\n if (!scene) return <div className=\"stage stage--missing\">no scene: {section.scene}</div>\n return (\n <div className={`stage stage--section${capture ? ' stage--capture' : ''}`}>\n {/* Header/footer — shared with the captured video. The eyebrow is a button (back to catalog);\n the section title shows on the portrait title card and is hidden in landscape (it headlines\n the slide there). GraphL + §n/N sit along the bottom. */}\n <header className=\"reel-head\">\n {eyebrow && (\n <button className=\"reel-head__eyebrow\" onClick={onHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n {eyebrow}\n </button>\n )}\n <h1 className=\"reel-head__title\">{section.title}</h1>\n </header>\n {/* Footer control bar — interactive ONLY (narration toggle + prev/next + position). Suppressed\n under ?capture=1 so the captured video frame stays clean: no GraphL wordmark, no counter,\n just the eyebrow/title + scene. Raised above the portrait drawer so nav works while the\n slide is open. */}\n {!capture && (\n <footer className=\"reel-foot reel-foot--controls\">\n <span className=\"reel-foot__grp\">\n <button className=\"reel-foot__ctrl\" onClick={onHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n <Home size={17} />\n </button>\n <button\n className=\"reel-foot__ctrl\"\n onClick={onToggleNarration}\n aria-label={narrating ? 'Pause narration (Space)' : 'Play narration (Space)'}\n title={narrating ? 'Pause narration (Space)' : 'Play narration (Space)'}\n >\n {narrating ? <Volume2 size={18} /> : <VolumeX size={18} />}\n </button>\n {/* Lucide here, a text glyph in SiteHeader's copy — deliberately. The bar is a port of\n ui-graphl and must match it character for character; this cluster's siblings are all\n lucide at 17-19px, and a text moon beside them would read as a different control. */}\n {onToggleTheme && (\n <button\n className=\"reel-foot__ctrl\"\n onClick={onToggleTheme}\n aria-label={theme === 'dark' ? 'Dark theme. Switch to light theme.' : 'Light theme. Switch to dark theme.'}\n title={theme === 'dark' ? 'Dark theme. Switch to light theme.' : 'Light theme. Switch to dark theme.'}\n >\n {theme === 'dark' ? <Moon size={17} /> : <Sun size={17} />}\n </button>\n )}\n </span>\n {/* Scrub bar — landscape only (CSS). In portrait the control row is already full at phone\n width, and the scene's bottom reserve (.stage--section .scene-area) is sized for ONE\n row: a second row would grow the footer up into the reel frame, and widening the\n reserve would re-frame every recorded portrait video. */}\n <NarrationBar audio={narrationAudio} />\n <span className=\"reel-foot__nav\">\n <button className=\"reel-foot__ctrl\" onClick={onPrev} aria-label=\"Previous section (Shift+←)\" title=\"Previous section (Shift+←)\">\n <ChevronLeft size={19} />\n </button>\n <span className=\"reel-foot__count reel-foot__count--live\">\n {index + 1} / {total}\n </span>\n <button className=\"reel-foot__ctrl\" onClick={onNext} aria-label=\"Next section (Shift+→)\" title=\"Next section (Shift+→)\">\n <ChevronRight size={19} />\n </button>\n </span>\n </footer>\n )}\n <div className=\"scene-area\">\n <SceneView scene={scene} focusId={section.focus} theme={theme} />\n </div>\n {/* Drawer toggle — portrait-only affordance to reveal the slide; suppressed at capture. */}\n {!capture && (\n <button\n className=\"slide-toggle\"\n onClick={() => setOpen((o) => !o)}\n aria-label={open ? 'Hide slide' : 'Show slide'}\n >\n {open ? '›' : '‹'}\n </button>\n )}\n <SlidePanel slide={section.slide} open={open} />\n </div>\n )\n}\n","import type { ReactNode } from 'react'\n\n// Platform chrome — the bar that sits above every GraphL page, here and at graphl.in. It is\n// deliberately ignorant of courses, scenes and this repo's subject: the whole point is that it\n// renders byte-identically on all eight sites, so a reader crossing from the catalog into a\n// concept app never leaves \"the site\". The repo's own name belongs in the <h1> below it.\n//\n// This is a PORT, not a new design. ui-graphl/index.html + styles.css are the original; the markup,\n// the class names and the CSS are the same, because two implementations of one design only stay one\n// design if neither drifts. When you change one, change the other.\n//\n// Not a component contract worth withholding: unlike SectionView, this has no route surface and the\n// recorder never sees it (it renders on the catalog page only), so exporting it costs nothing — and\n// python-lab, which is React but does not use ConceptApp, needs exactly this and nothing else.\n\n// The nav, mirroring catalog.json's `kinds`. Duplicated rather than fetched: the catalog lives at\n// graphl.in and this package is built separately, so reading it would mean a runtime fetch, a\n// loading state in the page's chrome, and a dev-server fallback — for a list that has been three\n// items all year. `nav` is a prop so a fourth section does not have to wait on seven repo upgrades.\nconst KINDS = [\n { id: 'courses', label: 'Courses' },\n { id: 'labs', label: 'Labs' },\n { id: 'coach', label: 'Coach' },\n]\n\n// The mark, inlined rather than referenced as /icon.svg. A content repo builds with `base` set to\n// its own subpath (\"/python/\"), so a relative src resolves somewhere else on every site and an\n// absolute one breaks the dev fixture; 335 bytes of SVG has neither problem, and nothing to flash.\nfunction Mark() {\n return (\n <svg className=\"site__logo\" width=\"28\" height=\"28\" viewBox=\"0 0 512 512\" aria-hidden=\"true\">\n <rect width=\"512\" height=\"512\" rx=\"80\" fill=\"#e8804f\" />\n <g fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"58\" strokeLinecap=\"butt\" strokeLinejoin=\"miter\">\n <path d=\"M 373 200 A 130 130 0 1 0 373 312\" />\n <path d=\"M 283 312 L 373 312 L 373 410\" />\n </g>\n </svg>\n )\n}\n\nexport function SiteHeader({\n kind = 'courses',\n nav = KINDS,\n home = '/',\n actions,\n}: {\n // Which nav item is \"you are here\" — a catalog.json `kind` id. A content repo is 'courses';\n // python-lab is 'labs'. Defaulted so the seven content repos pass nothing at all.\n kind?: string\n nav?: { id: string; label: string }[]\n // Where the brand points. '/' is the root catalog and is correct for every app deployed under\n // graphl.in; it is a prop only because the ui-shell dev fixture is not deployed under it.\n home?: string\n // Right-hand slot. The theme toggle and the account control land here when they exist, injected\n // by whoever owns them — the shell itself will never import Firebase.\n actions?: ReactNode\n}) {\n return (\n <header className=\"site\">\n <div className=\"site__inner\">\n <a className=\"site__brand\" href={home}>\n <Mark />\n <span className=\"site__name\">GraphL</span>\n </a>\n {/* Real anchors to <home>#<kind>, the same links app.js writes at the catalog — so\n middle-click, copy-link and keyboard all work without a single handler here. */}\n <nav className=\"site__nav\" aria-label=\"Sections\">\n {nav.map((k) => (\n <a\n key={k.id}\n className=\"site__link\"\n href={`${home}#${k.id}`}\n {...(k.id === kind ? { 'aria-current': 'page' as const } : {})}\n >\n {k.label}\n </a>\n ))}\n </nav>\n <div className=\"site__actions\">{actions}</div>\n </div>\n </header>\n )\n}\n","import { useState } from 'react'\nimport type { Course } from './types'\nimport { allSections } from './types'\nimport { SiteHeader } from './SiteHeader'\nimport type { ReactNode } from 'react'\n\n// The landing page: the GraphL course catalog. Each course is an ACCORDION — a header row (number ·\n// title · section count) that expands to reveal its ordered sections; a section row routes to\n// `#/<courseId>-<sectionId>`. Kept deliberately spare so it scales to ~11 courses without clutter.\n// This is what an empty hash (`#/`) resolves to in App.tsx.\nexport function CourseIndex({\n courses,\n subject,\n kind,\n actions,\n}: {\n courses: Course[]\n subject: string\n kind?: string // which nav section this app belongs to; see SiteHeader\n // SiteHeader's right-hand slot — the theme toggle. It rides the catalog ONLY, which is the whole\n // reason a reader-facing theme control is compatible with a deck being a video: SiteHeader never\n // renders on a section, so the control can never appear in a captured frame.\n actions?: ReactNode\n}) {\n // Which courses are expanded (multiple may be open). All start collapsed so the catalog opens as a\n // compact one-row-per-course list — it stays tidy as courses grow to ~11.\n const [open, setOpen] = useState<Set<string>>(() => new Set())\n const toggle = (id: string) =>\n setOpen((prev) => {\n const next = new Set(prev)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n return next\n })\n\n return (\n // The site bar is a SIBLING of .idx, not a child: it is full-bleed (its rule spans the\n // viewport) while .idx is a 940px centred column. Both use the same width and gutters, so the\n // brand lines up with the card edge below it.\n <>\n <SiteHeader kind={kind} actions={actions} />\n <div className=\"idx\">\n {/* The GraphL wordmark used to be an eyebrow here; it is in the bar above now. What stays\n is `subject` — the repo's own name (\"Python\", \"SQL\"), the one piece of this page the\n shell cannot know. */}\n <header className=\"idx__head\">\n <h1 className=\"idx__subject\">{subject}</h1>\n </header>\n\n {courses.length === 0 ? (\n <p className=\"idx__empty\">No courses authored yet — the app shell is ready.</p>\n ) : (\n <ol className=\"idx__grid\">\n {courses.map((course, i) => {\n const sections = allSections(course)\n const isOpen = open.has(course.id)\n return (\n <li key={course.id} className={`idx-card${isOpen ? ' idx-card--open' : ''}`}>\n <button\n className=\"idx-card__head\"\n onClick={() => toggle(course.id)}\n aria-expanded={isOpen}\n >\n <span className=\"idx-card__num\">{String(i + 1).padStart(2, '0')}</span>\n <span className=\"idx-card__title\">{course.title}</span>\n <span className=\"idx-card__count\">{sections.length} sections</span>\n <span className=\"idx-card__toggle\" aria-hidden=\"true\">\n ⌄\n </span>\n </button>\n\n {isOpen && (\n <ol className=\"idx-sec\">\n {sections.map(({ section, slug }, j) => (\n <li key={slug} className=\"idx-sec__row\">\n <a href={`#/${slug}`}>\n <span className=\"idx-sec__num\">{j + 1}</span>\n <span className=\"idx-sec__title\">{section.title}</span>\n <span className=\"idx-sec__chev\" aria-hidden=\"true\">\n ›\n </span>\n </a>\n </li>\n ))}\n </ol>\n )}\n </li>\n )\n })}\n </ol>\n )}\n\n <footer className=\"idx__foot\">\n <span className=\"idx__hint\">\n <kbd>→</kbd> next · <kbd>←</kbd> prev · <kbd>Space</kbd> narration\n </span>\n </footer>\n </div>\n </>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// The narration channel: one <audio> for the app's lifetime, driven by SPACE (toggle) and by\n// section navigation. Loading a section's clip keeps the play state — if narration is on, changing\n// sections auto-plays the next clip — and a clip that finishes calls `onEnded` so playback\n// auto-advances to the next SECTION. Timing comes from the clip length itself (no timestamp\n// anchors), so regenerated audio never drifts. A missing clip (not yet generated, or a 404) fails\n// silently and resets the play state — paging still works, just no audio.\n//\n// Adapted from graphl-studio's reveal-engine useNarration: this repo has no beats, so the unit is\n// one clip per section (`<audioBase>/<section-id>.wav`) rather than per beat.\nexport function useNarration(src: string | undefined, onEnded: () => void) {\n const audioRef = useRef<HTMLAudioElement | null>(null)\n // The same element, as state, purely so NarrationBar can subscribe to it once it exists. The bar\n // reads position straight off the element rather than through this hook's state, so a ~4Hz\n // `timeupdate` never re-renders the section (see NarrationBar).\n const [audio, setAudio] = useState<HTMLAudioElement | null>(null)\n const endedRef = useRef(onEnded)\n endedRef.current = onEnded\n const [playing, setPlaying] = useState(false)\n const playingRef = useRef(playing)\n playingRef.current = playing\n\n useEffect(() => {\n const a = new Audio()\n a.addEventListener('ended', () => endedRef.current())\n // A clip that can't load (not generated yet, 404) must not leave a stale \"playing\" state.\n a.addEventListener('error', () => setPlaying(false))\n audioRef.current = a\n setAudio(a)\n return () => a.pause()\n }, [])\n\n // Load the current section's clip; resume playing if narration was on. Because playback is\n // gesture-initiated (SPACE), the browser lets the auto-advance chain keep playing.\n useEffect(() => {\n const a = audioRef.current\n if (!a) return\n a.pause()\n if (src) {\n a.src = src\n a.currentTime = 0\n if (playingRef.current) a.play().catch(() => {})\n } else {\n // Fully unload so a later play() doesn't resume a stale buffered clip.\n a.removeAttribute('src')\n a.load()\n setPlaying(false)\n }\n }, [src])\n\n const toggle = () => {\n const a = audioRef.current\n if (!a || !a.getAttribute('src')) return // nothing loaded (a section with no clip)\n if (playingRef.current) {\n a.pause()\n setPlaying(false)\n } else {\n a.play().catch(() => {})\n setPlaying(true)\n }\n }\n\n /** Hard stop — pause and clear the play state (used at the end of the course). */\n const stop = () => {\n audioRef.current?.pause()\n setPlaying(false)\n }\n\n return { playing, toggle, stop, audio }\n}\n","// The PLATFORM theme contract, shared with graphl.in. ui-graphl/theme.js is the original; this is\n// the React port, and the two only stay one behaviour if neither drifts — same storage key, same\n// values, same attribute, same cross-tab sync.\n//\n// key localStorage['graphl:theme']\n// values 'light' | 'dark' | absent\n// switch data-theme on <html>; absent means \"no explicit choice\"\n//\n// Every GraphL app is same-origin under graphl.in, so a reader who picks light on the catalog walks\n// into a concept app already light, with nothing passed between them. Keep all four stable.\n//\n// ONE DELIBERATE DIFFERENCE FROM THE CATALOG, and it is the important one. At graphl.in the neutral\n// state means \"follow the OS\" (prefers-color-scheme). Here it means \"follow the DECK\" — the theme\n// the repo declared on ConceptApp. A deck is authored: its scenes, slides and narration were built\n// and reviewed in one look, and letting a reader's OS silently repaint an authored deck is not the\n// same decision as letting it repaint a directory page. The stored VALUES stay identical, so the\n// two apps never disagree about an explicit choice; only the absence is read differently.\n//\n// The capture path ignores all of this — see resolveTheme.\n\nimport { useCallback, useEffect, useState } from 'react'\nimport type { ThemeKey } from '@graphlearning/flow'\n\nconst KEY = 'graphl:theme'\n\n// The stored choice, or null for \"nothing chosen yet\" — which means the deck's declared theme.\n//\n// THE TOGGLE IS TWO-STATE: dark <-> light, and nothing else. There is no reader-visible \"deck\" or\n// \"system\" step. A third state costs a reader a press to get where they were going and has to be\n// labelled something (\"Deck theme\") that means nothing to them. The ABSENCE of a stored value still\n// means the deck's theme — that is what a first visit and every capture get — but once a reader has\n// an opinion the control simply flips, which is what every dark-mode toggle they have ever used does.\n//\n// localStorage throws in some privacy modes. A broken toggle must never take the page with it, so\n// every access is guarded and the fallback is simply \"nothing chosen\".\nexport function readStored(): ThemeKey | null {\n try {\n const stored = localStorage.getItem(KEY)\n return stored === 'light' || stored === 'dark' ? stored : null\n } catch {\n return null\n }\n}\n\nfunction writeStored(choice: ThemeKey) {\n try {\n localStorage.setItem(KEY, choice)\n } catch {\n /* the choice will not survive a reload; everything else still works */\n }\n}\n\n/**\n * The theme actually painted. CAPTURE PINS IT TO THE DECK, ignoring the stored choice and the reader\n * entirely — and that is not a nicety. A recorder runs a fresh headless profile with empty storage,\n * so without this pin what a capture painted would depend on the machine that rendered it. The\n * declared prop is the only thing burned into a video.\n */\nexport const resolveTheme = (deck: ThemeKey, stored: ThemeKey | null, capture: boolean): ThemeKey =>\n capture || !stored ? deck : stored\n\n/**\n * Owns the reader's choice and keeps <html data-theme> in step with it. Returns the theme to hand\n * SceneView, plus a two-state flip for the toggle.\n */\nexport function useTheme(deck: ThemeKey, capture: boolean) {\n const [stored, setStored] = useState<ThemeKey | null>(() => (capture ? null : readStored()))\n const theme = resolveTheme(deck, stored, capture)\n\n // The attribute goes on <html>, not a wrapper: ConceptApp renders a fragment, and a wrapping\n // element would change the section view's box model — which is burned into every recorded video.\n useEffect(() => {\n document.documentElement.setAttribute('data-theme', theme)\n return () => document.documentElement.removeAttribute('data-theme')\n }, [theme])\n\n // Another GraphL app on the same origin changed the choice — follow it, so the platform does not\n // disagree with itself between two tabs. Never while capturing.\n useEffect(() => {\n if (capture) return\n const onStorage = (e: StorageEvent) => {\n if (e.key === KEY || e.key === null) setStored(readStored())\n }\n window.addEventListener('storage', onStorage)\n return () => window.removeEventListener('storage', onStorage)\n }, [capture])\n\n // Swapping must look instantaneous. Several elements carry a 0.15s colour transition for hover,\n // and without suppressing them the ground flips at once while the nav and cards cross-fade behind\n // it — the switch reads as a smear rather than a change. Two nested rAFs: the first runs before\n // the paint that applies the new colours, the second after it. Ported from ui-graphl's theme.js.\n const toggle = useCallback(() => {\n const next: ThemeKey = theme === 'dark' ? 'light' : 'dark'\n writeStored(next)\n const root = document.documentElement\n root.classList.add('theme-swap')\n setStored(next)\n requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-swap')))\n }, [theme])\n\n return { theme, toggle }\n}\n","// The theme control, for SiteHeader's `actions` slot. A PORT of ui-graphl's #theme button — same\n// square icon, same \"where you are AND what the press does\" accessible name.\n//\n// TWO STATES, dark <-> light. The catalog cycles through a third, `system`; this does not, and the\n// difference is deliberate. A concept app has an authored look, so the neutral step would have to be\n// labelled \"Deck theme\" — a phrase that means nothing to a reader and costs them a press to get\n// where they were going. The ABSENCE of a stored choice still resolves to the deck's theme, which is\n// what a first visit and every capture get; the control just never walks back to it.\n//\n// WHY A READER CONTROL IS SAFE AT ALL, given a deck is a video: this renders inside SiteHeader, and\n// SiteHeader renders on the CATALOG PAGE ONLY, never on a section. The control can therefore never\n// appear in a captured frame, and capture pins to the declared deck theme regardless (see useTheme).\n\nimport type { ThemeKey } from '@graphlearning/flow'\n\n// The glyph shows where you ARE, not where the press goes — a toggle that previews its destination\n// reads as already-switched at a glance.\nconst FACE: Record<ThemeKey, { glyph: string; label: string }> = {\n dark: { glyph: '☾', label: 'Dark theme' },\n light: { glyph: '☀', label: 'Light theme' },\n}\n\nexport function ThemeToggle({ theme, onToggle }: { theme: ThemeKey; onToggle: () => void }) {\n const next: ThemeKey = theme === 'dark' ? 'light' : 'dark'\n const label = `${FACE[theme].label}. Switch to ${FACE[next].label.toLowerCase()}.`\n return (\n <button className=\"site__icon\" type=\"button\" onClick={onToggle} aria-label={label} title={label}>\n {FACE[theme].glyph}\n </button>\n )\n}\n","import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { SceneView, type ThemeKey } from '@graphlearning/flow'\nimport { SectionView } from './SectionView'\nimport type { Course, SceneLookup } from './types'\nimport { allSections, slugOf } from './types'\nimport { CourseIndex } from './CourseIndex'\nimport { useNarration } from './useNarration'\nimport { useTheme } from './useTheme'\nimport { ThemeToggle } from './ThemeToggle'\nimport { SEEK_S } from './NarrationBar'\n\n// Route contract (hash routing):\n// #/<course-section> → SECTION view — the final composited output of a slug (scene bg + slide)\n// #/<scene> → SCENE view — just the diagram\n// A bare id resolves to a section if it matches a slug, otherwise to a scene. Slugs (course-section)\n// and scene ids never collide, so no prefix is needed.\nexport function ConceptApp({\n subject,\n eyebrow,\n courses: COURSES,\n getScene,\n audioBase = '/',\n kind = 'courses',\n theme = 'dark',\n}: {\n subject: string // this repo's name, as the catalog's h1 shows it (\"Apache Spark\")\n courses: Record<string, Course> // the repo's course registry, in syllabus order\n // The section eyebrow's prefix (\"SPARK · EVOLUTION\"). Defaults to subject.toUpperCase(), which is\n // right for most repos — but NOT all: apache-spark titles itself \"Apache Spark\" and brands its\n // sections \"SPARK\", databricks-data-engineer brands its \"DATABRICKS\". The eyebrow is burned into\n // every recorded video, so it gets its own prop rather than being derived and quietly changed.\n eyebrow?: string\n getScene: SceneLookup // the repo's scene registry\n // Where the narration wavs are served from — a content repo passes import.meta.env.BASE_URL,\n // which is its vite `base` (\"/python/\"). It CANNOT be read here: import.meta.env is replaced at\n // the app's build time, and this package is built separately, so a literal here would bake in\n // the shell's own base and 404 on every deployed site. The layout under it is the content\n // model's, not the repo's: audio/<courseId>/<sectionId>.wav.\n audioBase?: string\n // Which section of the site this app belongs to — a catalog.json `kind` id, marked as the\n // current item in the site bar on the catalog page. Every content repo is a course, so the\n // default covers all seven; python-lab passes 'labs'.\n kind?: string\n // The surface the whole app is painted on — the scene pane (passed to SceneView) AND the shell's\n // own chrome (set as data-theme on <html>, which styles.css keys its light block off). One value\n // so the two cannot disagree: a light scene inside dark chrome is a white rectangle on a dark page.\n //\n // DECK-LEVEL, and deliberately not a viewer toggle. A deck is a video: the recorded frame must not\n // depend on the machine rendering it, or the same slug captures differently on two laptops. A repo\n // sets this once in main.tsx beside its theme.css import — it is the same per-repo design surface\n // as --brand/--brand-hover/--accent-2, and it stays OUT of the route contract, so no recorder\n // changes. A repo going light must also re-pick those three tokens: a brand tuned to glow on slate\n // can fall under 3:1 on off-white, and the shell cannot repick them without owning them.\n theme?: ThemeKey\n}) {\n\n const [hash, setHash] = useState(() => location.hash)\n useEffect(() => {\n const onHash = () => setHash(location.hash)\n window.addEventListener('hashchange', onHash)\n return () => window.removeEventListener('hashchange', onHash)\n }, [])\n\n const capture = new URLSearchParams(location.search).get('capture') === '1'\n\n // `theme` is the DECK's declared look; the reader may override it for live viewing via the toggle\n // on the catalog, and that choice is the platform-wide `graphl:theme` the catalog at graphl.in\n // writes — so crossing from there into a concept app carries it. Capture pins to the deck and\n // ignores the reader entirely; see useTheme.ts.\n const { theme: activeTheme, toggle: cycleTheme } = useTheme(theme, capture)\n const id = hash.replace(/^#\\/?/, '')\n\n // Which course are we in? The recorder lands on #/<course> to read the plan; a human lands on a\n // slug. So the active course is: a bare course id, else the course that OWNS the matched slug, else\n // the first course (the nav context for a bare scene view). Everything course-scoped below —\n // section nav, the capture plan, the audio path, the header eyebrow — reads from this.\n const activeCourse = useMemo(() => {\n if (COURSES[id]) return COURSES[id]\n for (const c of Object.values(COURSES)) {\n if (c.sections.some((s) => slugOf(c, s) === id)) return c\n }\n return Object.values(COURSES)[0]\n }, [id, COURSES])\n const sections = useMemo(() => allSections(activeCourse), [activeCourse])\n // The whole catalog as one ordered stream (in COURSES insertion\n // order). Section nav walks THIS so → past a course's last section rolls into the next course's\n // first — and narration flows chapter-to-chapter — instead of dead-ending at the index. The\n // per-course `sections` above still drives the capture plan and the §n/N header (chapter-relative).\n const globalSections = useMemo(() => Object.values(COURSES).flatMap(allSections), [COURSES])\n\n // A bare COURSE id in the hash has no page of its own — the GraphL catalog links each course to\n // `<slug>/#/<courseId>`, so a human arriving there should ENTER the course at its first section.\n // Guarded by !capture: the recorder deliberately lands on `#/<course>` to read window.__scene.plan\n // (below), so it must not be redirected. Replace (not push) so Back skips the bare course-id URL.\n useEffect(() => {\n if (capture || !COURSES[id]) return\n const first = sections[0]\n if (!first) return\n history.replaceState(null, '', `${location.pathname}${location.search}#/${first.slug}`)\n setHash(`#/${first.slug}`)\n }, [capture, id, sections])\n\n useEffect(() => {\n if (!capture) return\n // Capture contract (fleshed out in step 2): every slug + its scene + focus node, so the recorder\n // can screenshot each and knows which node the slide must avoid.\n ;(window as unknown as { __scene: unknown }).__scene = {\n plan: () =>\n sections.map(({ slug, section }) => ({\n slug,\n course: activeCourse.id, // audio lives at audio/<course>/<id>.wav\n id: section.id,\n scene: section.scene,\n focus: section.focus ?? null,\n })),\n }\n }, [capture, sections, activeCourse])\n\n // Section navigation, shared by the ← / → keys AND the on-screen pager. `dir` is +1 (next) / -1\n // (prev). Reads the LIVE hash at call time (not React state) so rapid presses can't act on a stale\n // index; from a non-section view, next enters the first section. Walks the GLOBAL stream, so a\n // course boundary is just another step — → past a course's last section enters the next course's\n // first, ← symmetrically crosses back. Only the two ENDS of the whole catalog fall back to the\n // catalog page (like Esc). Navigating just updates the hash, which re-renders via the listener.\n const go = useCallback(\n (dir: 1 | -1) => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const gidx = globalSections.findIndex(({ slug }) => slug === cur)\n if (gidx !== -1) {\n const target = globalSections[gidx + dir]\n location.hash = target ? `#/${target.slug}` : '' // catalog only at the catalog's ends\n return\n }\n // The CATALOG index (empty hash) is the home/start: → begins the stream at its first section;\n // ← does nothing (there is nothing before home — it must not fall into foundations' last).\n if (cur === '') {\n if (dir === 1 && globalSections[0]) location.hash = `#/${globalSections[0].slug}`\n return\n }\n // Otherwise a bare SCENE view (a scene id with course context): → enters the active course's\n // first section, ← its last.\n const target = dir === 1 ? sections[0] : sections[sections.length - 1]\n if (target) location.hash = `#/${target.slug}`\n },\n [sections, globalSections],\n )\n // Back to the catalog: clear the hash (empty hash → CourseIndex). No-ops when already there. Wired\n // to the top-left GraphL brand AND the Esc key (below), so any inner page is one gesture from home.\n const goHome = useCallback(() => {\n if (location.hash) location.hash = '' // inner page → this concept's index\n else if (location.pathname !== '/') location.href = '/' // already at the index → up to the graphl.in catalog\n }, [])\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') goHome()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [goHome])\n\n // Slug (section) first.\n const match = sections.find(({ slug }) => slug === id)\n\n // Narration channel. A section's clip lives at public/audio/<courseId>/<section-id>.wav (pushed\n // from the Colab generator). SPACE toggles it; when a clip finishes we roll into the NEXT section\n // (which auto-plays because the play state is preserved) so the whole course narrates hands-free.\n // Outside a section (scene view) src is undefined → the channel unloads.\n const audioUrl = match\n ? `${audioBase}audio/${activeCourse.id}/${match.section.id}.wav`\n : undefined\n const { playing, toggle, stop, audio } = useNarration(audioUrl, () => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const idx = globalSections.findIndex(({ slug }) => slug === cur)\n const next = globalSections[idx + 1] // rolls across course boundaries into the next chapter\n if (next) location.hash = `#/${next.slug}`\n else stop() // last section of the whole catalog — nothing to advance to\n })\n\n // ← / → : SCRUB the narration when this section has a clip, PAGE when it does not. The footer's\n // < > buttons are the pager's real home and Esc still leaves, so the arrows are free to do the\n // thing a LISTENER reaches for — a ±SEEK_S step, the convention every podcast and video player\n // already taught them. The gate is the clip's duration, which makes the rule visible rather than\n // modal: the arrows scrub exactly when the scrub bar is on screen. Where there is nothing to\n // scrub the old meaning stands, so the keyboard never goes dead — the catalog, a bare scene view,\n // a section whose wav 404s, and a whole repo with no narration yet (docker, java) all still page.\n // SHIFT forces the pager even mid-clip, so a listener is never stranded without a key for it.\n //\n // Position is read straight off the element, like NarrationBar — it never becomes React state, so\n // a seek re-renders the bar alone and not SceneView + the markdown slide.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n const dir = e.key === 'ArrowRight' ? 1 : -1\n e.preventDefault()\n const d = audio?.duration ?? 0 // NaN/Infinity while a clip loads, or when none is loaded\n if (audio && !e.shiftKey && Number.isFinite(d) && d > 0) {\n audio.currentTime = Math.min(d, Math.max(0, audio.currentTime + dir * SEEK_S))\n return\n }\n go(dir)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go, audio])\n\n // SPACE enables/disables narration. Re-bound every render (no deps) so it always sees the current\n // `toggle`; guarded so it never hijacks the spacebar while typing in an input.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== ' ' && e.code !== 'Space') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n e.preventDefault()\n toggle()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n })\n\n // Empty hash → the course catalog landing page (its own brand, no overlay, no home button here).\n if (!id)\n return (\n <CourseIndex\n courses={Object.values(COURSES)}\n subject={subject}\n kind={kind}\n // The toggle rides the catalog only — never a section, which is a video frame.\n actions={capture ? null : <ThemeToggle theme={activeTheme} onToggle={cycleTheme} />}\n />\n )\n\n // The bare SCENE view (a scene id, no course context) keeps the top-left GraphL brand as its route\n // home. The SECTION view instead carries the shared header/footer, whose eyebrow IS the home link,\n // so it renders no separate brand. Both are hidden at capture so the recorded frame stays clean.\n let content\n let brand = null\n if (match) {\n const index = sections.findIndex(({ slug }) => slug === id)\n content = (\n <SectionView\n section={match.section}\n getScene={getScene}\n // ?capture=1 suppresses the (interactive-only) drawer toggle so the recorded frame is clean.\n capture={capture}\n // Header eyebrow — subject · course; doubles as the back-to-catalog link.\n eyebrow={`${eyebrow ?? subject.toUpperCase()} · ${activeCourse.id.toUpperCase()}`}\n index={index}\n total={sections.length}\n onHome={goHome}\n onPrev={() => go(-1)}\n onNext={() => go(1)}\n narrating={playing}\n onToggleNarration={toggle}\n narrationAudio={audio}\n theme={activeTheme}\n // Suppressed under capture by SectionView's own !capture guard, but passed as undefined here\n // too so the button is not even constructed for a recording.\n onToggleTheme={capture ? undefined : cycleTheme}\n />\n )\n } else {\n // Otherwise a scene id.\n const scene = getScene(id)\n content = scene ? (\n <div className=\"stage\">\n <SceneView scene={scene} theme={activeTheme} />\n </div>\n ) : (\n <div className=\"stage stage--missing\">no scene or slug: {id}</div>\n )\n brand = capture ? null : (\n <button className=\"brand-home\" onClick={goHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n GraphL\n </button>\n )\n }\n\n return (\n <>\n {brand}\n {content}\n </>\n )\n}\n"],"names":["slugOf","course","section","allSections","DESIGN_PANE","useSlideScale","ref","useRef","zoom","setZoom","useState","useLayoutEffect","el","measure","ro","SlidePanel","slide","open","jsx","ReactMarkdown","remarkGfm","SEEK_S","NarrationBar","audio","time","setTime","duration","setDuration","trackRef","useEffect","sync","events","e","seekToClientX","clientX","r","f","onKeyDown","step","d","pct","jsxs","fmt","s","t","SectionView","getScene","capture","eyebrow","index","total","onHome","onPrev","onNext","narrating","onToggleNarration","narrationAudio","theme","onToggleTheme","setOpen","scene","Home","Volume2","VolumeX","Moon","Sun","ChevronLeft","ChevronRight","SceneView","o","KINDS","Mark","SiteHeader","kind","nav","home","actions","k","CourseIndex","courses","subject","toggle","id","prev","next","Fragment","i","sections","isOpen","slug","j","useNarration","src","onEnded","audioRef","setAudio","endedRef","playing","setPlaying","playingRef","a","_a","KEY","readStored","stored","writeStored","choice","resolveTheme","deck","useTheme","setStored","onStorage","useCallback","root","FACE","ThemeToggle","onToggle","label","ConceptApp","COURSES","audioBase","hash","setHash","onHash","activeTheme","cycleTheme","activeCourse","useMemo","c","globalSections","first","go","dir","cur","gidx","target","goHome","onKey","match","audioUrl","stop","idx","content","brand"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/types.ts","../src/useSlideScale.ts","../src/SlidePanel.tsx","../src/NarrationBar.tsx","../src/SectionView.tsx","../src/SiteHeader.tsx","../src/CourseIndex.tsx","../src/useNarration.ts","../src/useTheme.ts","../src/ThemeToggle.tsx","../src/ConceptApp.tsx"],"sourcesContent":["// The content model, owned by the shell because the shell is what renders it. A content repo\n// authors data that satisfies these types; it no longer declares them (every repo's copy of\n// content/types.ts was byte-identical, which is the definition of a shared contract).\n//\n// concept ⊃ course ⊃ section. A SECTION is the atomic unit of a video: one slug (course-section),\n// one scene, one slide, one narration clip.\n//\n// The authoring contract: .md (source of truth) → (.slide, .tts)\n// slide = terse markdown shown in the SlidePanel (for the eye)\n// narration = natural-flow spoken script for TTS / Chatterbox (for the ear)\n\nexport interface Section {\n id: string // \"what-is-cloud\" → slug: foundations-what-is-cloud\n title: string\n scene: string // scene id shown behind the panel (may be shared across sections)\n focus?: string // node the slide floats clear of (optional)\n slide: string // markdown → rendered in the SlidePanel\n narration: string // natural-flow TTS script (Chatterbox)\n}\n\nexport interface Course {\n id: string\n title: string\n sections: Section[]\n}\n\n// How a content repo hands its scenes to the shell. The shell never imports a scene registry — it\n// is given this lookup, so scenes stay entirely the repo's business.\nexport type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined\n\n// The slug for a section is `<courseId>-<sectionId>` — section IS the unit (one slide, one\n// narration), so no trailing beat index.\nexport function slugOf(course: Course, section: Section): string {\n return `${course.id}-${section.id}`\n}\n\nexport function allSections(course: Course): { section: Section; slug: string }[] {\n return course.sections.map((section) => ({ section, slug: slugOf(course, section) }))\n}\n","import { useLayoutEffect, useRef, useState } from 'react'\n\n// The slide is authored at a fixed DESIGN width and scaled with CSS `zoom` to the live pane width,\n// so it looks pixel-proportional at any frame — 1440 preview, 1080p, or 4K capture alike. This is\n// what makes the slide's dimensions a function of the TARGET video frame, not the browser window.\n//\n// DESIGN_PANE = 806 = 42% of a 1920-wide design frame (the slide pane's share). zoom = live/806:\n// 1440 window → pane ~605 → zoom 0.75 | 1080p → 806 → 1.0 | 4K → 1613 → 2.0\nexport const DESIGN_PANE = 806\n\nexport function useSlideScale() {\n const ref = useRef<HTMLElement>(null)\n const [zoom, setZoom] = useState(1)\n useLayoutEffect(() => {\n const el = ref.current\n if (!el) return\n const measure = () => setZoom(el.clientWidth / DESIGN_PANE)\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n return { ref, zoom }\n}\n","import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { DESIGN_PANE, useSlideScale } from './useSlideScale'\n\n// The slide (a section's `.slide` markdown) — a full-height surface fixed to the RIGHT, lifted off\n// the scene canvas for figure-ground. Position is CSS-driven (see index.css):\n// landscape (laptop → video) — a 42% column beside the scene.\n// portrait (mobile → reel) — an off-canvas drawer that slides in from the right when `open`.\n// The inner scaler is authored at DESIGN_PANE px and `zoom`-scaled to the live pane, so the slide is\n// proportional to the target video frame at any size (see useSlideScale).\nexport function SlidePanel({ slide, open }: { slide: string; open: boolean }) {\n const { ref, zoom } = useSlideScale()\n return (\n <aside ref={ref} className={`slide-panel${open ? ' slide-panel--open' : ''}`}>\n <div className=\"slide-panel__scaler\" style={{ width: DESIGN_PANE, zoom }}>\n <ReactMarkdown remarkPlugins={[remarkGfm]}>{slide}</ReactMarkdown>\n </div>\n </aside>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// The narration scrub bar — elapsed / position / duration for the current section's clip, in the\n// footer control cluster beside the volume toggle. Interactive ONLY: it lives inside SectionView's\n// `!capture` footer, so it can never appear in a recorded frame (same guarantee as the pager and the\n// theme toggle beside it).\n//\n// WHY IT TAKES THE ELEMENT, NOT STATE: `timeupdate` fires ~4x a second. Lifting that into\n// ConceptApp would re-render the whole section — SceneView (layout + react-flow) and the\n// react-markdown slide — four times a second for a 4px bar. So useNarration hands out the one\n// <audio> element and this component subscribes to it directly; the only thing that re-renders on a\n// tick is the bar. Nothing else in the shell learns that playback has a position.\n//\n// A section whose clip is missing or not generated yet (docker, java) has no duration, so the bar\n// renders NOTHING rather than a dead track — the volume toggle already carries that state.\n// The scrub step, in seconds — shared with ConceptApp's window-level ← / →, which seeks by the same\n// amount when a clip is loaded. One constant so the key does the same thing whether or not the track\n// happens to hold focus.\nexport const SEEK_S = 10\n\nexport function NarrationBar({ audio }: { audio: HTMLAudioElement | null }) {\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n const trackRef = useRef<HTMLDivElement | null>(null)\n\n useEffect(() => {\n if (!audio) return\n const sync = () => {\n setTime(audio.currentTime)\n // A clip still loading (or 404'd, or unloaded between sections) reports NaN/Infinity.\n setDuration(Number.isFinite(audio.duration) ? audio.duration : 0)\n }\n sync()\n // `emptied` is the one that matters on navigation: useNarration removes the src for a section\n // with no clip, and without this the bar would keep showing the previous clip's length.\n const events = ['loadedmetadata', 'durationchange', 'timeupdate', 'seeked', 'ended', 'emptied', 'error'] as const\n events.forEach((e) => audio.addEventListener(e, sync))\n return () => events.forEach((e) => audio.removeEventListener(e, sync))\n }, [audio])\n\n if (!audio || !duration) return null\n\n const seekToClientX = (clientX: number) => {\n const el = trackRef.current\n if (!el) return\n const r = el.getBoundingClientRect()\n const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))\n audio.currentTime = f * duration\n setTime(audio.currentTime) // don't wait for the next timeupdate — the thumb must track the finger\n }\n\n // Arrow keys scrub by the same SEEK_S as the window-level handler; stopping propagation keeps\n // ConceptApp from ALSO seeking the same clip (it would double the step) and keeps Shift+← / →\n // from paging out of a section the reader is actively scrubbing. Every other key (Esc home, Space\n // narration) is left to bubble. Home/End are handled only here, where the track has focus.\n const onKeyDown = (e: React.KeyboardEvent) => {\n const step = (d: number) => {\n e.preventDefault()\n e.stopPropagation()\n audio.currentTime = Math.min(duration, Math.max(0, audio.currentTime + d))\n setTime(audio.currentTime)\n }\n if (e.key === 'ArrowLeft') step(-SEEK_S)\n else if (e.key === 'ArrowRight') step(SEEK_S)\n else if (e.key === 'Home') step(-duration)\n else if (e.key === 'End') step(duration)\n }\n\n const pct = `${Math.min(100, (time / duration) * 100)}%`\n return (\n <span className=\"reel-foot__audio\">\n <span className=\"reel-foot__count reel-foot__time\">{fmt(time)}</span>\n <div\n ref={trackRef}\n className=\"reel-foot__track\"\n role=\"slider\"\n tabIndex={0}\n aria-label=\"Narration position\"\n aria-valuemin={0}\n aria-valuemax={Math.round(duration)}\n aria-valuenow={Math.round(time)}\n aria-valuetext={`${fmt(time)} of ${fmt(duration)}`}\n onKeyDown={onKeyDown}\n onPointerDown={(e) => {\n // Capture so a drag that leaves the 4px track keeps scrubbing (the bar is thin; fingers\n // and mice both wander off it).\n e.currentTarget.setPointerCapture(e.pointerId)\n seekToClientX(e.clientX)\n }}\n onPointerMove={(e) => {\n if (e.currentTarget.hasPointerCapture(e.pointerId)) seekToClientX(e.clientX)\n }}\n >\n <span className=\"reel-foot__fill\" style={{ width: pct }} />\n <span className=\"reel-foot__thumb\" style={{ left: pct }} />\n </div>\n <span className=\"reel-foot__count reel-foot__time\">{fmt(duration)}</span>\n </span>\n )\n}\n\n/** m:ss — clips are seconds-to-minutes, never hours. */\nfunction fmt(s: number) {\n const t = Number.isFinite(s) && s > 0 ? s : 0\n return `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`\n}\n","import { useState } from 'react'\nimport { Home, Volume2, VolumeX, ChevronLeft, ChevronRight, Moon, Sun } from 'lucide-react'\nimport { SceneView, type ThemeKey } from '@graphlearning/flow'\nimport type { Section, SceneLookup } from './types'\nimport { SlidePanel } from './SlidePanel'\nimport { NarrationBar } from './NarrationBar'\n\n// The SECTION view = the final composited output of a slug (course-section): the vertical scene +\n// the fixed-right slide, with an eyebrow + section title header. Layout is responsive (CSS):\n// landscape (laptop → 4K video) — scene fills the left, slide is a right column; the title lives\n// in the slide, so the header shows just the eyebrow.\n// portrait (mobile → reel) — scene fills the frame, slide is a full-width drawer toggled at\n// top-right; the header shows the full title card.\n// Under ?capture=1 the frame is clean for video: just the eyebrow + title + scene — NO footer and NO\n// interactive controls (the drawer toggle and the footer control bar are both suppressed).\n// Interactively, a footer control bar carries Home + narration + prev/next (raised above the drawer\n// so it works while the slide is open), the drawer toggle appears in portrait, and the eyebrow also\n// links back to the catalog. Keyboard works everywhere (← / → seek the narration ±10s, or page when\n// the section has no clip; Shift+← / → always page; Space toggles narration).\nexport function SectionView({\n section,\n getScene,\n capture = false,\n eyebrow,\n index = 0,\n total = 0,\n onHome,\n onPrev,\n onNext,\n narrating = false,\n onToggleNarration,\n narrationAudio = null,\n theme = 'dark',\n onToggleTheme,\n}: {\n section: Section\n getScene: SceneLookup // the repo's scene registry, injected — the shell owns no scenes\n capture?: boolean // ?capture=1: the footer shows GraphL + §n/N branding (video); else live controls\n eyebrow?: string // header eyebrow (e.g. \"PYTHON · SETUP\"); also the back-to-catalog link\n index?: number // 0-based position of this section in the course\n total?: number // total sections in the course\n onHome?: () => void // back to the catalog (wired to the eyebrow)\n onPrev?: () => void // previous section (same as Shift+←) — drives the footer nav\n onNext?: () => void // next section (same as Shift+→)\n narrating?: boolean // is the section clip currently playing (drives the volume icon)\n onToggleNarration?: () => void // play/pause narration (same as Space)\n // The narration channel's <audio>, handed straight to NarrationBar so playback position never\n // becomes React state up here — a 4Hz tick would re-render the scene and the slide. Absent (or a\n // section with no clip) → no bar.\n narrationAudio?: HTMLAudioElement | null\n // Forwarded straight to SceneView. Withheld from the barrel like the rest of SectionView, so the\n // only way a repo sets it is ConceptApp's prop — which is what keeps the scene pane and the\n // shell's chrome on one value.\n theme?: ThemeKey\n // Flips the theme from the section itself. SiteHeader (and its toggle) never renders here — a\n // section is a video frame — but the footer control bar is ALREADY interactive-only and suppressed\n // under ?capture=1, exactly like the pager and the drawer handle beside it. So the control lands in\n // the cluster that exists for precisely this: things a reader may touch and a recording never sees.\n onToggleTheme?: () => void\n}) {\n const [open, setOpen] = useState(false) // drawer state; only affects the portrait layout\n const scene = getScene(section.scene)\n if (!scene) return <div className=\"stage stage--missing\">no scene: {section.scene}</div>\n return (\n <div className=\"stage stage--section\">\n {/* Header/footer — shared with the captured video. The eyebrow is a button (back to catalog);\n the section title shows on the portrait title card and is hidden in landscape (it headlines\n the slide there). GraphL + §n/N sit along the bottom. */}\n <header className=\"reel-head\">\n {eyebrow && (\n <button className=\"reel-head__eyebrow\" onClick={onHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n {eyebrow}\n </button>\n )}\n <h1 className=\"reel-head__title\">{section.title}</h1>\n </header>\n {/* Footer control bar — interactive ONLY (narration toggle + prev/next + position). Suppressed\n under ?capture=1 so the captured video frame stays clean: no GraphL wordmark, no counter,\n just the eyebrow/title + scene. Raised above the portrait drawer so nav works while the\n slide is open. */}\n {!capture && (\n <footer className=\"reel-foot reel-foot--controls\">\n <span className=\"reel-foot__grp\">\n <button className=\"reel-foot__ctrl\" onClick={onHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n <Home size={17} />\n </button>\n <button\n className=\"reel-foot__ctrl\"\n onClick={onToggleNarration}\n aria-label={narrating ? 'Pause narration (Space)' : 'Play narration (Space)'}\n title={narrating ? 'Pause narration (Space)' : 'Play narration (Space)'}\n >\n {narrating ? <Volume2 size={18} /> : <VolumeX size={18} />}\n </button>\n {/* Lucide here, a text glyph in SiteHeader's copy — deliberately. The bar is a port of\n ui-graphl and must match it character for character; this cluster's siblings are all\n lucide at 17-19px, and a text moon beside them would read as a different control. */}\n {onToggleTheme && (\n <button\n className=\"reel-foot__ctrl\"\n onClick={onToggleTheme}\n aria-label={theme === 'dark' ? 'Dark theme. Switch to light theme.' : 'Light theme. Switch to dark theme.'}\n title={theme === 'dark' ? 'Dark theme. Switch to light theme.' : 'Light theme. Switch to dark theme.'}\n >\n {theme === 'dark' ? <Moon size={17} /> : <Sun size={17} />}\n </button>\n )}\n </span>\n {/* Scrub bar — landscape only (CSS). In portrait the control row is already full at phone\n width, and the scene's bottom reserve (.stage--section .scene-area) is sized for ONE\n row: a second row would grow the footer up into the reel frame, and widening the\n reserve would re-frame every recorded portrait video. */}\n <NarrationBar audio={narrationAudio} />\n <span className=\"reel-foot__nav\">\n <button className=\"reel-foot__ctrl\" onClick={onPrev} aria-label=\"Previous section (Shift+←)\" title=\"Previous section (Shift+←)\">\n <ChevronLeft size={19} />\n </button>\n <span className=\"reel-foot__count reel-foot__count--live\">\n {index + 1} / {total}\n </span>\n <button className=\"reel-foot__ctrl\" onClick={onNext} aria-label=\"Next section (Shift+→)\" title=\"Next section (Shift+→)\">\n <ChevronRight size={19} />\n </button>\n </span>\n </footer>\n )}\n <div className=\"scene-area\">\n <SceneView scene={scene} focusId={section.focus} theme={theme} />\n </div>\n {/* Drawer toggle — portrait-only affordance to reveal the slide; suppressed at capture. */}\n {!capture && (\n <button\n className=\"slide-toggle\"\n onClick={() => setOpen((o) => !o)}\n aria-label={open ? 'Hide slide' : 'Show slide'}\n >\n {open ? '›' : '‹'}\n </button>\n )}\n <SlidePanel slide={section.slide} open={open} />\n </div>\n )\n}\n","import type { ReactNode } from 'react'\n\n// Platform chrome — the bar that sits above every GraphL page, here and at graphl.in. It is\n// deliberately ignorant of courses, scenes and this repo's subject: the whole point is that it\n// renders byte-identically on all eight sites, so a reader crossing from the catalog into a\n// concept app never leaves \"the site\". The repo's own name belongs in the <h1> below it.\n//\n// This is a PORT, not a new design. ui-graphl/index.html + styles.css are the original; the markup,\n// the class names and the CSS are the same, because two implementations of one design only stay one\n// design if neither drifts. When you change one, change the other.\n//\n// Not a component contract worth withholding: unlike SectionView, this has no route surface and the\n// recorder never sees it (it renders on the catalog page only), so exporting it costs nothing — and\n// python-lab, which is React but does not use ConceptApp, needs exactly this and nothing else.\n\n// The nav, mirroring catalog.json's `kinds`. Duplicated rather than fetched: the catalog lives at\n// graphl.in and this package is built separately, so reading it would mean a runtime fetch, a\n// loading state in the page's chrome, and a dev-server fallback — for a list that has been three\n// items all year. `nav` is a prop so a fourth section does not have to wait on seven repo upgrades.\nconst KINDS = [\n { id: 'courses', label: 'Courses' },\n { id: 'labs', label: 'Labs' },\n { id: 'coach', label: 'Coach' },\n]\n\n// The mark, inlined rather than referenced as /icon.svg. A content repo builds with `base` set to\n// its own subpath (\"/python/\"), so a relative src resolves somewhere else on every site and an\n// absolute one breaks the dev fixture; 335 bytes of SVG has neither problem, and nothing to flash.\nfunction Mark() {\n return (\n <svg className=\"site__logo\" width=\"28\" height=\"28\" viewBox=\"0 0 512 512\" aria-hidden=\"true\">\n <rect width=\"512\" height=\"512\" rx=\"80\" fill=\"#e8804f\" />\n <g fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"58\" strokeLinecap=\"butt\" strokeLinejoin=\"miter\">\n <path d=\"M 373 200 A 130 130 0 1 0 373 312\" />\n <path d=\"M 283 312 L 373 312 L 373 410\" />\n </g>\n </svg>\n )\n}\n\nexport function SiteHeader({\n kind = 'courses',\n nav = KINDS,\n home = '/',\n actions,\n}: {\n // Which nav item is \"you are here\" — a catalog.json `kind` id. A content repo is 'courses';\n // python-lab is 'labs'. Defaulted so the seven content repos pass nothing at all.\n kind?: string\n nav?: { id: string; label: string }[]\n // Where the brand points. '/' is the root catalog and is correct for every app deployed under\n // graphl.in; it is a prop only because the ui-shell dev fixture is not deployed under it.\n home?: string\n // Right-hand slot. The theme toggle and the account control land here when they exist, injected\n // by whoever owns them — the shell itself will never import Firebase.\n actions?: ReactNode\n}) {\n return (\n <header className=\"site\">\n <div className=\"site__inner\">\n <a className=\"site__brand\" href={home}>\n <Mark />\n <span className=\"site__name\">GraphL</span>\n </a>\n {/* Real anchors to <home>#<kind>, the same links app.js writes at the catalog — so\n middle-click, copy-link and keyboard all work without a single handler here. */}\n <nav className=\"site__nav\" aria-label=\"Sections\">\n {nav.map((k) => (\n <a\n key={k.id}\n className=\"site__link\"\n href={`${home}#${k.id}`}\n {...(k.id === kind ? { 'aria-current': 'page' as const } : {})}\n >\n {k.label}\n </a>\n ))}\n </nav>\n <div className=\"site__actions\">{actions}</div>\n </div>\n </header>\n )\n}\n","import { useState } from 'react'\nimport type { Course } from './types'\nimport { allSections } from './types'\nimport { SiteHeader } from './SiteHeader'\nimport type { ReactNode } from 'react'\n\n// The landing page: the GraphL course catalog. Each course is an ACCORDION — a header row (number ·\n// title · section count) that expands to reveal its ordered sections; a section row routes to\n// `#/<courseId>-<sectionId>`. Kept deliberately spare so it scales to ~11 courses without clutter.\n// This is what an empty hash (`#/`) resolves to in App.tsx.\nexport function CourseIndex({\n courses,\n subject,\n kind,\n actions,\n}: {\n courses: Course[]\n subject: string\n kind?: string // which nav section this app belongs to; see SiteHeader\n // SiteHeader's right-hand slot — the theme toggle. It rides the catalog ONLY, which is the whole\n // reason a reader-facing theme control is compatible with a deck being a video: SiteHeader never\n // renders on a section, so the control can never appear in a captured frame.\n actions?: ReactNode\n}) {\n // Which courses are expanded (multiple may be open). All start collapsed so the catalog opens as a\n // compact one-row-per-course list — it stays tidy as courses grow to ~11.\n const [open, setOpen] = useState<Set<string>>(() => new Set())\n const toggle = (id: string) =>\n setOpen((prev) => {\n const next = new Set(prev)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n return next\n })\n\n return (\n // The site bar is a SIBLING of .idx, not a child: it is full-bleed (its rule spans the\n // viewport) while .idx is a 940px centred column. Both use the same width and gutters, so the\n // brand lines up with the card edge below it.\n <>\n <SiteHeader kind={kind} actions={actions} />\n <div className=\"idx\">\n {/* The GraphL wordmark used to be an eyebrow here; it is in the bar above now. What stays\n is `subject` — the repo's own name (\"Python\", \"SQL\"), the one piece of this page the\n shell cannot know. */}\n <header className=\"idx__head\">\n <h1 className=\"idx__subject\">{subject}</h1>\n </header>\n\n {courses.length === 0 ? (\n <p className=\"idx__empty\">No courses authored yet — the app shell is ready.</p>\n ) : (\n <ol className=\"idx__grid\">\n {courses.map((course, i) => {\n const sections = allSections(course)\n const isOpen = open.has(course.id)\n return (\n <li key={course.id} className={`idx-card${isOpen ? ' idx-card--open' : ''}`}>\n <button\n className=\"idx-card__head\"\n onClick={() => toggle(course.id)}\n aria-expanded={isOpen}\n >\n <span className=\"idx-card__num\">{String(i + 1).padStart(2, '0')}</span>\n <span className=\"idx-card__title\">{course.title}</span>\n <span className=\"idx-card__count\">{sections.length} sections</span>\n <span className=\"idx-card__toggle\" aria-hidden=\"true\">\n ⌄\n </span>\n </button>\n\n {isOpen && (\n <ol className=\"idx-sec\">\n {sections.map(({ section, slug }, j) => (\n <li key={slug} className=\"idx-sec__row\">\n <a href={`#/${slug}`}>\n <span className=\"idx-sec__num\">{j + 1}</span>\n <span className=\"idx-sec__title\">{section.title}</span>\n <span className=\"idx-sec__chev\" aria-hidden=\"true\">\n ›\n </span>\n </a>\n </li>\n ))}\n </ol>\n )}\n </li>\n )\n })}\n </ol>\n )}\n\n <footer className=\"idx__foot\">\n <span className=\"idx__hint\">\n <kbd>→</kbd> next · <kbd>←</kbd> prev · <kbd>Space</kbd> narration\n </span>\n </footer>\n </div>\n </>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// The narration channel: one <audio> for the app's lifetime, driven by SPACE (toggle) and by\n// section navigation. Loading a section's clip keeps the play state — if narration is on, changing\n// sections auto-plays the next clip — and a clip that finishes calls `onEnded` so playback\n// auto-advances to the next SECTION. Timing comes from the clip length itself (no timestamp\n// anchors), so regenerated audio never drifts. A missing clip (not yet generated, or a 404) fails\n// silently and resets the play state — paging still works, just no audio.\n//\n// Adapted from graphl-studio's reveal-engine useNarration: this repo has no beats, so the unit is\n// one clip per section (`<audioBase>/<section-id>.wav`) rather than per beat.\nexport function useNarration(src: string | undefined, onEnded: () => void) {\n const audioRef = useRef<HTMLAudioElement | null>(null)\n // The same element, as state, purely so NarrationBar can subscribe to it once it exists. The bar\n // reads position straight off the element rather than through this hook's state, so a ~4Hz\n // `timeupdate` never re-renders the section (see NarrationBar).\n const [audio, setAudio] = useState<HTMLAudioElement | null>(null)\n const endedRef = useRef(onEnded)\n endedRef.current = onEnded\n const [playing, setPlaying] = useState(false)\n const playingRef = useRef(playing)\n playingRef.current = playing\n\n useEffect(() => {\n const a = new Audio()\n a.addEventListener('ended', () => endedRef.current())\n // A clip that can't load (not generated yet, 404) must not leave a stale \"playing\" state.\n a.addEventListener('error', () => setPlaying(false))\n audioRef.current = a\n setAudio(a)\n return () => a.pause()\n }, [])\n\n // Load the current section's clip; resume playing if narration was on. Because playback is\n // gesture-initiated (SPACE), the browser lets the auto-advance chain keep playing.\n useEffect(() => {\n const a = audioRef.current\n if (!a) return\n a.pause()\n if (src) {\n a.src = src\n a.currentTime = 0\n if (playingRef.current) a.play().catch(() => {})\n } else {\n // Fully unload so a later play() doesn't resume a stale buffered clip.\n a.removeAttribute('src')\n a.load()\n setPlaying(false)\n }\n }, [src])\n\n const toggle = () => {\n const a = audioRef.current\n if (!a || !a.getAttribute('src')) return // nothing loaded (a section with no clip)\n if (playingRef.current) {\n a.pause()\n setPlaying(false)\n } else {\n a.play().catch(() => {})\n setPlaying(true)\n }\n }\n\n /** Hard stop — pause and clear the play state (used at the end of the course). */\n const stop = () => {\n audioRef.current?.pause()\n setPlaying(false)\n }\n\n return { playing, toggle, stop, audio }\n}\n","// The PLATFORM theme contract, shared with graphl.in. ui-graphl/theme.js is the original; this is\n// the React port, and the two only stay one behaviour if neither drifts — same storage key, same\n// values, same attribute, same cross-tab sync.\n//\n// key localStorage['graphl:theme']\n// values 'light' | 'dark' | absent\n// switch data-theme on <html>; absent means \"no explicit choice\"\n//\n// Every GraphL app is same-origin under graphl.in, so a reader who picks light on the catalog walks\n// into a concept app already light, with nothing passed between them. Keep all four stable.\n//\n// ONE DELIBERATE DIFFERENCE FROM THE CATALOG, and it is the important one. At graphl.in the neutral\n// state means \"follow the OS\" (prefers-color-scheme). Here it means \"follow the DECK\" — the theme\n// the repo declared on ConceptApp. A deck is authored: its scenes, slides and narration were built\n// and reviewed in one look, and letting a reader's OS silently repaint an authored deck is not the\n// same decision as letting it repaint a directory page. The stored VALUES stay identical, so the\n// two apps never disagree about an explicit choice; only the absence is read differently.\n//\n// The capture path ignores all of this — see resolveTheme.\n\nimport { useCallback, useEffect, useState } from 'react'\nimport type { ThemeKey } from '@graphlearning/flow'\n\nconst KEY = 'graphl:theme'\n\n// The stored choice, or null for \"nothing chosen yet\" — which means the deck's declared theme.\n//\n// THE TOGGLE IS TWO-STATE: dark <-> light, and nothing else. There is no reader-visible \"deck\" or\n// \"system\" step. A third state costs a reader a press to get where they were going and has to be\n// labelled something (\"Deck theme\") that means nothing to them. The ABSENCE of a stored value still\n// means the deck's theme — that is what a first visit and every capture get — but once a reader has\n// an opinion the control simply flips, which is what every dark-mode toggle they have ever used does.\n//\n// localStorage throws in some privacy modes. A broken toggle must never take the page with it, so\n// every access is guarded and the fallback is simply \"nothing chosen\".\nexport function readStored(): ThemeKey | null {\n try {\n const stored = localStorage.getItem(KEY)\n return stored === 'light' || stored === 'dark' ? stored : null\n } catch {\n return null\n }\n}\n\nfunction writeStored(choice: ThemeKey) {\n try {\n localStorage.setItem(KEY, choice)\n } catch {\n /* the choice will not survive a reload; everything else still works */\n }\n}\n\n/**\n * The theme actually painted. CAPTURE PINS IT TO THE DECK, ignoring the stored choice and the reader\n * entirely — and that is not a nicety. A recorder runs a fresh headless profile with empty storage,\n * so without this pin what a capture painted would depend on the machine that rendered it. The\n * declared prop is the only thing burned into a video.\n */\nexport const resolveTheme = (deck: ThemeKey, stored: ThemeKey | null, capture: boolean): ThemeKey =>\n capture || !stored ? deck : stored\n\n/**\n * Owns the reader's choice and keeps <html data-theme> in step with it. Returns the theme to hand\n * SceneView, plus a two-state flip for the toggle.\n */\nexport function useTheme(deck: ThemeKey, capture: boolean) {\n const [stored, setStored] = useState<ThemeKey | null>(() => (capture ? null : readStored()))\n const theme = resolveTheme(deck, stored, capture)\n\n // The attribute goes on <html>, not a wrapper: ConceptApp renders a fragment, and a wrapping\n // element would change the section view's box model — which is burned into every recorded video.\n useEffect(() => {\n document.documentElement.setAttribute('data-theme', theme)\n return () => document.documentElement.removeAttribute('data-theme')\n }, [theme])\n\n // Another GraphL app on the same origin changed the choice — follow it, so the platform does not\n // disagree with itself between two tabs. Never while capturing.\n useEffect(() => {\n if (capture) return\n const onStorage = (e: StorageEvent) => {\n if (e.key === KEY || e.key === null) setStored(readStored())\n }\n window.addEventListener('storage', onStorage)\n return () => window.removeEventListener('storage', onStorage)\n }, [capture])\n\n // Swapping must look instantaneous. Several elements carry a 0.15s colour transition for hover,\n // and without suppressing them the ground flips at once while the nav and cards cross-fade behind\n // it — the switch reads as a smear rather than a change. Two nested rAFs: the first runs before\n // the paint that applies the new colours, the second after it. Ported from ui-graphl's theme.js.\n const toggle = useCallback(() => {\n const next: ThemeKey = theme === 'dark' ? 'light' : 'dark'\n writeStored(next)\n const root = document.documentElement\n root.classList.add('theme-swap')\n setStored(next)\n requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-swap')))\n }, [theme])\n\n return { theme, toggle }\n}\n","// The theme control, for SiteHeader's `actions` slot. A PORT of ui-graphl's #theme button — same\n// square icon, same \"where you are AND what the press does\" accessible name.\n//\n// TWO STATES, dark <-> light. The catalog cycles through a third, `system`; this does not, and the\n// difference is deliberate. A concept app has an authored look, so the neutral step would have to be\n// labelled \"Deck theme\" — a phrase that means nothing to a reader and costs them a press to get\n// where they were going. The ABSENCE of a stored choice still resolves to the deck's theme, which is\n// what a first visit and every capture get; the control just never walks back to it.\n//\n// WHY A READER CONTROL IS SAFE AT ALL, given a deck is a video: this renders inside SiteHeader, and\n// SiteHeader renders on the CATALOG PAGE ONLY, never on a section. The control can therefore never\n// appear in a captured frame, and capture pins to the declared deck theme regardless (see useTheme).\n\nimport type { ThemeKey } from '@graphlearning/flow'\n\n// The glyph shows where you ARE, not where the press goes — a toggle that previews its destination\n// reads as already-switched at a glance.\nconst FACE: Record<ThemeKey, { glyph: string; label: string }> = {\n dark: { glyph: '☾', label: 'Dark theme' },\n light: { glyph: '☀', label: 'Light theme' },\n}\n\nexport function ThemeToggle({ theme, onToggle }: { theme: ThemeKey; onToggle: () => void }) {\n const next: ThemeKey = theme === 'dark' ? 'light' : 'dark'\n const label = `${FACE[theme].label}. Switch to ${FACE[next].label.toLowerCase()}.`\n return (\n <button className=\"site__icon\" type=\"button\" onClick={onToggle} aria-label={label} title={label}>\n {FACE[theme].glyph}\n </button>\n )\n}\n","import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { SceneView, type ThemeKey } from '@graphlearning/flow'\nimport { SectionView } from './SectionView'\nimport type { Course, SceneLookup } from './types'\nimport { allSections, slugOf } from './types'\nimport { CourseIndex } from './CourseIndex'\nimport { useNarration } from './useNarration'\nimport { useTheme } from './useTheme'\nimport { ThemeToggle } from './ThemeToggle'\nimport { SEEK_S } from './NarrationBar'\n\n// Route contract (hash routing):\n// #/<course-section> → SECTION view — the final composited output of a slug (scene bg + slide)\n// #/<scene> → SCENE view — just the diagram\n// A bare id resolves to a section if it matches a slug, otherwise to a scene. Slugs (course-section)\n// and scene ids never collide, so no prefix is needed.\nexport function ConceptApp({\n subject,\n eyebrow,\n courses: COURSES,\n getScene,\n audioBase = '/',\n kind = 'courses',\n theme = 'dark',\n}: {\n subject: string // this repo's name, as the catalog's h1 shows it (\"Apache Spark\")\n courses: Record<string, Course> // the repo's course registry, in syllabus order\n // The section eyebrow's prefix (\"SPARK · EVOLUTION\"). Defaults to subject.toUpperCase(), which is\n // right for most repos — but NOT all: apache-spark titles itself \"Apache Spark\" and brands its\n // sections \"SPARK\", databricks-data-engineer brands its \"DATABRICKS\". The eyebrow is burned into\n // every recorded video, so it gets its own prop rather than being derived and quietly changed.\n eyebrow?: string\n getScene: SceneLookup // the repo's scene registry\n // Where the narration wavs are served from — a content repo passes import.meta.env.BASE_URL,\n // which is its vite `base` (\"/python/\"). It CANNOT be read here: import.meta.env is replaced at\n // the app's build time, and this package is built separately, so a literal here would bake in\n // the shell's own base and 404 on every deployed site. The layout under it is the content\n // model's, not the repo's: audio/<courseId>/<sectionId>.wav.\n audioBase?: string\n // Which section of the site this app belongs to — a catalog.json `kind` id, marked as the\n // current item in the site bar on the catalog page. Every content repo is a course, so the\n // default covers all seven; python-lab passes 'labs'.\n kind?: string\n // The surface the whole app is painted on — the scene pane (passed to SceneView) AND the shell's\n // own chrome (set as data-theme on <html>, which styles.css keys its light block off). One value\n // so the two cannot disagree: a light scene inside dark chrome is a white rectangle on a dark page.\n //\n // DECK-LEVEL, and deliberately not a viewer toggle. A deck is a video: the recorded frame must not\n // depend on the machine rendering it, or the same slug captures differently on two laptops. A repo\n // sets this once in main.tsx beside its theme.css import — it is the same per-repo design surface\n // as --brand/--brand-hover/--accent-2, and it stays OUT of the route contract, so no recorder\n // changes. A repo going light must also re-pick those three tokens: a brand tuned to glow on slate\n // can fall under 3:1 on off-white, and the shell cannot repick them without owning them.\n theme?: ThemeKey\n}) {\n\n const [hash, setHash] = useState(() => location.hash)\n useEffect(() => {\n const onHash = () => setHash(location.hash)\n window.addEventListener('hashchange', onHash)\n return () => window.removeEventListener('hashchange', onHash)\n }, [])\n\n const capture = new URLSearchParams(location.search).get('capture') === '1'\n\n // `theme` is the DECK's declared look; the reader may override it for live viewing via the toggle\n // on the catalog, and that choice is the platform-wide `graphl:theme` the catalog at graphl.in\n // writes — so crossing from there into a concept app carries it. Capture pins to the deck and\n // ignores the reader entirely; see useTheme.ts.\n const { theme: activeTheme, toggle: cycleTheme } = useTheme(theme, capture)\n const id = hash.replace(/^#\\/?/, '')\n\n // Which course are we in? The recorder lands on #/<course> to read the plan; a human lands on a\n // slug. So the active course is: a bare course id, else the course that OWNS the matched slug, else\n // the first course (the nav context for a bare scene view). Everything course-scoped below —\n // section nav, the capture plan, the audio path, the header eyebrow — reads from this.\n const activeCourse = useMemo(() => {\n if (COURSES[id]) return COURSES[id]\n for (const c of Object.values(COURSES)) {\n if (c.sections.some((s) => slugOf(c, s) === id)) return c\n }\n return Object.values(COURSES)[0]\n }, [id, COURSES])\n const sections = useMemo(() => allSections(activeCourse), [activeCourse])\n // The whole catalog as one ordered stream (in COURSES insertion\n // order). Section nav walks THIS so → past a course's last section rolls into the next course's\n // first — and narration flows chapter-to-chapter — instead of dead-ending at the index. The\n // per-course `sections` above still drives the capture plan and the §n/N header (chapter-relative).\n const globalSections = useMemo(() => Object.values(COURSES).flatMap(allSections), [COURSES])\n\n // A bare COURSE id in the hash has no page of its own — the GraphL catalog links each course to\n // `<slug>/#/<courseId>`, so a human arriving there should ENTER the course at its first section.\n // Guarded by !capture: the recorder deliberately lands on `#/<course>` to read window.__scene.plan\n // (below), so it must not be redirected. Replace (not push) so Back skips the bare course-id URL.\n useEffect(() => {\n if (capture || !COURSES[id]) return\n const first = sections[0]\n if (!first) return\n history.replaceState(null, '', `${location.pathname}${location.search}#/${first.slug}`)\n setHash(`#/${first.slug}`)\n }, [capture, id, sections])\n\n useEffect(() => {\n if (!capture) return\n // Capture contract (fleshed out in step 2): every slug + its scene + focus node, so the recorder\n // can screenshot each and knows which node the slide must avoid.\n ;(window as unknown as { __scene: unknown }).__scene = {\n plan: () =>\n sections.map(({ slug, section }) => ({\n slug,\n course: activeCourse.id, // audio lives at audio/<course>/<id>.wav\n id: section.id,\n scene: section.scene,\n focus: section.focus ?? null,\n })),\n }\n }, [capture, sections, activeCourse])\n\n // Section navigation, shared by the ← / → keys AND the on-screen pager. `dir` is +1 (next) / -1\n // (prev). Reads the LIVE hash at call time (not React state) so rapid presses can't act on a stale\n // index; from a non-section view, next enters the first section. Walks the GLOBAL stream, so a\n // course boundary is just another step — → past a course's last section enters the next course's\n // first, ← symmetrically crosses back. Only the two ENDS of the whole catalog fall back to the\n // catalog page (like Esc). Navigating just updates the hash, which re-renders via the listener.\n const go = useCallback(\n (dir: 1 | -1) => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const gidx = globalSections.findIndex(({ slug }) => slug === cur)\n if (gidx !== -1) {\n const target = globalSections[gidx + dir]\n location.hash = target ? `#/${target.slug}` : '' // catalog only at the catalog's ends\n return\n }\n // The CATALOG index (empty hash) is the home/start: → begins the stream at its first section;\n // ← does nothing (there is nothing before home — it must not fall into foundations' last).\n if (cur === '') {\n if (dir === 1 && globalSections[0]) location.hash = `#/${globalSections[0].slug}`\n return\n }\n // Otherwise a bare SCENE view (a scene id with course context): → enters the active course's\n // first section, ← its last.\n const target = dir === 1 ? sections[0] : sections[sections.length - 1]\n if (target) location.hash = `#/${target.slug}`\n },\n [sections, globalSections],\n )\n // Back to the catalog: clear the hash (empty hash → CourseIndex). No-ops when already there. Wired\n // to the top-left GraphL brand AND the Esc key (below), so any inner page is one gesture from home.\n const goHome = useCallback(() => {\n if (location.hash) location.hash = '' // inner page → this concept's index\n else if (location.pathname !== '/') location.href = '/' // already at the index → up to the graphl.in catalog\n }, [])\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') goHome()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [goHome])\n\n // Slug (section) first.\n const match = sections.find(({ slug }) => slug === id)\n\n // Narration channel. A section's clip lives at public/audio/<courseId>/<section-id>.wav (pushed\n // from the Colab generator). SPACE toggles it; when a clip finishes we roll into the NEXT section\n // (which auto-plays because the play state is preserved) so the whole course narrates hands-free.\n // Outside a section (scene view) src is undefined → the channel unloads.\n const audioUrl = match\n ? `${audioBase}audio/${activeCourse.id}/${match.section.id}.wav`\n : undefined\n const { playing, toggle, stop, audio } = useNarration(audioUrl, () => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const idx = globalSections.findIndex(({ slug }) => slug === cur)\n const next = globalSections[idx + 1] // rolls across course boundaries into the next chapter\n if (next) location.hash = `#/${next.slug}`\n else stop() // last section of the whole catalog — nothing to advance to\n })\n\n // ← / → : SCRUB the narration when this section has a clip, PAGE when it does not. The footer's\n // < > buttons are the pager's real home and Esc still leaves, so the arrows are free to do the\n // thing a LISTENER reaches for — a ±SEEK_S step, the convention every podcast and video player\n // already taught them. The gate is the clip's duration, which makes the rule visible rather than\n // modal: the arrows scrub exactly when the scrub bar is on screen. Where there is nothing to\n // scrub the old meaning stands, so the keyboard never goes dead — the catalog, a bare scene view,\n // a section whose wav 404s, and a whole repo with no narration yet (docker, java) all still page.\n // SHIFT forces the pager even mid-clip, so a listener is never stranded without a key for it.\n //\n // Position is read straight off the element, like NarrationBar — it never becomes React state, so\n // a seek re-renders the bar alone and not SceneView + the markdown slide.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n const dir = e.key === 'ArrowRight' ? 1 : -1\n e.preventDefault()\n const d = audio?.duration ?? 0 // NaN/Infinity while a clip loads, or when none is loaded\n if (audio && !e.shiftKey && Number.isFinite(d) && d > 0) {\n audio.currentTime = Math.min(d, Math.max(0, audio.currentTime + dir * SEEK_S))\n return\n }\n go(dir)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go, audio])\n\n // SPACE enables/disables narration. Re-bound every render (no deps) so it always sees the current\n // `toggle`; guarded so it never hijacks the spacebar while typing in an input.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== ' ' && e.code !== 'Space') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n e.preventDefault()\n toggle()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n })\n\n // Empty hash → the course catalog landing page (its own brand, no overlay, no home button here).\n if (!id)\n return (\n <CourseIndex\n courses={Object.values(COURSES)}\n subject={subject}\n kind={kind}\n // The toggle rides the catalog only — never a section, which is a video frame.\n actions={capture ? null : <ThemeToggle theme={activeTheme} onToggle={cycleTheme} />}\n />\n )\n\n // The bare SCENE view (a scene id, no course context) keeps the top-left GraphL brand as its route\n // home. The SECTION view instead carries the shared header/footer, whose eyebrow IS the home link,\n // so it renders no separate brand. Both are hidden at capture so the recorded frame stays clean.\n let content\n let brand = null\n if (match) {\n const index = sections.findIndex(({ slug }) => slug === id)\n content = (\n <SectionView\n section={match.section}\n getScene={getScene}\n // ?capture=1 suppresses the (interactive-only) drawer toggle so the recorded frame is clean.\n capture={capture}\n // Header eyebrow — subject · course; doubles as the back-to-catalog link.\n eyebrow={`${eyebrow ?? subject.toUpperCase()} · ${activeCourse.id.toUpperCase()}`}\n index={index}\n total={sections.length}\n onHome={goHome}\n onPrev={() => go(-1)}\n onNext={() => go(1)}\n narrating={playing}\n onToggleNarration={toggle}\n narrationAudio={audio}\n theme={activeTheme}\n // Suppressed under capture by SectionView's own !capture guard, but passed as undefined here\n // too so the button is not even constructed for a recording.\n onToggleTheme={capture ? undefined : cycleTheme}\n />\n )\n } else {\n // Otherwise a scene id.\n const scene = getScene(id)\n content = scene ? (\n <div className=\"stage\">\n <SceneView scene={scene} theme={activeTheme} />\n </div>\n ) : (\n <div className=\"stage stage--missing\">no scene or slug: {id}</div>\n )\n brand = capture ? null : (\n <button className=\"brand-home\" onClick={goHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n GraphL\n </button>\n )\n }\n\n return (\n <>\n {brand}\n {content}\n </>\n )\n}\n"],"names":["slugOf","course","section","allSections","DESIGN_PANE","useSlideScale","ref","useRef","zoom","setZoom","useState","useLayoutEffect","el","measure","ro","SlidePanel","slide","open","jsx","ReactMarkdown","remarkGfm","SEEK_S","NarrationBar","audio","time","setTime","duration","setDuration","trackRef","useEffect","sync","events","e","seekToClientX","clientX","f","onKeyDown","step","d","pct","jsxs","fmt","s","t","SectionView","getScene","capture","eyebrow","index","total","onHome","onPrev","onNext","narrating","onToggleNarration","narrationAudio","theme","onToggleTheme","setOpen","scene","Home","Volume2","VolumeX","Moon","Sun","ChevronLeft","ChevronRight","SceneView","o","KINDS","Mark","SiteHeader","kind","nav","home","actions","k","CourseIndex","courses","subject","toggle","id","prev","next","Fragment","i","sections","isOpen","slug","j","useNarration","src","onEnded","audioRef","setAudio","endedRef","playing","setPlaying","playingRef","a","_a","KEY","readStored","stored","writeStored","choice","resolveTheme","deck","useTheme","setStored","onStorage","useCallback","root","FACE","ThemeToggle","onToggle","label","ConceptApp","COURSES","audioBase","hash","setHash","onHash","activeTheme","cycleTheme","activeCourse","useMemo","c","globalSections","first","go","dir","cur","gidx","target","goHome","onKey","match","audioUrl","stop","idx","content","brand"],"mappings":"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"}
|
package/dist/styles.css
CHANGED
|
@@ -441,35 +441,19 @@ body {
|
|
|
441
441
|
.stage--section {
|
|
442
442
|
display: flex;
|
|
443
443
|
}
|
|
444
|
-
/* The scene is a 9:16 PHONE FRAME at full height — the shape a reel is — centred in the left 58%
|
|
445
|
-
(the slide keeps its 42% column). Width follows height (56.25vh), capped at the column so a
|
|
446
|
-
squat window shrinks the frame's width, never its height. --frame-gap is the empty band each
|
|
447
|
-
side of it; the header and footer are pulled in to the frame's edges so they brand the frame,
|
|
448
|
-
not the empty bands. */
|
|
449
|
-
.stage--section {
|
|
450
|
-
--frame-w: min(56.25vh, 58vw);
|
|
451
|
-
--frame-gap: calc((58vw - var(--frame-w)) / 2);
|
|
452
|
-
}
|
|
453
444
|
.scene-area {
|
|
454
445
|
position: relative;
|
|
455
|
-
flex:
|
|
456
|
-
width:
|
|
457
|
-
margin: 0 var(--frame-gap);
|
|
446
|
+
flex: 1 1 auto;
|
|
447
|
+
min-width: 0;
|
|
458
448
|
height: 100%;
|
|
459
449
|
inset: auto;
|
|
460
450
|
}
|
|
461
|
-
/*
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
.stage--section:not(.stage--capture) .scene-area {
|
|
465
|
-
box-shadow: 0 0 0 1px rgba(var(--ink-rgb), 0.2);
|
|
466
|
-
}
|
|
467
|
-
/* Header/footer carry over but constrained to the FRAME. The section title is dropped here — it
|
|
468
|
-
already headlines the slide — leaving the eyebrow on top and the GraphL + §n/N line below,
|
|
451
|
+
/* Header/footer carry over but constrained to the SCENE column (left 58%; the slide is the right
|
|
452
|
+
42%) so they brand the diagram without straddling the slide. The section title is dropped here —
|
|
453
|
+
it already headlines the slide — leaving the eyebrow on top and the GraphL + §n/N line below,
|
|
469
454
|
sitting in the scene's fitView margins. Font sizes (vh) are shared with portrait. */
|
|
470
455
|
.reel-head {
|
|
471
|
-
|
|
472
|
-
right: calc(42% + var(--frame-gap));
|
|
456
|
+
right: 42%; /* right edge at the scene/slide boundary → spans only the scene column */
|
|
473
457
|
padding: 2.8vh 0 0 1.9vw;
|
|
474
458
|
}
|
|
475
459
|
.reel-head__title {
|
|
@@ -479,12 +463,11 @@ body {
|
|
|
479
463
|
margin-bottom: 0;
|
|
480
464
|
}
|
|
481
465
|
.reel-foot {
|
|
482
|
-
|
|
483
|
-
right: calc(42% + var(--frame-gap));
|
|
466
|
+
right: 42%;
|
|
484
467
|
padding: 0 1.9vw 2.6vh;
|
|
485
468
|
}
|
|
486
|
-
/* The
|
|
487
|
-
note on .reel-foot__audio). */
|
|
469
|
+
/* The scene column is wide enough for the scrub bar between the two clusters (see the
|
|
470
|
+
portrait-only note on .reel-foot__audio). */
|
|
488
471
|
.reel-foot__audio {
|
|
489
472
|
display: flex;
|
|
490
473
|
flex: 1 1 auto;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphlearning/shell",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "The GraphL concept-app shell — hash router, section view (scene left / slide right), slide panel, course catalog and narration channel, plus the capture/record/publish toolchain that drives them. A content repo supplies courses + scenes.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|