@graphlearning/shell 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ export declare function NarrationBar({ audio }: {
2
+ audio: HTMLAudioElement | null;
3
+ }): import("react").JSX.Element | null;
@@ -1,6 +1,6 @@
1
1
  import { type ThemeKey } from '@graphlearning/flow';
2
2
  import type { Section, SceneLookup } from './types';
3
- export declare function SectionView({ section, getScene, capture, eyebrow, index, total, onHome, onPrev, onNext, narrating, onToggleNarration, theme, onToggleTheme, }: {
3
+ export declare function SectionView({ section, getScene, capture, eyebrow, index, total, onHome, onPrev, onNext, narrating, onToggleNarration, narrationAudio, theme, onToggleTheme, }: {
4
4
  section: Section;
5
5
  getScene: SceneLookup;
6
6
  capture?: boolean;
@@ -12,6 +12,7 @@ export declare function SectionView({ section, getScene, capture, eyebrow, index
12
12
  onNext?: () => void;
13
13
  narrating?: boolean;
14
14
  onToggleNarration?: () => void;
15
+ narrationAudio?: HTMLAudioElement | null;
15
16
  theme?: ThemeKey;
16
17
  onToggleTheme?: () => void;
17
18
  }): import("react").JSX.Element;
package/dist/index.js CHANGED
@@ -1,384 +1,443 @@
1
- import { jsx as e, jsxs as d, Fragment as M } from "react/jsx-runtime";
2
- import { useRef as S, useState as x, useLayoutEffect as q, useEffect as N, useCallback as T, useMemo as C } from "react";
3
- import { SceneView as H } from "@graphlearning/flow";
4
- import { Home as W, Volume2 as X, VolumeX as O, Moon as Y, Sun as Z, ChevronLeft as J, ChevronRight as Q } from "lucide-react";
5
- import U from "react-markdown";
6
- import ee from "remark-gfm";
7
- function K(n, r) {
8
- return `${n.id}-${r.id}`;
1
+ import { jsx as t, jsxs as h, Fragment as H } from "react/jsx-runtime";
2
+ import { useRef as L, useState as b, useLayoutEffect as W, useEffect as v, useCallback as M, useMemo as C } from "react";
3
+ import { SceneView as F } from "@graphlearning/flow";
4
+ import { Home as O, Volume2 as Y, VolumeX as Z, Moon as J, Sun as Q, ChevronLeft as U, ChevronRight as ee } from "lucide-react";
5
+ import te from "react-markdown";
6
+ import ne from "remark-gfm";
7
+ function K(e, a) {
8
+ return `${e.id}-${a.id}`;
9
9
  }
10
- function z(n) {
11
- return n.sections.map((r) => ({ section: r, slug: K(n, r) }));
10
+ function I(e) {
11
+ return e.sections.map((a) => ({ section: a, slug: K(e, a) }));
12
12
  }
13
- const F = 806;
14
- function te() {
15
- const n = S(null), [r, s] = x(1);
16
- return q(() => {
17
- const o = n.current;
18
- if (!o) return;
19
- const l = () => s(o.clientWidth / F);
20
- l();
21
- const u = new ResizeObserver(l);
22
- return u.observe(o), () => u.disconnect();
23
- }, []), { ref: n, zoom: r };
13
+ const R = 806;
14
+ function ae() {
15
+ const e = L(null), [a, s] = b(1);
16
+ return W(() => {
17
+ const i = e.current;
18
+ if (!i) return;
19
+ const d = () => s(i.clientWidth / R);
20
+ d();
21
+ const p = new ResizeObserver(d);
22
+ return p.observe(i), () => p.disconnect();
23
+ }, []), { ref: e, zoom: a };
24
24
  }
25
- function ne({ slide: n, open: r }) {
26
- const { ref: s, zoom: o } = te();
27
- return /* @__PURE__ */ e("aside", { ref: s, className: `slide-panel${r ? " slide-panel--open" : ""}`, children: /* @__PURE__ */ e("div", { className: "slide-panel__scaler", style: { width: F, zoom: o }, children: /* @__PURE__ */ e(U, { remarkPlugins: [ee], children: n }) }) });
25
+ function re({ slide: e, open: a }) {
26
+ const { ref: s, zoom: i } = ae();
27
+ return /* @__PURE__ */ t("aside", { ref: s, className: `slide-panel${a ? " slide-panel--open" : ""}`, children: /* @__PURE__ */ t("div", { className: "slide-panel__scaler", style: { width: R, zoom: i }, children: /* @__PURE__ */ t(te, { remarkPlugins: [ne], children: e }) }) });
28
28
  }
29
- function ae({
30
- section: n,
31
- getScene: r,
29
+ function se({ audio: e }) {
30
+ const [a, s] = b(0), [i, d] = b(0), p = L(null);
31
+ if (v(() => {
32
+ if (!e) return;
33
+ const n = () => {
34
+ s(e.currentTime), d(Number.isFinite(e.duration) ? e.duration : 0);
35
+ };
36
+ n();
37
+ const u = ["loadedmetadata", "durationchange", "timeupdate", "seeked", "ended", "emptied", "error"];
38
+ return u.forEach((r) => e.addEventListener(r, n)), () => u.forEach((r) => e.removeEventListener(r, n));
39
+ }, [e]), !e || !i) return null;
40
+ const f = (n) => {
41
+ const u = p.current;
42
+ if (!u) return;
43
+ const r = u.getBoundingClientRect(), m = Math.min(1, Math.max(0, (n - r.left) / r.width));
44
+ e.currentTime = m * i, s(e.currentTime);
45
+ }, l = (n) => {
46
+ const u = (r) => {
47
+ n.preventDefault(), n.stopPropagation(), e.currentTime = Math.min(i, Math.max(0, e.currentTime + r)), s(e.currentTime);
48
+ };
49
+ n.key === "ArrowLeft" ? u(-5) : n.key === "ArrowRight" ? u(5) : n.key === "Home" ? u(-i) : n.key === "End" && u(i);
50
+ }, g = `${Math.min(100, a / i * 100)}%`;
51
+ return /* @__PURE__ */ h("span", { className: "reel-foot__audio", children: [
52
+ /* @__PURE__ */ t("span", { className: "reel-foot__count reel-foot__time", children: T(a) }),
53
+ /* @__PURE__ */ h(
54
+ "div",
55
+ {
56
+ ref: p,
57
+ className: "reel-foot__track",
58
+ role: "slider",
59
+ tabIndex: 0,
60
+ "aria-label": "Narration position",
61
+ "aria-valuemin": 0,
62
+ "aria-valuemax": Math.round(i),
63
+ "aria-valuenow": Math.round(a),
64
+ "aria-valuetext": `${T(a)} of ${T(i)}`,
65
+ onKeyDown: l,
66
+ onPointerDown: (n) => {
67
+ n.currentTarget.setPointerCapture(n.pointerId), f(n.clientX);
68
+ },
69
+ onPointerMove: (n) => {
70
+ n.currentTarget.hasPointerCapture(n.pointerId) && f(n.clientX);
71
+ },
72
+ children: [
73
+ /* @__PURE__ */ t("span", { className: "reel-foot__fill", style: { width: g } }),
74
+ /* @__PURE__ */ t("span", { className: "reel-foot__thumb", style: { left: g } })
75
+ ]
76
+ }
77
+ ),
78
+ /* @__PURE__ */ t("span", { className: "reel-foot__count reel-foot__time", children: T(i) })
79
+ ] });
80
+ }
81
+ function T(e) {
82
+ const a = Number.isFinite(e) && e > 0 ? e : 0;
83
+ return `${Math.floor(a / 60)}:${String(Math.floor(a % 60)).padStart(2, "0")}`;
84
+ }
85
+ function oe({
86
+ section: e,
87
+ getScene: a,
32
88
  capture: s = !1,
33
- eyebrow: o,
34
- index: l = 0,
35
- total: u = 0,
36
- onHome: m,
37
- onPrev: c,
38
- onNext: v,
39
- narrating: t = !1,
40
- onToggleNarration: w,
41
- theme: k = "dark",
42
- onToggleTheme: h
89
+ eyebrow: i,
90
+ index: d = 0,
91
+ total: p = 0,
92
+ onHome: f,
93
+ onPrev: l,
94
+ onNext: g,
95
+ narrating: n = !1,
96
+ onToggleNarration: u,
97
+ narrationAudio: r = null,
98
+ theme: m = "dark",
99
+ onToggleTheme: N
43
100
  }) {
44
- const [g, f] = x(!1), p = r(n.scene);
45
- return p ? /* @__PURE__ */ d("div", { className: "stage stage--section", children: [
46
- /* @__PURE__ */ d("header", { className: "reel-head", children: [
47
- o && /* @__PURE__ */ e("button", { className: "reel-head__eyebrow", onClick: m, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: o }),
48
- /* @__PURE__ */ e("h1", { className: "reel-head__title", children: n.title })
101
+ const [_, w] = b(!1), y = a(e.scene);
102
+ return y ? /* @__PURE__ */ h("div", { className: "stage stage--section", children: [
103
+ /* @__PURE__ */ h("header", { className: "reel-head", children: [
104
+ i && /* @__PURE__ */ t("button", { className: "reel-head__eyebrow", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: i }),
105
+ /* @__PURE__ */ t("h1", { className: "reel-head__title", children: e.title })
49
106
  ] }),
50
- !s && /* @__PURE__ */ d("footer", { className: "reel-foot reel-foot--controls", children: [
51
- /* @__PURE__ */ d("span", { className: "reel-foot__grp", children: [
52
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: m, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: /* @__PURE__ */ e(W, { size: 17 }) }),
53
- /* @__PURE__ */ e(
107
+ !s && /* @__PURE__ */ h("footer", { className: "reel-foot reel-foot--controls", children: [
108
+ /* @__PURE__ */ h("span", { className: "reel-foot__grp", children: [
109
+ /* @__PURE__ */ t("button", { className: "reel-foot__ctrl", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: /* @__PURE__ */ t(O, { size: 17 }) }),
110
+ /* @__PURE__ */ t(
54
111
  "button",
55
112
  {
56
113
  className: "reel-foot__ctrl",
57
- onClick: w,
58
- "aria-label": t ? "Pause narration (Space)" : "Play narration (Space)",
59
- title: t ? "Pause narration (Space)" : "Play narration (Space)",
60
- children: t ? /* @__PURE__ */ e(X, { size: 18 }) : /* @__PURE__ */ e(O, { size: 18 })
114
+ onClick: u,
115
+ "aria-label": n ? "Pause narration (Space)" : "Play narration (Space)",
116
+ title: n ? "Pause narration (Space)" : "Play narration (Space)",
117
+ children: n ? /* @__PURE__ */ t(Y, { size: 18 }) : /* @__PURE__ */ t(Z, { size: 18 })
61
118
  }
62
119
  ),
63
- h && /* @__PURE__ */ e(
120
+ N && /* @__PURE__ */ t(
64
121
  "button",
65
122
  {
66
123
  className: "reel-foot__ctrl",
67
- onClick: h,
68
- "aria-label": k === "dark" ? "Dark theme. Switch to light theme." : "Light theme. Switch to dark theme.",
69
- title: k === "dark" ? "Dark theme. Switch to light theme." : "Light theme. Switch to dark theme.",
70
- children: k === "dark" ? /* @__PURE__ */ e(Y, { size: 17 }) : /* @__PURE__ */ e(Z, { size: 17 })
124
+ onClick: N,
125
+ "aria-label": m === "dark" ? "Dark theme. Switch to light theme." : "Light theme. Switch to dark theme.",
126
+ title: m === "dark" ? "Dark theme. Switch to light theme." : "Light theme. Switch to dark theme.",
127
+ children: m === "dark" ? /* @__PURE__ */ t(J, { size: 17 }) : /* @__PURE__ */ t(Q, { size: 17 })
71
128
  }
72
129
  )
73
130
  ] }),
74
- /* @__PURE__ */ d("span", { className: "reel-foot__nav", children: [
75
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: c, "aria-label": "Previous section (←)", title: "Previous section (←)", children: /* @__PURE__ */ e(J, { size: 19 }) }),
76
- /* @__PURE__ */ d("span", { className: "reel-foot__count reel-foot__count--live", children: [
77
- l + 1,
131
+ /* @__PURE__ */ t(se, { audio: r }),
132
+ /* @__PURE__ */ h("span", { className: "reel-foot__nav", children: [
133
+ /* @__PURE__ */ t("button", { className: "reel-foot__ctrl", onClick: l, "aria-label": "Previous section (←)", title: "Previous section (←)", children: /* @__PURE__ */ t(U, { size: 19 }) }),
134
+ /* @__PURE__ */ h("span", { className: "reel-foot__count reel-foot__count--live", children: [
135
+ d + 1,
78
136
  " / ",
79
- u
137
+ p
80
138
  ] }),
81
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: v, "aria-label": "Next section (→)", title: "Next section (→)", children: /* @__PURE__ */ e(Q, { size: 19 }) })
139
+ /* @__PURE__ */ t("button", { className: "reel-foot__ctrl", onClick: g, "aria-label": "Next section (→)", title: "Next section (→)", children: /* @__PURE__ */ t(ee, { size: 19 }) })
82
140
  ] })
83
141
  ] }),
84
- /* @__PURE__ */ e("div", { className: "scene-area", children: /* @__PURE__ */ e(H, { scene: p, focusId: n.focus, theme: k }) }),
85
- !s && /* @__PURE__ */ e(
142
+ /* @__PURE__ */ t("div", { className: "scene-area", children: /* @__PURE__ */ t(F, { scene: y, focusId: e.focus, theme: m }) }),
143
+ !s && /* @__PURE__ */ t(
86
144
  "button",
87
145
  {
88
146
  className: "slide-toggle",
89
- onClick: () => f((b) => !b),
90
- "aria-label": g ? "Hide slide" : "Show slide",
91
- children: g ? "›" : "‹"
147
+ onClick: () => w((x) => !x),
148
+ "aria-label": _ ? "Hide slide" : "Show slide",
149
+ children: _ ? "›" : "‹"
92
150
  }
93
151
  ),
94
- /* @__PURE__ */ e(ne, { slide: n.slide, open: g })
95
- ] }) : /* @__PURE__ */ d("div", { className: "stage stage--missing", children: [
152
+ /* @__PURE__ */ t(re, { slide: e.slide, open: _ })
153
+ ] }) : /* @__PURE__ */ h("div", { className: "stage stage--missing", children: [
96
154
  "no scene: ",
97
- n.scene
155
+ e.scene
98
156
  ] });
99
157
  }
100
- const se = [
158
+ const ie = [
101
159
  { id: "courses", label: "Courses" },
102
160
  { id: "labs", label: "Labs" },
103
161
  { id: "coach", label: "Coach" }
104
162
  ];
105
- function re() {
106
- return /* @__PURE__ */ d("svg", { className: "site__logo", width: "28", height: "28", viewBox: "0 0 512 512", "aria-hidden": "true", children: [
107
- /* @__PURE__ */ e("rect", { width: "512", height: "512", rx: "80", fill: "#e8804f" }),
108
- /* @__PURE__ */ d("g", { fill: "none", stroke: "#ffffff", strokeWidth: "58", strokeLinecap: "butt", strokeLinejoin: "miter", children: [
109
- /* @__PURE__ */ e("path", { d: "M 373 200 A 130 130 0 1 0 373 312" }),
110
- /* @__PURE__ */ e("path", { d: "M 283 312 L 373 312 L 373 410" })
163
+ function le() {
164
+ return /* @__PURE__ */ h("svg", { className: "site__logo", width: "28", height: "28", viewBox: "0 0 512 512", "aria-hidden": "true", children: [
165
+ /* @__PURE__ */ t("rect", { width: "512", height: "512", rx: "80", fill: "#e8804f" }),
166
+ /* @__PURE__ */ h("g", { fill: "none", stroke: "#ffffff", strokeWidth: "58", strokeLinecap: "butt", strokeLinejoin: "miter", children: [
167
+ /* @__PURE__ */ t("path", { d: "M 373 200 A 130 130 0 1 0 373 312" }),
168
+ /* @__PURE__ */ t("path", { d: "M 283 312 L 373 312 L 373 410" })
111
169
  ] })
112
170
  ] });
113
171
  }
114
- function oe({
115
- kind: n = "courses",
116
- nav: r = se,
172
+ function ce({
173
+ kind: e = "courses",
174
+ nav: a = ie,
117
175
  home: s = "/",
118
- actions: o
176
+ actions: i
119
177
  }) {
120
- return /* @__PURE__ */ e("header", { className: "site", children: /* @__PURE__ */ d("div", { className: "site__inner", children: [
121
- /* @__PURE__ */ d("a", { className: "site__brand", href: s, children: [
122
- /* @__PURE__ */ e(re, {}),
123
- /* @__PURE__ */ e("span", { className: "site__name", children: "GraphL" })
178
+ return /* @__PURE__ */ t("header", { className: "site", children: /* @__PURE__ */ h("div", { className: "site__inner", children: [
179
+ /* @__PURE__ */ h("a", { className: "site__brand", href: s, children: [
180
+ /* @__PURE__ */ t(le, {}),
181
+ /* @__PURE__ */ t("span", { className: "site__name", children: "GraphL" })
124
182
  ] }),
125
- /* @__PURE__ */ e("nav", { className: "site__nav", "aria-label": "Sections", children: r.map((l) => /* @__PURE__ */ e(
183
+ /* @__PURE__ */ t("nav", { className: "site__nav", "aria-label": "Sections", children: a.map((d) => /* @__PURE__ */ t(
126
184
  "a",
127
185
  {
128
186
  className: "site__link",
129
- href: `${s}#${l.id}`,
130
- ...l.id === n ? { "aria-current": "page" } : {},
131
- children: l.label
187
+ href: `${s}#${d.id}`,
188
+ ...d.id === e ? { "aria-current": "page" } : {},
189
+ children: d.label
132
190
  },
133
- l.id
191
+ d.id
134
192
  )) }),
135
- /* @__PURE__ */ e("div", { className: "site__actions", children: o })
193
+ /* @__PURE__ */ t("div", { className: "site__actions", children: i })
136
194
  ] }) });
137
195
  }
138
- function ie({
139
- courses: n,
140
- subject: r,
196
+ function de({
197
+ courses: e,
198
+ subject: a,
141
199
  kind: s,
142
- actions: o
200
+ actions: i
143
201
  }) {
144
- const [l, u] = x(() => /* @__PURE__ */ new Set()), m = (c) => u((v) => {
145
- const t = new Set(v);
146
- return t.has(c) ? t.delete(c) : t.add(c), t;
202
+ const [d, p] = b(() => /* @__PURE__ */ new Set()), f = (l) => p((g) => {
203
+ const n = new Set(g);
204
+ return n.has(l) ? n.delete(l) : n.add(l), n;
147
205
  });
148
206
  return (
149
207
  // The site bar is a SIBLING of .idx, not a child: it is full-bleed (its rule spans the
150
208
  // viewport) while .idx is a 940px centred column. Both use the same width and gutters, so the
151
209
  // brand lines up with the card edge below it.
152
- /* @__PURE__ */ d(M, { children: [
153
- /* @__PURE__ */ e(oe, { kind: s, actions: o }),
154
- /* @__PURE__ */ d("div", { className: "idx", children: [
155
- /* @__PURE__ */ e("header", { className: "idx__head", children: /* @__PURE__ */ e("h1", { className: "idx__subject", children: r }) }),
156
- n.length === 0 ? /* @__PURE__ */ e("p", { className: "idx__empty", children: "No courses authored yet — the app shell is ready." }) : /* @__PURE__ */ e("ol", { className: "idx__grid", children: n.map((c, v) => {
157
- const t = z(c), w = l.has(c.id);
158
- return /* @__PURE__ */ d("li", { className: `idx-card${w ? " idx-card--open" : ""}`, children: [
159
- /* @__PURE__ */ d(
210
+ /* @__PURE__ */ h(H, { children: [
211
+ /* @__PURE__ */ t(ce, { kind: s, actions: i }),
212
+ /* @__PURE__ */ h("div", { className: "idx", children: [
213
+ /* @__PURE__ */ t("header", { className: "idx__head", children: /* @__PURE__ */ t("h1", { className: "idx__subject", children: a }) }),
214
+ 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, g) => {
215
+ const n = I(l), u = d.has(l.id);
216
+ return /* @__PURE__ */ h("li", { className: `idx-card${u ? " idx-card--open" : ""}`, children: [
217
+ /* @__PURE__ */ h(
160
218
  "button",
161
219
  {
162
220
  className: "idx-card__head",
163
- onClick: () => m(c.id),
164
- "aria-expanded": w,
221
+ onClick: () => f(l.id),
222
+ "aria-expanded": u,
165
223
  children: [
166
- /* @__PURE__ */ e("span", { className: "idx-card__num", children: String(v + 1).padStart(2, "0") }),
167
- /* @__PURE__ */ e("span", { className: "idx-card__title", children: c.title }),
168
- /* @__PURE__ */ d("span", { className: "idx-card__count", children: [
169
- t.length,
224
+ /* @__PURE__ */ t("span", { className: "idx-card__num", children: String(g + 1).padStart(2, "0") }),
225
+ /* @__PURE__ */ t("span", { className: "idx-card__title", children: l.title }),
226
+ /* @__PURE__ */ h("span", { className: "idx-card__count", children: [
227
+ n.length,
170
228
  " sections"
171
229
  ] }),
172
- /* @__PURE__ */ e("span", { className: "idx-card__toggle", "aria-hidden": "true", children: "⌄" })
230
+ /* @__PURE__ */ t("span", { className: "idx-card__toggle", "aria-hidden": "true", children: "⌄" })
173
231
  ]
174
232
  }
175
233
  ),
176
- w && /* @__PURE__ */ e("ol", { className: "idx-sec", children: t.map(({ section: k, slug: h }, g) => /* @__PURE__ */ e("li", { className: "idx-sec__row", children: /* @__PURE__ */ d("a", { href: `#/${h}`, children: [
177
- /* @__PURE__ */ e("span", { className: "idx-sec__num", children: g + 1 }),
178
- /* @__PURE__ */ e("span", { className: "idx-sec__title", children: k.title }),
179
- /* @__PURE__ */ e("span", { className: "idx-sec__chev", "aria-hidden": "true", children: "›" })
180
- ] }) }, h)) })
181
- ] }, c.id);
234
+ u && /* @__PURE__ */ t("ol", { className: "idx-sec", children: n.map(({ section: r, slug: m }, N) => /* @__PURE__ */ t("li", { className: "idx-sec__row", children: /* @__PURE__ */ h("a", { href: `#/${m}`, children: [
235
+ /* @__PURE__ */ t("span", { className: "idx-sec__num", children: N + 1 }),
236
+ /* @__PURE__ */ t("span", { className: "idx-sec__title", children: r.title }),
237
+ /* @__PURE__ */ t("span", { className: "idx-sec__chev", "aria-hidden": "true", children: "›" })
238
+ ] }) }, m)) })
239
+ ] }, l.id);
182
240
  }) }),
183
- /* @__PURE__ */ e("footer", { className: "idx__foot", children: /* @__PURE__ */ d("span", { className: "idx__hint", children: [
184
- /* @__PURE__ */ e("kbd", { children: "→" }),
241
+ /* @__PURE__ */ t("footer", { className: "idx__foot", children: /* @__PURE__ */ h("span", { className: "idx__hint", children: [
242
+ /* @__PURE__ */ t("kbd", { children: "→" }),
185
243
  " next · ",
186
- /* @__PURE__ */ e("kbd", { children: "←" }),
244
+ /* @__PURE__ */ t("kbd", { children: "←" }),
187
245
  " prev · ",
188
- /* @__PURE__ */ e("kbd", { children: "Space" }),
246
+ /* @__PURE__ */ t("kbd", { children: "Space" }),
189
247
  " narration"
190
248
  ] }) })
191
249
  ] })
192
250
  ] })
193
251
  );
194
252
  }
195
- function le(n, r) {
196
- const s = S(null), o = S(r);
197
- o.current = r;
198
- const [l, u] = x(!1), m = S(l);
199
- return m.current = l, N(() => {
200
- const t = new Audio();
201
- return t.addEventListener("ended", () => o.current()), t.addEventListener("error", () => u(!1)), s.current = t, () => t.pause();
202
- }, []), N(() => {
203
- const t = s.current;
204
- t && (t.pause(), n ? (t.src = n, t.currentTime = 0, m.current && t.play().catch(() => {
205
- })) : (t.removeAttribute("src"), t.load(), u(!1)));
206
- }, [n]), { playing: l, toggle: () => {
207
- const t = s.current;
208
- !t || !t.getAttribute("src") || (m.current ? (t.pause(), u(!1)) : (t.play().catch(() => {
209
- }), u(!0)));
253
+ function he(e, a) {
254
+ const s = L(null), [i, d] = b(null), p = L(a);
255
+ p.current = a;
256
+ const [f, l] = b(!1), g = L(f);
257
+ return g.current = f, v(() => {
258
+ const r = new Audio();
259
+ return r.addEventListener("ended", () => p.current()), r.addEventListener("error", () => l(!1)), s.current = r, d(r), () => r.pause();
260
+ }, []), v(() => {
261
+ const r = s.current;
262
+ r && (r.pause(), e ? (r.src = e, r.currentTime = 0, g.current && r.play().catch(() => {
263
+ })) : (r.removeAttribute("src"), r.load(), l(!1)));
264
+ }, [e]), { playing: f, toggle: () => {
265
+ const r = s.current;
266
+ !r || !r.getAttribute("src") || (g.current ? (r.pause(), l(!1)) : (r.play().catch(() => {
267
+ }), l(!0)));
210
268
  }, stop: () => {
211
- var t;
212
- (t = s.current) == null || t.pause(), u(!1);
213
- } };
269
+ var r;
270
+ (r = s.current) == null || r.pause(), l(!1);
271
+ }, audio: i };
214
272
  }
215
- const I = "graphl:theme";
216
- function D() {
273
+ const D = "graphl:theme";
274
+ function j() {
217
275
  try {
218
- const n = localStorage.getItem(I);
219
- return n === "light" || n === "dark" ? n : null;
276
+ const e = localStorage.getItem(D);
277
+ return e === "light" || e === "dark" ? e : null;
220
278
  } catch {
221
279
  return null;
222
280
  }
223
281
  }
224
- function ce(n) {
282
+ function ue(e) {
225
283
  try {
226
- localStorage.setItem(I, n);
284
+ localStorage.setItem(D, e);
227
285
  } catch {
228
286
  }
229
287
  }
230
- const de = (n, r, s) => s || !r ? n : r;
231
- function he(n, r) {
232
- const [s, o] = x(() => r ? null : D()), l = de(n, s, r);
233
- N(() => (document.documentElement.setAttribute("data-theme", l), () => document.documentElement.removeAttribute("data-theme")), [l]), N(() => {
234
- if (r) return;
235
- const m = (c) => {
236
- (c.key === I || c.key === null) && o(D());
288
+ const me = (e, a, s) => s || !a ? e : a;
289
+ function fe(e, a) {
290
+ const [s, i] = b(() => a ? null : j()), d = me(e, s, a);
291
+ v(() => (document.documentElement.setAttribute("data-theme", d), () => document.documentElement.removeAttribute("data-theme")), [d]), v(() => {
292
+ if (a) return;
293
+ const f = (l) => {
294
+ (l.key === D || l.key === null) && i(j());
237
295
  };
238
- return window.addEventListener("storage", m), () => window.removeEventListener("storage", m);
239
- }, [r]);
240
- const u = T(() => {
241
- const m = l === "dark" ? "light" : "dark";
242
- ce(m);
243
- const c = document.documentElement;
244
- c.classList.add("theme-swap"), o(m), requestAnimationFrame(() => requestAnimationFrame(() => c.classList.remove("theme-swap")));
245
- }, [l]);
246
- return { theme: l, toggle: u };
296
+ return window.addEventListener("storage", f), () => window.removeEventListener("storage", f);
297
+ }, [a]);
298
+ const p = M(() => {
299
+ const f = d === "dark" ? "light" : "dark";
300
+ ue(f);
301
+ const l = document.documentElement;
302
+ l.classList.add("theme-swap"), i(f), requestAnimationFrame(() => requestAnimationFrame(() => l.classList.remove("theme-swap")));
303
+ }, [d]);
304
+ return { theme: d, toggle: p };
247
305
  }
248
306
  const P = {
249
307
  dark: { glyph: "☾", label: "Dark theme" },
250
308
  light: { glyph: "☀", label: "Light theme" }
251
309
  };
252
- function ue({ theme: n, onToggle: r }) {
253
- const s = n === "dark" ? "light" : "dark", o = `${P[n].label}. Switch to ${P[s].label.toLowerCase()}.`;
254
- return /* @__PURE__ */ e("button", { className: "site__icon", type: "button", onClick: r, "aria-label": o, title: o, children: P[n].glyph });
310
+ function pe({ theme: e, onToggle: a }) {
311
+ const s = e === "dark" ? "light" : "dark", i = `${P[e].label}. Switch to ${P[s].label.toLowerCase()}.`;
312
+ return /* @__PURE__ */ t("button", { className: "site__icon", type: "button", onClick: a, "aria-label": i, title: i, children: P[e].glyph });
255
313
  }
256
- function ve({
257
- subject: n,
258
- eyebrow: r,
314
+ function be({
315
+ subject: e,
316
+ eyebrow: a,
259
317
  courses: s,
260
- getScene: o,
261
- audioBase: l = "/",
262
- kind: u = "courses",
263
- theme: m = "dark"
318
+ getScene: i,
319
+ audioBase: d = "/",
320
+ kind: p = "courses",
321
+ theme: f = "dark"
264
322
  }) {
265
- const [c, v] = x(() => location.hash);
266
- N(() => {
267
- const a = () => v(location.hash);
268
- return window.addEventListener("hashchange", a), () => window.removeEventListener("hashchange", a);
323
+ const [l, g] = b(() => location.hash);
324
+ v(() => {
325
+ const o = () => g(location.hash);
326
+ return window.addEventListener("hashchange", o), () => window.removeEventListener("hashchange", o);
269
327
  }, []);
270
- const t = new URLSearchParams(location.search).get("capture") === "1", { theme: w, toggle: k } = he(m, t), h = c.replace(/^#\/?/, ""), g = C(() => {
271
- if (s[h]) return s[h];
272
- for (const a of Object.values(s))
273
- if (a.sections.some((i) => K(a, i) === h)) return a;
328
+ const n = new URLSearchParams(location.search).get("capture") === "1", { theme: u, toggle: r } = fe(f, n), m = l.replace(/^#\/?/, ""), N = C(() => {
329
+ if (s[m]) return s[m];
330
+ for (const o of Object.values(s))
331
+ if (o.sections.some((c) => K(o, c) === m)) return o;
274
332
  return Object.values(s)[0];
275
- }, [h, s]), f = C(() => z(g), [g]), p = C(() => Object.values(s).flatMap(z), [s]);
276
- N(() => {
277
- if (t || !s[h]) return;
278
- const a = f[0];
279
- a && (history.replaceState(null, "", `${location.pathname}${location.search}#/${a.slug}`), v(`#/${a.slug}`));
280
- }, [t, h, f]), N(() => {
281
- t && (window.__scene = {
282
- plan: () => f.map(({ slug: a, section: i }) => ({
283
- slug: a,
284
- course: g.id,
333
+ }, [m, s]), _ = C(() => I(N), [N]), w = C(() => Object.values(s).flatMap(I), [s]);
334
+ v(() => {
335
+ if (n || !s[m]) return;
336
+ const o = _[0];
337
+ o && (history.replaceState(null, "", `${location.pathname}${location.search}#/${o.slug}`), g(`#/${o.slug}`));
338
+ }, [n, m, _]), v(() => {
339
+ n && (window.__scene = {
340
+ plan: () => _.map(({ slug: o, section: c }) => ({
341
+ slug: o,
342
+ course: N.id,
285
343
  // audio lives at audio/<course>/<id>.wav
286
- id: i.id,
287
- scene: i.scene,
288
- focus: i.focus ?? null
344
+ id: c.id,
345
+ scene: c.scene,
346
+ focus: c.focus ?? null
289
347
  }))
290
348
  });
291
- }, [t, f, g]);
292
- const b = T(
293
- (a) => {
294
- const i = location.hash.replace(/^#\/?/, ""), _ = p.findIndex(({ slug: $ }) => $ === i);
295
- if (_ !== -1) {
296
- const $ = p[_ + a];
297
- location.hash = $ ? `#/${$.slug}` : "";
349
+ }, [n, _, N]);
350
+ const y = M(
351
+ (o) => {
352
+ const c = location.hash.replace(/^#\/?/, ""), k = w.findIndex(({ slug: S }) => S === c);
353
+ if (k !== -1) {
354
+ const S = w[k + o];
355
+ location.hash = S ? `#/${S.slug}` : "";
298
356
  return;
299
357
  }
300
- if (i === "") {
301
- a === 1 && p[0] && (location.hash = `#/${p[0].slug}`);
358
+ if (c === "") {
359
+ o === 1 && w[0] && (location.hash = `#/${w[0].slug}`);
302
360
  return;
303
361
  }
304
- const E = a === 1 ? f[0] : f[f.length - 1];
305
- E && (location.hash = `#/${E.slug}`);
362
+ const $ = o === 1 ? _[0] : _[_.length - 1];
363
+ $ && (location.hash = `#/${$.slug}`);
306
364
  },
307
- [f, p]
365
+ [_, w]
308
366
  );
309
- N(() => {
310
- const a = (i) => {
311
- i.key !== "ArrowRight" && i.key !== "ArrowLeft" || (i.preventDefault(), b(i.key === "ArrowRight" ? 1 : -1));
367
+ v(() => {
368
+ const o = (c) => {
369
+ c.key !== "ArrowRight" && c.key !== "ArrowLeft" || (c.preventDefault(), y(c.key === "ArrowRight" ? 1 : -1));
312
370
  };
313
- return window.addEventListener("keydown", a), () => window.removeEventListener("keydown", a);
314
- }, [b]);
315
- const y = T(() => {
371
+ return window.addEventListener("keydown", o), () => window.removeEventListener("keydown", o);
372
+ }, [y]);
373
+ const x = M(() => {
316
374
  location.hash ? location.hash = "" : location.pathname !== "/" && (location.href = "/");
317
375
  }, []);
318
- N(() => {
319
- const a = (i) => {
320
- i.key === "Escape" && y();
376
+ v(() => {
377
+ const o = (c) => {
378
+ c.key === "Escape" && x();
321
379
  };
322
- return window.addEventListener("keydown", a), () => window.removeEventListener("keydown", a);
323
- }, [y]);
324
- const L = f.find(({ slug: a }) => a === h), G = L ? `${l}audio/${g.id}/${L.section.id}.wav` : void 0, { playing: V, toggle: j, stop: R } = le(G, () => {
325
- const a = location.hash.replace(/^#\/?/, ""), i = p.findIndex(({ slug: E }) => E === a), _ = p[i + 1];
326
- _ ? location.hash = `#/${_.slug}` : R();
380
+ return window.addEventListener("keydown", o), () => window.removeEventListener("keydown", o);
381
+ }, [x]);
382
+ const E = _.find(({ slug: o }) => o === m), X = E ? `${d}audio/${N.id}/${E.section.id}.wav` : void 0, { playing: G, toggle: z, stop: V, audio: q } = he(X, () => {
383
+ const o = location.hash.replace(/^#\/?/, ""), c = w.findIndex(({ slug: $ }) => $ === o), k = w[c + 1];
384
+ k ? location.hash = `#/${k.slug}` : V();
327
385
  });
328
- if (N(() => {
329
- const a = (i) => {
330
- if (i.key !== " " && i.code !== "Space") return;
331
- const _ = i.target;
332
- _ && (_.tagName === "INPUT" || _.tagName === "TEXTAREA" || _.isContentEditable) || (i.preventDefault(), j());
386
+ if (v(() => {
387
+ const o = (c) => {
388
+ if (c.key !== " " && c.code !== "Space") return;
389
+ const k = c.target;
390
+ k && (k.tagName === "INPUT" || k.tagName === "TEXTAREA" || k.isContentEditable) || (c.preventDefault(), z());
333
391
  };
334
- return window.addEventListener("keydown", a), () => window.removeEventListener("keydown", a);
335
- }), !h)
336
- return /* @__PURE__ */ e(
337
- ie,
392
+ return window.addEventListener("keydown", o), () => window.removeEventListener("keydown", o);
393
+ }), !m)
394
+ return /* @__PURE__ */ t(
395
+ de,
338
396
  {
339
397
  courses: Object.values(s),
340
- subject: n,
341
- kind: u,
342
- actions: t ? null : /* @__PURE__ */ e(ue, { theme: w, onToggle: k })
398
+ subject: e,
399
+ kind: p,
400
+ actions: n ? null : /* @__PURE__ */ t(pe, { theme: u, onToggle: r })
343
401
  }
344
402
  );
345
403
  let A, B = null;
346
- if (L) {
347
- const a = f.findIndex(({ slug: i }) => i === h);
348
- A = /* @__PURE__ */ e(
349
- ae,
404
+ if (E) {
405
+ const o = _.findIndex(({ slug: c }) => c === m);
406
+ A = /* @__PURE__ */ t(
407
+ oe,
350
408
  {
351
- section: L.section,
352
- getScene: o,
353
- capture: t,
354
- eyebrow: `${r ?? n.toUpperCase()} · ${g.id.toUpperCase()}`,
355
- index: a,
356
- total: f.length,
357
- onHome: y,
358
- onPrev: () => b(-1),
359
- onNext: () => b(1),
360
- narrating: V,
361
- onToggleNarration: j,
362
- theme: w,
363
- onToggleTheme: t ? void 0 : k
409
+ section: E.section,
410
+ getScene: i,
411
+ capture: n,
412
+ eyebrow: `${a ?? e.toUpperCase()} · ${N.id.toUpperCase()}`,
413
+ index: o,
414
+ total: _.length,
415
+ onHome: x,
416
+ onPrev: () => y(-1),
417
+ onNext: () => y(1),
418
+ narrating: G,
419
+ onToggleNarration: z,
420
+ narrationAudio: q,
421
+ theme: u,
422
+ onToggleTheme: n ? void 0 : r
364
423
  }
365
424
  );
366
425
  } else {
367
- const a = o(h);
368
- A = a ? /* @__PURE__ */ e("div", { className: "stage", children: /* @__PURE__ */ e(H, { scene: a, theme: w }) }) : /* @__PURE__ */ d("div", { className: "stage stage--missing", children: [
426
+ const o = i(m);
427
+ A = o ? /* @__PURE__ */ t("div", { className: "stage", children: /* @__PURE__ */ t(F, { scene: o, theme: u }) }) : /* @__PURE__ */ h("div", { className: "stage stage--missing", children: [
369
428
  "no scene or slug: ",
370
- h
371
- ] }), B = t ? null : /* @__PURE__ */ e("button", { className: "brand-home", onClick: y, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: "GraphL" });
429
+ m
430
+ ] }), B = n ? null : /* @__PURE__ */ t("button", { className: "brand-home", onClick: x, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: "GraphL" });
372
431
  }
373
- return /* @__PURE__ */ d(M, { children: [
432
+ return /* @__PURE__ */ h(H, { children: [
374
433
  B,
375
434
  A
376
435
  ] });
377
436
  }
378
437
  export {
379
- ve as ConceptApp,
380
- oe as SiteHeader,
381
- z as allSections,
438
+ be as ConceptApp,
439
+ ce as SiteHeader,
440
+ I as allSections,
382
441
  K as slugOf
383
442
  };
384
443
  //# sourceMappingURL=index.js.map
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/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 { 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'\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 (← / → navigate, 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 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 ← key) — drives the footer nav\n onNext?: () => void // next section (same as → key)\n narrating?: boolean // is the section clip currently playing (drives the volume icon)\n onToggleNarration?: () => void // play/pause narration (same as Space)\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 <span className=\"reel-foot__nav\">\n <button className=\"reel-foot__ctrl\" onClick={onPrev} aria-label=\"Previous section (←)\" title=\"Previous section (←)\">\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 (→)\" title=\"Next section (→)\">\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 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 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 }\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'\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 useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n e.preventDefault()\n go(e.key === 'ArrowRight' ? 1 : -1)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go])\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 } = 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 // 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 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","SectionView","getScene","capture","eyebrow","index","total","onHome","onPrev","onNext","narrating","onToggleNarration","theme","onToggleTheme","setOpen","scene","jsxs","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","endedRef","playing","setPlaying","playingRef","useEffect","a","_a","KEY","readStored","stored","writeStored","choice","resolveTheme","deck","useTheme","setStored","onStorage","e","useCallback","root","FACE","ThemeToggle","onToggle","label","ConceptApp","COURSES","audioBase","hash","setHash","onHash","activeTheme","cycleTheme","activeCourse","useMemo","c","s","globalSections","first","go","dir","cur","gidx","target","onKey","goHome","match","audioUrl","stop","idx","t","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.\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 when the bar HAS FOCUS and must not also page the section: ConceptApp's ← / →\n // handler is on `window`, so stopping propagation here keeps it from firing. Every other key\n // (Esc home, Space narration) is left to bubble.\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(-5)\n else if (e.key === 'ArrowRight') step(5)\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 (← / → navigate, 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 ← key) — drives the footer nav\n onNext?: () => void // next section (same as → key)\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 (←)\" title=\"Previous section (←)\">\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 (→)\" title=\"Next section (→)\">\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'\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 useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n e.preventDefault()\n go(e.key === 'ArrowRight' ? 1 : -1)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go])\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 // 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","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","onKey","goHome","match","audioUrl","stop","idx","content","brand"],"mappings":"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"}
package/dist/styles.css CHANGED
@@ -228,11 +228,14 @@ body {
228
228
  -webkit-tap-highlight-color: transparent; /* no grey/blue tap flash on mobile */
229
229
  }
230
230
  /* Hover feedback only on real-pointer devices — on touch, :hover "sticks" after a tap and leaves the
231
- icon highlighted until you tap elsewhere. */
231
+ icon highlighted until you tap elsewhere.
232
+ --brand, NOT --idx-accent: that token is declared on `.idx` (the catalog column) and does not
233
+ exist on a section, so every --idx-accent here resolved to nothing and the hover/focus accent was
234
+ silently dead. --brand is what .idx aliases it to anyway. */
232
235
  @media (hover: hover) {
233
236
  .reel-foot__ctrl:hover {
234
- color: var(--idx-accent);
235
- border-color: var(--idx-accent);
237
+ color: var(--brand);
238
+ border-color: var(--brand);
236
239
  }
237
240
  }
238
241
  /* Drop the focus ring on click/tap; keep a keyboard-only ring for accessibility. */
@@ -240,7 +243,7 @@ body {
240
243
  outline: none;
241
244
  }
242
245
  .reel-foot__ctrl:focus-visible {
243
- outline: 2px solid var(--idx-accent);
246
+ outline: 2px solid var(--brand);
244
247
  outline-offset: 2px;
245
248
  }
246
249
  .reel-foot__count--live {
@@ -249,6 +252,86 @@ body {
249
252
  text-align: center;
250
253
  }
251
254
 
255
+ /* Narration scrub bar — the middle child of the control cluster (NarrationBar). LANDSCAPE ONLY: it
256
+ is `display: none` here and switched on in the landscape block below. In portrait the row is
257
+ already full at phone width (three controls + the pager leave ~60px), and the only other place to
258
+ put it is a second row — which would grow this absolutely-positioned footer UP into the frame,
259
+ since the portrait scene reserve (.stage--section .scene-area, bottom: 7.5vh) is sized for one
260
+ row. Widening that reserve would re-frame every recorded reel, so the bar yields instead. */
261
+ .reel-foot__audio {
262
+ display: none;
263
+ align-items: center;
264
+ gap: 10px;
265
+ min-width: 0;
266
+ }
267
+ .reel-foot__time {
268
+ pointer-events: none;
269
+ min-width: 2.6em;
270
+ text-align: center;
271
+ font-size: 1.3vh;
272
+ }
273
+ /* The track is the hit target, so it gets vertical padding (a 4px line is not tappable) with the
274
+ visible rail drawn as a pseudo-element inside it. */
275
+ .reel-foot__track {
276
+ position: relative;
277
+ flex: 1 1 auto;
278
+ min-width: 0;
279
+ height: 18px;
280
+ display: flex;
281
+ align-items: center;
282
+ cursor: pointer;
283
+ pointer-events: auto; /* the footer container is pointer-events:none */
284
+ touch-action: none; /* a drag scrubs; it must not scroll the page */
285
+ -webkit-tap-highlight-color: transparent;
286
+ }
287
+ .reel-foot__track::before {
288
+ content: '';
289
+ position: absolute;
290
+ left: 0;
291
+ right: 0;
292
+ height: 4px;
293
+ border-radius: 999px;
294
+ background: rgba(var(--ink-rgb), 0.16);
295
+ }
296
+ .reel-foot__fill {
297
+ position: relative;
298
+ height: 4px;
299
+ border-radius: 999px;
300
+ background: var(--brand);
301
+ pointer-events: none;
302
+ }
303
+ .reel-foot__thumb {
304
+ position: absolute;
305
+ top: 50%;
306
+ width: 10px;
307
+ height: 10px;
308
+ margin-left: -5px;
309
+ border-radius: 50%;
310
+ background: var(--brand);
311
+ transform: translateY(-50%) scale(0);
312
+ transition: transform 0.12s;
313
+ pointer-events: none;
314
+ }
315
+ /* The thumb is an affordance, not decoration: it appears on hover/focus/scrub and otherwise the bar
316
+ reads as a thin progress line (the footer sits over the scene). */
317
+ @media (hover: hover) {
318
+ .reel-foot__track:hover .reel-foot__thumb {
319
+ transform: translateY(-50%) scale(1);
320
+ }
321
+ }
322
+ .reel-foot__track:active .reel-foot__thumb,
323
+ .reel-foot__track:focus-visible .reel-foot__thumb {
324
+ transform: translateY(-50%) scale(1);
325
+ }
326
+ .reel-foot__track:focus:not(:focus-visible) {
327
+ outline: none;
328
+ }
329
+ .reel-foot__track:focus-visible {
330
+ outline: 2px solid var(--brand);
331
+ outline-offset: 4px;
332
+ border-radius: 999px;
333
+ }
334
+
252
335
  /* GraphL wordmark — top-left of the bare SCENE view; click or Esc → back to catalog. (The section
253
336
  view brands via its own header/footer, whose eyebrow is the home link.)
254
337
  Fixed to the viewport so it sits above the stage regardless of layout; hidden under ?capture=1. */
@@ -378,6 +461,13 @@ body {
378
461
  right: 42%;
379
462
  padding: 0 1.9vw 2.6vh;
380
463
  }
464
+ /* The scene column is wide enough for the scrub bar between the two clusters (see the
465
+ portrait-only note on .reel-foot__audio). */
466
+ .reel-foot__audio {
467
+ display: flex;
468
+ flex: 1 1 auto;
469
+ margin: 0 1.4vw;
470
+ }
381
471
  /* Scene reserve is portrait-only; in landscape the scene-area is the full-height flex column. */
382
472
  .stage--section .scene-area {
383
473
  top: auto;
@@ -2,4 +2,5 @@ export declare function useNarration(src: string | undefined, onEnded: () => voi
2
2
  playing: boolean;
3
3
  toggle: () => void;
4
4
  stop: () => void;
5
+ audio: HTMLAudioElement | null;
5
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphlearning/shell",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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": {
@@ -28,7 +28,7 @@
28
28
  "watch": "vite build --watch"
29
29
  },
30
30
  "peerDependencies": {
31
- "@graphlearning/flow": ">=0.8.0 <1.0.0",
31
+ "@graphlearning/flow": ">=0.8.0 <2.0.0",
32
32
  "esbuild": ">=0.20",
33
33
  "lucide-react": "^1.28.0",
34
34
  "puppeteer": ">=24",
@@ -38,7 +38,7 @@
38
38
  "remark-gfm": "^4.0.1"
39
39
  },
40
40
  "devDependencies": {
41
- "@graphlearning/flow": "^0.8.0",
41
+ "@graphlearning/flow": "^1.0.0",
42
42
  "@types/react": "^19.2.0",
43
43
  "@types/react-dom": "^19.2.0",
44
44
  "@vitejs/plugin-react": "^4.3.4",