@graphlearning/shell 0.1.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 +65 -0
- package/dist/ConceptApp.d.ts +2 -1
- package/dist/CourseIndex.d.ts +2 -1
- package/dist/SiteHeader.d.ts +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +247 -198
- package/dist/index.js.map +1 -1
- package/dist/styles.css +171 -17
- package/package.json +24 -4
- package/scripts/_paths.mjs +72 -0
- package/scripts/capture-shots.mjs +17 -0
- package/scripts/gen-audio-manifest.mjs +57 -0
- package/scripts/gen-descriptions.mjs +155 -0
- package/scripts/record-course.mjs +316 -0
- package/scripts/record-reels.mjs +247 -0
- package/scripts/shots-4k.mjs +54 -0
- package/scripts/thumb-template.html +52 -0
- package/scripts/thumb.mjs +232 -0
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":"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"}
|
|
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
|
-
|
|
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:
|
|
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:
|
|
430
|
-
.
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphlearning/shell",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The GraphL concept-app shell
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|
|
7
7
|
"type": "git",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"files": [
|
|
12
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"scripts"
|
|
13
14
|
],
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
@@ -32,7 +33,9 @@
|
|
|
32
33
|
"react": "^19.2.0",
|
|
33
34
|
"react-dom": "^19.2.0",
|
|
34
35
|
"react-markdown": "^10.1.0",
|
|
35
|
-
"remark-gfm": "^4.0.1"
|
|
36
|
+
"remark-gfm": "^4.0.1",
|
|
37
|
+
"puppeteer": ">=24",
|
|
38
|
+
"esbuild": ">=0.20"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@graphlearning/flow": "^0.5.0",
|
|
@@ -47,5 +50,22 @@
|
|
|
47
50
|
"remark-gfm": "^4.0.1",
|
|
48
51
|
"typescript": "~5.7.2",
|
|
49
52
|
"vite": "^6.0.5"
|
|
53
|
+
},
|
|
54
|
+
"bin": {
|
|
55
|
+
"graphl-record": "scripts/record-course.mjs",
|
|
56
|
+
"graphl-record-reels": "scripts/record-reels.mjs",
|
|
57
|
+
"graphl-shots-4k": "scripts/shots-4k.mjs",
|
|
58
|
+
"graphl-capture-shots": "scripts/capture-shots.mjs",
|
|
59
|
+
"graphl-gen-audio": "scripts/gen-audio-manifest.mjs",
|
|
60
|
+
"graphl-gen-desc": "scripts/gen-descriptions.mjs",
|
|
61
|
+
"graphl-thumb": "scripts/thumb.mjs"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"puppeteer": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"esbuild": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
50
70
|
}
|
|
51
71
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Where these scripts read and write, now that they live in a package rather than in each repo.
|
|
2
|
+
//
|
|
3
|
+
// They used to resolve everything relative to their own file (`here = dirname(import.meta.url)`),
|
|
4
|
+
// which worked only because scripts/ sat inside the app. From node_modules that points at the
|
|
5
|
+
// package, so the two roots are now explicit:
|
|
6
|
+
//
|
|
7
|
+
// repoDir — the content repo being built. npm run <script> sets cwd to the package root, so
|
|
8
|
+
// process.cwd() IS the repo. Source, public/audio and the dev server live here.
|
|
9
|
+
// dataDir — repoDir/scripts: the repo's own script DATA (titles.json, audio-manifest.json) and
|
|
10
|
+
// every output (out/, segments/, .tmp/). Deliberately the same paths as before the
|
|
11
|
+
// extraction, so .gitignore entries and muscle memory still hold.
|
|
12
|
+
// pkgDir — this package's scripts/: machinery that ships with the shell (thumb-template.html).
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
export const pkgDir = dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
export const repoDir = process.cwd()
|
|
21
|
+
export const dataDir = join(repoDir, 'scripts')
|
|
22
|
+
|
|
23
|
+
if (!existsSync(join(repoDir, 'package.json'))) {
|
|
24
|
+
console.error(`No package.json in ${repoDir} — run this from a content repo's root (npm run …).`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// scripts/concept.json — the per-repo publishing identity. These used to be hardcoded defaults in
|
|
29
|
+
// each repo's copy of thumb.mjs / gen-descriptions.mjs, which is exactly why they drifted (aws's
|
|
30
|
+
// panel gradient was still AWS-orange in sql's copy for a while). Env vars still override, as before.
|
|
31
|
+
const CONFIG_PATH = join(dataDir, 'concept.json')
|
|
32
|
+
const raw = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) : {}
|
|
33
|
+
|
|
34
|
+
export const concept = {
|
|
35
|
+
// The concept name used in video DESCRIPTIONS ("Apache Spark"). Not necessarily the app's
|
|
36
|
+
// subject — databricks-data-engineer publishes as "Databricks".
|
|
37
|
+
name: process.env.CONCEPT ?? raw.concept ?? 'GraphL',
|
|
38
|
+
// The THUMBNAIL kicker, which is not always the same string: apache-spark's descriptions say
|
|
39
|
+
// "Apache Spark" while its thumbnails say "SPARK" (a long name does not fit the panel). The two
|
|
40
|
+
// lived in separate script copies before the extraction, so the divergence was invisible —
|
|
41
|
+
// collapsing them into one field silently rebrands that concept's thumbnails.
|
|
42
|
+
kicker: process.env.CONCEPT_KICKER ?? raw.kicker ?? process.env.CONCEPT ?? raw.concept ?? 'GraphL',
|
|
43
|
+
site: process.env.SITE ?? raw.site ?? 'https://graphl.in',
|
|
44
|
+
// The catalog path the app deploys under — usually /<repo>, but aws deploys at /aws-content.
|
|
45
|
+
appPath: (process.env.APP_PATH ?? raw.appPath ?? '/').replace(/\/$/, ''),
|
|
46
|
+
hashtags: process.env.HASHTAGS ?? raw.hashtags ?? '#TechEducation',
|
|
47
|
+
// The thumbnail's right-hand panel gradient, anchored on the concept's --brand.
|
|
48
|
+
panelBg: process.env.PANEL_BG ?? raw.panelBg ?? 'radial-gradient(118% 104% at 70% 34%, #5b8cff 0%, #2a4fb8 44%, #0b1330 100%)',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Load a peer dependency (puppeteer, esbuild) from the CONTENT REPO, not from this package.
|
|
52
|
+
//
|
|
53
|
+
// A bare `import('puppeteer')` resolves by walking up from the importing FILE. These scripts live in
|
|
54
|
+
// node_modules/@graphlearning/shell/scripts, and under a local `file:../ui-shell` install that path
|
|
55
|
+
// is a symlink — node resolves it to the real ui-shell/ directory and walks up from there, never
|
|
56
|
+
// reaching the repo's node_modules. Anchoring the resolution at repoDir works under both layouts,
|
|
57
|
+
// and it is also the honest description of the dependency: these are the REPO's tools, which the
|
|
58
|
+
// scripts borrow. (esbuild previously resolved only by accident, via ui-shell's own vite install —
|
|
59
|
+
// that would have failed outright once this package was consumed from the registry.)
|
|
60
|
+
const requireFromRepo = createRequire(join(repoDir, 'package.json'))
|
|
61
|
+
|
|
62
|
+
export async function loadPeer(name) {
|
|
63
|
+
try {
|
|
64
|
+
return await import(pathToFileURL(requireFromRepo.resolve(name)).href)
|
|
65
|
+
} catch {
|
|
66
|
+
console.error(`Missing "${name}" in ${repoDir}. It is an optional peer of @graphlearning/shell, ` +
|
|
67
|
+
`needed by the capture/record scripts — install it there: npm i -D ${name}`)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { resolve }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// capture-shots.mjs — [STEP 2, not yet wired] screenshot every slug's scene + record node coords.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/capture-shots.mjs [--only <slug[,slug]>]
|
|
5
|
+
//
|
|
6
|
+
// Plan (blueprint: ../../graphl-studio/aws-lab/scripts/capture-shots.mjs):
|
|
7
|
+
// 1. spawn `npm run dev`, open it headless at 4K (3840×2160) with ?capture=1.
|
|
8
|
+
// 2. read window.__scene.plan() → the list of slugs (scene + focus node).
|
|
9
|
+
// 3. for each slug: render its scene, wait for the painted frame, screenshot → images/<slug>.png
|
|
10
|
+
// (full-frame background), and MEASURE node bounding boxes (react-flow node rects) → coords,
|
|
11
|
+
// including the focus node's box. Coords are written to a sidecar so the compose step can float
|
|
12
|
+
// the text panel clear of the narrating node.
|
|
13
|
+
//
|
|
14
|
+
// The full-frame PNG is the video background; the coords drive dynamic panel placement.
|
|
15
|
+
|
|
16
|
+
console.error('capture-shots.mjs is a step-2 stub — not wired yet. See the header for the plan.')
|
|
17
|
+
process.exit(1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gen-audio-manifest.mjs — flatten the typed course catalog into the audio manifest.
|
|
3
|
+
//
|
|
4
|
+
// The Colab/Chatterbox notebook can't parse our TypeScript course files, so this is the bridge:
|
|
5
|
+
// it evaluates the REAL `COURSES` registry (esbuild strips the type-only `../types` imports, so the
|
|
6
|
+
// content files have no runtime deps) and folds every SECTION into one flat JSON entry, keyed to the
|
|
7
|
+
// audio contract path `<courseId>/<section-id>.wav`.
|
|
8
|
+
//
|
|
9
|
+
// Unlike graphl-studio (one wav per BEAT → `<course>/<section>-<beat>.wav`), this repo's model is
|
|
10
|
+
// one section = one unit = one narration, so there is NO beat index — just `<course>/<section>.wav`.
|
|
11
|
+
//
|
|
12
|
+
// Output: scripts/audio-manifest.json → { count, entries: [{ course, section, file, narration }] }
|
|
13
|
+
//
|
|
14
|
+
// Run: npm run gen:audio (then commit the json so the notebook sees it)
|
|
15
|
+
|
|
16
|
+
import { writeFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
20
|
+
const { build } = await loadPeer('esbuild')
|
|
21
|
+
const entry = resolve(repoDir, 'src/content/index.ts')
|
|
22
|
+
const outFile = resolve(dataDir, 'audio-manifest.json')
|
|
23
|
+
|
|
24
|
+
// Bundle the content registry to an in-memory ESM string. The content files import ONLY `../types`
|
|
25
|
+
// (`import type` → erased), so the bundle has zero runtime deps and imports cleanly in Node.
|
|
26
|
+
const result = await build({
|
|
27
|
+
entryPoints: [entry],
|
|
28
|
+
bundle: true,
|
|
29
|
+
format: 'esm',
|
|
30
|
+
platform: 'node',
|
|
31
|
+
write: false,
|
|
32
|
+
})
|
|
33
|
+
const code = result.outputFiles[0].text
|
|
34
|
+
const mod = await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
|
|
35
|
+
const { COURSES } = mod
|
|
36
|
+
|
|
37
|
+
const entries = []
|
|
38
|
+
for (const course of Object.values(COURSES)) {
|
|
39
|
+
course.sections.forEach((section) => {
|
|
40
|
+
entries.push({
|
|
41
|
+
course: course.id,
|
|
42
|
+
section: section.id,
|
|
43
|
+
file: `${course.id}/${section.id}.wav`,
|
|
44
|
+
narration: section.narration,
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const manifest = { count: entries.length, entries }
|
|
50
|
+
writeFileSync(outFile, JSON.stringify(manifest, null, 2) + '\n', 'utf-8')
|
|
51
|
+
|
|
52
|
+
// A short human summary — how many sections per course, so a bad fold is obvious at a glance.
|
|
53
|
+
const perCourse = {}
|
|
54
|
+
for (const e of entries) perCourse[e.course] = (perCourse[e.course] ?? 0) + 1
|
|
55
|
+
console.log(`Wrote ${entries.length} section(s) -> scripts/audio-manifest.json`)
|
|
56
|
+
for (const c of Object.values(COURSES))
|
|
57
|
+
console.log(` ${c.id.padEnd(12)} ${perCourse[c.id] ?? 0} section(s)`)
|