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