@graphlearning/shell 0.1.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 +79 -0
- package/dist/ConceptApp.d.ts +8 -0
- package/dist/CourseIndex.d.ts +5 -0
- package/dist/SectionView.d.ts +14 -0
- package/dist/SlidePanel.d.ts +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +269 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +593 -0
- package/dist/types.d.ts +19 -0
- package/dist/useNarration.d.ts +5 -0
- package/dist/useSlideScale.d.ts +5 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @graphlearning/shell
|
|
2
|
+
|
|
3
|
+
The GraphL concept-app shell: everything a content repo renders *around* its content.
|
|
4
|
+
|
|
5
|
+
A content repo authors two things — **courses** (sections: scene + slide + narration) and **scenes**
|
|
6
|
+
(react-flow diagrams / code cards, drawn by [`@graphlearning/flow`](../ui-flow)). Everything else —
|
|
7
|
+
the hash router, the section composition, the slide panel, the course catalog, the narration
|
|
8
|
+
channel, the whole stylesheet — was an identical copy in all seven repos. It lives here now.
|
|
9
|
+
|
|
10
|
+
## Use it
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import '@graphlearning/flow/styles.css' // engine: react-flow + the IBM Plex faces
|
|
14
|
+
import '@graphlearning/shell/styles.css' // shell: layout + surface (it paints --bg)
|
|
15
|
+
import './theme.css' // repo: --brand / --brand-hover / --accent-2
|
|
16
|
+
import { ConceptApp } from '@graphlearning/shell'
|
|
17
|
+
import { COURSES } from './content'
|
|
18
|
+
import { getScene } from './scenes'
|
|
19
|
+
|
|
20
|
+
<ConceptApp
|
|
21
|
+
subject="Apache Spark" // the catalog's h1
|
|
22
|
+
eyebrow="SPARK" // the section eyebrow prefix — omit when it is subject.toUpperCase()
|
|
23
|
+
courses={COURSES}
|
|
24
|
+
getScene={getScene}
|
|
25
|
+
audioBase={import.meta.env.BASE_URL}
|
|
26
|
+
/>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That is the whole integration. A content repo's `src/` is then `content/`, `scenes/`, `main.tsx`
|
|
30
|
+
and `theme.css`.
|
|
31
|
+
|
|
32
|
+
## The three contracts
|
|
33
|
+
|
|
34
|
+
**1. Import order.** The three stylesheets cascade: engine, shell, theme. The theme must come last
|
|
35
|
+
or the repo's brand tokens lose to the shell's neutral defaults.
|
|
36
|
+
|
|
37
|
+
**2. The shell paints the canvas.** `SceneView` draws its dots at `#2a2f38` and assumes a dark
|
|
38
|
+
surface behind it. That surface (`--bg: #1a1d23`) is defined in this package's stylesheet — it is
|
|
39
|
+
the contract each repo used to re-declare by hand.
|
|
40
|
+
|
|
41
|
+
**3. `audioBase` must be passed in.** `import.meta.env.BASE_URL` is replaced at the *consuming
|
|
42
|
+
app's* build time (its vite `base`, e.g. `/python/`). This package is built separately, so a literal
|
|
43
|
+
here would bake in the wrong base and 404 on every deployed site.
|
|
44
|
+
|
|
45
|
+
## The per-repo surface
|
|
46
|
+
|
|
47
|
+
Three CSS tokens, `subject`, and (when it differs) `eyebrow`. That is all that legitimately varies
|
|
48
|
+
between the seven concept apps.
|
|
49
|
+
|
|
50
|
+
| token | what it colours |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `--brand` | section eyebrow, slide `h2`, catalog accent, brand-home hover |
|
|
53
|
+
| `--brand-hover` | the eyebrow's hover state — a lighter tint of `--brand` |
|
|
54
|
+
| `--accent-2` | the slide's `###` subheading — a counter tone to `--brand` |
|
|
55
|
+
|
|
56
|
+
## The route contract
|
|
57
|
+
|
|
58
|
+
The recorder (`scripts/record-course.mjs` in each content repo) drives these, so they are API:
|
|
59
|
+
|
|
60
|
+
- `#/<courseId>-<sectionId>` — the section view, the composited video frame
|
|
61
|
+
- `#/<sceneId>` — the bare scene view
|
|
62
|
+
- `?capture=1` — suppress every interactive control, leaving a clean frame
|
|
63
|
+
- `window.__scene.plan()` — the active course's sections, for the recorder to walk
|
|
64
|
+
|
|
65
|
+
## Develop
|
|
66
|
+
|
|
67
|
+
`npm run dev` serves a fixture harness on :5176 — two throwaway courses with a diagram scene, a code
|
|
68
|
+
scene and a deliberately missing scene. Two courses, not one, because it is the only way to exercise
|
|
69
|
+
the global section stream (→ past a course's last section enters the next course's first). A shell
|
|
70
|
+
regression is visible here before it reaches any content repo.
|
|
71
|
+
|
|
72
|
+
`npm run build` → `dist/index.js` + `dist/index.d.ts` + `dist/styles.css`.
|
|
73
|
+
|
|
74
|
+
## Why a peer dependency on the engine
|
|
75
|
+
|
|
76
|
+
`@graphlearning/flow` is a **peer**, not a dependency. Bundling it would put a second engine (and a
|
|
77
|
+
second react-flow store) inside this package. The content repo pins the engine itself and must get
|
|
78
|
+
exactly one copy — and a wide peer range (`>=0.5.0 <1.0.0`) means an engine minor does not force a
|
|
79
|
+
republish of this package.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Course, SceneLookup } from './types';
|
|
2
|
+
export declare function ConceptApp({ subject, eyebrow, courses: COURSES, getScene, audioBase, }: {
|
|
3
|
+
subject: string;
|
|
4
|
+
courses: Record<string, Course>;
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
getScene: SceneLookup;
|
|
7
|
+
audioBase?: string;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Section, SceneLookup } from './types';
|
|
2
|
+
export declare function SectionView({ section, getScene, capture, eyebrow, index, total, onHome, onPrev, onNext, narrating, onToggleNarration, }: {
|
|
3
|
+
section: Section;
|
|
4
|
+
getScene: SceneLookup;
|
|
5
|
+
capture?: boolean;
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
index?: number;
|
|
8
|
+
total?: number;
|
|
9
|
+
onHome?: () => void;
|
|
10
|
+
onPrev?: () => void;
|
|
11
|
+
onNext?: () => void;
|
|
12
|
+
narrating?: boolean;
|
|
13
|
+
onToggleNarration?: () => void;
|
|
14
|
+
}): import("react").JSX.Element;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
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";
|
|
5
|
+
import X from "react-markdown";
|
|
6
|
+
import F from "remark-gfm";
|
|
7
|
+
function I(a, i) {
|
|
8
|
+
return `${a.id}-${i.id}`;
|
|
9
|
+
}
|
|
10
|
+
function E(a) {
|
|
11
|
+
return a.sections.map((i) => ({ section: i, slug: I(a, i) }));
|
|
12
|
+
}
|
|
13
|
+
const j = 806;
|
|
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 };
|
|
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 }) }) });
|
|
28
|
+
}
|
|
29
|
+
function Z({
|
|
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
|
|
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 }),
|
|
46
|
+
/* @__PURE__ */ e("h1", { className: "reel-head__title", children: a.title })
|
|
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 }) }),
|
|
51
|
+
/* @__PURE__ */ e(
|
|
52
|
+
"button",
|
|
53
|
+
{
|
|
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 })
|
|
59
|
+
}
|
|
60
|
+
)
|
|
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,
|
|
66
|
+
" / ",
|
|
67
|
+
c
|
|
68
|
+
] }),
|
|
69
|
+
/* @__PURE__ */ e("button", { className: "reel-foot__ctrl", onClick: o, "aria-label": "Next section (→)", title: "Next section (→)", children: /* @__PURE__ */ e(R, { size: 19 }) })
|
|
70
|
+
] })
|
|
71
|
+
] }),
|
|
72
|
+
/* @__PURE__ */ e("div", { className: "scene-area", children: /* @__PURE__ */ e(z, { scene: N, focusId: a.focus }) }),
|
|
73
|
+
!r && /* @__PURE__ */ e(
|
|
74
|
+
"button",
|
|
75
|
+
{
|
|
76
|
+
className: "slide-toggle",
|
|
77
|
+
onClick: () => v((w) => !w),
|
|
78
|
+
"aria-label": m ? "Hide slide" : "Show slide",
|
|
79
|
+
children: m ? "›" : "‹"
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
/* @__PURE__ */ e(W, { slide: a.slide, open: m })
|
|
83
|
+
] }) : /* @__PURE__ */ h("div", { className: "stage stage--missing", children: [
|
|
84
|
+
"no scene: ",
|
|
85
|
+
a.scene
|
|
86
|
+
] });
|
|
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
|
+
] }) })
|
|
133
|
+
] });
|
|
134
|
+
}
|
|
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)));
|
|
150
|
+
}, stop: () => {
|
|
151
|
+
var t;
|
|
152
|
+
(t = r.current) == null || t.pause(), c(!1);
|
|
153
|
+
} };
|
|
154
|
+
}
|
|
155
|
+
function ae({
|
|
156
|
+
subject: a,
|
|
157
|
+
eyebrow: i,
|
|
158
|
+
courses: r,
|
|
159
|
+
getScene: d,
|
|
160
|
+
audioBase: p = "/"
|
|
161
|
+
}) {
|
|
162
|
+
const [c, f] = x(() => location.hash);
|
|
163
|
+
_(() => {
|
|
164
|
+
const n = () => f(location.hash);
|
|
165
|
+
return window.addEventListener("hashchange", n), () => window.removeEventListener("hashchange", n);
|
|
166
|
+
}, []);
|
|
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,
|
|
182
|
+
// audio lives at audio/<course>/<id>.wav
|
|
183
|
+
id: s.id,
|
|
184
|
+
scene: s.scene,
|
|
185
|
+
focus: s.focus ?? null
|
|
186
|
+
}))
|
|
187
|
+
});
|
|
188
|
+
}, [l, u, t]);
|
|
189
|
+
const v = C(
|
|
190
|
+
(n) => {
|
|
191
|
+
const s = location.hash.replace(/^#\/?/, ""), g = m.findIndex(({ slug: k }) => k === s);
|
|
192
|
+
if (g !== -1) {
|
|
193
|
+
const k = m[g + n];
|
|
194
|
+
location.hash = k ? `#/${k.slug}` : "";
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (s === "") {
|
|
198
|
+
n === 1 && m[0] && (location.hash = `#/${m[0].slug}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const b = n === 1 ? u[0] : u[u.length - 1];
|
|
202
|
+
b && (location.hash = `#/${b.slug}`);
|
|
203
|
+
},
|
|
204
|
+
[u, m]
|
|
205
|
+
);
|
|
206
|
+
_(() => {
|
|
207
|
+
const n = (s) => {
|
|
208
|
+
s.key !== "ArrowRight" && s.key !== "ArrowLeft" || (s.preventDefault(), v(s.key === "ArrowRight" ? 1 : -1));
|
|
209
|
+
};
|
|
210
|
+
return window.addEventListener("keydown", n), () => window.removeEventListener("keydown", n);
|
|
211
|
+
}, [v]);
|
|
212
|
+
const N = C(() => {
|
|
213
|
+
location.hash ? location.hash = "" : location.pathname !== "/" && (location.href = "/");
|
|
214
|
+
}, []);
|
|
215
|
+
_(() => {
|
|
216
|
+
const n = (s) => {
|
|
217
|
+
s.key === "Escape" && N();
|
|
218
|
+
};
|
|
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();
|
|
224
|
+
});
|
|
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());
|
|
230
|
+
};
|
|
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);
|
|
236
|
+
$ = /* @__PURE__ */ e(
|
|
237
|
+
Z,
|
|
238
|
+
{
|
|
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,
|
|
246
|
+
onPrev: () => v(-1),
|
|
247
|
+
onNext: () => v(1),
|
|
248
|
+
narrating: H,
|
|
249
|
+
onToggleNarration: P
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
} 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: [
|
|
255
|
+
"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" });
|
|
258
|
+
}
|
|
259
|
+
return /* @__PURE__ */ h(T, { children: [
|
|
260
|
+
A,
|
|
261
|
+
$
|
|
262
|
+
] });
|
|
263
|
+
}
|
|
264
|
+
export {
|
|
265
|
+
ae as ConceptApp,
|
|
266
|
+
E as allSections,
|
|
267
|
+
I as slugOf
|
|
268
|
+
};
|
|
269
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":"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"}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/* @graphlearning/shell — the concept-app stylesheet.
|
|
2
|
+
*
|
|
3
|
+
* Import ONCE in a content repo's main.tsx, and import it BEFORE the repo's own theme.css:
|
|
4
|
+
* import '@graphlearning/flow/styles.css' // engine: react-flow + the IBM Plex faces
|
|
5
|
+
* import '@graphlearning/shell/styles.css' // this file: the shell's layout + surface
|
|
6
|
+
* import './theme.css' // the repo: --brand / --brand-hover / --accent-2
|
|
7
|
+
*
|
|
8
|
+
* THE SHELL PAINTS THE CANVAS. ui-flow's SceneView draws its dots at #2a2f38 and assumes a dark
|
|
9
|
+
* surface behind it; that surface (--bg) is defined here. This is the contract each content repo
|
|
10
|
+
* used to re-declare by hand in its own index.css.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--bg: #1a1d23; /* Zed-slate (matches graphl-studio/aws) */
|
|
15
|
+
--fg: #eceef2;
|
|
16
|
+
/* --- the three tokens a CONTENT REPO owns -------------------------------------------------
|
|
17
|
+
These are NEUTRAL DEFAULTS. Each repo overrides them in its own theme.css, imported AFTER
|
|
18
|
+
this stylesheet so the cascade lets the repo win. They are the entire per-repo design
|
|
19
|
+
surface — everything else in this file is identical for every concept.
|
|
20
|
+
--brand the one accent: section eyebrow, slide title (h2), catalog accent, brand hover
|
|
21
|
+
--brand-hover a lighter tint of --brand, for the eyebrow's hover state
|
|
22
|
+
--accent-2 a counter tone to --brand, for the slide's `###` subheading
|
|
23
|
+
Defaults rather than nothing: a repo that forgets theme.css renders in slate and is obviously
|
|
24
|
+
unbranded, instead of painting invalid colours and losing the text entirely. */
|
|
25
|
+
--brand: #8b909c;
|
|
26
|
+
--brand-hover: #a8adb8;
|
|
27
|
+
--accent-2: #c8cdd6;
|
|
28
|
+
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
html,
|
|
32
|
+
body,
|
|
33
|
+
#root {
|
|
34
|
+
height: 100%;
|
|
35
|
+
margin: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
body {
|
|
39
|
+
background: var(--bg);
|
|
40
|
+
color: var(--fg);
|
|
41
|
+
font-family: var(--font-sans);
|
|
42
|
+
-webkit-font-smoothing: antialiased;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The stage fills the viewport; at capture time the viewport is the target frame (4K landscape or
|
|
46
|
+
1080×1920 portrait), so the composition fills it exactly. */
|
|
47
|
+
.stage {
|
|
48
|
+
width: 100vw;
|
|
49
|
+
height: 100vh;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.stage--missing {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
font-size: 18px;
|
|
57
|
+
opacity: 0.7;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* react-flow theming to sit on the dark stage */
|
|
61
|
+
.react-flow {
|
|
62
|
+
background: var(--bg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* --- Section layout (scene + fixed-right slide surface) -------------------- */
|
|
66
|
+
/* The slide is a full-height surface LIFTED off the scene canvas (figure-ground), fixed right.
|
|
67
|
+
Base = PORTRAIT (mobile → reel): scene fills the frame; slide is an off-canvas right drawer. */
|
|
68
|
+
.stage--section {
|
|
69
|
+
position: relative;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
}
|
|
72
|
+
.scene-area {
|
|
73
|
+
position: absolute;
|
|
74
|
+
inset: 0;
|
|
75
|
+
}
|
|
76
|
+
.slide-panel {
|
|
77
|
+
position: absolute;
|
|
78
|
+
top: 0;
|
|
79
|
+
right: 0;
|
|
80
|
+
height: 100%;
|
|
81
|
+
width: 100%; /* portrait: full-screen reading surface (landscape overrides to a 42% column) */
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: safe center;
|
|
84
|
+
justify-content: center;
|
|
85
|
+
overflow: auto;
|
|
86
|
+
box-sizing: border-box;
|
|
87
|
+
background: #2f333d; /* lifted slide surface, off the #1a1d23 scene canvas */
|
|
88
|
+
border-left: 1px solid rgba(255, 255, 255, 0.09);
|
|
89
|
+
transform: translateX(100%);
|
|
90
|
+
transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
|
91
|
+
z-index: 10;
|
|
92
|
+
}
|
|
93
|
+
.slide-panel--open {
|
|
94
|
+
transform: translateX(0);
|
|
95
|
+
}
|
|
96
|
+
.slide-toggle {
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: 14px;
|
|
99
|
+
right: 14px;
|
|
100
|
+
z-index: 11;
|
|
101
|
+
width: 36px;
|
|
102
|
+
height: 36px;
|
|
103
|
+
border-radius: 10px;
|
|
104
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
105
|
+
background: rgba(26, 29, 35, 0.9);
|
|
106
|
+
color: rgba(255, 255, 255, 0.82);
|
|
107
|
+
font-size: 19px;
|
|
108
|
+
line-height: 1;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: color 0.15s, border-color 0.15s;
|
|
111
|
+
-webkit-tap-highlight-color: transparent;
|
|
112
|
+
}
|
|
113
|
+
@media (hover: hover) {
|
|
114
|
+
.slide-toggle:hover {
|
|
115
|
+
color: var(--idx-accent);
|
|
116
|
+
border-color: var(--idx-accent);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
.slide-toggle:focus:not(:focus-visible) {
|
|
120
|
+
outline: none;
|
|
121
|
+
}
|
|
122
|
+
.slide-toggle:focus-visible {
|
|
123
|
+
outline: 2px solid var(--idx-accent);
|
|
124
|
+
outline-offset: 2px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Footer control bar (interactive only). Raised above the portrait drawer so Home + narration + nav
|
|
128
|
+
stay usable while the slide is open. Compound selector (.reel-foot.reel-foot--controls, 0,2,0) so it
|
|
129
|
+
outranks the base .reel-foot rule (z-index 8 / align baseline), which is defined LATER in the file —
|
|
130
|
+
at equal specificity that base would win here. */
|
|
131
|
+
.reel-foot.reel-foot--controls {
|
|
132
|
+
z-index: 12;
|
|
133
|
+
align-items: center;
|
|
134
|
+
}
|
|
135
|
+
.reel-foot__grp,
|
|
136
|
+
.reel-foot__nav {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 10px;
|
|
140
|
+
}
|
|
141
|
+
.reel-foot__ctrl {
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
width: 36px;
|
|
146
|
+
height: 36px;
|
|
147
|
+
border-radius: 10px;
|
|
148
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
149
|
+
background: rgba(26, 29, 35, 0.9);
|
|
150
|
+
color: rgba(255, 255, 255, 0.82);
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
pointer-events: auto; /* the footer container is pointer-events:none; re-enable the buttons */
|
|
153
|
+
transition: color 0.15s, border-color 0.15s;
|
|
154
|
+
-webkit-tap-highlight-color: transparent; /* no grey/blue tap flash on mobile */
|
|
155
|
+
}
|
|
156
|
+
/* Hover feedback only on real-pointer devices — on touch, :hover "sticks" after a tap and leaves the
|
|
157
|
+
icon highlighted until you tap elsewhere. */
|
|
158
|
+
@media (hover: hover) {
|
|
159
|
+
.reel-foot__ctrl:hover {
|
|
160
|
+
color: var(--idx-accent);
|
|
161
|
+
border-color: var(--idx-accent);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/* Drop the focus ring on click/tap; keep a keyboard-only ring for accessibility. */
|
|
165
|
+
.reel-foot__ctrl:focus:not(:focus-visible) {
|
|
166
|
+
outline: none;
|
|
167
|
+
}
|
|
168
|
+
.reel-foot__ctrl:focus-visible {
|
|
169
|
+
outline: 2px solid var(--idx-accent);
|
|
170
|
+
outline-offset: 2px;
|
|
171
|
+
}
|
|
172
|
+
.reel-foot__count--live {
|
|
173
|
+
pointer-events: none;
|
|
174
|
+
min-width: 3.5em;
|
|
175
|
+
text-align: center;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* GraphL wordmark — top-left of the bare SCENE view; click or Esc → back to catalog. (The section
|
|
179
|
+
view brands via its own header/footer, whose eyebrow is the home link.)
|
|
180
|
+
Fixed to the viewport so it sits above the stage regardless of layout; hidden under ?capture=1. */
|
|
181
|
+
.brand-home {
|
|
182
|
+
position: fixed;
|
|
183
|
+
top: 14px;
|
|
184
|
+
left: 16px;
|
|
185
|
+
z-index: 20;
|
|
186
|
+
padding: 4px 6px;
|
|
187
|
+
border: none;
|
|
188
|
+
background: transparent;
|
|
189
|
+
color: rgba(255, 255, 255, 0.82);
|
|
190
|
+
font-family: var(--font-sans);
|
|
191
|
+
font-size: 17px;
|
|
192
|
+
font-weight: 700;
|
|
193
|
+
letter-spacing: -0.01em;
|
|
194
|
+
line-height: 1;
|
|
195
|
+
cursor: pointer;
|
|
196
|
+
transition: color 0.2s;
|
|
197
|
+
}
|
|
198
|
+
.brand-home:hover {
|
|
199
|
+
color: var(--brand); /* Python-blue, matching the catalog accent */
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Section header/footer — shared chrome for the interactive app AND the captured video. It brands the
|
|
203
|
+
empty canvas around the fitView-centred scene: an "AWS · FOUNDATIONS" eyebrow (also the back-to-
|
|
204
|
+
catalog link) + the section title up top, a GraphL brand + §n/N counter below. Sizes are in vh so
|
|
205
|
+
they hold the SAME pixels at the capture frames (3840×2160 / 1080×1920) yet scale proportionally to
|
|
206
|
+
any interactive window. The bands are pointer-events:none (they overlay the scene) except the
|
|
207
|
+
eyebrow button, which re-enables clicks; z-index sits below the portrait slide drawer (z 10) so an
|
|
208
|
+
open drawer covers them. */
|
|
209
|
+
.reel-head {
|
|
210
|
+
position: absolute;
|
|
211
|
+
top: 0;
|
|
212
|
+
left: 0;
|
|
213
|
+
right: 0;
|
|
214
|
+
z-index: 8;
|
|
215
|
+
padding: 3.75vh 6vw 0;
|
|
216
|
+
pointer-events: none;
|
|
217
|
+
}
|
|
218
|
+
.reel-head__eyebrow {
|
|
219
|
+
display: inline-block;
|
|
220
|
+
margin: 0 0 1.6vh;
|
|
221
|
+
padding: 0;
|
|
222
|
+
border: 0;
|
|
223
|
+
background: none;
|
|
224
|
+
font-family: inherit;
|
|
225
|
+
font-size: 1.4vh;
|
|
226
|
+
font-weight: 700;
|
|
227
|
+
letter-spacing: 0.18em;
|
|
228
|
+
text-transform: uppercase;
|
|
229
|
+
color: var(--brand); /* Python-blue accent, matching the catalog brand */
|
|
230
|
+
pointer-events: auto; /* the one clickable element in the header → back to catalog */
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
transition: color 0.2s;
|
|
233
|
+
}
|
|
234
|
+
.reel-head__eyebrow:hover {
|
|
235
|
+
color: var(--brand-hover);
|
|
236
|
+
}
|
|
237
|
+
.reel-head__title {
|
|
238
|
+
margin: 0;
|
|
239
|
+
font-size: 3.1vh;
|
|
240
|
+
font-weight: 700;
|
|
241
|
+
line-height: 1.08;
|
|
242
|
+
letter-spacing: -0.02em;
|
|
243
|
+
color: var(--fg);
|
|
244
|
+
}
|
|
245
|
+
.reel-foot {
|
|
246
|
+
position: absolute;
|
|
247
|
+
left: 0;
|
|
248
|
+
right: 0;
|
|
249
|
+
bottom: 0;
|
|
250
|
+
z-index: 8;
|
|
251
|
+
padding: 0 6vw 2.9vh;
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: baseline;
|
|
254
|
+
justify-content: space-between;
|
|
255
|
+
pointer-events: none;
|
|
256
|
+
}
|
|
257
|
+
.reel-foot__brand {
|
|
258
|
+
font-size: 1.55vh;
|
|
259
|
+
font-weight: 700;
|
|
260
|
+
letter-spacing: -0.01em;
|
|
261
|
+
color: rgba(255, 255, 255, 0.9);
|
|
262
|
+
}
|
|
263
|
+
.reel-foot__count {
|
|
264
|
+
font-size: 1.4vh;
|
|
265
|
+
color: rgba(255, 255, 255, 0.5);
|
|
266
|
+
font-variant-numeric: tabular-nums;
|
|
267
|
+
letter-spacing: 0.04em;
|
|
268
|
+
}
|
|
269
|
+
/* Reserve the bands so fitView packs the scene BETWEEN them (never under the title). Every section
|
|
270
|
+
view shows the header/footer, so the reserve is scoped to the section (not just capture). */
|
|
271
|
+
.stage--section .scene-area {
|
|
272
|
+
top: 12.5vh;
|
|
273
|
+
bottom: 7.5vh;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* LANDSCAPE (laptop → 4K video): the slide is a true 42% COLUMN — no absolute cap, so it stays 42%
|
|
277
|
+
of whatever the frame is, 4K included. The scene fills the rest; drawer transform/toggle off. */
|
|
278
|
+
@media (orientation: landscape) {
|
|
279
|
+
.stage--section {
|
|
280
|
+
display: flex;
|
|
281
|
+
}
|
|
282
|
+
.scene-area {
|
|
283
|
+
position: relative;
|
|
284
|
+
flex: 1 1 auto;
|
|
285
|
+
min-width: 0;
|
|
286
|
+
height: 100%;
|
|
287
|
+
inset: auto;
|
|
288
|
+
}
|
|
289
|
+
/* Header/footer carry over but constrained to the SCENE column (left 58%; the slide is the right
|
|
290
|
+
42%) so they brand the diagram without straddling the slide. The section title is dropped here —
|
|
291
|
+
it already headlines the slide — leaving the eyebrow on top and the GraphL + §n/N line below,
|
|
292
|
+
sitting in the scene's fitView margins. Font sizes (vh) are shared with portrait. */
|
|
293
|
+
.reel-head {
|
|
294
|
+
right: 42%; /* right edge at the scene/slide boundary → spans only the scene column */
|
|
295
|
+
padding: 2.8vh 0 0 1.9vw;
|
|
296
|
+
}
|
|
297
|
+
.reel-head__title {
|
|
298
|
+
display: none; /* the section title lives in the slide in landscape */
|
|
299
|
+
}
|
|
300
|
+
.reel-head__eyebrow {
|
|
301
|
+
margin-bottom: 0;
|
|
302
|
+
}
|
|
303
|
+
.reel-foot {
|
|
304
|
+
right: 42%;
|
|
305
|
+
padding: 0 1.9vw 2.6vh;
|
|
306
|
+
}
|
|
307
|
+
/* Scene reserve is portrait-only; in landscape the scene-area is the full-height flex column. */
|
|
308
|
+
.stage--section .scene-area {
|
|
309
|
+
top: auto;
|
|
310
|
+
bottom: auto;
|
|
311
|
+
}
|
|
312
|
+
.slide-panel {
|
|
313
|
+
position: relative;
|
|
314
|
+
top: auto;
|
|
315
|
+
right: auto;
|
|
316
|
+
height: 100%;
|
|
317
|
+
width: auto;
|
|
318
|
+
flex: 0 0 42%;
|
|
319
|
+
transform: none;
|
|
320
|
+
}
|
|
321
|
+
.slide-toggle {
|
|
322
|
+
display: none;
|
|
323
|
+
}
|
|
324
|
+
/* Restore the capture-tuned slide metrics (the base carries the larger portrait values). Scoped
|
|
325
|
+
with .stage--section so it outranks the base .slide-panel__scaler rule, which is defined LATER
|
|
326
|
+
in the file — at equal specificity that base would otherwise win here too. */
|
|
327
|
+
.stage--section .slide-panel__scaler {
|
|
328
|
+
padding: 0 60px;
|
|
329
|
+
--fs-body: 26px;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* --- Slide scaler + markdown ----------------------------------------------- */
|
|
334
|
+
/* The scaler is authored at DESIGN_PANE px (see useSlideScale) and `zoom`-scaled to the live pane,
|
|
335
|
+
so every dimension below is DESIGN px and stays proportional to the target frame at any size.
|
|
336
|
+
One type scale drives the whole slide — every size steps off --fs-body, so retuning the slide for
|
|
337
|
+
capture legibility is a ONE-NUMBER change (adapted from the studio reveal-engine). */
|
|
338
|
+
.slide-panel__scaler {
|
|
339
|
+
box-sizing: border-box;
|
|
340
|
+
padding: 0 40px; /* portrait: trimmed side padding to fill the phone width (landscape restores 60) */
|
|
341
|
+
color: #c8cdd6;
|
|
342
|
+
--fs-body: 31px; /* portrait/reels: larger so the slide fills the phone (landscape restores 26) */
|
|
343
|
+
--fs-title: calc(var(--fs-body) * 1.73);
|
|
344
|
+
--fs-heading: calc(var(--fs-body) * 1.2);
|
|
345
|
+
--fs-code: calc(var(--fs-body) * 0.78);
|
|
346
|
+
font-size: var(--fs-body);
|
|
347
|
+
line-height: 1.45;
|
|
348
|
+
}
|
|
349
|
+
.slide-panel__scaler > :first-child {
|
|
350
|
+
margin-top: 0;
|
|
351
|
+
}
|
|
352
|
+
.slide-panel__scaler > :last-child {
|
|
353
|
+
margin-bottom: 0;
|
|
354
|
+
}
|
|
355
|
+
/* `##` → the slide title. */
|
|
356
|
+
.slide-panel__scaler h2 {
|
|
357
|
+
margin: 0 0 20px;
|
|
358
|
+
font-size: var(--fs-title);
|
|
359
|
+
font-weight: 700;
|
|
360
|
+
line-height: 1.15;
|
|
361
|
+
letter-spacing: -0.01em;
|
|
362
|
+
color: var(--brand); /* Python-blue brand accent — same anchor as the reel eyebrow + catalog */
|
|
363
|
+
}
|
|
364
|
+
/* `###` → a section heading in Python-yellow: a warm counter to the cool blue title (the Python
|
|
365
|
+
logo's blue + yellow). Sentence-case and sized above the body (a real heading, not a quiet
|
|
366
|
+
eyebrow) — the warm/cool pair structures the slide at a glance while the body stays neutral. */
|
|
367
|
+
.slide-panel__scaler h3 {
|
|
368
|
+
margin: 30px 0 12px;
|
|
369
|
+
font-size: var(--fs-heading);
|
|
370
|
+
font-weight: 700;
|
|
371
|
+
line-height: 1.2;
|
|
372
|
+
letter-spacing: -0.01em;
|
|
373
|
+
color: var(--accent-2); /* Python-yellow */
|
|
374
|
+
}
|
|
375
|
+
.slide-panel__scaler p {
|
|
376
|
+
margin: 0 0 16px;
|
|
377
|
+
}
|
|
378
|
+
.slide-panel__scaler ul {
|
|
379
|
+
margin: 0 0 18px;
|
|
380
|
+
padding-left: 0;
|
|
381
|
+
list-style: none;
|
|
382
|
+
display: flex;
|
|
383
|
+
flex-direction: column;
|
|
384
|
+
gap: 14px;
|
|
385
|
+
}
|
|
386
|
+
.slide-panel__scaler li {
|
|
387
|
+
position: relative;
|
|
388
|
+
padding-left: 30px;
|
|
389
|
+
}
|
|
390
|
+
.slide-panel__scaler ul > li::before {
|
|
391
|
+
content: '—';
|
|
392
|
+
position: absolute;
|
|
393
|
+
left: 0;
|
|
394
|
+
color: rgba(255, 255, 255, 0.28);
|
|
395
|
+
}
|
|
396
|
+
.slide-panel__scaler strong {
|
|
397
|
+
color: rgba(255, 255, 255, 0.95);
|
|
398
|
+
font-weight: 700;
|
|
399
|
+
}
|
|
400
|
+
.slide-panel__scaler em {
|
|
401
|
+
font-style: italic;
|
|
402
|
+
color: rgba(255, 255, 255, 0.85);
|
|
403
|
+
}
|
|
404
|
+
.slide-panel__scaler code {
|
|
405
|
+
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
|
406
|
+
font-size: var(--fs-code);
|
|
407
|
+
padding: 2px 7px;
|
|
408
|
+
border-radius: 5px;
|
|
409
|
+
background: rgba(255, 255, 255, 0.08);
|
|
410
|
+
color: rgba(255, 255, 255, 0.9);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* --- Course catalog / landing page (CourseIndex) ---------------------------- */
|
|
414
|
+
/* The index is a scrollable page (unlike the fixed stage): the catalog of courses, each course a
|
|
415
|
+
card listing its ordered sections. Python-blue (--idx-accent) is the one accent. */
|
|
416
|
+
.idx {
|
|
417
|
+
--idx-accent: var(--brand);
|
|
418
|
+
--idx-muted: #8b909c;
|
|
419
|
+
--idx-line: rgba(255, 255, 255, 0.09);
|
|
420
|
+
min-height: 100vh;
|
|
421
|
+
max-width: 940px;
|
|
422
|
+
margin: 0 auto;
|
|
423
|
+
padding: 40px 32px 64px;
|
|
424
|
+
box-sizing: border-box;
|
|
425
|
+
}
|
|
426
|
+
.idx__head {
|
|
427
|
+
margin-bottom: 36px;
|
|
428
|
+
}
|
|
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
|
+
}
|
|
444
|
+
.idx__subject {
|
|
445
|
+
margin: 0;
|
|
446
|
+
font-size: 40px;
|
|
447
|
+
font-weight: 700;
|
|
448
|
+
letter-spacing: -0.02em;
|
|
449
|
+
line-height: 1.05;
|
|
450
|
+
}
|
|
451
|
+
.idx__empty {
|
|
452
|
+
color: var(--idx-muted);
|
|
453
|
+
font-size: 16px;
|
|
454
|
+
padding: 28px;
|
|
455
|
+
border: 1px dashed var(--idx-line);
|
|
456
|
+
border-radius: 14px;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* Courses grid — a single column of accordion cards (tight gap since most sit collapsed). */
|
|
460
|
+
.idx__grid {
|
|
461
|
+
list-style: none;
|
|
462
|
+
margin: 0;
|
|
463
|
+
padding: 0;
|
|
464
|
+
display: grid;
|
|
465
|
+
gap: 12px;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* A course card = an accordion: a header button (number · title · count · caret) that toggles the
|
|
469
|
+
section list below it. */
|
|
470
|
+
.idx-card {
|
|
471
|
+
border: 1px solid var(--idx-line);
|
|
472
|
+
border-radius: 14px;
|
|
473
|
+
background: rgba(255, 255, 255, 0.018);
|
|
474
|
+
overflow: hidden;
|
|
475
|
+
}
|
|
476
|
+
.idx-card__head {
|
|
477
|
+
width: 100%;
|
|
478
|
+
display: grid;
|
|
479
|
+
grid-template-columns: auto 1fr auto auto;
|
|
480
|
+
align-items: center;
|
|
481
|
+
gap: 18px;
|
|
482
|
+
padding: 20px 24px;
|
|
483
|
+
border: none;
|
|
484
|
+
background: transparent;
|
|
485
|
+
color: inherit;
|
|
486
|
+
font: inherit;
|
|
487
|
+
text-align: left;
|
|
488
|
+
cursor: pointer;
|
|
489
|
+
transition: background 0.15s ease;
|
|
490
|
+
}
|
|
491
|
+
.idx-card__head:hover {
|
|
492
|
+
background: #22262e;
|
|
493
|
+
}
|
|
494
|
+
/* Divider only when open — a collapsed row is a clean single bar. */
|
|
495
|
+
.idx-card--open .idx-card__head {
|
|
496
|
+
border-bottom: 1px solid var(--idx-line);
|
|
497
|
+
}
|
|
498
|
+
.idx-card__num {
|
|
499
|
+
font-size: 14px;
|
|
500
|
+
font-weight: 700;
|
|
501
|
+
font-variant-numeric: tabular-nums;
|
|
502
|
+
color: var(--idx-accent);
|
|
503
|
+
border: 1px solid var(--idx-accent);
|
|
504
|
+
border-radius: 8px;
|
|
505
|
+
padding: 5px 8px;
|
|
506
|
+
line-height: 1;
|
|
507
|
+
}
|
|
508
|
+
.idx-card__title {
|
|
509
|
+
min-width: 0;
|
|
510
|
+
font-size: 20px;
|
|
511
|
+
font-weight: 700;
|
|
512
|
+
letter-spacing: -0.01em;
|
|
513
|
+
}
|
|
514
|
+
.idx-card__count {
|
|
515
|
+
flex: none;
|
|
516
|
+
font-size: 13px;
|
|
517
|
+
font-weight: 600;
|
|
518
|
+
color: var(--idx-muted);
|
|
519
|
+
font-variant-numeric: tabular-nums;
|
|
520
|
+
white-space: nowrap;
|
|
521
|
+
}
|
|
522
|
+
.idx-card__toggle {
|
|
523
|
+
flex: none;
|
|
524
|
+
font-size: 18px;
|
|
525
|
+
line-height: 1;
|
|
526
|
+
color: var(--idx-muted);
|
|
527
|
+
transition: transform 0.18s ease, color 0.15s ease;
|
|
528
|
+
}
|
|
529
|
+
.idx-card--open .idx-card__toggle {
|
|
530
|
+
transform: rotate(180deg);
|
|
531
|
+
color: var(--idx-accent);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* Section list inside a card — thin numbered rows, hover slides the chevron. */
|
|
535
|
+
.idx-sec {
|
|
536
|
+
list-style: none;
|
|
537
|
+
margin: 0;
|
|
538
|
+
padding: 6px 0;
|
|
539
|
+
}
|
|
540
|
+
.idx-sec__row a {
|
|
541
|
+
display: flex;
|
|
542
|
+
align-items: center;
|
|
543
|
+
gap: 16px;
|
|
544
|
+
padding: 11px 26px;
|
|
545
|
+
text-decoration: none;
|
|
546
|
+
color: inherit;
|
|
547
|
+
transition: background 0.12s ease, padding-left 0.12s ease;
|
|
548
|
+
}
|
|
549
|
+
.idx-sec__row a:hover {
|
|
550
|
+
background: rgba(240, 144, 47, 0.07);
|
|
551
|
+
padding-left: 32px;
|
|
552
|
+
}
|
|
553
|
+
.idx-sec__num {
|
|
554
|
+
flex: none;
|
|
555
|
+
width: 22px;
|
|
556
|
+
font-size: 13px;
|
|
557
|
+
font-weight: 600;
|
|
558
|
+
font-variant-numeric: tabular-nums;
|
|
559
|
+
color: #5b616f;
|
|
560
|
+
}
|
|
561
|
+
.idx-sec__row a:hover .idx-sec__num {
|
|
562
|
+
color: var(--idx-accent);
|
|
563
|
+
}
|
|
564
|
+
.idx-sec__title {
|
|
565
|
+
flex: 1;
|
|
566
|
+
min-width: 0;
|
|
567
|
+
font-size: 15.5px;
|
|
568
|
+
}
|
|
569
|
+
.idx-sec__chev {
|
|
570
|
+
flex: none;
|
|
571
|
+
font-size: 19px;
|
|
572
|
+
color: #4b515e;
|
|
573
|
+
transition: color 0.12s ease, transform 0.12s ease;
|
|
574
|
+
}
|
|
575
|
+
.idx-sec__row a:hover .idx-sec__chev {
|
|
576
|
+
color: var(--idx-accent);
|
|
577
|
+
transform: translateX(3px);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.idx__foot {
|
|
581
|
+
margin-top: 40px;
|
|
582
|
+
color: var(--idx-muted);
|
|
583
|
+
font-size: 13px;
|
|
584
|
+
}
|
|
585
|
+
.idx__hint kbd {
|
|
586
|
+
font-family: var(--font-sans);
|
|
587
|
+
font-size: 12px;
|
|
588
|
+
padding: 2px 7px;
|
|
589
|
+
border-radius: 5px;
|
|
590
|
+
background: rgba(255, 255, 255, 0.08);
|
|
591
|
+
color: rgba(255, 255, 255, 0.9);
|
|
592
|
+
}
|
|
593
|
+
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Section {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
scene: string;
|
|
5
|
+
focus?: string;
|
|
6
|
+
slide: string;
|
|
7
|
+
narration: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Course {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
sections: Section[];
|
|
13
|
+
}
|
|
14
|
+
export type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined;
|
|
15
|
+
export declare function slugOf(course: Course, section: Section): string;
|
|
16
|
+
export declare function allSections(course: Course): {
|
|
17
|
+
section: Section;
|
|
18
|
+
slug: string;
|
|
19
|
+
}[];
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@graphlearning/shell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The GraphL concept-app shell — hash router, section view (scene left / slide right), slide panel, course catalog and narration channel. A content repo supplies courses + scenes; the shell is everything around them.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/schemabotview/ui-shell.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./styles.css": "./dist/styles.css"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "vite",
|
|
26
|
+
"build": "vite build && tsc --emitDeclarationOnly && cp src/styles.css dist/styles.css",
|
|
27
|
+
"watch": "vite build --watch"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@graphlearning/flow": ">=0.5.0 <1.0.0",
|
|
31
|
+
"lucide-react": "^1.28.0",
|
|
32
|
+
"react": "^19.2.0",
|
|
33
|
+
"react-dom": "^19.2.0",
|
|
34
|
+
"react-markdown": "^10.1.0",
|
|
35
|
+
"remark-gfm": "^4.0.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@graphlearning/flow": "^0.5.0",
|
|
39
|
+
"@types/react": "^19.2.0",
|
|
40
|
+
"@types/react-dom": "^19.2.0",
|
|
41
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
42
|
+
"@xyflow/react": "^12.11.2",
|
|
43
|
+
"lucide-react": "^1.28.0",
|
|
44
|
+
"react": "^19.2.0",
|
|
45
|
+
"react-dom": "^19.2.0",
|
|
46
|
+
"react-markdown": "^10.1.0",
|
|
47
|
+
"remark-gfm": "^4.0.1",
|
|
48
|
+
"typescript": "~5.7.2",
|
|
49
|
+
"vite": "^6.0.5"
|
|
50
|
+
}
|
|
51
|
+
}
|