+ );
+}
diff --git a/packages/studio/src/components/panels/slideshowPanelHelpers.ts b/packages/studio/src/components/panels/slideshowPanelHelpers.ts
new file mode 100644
index 0000000000..24f454bcbe
--- /dev/null
+++ b/packages/studio/src/components/panels/slideshowPanelHelpers.ts
@@ -0,0 +1,195 @@
+/**
+ * Pure manifest-transform helpers for SlideshowPanel.
+ * No React, no side-effects — fully unit-testable.
+ */
+
+import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
+
+// ── Scene shape used by the panel UI ──────────────────────────────────────
+
+export interface SceneInfo {
+ id: string;
+ label: string;
+ start: number;
+ duration: number;
+}
+
+// ── Pure manifest transforms ───────────────────────────────────────────────
+
+/** Toggle a scene in the main-line slide list. */
+export function toggleMainLineSlide(
+ manifest: SlideshowManifest,
+ sceneId: string,
+): SlideshowManifest {
+ const exists = manifest.slides.some((s) => s.sceneId === sceneId);
+ const slides: SlideRef[] = exists
+ ? manifest.slides.filter((s) => s.sceneId !== sceneId)
+ : [...manifest.slides, { sceneId }];
+ return { ...manifest, slides };
+}
+
+// fallow-ignore-next-line complexity
+/** Move a main-line slide up or down by one position. */
+export function reorderMainLineSlide(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ direction: "up" | "down",
+): SlideshowManifest {
+ const idx = manifest.slides.findIndex((s) => s.sceneId === sceneId);
+ if (idx === -1) return manifest;
+ const next = direction === "up" ? idx - 1 : idx + 1;
+ if (next < 0 || next >= manifest.slides.length) return manifest;
+ const slides = [...manifest.slides];
+ const a = slides[idx];
+ const b = slides[next];
+ if (!a || !b) return manifest;
+ slides[idx] = b;
+ slides[next] = a;
+ return { ...manifest, slides };
+}
+
+/** Update notes on a main-line slide (adds slide entry if absent). */
+export function setSlideNotes(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ notes: string,
+): SlideshowManifest {
+ const exists = manifest.slides.some((s) => s.sceneId === sceneId);
+ const slides: SlideRef[] = exists
+ ? manifest.slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s))
+ : [...manifest.slides, { sceneId, notes }];
+ return { ...manifest, slides };
+}
+
+/** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
+export function addFragment(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ time: number,
+): SlideshowManifest {
+ const exists = manifest.slides.some((s) => s.sceneId === sceneId);
+ const slides: SlideRef[] = exists
+ ? manifest.slides.map((s) => {
+ if (s.sceneId !== sceneId) return s;
+ const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
+ return { ...s, fragments: frags };
+ })
+ : [...manifest.slides, { sceneId, fragments: [time] }];
+ return { ...manifest, slides };
+}
+
+/** Remove a fragment hold-point by value from a main-line slide. */
+export function removeFragment(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ time: number,
+): SlideshowManifest {
+ return {
+ ...manifest,
+ slides: manifest.slides.map((s) => {
+ if (s.sceneId !== sceneId) return s;
+ return { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) };
+ }),
+ };
+}
+
+/** Create a new branch sequence. Rejects duplicate ids. */
+export function createSequence(
+ manifest: SlideshowManifest,
+ id: string,
+ label: string,
+): SlideshowManifest {
+ const existing = manifest.slideSequences ?? [];
+ if (existing.some((seq) => seq.id === id)) return manifest;
+ return {
+ ...manifest,
+ slideSequences: [...existing, { id, label, slides: [] }],
+ };
+}
+
+/** Rename an existing branch sequence label. */
+export function renameSequence(
+ manifest: SlideshowManifest,
+ id: string,
+ label: string,
+): SlideshowManifest {
+ return {
+ ...manifest,
+ slideSequences: (manifest.slideSequences ?? []).map((seq) =>
+ seq.id === id ? { ...seq, label } : seq,
+ ),
+ };
+}
+
+function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
+ return slides.map((s) => {
+ if (!s.hotspots) return s;
+ const hotspots = s.hotspots.filter((h) => h.target !== targetId);
+ return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
+ });
+}
+
+/** Delete a branch sequence by id, removing any hotspot targeting it. */
+export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
+ const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
+ return {
+ ...manifest,
+ slides: pruneHotspots(manifest.slides, id),
+ slideSequences: remainingSequences.map((seq) => ({
+ ...seq,
+ slides: pruneHotspots(seq.slides, id),
+ })),
+ };
+}
+
+/** Add or remove a scene slide from a branch sequence. */
+export function assignToBranch(
+ manifest: SlideshowManifest,
+ sequenceId: string,
+ sceneId: string,
+ assign: boolean,
+): SlideshowManifest {
+ return {
+ ...manifest,
+ slideSequences: (manifest.slideSequences ?? []).map((seq) => {
+ if (seq.id !== sequenceId) return seq;
+ if (assign) {
+ if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
+ return { ...seq, slides: [...seq.slides, { sceneId }] };
+ }
+ return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
+ }),
+ };
+}
+
+/** Add a hotspot to a main-line slide. */
+export function addHotspot(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ hotspot: SlideHotspot,
+): SlideshowManifest {
+ return {
+ ...manifest,
+ slides: manifest.slides.map((s) => {
+ if (s.sceneId !== sceneId) return s;
+ const existing = s.hotspots ?? [];
+ if (existing.some((h) => h.id === hotspot.id)) return s;
+ return { ...s, hotspots: [...existing, hotspot] };
+ }),
+ };
+}
+
+/** Remove a hotspot by id from a main-line slide. */
+export function removeHotspot(
+ manifest: SlideshowManifest,
+ sceneId: string,
+ hotspotId: string,
+): SlideshowManifest {
+ return {
+ ...manifest,
+ slides: manifest.slides.map((s) => {
+ if (s.sceneId !== sceneId) return s;
+ return { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) };
+ }),
+ };
+}
diff --git a/packages/studio/src/hooks/useSlideshowPersist.ts b/packages/studio/src/hooks/useSlideshowPersist.ts
new file mode 100644
index 0000000000..b4a525946e
--- /dev/null
+++ b/packages/studio/src/hooks/useSlideshowPersist.ts
@@ -0,0 +1,68 @@
+import { useCallback, type MutableRefObject } from "react";
+import type { Composition } from "@hyperframes/sdk";
+import type { SlideshowManifest } from "@hyperframes/core/slideshow";
+import type { EditHistoryKind } from "../utils/editHistory";
+import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
+
+export interface UseSlideshowPersistParams {
+ sdkSession: Composition | null;
+ activeCompPath: string | null;
+ readProjectFile: (path: string) => Promise;
+ writeProjectFile: (path: string, content: string) => Promise;
+ recordEdit: (entry: {
+ label: string;
+ kind: EditHistoryKind;
+ files: Record;
+ }) => Promise;
+ reloadPreview: () => void;
+ domEditSaveTimestampRef: MutableRefObject;
+ /**
+ * When provided, rapid writes with the same key coalesce through the
+ * save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
+ * collapse to a single undo entry rather than polluting history.
+ * Pass e.g. `"slideshow-notes:" + activeCompPath` for the notes path.
+ */
+ coalesceKey?: string;
+}
+
+export function useSlideshowPersist({
+ sdkSession,
+ activeCompPath,
+ readProjectFile,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ coalesceKey,
+}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise {
+ return useCallback(
+ async (manifest: SlideshowManifest) => {
+ if (!sdkSession) return;
+ const path = activeCompPath ?? "index.html";
+ const originalContent = await readProjectFile(path);
+ await persistSlideshowManifest({
+ manifest,
+ sdkSession,
+ originalContent,
+ targetPath: path,
+ deps: {
+ editHistory: { recordEdit },
+ writeProjectFile,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ },
+ coalesceKey,
+ });
+ },
+ [
+ sdkSession,
+ activeCompPath,
+ readProjectFile,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ coalesceKey,
+ ],
+ );
+}
diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts
index 77c48c010e..b870c739a1 100644
--- a/packages/studio/src/utils/sdkCutover.ts
+++ b/packages/studio/src/utils/sdkCutover.ts
@@ -144,10 +144,11 @@ interface CutoverOptions {
skipRefresh?: boolean;
}
-// ponytail: internal; export only if a third caller appears.
+// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
+// the SDK dispatch path since ")).toBe(true);
+ });
+
+ // Fix 1: breakout test
+ it("does NOT embed a literal inside the JSON body", () => {
+ const manifest = { slides: [{ sceneId: "s1", notes: "x" }] };
+ const html = buildSlideshowIslandHtml(manifest);
+ // The only closing should be the real one at the very end.
+ // Strip that trailing tag and confirm no remains.
+ const withoutClosingTag = html.slice(0, html.lastIndexOf(""));
+ expect(withoutClosingTag).not.toContain("");
+ });
+
+ it("round-trips a manifest containing in notes via parseSlideshowManifest", () => {
+ const notes = "x";
+ const manifest = { slides: [{ sceneId: "s1", notes }] };
+ const html = `${buildSlideshowIslandHtml(manifest)}`;
+ const parsed = parseSlideshowManifest(html);
+ expect(parsed?.slides[0]).toMatchObject({ sceneId: "s1", notes });
+ });
+});
+
+describe("persistSlideshowManifest — op construction", () => {
+ function makeDeps(writeProjectFile: ReturnType): CutoverDeps {
+ return {
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
+ writeProjectFile,
+ reloadPreview: vi.fn(),
+ domEditSaveTimestampRef: { current: 0 },
+ };
+ }
+
+ it("writes the serialized manifest when the island already exists", async () => {
+ const { persistSlideshowManifest } = await import("./setSlideshowManifest");
+
+ const manifest = { slides: [{ sceneId: "scene-1" }] };
+ const island = buildSlideshowIslandHtml(manifest);
+ const originalHtml = `${island}`;
+
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
+ const deps = makeDeps(writeProjectFile);
+ const recordEdit = deps.editHistory.recordEdit as ReturnType;
+
+ const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
+
+ await persistSlideshowManifest({
+ manifest: { slides: [{ sceneId: "scene-2" }] },
+ sdkSession: mockSession as never,
+ originalContent: originalHtml,
+ targetPath: "/proj/comp.html",
+ deps,
+ });
+
+ expect(writeProjectFile).toHaveBeenCalledOnce();
+ const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
+ expect(written).toContain('"sceneId": "scene-2"');
+ expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit slideshow" }));
+ });
+
+ it("inserts the island when none exists in the serialized HTML", async () => {
+ const { persistSlideshowManifest } = await import("./setSlideshowManifest");
+
+ const baseHtml = "";
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
+ const deps = makeDeps(writeProjectFile);
+ const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
+
+ await persistSlideshowManifest({
+ manifest: { slides: [{ sceneId: "new-scene" }] },
+ sdkSession: mockSession as never,
+ originalContent: baseHtml,
+ targetPath: "/proj/comp.html",
+ deps,
+ });
+
+ expect(writeProjectFile).toHaveBeenCalledOnce();
+ const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
+ expect(written).toContain('"sceneId": "new-scene"');
+ expect(written).toContain('type="application/hyperframes-slideshow+json"');
+ });
+
+ // Fix 2: two stale islands should collapse to exactly one after persist
+ it("collapses two stale islands into exactly one after persist", async () => {
+ const { persistSlideshowManifest } = await import("./setSlideshowManifest");
+
+ const staleIsland1 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-1" }] });
+ const staleIsland2 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-2" }] });
+ const twoIslandHtml = `${staleIsland1}${staleIsland2}`;
+
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
+ const deps = makeDeps(writeProjectFile);
+ const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
+
+ await persistSlideshowManifest({
+ manifest: { slides: [{ sceneId: "fresh" }] },
+ sdkSession: mockSession as never,
+ originalContent: twoIslandHtml,
+ targetPath: "/proj/comp.html",
+ deps,
+ });
+
+ expect(writeProjectFile).toHaveBeenCalledOnce();
+ const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
+
+ // Count occurrences of the island script open tag
+ const islandCount = (written.match(/type="application\/hyperframes-slideshow\+json"/g) ?? [])
+ .length;
+ expect(islandCount).toBe(1);
+ expect(written).toContain('"sceneId": "fresh"');
+ expect(written).not.toContain('"sceneId": "old-1"');
+ expect(written).not.toContain('"sceneId": "old-2"');
+ });
+});
diff --git a/packages/studio/src/utils/setSlideshowManifest.ts b/packages/studio/src/utils/setSlideshowManifest.ts
new file mode 100644
index 0000000000..d7ddb4ae05
--- /dev/null
+++ b/packages/studio/src/utils/setSlideshowManifest.ts
@@ -0,0 +1,79 @@
+/**
+ * setSlideshowManifest — Studio persist helper for the slideshow JSON island.
+ *
+ * The island is a `
+// blocks (global + case-insensitive) so we can strip every stale island in one pass.
+const ISLAND_RE = new RegExp(
+ `` cannot
+ // break out of the script tag. JSON.parse round-trips > unchanged.
+ const json = JSON.stringify(manifest, null, 2).replace(//g, "\\u003e");
+ return ``;
+}
+
+export interface PersistSlideshowArgs {
+ manifest: SlideshowManifest;
+ /** Live SDK Composition session — used only to read the current serialized HTML. */
+ sdkSession: Pick;
+ /** Exact on-disk bytes for the undo-history `before` baseline. */
+ originalContent: string;
+ targetPath: string;
+ deps: CutoverDeps;
+ /** Optional label override (default: "Edit slideshow"). */
+ label?: string;
+ /**
+ * When provided, threads a coalesceKey into recordEdit so rapid writes
+ * (e.g. per-keystroke notes changes) collapse to a single undo entry.
+ */
+ coalesceKey?: string;
+}
+
+export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise {
+ const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
+
+ const islandHtml = buildSlideshowIslandHtml(manifest);
+ const current = sdkSession.serialize();
+
+ // Strip ALL existing islands (handles the case where two stale islands
+ // accumulated) then insert exactly one fresh island.
+ const stripped = current.replace(ISLAND_RE, "");
+
+ let after: string;
+ const bodyClose = stripped.lastIndexOf("
`, before the scene divs, so it is easy to find.
+
+---
+
+## Schema
+
+### `SlideshowManifest` (the top-level island object)
+
+```json
+{
+ "slides": [
+ /* SlideRef[] — the main line, in order */
+ ],
+ "slideSequences": [
+ /* SlideSequence[] — off-line branch sequences */
+ ]
+}
+```
+
+### `SlideRef`
+
+```json
+{
+ "sceneId": "problem",
+ "notes": "Lead with the pain, not the company.",
+ "fragments": [3.5, 5.2, 7.0],
+ "hotspots": [
+ /* SlideHotspot[] */
+ ],
+
+ "ttsScript": null,
+ "ttsAudioUrl": null,
+ "ttsDurationMs": null
+}
+```
+
+| Field | Required | Notes |
+| ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `sceneId` | yes | Must match a scene's `data-composition-id` exactly. The lint rule resolves both `data-composition-id` and `.clip[id]`. |
+| `notes` | no | Presenter-only text. Never shown to the audience. |
+| `fragments` | no | Array of times (seconds) within the slide's `[start, end]` range — see Fragments below. |
+| `hotspots` | no | Interactive overlays that trigger a branch — see Branching below. |
+| `startTime` | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
+| `endTime` | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
+| `ttsScript`, `ttsAudioUrl`, `ttsDurationMs` | no | **Reserved.** Schema fields exist but TTS playback is not yet wired. Omit unless you are pre-populating for a future build. |
+
+### `SlideHotspot`
+
+```json
+{
+ "id": "h1",
+ "label": "How did we calculate this?",
+ "target": "market-deep-dive",
+ "region": { "x": 60, "y": 10, "w": 35, "h": 20 }
+}
+```
+
+| Field | Required | Notes |
+| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `id` | yes | Unique within the slide. |
+| `label` | yes | Tooltip / button text shown to the audience. |
+| `target` | yes | Must match a `SlideSequence.id` in `slideSequences`. |
+| `region` | no | Percentage-of-slide bounding box: `{x, y, w, h}` in `0–100`. Omit to render the hotspot as a full-slide labeled button instead. |
+
+### `SlideSequence`
+
+```json
+{
+ "id": "market-deep-dive",
+ "label": "Market sizing methodology",
+ "slides": [{ "sceneId": "mkt-1" }, { "sceneId": "mkt-2" }]
+}
+```
+
+`slides` inside a sequence uses the same `SlideRef` shape as the main line. Fragments and nested hotspots are allowed.
+
+---
+
+## Slide writing rules
+
+These are hard constraints, not suggestions. A slide that violates them will be outright replaced when a reviewer sees it.
+
+- **Headline is a complete-sentence claim, not a label.** Write "SMBs spend 14 hours/week on manual scheduling" not "Scheduling problem". The sentence should stand alone if the visual is ignored.
+- **One idea + one visual per slide.** If you are tempted to add a second bullet cluster or a second chart, split the slide.
+- **Lead with the punchline.** The strongest point goes first — on the slide and in the deck order. Investors read left-to-right, top-to-bottom, and they stop.
+- **Bottom-up market sizing only.** Never write "$50B TAM" without showing the math. Build from unit economics up: accounts × ACV, or transactions × take-rate.
+- **Font minimum 30pt equivalent.** At 1920×1080, a headline is 72–96px; body copy is 48px. Never go below 40px for any text the audience must read.
+
+---
+
+## Fragments: reveal hold-points within a slide
+
+A fragment is a time (in seconds) within a slide's `[start, end]` range where the controller pauses before the next reveal.
+
+**How it works:**
+
+1. Player enters the slide — seeks to `start`, then plays.
+2. Controller pauses at `fragments[0]`. The first element's GSAP entrance has just landed.
+3. User presses Next (or →) — plays to `fragments[1]`, pauses again.
+4. After the last fragment, Next plays to `slide.end` and holds.
+5. Next again advances to the next slide.
+
+Fragment times must be strictly inside `[start, end]`. The lint rule rejects fragments outside that range.
+
+Fragment times are **absolute composition-timeline positions** — the same coordinate space as `data-start` — not offsets relative to the scene's start.
+
+Each fragment is a play-to-and-hold, not a seek jump — so every element that enters between the previous hold-point and this one plays its GSAP entrance animation. Design the clip entrance animations to work as sequential reveals.
+
+---
+
+## Branching: hotspots and slide sequences
+
+Branch slides are real scenes in the same composition timeline. They are listed only under `slideSequences` and are excluded from main-line navigation — the player never visits them unless a hotspot fires.
+
+**Navigation model:**
+
+- Clicking a hotspot pushes `{sequenceId, slideIndex: 0}` onto the nav stack and enters the branch's first slide.
+- **back()** pops the stack and returns to the exact parent slide (the one that held the hotspot).
+- **backToMain()** clears the entire stack and returns to the root slide.
+- Breadcrumb renders from the stack: `Main deck › Market sizing methodology › Slide 2`.
+- The slide counter inside a branch is scoped to that sequence (`1 of 2`, not the main-deck total).
+
+**What to avoid:**
+
+- Do not add branch scene IDs to the main `slides` array. They must appear only inside a `slideSequences` entry. The lint rule flags overlap.
+- Branch scenes are included in the continuous timeline, so a naive linear video export would include them. Export reads main-line slides only (deferred; flagged in the spec).
+
+---
+
+## Worked example: 3-slide deck with fragments and a branch
+
+### Scene HTML (skeleton)
+
+```html
+
+
+
+
+
+
+
+ SMBs lose $40B/year to manual scheduling
+
+
+
+
+
+
+
+
+ Three gaps operators can not close
+
+
+ No-shows cost 23% of booked revenue
+
+
+ Manual reminders take 4h/week per staff
+
+
+ Rescheduling friction drives 40% churn
+
+
+
+
+
+
+
+
+ Acme automates scheduling for service SMBs — no-shows down 80% in 90 days
+
+
+
+
+```
+
+### Key points in the example
+
+- The island `sceneId` values (`"hook"`, `"problem"`, `"solution"`, `"mkt-math"`) exactly match `data-composition-id` values on scene divs.
+- `mkt-math` appears only in `slideSequences` — it is never in the top-level `slides` array.
+- Fragment times (`11.0`, `15.0`) are within the `problem` scene's `[6, 21]` range (times are absolute composition-timeline positions).
+- The hotspot `region` (`x: 55, y: 60, w: 40, h: 20`) positions the clickable area in the lower-right quadrant of the problem slide.
+- GSAP timelines are registered on `window.__timelines` and are paused — the HyperFrames engine drives playback; do not call `.play()` at construction time.
+
+---
+
+## Wrapping component
+
+Wrap the composition in `` around `` in any embedding context:
+
+```html
+
+
+
+```
+
+`` provides the navigation chrome (Prev / Next buttons, progress dots, breadcrumb, counter), keyboard handling (← / → and Space / Backspace), touch swipe, and hotspot overlays.
+
+**Presenter mode:** the Present button calls `window.open('?mode=audience')` for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via `BroadcastChannel('hf-slideshow')`.
+
+---
+
+## Running a slideshow standalone (interim)
+
+The **durable answer** is engine-hosted: `hyperframes preview --slideshow` / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines, owns the gesture frame, and reads the island from the composition. That path is coming; prefer it once it ships.
+
+Until then, standalone demos (a composition opened via the bare player bundle in a browser, without the engine) require workarounds for four gaps: the player does not drive GSAP seek-timelines, the island must be duplicated into the wrapper, audio must live in the parent frame, and animations must be self-driving. These patterns are documented in:
+
+```
+skills/slideshow/references/standalone-harness.md
+```
+
+Do not treat the patterns there as the blessed model — they exist only to bridge the gap until the engine-hosted path lands.
+
+---
+
+## Validation
+
+After authoring or editing a slideshow composition, run:
+
+```bash
+npx hyperframes lint
+```
+
+The slideshow lint rule checks:
+
+- Every `slide.sceneId` resolves to an existing scene (by `data-composition-id`).
+- Every `hotspot.target` references a defined `slideSequence` id.
+- Fragment times fall within each slide's `[start, end]` range.
+- No two main-line slides overlap in time.
+
+Fix all violations before previewing. A composition that fails lint will not parse correctly in the player.
diff --git a/skills/slideshow/references/standalone-harness.md b/skills/slideshow/references/standalone-harness.md
new file mode 100644
index 0000000000..cef61303e2
--- /dev/null
+++ b/skills/slideshow/references/standalone-harness.md
@@ -0,0 +1,597 @@
+# Standalone HyperFrames Slideshow Harness
+
+## 1. Interim framing — why this exists
+
+These patterns are a **temporary workaround** for standalone demos. The durable solution is engine-hosted: a future `hyperframes preview --slideshow` / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines frame-by-frame, owns the gesture frame, and reads the slideshow island directly from the composition. When that path ships, most of what follows collapses.
+
+Until then, a standalone slideshow opened via the bare player bundle must work around four facts:
+
+1. The bare `` does **not** drive GSAP/Three seek-timelines frame-by-frame — only the engine does. Animations that wait to be seeked stay at frame 0.
+2. `` reads the slideshow island from its **own innerHTML** (the wrapper element), not from the composition the player loads. The island must be duplicated into the wrapper.
+3. The composition runs in the player's **iframe**; user keypresses and pointer events land on the **parent page**. Any gesture-gated API (Audio, AudioContext) must live in the parent — an iframe without its own user activation is permanently autoplay-blocked.
+4. Autoplay, Three.js, and entrance animations must be **self-driving** because the engine is not present.
+
+Do not treat these as the blessed authoring model. When the engine-hosted path ships, compositions authored the normal way will just work.
+
+**Living reference implementations:**
+
+- `registry/examples/airbnb-deck/index.html` + `demo.html` — full pattern set (Three.js, fragments, SFX, branch slide)
+- `registry/examples/startup-pitch/index.html` — minimal version (no 3D), good starting point
+
+---
+
+## 2. The parent wrapper (demo.html)
+
+The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
+
+```html
+
+
+
+
+
+ My Deck — Slideshow Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 3. Playhead-driven scene visibility
+
+Without the engine, scenes are driven by a `root` GSAP timeline that the composition manages on its own clock. The visibility controller reads `window.__timelines.root.time()` via that timeline's `onUpdate` callback and sets `opacity` accordingly. Only the active scene is visible.
+
+The key insight: scene backgrounds must be `transparent` (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline `background` set the visual fill.
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 4. Imperative entrances on slide-activate
+
+The engine-hosted path drives GSAP seek-timelines frame by frame. Without it, seek-timeline tweens never fire. Instead, fire imperative `gsap.from()` calls each time a scene becomes active — these run on GSAP's own ticker and are independent of any playhead.
+
+Fragment reveals use playhead-crossing: the visibility controller checks whether the playhead has passed each fragment's hold-time and fires an animation on the first crossing. Bunch fragment hold-times near the scene start (within the first 300–500 ms of the scene) so successive ArrowRight presses feel like snappy sequential reveals rather than long waits.
+
+```js
+// --- Entrance animations ---
+
+function fireEntrance(sceneEl) {
+ // [data-anim] marks elements that should entrance on slide-activate.
+ // Add data-anim to eyebrows, headlines, subheads, and card grids.
+ var animEls = sceneEl.querySelectorAll("[data-anim]");
+ if (!animEls.length) return;
+ gsap.from(animEls, {
+ opacity: 0,
+ y: 28,
+ duration: 0.4,
+ stagger: 0.07,
+ ease: "power2.out",
+ overwrite: true, // cancel any in-flight animation on rapid slide changes
+ });
+}
+
+// --- Fragment reveals ---
+
+// Fragment config: times in absolute composition timeline seconds,
+// bunched near the scene start for snappy successive reveals.
+var fragments = [
+ { time: 9.3, id: "prob-item1", revealed: false },
+ { time: 9.6, id: "prob-item2", revealed: false },
+];
+
+function revealFragment(id) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ gsap.fromTo(
+ el,
+ { opacity: 0, x: -24 },
+ { opacity: 1, x: 0, duration: 0.35, ease: "power2.out", overwrite: true },
+ );
+}
+
+// Inside updateVisibility(t):
+for (var f = 0; f < fragments.length; f++) {
+ if (!fragments[f].revealed && t >= fragments[f].time) {
+ fragments[f].revealed = true;
+ revealFragment(fragments[f].id);
+ }
+}
+
+// On problem scene re-entry, reset all fragment states:
+if (active && lastActiveId !== s.id && s.id === "scene-problem") {
+ for (var f = 0; f < fragments.length; f++) {
+ fragments[f].revealed = false;
+ var pEl = document.getElementById(fragments[f].id);
+ if (pEl) gsap.set(pEl, { opacity: 0, clearProps: "transform" });
+ }
+}
+```
+
+Fragment items start with `opacity: 0` in CSS. The visibility controller reveals them; the entrance driver does not touch them until crossing.
+
+---
+
+## 5. The scenes bootstrap postMessage
+
+`` must know each scene's time range to map a `sceneId` to a playhead position. Without the engine injecting this at runtime, the composition must post it manually after load.
+
+Post the manifest from the composition (index.html), not the parent wrapper:
+
+```js
+// In index.html — post after a brief delay so the parent frame has settled
+(function () {
+ var FPS = 30;
+ var totalSeconds = 108; // match your composition's data-duration
+ var totalFrames = totalSeconds * FPS;
+
+ var scenes = [
+ // EVERY scene — including branch scenes — must appear here.
+ // id must match data-composition-id; start/duration in seconds.
+ { id: "cover", start: 0, duration: 9 },
+ { id: "problem", start: 9, duration: 9 },
+ { id: "solution", start: 18, duration: 9 },
+ // ... all main-line scenes ...
+ // branch scene — listed last, NOT in main slides array in the island
+ { id: "market-sizing", start: 99, duration: 9 },
+ ];
+
+ function postTimeline() {
+ parent.postMessage(
+ {
+ source: "hf-preview",
+ type: "timeline",
+ durationInFrames: totalFrames,
+ scenes: scenes,
+ },
+ "*",
+ );
+ }
+
+ // ~300ms delay after load to let the parent settle
+ if (document.readyState === "complete") {
+ setTimeout(postTimeline, 300);
+ } else {
+ window.addEventListener("load", function () {
+ setTimeout(postTimeline, 300);
+ });
+ }
+})();
+```
+
+Omitting any scene (including branch scenes) from this manifest means the slideshow component cannot seek to it. Include every scene declared in the HTML, even scenes only reachable via a hotspot.
+
+---
+
+## 6. Audio/SFX — built-in mute control via ``
+
+Audio **must** live in the parent page, not the composition iframe. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is permanently autoplay-blocked. The user's keypress lands on the parent, so the parent is the only frame that can get the activation token.
+
+### Mute toggle — built-in chrome control
+
+Add the `sound` boolean attribute to `` in demo.html. The component renders a speaker/speaker-muted SVG button as the **leftmost item in the nav capsule**, styled identically to the prev/next ghost buttons. No separate mute button in the composition.
+
+```html
+ ...
+```
+
+The component:
+
+- Tracks `muted` state (default `false`); exposes a `muted` getter
+- Reflects to a `data-hf-muted` attribute on the host when muted
+- Dispatches `CustomEvent("hf-sound", { detail: { muted }, bubbles: true, composed: true })` on every toggle
+
+The parent audio player gates on the `hf-sound` event:
+
+```js
+var muted = false;
+var slideshow = document.querySelector("hyperframes-slideshow");
+if (slideshow) {
+ slideshow.addEventListener("hf-sound", function (e) {
+ muted = e.detail && e.detail.muted === true;
+ });
+}
+// In message handler:
+if (muted) return; // skip play
+```
+
+If `sound` is **not** present on `` (decks without audio), the mute control is hidden — the capsule shows only nav.
+
+### Composition: post cues unconditionally
+
+The composition posts sfx cues **unconditionally** — it does not track mute state. The parent gates on `muted`:
+
+**In the composition (index.html):**
+
+```js
+// Post an sfx cue at transition points — unconditionally.
+// The parent audio player gates on the slideshow component's mute state.
+function playSfx(name) {
+ try {
+ parent.postMessage({ type: "hf-sfx", name: name }, "*");
+ } catch (e) {}
+}
+
+// Fire at scene transitions:
+// playSfx("advance") — moving to the next main-line slide
+// playSfx("back") — returning from a branch
+// playSfx("branch-enter") — entering a branch
+// playSfx("fragment") — a fragment item is revealed
+```
+
+Do NOT add a mute button inside the composition. The `#sfx-mute` coral button pattern is removed; the nav capsule in the parent chrome owns mute.
+
+**In the parent (demo.html):**
+
+```html
+
+```
+
+**Sourcing SFX files:** use the HeyGen MCP `search_audio_sounds` tool with `type=sound_effects` and keywords like "whoosh", "click", "transition". Download the results to a local `sfx/` directory next to `demo.html` and reference them by relative path. Do not fetch SFX at render time — the HyperFrames determinism rule forbids runtime network requests; pre-download and commit them.
+
+---
+
+## 7. Three.js (optional)
+
+Add a Three.js scene behind the slides for ambient motion. The key rules:
+
+- **Own rAF loop** — do not integrate with the HF seek timeline. Three.js runs its own `requestAnimationFrame` loop independent of playhead position.
+- **One persistent canvas** — create the canvas once; update geometry/materials in-place per scene.
+- **Guard renderer creation** — WebGL may be unavailable (software-GL environments, some CI contexts). Create the renderer inside try/catch once; if it fails, hide the canvas and expose no-op stubs. Do not spam `console.error` — silence it during creation and restore it in `finally`.
+- **Full-bleed, behind content** — fix the canvas at `z-index: 0`, `pointer-events: none`, behind scene frames at `z-index: 1`.
+- **Transparent scene frames** — set scene backgrounds to `transparent` so the 3D canvas shows through. Use a radial-gradient scrim on the text container (not the scene frame itself) to keep type legible while letting 3D show in the margins.
+- **Expose a mood hook** — export `window.__threeApplyMood(sceneKey)` so the visibility controller can switch particle colors, toggle sub-objects, or change the clear color when the active scene changes.
+
+```js
+// In index.html — Three.js setup (module script)
+import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
+
+var canvas = document.getElementById("three-canvas");
+var renderer = null;
+var _err = console.error;
+console.error = function () {}; // silence THREE's multi-line GPU error during init
+try {
+ renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
+} catch (e) {
+ // renderer stays null
+} finally {
+ console.error = _err;
+}
+
+if (!renderer) {
+ // Graceful degradation — branded layout is the fallback.
+ canvas.style.display = "none";
+ window.__threeApplyMood = function () {};
+ // Do NOT start the rAF loop.
+} else {
+ renderer.setSize(1920, 1080);
+ renderer.setPixelRatio(1);
+ canvas.style.cssText =
+ "position:fixed;top:0;left:0;width:1920px;height:1080px;z-index:0;pointer-events:none";
+
+ var scene = new THREE.Scene();
+ var camera = new THREE.PerspectiveCamera(60, 1920 / 1080, 0.1, 1000);
+ camera.position.set(0, 0, 5);
+
+ // --- build your particle system, meshes, etc. here ---
+
+ // Mood config: map sceneId → visual state (colors, sub-object visibility, bg color)
+ var MOODS = {
+ cover: {
+ /* particle color, opacity, bg ... */
+ },
+ problem: {
+ /* ... */
+ },
+ // one entry per scene key
+ };
+
+ window.__threeApplyMood = function (sceneKey) {
+ var m = MOODS[sceneKey] || MOODS["cover"];
+ // update geometry attributes, material opacity, sub-group visibility, etc.
+ };
+ window.__threeApplyMood("cover"); // apply initial state
+
+ // --- own rAF loop ---
+ var lastTime = null;
+ function animate(ts) {
+ requestAnimationFrame(animate);
+ if (lastTime === null) lastTime = ts;
+ var dt = Math.min((ts - lastTime) / 1000, 0.05);
+ lastTime = ts;
+ // update particles, rotate objects, etc.
+ renderer.render(scene, camera);
+ }
+ requestAnimationFrame(animate);
+}
+```
+
+**CSS for transparent scene frames + scrim:**
+
+```css
+/* Three.js canvas — always behind everything */
+#three-canvas {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 1920px;
+ height: 1080px;
+ z-index: 0;
+ pointer-events: none;
+}
+
+/* Scene frames are transparent so the 3D canvas shows through */
+.scene-frame {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 1920px;
+ height: 1080px;
+ background: transparent; /* NOT opaque — 3D would be occluded */
+ z-index: 1;
+}
+
+/* Scrim on the TEXT container — not the scene frame.
+ Radial gradient: opaque in the center where text is, transparent at edges
+ so 3D shows in the whitespace margins. */
+.slide-inner.scrim-light {
+ background: radial-gradient(
+ ellipse 75% 80% at 50% 50%,
+ rgba(255, 255, 255, 0.88) 30%,
+ rgba(255, 255, 255, 0.6) 65%,
+ rgba(255, 255, 255, 0) 100%
+ );
+}
+```
+
+---
+
+## 8. Foot-gun checklist
+
+| Failure | Symptom | One-line fix |
+| ----------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `