@graphlearning/shell 0.2.0 → 0.3.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 CHANGED
@@ -29,6 +29,31 @@ import { getScene } from './scenes'
29
29
  That is the whole integration. A content repo's `src/` is then `content/`, `scenes/`, `main.tsx`
30
30
  and `theme.css`.
31
31
 
32
+ ## The site bar
33
+
34
+ `ConceptApp` renders the platform's header — brand, section nav, an empty actions slot — above its
35
+ catalog page, and nowhere else. It is a port of the bar at graphl.in, class for class, so a reader
36
+ crossing from the catalog into a concept app never leaves "the site". Nothing to pass: a content
37
+ repo is a course, which is the default.
38
+
39
+ It is deliberately platform-coloured, not `--brand`: looking identical on all eight sites is the
40
+ whole reason it exists. It renders on the catalog page ONLY — never on a section, which is a video
41
+ frame — so the recorder cannot see it.
42
+
43
+ An app that is not a course (`python-lab` is a lab) says so, and one that is not a `ConceptApp` at
44
+ all imports the bar directly — the one part of this package that is exported on its own:
45
+
46
+ ```tsx
47
+ <ConceptApp … kind="labs" /> // marks Labs as the current nav item
48
+
49
+ import { SiteHeader } from '@graphlearning/shell'
50
+ <SiteHeader kind="labs" actions={<ThemeToggle />} />
51
+ ```
52
+
53
+ `ui-graphl` is buildless vanilla and this is a React package, so the bar exists twice by necessity.
54
+ One design, two implementations — **when you change one, change the other**, or they drift and the
55
+ seam shows on every crossing.
56
+
32
57
  ## The three contracts
33
58
 
34
59
  **1. Import order.** The three stylesheets cascade: engine, shell, theme. The theme must come last
@@ -1,8 +1,9 @@
1
1
  import type { Course, SceneLookup } from './types';
2
- export declare function ConceptApp({ subject, eyebrow, courses: COURSES, getScene, audioBase, }: {
2
+ export declare function ConceptApp({ subject, eyebrow, courses: COURSES, getScene, audioBase, kind, }: {
3
3
  subject: string;
4
4
  courses: Record<string, Course>;
5
5
  eyebrow?: string;
6
6
  getScene: SceneLookup;
7
7
  audioBase?: string;
8
+ kind?: string;
8
9
  }): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import type { Course } from './types';
2
- export declare function CourseIndex({ courses, subject }: {
2
+ export declare function CourseIndex({ courses, subject, kind, }: {
3
3
  courses: Course[];
4
4
  subject: string;
5
+ kind?: string;
5
6
  }): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function SiteHeader({ kind, nav, home, actions, }: {
3
+ kind?: string;
4
+ nav?: {
5
+ id: string;
6
+ label: string;
7
+ }[];
8
+ home?: string;
9
+ actions?: ReactNode;
10
+ }): import("react").JSX.Element;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type { Section, Course, SceneLookup } from './types';
2
2
  export { slugOf, allSections } from './types';
3
3
  export { ConceptApp } from './ConceptApp';
4
+ export { SiteHeader } from './SiteHeader';
package/dist/index.js CHANGED
@@ -1,269 +1,318 @@
1
- import { jsx as e, jsxs as h, Fragment as T } from "react/jsx-runtime";
2
- import { useRef as y, useState as x, useLayoutEffect as G, useEffect as _, useMemo as L, useCallback as C } from "react";
3
- import { SceneView as z } from "@graphlearning/flow";
4
- import { Home as V, Volume2 as D, VolumeX as K, ChevronLeft as M, ChevronRight as R } from "lucide-react";
1
+ import { jsx as e, jsxs as i, Fragment as I } from "react/jsx-runtime";
2
+ import { useRef as L, useState as b, useLayoutEffect as D, useEffect as N, useMemo as E, useCallback as z } from "react";
3
+ import { SceneView as S } from "@graphlearning/flow";
4
+ import { Home as G, Volume2 as K, VolumeX as V, ChevronLeft as R, ChevronRight as W } from "lucide-react";
5
5
  import X from "react-markdown";
6
6
  import F from "remark-gfm";
7
- function I(a, i) {
8
- return `${a.id}-${i.id}`;
7
+ function j(a, c) {
8
+ return `${a.id}-${c.id}`;
9
9
  }
10
- function E(a) {
11
- return a.sections.map((i) => ({ section: i, slug: I(a, i) }));
10
+ function P(a) {
11
+ return a.sections.map((c) => ({ section: c, slug: j(a, c) }));
12
12
  }
13
- const j = 806;
13
+ const B = 806;
14
14
  function O() {
15
- const a = y(null), [i, r] = x(1);
16
- return G(() => {
17
- const d = a.current;
18
- if (!d) return;
19
- const p = () => r(d.clientWidth / j);
20
- p();
21
- const c = new ResizeObserver(p);
22
- return c.observe(d), () => c.disconnect();
23
- }, []), { ref: a, zoom: i };
15
+ const a = L(null), [c, s] = b(1);
16
+ return D(() => {
17
+ const l = a.current;
18
+ if (!l) return;
19
+ const h = () => s(l.clientWidth / B);
20
+ h();
21
+ const f = new ResizeObserver(h);
22
+ return f.observe(l), () => f.disconnect();
23
+ }, []), { ref: a, zoom: c };
24
24
  }
25
- function W({ slide: a, open: i }) {
26
- const { ref: r, zoom: d } = O();
27
- return /* @__PURE__ */ e("aside", { ref: r, className: `slide-panel${i ? " slide-panel--open" : ""}`, children: /* @__PURE__ */ e("div", { className: "slide-panel__scaler", style: { width: j, zoom: d }, children: /* @__PURE__ */ e(X, { remarkPlugins: [F], children: a }) }) });
25
+ function Z({ slide: a, open: c }) {
26
+ const { ref: s, zoom: l } = O();
27
+ return /* @__PURE__ */ e("aside", { ref: s, className: `slide-panel${c ? " slide-panel--open" : ""}`, children: /* @__PURE__ */ e("div", { className: "slide-panel__scaler", style: { width: B, zoom: l }, children: /* @__PURE__ */ e(X, { remarkPlugins: [F], children: a }) }) });
28
28
  }
29
- function Z({
29
+ function q({
30
30
  section: a,
31
- getScene: i,
32
- capture: r = !1,
33
- eyebrow: d,
34
- index: p = 0,
35
- total: c = 0,
36
- onHome: f,
37
- onPrev: l,
38
- onNext: o,
39
- narrating: t = !1,
40
- onToggleNarration: u
31
+ getScene: c,
32
+ capture: s = !1,
33
+ eyebrow: l,
34
+ index: h = 0,
35
+ total: f = 0,
36
+ onHome: o,
37
+ onPrev: _,
38
+ onNext: u,
39
+ narrating: n = !1,
40
+ onToggleNarration: p
41
41
  }) {
42
- const [m, v] = x(!1), N = i(a.scene);
43
- return N ? /* @__PURE__ */ h("div", { className: "stage stage--section", children: [
44
- /* @__PURE__ */ h("header", { className: "reel-head", children: [
45
- d && /* @__PURE__ */ e("button", { className: "reel-head__eyebrow", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: d }),
42
+ const [d, m] = b(!1), v = c(a.scene);
43
+ return v ? /* @__PURE__ */ i("div", { className: "stage stage--section", children: [
44
+ /* @__PURE__ */ i("header", { className: "reel-head", children: [
45
+ l && /* @__PURE__ */ e("button", { className: "reel-head__eyebrow", onClick: o, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: l }),
46
46
  /* @__PURE__ */ e("h1", { className: "reel-head__title", children: a.title })
47
47
  ] }),
48
- !r && /* @__PURE__ */ h("footer", { className: "reel-foot reel-foot--controls", children: [
49
- /* @__PURE__ */ h("span", { className: "reel-foot__grp", children: [
50
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: f, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: /* @__PURE__ */ e(V, { size: 17 }) }),
48
+ !s && /* @__PURE__ */ i("footer", { className: "reel-foot reel-foot--controls", children: [
49
+ /* @__PURE__ */ i("span", { className: "reel-foot__grp", children: [
50
+ /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: o, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: /* @__PURE__ */ e(G, { size: 17 }) }),
51
51
  /* @__PURE__ */ e(
52
52
  "button",
53
53
  {
54
54
  className: "reel-foot__ctrl",
55
- onClick: u,
56
- "aria-label": t ? "Pause narration (Space)" : "Play narration (Space)",
57
- title: t ? "Pause narration (Space)" : "Play narration (Space)",
58
- children: t ? /* @__PURE__ */ e(D, { size: 18 }) : /* @__PURE__ */ e(K, { size: 18 })
55
+ onClick: p,
56
+ "aria-label": n ? "Pause narration (Space)" : "Play narration (Space)",
57
+ title: n ? "Pause narration (Space)" : "Play narration (Space)",
58
+ children: n ? /* @__PURE__ */ e(K, { size: 18 }) : /* @__PURE__ */ e(V, { size: 18 })
59
59
  }
60
60
  )
61
61
  ] }),
62
- /* @__PURE__ */ h("span", { className: "reel-foot__nav", children: [
63
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: l, "aria-label": "Previous section (←)", title: "Previous section (←)", children: /* @__PURE__ */ e(M, { size: 19 }) }),
64
- /* @__PURE__ */ h("span", { className: "reel-foot__count reel-foot__count--live", children: [
65
- p + 1,
62
+ /* @__PURE__ */ i("span", { className: "reel-foot__nav", children: [
63
+ /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: _, "aria-label": "Previous section (←)", title: "Previous section (←)", children: /* @__PURE__ */ e(R, { size: 19 }) }),
64
+ /* @__PURE__ */ i("span", { className: "reel-foot__count reel-foot__count--live", children: [
65
+ h + 1,
66
66
  " / ",
67
- c
67
+ f
68
68
  ] }),
69
- /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: o, "aria-label": "Next section (→)", title: "Next section (→)", children: /* @__PURE__ */ e(R, { size: 19 }) })
69
+ /* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: u, "aria-label": "Next section (→)", title: "Next section (→)", children: /* @__PURE__ */ e(W, { size: 19 }) })
70
70
  ] })
71
71
  ] }),
72
- /* @__PURE__ */ e("div", { className: "scene-area", children: /* @__PURE__ */ e(z, { scene: N, focusId: a.focus }) }),
73
- !r && /* @__PURE__ */ e(
72
+ /* @__PURE__ */ e("div", { className: "scene-area", children: /* @__PURE__ */ e(S, { scene: v, focusId: a.focus }) }),
73
+ !s && /* @__PURE__ */ e(
74
74
  "button",
75
75
  {
76
76
  className: "slide-toggle",
77
- onClick: () => v((w) => !w),
78
- "aria-label": m ? "Hide slide" : "Show slide",
79
- children: m ? "›" : "‹"
77
+ onClick: () => m((w) => !w),
78
+ "aria-label": d ? "Hide slide" : "Show slide",
79
+ children: d ? "›" : "‹"
80
80
  }
81
81
  ),
82
- /* @__PURE__ */ e(W, { slide: a.slide, open: m })
83
- ] }) : /* @__PURE__ */ h("div", { className: "stage stage--missing", children: [
82
+ /* @__PURE__ */ e(Z, { slide: a.slide, open: d })
83
+ ] }) : /* @__PURE__ */ i("div", { className: "stage stage--missing", children: [
84
84
  "no scene: ",
85
85
  a.scene
86
86
  ] });
87
87
  }
88
- function q({ courses: a, subject: i }) {
89
- const [r, d] = x(() => /* @__PURE__ */ new Set()), p = (c) => d((f) => {
90
- const l = new Set(f);
91
- return l.has(c) ? l.delete(c) : l.add(c), l;
92
- });
93
- return /* @__PURE__ */ h("div", { className: "idx", children: [
94
- /* @__PURE__ */ h("header", { className: "idx__head", children: [
95
- /* @__PURE__ */ e("a", { className: "idx__brand", href: "/", children: "GraphL" }),
96
- /* @__PURE__ */ e("h1", { className: "idx__subject", children: i })
97
- ] }),
98
- a.length === 0 ? /* @__PURE__ */ e("p", { className: "idx__empty", children: "No courses authored yet — the app shell is ready." }) : /* @__PURE__ */ e("ol", { className: "idx__grid", children: a.map((c, f) => {
99
- const l = E(c), o = r.has(c.id);
100
- return /* @__PURE__ */ h("li", { className: `idx-card${o ? " idx-card--open" : ""}`, children: [
101
- /* @__PURE__ */ h(
102
- "button",
103
- {
104
- className: "idx-card__head",
105
- onClick: () => p(c.id),
106
- "aria-expanded": o,
107
- children: [
108
- /* @__PURE__ */ e("span", { className: "idx-card__num", children: String(f + 1).padStart(2, "0") }),
109
- /* @__PURE__ */ e("span", { className: "idx-card__title", children: c.title }),
110
- /* @__PURE__ */ h("span", { className: "idx-card__count", children: [
111
- l.length,
112
- " sections"
113
- ] }),
114
- /* @__PURE__ */ e("span", { className: "idx-card__toggle", "aria-hidden": "true", children: "⌄" })
115
- ]
116
- }
117
- ),
118
- o && /* @__PURE__ */ e("ol", { className: "idx-sec", children: l.map(({ section: t, slug: u }, m) => /* @__PURE__ */ e("li", { className: "idx-sec__row", children: /* @__PURE__ */ h("a", { href: `#/${u}`, children: [
119
- /* @__PURE__ */ e("span", { className: "idx-sec__num", children: m + 1 }),
120
- /* @__PURE__ */ e("span", { className: "idx-sec__title", children: t.title }),
121
- /* @__PURE__ */ e("span", { className: "idx-sec__chev", "aria-hidden": "true", children: "›" })
122
- ] }) }, u)) })
123
- ] }, c.id);
124
- }) }),
125
- /* @__PURE__ */ e("footer", { className: "idx__foot", children: /* @__PURE__ */ h("span", { className: "idx__hint", children: [
126
- /* @__PURE__ */ e("kbd", { children: "→" }),
127
- " next · ",
128
- /* @__PURE__ */ e("kbd", { children: "←" }),
129
- " prev · ",
130
- /* @__PURE__ */ e("kbd", { children: "Space" }),
131
- " narration"
132
- ] }) })
88
+ const J = [
89
+ { id: "courses", label: "Courses" },
90
+ { id: "labs", label: "Labs" },
91
+ { id: "coach", label: "Coach" }
92
+ ];
93
+ function Q() {
94
+ return /* @__PURE__ */ i("svg", { className: "site__logo", width: "28", height: "28", viewBox: "0 0 512 512", "aria-hidden": "true", children: [
95
+ /* @__PURE__ */ e("rect", { width: "512", height: "512", rx: "80", fill: "#e8804f" }),
96
+ /* @__PURE__ */ i("g", { fill: "none", stroke: "#ffffff", strokeWidth: "58", strokeLinecap: "butt", strokeLinejoin: "miter", children: [
97
+ /* @__PURE__ */ e("path", { d: "M 373 200 A 130 130 0 1 0 373 312" }),
98
+ /* @__PURE__ */ e("path", { d: "M 283 312 L 373 312 L 373 410" })
99
+ ] })
133
100
  ] });
134
101
  }
135
- function J(a, i) {
136
- const r = y(null), d = y(i);
137
- d.current = i;
138
- const [p, c] = x(!1), f = y(p);
139
- return f.current = p, _(() => {
140
- const t = new Audio();
141
- return t.addEventListener("ended", () => d.current()), t.addEventListener("error", () => c(!1)), r.current = t, () => t.pause();
142
- }, []), _(() => {
143
- const t = r.current;
144
- t && (t.pause(), a ? (t.src = a, t.currentTime = 0, f.current && t.play().catch(() => {
145
- })) : (t.removeAttribute("src"), t.load(), c(!1)));
146
- }, [a]), { playing: p, toggle: () => {
147
- const t = r.current;
148
- !t || !t.getAttribute("src") || (f.current ? (t.pause(), c(!1)) : (t.play().catch(() => {
149
- }), c(!0)));
102
+ function Y({
103
+ kind: a = "courses",
104
+ nav: c = J,
105
+ home: s = "/",
106
+ actions: l
107
+ }) {
108
+ return /* @__PURE__ */ e("header", { className: "site", children: /* @__PURE__ */ i("div", { className: "site__inner", children: [
109
+ /* @__PURE__ */ i("a", { className: "site__brand", href: s, children: [
110
+ /* @__PURE__ */ e(Q, {}),
111
+ /* @__PURE__ */ e("span", { className: "site__name", children: "GraphL" })
112
+ ] }),
113
+ /* @__PURE__ */ e("nav", { className: "site__nav", "aria-label": "Sections", children: c.map((h) => /* @__PURE__ */ e(
114
+ "a",
115
+ {
116
+ className: "site__link",
117
+ href: `${s}#${h.id}`,
118
+ ...h.id === a ? { "aria-current": "page" } : {},
119
+ children: h.label
120
+ },
121
+ h.id
122
+ )) }),
123
+ /* @__PURE__ */ e("div", { className: "site__actions", children: l })
124
+ ] }) });
125
+ }
126
+ function U({
127
+ courses: a,
128
+ subject: c,
129
+ kind: s
130
+ }) {
131
+ const [l, h] = b(() => /* @__PURE__ */ new Set()), f = (o) => h((_) => {
132
+ const u = new Set(_);
133
+ return u.has(o) ? u.delete(o) : u.add(o), u;
134
+ });
135
+ return (
136
+ // The site bar is a SIBLING of .idx, not a child: it is full-bleed (its rule spans the
137
+ // viewport) while .idx is a 940px centred column. Both use the same width and gutters, so the
138
+ // brand lines up with the card edge below it.
139
+ /* @__PURE__ */ i(I, { children: [
140
+ /* @__PURE__ */ e(Y, { kind: s }),
141
+ /* @__PURE__ */ i("div", { className: "idx", children: [
142
+ /* @__PURE__ */ e("header", { className: "idx__head", children: /* @__PURE__ */ e("h1", { className: "idx__subject", children: c }) }),
143
+ a.length === 0 ? /* @__PURE__ */ e("p", { className: "idx__empty", children: "No courses authored yet — the app shell is ready." }) : /* @__PURE__ */ e("ol", { className: "idx__grid", children: a.map((o, _) => {
144
+ const u = P(o), n = l.has(o.id);
145
+ return /* @__PURE__ */ i("li", { className: `idx-card${n ? " idx-card--open" : ""}`, children: [
146
+ /* @__PURE__ */ i(
147
+ "button",
148
+ {
149
+ className: "idx-card__head",
150
+ onClick: () => f(o.id),
151
+ "aria-expanded": n,
152
+ children: [
153
+ /* @__PURE__ */ e("span", { className: "idx-card__num", children: String(_ + 1).padStart(2, "0") }),
154
+ /* @__PURE__ */ e("span", { className: "idx-card__title", children: o.title }),
155
+ /* @__PURE__ */ i("span", { className: "idx-card__count", children: [
156
+ u.length,
157
+ " sections"
158
+ ] }),
159
+ /* @__PURE__ */ e("span", { className: "idx-card__toggle", "aria-hidden": "true", children: "⌄" })
160
+ ]
161
+ }
162
+ ),
163
+ n && /* @__PURE__ */ e("ol", { className: "idx-sec", children: u.map(({ section: p, slug: d }, m) => /* @__PURE__ */ e("li", { className: "idx-sec__row", children: /* @__PURE__ */ i("a", { href: `#/${d}`, children: [
164
+ /* @__PURE__ */ e("span", { className: "idx-sec__num", children: m + 1 }),
165
+ /* @__PURE__ */ e("span", { className: "idx-sec__title", children: p.title }),
166
+ /* @__PURE__ */ e("span", { className: "idx-sec__chev", "aria-hidden": "true", children: "›" })
167
+ ] }) }, d)) })
168
+ ] }, o.id);
169
+ }) }),
170
+ /* @__PURE__ */ e("footer", { className: "idx__foot", children: /* @__PURE__ */ i("span", { className: "idx__hint", children: [
171
+ /* @__PURE__ */ e("kbd", { children: "→" }),
172
+ " next · ",
173
+ /* @__PURE__ */ e("kbd", { children: "←" }),
174
+ " prev · ",
175
+ /* @__PURE__ */ e("kbd", { children: "Space" }),
176
+ " narration"
177
+ ] }) })
178
+ ] })
179
+ ] })
180
+ );
181
+ }
182
+ function ee(a, c) {
183
+ const s = L(null), l = L(c);
184
+ l.current = c;
185
+ const [h, f] = b(!1), o = L(h);
186
+ return o.current = h, N(() => {
187
+ const n = new Audio();
188
+ return n.addEventListener("ended", () => l.current()), n.addEventListener("error", () => f(!1)), s.current = n, () => n.pause();
189
+ }, []), N(() => {
190
+ const n = s.current;
191
+ n && (n.pause(), a ? (n.src = a, n.currentTime = 0, o.current && n.play().catch(() => {
192
+ })) : (n.removeAttribute("src"), n.load(), f(!1)));
193
+ }, [a]), { playing: h, toggle: () => {
194
+ const n = s.current;
195
+ !n || !n.getAttribute("src") || (o.current ? (n.pause(), f(!1)) : (n.play().catch(() => {
196
+ }), f(!0)));
150
197
  }, stop: () => {
151
- var t;
152
- (t = r.current) == null || t.pause(), c(!1);
198
+ var n;
199
+ (n = s.current) == null || n.pause(), f(!1);
153
200
  } };
154
201
  }
155
- function ae({
202
+ function ce({
156
203
  subject: a,
157
- eyebrow: i,
158
- courses: r,
159
- getScene: d,
160
- audioBase: p = "/"
204
+ eyebrow: c,
205
+ courses: s,
206
+ getScene: l,
207
+ audioBase: h = "/",
208
+ kind: f = "courses"
161
209
  }) {
162
- const [c, f] = x(() => location.hash);
163
- _(() => {
164
- const n = () => f(location.hash);
165
- return window.addEventListener("hashchange", n), () => window.removeEventListener("hashchange", n);
210
+ const [o, _] = b(() => location.hash);
211
+ N(() => {
212
+ const t = () => _(location.hash);
213
+ return window.addEventListener("hashchange", t), () => window.removeEventListener("hashchange", t);
166
214
  }, []);
167
- const l = new URLSearchParams(location.search).get("capture") === "1", o = c.replace(/^#\/?/, ""), t = L(() => {
168
- if (r[o]) return r[o];
169
- for (const n of Object.values(r))
170
- if (n.sections.some((s) => I(n, s) === o)) return n;
171
- return Object.values(r)[0];
172
- }, [o, r]), u = L(() => E(t), [t]), m = L(() => Object.values(r).flatMap(E), [r]);
173
- _(() => {
174
- if (l || !r[o]) return;
175
- const n = u[0];
176
- n && (history.replaceState(null, "", `${location.pathname}${location.search}#/${n.slug}`), f(`#/${n.slug}`));
177
- }, [l, o, u]), _(() => {
178
- l && (window.__scene = {
179
- plan: () => u.map(({ slug: n, section: s }) => ({
180
- slug: n,
181
- course: t.id,
215
+ const u = new URLSearchParams(location.search).get("capture") === "1", n = o.replace(/^#\/?/, ""), p = E(() => {
216
+ if (s[n]) return s[n];
217
+ for (const t of Object.values(s))
218
+ if (t.sections.some((r) => j(t, r) === n)) return t;
219
+ return Object.values(s)[0];
220
+ }, [n, s]), d = E(() => P(p), [p]), m = E(() => Object.values(s).flatMap(P), [s]);
221
+ N(() => {
222
+ if (u || !s[n]) return;
223
+ const t = d[0];
224
+ t && (history.replaceState(null, "", `${location.pathname}${location.search}#/${t.slug}`), _(`#/${t.slug}`));
225
+ }, [u, n, d]), N(() => {
226
+ u && (window.__scene = {
227
+ plan: () => d.map(({ slug: t, section: r }) => ({
228
+ slug: t,
229
+ course: p.id,
182
230
  // audio lives at audio/<course>/<id>.wav
183
- id: s.id,
184
- scene: s.scene,
185
- focus: s.focus ?? null
231
+ id: r.id,
232
+ scene: r.scene,
233
+ focus: r.focus ?? null
186
234
  }))
187
235
  });
188
- }, [l, u, t]);
189
- const v = C(
190
- (n) => {
191
- const s = location.hash.replace(/^#\/?/, ""), g = m.findIndex(({ slug: k }) => k === s);
236
+ }, [u, d, p]);
237
+ const v = z(
238
+ (t) => {
239
+ const r = location.hash.replace(/^#\/?/, ""), g = m.findIndex(({ slug: y }) => y === r);
192
240
  if (g !== -1) {
193
- const k = m[g + n];
194
- location.hash = k ? `#/${k.slug}` : "";
241
+ const y = m[g + t];
242
+ location.hash = y ? `#/${y.slug}` : "";
195
243
  return;
196
244
  }
197
- if (s === "") {
198
- n === 1 && m[0] && (location.hash = `#/${m[0].slug}`);
245
+ if (r === "") {
246
+ t === 1 && m[0] && (location.hash = `#/${m[0].slug}`);
199
247
  return;
200
248
  }
201
- const b = n === 1 ? u[0] : u[u.length - 1];
202
- b && (location.hash = `#/${b.slug}`);
249
+ const k = t === 1 ? d[0] : d[d.length - 1];
250
+ k && (location.hash = `#/${k.slug}`);
203
251
  },
204
- [u, m]
252
+ [d, m]
205
253
  );
206
- _(() => {
207
- const n = (s) => {
208
- s.key !== "ArrowRight" && s.key !== "ArrowLeft" || (s.preventDefault(), v(s.key === "ArrowRight" ? 1 : -1));
254
+ N(() => {
255
+ const t = (r) => {
256
+ r.key !== "ArrowRight" && r.key !== "ArrowLeft" || (r.preventDefault(), v(r.key === "ArrowRight" ? 1 : -1));
209
257
  };
210
- return window.addEventListener("keydown", n), () => window.removeEventListener("keydown", n);
258
+ return window.addEventListener("keydown", t), () => window.removeEventListener("keydown", t);
211
259
  }, [v]);
212
- const N = C(() => {
260
+ const w = z(() => {
213
261
  location.hash ? location.hash = "" : location.pathname !== "/" && (location.href = "/");
214
262
  }, []);
215
- _(() => {
216
- const n = (s) => {
217
- s.key === "Escape" && N();
263
+ N(() => {
264
+ const t = (r) => {
265
+ r.key === "Escape" && w();
218
266
  };
219
- return window.addEventListener("keydown", n), () => window.removeEventListener("keydown", n);
220
- }, [N]);
221
- const w = u.find(({ slug: n }) => n === o), B = w ? `${p}audio/${t.id}/${w.section.id}.wav` : void 0, { playing: H, toggle: P, stop: S } = J(B, () => {
222
- const n = location.hash.replace(/^#\/?/, ""), s = m.findIndex(({ slug: b }) => b === n), g = m[s + 1];
223
- g ? location.hash = `#/${g.slug}` : S();
267
+ return window.addEventListener("keydown", t), () => window.removeEventListener("keydown", t);
268
+ }, [w]);
269
+ const x = d.find(({ slug: t }) => t === n), H = x ? `${h}audio/${p.id}/${x.section.id}.wav` : void 0, { playing: M, toggle: A, stop: T } = ee(H, () => {
270
+ const t = location.hash.replace(/^#\/?/, ""), r = m.findIndex(({ slug: k }) => k === t), g = m[r + 1];
271
+ g ? location.hash = `#/${g.slug}` : T();
224
272
  });
225
- if (_(() => {
226
- const n = (s) => {
227
- if (s.key !== " " && s.code !== "Space") return;
228
- const g = s.target;
229
- g && (g.tagName === "INPUT" || g.tagName === "TEXTAREA" || g.isContentEditable) || (s.preventDefault(), P());
273
+ if (N(() => {
274
+ const t = (r) => {
275
+ if (r.key !== " " && r.code !== "Space") return;
276
+ const g = r.target;
277
+ g && (g.tagName === "INPUT" || g.tagName === "TEXTAREA" || g.isContentEditable) || (r.preventDefault(), A());
230
278
  };
231
- return window.addEventListener("keydown", n), () => window.removeEventListener("keydown", n);
232
- }), !o) return /* @__PURE__ */ e(q, { courses: Object.values(r), subject: a });
233
- let $, A = null;
234
- if (w) {
235
- const n = u.findIndex(({ slug: s }) => s === o);
279
+ return window.addEventListener("keydown", t), () => window.removeEventListener("keydown", t);
280
+ }), !n) return /* @__PURE__ */ e(U, { courses: Object.values(s), subject: a, kind: f });
281
+ let $, C = null;
282
+ if (x) {
283
+ const t = d.findIndex(({ slug: r }) => r === n);
236
284
  $ = /* @__PURE__ */ e(
237
- Z,
285
+ q,
238
286
  {
239
- section: w.section,
240
- getScene: d,
241
- capture: l,
242
- eyebrow: `${i ?? a.toUpperCase()} · ${t.id.toUpperCase()}`,
243
- index: n,
244
- total: u.length,
245
- onHome: N,
287
+ section: x.section,
288
+ getScene: l,
289
+ capture: u,
290
+ eyebrow: `${c ?? a.toUpperCase()} · ${p.id.toUpperCase()}`,
291
+ index: t,
292
+ total: d.length,
293
+ onHome: w,
246
294
  onPrev: () => v(-1),
247
295
  onNext: () => v(1),
248
- narrating: H,
249
- onToggleNarration: P
296
+ narrating: M,
297
+ onToggleNarration: A
250
298
  }
251
299
  );
252
300
  } else {
253
- const n = d(o);
254
- $ = n ? /* @__PURE__ */ e("div", { className: "stage", children: /* @__PURE__ */ e(z, { scene: n }) }) : /* @__PURE__ */ h("div", { className: "stage stage--missing", children: [
301
+ const t = l(n);
302
+ $ = t ? /* @__PURE__ */ e("div", { className: "stage", children: /* @__PURE__ */ e(S, { scene: t }) }) : /* @__PURE__ */ i("div", { className: "stage stage--missing", children: [
255
303
  "no scene or slug: ",
256
- o
257
- ] }), A = l ? null : /* @__PURE__ */ e("button", { className: "brand-home", onClick: N, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: "GraphL" });
304
+ n
305
+ ] }), C = u ? null : /* @__PURE__ */ e("button", { className: "brand-home", onClick: w, "aria-label": "Back to catalog (Esc)", title: "Back to catalog (Esc)", children: "GraphL" });
258
306
  }
259
- return /* @__PURE__ */ h(T, { children: [
260
- A,
307
+ return /* @__PURE__ */ i(I, { children: [
308
+ C,
261
309
  $
262
310
  ] });
263
311
  }
264
312
  export {
265
- ae as ConceptApp,
266
- E as allSections,
267
- I as slugOf
313
+ ce as ConceptApp,
314
+ Y as SiteHeader,
315
+ P as allSections,
316
+ j as slugOf
268
317
  };
269
318
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/types.ts","../src/useSlideScale.ts","../src/SlidePanel.tsx","../src/SectionView.tsx","../src/CourseIndex.tsx","../src/useNarration.ts","../src/ConceptApp.tsx"],"sourcesContent":["// The content model, owned by the shell because the shell is what renders it. A content repo\n// authors data that satisfies these types; it no longer declares them (every repo's copy of\n// content/types.ts was byte-identical, which is the definition of a shared contract).\n//\n// concept ⊃ course ⊃ section. A SECTION is the atomic unit of a video: one slug (course-section),\n// one scene, one slide, one narration clip.\n//\n// The authoring contract: .md (source of truth) → (.slide, .tts)\n// slide = terse markdown shown in the SlidePanel (for the eye)\n// narration = natural-flow spoken script for TTS / Chatterbox (for the ear)\n\nexport interface Section {\n id: string // \"what-is-cloud\" → slug: foundations-what-is-cloud\n title: string\n scene: string // scene id shown behind the panel (may be shared across sections)\n focus?: string // node the slide floats clear of (optional)\n slide: string // markdown → rendered in the SlidePanel\n narration: string // natural-flow TTS script (Chatterbox)\n}\n\nexport interface Course {\n id: string\n title: string\n sections: Section[]\n}\n\n// How a content repo hands its scenes to the shell. The shell never imports a scene registry — it\n// is given this lookup, so scenes stay entirely the repo's business.\nexport type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined\n\n// The slug for a section is `<courseId>-<sectionId>` — section IS the unit (one slide, one\n// narration), so no trailing beat index.\nexport function slugOf(course: Course, section: Section): string {\n return `${course.id}-${section.id}`\n}\n\nexport function allSections(course: Course): { section: Section; slug: string }[] {\n return course.sections.map((section) => ({ section, slug: slugOf(course, section) }))\n}\n","import { useLayoutEffect, useRef, useState } from 'react'\n\n// The slide is authored at a fixed DESIGN width and scaled with CSS `zoom` to the live pane width,\n// so it looks pixel-proportional at any frame — 1440 preview, 1080p, or 4K capture alike. This is\n// what makes the slide's dimensions a function of the TARGET video frame, not the browser window.\n//\n// DESIGN_PANE = 806 = 42% of a 1920-wide design frame (the slide pane's share). zoom = live/806:\n// 1440 window → pane ~605 → zoom 0.75 | 1080p → 806 → 1.0 | 4K → 1613 → 2.0\nexport const DESIGN_PANE = 806\n\nexport function useSlideScale() {\n const ref = useRef<HTMLElement>(null)\n const [zoom, setZoom] = useState(1)\n useLayoutEffect(() => {\n const el = ref.current\n if (!el) return\n const measure = () => setZoom(el.clientWidth / DESIGN_PANE)\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n return { ref, zoom }\n}\n","import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { DESIGN_PANE, useSlideScale } from './useSlideScale'\n\n// The slide (a section's `.slide` markdown) — a full-height surface fixed to the RIGHT, lifted off\n// the scene canvas for figure-ground. Position is CSS-driven (see index.css):\n// landscape (laptop → video) — a 42% column beside the scene.\n// portrait (mobile → reel) — an off-canvas drawer that slides in from the right when `open`.\n// The inner scaler is authored at DESIGN_PANE px and `zoom`-scaled to the live pane, so the slide is\n// proportional to the target video frame at any size (see useSlideScale).\nexport function SlidePanel({ slide, open }: { slide: string; open: boolean }) {\n const { ref, zoom } = useSlideScale()\n return (\n <aside ref={ref} className={`slide-panel${open ? ' slide-panel--open' : ''}`}>\n <div className=\"slide-panel__scaler\" style={{ width: DESIGN_PANE, zoom }}>\n <ReactMarkdown remarkPlugins={[remarkGfm]}>{slide}</ReactMarkdown>\n </div>\n </aside>\n )\n}\n","import { useState } from 'react'\nimport { Home, Volume2, VolumeX, ChevronLeft, ChevronRight } from 'lucide-react'\nimport { SceneView } from '@graphlearning/flow'\nimport type { Section, SceneLookup } from './types'\nimport { SlidePanel } from './SlidePanel'\n\n// The SECTION view = the final composited output of a slug (course-section): the vertical scene +\n// the fixed-right slide, with an eyebrow + section title header. Layout is responsive (CSS):\n// landscape (laptop → 4K video) — scene fills the left, slide is a right column; the title lives\n// in the slide, so the header shows just the eyebrow.\n// portrait (mobile → reel) — scene fills the frame, slide is a full-width drawer toggled at\n// top-right; the header shows the full title card.\n// Under ?capture=1 the frame is clean for video: just the eyebrow + title + scene — NO footer and NO\n// interactive controls (the drawer toggle and the footer control bar are both suppressed).\n// Interactively, a footer control bar carries Home + narration + prev/next (raised above the drawer\n// so it works while the slide is open), the drawer toggle appears in portrait, and the eyebrow also\n// links back to the catalog. Keyboard works everywhere (← / → navigate, Space toggles narration).\nexport function SectionView({\n section,\n getScene,\n capture = false,\n eyebrow,\n index = 0,\n total = 0,\n onHome,\n onPrev,\n onNext,\n narrating = false,\n onToggleNarration,\n}: {\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}) {\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 </span>\n <span className=\"reel-foot__nav\">\n <button className=\"reel-foot__ctrl\" onClick={onPrev} aria-label=\"Previous section (←)\" title=\"Previous section (←)\">\n <ChevronLeft size={19} />\n </button>\n <span className=\"reel-foot__count reel-foot__count--live\">\n {index + 1} / {total}\n </span>\n <button className=\"reel-foot__ctrl\" onClick={onNext} aria-label=\"Next section (→)\" title=\"Next section (→)\">\n <ChevronRight size={19} />\n </button>\n </span>\n </footer>\n )}\n <div className=\"scene-area\">\n <SceneView scene={scene} focusId={section.focus} />\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 { useState } from 'react'\nimport type { Course } from './types'\nimport { allSections } from './types'\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({ courses, subject }: { courses: Course[]; subject: string }) {\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 <div className=\"idx\">\n {/* GraphL = the platform brand (constant across every content repo); the eyebrow links back to\n the root graphl.in catalog. `subject` is the repo's own name (\"Python\", \"SQL\"), passed in\n by the app — the one piece of this page the shell cannot know. */}\n <header className=\"idx__head\">\n <a className=\"idx__brand\" href=\"/\">\n GraphL\n </a>\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","import { useEffect, useRef, useState } from 'react'\n\n// The narration channel: one <audio> for the app's lifetime, driven by SPACE (toggle) and by\n// section navigation. Loading a section's clip keeps the play state — if narration is on, changing\n// sections auto-plays the next clip — and a clip that finishes calls `onEnded` so playback\n// auto-advances to the next SECTION. Timing comes from the clip length itself (no timestamp\n// anchors), so regenerated audio never drifts. A missing clip (not yet generated, or a 404) fails\n// silently and resets the play state — paging still works, just no audio.\n//\n// Adapted from graphl-studio's reveal-engine useNarration: this repo has no beats, so the unit is\n// one clip per section (`<audioBase>/<section-id>.wav`) rather than per beat.\nexport function useNarration(src: string | undefined, onEnded: () => void) {\n const audioRef = useRef<HTMLAudioElement | null>(null)\n const endedRef = useRef(onEnded)\n endedRef.current = onEnded\n const [playing, setPlaying] = useState(false)\n const playingRef = useRef(playing)\n playingRef.current = playing\n\n useEffect(() => {\n const a = new Audio()\n a.addEventListener('ended', () => endedRef.current())\n // A clip that can't load (not generated yet, 404) must not leave a stale \"playing\" state.\n a.addEventListener('error', () => setPlaying(false))\n audioRef.current = a\n return () => a.pause()\n }, [])\n\n // Load the current section's clip; resume playing if narration was on. Because playback is\n // gesture-initiated (SPACE), the browser lets the auto-advance chain keep playing.\n useEffect(() => {\n const a = audioRef.current\n if (!a) return\n a.pause()\n if (src) {\n a.src = src\n a.currentTime = 0\n if (playingRef.current) a.play().catch(() => {})\n } else {\n // Fully unload so a later play() doesn't resume a stale buffered clip.\n a.removeAttribute('src')\n a.load()\n setPlaying(false)\n }\n }, [src])\n\n const toggle = () => {\n const a = audioRef.current\n if (!a || !a.getAttribute('src')) return // nothing loaded (a section with no clip)\n if (playingRef.current) {\n a.pause()\n setPlaying(false)\n } else {\n a.play().catch(() => {})\n setPlaying(true)\n }\n }\n\n /** Hard stop — pause and clear the play state (used at the end of the course). */\n const stop = () => {\n audioRef.current?.pause()\n setPlaying(false)\n }\n\n return { playing, toggle, stop }\n}\n","import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { SceneView } 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'\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}: {\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}) {\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 const id = hash.replace(/^#\\/?/, '')\n\n // Which course are we in? The recorder lands on #/<course> to read the plan; a human lands on a\n // slug. So the active course is: a bare course id, else the course that OWNS the matched slug, else\n // the first course (the nav context for a bare scene view). Everything course-scoped below —\n // section nav, the capture plan, the audio path, the header eyebrow — reads from this.\n const activeCourse = useMemo(() => {\n if (COURSES[id]) return COURSES[id]\n for (const c of Object.values(COURSES)) {\n if (c.sections.some((s) => slugOf(c, s) === id)) return c\n }\n return Object.values(COURSES)[0]\n }, [id, COURSES])\n const sections = useMemo(() => allSections(activeCourse), [activeCourse])\n // The whole catalog as one ordered stream (in COURSES insertion\n // order). Section nav walks THIS so → past a course's last section rolls into the next course's\n // first — and narration flows chapter-to-chapter — instead of dead-ending at the index. The\n // per-course `sections` above still drives the capture plan and the §n/N header (chapter-relative).\n const globalSections = useMemo(() => Object.values(COURSES).flatMap(allSections), [COURSES])\n\n // A bare COURSE id in the hash has no page of its own — the GraphL catalog links each course to\n // `<slug>/#/<courseId>`, so a human arriving there should ENTER the course at its first section.\n // Guarded by !capture: the recorder deliberately lands on `#/<course>` to read window.__scene.plan\n // (below), so it must not be redirected. Replace (not push) so Back skips the bare course-id URL.\n useEffect(() => {\n if (capture || !COURSES[id]) return\n const first = sections[0]\n if (!first) return\n history.replaceState(null, '', `${location.pathname}${location.search}#/${first.slug}`)\n setHash(`#/${first.slug}`)\n }, [capture, id, sections])\n\n useEffect(() => {\n if (!capture) return\n // Capture contract (fleshed out in step 2): every slug + its scene + focus node, so the recorder\n // can screenshot each and knows which node the slide must avoid.\n ;(window as unknown as { __scene: unknown }).__scene = {\n plan: () =>\n sections.map(({ slug, section }) => ({\n slug,\n course: activeCourse.id, // audio lives at audio/<course>/<id>.wav\n id: section.id,\n scene: section.scene,\n focus: section.focus ?? null,\n })),\n }\n }, [capture, sections, activeCourse])\n\n // Section navigation, shared by the ← / → keys AND the on-screen pager. `dir` is +1 (next) / -1\n // (prev). Reads the LIVE hash at call time (not React state) so rapid presses can't act on a stale\n // index; from a non-section view, next enters the first section. Walks the GLOBAL stream, so a\n // course boundary is just another step — → past a course's last section enters the next course's\n // first, ← symmetrically crosses back. Only the two ENDS of the whole catalog fall back to the\n // catalog page (like Esc). Navigating just updates the hash, which re-renders via the listener.\n const go = useCallback(\n (dir: 1 | -1) => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const gidx = globalSections.findIndex(({ slug }) => slug === cur)\n if (gidx !== -1) {\n const target = globalSections[gidx + dir]\n location.hash = target ? `#/${target.slug}` : '' // catalog only at the catalog's ends\n return\n }\n // The CATALOG index (empty hash) is the home/start: → begins the stream at its first section;\n // ← does nothing (there is nothing before home — it must not fall into foundations' last).\n if (cur === '') {\n if (dir === 1 && globalSections[0]) location.hash = `#/${globalSections[0].slug}`\n return\n }\n // Otherwise a bare SCENE view (a scene id with course context): → enters the active course's\n // first section, ← its last.\n const target = dir === 1 ? sections[0] : sections[sections.length - 1]\n if (target) location.hash = `#/${target.slug}`\n },\n [sections, globalSections],\n )\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n e.preventDefault()\n go(e.key === 'ArrowRight' ? 1 : -1)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go])\n\n // Back to the catalog: clear the hash (empty hash → CourseIndex). No-ops when already there. Wired\n // to the top-left GraphL brand AND the Esc key (below), so any inner page is one gesture from home.\n const goHome = useCallback(() => {\n if (location.hash) location.hash = '' // inner page → this concept's index\n else if (location.pathname !== '/') location.href = '/' // already at the index → up to the graphl.in catalog\n }, [])\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') goHome()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [goHome])\n\n // Slug (section) first.\n const match = sections.find(({ slug }) => slug === id)\n\n // Narration channel. A section's clip lives at public/audio/<courseId>/<section-id>.wav (pushed\n // from the Colab generator). SPACE toggles it; when a clip finishes we roll into the NEXT section\n // (which auto-plays because the play state is preserved) so the whole course narrates hands-free.\n // Outside a section (scene view) src is undefined → the channel unloads.\n const audioUrl = match\n ? `${audioBase}audio/${activeCourse.id}/${match.section.id}.wav`\n : undefined\n const { playing, toggle, stop } = useNarration(audioUrl, () => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const idx = globalSections.findIndex(({ slug }) => slug === cur)\n const next = globalSections[idx + 1] // rolls across course boundaries into the next chapter\n if (next) location.hash = `#/${next.slug}`\n else stop() // last section of the whole catalog — nothing to advance to\n })\n\n // SPACE enables/disables narration. Re-bound every render (no deps) so it always sees the current\n // `toggle`; guarded so it never hijacks the spacebar while typing in an input.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== ' ' && e.code !== 'Space') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n e.preventDefault()\n toggle()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n })\n\n // Empty hash → the course catalog landing page (its own brand, no overlay, no home button here).\n if (!id) return <CourseIndex courses={Object.values(COURSES)} subject={subject} />\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 />\n )\n } else {\n // Otherwise a scene id.\n const scene = getScene(id)\n content = scene ? (\n <div className=\"stage\">\n <SceneView scene={scene} />\n </div>\n ) : (\n <div className=\"stage stage--missing\">no scene or slug: {id}</div>\n )\n brand = capture ? null : (\n <button className=\"brand-home\" onClick={goHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n GraphL\n </button>\n )\n }\n\n return (\n <>\n {brand}\n {content}\n </>\n )\n}\n"],"names":["slugOf","course","section","allSections","DESIGN_PANE","useSlideScale","ref","useRef","zoom","setZoom","useState","useLayoutEffect","el","measure","ro","SlidePanel","slide","open","jsx","ReactMarkdown","remarkGfm","SectionView","getScene","capture","eyebrow","index","total","onHome","onPrev","onNext","narrating","onToggleNarration","setOpen","scene","jsxs","Home","Volume2","VolumeX","ChevronLeft","ChevronRight","SceneView","o","CourseIndex","courses","subject","toggle","id","prev","next","i","sections","isOpen","slug","j","useNarration","src","onEnded","audioRef","endedRef","playing","setPlaying","playingRef","useEffect","a","_a","ConceptApp","COURSES","audioBase","hash","setHash","onHash","activeCourse","useMemo","c","globalSections","first","go","useCallback","dir","cur","gidx","target","onKey","e","goHome","match","audioUrl","stop","idx","t","content","brand","Fragment"],"mappings":";;;;;;AAgCO,SAASA,EAAOC,GAAgBC,GAA0B;AAC/D,SAAO,GAAGD,EAAO,EAAE,IAAIC,EAAQ,EAAE;AACnC;AAEO,SAASC,EAAYF,GAAsD;AAChF,SAAOA,EAAO,SAAS,IAAI,CAACC,OAAa,EAAE,SAAAA,GAAS,MAAMF,EAAOC,GAAQC,CAAO,EAAA,EAAI;AACtF;AC9BO,MAAME,IAAc;AAEpB,SAASC,IAAgB;AAC9B,QAAMC,IAAMC,EAAoB,IAAI,GAC9B,CAACC,GAAMC,CAAO,IAAIC,EAAS,CAAC;AAClC,SAAAC,EAAgB,MAAM;AACpB,UAAMC,IAAKN,EAAI;AACf,QAAI,CAACM,EAAI;AACT,UAAMC,IAAU,MAAMJ,EAAQG,EAAG,cAAcR,CAAW;AAC1D,IAAAS,EAAA;AACA,UAAMC,IAAK,IAAI,eAAeD,CAAO;AACrC,WAAAC,EAAG,QAAQF,CAAE,GACN,MAAME,EAAG,WAAA;AAAA,EAClB,GAAG,CAAA,CAAE,GACE,EAAE,KAAAR,GAAK,MAAAE,EAAA;AAChB;ACbO,SAASO,EAAW,EAAE,OAAAC,GAAO,MAAAC,KAA0C;AAC5E,QAAM,EAAE,KAAAX,GAAK,MAAAE,EAAA,IAASH,EAAA;AACtB,SACE,gBAAAa,EAAC,SAAA,EAAM,KAAAZ,GAAU,WAAW,cAAcW,IAAO,uBAAuB,EAAE,IACxE,UAAA,gBAAAC,EAAC,OAAA,EAAI,WAAU,uBAAsB,OAAO,EAAE,OAAOd,GAAa,MAAAI,EAAA,GAChE,UAAA,gBAAAU,EAACC,GAAA,EAAc,eAAe,CAACC,CAAS,GAAI,UAAAJ,EAAA,CAAM,EAAA,CACpD,GACF;AAEJ;ACFO,SAASK,EAAY;AAAA,EAC1B,SAAAnB;AAAA,EACA,UAAAoB;AAAA,EACA,SAAAC,IAAU;AAAA,EACV,SAAAC;AAAA,EACA,OAAAC,IAAQ;AAAA,EACR,OAAAC,IAAQ;AAAA,EACR,QAAAC;AAAA,EACA,QAAAC;AAAA,EACA,QAAAC;AAAA,EACA,WAAAC,IAAY;AAAA,EACZ,mBAAAC;AACF,GAYG;AACD,QAAM,CAACd,GAAMe,CAAO,IAAItB,EAAS,EAAK,GAChCuB,IAAQX,EAASpB,EAAQ,KAAK;AACpC,SAAK+B,IAEH,gBAAAC,EAAC,OAAA,EAAI,WAAU,wBAIb,UAAA;AAAA,IAAA,gBAAAA,EAAC,UAAA,EAAO,WAAU,aACf,UAAA;AAAA,MAAAV,KACC,gBAAAN,EAAC,UAAA,EAAO,WAAU,sBAAqB,SAASS,GAAQ,cAAW,yBAAwB,OAAM,yBAC9F,UAAAH,EAAA,CACH;AAAA,MAEF,gBAAAN,EAAC,MAAA,EAAG,WAAU,oBAAoB,YAAQ,MAAA,CAAM;AAAA,IAAA,GAClD;AAAA,IAKC,CAACK,KACA,gBAAAW,EAAC,UAAA,EAAO,WAAU,iCAChB,UAAA;AAAA,MAAA,gBAAAA,EAAC,QAAA,EAAK,WAAU,kBACd,UAAA;AAAA,QAAA,gBAAAhB,EAAC,UAAA,EAAO,WAAU,mBAAkB,SAASS,GAAQ,cAAW,yBAAwB,OAAM,yBAC5F,UAAA,gBAAAT,EAACiB,GAAA,EAAK,MAAM,IAAI,GAClB;AAAA,QACA,gBAAAjB;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WAAU;AAAA,YACV,SAASa;AAAA,YACT,cAAYD,IAAY,4BAA4B;AAAA,YACpD,OAAOA,IAAY,4BAA4B;AAAA,YAE9C,UAAAA,sBAAaM,GAAA,EAAQ,MAAM,IAAI,IAAK,gBAAAlB,EAACmB,GAAA,EAAQ,MAAM,GAAA,CAAI;AAAA,UAAA;AAAA,QAAA;AAAA,MAC1D,GACF;AAAA,MACA,gBAAAH,EAAC,QAAA,EAAK,WAAU,kBACd,UAAA;AAAA,QAAA,gBAAAhB,EAAC,UAAA,EAAO,WAAU,mBAAkB,SAASU,GAAQ,cAAW,wBAAuB,OAAM,wBAC3F,UAAA,gBAAAV,EAACoB,GAAA,EAAY,MAAM,IAAI,GACzB;AAAA,QACA,gBAAAJ,EAAC,QAAA,EAAK,WAAU,2CACb,UAAA;AAAA,UAAAT,IAAQ;AAAA,UAAE;AAAA,UAAIC;AAAA,QAAA,GACjB;AAAA,QACA,gBAAAR,EAAC,UAAA,EAAO,WAAU,mBAAkB,SAASW,GAAQ,cAAW,oBAAmB,OAAM,oBACvF,UAAA,gBAAAX,EAACqB,GAAA,EAAa,MAAM,IAAI,EAAA,CAC1B;AAAA,MAAA,EAAA,CACF;AAAA,IAAA,GACF;AAAA,IAEF,gBAAArB,EAAC,OAAA,EAAI,WAAU,cACb,UAAA,gBAAAA,EAACsB,KAAU,OAAAP,GAAc,SAAS/B,EAAQ,MAAA,CAAO,EAAA,CACnD;AAAA,IAEC,CAACqB,KACA,gBAAAL;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAU;AAAA,QACV,SAAS,MAAMc,EAAQ,CAACS,MAAM,CAACA,CAAC;AAAA,QAChC,cAAYxB,IAAO,eAAe;AAAA,QAEjC,cAAO,MAAM;AAAA,MAAA;AAAA,IAAA;AAAA,IAGlB,gBAAAC,EAACH,GAAA,EAAW,OAAOb,EAAQ,OAAO,MAAAe,EAAA,CAAY;AAAA,EAAA,GAChD,IA5DiB,gBAAAiB,EAAC,OAAA,EAAI,WAAU,wBAAuB,UAAA;AAAA,IAAA;AAAA,IAAWhC,EAAQ;AAAA,EAAA,GAAM;AA8DpF;AClGO,SAASwC,EAAY,EAAE,SAAAC,GAAS,SAAAC,KAAmD;AAGxF,QAAM,CAAC3B,GAAMe,CAAO,IAAItB,EAAsB,MAAM,oBAAI,KAAK,GACvDmC,IAAS,CAACC,MACdd,EAAQ,CAACe,MAAS;AAChB,UAAMC,IAAO,IAAI,IAAID,CAAI;AACzB,WAAIC,EAAK,IAAIF,CAAE,IAAGE,EAAK,OAAOF,CAAE,IAC3BE,EAAK,IAAIF,CAAE,GACTE;AAAA,EACT,CAAC;AAEH,SACE,gBAAAd,EAAC,OAAA,EAAI,WAAU,OAIb,UAAA;AAAA,IAAA,gBAAAA,EAAC,UAAA,EAAO,WAAU,aAChB,UAAA;AAAA,MAAA,gBAAAhB,EAAC,KAAA,EAAE,WAAU,cAAa,MAAK,KAAI,UAAA,UAEnC;AAAA,MACA,gBAAAA,EAAC,MAAA,EAAG,WAAU,gBAAgB,UAAA0B,EAAA,CAAQ;AAAA,IAAA,GACxC;AAAA,IAECD,EAAQ,WAAW,IAClB,gBAAAzB,EAAC,KAAA,EAAE,WAAU,cAAa,UAAA,oDAAA,CAAiD,IAE3E,gBAAAA,EAAC,QAAG,WAAU,aACX,YAAQ,IAAI,CAACjB,GAAQgD,MAAM;AAC1B,YAAMC,IAAW/C,EAAYF,CAAM,GAC7BkD,IAASlC,EAAK,IAAIhB,EAAO,EAAE;AACjC,+BACG,MAAA,EAAmB,WAAW,WAAWkD,IAAS,oBAAoB,EAAE,IACvE,UAAA;AAAA,QAAA,gBAAAjB;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WAAU;AAAA,YACV,SAAS,MAAMW,EAAO5C,EAAO,EAAE;AAAA,YAC/B,iBAAekD;AAAA,YAEf,UAAA;AAAA,cAAA,gBAAAjC,EAAC,QAAA,EAAK,WAAU,iBAAiB,UAAA,OAAO+B,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG,EAAA,CAAE;AAAA,cAChE,gBAAA/B,EAAC,QAAA,EAAK,WAAU,mBAAmB,YAAO,OAAM;AAAA,cAChD,gBAAAgB,EAAC,QAAA,EAAK,WAAU,mBAAmB,UAAA;AAAA,gBAAAgB,EAAS;AAAA,gBAAO;AAAA,cAAA,GAAS;AAAA,gCAC3D,QAAA,EAAK,WAAU,oBAAmB,eAAY,QAAO,UAAA,IAAA,CAEtD;AAAA,YAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGDC,uBACE,MAAA,EAAG,WAAU,WACX,UAAAD,EAAS,IAAI,CAAC,EAAE,SAAAhD,GAAS,MAAAkD,EAAA,GAAQC,MAChC,gBAAAnC,EAAC,QAAc,WAAU,gBACvB,4BAAC,KAAA,EAAE,MAAM,KAAKkC,CAAI,IAChB,UAAA;AAAA,UAAA,gBAAAlC,EAAC,QAAA,EAAK,WAAU,gBAAgB,UAAAmC,IAAI,GAAE;AAAA,UACtC,gBAAAnC,EAAC,QAAA,EAAK,WAAU,kBAAkB,YAAQ,OAAM;AAAA,4BAC/C,QAAA,EAAK,WAAU,iBAAgB,eAAY,QAAO,UAAA,IAAA,CAEnD;AAAA,QAAA,GACF,EAAA,GAPOkC,CAQT,CACD,EAAA,CACH;AAAA,MAAA,EAAA,GA3BKnD,EAAO,EA6BhB;AAAA,IAEJ,CAAC,EAAA,CACH;AAAA,sBAGD,UAAA,EAAO,WAAU,aAChB,UAAA,gBAAAiC,EAAC,QAAA,EAAK,WAAU,aACd,UAAA;AAAA,MAAA,gBAAAhB,EAAC,SAAI,UAAA,IAAA,CAAC;AAAA,MAAM;AAAA,MAAQ,gBAAAA,EAAC,SAAI,UAAA,IAAA,CAAC;AAAA,MAAM;AAAA,MAAQ,gBAAAA,EAAC,SAAI,UAAA,QAAA,CAAK;AAAA,MAAM;AAAA,IAAA,EAAA,CAC1D,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;ACvEO,SAASoC,EAAaC,GAAyBC,GAAqB;AACzE,QAAMC,IAAWlD,EAAgC,IAAI,GAC/CmD,IAAWnD,EAAOiD,CAAO;AAC/B,EAAAE,EAAS,UAAUF;AACnB,QAAM,CAACG,GAASC,CAAU,IAAIlD,EAAS,EAAK,GACtCmD,IAAatD,EAAOoD,CAAO;AACjC,SAAAE,EAAW,UAAUF,GAErBG,EAAU,MAAM;AACd,UAAMC,IAAI,IAAI,MAAA;AACd,WAAAA,EAAE,iBAAiB,SAAS,MAAML,EAAS,SAAS,GAEpDK,EAAE,iBAAiB,SAAS,MAAMH,EAAW,EAAK,CAAC,GACnDH,EAAS,UAAUM,GACZ,MAAMA,EAAE,MAAA;AAAA,EACjB,GAAG,CAAA,CAAE,GAILD,EAAU,MAAM;AACd,UAAMC,IAAIN,EAAS;AACnB,IAAKM,MACLA,EAAE,MAAA,GACER,KACFQ,EAAE,MAAMR,GACRQ,EAAE,cAAc,GACZF,EAAW,WAASE,EAAE,KAAA,EAAO,MAAM,MAAM;AAAA,IAAC,CAAC,MAG/CA,EAAE,gBAAgB,KAAK,GACvBA,EAAE,KAAA,GACFH,EAAW,EAAK;AAAA,EAEpB,GAAG,CAACL,CAAG,CAAC,GAoBD,EAAE,SAAAI,GAAS,QAlBH,MAAM;AACnB,UAAMI,IAAIN,EAAS;AACnB,IAAI,CAACM,KAAK,CAACA,EAAE,aAAa,KAAK,MAC3BF,EAAW,WACbE,EAAE,MAAA,GACFH,EAAW,EAAK,MAEhBG,EAAE,OAAO,MAAM,MAAM;AAAA,IAAC,CAAC,GACvBH,EAAW,EAAI;AAAA,EAEnB,GAQ0B,MALb,MAAM;;AACjB,KAAAI,IAAAP,EAAS,YAAT,QAAAO,EAAkB,SAClBJ,EAAW,EAAK;AAAA,EAClB,EAE0B;AAC5B;ACpDO,SAASK,GAAW;AAAA,EACzB,SAAArB;AAAA,EACA,SAAApB;AAAA,EACA,SAAS0C;AAAA,EACT,UAAA5C;AAAA,EACA,WAAA6C,IAAY;AACd,GAeG;AACD,QAAM,CAACC,GAAMC,CAAO,IAAI3D,EAAS,MAAM,SAAS,IAAI;AACpD,EAAAoD,EAAU,MAAM;AACd,UAAMQ,IAAS,MAAMD,EAAQ,SAAS,IAAI;AAC1C,kBAAO,iBAAiB,cAAcC,CAAM,GACrC,MAAM,OAAO,oBAAoB,cAAcA,CAAM;AAAA,EAC9D,GAAG,CAAA,CAAE;AAEL,QAAM/C,IAAU,IAAI,gBAAgB,SAAS,MAAM,EAAE,IAAI,SAAS,MAAM,KAClEuB,IAAKsB,EAAK,QAAQ,SAAS,EAAE,GAM7BG,IAAeC,EAAQ,MAAM;AACjC,QAAIN,EAAQpB,CAAE,EAAG,QAAOoB,EAAQpB,CAAE;AAClC,eAAW2B,KAAK,OAAO,OAAOP,CAAO;AACnC,UAAIO,EAAE,SAAS,KAAK,CAAC,MAAMzE,EAAOyE,GAAG,CAAC,MAAM3B,CAAE,EAAG,QAAO2B;AAE1D,WAAO,OAAO,OAAOP,CAAO,EAAE,CAAC;AAAA,EACjC,GAAG,CAACpB,GAAIoB,CAAO,CAAC,GACVhB,IAAWsB,EAAQ,MAAMrE,EAAYoE,CAAY,GAAG,CAACA,CAAY,CAAC,GAKlEG,IAAiBF,EAAQ,MAAM,OAAO,OAAON,CAAO,EAAE,QAAQ/D,CAAW,GAAG,CAAC+D,CAAO,CAAC;AAM3F,EAAAJ,EAAU,MAAM;AACd,QAAIvC,KAAW,CAAC2C,EAAQpB,CAAE,EAAG;AAC7B,UAAM6B,IAAQzB,EAAS,CAAC;AACxB,IAAKyB,MACL,QAAQ,aAAa,MAAM,IAAI,GAAG,SAAS,QAAQ,GAAG,SAAS,MAAM,KAAKA,EAAM,IAAI,EAAE,GACtFN,EAAQ,KAAKM,EAAM,IAAI,EAAE;AAAA,EAC3B,GAAG,CAACpD,GAASuB,GAAII,CAAQ,CAAC,GAE1BY,EAAU,MAAM;AACd,IAAKvC,MAGH,OAA2C,UAAU;AAAA,MACrD,MAAM,MACJ2B,EAAS,IAAI,CAAC,EAAE,MAAAE,GAAM,SAAAlD,SAAe;AAAA,QACnC,MAAAkD;AAAA,QACA,QAAQmB,EAAa;AAAA;AAAA,QACrB,IAAIrE,EAAQ;AAAA,QACZ,OAAOA,EAAQ;AAAA,QACf,OAAOA,EAAQ,SAAS;AAAA,MAAA,EACxB;AAAA,IAAA;AAAA,EAER,GAAG,CAACqB,GAAS2B,GAAUqB,CAAY,CAAC;AAQpC,QAAMK,IAAKC;AAAA,IACT,CAACC,MAAgB;AACf,YAAMC,IAAM,SAAS,KAAK,QAAQ,SAAS,EAAE,GACvCC,IAAON,EAAe,UAAU,CAAC,EAAE,MAAAtB,EAAA,MAAWA,MAAS2B,CAAG;AAChE,UAAIC,MAAS,IAAI;AACf,cAAMC,IAASP,EAAeM,IAAOF,CAAG;AACxC,iBAAS,OAAOG,IAAS,KAAKA,EAAO,IAAI,KAAK;AAC9C;AAAA,MACF;AAGA,UAAIF,MAAQ,IAAI;AACd,QAAID,MAAQ,KAAKJ,EAAe,CAAC,MAAG,SAAS,OAAO,KAAKA,EAAe,CAAC,EAAE,IAAI;AAC/E;AAAA,MACF;AAGA,YAAMO,IAASH,MAAQ,IAAI5B,EAAS,CAAC,IAAIA,EAASA,EAAS,SAAS,CAAC;AACrE,MAAI+B,MAAQ,SAAS,OAAO,KAAKA,EAAO,IAAI;AAAA,IAC9C;AAAA,IACA,CAAC/B,GAAUwB,CAAc;AAAA,EAAA;AAE3B,EAAAZ,EAAU,MAAM;AACd,UAAMoB,IAAQ,CAACC,MAAqB;AAClC,MAAIA,EAAE,QAAQ,gBAAgBA,EAAE,QAAQ,gBACxCA,EAAE,eAAA,GACFP,EAAGO,EAAE,QAAQ,eAAe,IAAI,EAAE;AAAA,IACpC;AACA,kBAAO,iBAAiB,WAAWD,CAAK,GACjC,MAAM,OAAO,oBAAoB,WAAWA,CAAK;AAAA,EAC1D,GAAG,CAACN,CAAE,CAAC;AAIP,QAAMQ,IAASP,EAAY,MAAM;AAC/B,IAAI,SAAS,OAAM,SAAS,OAAO,KAC1B,SAAS,aAAa,QAAK,SAAS,OAAO;AAAA,EACtD,GAAG,CAAA,CAAE;AACL,EAAAf,EAAU,MAAM;AACd,UAAMoB,IAAQ,CAACC,MAAqB;AAClC,MAAIA,EAAE,QAAQ,YAAUC,EAAA;AAAA,IAC1B;AACA,kBAAO,iBAAiB,WAAWF,CAAK,GACjC,MAAM,OAAO,oBAAoB,WAAWA,CAAK;AAAA,EAC1D,GAAG,CAACE,CAAM,CAAC;AAGX,QAAMC,IAAQnC,EAAS,KAAK,CAAC,EAAE,MAAAE,EAAA,MAAWA,MAASN,CAAE,GAM/CwC,IAAWD,IACb,GAAGlB,CAAS,SAASI,EAAa,EAAE,IAAIc,EAAM,QAAQ,EAAE,SACxD,QACE,EAAE,SAAA1B,GAAS,QAAAd,GAAQ,MAAA0C,MAASjC,EAAagC,GAAU,MAAM;AAC7D,UAAMP,IAAM,SAAS,KAAK,QAAQ,SAAS,EAAE,GACvCS,IAAMd,EAAe,UAAU,CAAC,EAAE,MAAAtB,EAAA,MAAWA,MAAS2B,CAAG,GACzD/B,IAAO0B,EAAec,IAAM,CAAC;AACnC,IAAIxC,IAAM,SAAS,OAAO,KAAKA,EAAK,IAAI,KACnCuC,EAAA;AAAA,EACP,CAAC;AAiBD,MAbAzB,EAAU,MAAM;AACd,UAAMoB,IAAQ,CAACC,MAAqB;AAClC,UAAIA,EAAE,QAAQ,OAAOA,EAAE,SAAS,QAAS;AACzC,YAAMM,IAAIN,EAAE;AACZ,MAAIM,MAAMA,EAAE,YAAY,WAAWA,EAAE,YAAY,cAAcA,EAAE,uBACjEN,EAAE,eAAA,GACFtC,EAAA;AAAA,IACF;AACA,kBAAO,iBAAiB,WAAWqC,CAAK,GACjC,MAAM,OAAO,oBAAoB,WAAWA,CAAK;AAAA,EAC1D,CAAC,GAGG,CAACpC,EAAI,QAAO,gBAAA5B,EAACwB,GAAA,EAAY,SAAS,OAAO,OAAOwB,CAAO,GAAG,SAAAtB,EAAA,CAAkB;AAKhF,MAAI8C,GACAC,IAAQ;AACZ,MAAIN,GAAO;AACT,UAAM5D,IAAQyB,EAAS,UAAU,CAAC,EAAE,MAAAE,EAAA,MAAWA,MAASN,CAAE;AAC1D,IAAA4C,IACE,gBAAAxE;AAAA,MAACG;AAAA,MAAA;AAAA,QACC,SAASgE,EAAM;AAAA,QACf,UAAA/D;AAAA,QAEA,SAAAC;AAAA,QAEA,SAAS,GAAGC,KAAWoB,EAAQ,YAAA,CAAa,MAAM2B,EAAa,GAAG,YAAA,CAAa;AAAA,QAC/E,OAAA9C;AAAA,QACA,OAAOyB,EAAS;AAAA,QAChB,QAAQkC;AAAA,QACR,QAAQ,MAAMR,EAAG,EAAE;AAAA,QACnB,QAAQ,MAAMA,EAAG,CAAC;AAAA,QAClB,WAAWjB;AAAA,QACX,mBAAmBd;AAAA,MAAA;AAAA,IAAA;AAAA,EAGzB,OAAO;AAEL,UAAMZ,IAAQX,EAASwB,CAAE;AACzB,IAAA4C,IAAUzD,IACR,gBAAAf,EAAC,OAAA,EAAI,WAAU,SACb,UAAA,gBAAAA,EAACsB,GAAA,EAAU,OAAAP,EAAA,CAAc,EAAA,CAC3B,IAEA,gBAAAC,EAAC,OAAA,EAAI,WAAU,wBAAuB,UAAA;AAAA,MAAA;AAAA,MAAmBY;AAAA,IAAA,GAAG,GAE9D6C,IAAQpE,IAAU,OAChB,gBAAAL,EAAC,UAAA,EAAO,WAAU,cAAa,SAASkE,GAAQ,cAAW,yBAAwB,OAAM,yBAAwB,UAAA,UAEjH;AAAA,EAEJ;AAEA,SACE,gBAAAlD,EAAA0D,GAAA,EACG,UAAA;AAAA,IAAAD;AAAA,IACAD;AAAA,EAAA,GACH;AAEJ;"}
1
+ {"version":3,"file":"index.js","sources":["../src/types.ts","../src/useSlideScale.ts","../src/SlidePanel.tsx","../src/SectionView.tsx","../src/SiteHeader.tsx","../src/CourseIndex.tsx","../src/useNarration.ts","../src/ConceptApp.tsx"],"sourcesContent":["// The content model, owned by the shell because the shell is what renders it. A content repo\n// authors data that satisfies these types; it no longer declares them (every repo's copy of\n// content/types.ts was byte-identical, which is the definition of a shared contract).\n//\n// concept ⊃ course ⊃ section. A SECTION is the atomic unit of a video: one slug (course-section),\n// one scene, one slide, one narration clip.\n//\n// The authoring contract: .md (source of truth) → (.slide, .tts)\n// slide = terse markdown shown in the SlidePanel (for the eye)\n// narration = natural-flow spoken script for TTS / Chatterbox (for the ear)\n\nexport interface Section {\n id: string // \"what-is-cloud\" → slug: foundations-what-is-cloud\n title: string\n scene: string // scene id shown behind the panel (may be shared across sections)\n focus?: string // node the slide floats clear of (optional)\n slide: string // markdown → rendered in the SlidePanel\n narration: string // natural-flow TTS script (Chatterbox)\n}\n\nexport interface Course {\n id: string\n title: string\n sections: Section[]\n}\n\n// How a content repo hands its scenes to the shell. The shell never imports a scene registry — it\n// is given this lookup, so scenes stay entirely the repo's business.\nexport type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined\n\n// The slug for a section is `<courseId>-<sectionId>` — section IS the unit (one slide, one\n// narration), so no trailing beat index.\nexport function slugOf(course: Course, section: Section): string {\n return `${course.id}-${section.id}`\n}\n\nexport function allSections(course: Course): { section: Section; slug: string }[] {\n return course.sections.map((section) => ({ section, slug: slugOf(course, section) }))\n}\n","import { useLayoutEffect, useRef, useState } from 'react'\n\n// The slide is authored at a fixed DESIGN width and scaled with CSS `zoom` to the live pane width,\n// so it looks pixel-proportional at any frame — 1440 preview, 1080p, or 4K capture alike. This is\n// what makes the slide's dimensions a function of the TARGET video frame, not the browser window.\n//\n// DESIGN_PANE = 806 = 42% of a 1920-wide design frame (the slide pane's share). zoom = live/806:\n// 1440 window → pane ~605 → zoom 0.75 | 1080p → 806 → 1.0 | 4K → 1613 → 2.0\nexport const DESIGN_PANE = 806\n\nexport function useSlideScale() {\n const ref = useRef<HTMLElement>(null)\n const [zoom, setZoom] = useState(1)\n useLayoutEffect(() => {\n const el = ref.current\n if (!el) return\n const measure = () => setZoom(el.clientWidth / DESIGN_PANE)\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n return { ref, zoom }\n}\n","import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { DESIGN_PANE, useSlideScale } from './useSlideScale'\n\n// The slide (a section's `.slide` markdown) — a full-height surface fixed to the RIGHT, lifted off\n// the scene canvas for figure-ground. Position is CSS-driven (see index.css):\n// landscape (laptop → video) — a 42% column beside the scene.\n// portrait (mobile → reel) — an off-canvas drawer that slides in from the right when `open`.\n// The inner scaler is authored at DESIGN_PANE px and `zoom`-scaled to the live pane, so the slide is\n// proportional to the target video frame at any size (see useSlideScale).\nexport function SlidePanel({ slide, open }: { slide: string; open: boolean }) {\n const { ref, zoom } = useSlideScale()\n return (\n <aside ref={ref} className={`slide-panel${open ? ' slide-panel--open' : ''}`}>\n <div className=\"slide-panel__scaler\" style={{ width: DESIGN_PANE, zoom }}>\n <ReactMarkdown remarkPlugins={[remarkGfm]}>{slide}</ReactMarkdown>\n </div>\n </aside>\n )\n}\n","import { useState } from 'react'\nimport { Home, Volume2, VolumeX, ChevronLeft, ChevronRight } from 'lucide-react'\nimport { SceneView } from '@graphlearning/flow'\nimport type { Section, SceneLookup } from './types'\nimport { SlidePanel } from './SlidePanel'\n\n// The SECTION view = the final composited output of a slug (course-section): the vertical scene +\n// the fixed-right slide, with an eyebrow + section title header. Layout is responsive (CSS):\n// landscape (laptop → 4K video) — scene fills the left, slide is a right column; the title lives\n// in the slide, so the header shows just the eyebrow.\n// portrait (mobile → reel) — scene fills the frame, slide is a full-width drawer toggled at\n// top-right; the header shows the full title card.\n// Under ?capture=1 the frame is clean for video: just the eyebrow + title + scene — NO footer and NO\n// interactive controls (the drawer toggle and the footer control bar are both suppressed).\n// Interactively, a footer control bar carries Home + narration + prev/next (raised above the drawer\n// so it works while the slide is open), the drawer toggle appears in portrait, and the eyebrow also\n// links back to the catalog. Keyboard works everywhere (← / → navigate, Space toggles narration).\nexport function SectionView({\n section,\n getScene,\n capture = false,\n eyebrow,\n index = 0,\n total = 0,\n onHome,\n onPrev,\n onNext,\n narrating = false,\n onToggleNarration,\n}: {\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}) {\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 </span>\n <span className=\"reel-foot__nav\">\n <button className=\"reel-foot__ctrl\" onClick={onPrev} aria-label=\"Previous section (←)\" title=\"Previous section (←)\">\n <ChevronLeft size={19} />\n </button>\n <span className=\"reel-foot__count reel-foot__count--live\">\n {index + 1} / {total}\n </span>\n <button className=\"reel-foot__ctrl\" onClick={onNext} aria-label=\"Next section (→)\" title=\"Next section (→)\">\n <ChevronRight size={19} />\n </button>\n </span>\n </footer>\n )}\n <div className=\"scene-area\">\n <SceneView scene={scene} focusId={section.focus} />\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'\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}: {\n courses: Course[]\n subject: string\n kind?: string // which nav section this app belongs to; see SiteHeader\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} />\n <div className=\"idx\">\n {/* The GraphL wordmark used to be an eyebrow here; it is in the bar above now. What stays\n is `subject` — the repo's own name (\"Python\", \"SQL\"), the one piece of this page the\n shell cannot know. */}\n <header className=\"idx__head\">\n <h1 className=\"idx__subject\">{subject}</h1>\n </header>\n\n {courses.length === 0 ? (\n <p className=\"idx__empty\">No courses authored yet — the app shell is ready.</p>\n ) : (\n <ol className=\"idx__grid\">\n {courses.map((course, i) => {\n const sections = allSections(course)\n const isOpen = open.has(course.id)\n return (\n <li key={course.id} className={`idx-card${isOpen ? ' idx-card--open' : ''}`}>\n <button\n className=\"idx-card__head\"\n onClick={() => toggle(course.id)}\n aria-expanded={isOpen}\n >\n <span className=\"idx-card__num\">{String(i + 1).padStart(2, '0')}</span>\n <span className=\"idx-card__title\">{course.title}</span>\n <span className=\"idx-card__count\">{sections.length} sections</span>\n <span className=\"idx-card__toggle\" aria-hidden=\"true\">\n ⌄\n </span>\n </button>\n\n {isOpen && (\n <ol className=\"idx-sec\">\n {sections.map(({ section, slug }, j) => (\n <li key={slug} className=\"idx-sec__row\">\n <a href={`#/${slug}`}>\n <span className=\"idx-sec__num\">{j + 1}</span>\n <span className=\"idx-sec__title\">{section.title}</span>\n <span className=\"idx-sec__chev\" aria-hidden=\"true\">\n ›\n </span>\n </a>\n </li>\n ))}\n </ol>\n )}\n </li>\n )\n })}\n </ol>\n )}\n\n <footer className=\"idx__foot\">\n <span className=\"idx__hint\">\n <kbd>→</kbd> next · <kbd>←</kbd> prev · <kbd>Space</kbd> narration\n </span>\n </footer>\n </div>\n </>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// The narration channel: one <audio> for the app's lifetime, driven by SPACE (toggle) and by\n// section navigation. Loading a section's clip keeps the play state — if narration is on, changing\n// sections auto-plays the next clip — and a clip that finishes calls `onEnded` so playback\n// auto-advances to the next SECTION. Timing comes from the clip length itself (no timestamp\n// anchors), so regenerated audio never drifts. A missing clip (not yet generated, or a 404) fails\n// silently and resets the play state — paging still works, just no audio.\n//\n// Adapted from graphl-studio's reveal-engine useNarration: this repo has no beats, so the unit is\n// one clip per section (`<audioBase>/<section-id>.wav`) rather than per beat.\nexport function useNarration(src: string | undefined, onEnded: () => void) {\n const audioRef = useRef<HTMLAudioElement | null>(null)\n const endedRef = useRef(onEnded)\n endedRef.current = onEnded\n const [playing, setPlaying] = useState(false)\n const playingRef = useRef(playing)\n playingRef.current = playing\n\n useEffect(() => {\n const a = new Audio()\n a.addEventListener('ended', () => endedRef.current())\n // A clip that can't load (not generated yet, 404) must not leave a stale \"playing\" state.\n a.addEventListener('error', () => setPlaying(false))\n audioRef.current = a\n return () => a.pause()\n }, [])\n\n // Load the current section's clip; resume playing if narration was on. Because playback is\n // gesture-initiated (SPACE), the browser lets the auto-advance chain keep playing.\n useEffect(() => {\n const a = audioRef.current\n if (!a) return\n a.pause()\n if (src) {\n a.src = src\n a.currentTime = 0\n if (playingRef.current) a.play().catch(() => {})\n } else {\n // Fully unload so a later play() doesn't resume a stale buffered clip.\n a.removeAttribute('src')\n a.load()\n setPlaying(false)\n }\n }, [src])\n\n const toggle = () => {\n const a = audioRef.current\n if (!a || !a.getAttribute('src')) return // nothing loaded (a section with no clip)\n if (playingRef.current) {\n a.pause()\n setPlaying(false)\n } else {\n a.play().catch(() => {})\n setPlaying(true)\n }\n }\n\n /** Hard stop — pause and clear the play state (used at the end of the course). */\n const stop = () => {\n audioRef.current?.pause()\n setPlaying(false)\n }\n\n return { playing, toggle, stop }\n}\n","import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { SceneView } 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'\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}: {\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}) {\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 const id = hash.replace(/^#\\/?/, '')\n\n // Which course are we in? The recorder lands on #/<course> to read the plan; a human lands on a\n // slug. So the active course is: a bare course id, else the course that OWNS the matched slug, else\n // the first course (the nav context for a bare scene view). Everything course-scoped below —\n // section nav, the capture plan, the audio path, the header eyebrow — reads from this.\n const activeCourse = useMemo(() => {\n if (COURSES[id]) return COURSES[id]\n for (const c of Object.values(COURSES)) {\n if (c.sections.some((s) => slugOf(c, s) === id)) return c\n }\n return Object.values(COURSES)[0]\n }, [id, COURSES])\n const sections = useMemo(() => allSections(activeCourse), [activeCourse])\n // The whole catalog as one ordered stream (in COURSES insertion\n // order). Section nav walks THIS so → past a course's last section rolls into the next course's\n // first — and narration flows chapter-to-chapter — instead of dead-ending at the index. The\n // per-course `sections` above still drives the capture plan and the §n/N header (chapter-relative).\n const globalSections = useMemo(() => Object.values(COURSES).flatMap(allSections), [COURSES])\n\n // A bare COURSE id in the hash has no page of its own — the GraphL catalog links each course to\n // `<slug>/#/<courseId>`, so a human arriving there should ENTER the course at its first section.\n // Guarded by !capture: the recorder deliberately lands on `#/<course>` to read window.__scene.plan\n // (below), so it must not be redirected. Replace (not push) so Back skips the bare course-id URL.\n useEffect(() => {\n if (capture || !COURSES[id]) return\n const first = sections[0]\n if (!first) return\n history.replaceState(null, '', `${location.pathname}${location.search}#/${first.slug}`)\n setHash(`#/${first.slug}`)\n }, [capture, id, sections])\n\n useEffect(() => {\n if (!capture) return\n // Capture contract (fleshed out in step 2): every slug + its scene + focus node, so the recorder\n // can screenshot each and knows which node the slide must avoid.\n ;(window as unknown as { __scene: unknown }).__scene = {\n plan: () =>\n sections.map(({ slug, section }) => ({\n slug,\n course: activeCourse.id, // audio lives at audio/<course>/<id>.wav\n id: section.id,\n scene: section.scene,\n focus: section.focus ?? null,\n })),\n }\n }, [capture, sections, activeCourse])\n\n // Section navigation, shared by the ← / → keys AND the on-screen pager. `dir` is +1 (next) / -1\n // (prev). Reads the LIVE hash at call time (not React state) so rapid presses can't act on a stale\n // index; from a non-section view, next enters the first section. Walks the GLOBAL stream, so a\n // course boundary is just another step — → past a course's last section enters the next course's\n // first, ← symmetrically crosses back. Only the two ENDS of the whole catalog fall back to the\n // catalog page (like Esc). Navigating just updates the hash, which re-renders via the listener.\n const go = useCallback(\n (dir: 1 | -1) => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const gidx = globalSections.findIndex(({ slug }) => slug === cur)\n if (gidx !== -1) {\n const target = globalSections[gidx + dir]\n location.hash = target ? `#/${target.slug}` : '' // catalog only at the catalog's ends\n return\n }\n // The CATALOG index (empty hash) is the home/start: → begins the stream at its first section;\n // ← does nothing (there is nothing before home — it must not fall into foundations' last).\n if (cur === '') {\n if (dir === 1 && globalSections[0]) location.hash = `#/${globalSections[0].slug}`\n return\n }\n // Otherwise a bare SCENE view (a scene id with course context): → enters the active course's\n // first section, ← its last.\n const target = dir === 1 ? sections[0] : sections[sections.length - 1]\n if (target) location.hash = `#/${target.slug}`\n },\n [sections, globalSections],\n )\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return\n e.preventDefault()\n go(e.key === 'ArrowRight' ? 1 : -1)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [go])\n\n // Back to the catalog: clear the hash (empty hash → CourseIndex). No-ops when already there. Wired\n // to the top-left GraphL brand AND the Esc key (below), so any inner page is one gesture from home.\n const goHome = useCallback(() => {\n if (location.hash) location.hash = '' // inner page → this concept's index\n else if (location.pathname !== '/') location.href = '/' // already at the index → up to the graphl.in catalog\n }, [])\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') goHome()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [goHome])\n\n // Slug (section) first.\n const match = sections.find(({ slug }) => slug === id)\n\n // Narration channel. A section's clip lives at public/audio/<courseId>/<section-id>.wav (pushed\n // from the Colab generator). SPACE toggles it; when a clip finishes we roll into the NEXT section\n // (which auto-plays because the play state is preserved) so the whole course narrates hands-free.\n // Outside a section (scene view) src is undefined → the channel unloads.\n const audioUrl = match\n ? `${audioBase}audio/${activeCourse.id}/${match.section.id}.wav`\n : undefined\n const { playing, toggle, stop } = useNarration(audioUrl, () => {\n const cur = location.hash.replace(/^#\\/?/, '')\n const idx = globalSections.findIndex(({ slug }) => slug === cur)\n const next = globalSections[idx + 1] // rolls across course boundaries into the next chapter\n if (next) location.hash = `#/${next.slug}`\n else stop() // last section of the whole catalog — nothing to advance to\n })\n\n // SPACE enables/disables narration. Re-bound every render (no deps) so it always sees the current\n // `toggle`; guarded so it never hijacks the spacebar while typing in an input.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== ' ' && e.code !== 'Space') return\n const t = e.target as HTMLElement | null\n if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return\n e.preventDefault()\n toggle()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n })\n\n // Empty hash → the course catalog landing page (its own brand, no overlay, no home button here).\n if (!id) return <CourseIndex courses={Object.values(COURSES)} subject={subject} kind={kind} />\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 />\n )\n } else {\n // Otherwise a scene id.\n const scene = getScene(id)\n content = scene ? (\n <div className=\"stage\">\n <SceneView scene={scene} />\n </div>\n ) : (\n <div className=\"stage stage--missing\">no scene or slug: {id}</div>\n )\n brand = capture ? null : (\n <button className=\"brand-home\" onClick={goHome} aria-label=\"Back to catalog (Esc)\" title=\"Back to catalog (Esc)\">\n GraphL\n </button>\n )\n }\n\n return (\n <>\n {brand}\n {content}\n </>\n )\n}\n"],"names":["slugOf","course","section","allSections","DESIGN_PANE","useSlideScale","ref","useRef","zoom","setZoom","useState","useLayoutEffect","el","measure","ro","SlidePanel","slide","open","jsx","ReactMarkdown","remarkGfm","SectionView","getScene","capture","eyebrow","index","total","onHome","onPrev","onNext","narrating","onToggleNarration","setOpen","scene","jsxs","Home","Volume2","VolumeX","ChevronLeft","ChevronRight","SceneView","o","KINDS","Mark","SiteHeader","kind","nav","home","actions","k","CourseIndex","courses","subject","toggle","id","prev","next","Fragment","i","sections","isOpen","slug","j","useNarration","src","onEnded","audioRef","endedRef","playing","setPlaying","playingRef","useEffect","a","_a","ConceptApp","COURSES","audioBase","hash","setHash","onHash","activeCourse","useMemo","c","s","globalSections","first","go","useCallback","dir","cur","gidx","target","onKey","e","goHome","match","audioUrl","stop","idx","t","content","brand"],"mappings":"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"}
package/dist/styles.css CHANGED
@@ -26,6 +26,23 @@
26
26
  --brand-hover: #a8adb8;
27
27
  --accent-2: #c8cdd6;
28
28
  --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
29
+
30
+ /* --- site-chrome tokens (SiteHeader) ------------------------------------------------------
31
+ The same names and the same values ui-graphl declares, so the bar is identical on the catalog
32
+ and inside every app. Do NOT rename them: when light mode arrives it is purely additive here
33
+ — a light :root plus prefers-color-scheme and [data-theme] blocks, same names — and the
34
+ header's own CSS never has to change again. --text aliases the shell's existing --fg (same
35
+ value) rather than duplicating the colour. These are PLATFORM tokens: the bar stays
36
+ platform-blue on every site, deliberately NOT --brand, because looking the same everywhere is
37
+ the whole reason it exists. */
38
+ --text: var(--fg);
39
+ --muted: #8b909c;
40
+ --accent: #5b8cff;
41
+ --accent-soft: rgba(91, 140, 255, 0.14); /* the current nav item */
42
+ --line: rgba(255, 255, 255, 0.09);
43
+ --header-bg: rgba(255, 255, 255, 0.014);
44
+ --hover: rgba(255, 255, 255, 0.05);
45
+ --site-h: 60px; /* the bar's row height (its 1px rule is extra) — .idx subtracts it from 100vh */
29
46
  }
30
47
 
31
48
  html,
@@ -413,34 +430,162 @@ body {
413
430
  /* --- Course catalog / landing page (CourseIndex) ---------------------------- */
414
431
  /* The index is a scrollable page (unlike the fixed stage): the catalog of courses, each course a
415
432
  card listing its ordered sections. Python-blue (--idx-accent) is the one accent. */
433
+ /* ── Site chrome (SiteHeader) ──────────────────────────────────────────────────────────────────
434
+ Ported from ui-graphl/styles.css, class for class. ui-graphl is buildless vanilla and this is a
435
+ React package, so one shared component is impossible — but one shared DESIGN is not, and it only
436
+ survives if neither copy drifts. Change this, change that. The single deliberate deviation is
437
+ --site-h: over there the 60px row height is a literal, here .idx has to subtract it.
438
+
439
+ Traditional chrome: brand on the left, section nav on the right, full-bleed rule underneath. The
440
+ inner row matches the catalog's own max-width so the brand lines up with the cards below it. */
441
+ .site {
442
+ border-bottom: 1px solid var(--line);
443
+ background: var(--header-bg);
444
+ }
445
+ .site__inner {
446
+ max-width: 940px;
447
+ margin: 0 auto;
448
+ /* Explicit, and NOT optional: ui-graphl has a global `* { box-sizing: border-box }` and this
449
+ stylesheet deliberately does not (a global reset here would move the section view, which is
450
+ burned into video). Without it the bar's column is 940 + 64 of padding while .idx is 940
451
+ inclusive, and the brand sits 32px left of the cards it is supposed to line up with. */
452
+ box-sizing: border-box;
453
+ padding: 0 32px;
454
+ min-height: var(--site-h);
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 20px;
458
+ }
459
+ .site__brand {
460
+ margin-right: auto; /* holds the brand left; nav + actions collect on the right */
461
+ display: inline-flex;
462
+ align-items: center;
463
+ gap: 10px;
464
+ text-decoration: none;
465
+ color: inherit;
466
+ }
467
+ .site__logo {
468
+ display: block;
469
+ border-radius: 6px;
470
+ }
471
+ .site__name {
472
+ font-size: 17px;
473
+ font-weight: 700;
474
+ letter-spacing: -0.01em;
475
+ }
476
+ .site__nav {
477
+ display: flex;
478
+ gap: 4px;
479
+ }
480
+ .site__link {
481
+ flex: none;
482
+ white-space: nowrap;
483
+ padding: 8px 12px;
484
+ border-radius: 8px;
485
+ font-size: 15px;
486
+ font-weight: 600;
487
+ color: var(--muted);
488
+ text-decoration: none;
489
+ transition: color 0.15s ease, background 0.15s ease;
490
+ }
491
+ .site__link:hover {
492
+ color: var(--text);
493
+ background: var(--hover);
494
+ }
495
+ .site__link[aria-current='page'] {
496
+ color: var(--text);
497
+ background: var(--accent-soft);
498
+ }
499
+ .site__link:focus-visible {
500
+ outline: 2px solid var(--accent);
501
+ outline-offset: 2px;
502
+ }
503
+ /* The icon buttons — the theme toggle and the account control when they exist. Kept separate from
504
+ the nav so that on a phone the nav can drop to its own row while these stay up on the brand row.
505
+ Empty until something is passed to SiteHeader's `actions` slot. */
506
+ .site__actions {
507
+ display: flex;
508
+ align-items: center;
509
+ gap: 6px;
510
+ }
511
+ /* Square icon button, styled here so the slot's occupant does not have to carry its own CSS. Sized
512
+ 36px so it is a comfortable target without towering over the 60px header row. */
513
+ .site__icon {
514
+ flex: none;
515
+ width: 36px;
516
+ height: 36px;
517
+ display: inline-flex;
518
+ align-items: center;
519
+ justify-content: center;
520
+ font-size: 16px;
521
+ line-height: 1;
522
+ font-family: inherit;
523
+ color: var(--muted);
524
+ background: transparent;
525
+ border: 1px solid transparent;
526
+ border-radius: 9px;
527
+ cursor: pointer;
528
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
529
+ }
530
+ .site__icon:hover {
531
+ color: var(--text);
532
+ background: var(--hover);
533
+ }
534
+ .site__icon:focus-visible {
535
+ outline: 2px solid var(--accent);
536
+ outline-offset: 2px;
537
+ }
538
+
539
+ /* Phone: the nav row scrolls horizontally rather than collapsing into a hamburger. There are three
540
+ sections today and there will be more, but they are the primary navigation and the whole point of
541
+ the current one is being visible; a menu would hide "you are here" behind a tap. */
542
+ @media (max-width: 560px) {
543
+ .site__inner {
544
+ padding: 8px 20px;
545
+ flex-wrap: wrap;
546
+ gap: 0 12px;
547
+ }
548
+ .site__nav {
549
+ order: 3; /* after brand and actions ⇒ its own row */
550
+ flex: 1 0 100%;
551
+ margin: 2px -20px 0; /* bleed to the screen edges so the row can scroll fully */
552
+ padding: 0 20px 4px;
553
+ overflow-x: auto;
554
+ scrollbar-width: none;
555
+ }
556
+ .site__nav::-webkit-scrollbar {
557
+ display: none;
558
+ }
559
+ .site__link {
560
+ display: flex;
561
+ align-items: center;
562
+ min-height: 40px; /* comfortable touch target */
563
+ padding: 8px 10px;
564
+ font-size: 14px;
565
+ }
566
+ .site__icon {
567
+ width: 40px;
568
+ height: 40px;
569
+ }
570
+ }
571
+
416
572
  .idx {
417
573
  --idx-accent: var(--brand);
418
574
  --idx-muted: #8b909c;
419
575
  --idx-line: rgba(255, 255, 255, 0.09);
420
- min-height: 100vh;
576
+ /* The bar is a sibling above this column, so the viewport it can fill is what the bar leaves.
577
+ On a phone the bar wraps to two rows and this under-subtracts — harmless, it is a min. */
578
+ min-height: calc(100vh - var(--site-h) - 1px); /* the bar's row, plus its 1px rule */
421
579
  max-width: 940px;
422
580
  margin: 0 auto;
423
- padding: 40px 32px 64px;
581
+ padding: 28px 32px 64px;
424
582
  box-sizing: border-box;
425
583
  }
426
584
  .idx__head {
427
585
  margin-bottom: 36px;
428
586
  }
429
- /* Catalog header: GraphL platform eyebrow over the AWS subject title (no marketing subtitle). */
430
- .idx__brand {
431
- display: inline-block;
432
- font-size: 13px;
433
- font-weight: 700;
434
- letter-spacing: 0.16em;
435
- text-transform: uppercase;
436
- color: var(--idx-accent);
437
- margin-bottom: 10px;
438
- text-decoration: none;
439
- transition: opacity 0.15s ease;
440
- }
441
- .idx__brand:hover {
442
- opacity: 0.72;
443
- }
587
+ /* Catalog header: just the subject title now — the GraphL wordmark it used to sit under is in
588
+ the site bar above (SiteHeader), which is the same bar graphl.in carries. */
444
589
  .idx__subject {
445
590
  margin: 0;
446
591
  font-size: 40px;
@@ -591,3 +736,12 @@ body {
591
736
  color: rgba(255, 255, 255, 0.9);
592
737
  }
593
738
 
739
+ /* The bar's phone gutter is 20px, so the catalog column's has to be too — otherwise the brand and
740
+ the subject title below it sit on two different left edges. Matches ui-graphl's own phone rule.
741
+ It lives down HERE, not up with the rest of the .site phone rules, because .idx's own padding is
742
+ declared after them and would otherwise win at equal specificity. */
743
+ @media (max-width: 560px) {
744
+ .idx {
745
+ padding: 20px 20px 48px;
746
+ }
747
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphlearning/shell",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The GraphL concept-app shell \u2014 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": {