Skill Tracer
Skills › HyperFrames › slideshow › standalone-harness.md

standalone-harness.md

1017 lines · skills/slideshow/references/standalone-harness.md at a595755

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Standalone HyperFrames Slideshow Harness
2

Standalone HyperFrames Slideshow Harness

Raw lines 1 to 2
3## 1. Interim framing — why this exists
4
5These 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.
6
7Until then, a standalone slideshow opened via the bare player bundle must work around three facts:
8
91. The composition must expose a seekable `window.__timelines.root` timeline. Anything outside that seek path, such as Three.js loops or imperative entrance effects, must be self-driving.
102. `<hyperframes-slideshow>` 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.
113. The composition runs in the player's **iframe**; user keypresses and pointer events land on the **parent page**. Wrapper-owned SFX/global audio should live in the parent, where the activation token is reliable. Normal slide media stays in the composition and is stopped by the slideshow player on slide exit.
13Do not treat these as the blessed authoring model. When the engine-hosted path ships, compositions authored the normal way will just work.
15**Living reference implementations:**
17---

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 three facts:

  1. The composition must expose a seekable window.__timelines.root timeline. Anything outside that seek path, such as Three.js loops or imperative entrance effects, must be self-driving.
  2. <hyperframes-slideshow> 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. Wrapper-owned SFX/global audio should live in the parent, where the activation token is reliable. Normal slide media stays in the composition and is stopped by the slideshow player on slide exit.

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:


Raw lines 3 to 18
19## 2. The parent wrapper (`index.html` for deliverables, `demo.html` in examples)
21The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
23For public or user-facing generated projects, make this wrapper the root `index.html` so opening the project in a browser runs the slideshow. Put the raw HyperFrames composition in a separate path such as `composition/index.html`. In repo examples you may still see this file called `demo.html`; that name is a reference pattern, not the preferred handoff for a standalone deck.
25```html
26<!doctype html>
27<html lang="en">
28 <head>
29 <meta charset="UTF-8" />
30 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
31 <title>My Deck — Slideshow Demo</title>
33 <!--
34 Load both bundles from packages/player/dist.
35 The global builds register <hyperframes-player> and <hyperframes-slideshow>
36 as custom elements — no import map needed.
37 -->
38 <script src="../../../packages/player/dist/hyperframes-player.global.js"></script>
39 <script src="../../../packages/player/dist/slideshow/hyperframes-slideshow.global.js"></script>
41 <style>
42 *,
43 *::before,
44 *::after {
45 box-sizing: border-box;
46 margin: 0;
47 padding: 0;
48 }
49 html,
50 body {
51 width: 100%;
52 height: 100%;
53 overflow: hidden;
54 background: #111;
55 }
56 </style>
57 </head>
58 <body>
59 <!--
60 tabindex="0" is critical — <hyperframes-slideshow> binds keydown
61 (ArrowLeft/Right, Space, Backspace) to itself. Without tabindex the
62 element cannot receive focus and arrow keys are dead.
63 -->
64 <hyperframes-slideshow
65 tabindex="0"
66 style="display: block; position: relative; width: 100vw; height: 100vh"
67 >
68 <hyperframes-player
69 interactive
70 style="position: absolute; inset: 0"
71 src="composition/index.html"
72 ></hyperframes-player>
74 <!--
75 DUPLICATED ISLAND — keep in sync with the island inside index.html.
76 <hyperframes-slideshow> reads from its own innerHTML, not from the
77 composition the player loads. Every time slides/fragments/hotspots/
78 sequences change in index.html, update this copy too.
79 -->
80 <script type="application/hyperframes-slideshow+json">
81 {
82 "slides": [
83 { "sceneId": "scene-one", "notes": "..." },
84 {
85 "sceneId": "scene-two",
86 "notes": "...",
87 "fragments": [8.3, 8.6]
88 }
89 ],
90 "slideSequences": [
91 {
92 "id": "branch-one",
93 "label": "Branch label",
94 "slides": [{ "sceneId": "branch-scene", "notes": "..." }]
95 }
96 ]
97 }
98 </script>
99 </hyperframes-slideshow>
100 <!-- The built-in slideshow nav capsule renders Present; do not add a wrapper-level button. -->
102 <!-- Audio player lives here — see Section 6 -->
103 </body>
104</html>
105```
107`interactive` is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction.

2. The parent wrapper (index.html for deliverables, demo.html in examples)

The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.

For public or user-facing generated projects, make this wrapper the root index.html so opening the project in a browser runs the slideshow. Put the raw HyperFrames composition in a separate path such as composition/index.html. In repo examples you may still see this file called demo.html; that name is a reference pattern, not the preferred handoff for a standalone deck.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Deck — Slideshow Demo</title>

    <!--
      Load both bundles from packages/player/dist.
      The global builds register <hyperframes-player> and <hyperframes-slideshow>
      as custom elements — no import map needed.
    -->
    <script src="../../../packages/player/dist/hyperframes-player.global.js"></script>
    <script src="../../../packages/player/dist/slideshow/hyperframes-slideshow.global.js"></script>

    <style>
      *,
      *::before,
      *::after {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }
      html,
      body {
        width: 100%;
        height: 100%;
        overflow: hidden;
        background: #111;
      }
    </style>
  </head>
  <body>
    <!--
      tabindex="0" is critical — <hyperframes-slideshow> binds keydown
      (ArrowLeft/Right, Space, Backspace) to itself. Without tabindex the
      element cannot receive focus and arrow keys are dead.
    -->
    <hyperframes-slideshow
      tabindex="0"
      style="display: block; position: relative; width: 100vw; height: 100vh"
    >
      <hyperframes-player
        interactive
        style="position: absolute; inset: 0"
        src="composition/index.html"
      ></hyperframes-player>

      <!--
        DUPLICATED ISLAND — keep in sync with the island inside index.html.
        <hyperframes-slideshow> reads from its own innerHTML, not from the
        composition the player loads. Every time slides/fragments/hotspots/
        sequences change in index.html, update this copy too.
      -->
      <script type="application/hyperframes-slideshow+json">
        {
          "slides": [
            { "sceneId": "scene-one", "notes": "..." },
            {
              "sceneId": "scene-two",
              "notes": "...",
              "fragments": [8.3, 8.6]
            }
          ],
          "slideSequences": [
            {
              "id": "branch-one",
              "label": "Branch label",
              "slides": [{ "sceneId": "branch-scene", "notes": "..." }]
            }
          ]
        }
      </script>
    </hyperframes-slideshow>
    <!-- The built-in slideshow nav capsule renders Present; do not add a wrapper-level button. -->

    <!-- Audio player lives here — see Section 6 -->
  </body>
</html>

interactive is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction.

Raw lines 19 to 108
109### Per-slide `autoplay`
111Add `"autoplay": true` to a slide in the island to play that slide's first `<video>` from the start when the presenter lands on it. The slideshow still holds — it never auto-advances — so the presenter clicks Next when ready; autoplay only saves a manual play click into the composition.
113```json
115 "sceneId": "cold-open",
116 "autoplay": true,
117 "notes": "Promo plays on enter; click Next when it ends."
119```
121Use `autoplay` when the video **is** the slide's primary content and its natural end is the cue to advance — a cold-open promo, a demo clip you let run and then move on from. Do **not** use it for background/ambient loops or for footage the presenter talks over; those should start on the presenter's own cue (a click on the clip's controls via `interactive`), not automatically. One autoplay clip per slide (the first `<video>` in the scene).

Per-slide autoplay

Add "autoplay": true to a slide in the island to play that slide's first <video> from the start when the presenter lands on it. The slideshow still holds — it never auto-advances — so the presenter clicks Next when ready; autoplay only saves a manual play click into the composition.

{
  "sceneId": "cold-open",
  "autoplay": true,
  "notes": "Promo plays on enter; click Next when it ends."
}

Use autoplay when the video is the slide's primary content and its natural end is the cue to advance — a cold-open promo, a demo clip you let run and then move on from. Do not use it for background/ambient loops or for footage the presenter talks over; those should start on the presenter's own cue (a click on the clip's controls via interactive), not automatically. One autoplay clip per slide (the first <video> in the scene).

Raw lines 109 to 122
123### Presenter media bridge for interactive media
125Presenter/audience mode syncs slide position through a deck-scoped `BroadcastChannel`. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state.
127Audible playback has one extra browser constraint: a `BroadcastChannel` message does not carry the presenter's user activation into the audience window. The audience window should try presenter-driven playback muted first, because browsers usually allow muted autoplay; it may still reject `media.play()` even while it accepts remote `currentTime` updates. If that happens, do not keep chasing presenter `timeupdate` messages; show an audience-side unlock control, store the latest play intent, and retry playback from that intent after the audience window receives a click/key gesture.
129```js
130(function () {
131 if (typeof BroadcastChannel === "undefined") return;
133 var sender =
134 new URLSearchParams(location.search).get("mode") === "audience" ? "audience" : "presenter";
135 var channel = new BroadcastChannel("hf-slideshow:" + location.pathname);
136 var applyingRemote = false;
137 var lastTimeBroadcast = 0;
138 var pendingPlayByKey = {};
139 var blockedPlayByKey = {};
140 var mutedPlaybackByKey = {};
141 var unlockButton = null;
143 function frameDocument() {
144 var player = document.querySelector("hyperframes-player");
145 var frame = player && player.iframeElement;
146 try {
147 return frame && frame.contentDocument ? frame.contentDocument : null;
148 } catch {
149 return null;
150 }
151 }
152 function mediaNodes() {
153 var doc = frameDocument();
154 return doc ? Array.from(doc.querySelectorAll("video,audio")) : [];
155 }
156 function mediaKey(el, index) {
157 return el.id ? "id:" + el.id : el.tagName.toLowerCase() + ":" + index;
158 }
159 function findMedia(key) {
160 return mediaNodes().find(function (el, index) {
161 return mediaKey(el, index) === key;
162 });
163 }
164 function syncMediaState(el, msg, allowTimeSync) {
165 if (typeof msg.playbackRate === "number") el.playbackRate = msg.playbackRate;
166 if (typeof msg.volume === "number") el.volume = Math.max(0, Math.min(1, msg.volume));
167 if (sender === "audience" && mutedPlaybackByKey[msg.key]) {
168 el.muted = true;
169 } else if (typeof msg.muted === "boolean") {
170 el.muted = msg.muted;
171 }
172 if (
173 allowTimeSync &&
174 typeof msg.currentTime === "number" &&
175 Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35
176 ) {
177 el.currentTime = Math.max(0, msg.currentTime);
178 }
179 }
180 function hasBlockedPlay() {
181 return Object.keys(blockedPlayByKey).length > 0;
182 }
183 function hideAudienceUnlockIfClear() {
184 if (hasBlockedPlay() || !unlockButton) return;
185 unlockButton.remove();
186 unlockButton = null;
187 }
188 function showAudienceUnlock() {
189 if (sender !== "audience" || unlockButton) return;
190 unlockButton = document.createElement("button");
191 unlockButton.type = "button";
192 unlockButton.textContent = "Enable audience media";
193 unlockButton.style.cssText =
194 "position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;";
195 unlockButton.addEventListener("click", retryBlockedPlays);
196 document.body.appendChild(unlockButton);
197 }
198 function rememberBlockedPlay(msg) {
199 pendingPlayByKey[msg.key] = msg;
200 blockedPlayByKey[msg.key] = true;
201 showAudienceUnlock();
202 }
203 function clearBlockedPlay(key) {
204 delete blockedPlayByKey[key];
205 hideAudienceUnlockIfClear();
206 }
207 function tryPlay(el, msg) {
208 if (sender === "audience") mutedPlaybackByKey[msg.key] = true;
209 syncMediaState(el, msg, true);
210 if (sender === "audience") el.muted = true;
211 try {
212 var playResult = el.play();
213 if (playResult && typeof playResult.then === "function") {
214 playResult
215 .then(function () {
216 clearBlockedPlay(msg.key);
217 })
218 .catch(function () {
219 rememberBlockedPlay(msg);
220 });
221 } else {
222 clearBlockedPlay(msg.key);
223 }
224 } catch (e) {
225 rememberBlockedPlay(msg);
226 }
227 }
228 function retryBlockedPlays() {
229 wireMedia();
230 applyingRemote = true;
231 try {
232 Object.keys(pendingPlayByKey).forEach(function (key) {
233 var msg = pendingPlayByKey[key];
234 var el = findMedia(key);
235 if (el && msg) tryPlay(el, msg);
236 });
237 } finally {
238 setTimeout(function () {
239 applyingRemote = false;
240 }, 300);
241 }
242 }
243 function publish(el, index, action) {
244 if (sender !== "presenter") return;
245 if (applyingRemote) return;
246 if (action === "timeupdate") {
247 var now = performance.now();
248 if (now - lastTimeBroadcast < 450 && !el.paused) return;
249 lastTimeBroadcast = now;
250 }
251 channel.postMessage({
252 type: "media",
253 sender,
254 key: mediaKey(el, index),
255 action,
256 currentTime: el.currentTime || 0,
257 paused: el.paused,
258 ended: el.ended,
259 muted: el.muted,
260 volume: el.volume,
261 playbackRate: el.playbackRate,
262 });
263 }
264 function wireMedia() {
265 mediaNodes().forEach(function (el, index) {
266 if (el.dataset.hfPresenterMediaSync === "1") return;
267 el.dataset.hfPresenterMediaSync = "1";
268 [
269 "play",
270 "pause",
271 "seeking",
272 "seeked",
273 "ratechange",
274 "volumechange",
275 "ended",
276 "timeupdate",
277 ].forEach(function (name) {
278 el.addEventListener(name, function () {
279 publish(el, index, name);
280 });
281 });
282 });
283 }
284 channel.addEventListener("message", function (event) {
285 var msg = event.data;
286 if (!msg || msg.type !== "media" || msg.sender === sender) return;
287 if (sender === "audience" && blockedPlayByKey[msg.key] && msg.action === "timeupdate") {
288 pendingPlayByKey[msg.key] = msg;
289 showAudienceUnlock();
290 return;
291 }
292 var el = findMedia(msg.key);
293 if (!el) return;
294 applyingRemote = true;
295 try {
296 if (
297 msg.action === "play" ||
298 (sender === "audience" && msg.action === "timeupdate" && msg.paused === false && el.paused)
299 ) {
300 pendingPlayByKey[msg.key] = msg;
301 tryPlay(el, msg);
302 } else {
303 syncMediaState(el, msg, true);
304 }
305 if (msg.action === "pause" || msg.action === "ended") {
306 delete pendingPlayByKey[msg.key];
307 delete mutedPlaybackByKey[msg.key];
308 clearBlockedPlay(msg.key);
309 el.pause();
310 }
311 } catch {
312 } finally {
313 setTimeout(function () {
314 applyingRemote = false;
315 }, 300);
316 }
317 });
318 wireMedia();
319 window.addEventListener("load", wireMedia);
320 window.addEventListener("keydown", retryBlockedPlays, true);
321 window.addEventListener("pointerdown", retryBlockedPlays, true);
322 setInterval(wireMedia, 1000);
323})();
324```

Presenter media bridge for interactive media

Presenter/audience mode syncs slide position through a deck-scoped BroadcastChannel. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state.

Audible playback has one extra browser constraint: a BroadcastChannel message does not carry the presenter's user activation into the audience window. The audience window should try presenter-driven playback muted first, because browsers usually allow muted autoplay; it may still reject media.play() even while it accepts remote currentTime updates. If that happens, do not keep chasing presenter timeupdate messages; show an audience-side unlock control, store the latest play intent, and retry playback from that intent after the audience window receives a click/key gesture.

(function () {
  if (typeof BroadcastChannel === "undefined") return;

  var sender =
    new URLSearchParams(location.search).get("mode") === "audience" ? "audience" : "presenter";
  var channel = new BroadcastChannel("hf-slideshow:" + location.pathname);
  var applyingRemote = false;
  var lastTimeBroadcast = 0;
  var pendingPlayByKey = {};
  var blockedPlayByKey = {};
  var mutedPlaybackByKey = {};
  var unlockButton = null;

  function frameDocument() {
    var player = document.querySelector("hyperframes-player");
    var frame = player && player.iframeElement;
    try {
      return frame && frame.contentDocument ? frame.contentDocument : null;
    } catch {
      return null;
    }
  }
  function mediaNodes() {
    var doc = frameDocument();
    return doc ? Array.from(doc.querySelectorAll("video,audio")) : [];
  }
  function mediaKey(el, index) {
    return el.id ? "id:" + el.id : el.tagName.toLowerCase() + ":" + index;
  }
  function findMedia(key) {
    return mediaNodes().find(function (el, index) {
      return mediaKey(el, index) === key;
    });
  }
  function syncMediaState(el, msg, allowTimeSync) {
    if (typeof msg.playbackRate === "number") el.playbackRate = msg.playbackRate;
    if (typeof msg.volume === "number") el.volume = Math.max(0, Math.min(1, msg.volume));
    if (sender === "audience" && mutedPlaybackByKey[msg.key]) {
      el.muted = true;
    } else if (typeof msg.muted === "boolean") {
      el.muted = msg.muted;
    }
    if (
      allowTimeSync &&
      typeof msg.currentTime === "number" &&
      Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35
    ) {
      el.currentTime = Math.max(0, msg.currentTime);
    }
  }
  function hasBlockedPlay() {
    return Object.keys(blockedPlayByKey).length > 0;
  }
  function hideAudienceUnlockIfClear() {
    if (hasBlockedPlay() || !unlockButton) return;
    unlockButton.remove();
    unlockButton = null;
  }
  function showAudienceUnlock() {
    if (sender !== "audience" || unlockButton) return;
    unlockButton = document.createElement("button");
    unlockButton.type = "button";
    unlockButton.textContent = "Enable audience media";
    unlockButton.style.cssText =
      "position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;";
    unlockButton.addEventListener("click", retryBlockedPlays);
    document.body.appendChild(unlockButton);
  }
  function rememberBlockedPlay(msg) {
    pendingPlayByKey[msg.key] = msg;
    blockedPlayByKey[msg.key] = true;
    showAudienceUnlock();
  }
  function clearBlockedPlay(key) {
    delete blockedPlayByKey[key];
    hideAudienceUnlockIfClear();
  }
  function tryPlay(el, msg) {
    if (sender === "audience") mutedPlaybackByKey[msg.key] = true;
    syncMediaState(el, msg, true);
    if (sender === "audience") el.muted = true;
    try {
      var playResult = el.play();
      if (playResult && typeof playResult.then === "function") {
        playResult
          .then(function () {
            clearBlockedPlay(msg.key);
          })
          .catch(function () {
            rememberBlockedPlay(msg);
          });
      } else {
        clearBlockedPlay(msg.key);
      }
    } catch (e) {
      rememberBlockedPlay(msg);
    }
  }
  function retryBlockedPlays() {
    wireMedia();
    applyingRemote = true;
    try {
      Object.keys(pendingPlayByKey).forEach(function (key) {
        var msg = pendingPlayByKey[key];
        var el = findMedia(key);
        if (el && msg) tryPlay(el, msg);
      });
    } finally {
      setTimeout(function () {
        applyingRemote = false;
      }, 300);
    }
  }
  function publish(el, index, action) {
    if (sender !== "presenter") return;
    if (applyingRemote) return;
    if (action === "timeupdate") {
      var now = performance.now();
      if (now - lastTimeBroadcast < 450 && !el.paused) return;
      lastTimeBroadcast = now;
    }
    channel.postMessage({
      type: "media",
      sender,
      key: mediaKey(el, index),
      action,
      currentTime: el.currentTime || 0,
      paused: el.paused,
      ended: el.ended,
      muted: el.muted,
      volume: el.volume,
      playbackRate: el.playbackRate,
    });
  }
  function wireMedia() {
    mediaNodes().forEach(function (el, index) {
      if (el.dataset.hfPresenterMediaSync === "1") return;
      el.dataset.hfPresenterMediaSync = "1";
      [
        "play",
        "pause",
        "seeking",
        "seeked",
        "ratechange",
        "volumechange",
        "ended",
        "timeupdate",
      ].forEach(function (name) {
        el.addEventListener(name, function () {
          publish(el, index, name);
        });
      });
    });
  }
  channel.addEventListener("message", function (event) {
    var msg = event.data;
    if (!msg || msg.type !== "media" || msg.sender === sender) return;
    if (sender === "audience" && blockedPlayByKey[msg.key] && msg.action === "timeupdate") {
      pendingPlayByKey[msg.key] = msg;
      showAudienceUnlock();
      return;
    }
    var el = findMedia(msg.key);
    if (!el) return;
    applyingRemote = true;
    try {
      if (
        msg.action === "play" ||
        (sender === "audience" && msg.action === "timeupdate" && msg.paused === false && el.paused)
      ) {
        pendingPlayByKey[msg.key] = msg;
        tryPlay(el, msg);
      } else {
        syncMediaState(el, msg, true);
      }
      if (msg.action === "pause" || msg.action === "ended") {
        delete pendingPlayByKey[msg.key];
        delete mutedPlaybackByKey[msg.key];
        clearBlockedPlay(msg.key);
        el.pause();
      }
    } catch {
    } finally {
      setTimeout(function () {
        applyingRemote = false;
      }, 300);
    }
  });
  wireMedia();
  window.addEventListener("load", wireMedia);
  window.addEventListener("keydown", retryBlockedPlays, true);
  window.addEventListener("pointerdown", retryBlockedPlays, true);
  setInterval(wireMedia, 1000);
})();
Raw lines 123 to 325
326### Custom media visualizers
328For waveform, beat-grid, canvas, or timeline players, wire visual state to the native media element. This keeps native controls, custom controls, presenter sync, slide-exit cleanup, and global mute in one event path.
330```js
331function wireMediaDrivenVisualizer(media, renderFrame, fireCrossedEvents) {
332 var mediaFrame = 0;
333 var lastTime = media.currentTime || 0;
335 function update() {
336 var time = media.currentTime || 0;
337 if (Math.abs(time - lastTime) < 1.5 && time >= lastTime) {
338 fireCrossedEvents(lastTime, time);
339 }
340 lastTime = time;
341 renderFrame(time, media);
342 }
344 function start() {
345 if (!media.requestVideoFrameCallback || mediaFrame) return;
346 mediaFrame = media.requestVideoFrameCallback(function () {
347 mediaFrame = 0;
348 update();
349 if (!media.paused && !media.ended) start();
350 });
351 }
353 media.addEventListener("play", start);
354 media.addEventListener("playing", start);
355 media.addEventListener("pause", update);
356 media.addEventListener("ended", update);
357 media.addEventListener("timeupdate", update);
358 media.addEventListener("seeking", function () {
359 lastTime = media.currentTime || 0;
360 renderFrame(lastTime, media);
361 });
362 media.addEventListener("seeked", update);
363 media.addEventListener("ratechange", update);
364 media.addEventListener("volumechange", update);
365 renderFrame(media.currentTime || 0, media);
367```
369Do not use `requestAnimationFrame` inside compositions for media sync; composition lint rejects wall-clock loops. Prefer `HTMLVideoElement.requestVideoFrameCallback()` for smooth video-tied updates and rely on native `timeupdate`/seek events as the fallback.
371Use a dedicated wiring marker such as `data-media-sync-wired`. Do not reuse a marker like `data-wired` for both "timeline DOM already rendered" and "media event listeners attached"; pre-rendered timeline HTML will otherwise skip listener setup.

Custom media visualizers

For waveform, beat-grid, canvas, or timeline players, wire visual state to the native media element. This keeps native controls, custom controls, presenter sync, slide-exit cleanup, and global mute in one event path.

function wireMediaDrivenVisualizer(media, renderFrame, fireCrossedEvents) {
  var mediaFrame = 0;
  var lastTime = media.currentTime || 0;

  function update() {
    var time = media.currentTime || 0;
    if (Math.abs(time - lastTime) < 1.5 && time >= lastTime) {
      fireCrossedEvents(lastTime, time);
    }
    lastTime = time;
    renderFrame(time, media);
  }

  function start() {
    if (!media.requestVideoFrameCallback || mediaFrame) return;
    mediaFrame = media.requestVideoFrameCallback(function () {
      mediaFrame = 0;
      update();
      if (!media.paused && !media.ended) start();
    });
  }

  media.addEventListener("play", start);
  media.addEventListener("playing", start);
  media.addEventListener("pause", update);
  media.addEventListener("ended", update);
  media.addEventListener("timeupdate", update);
  media.addEventListener("seeking", function () {
    lastTime = media.currentTime || 0;
    renderFrame(lastTime, media);
  });
  media.addEventListener("seeked", update);
  media.addEventListener("ratechange", update);
  media.addEventListener("volumechange", update);
  renderFrame(media.currentTime || 0, media);
}

Do not use requestAnimationFrame inside compositions for media sync; composition lint rejects wall-clock loops. Prefer HTMLVideoElement.requestVideoFrameCallback() for smooth video-tied updates and rely on native timeupdate/seek events as the fallback.

Use a dedicated wiring marker such as data-media-sync-wired. Do not reuse a marker like data-wired for both "timeline DOM already rendered" and "media event listeners attached"; pre-rendered timeline HTML will otherwise skip listener setup.

Raw lines 326 to 372
373### Editable presenter notes
375The shared `<hyperframes-slideshow>` presenter already renders speaker notes as an editable textarea and stores edits in `localStorage`. Do not add deck-specific note editors when the shared player is available.
377For interim custom wrappers that cannot use the shared presenter chrome, use this deterministic storage contract exactly so notes migrate cleanly:
379```js
380const NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:";
382function notesDeckKey(slideshowEl) {
383 const explicit = slideshowEl.getAttribute("notes-storage-key");
384 if (explicit && explicit.trim()) return explicit.trim();
386 const playerSrc = slideshowEl.querySelector("hyperframes-player")?.getAttribute("src") || "";
387 let resolvedPlayerSrc = playerSrc;
388 try {
389 resolvedPlayerSrc = new URL(playerSrc, location.href).href;
390 } catch {}
392 return `${location.origin}${location.pathname}|${document.title}|${resolvedPlayerSrc}`;
395function notesStorageKey(slideshowEl, position, slide) {
396 return `${NOTES_STORAGE_PREFIX}${JSON.stringify([
397 notesDeckKey(slideshowEl),
398 position.sequenceId,
399 position.slideIndex,
400 slide.sceneId || "",
401 ])}`;
404function readPresenterNotes(slideshowEl, position, slide) {
405 const key = notesStorageKey(slideshowEl, position, slide);
406 try {
407 const stored = localStorage.getItem(key);
408 return stored == null ? slide.notes || "" : stored;
409 } catch {
410 return slide.notes || "";
411 }
414function wirePresenterNotes(textarea, slideshowEl, position, slide) {
415 const key = notesStorageKey(slideshowEl, position, slide);
416 textarea.value = readPresenterNotes(slideshowEl, position, slide);
417 textarea.addEventListener("input", function () {
418 try {
419 localStorage.setItem(key, textarea.value);
420 } catch {}
421 });
423```
425Clearing the textarea must save an empty string, not remove the local value, because a presenter may intentionally blank a manifest note for their run. Use `notes-storage-key="stable-deck-id"` on `<hyperframes-slideshow>` when a standalone demo has a stable project id; otherwise the fallback key isolates by page, title, and player `src`.
427---

Editable presenter notes

The shared <hyperframes-slideshow> presenter already renders speaker notes as an editable textarea and stores edits in localStorage. Do not add deck-specific note editors when the shared player is available.

For interim custom wrappers that cannot use the shared presenter chrome, use this deterministic storage contract exactly so notes migrate cleanly:

const NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:";

function notesDeckKey(slideshowEl) {
  const explicit = slideshowEl.getAttribute("notes-storage-key");
  if (explicit && explicit.trim()) return explicit.trim();

  const playerSrc = slideshowEl.querySelector("hyperframes-player")?.getAttribute("src") || "";
  let resolvedPlayerSrc = playerSrc;
  try {
    resolvedPlayerSrc = new URL(playerSrc, location.href).href;
  } catch {}

  return `${location.origin}${location.pathname}|${document.title}|${resolvedPlayerSrc}`;
}

function notesStorageKey(slideshowEl, position, slide) {
  return `${NOTES_STORAGE_PREFIX}${JSON.stringify([
    notesDeckKey(slideshowEl),
    position.sequenceId,
    position.slideIndex,
    slide.sceneId || "",
  ])}`;
}

function readPresenterNotes(slideshowEl, position, slide) {
  const key = notesStorageKey(slideshowEl, position, slide);
  try {
    const stored = localStorage.getItem(key);
    return stored == null ? slide.notes || "" : stored;
  } catch {
    return slide.notes || "";
  }
}

function wirePresenterNotes(textarea, slideshowEl, position, slide) {
  const key = notesStorageKey(slideshowEl, position, slide);
  textarea.value = readPresenterNotes(slideshowEl, position, slide);
  textarea.addEventListener("input", function () {
    try {
      localStorage.setItem(key, textarea.value);
    } catch {}
  });
}

Clearing the textarea must save an empty string, not remove the local value, because a presenter may intentionally blank a manifest note for their run. Use notes-storage-key="stable-deck-id" on <hyperframes-slideshow> when a standalone demo has a stable project id; otherwise the fallback key isolates by page, title, and player src.


Raw lines 373 to 428
429## 3. Playhead-driven scene visibility
431Without 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.
433The 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.
435For converted source pages, preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. Port source-specific widgets exactly where practical: custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.

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.

For converted source pages, preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. Port source-specific widgets exactly where practical: custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.

Raw lines 429 to 436
437### Navigation camera transitions for converted pages
439When a source page uses scroll to move a translated/scaled world, slideshow navigation usually seeks directly to each slide's hold frame. That seek bypasses any in-timeline interpolation near the scene boundary, so a deck can compute the right camera positions and still appear to jump. Add an explicit standalone navigation transition for manual slide changes, while keeping normal HyperFrames timeline seeks static and deterministic.
441Use this pattern only for direct-open/presenter slideshow UI. Do not depend on CSS transitions for rendered video output; rendered compositions must still be correct when seeking a single frame.
443```css
444#world {
445 transform-origin: 0 0;
446 will-change: transform;
447 transition:
448 transform 760ms cubic-bezier(0.22, 1, 0.36, 1),
449 opacity 0.3s ease;
452#world.hf-camera-static {
453 transition: opacity 0.3s ease;
455```
457```js
458var currentCamera = null;
459var currentSlideIndex = null;
461function cameraTransform(cam) {
462 return "translate(" + cam.tx + "px," + cam.ty + "px) scale(" + cam.s + ")";
465function setWorldCamera(world, cam, animate) {
466 if (!cam) return;
467 if (!animate) world.classList.add("hf-camera-static");
468 world.style.transform = cameraTransform(cam);
469 world.style.opacity = "1";
470 if (!animate) {
471 world.getBoundingClientRect();
472 world.classList.remove("hf-camera-static");
473 }
474 currentCamera = cam;
477function slideIndexAtTime(t, slideDuration, slideCount) {
478 return Math.max(0, Math.min(slideCount - 1, Math.floor(t / slideDuration)));
481function updateCameraForTime(t, opts) {
482 var nextSlideIndex = slideIndexAtTime(t, SLIDE_DURATION, SLIDES.length);
483 var jumpedBetweenSlides =
484 currentSlideIndex !== null &&
485 nextSlideIndex !== currentSlideIndex &&
486 Math.abs(t - lastTime) > 1.2;
487 var animateCamera = Boolean(
488 window.__hfCameraTransitionsEnabled && jumpedBetweenSlides && !(opts && opts.staticCamera),
489 );
490 var cam = cameraAtTime(t);
491 setWorldCamera(world, cam, animateCamera);
492 currentSlideIndex = nextSlideIndex;
494```
496During measurement, temporarily remove the transform with `hf-camera-static`, compute all element union rects, then restore `currentCamera` without animation. On initial load, resize, and validation-style seeks, call `updateCameraForTime(t, { staticCamera: true })`. In the standalone wrapper, set `iframe.contentWindow.__hfCameraTransitionsEnabled = true` after the player iframe is available. That keeps the exported composition seekable while letting presenter navigation glide between focal points.
498Before validation, resolve source font variables. HyperFrames lint accepts concrete generic stacks such as `system-ui, sans-serif` and `ui-monospace, monospace`, or real `@font-face` declarations pointing at local font files. It does not accept `font-family: var(--f-body)` / `var(--f-mono)` as a render-safe family.
500```html
501<!-- In index.html (composition) -->
503<!-- Shared scene CSS — all scenes start hidden -->
504<style>
505 .scene-frame {
506 position: absolute;
507 top: 0;
508 left: 0;
509 width: 1920px;
510 height: 1080px;
511 overflow: hidden;
512 opacity: 0; /* hidden at rest — visibility controller shows the active one */
513 visibility: hidden; /* opacity:0 alone still lets invisible frames block clicks */
514 pointer-events: none; /* inactive scenes must not swallow events */
515 }
516</style>
518<!--
519 content-visible-at-rest: mark the first scene's elements with their
520 final non-hidden state so the deck is not blank before the controller
521 fires. The controller calls updateVisibility(0) synchronously on load.
523 If using Three.js canvas behind scenes, set background: transparent
524 here and let the 3D canvas + body color supply the fill.
525-->
526<div
527 id="scene-cover"
528 class="scene-frame clip"
529 data-composition-id="cover"
530 data-start="0"
531 data-duration="9"
532 style="background: transparent"
534 <!-- content here -->
535</div>
537<div
538 id="scene-problem"
539 class="scene-frame clip"
540 data-composition-id="problem"
541 data-start="9"
542 data-duration="9"
543 style="background: transparent"
545 <!-- content here -->
546</div>
548<!-- Root timeline — spans the full composition duration -->
549<script>
550 (function () {
551 window.__timelines = window.__timelines || {};
552 var tl = gsap.timeline({ paused: true });
553 // A single to() for the full duration establishes the seekable range
554 tl.to({}, { duration: 108 }); // replace 108 with your total seconds
555 window.__timelines["root"] = tl;
556 })();
557</script>
559<!-- Visibility controller -->
560<script>
561 (function () {
562 var scenes = [
563 { id: "scene-cover", start: 0, end: 9 },
564 { id: "scene-problem", start: 9, end: 18 },
565 // ... all scenes including branch scenes
566 ];
568 var lastActiveId = null;
570 function updateVisibility(t) {
571 for (var i = 0; i < scenes.length; i++) {
572 var s = scenes[i];
573 var el = document.getElementById(s.id);
574 if (!el) continue;
575 var active = t >= s.start && t < s.end;
576 el.style.opacity = active ? "1" : "0";
577 el.style.visibility = active ? "visible" : "hidden";
578 el.style.pointerEvents = active ? "auto" : "none";
580 if (active && lastActiveId !== s.id) {
581 lastActiveId = s.id;
582 fireEntrance(el); // see Section 4
583 }
584 }
585 // fragment reveals here — see Section 4
586 }
588 window.__hfSetTime = updateVisibility;
590 // Show first slide immediately — avoids blank on load
591 updateVisibility(0);
593 // Hook the root timeline so every seek drives visibility
594 var root = window.__timelines && window.__timelines["root"];
595 if (root) {
596 root.eventCallback("onUpdate", function () {
597 updateVisibility(root.time());
598 });
599 }
600 })();
601</script>
602```
604---

Navigation camera transitions for converted pages

When a source page uses scroll to move a translated/scaled world, slideshow navigation usually seeks directly to each slide's hold frame. That seek bypasses any in-timeline interpolation near the scene boundary, so a deck can compute the right camera positions and still appear to jump. Add an explicit standalone navigation transition for manual slide changes, while keeping normal HyperFrames timeline seeks static and deterministic.

Use this pattern only for direct-open/presenter slideshow UI. Do not depend on CSS transitions for rendered video output; rendered compositions must still be correct when seeking a single frame.

#world {
  transform-origin: 0 0;
  will-change: transform;
  transition:
    transform 760ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.3s ease;
}

#world.hf-camera-static {
  transition: opacity 0.3s ease;
}
var currentCamera = null;
var currentSlideIndex = null;

function cameraTransform(cam) {
  return "translate(" + cam.tx + "px," + cam.ty + "px) scale(" + cam.s + ")";
}

function setWorldCamera(world, cam, animate) {
  if (!cam) return;
  if (!animate) world.classList.add("hf-camera-static");
  world.style.transform = cameraTransform(cam);
  world.style.opacity = "1";
  if (!animate) {
    world.getBoundingClientRect();
    world.classList.remove("hf-camera-static");
  }
  currentCamera = cam;
}

function slideIndexAtTime(t, slideDuration, slideCount) {
  return Math.max(0, Math.min(slideCount - 1, Math.floor(t / slideDuration)));
}

function updateCameraForTime(t, opts) {
  var nextSlideIndex = slideIndexAtTime(t, SLIDE_DURATION, SLIDES.length);
  var jumpedBetweenSlides =
    currentSlideIndex !== null &&
    nextSlideIndex !== currentSlideIndex &&
    Math.abs(t - lastTime) > 1.2;
  var animateCamera = Boolean(
    window.__hfCameraTransitionsEnabled && jumpedBetweenSlides && !(opts && opts.staticCamera),
  );
  var cam = cameraAtTime(t);
  setWorldCamera(world, cam, animateCamera);
  currentSlideIndex = nextSlideIndex;
}

During measurement, temporarily remove the transform with hf-camera-static, compute all element union rects, then restore currentCamera without animation. On initial load, resize, and validation-style seeks, call updateCameraForTime(t, { staticCamera: true }). In the standalone wrapper, set iframe.contentWindow.__hfCameraTransitionsEnabled = true after the player iframe is available. That keeps the exported composition seekable while letting presenter navigation glide between focal points.

Before validation, resolve source font variables. HyperFrames lint accepts concrete generic stacks such as system-ui, sans-serif and ui-monospace, monospace, or real @font-face declarations pointing at local font files. It does not accept font-family: var(--f-body) / var(--f-mono) as a render-safe family.

<!-- In index.html (composition) -->

<!-- Shared scene CSS — all scenes start hidden -->
<style>
  .scene-frame {
    position: absolute;
    top: 0;
    left: 0;
    width: 1920px;
    height: 1080px;
    overflow: hidden;
    opacity: 0; /* hidden at rest — visibility controller shows the active one */
    visibility: hidden; /* opacity:0 alone still lets invisible frames block clicks */
    pointer-events: none; /* inactive scenes must not swallow events */
  }
</style>

<!--
  content-visible-at-rest: mark the first scene's elements with their
  final non-hidden state so the deck is not blank before the controller
  fires. The controller calls updateVisibility(0) synchronously on load.

  If using Three.js canvas behind scenes, set background: transparent
  here and let the 3D canvas + body color supply the fill.
-->
<div
  id="scene-cover"
  class="scene-frame clip"
  data-composition-id="cover"
  data-start="0"
  data-duration="9"
  style="background: transparent"
>
  <!-- content here -->
</div>

<div
  id="scene-problem"
  class="scene-frame clip"
  data-composition-id="problem"
  data-start="9"
  data-duration="9"
  style="background: transparent"
>
  <!-- content here -->
</div>

<!-- Root timeline — spans the full composition duration -->
<script>
  (function () {
    window.__timelines = window.__timelines || {};
    var tl = gsap.timeline({ paused: true });
    // A single to() for the full duration establishes the seekable range
    tl.to({}, { duration: 108 }); // replace 108 with your total seconds
    window.__timelines["root"] = tl;
  })();
</script>

<!-- Visibility controller -->
<script>
  (function () {
    var scenes = [
      { id: "scene-cover", start: 0, end: 9 },
      { id: "scene-problem", start: 9, end: 18 },
      // ... all scenes including branch scenes
    ];

    var lastActiveId = null;

    function updateVisibility(t) {
      for (var i = 0; i < scenes.length; i++) {
        var s = scenes[i];
        var el = document.getElementById(s.id);
        if (!el) continue;
        var active = t >= s.start && t < s.end;
        el.style.opacity = active ? "1" : "0";
        el.style.visibility = active ? "visible" : "hidden";
        el.style.pointerEvents = active ? "auto" : "none";

        if (active && lastActiveId !== s.id) {
          lastActiveId = s.id;
          fireEntrance(el); // see Section 4
        }
      }
      // fragment reveals here — see Section 4
    }

    window.__hfSetTime = updateVisibility;

    // Show first slide immediately — avoids blank on load
    updateVisibility(0);

    // Hook the root timeline so every seek drives visibility
    var root = window.__timelines && window.__timelines["root"];
    if (root) {
      root.eventCallback("onUpdate", function () {
        updateVisibility(root.time());
      });
    }
  })();
</script>

Raw lines 437 to 605
606## 4. Imperative entrances on slide-activate
608The 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.
610Fragment 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.
612```js
613// --- Entrance animations ---
615function fireEntrance(sceneEl) {
616 // [data-anim] marks elements that should entrance on slide-activate.
617 // Add data-anim to eyebrows, headlines, subheads, and card grids.
618 var animEls = sceneEl.querySelectorAll("[data-anim]");
619 if (!animEls.length) return;
620 gsap.from(animEls, {
621 opacity: 0,
622 y: 28,
623 duration: 0.4,
624 stagger: 0.07,
625 ease: "power2.out",
626 overwrite: true, // cancel any in-flight animation on rapid slide changes
627 });
630// --- Fragment reveals ---
632// Fragment config: times in absolute composition timeline seconds,
633// bunched near the scene start for snappy successive reveals.
634var fragments = [
635 { time: 9.3, id: "prob-item1", revealed: false },
636 { time: 9.6, id: "prob-item2", revealed: false },
637];
639function revealFragment(id) {
640 var el = document.getElementById(id);
641 if (!el) return;
642 gsap.fromTo(
643 el,
644 { opacity: 0, x: -24 },
645 { opacity: 1, x: 0, duration: 0.35, ease: "power2.out", overwrite: true },
646 );
649// Inside updateVisibility(t):
650for (var f = 0; f < fragments.length; f++) {
651 if (!fragments[f].revealed && t >= fragments[f].time) {
652 fragments[f].revealed = true;
653 revealFragment(fragments[f].id);
654 }
657// On problem scene re-entry, reset all fragment states:
658if (active && lastActiveId !== s.id && s.id === "scene-problem") {
659 for (var f = 0; f < fragments.length; f++) {
660 fragments[f].revealed = false;
661 var pEl = document.getElementById(fragments[f].id);
662 if (pEl) gsap.set(pEl, { opacity: 0, clearProps: "transform" });
663 }
665```
667Fragment items start with `opacity: 0` in CSS. The visibility controller reveals them; the entrance driver does not touch them until crossing.
669---

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.

// --- 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.


Raw lines 606 to 670
671## 5. The scenes bootstrap postMessage
673`<hyperframes-slideshow>` 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.
675Post the manifest from the composition (index.html), not the parent wrapper:
677```js
678// In index.html — post after a brief delay so the parent frame has settled
679(function () {
680 var FPS = 30;
681 var totalSeconds = 108; // match your composition's data-duration
682 var totalFrames = totalSeconds * FPS;
684 var scenes = [
685 // EVERY scene — including branch scenes — must appear here.
686 // id must match data-composition-id; start/duration in seconds.
687 { id: "cover", start: 0, duration: 9 },
688 { id: "problem", start: 9, duration: 9 },
689 { id: "solution", start: 18, duration: 9 },
690 // ... all main-line scenes ...
691 // branch scene — listed last, NOT in main slides array in the island
692 { id: "market-sizing", start: 99, duration: 9 },
693 ];
695 function postTimeline() {
696 parent.postMessage(
697 {
698 source: "hf-preview",
699 type: "timeline",
700 durationInFrames: totalFrames,
701 scenes: scenes,
702 },
703 "*",
704 );
705 }
707 // ~300ms delay after load to let the parent settle
708 if (document.readyState === "complete") {
709 setTimeout(postTimeline, 300);
710 } else {
711 window.addEventListener("load", function () {
712 setTimeout(postTimeline, 300);
713 });
714 }
715})();
716```
718Omitting 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.
720---

5. The scenes bootstrap postMessage

<hyperframes-slideshow> 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:

// 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.


Raw lines 671 to 721
722## 6. Audio/SFX — built-in mute control via `<hyperframes-slideshow sound>`
724Wrapper-owned SFX should live in the parent page. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is often autoplay-blocked. The user's keypress lands on the parent, so the parent is the reliable frame for click/transition sound effects.
726Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling `hyperframes-player.stopMedia()`, which pauses iframe `<video>` / `<audio>`, runtime WebAudio, and parent proxies adopted from iframe media. Same-slide fragment reveals do not stop media, and global/deck-level parent audio such as `audio-src` is left alone. Do not hand-roll per-slide cleanup scripts for regular video/audio players.
728Every copied `<video>` / `<audio>` with a `src` must be timed for HyperFrames ownership:
730```html
731<video
732 src="assets/demo.mp4"
733 controls
734 playsinline
735 preload="metadata"
736 data-start="0"
737 data-duration="96"
738 data-has-audio="true"
739></video>
740```
742Use `data-has-audio="true"` only for audible media. Muted autoplay loops can omit it. Do not leave `preload="none"` in converted compositions.
744Implementation detail: iframe media elements belong to the iframe's DOM realm. Fallback code in the parent page/player must not use the parent page's `el instanceof HTMLMediaElement` check for iframe nodes; in real browsers that fails and leaves videos audible. Use `el.ownerDocument.defaultView.HTMLMediaElement` or a tag/duck-type guard before setting `muted` or calling `pause()`.

6. Audio/SFX — built-in mute control via <hyperframes-slideshow sound>

Wrapper-owned SFX should live in the parent page. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is often autoplay-blocked. The user's keypress lands on the parent, so the parent is the reliable frame for click/transition sound effects.

Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling hyperframes-player.stopMedia(), which pauses iframe <video> / <audio>, runtime WebAudio, and parent proxies adopted from iframe media. Same-slide fragment reveals do not stop media, and global/deck-level parent audio such as audio-src is left alone. Do not hand-roll per-slide cleanup scripts for regular video/audio players.

Every copied <video> / <audio> with a src must be timed for HyperFrames ownership:

<video
  src="assets/demo.mp4"
  controls
  playsinline
  preload="metadata"
  data-start="0"
  data-duration="96"
  data-has-audio="true"
></video>

Use data-has-audio="true" only for audible media. Muted autoplay loops can omit it. Do not leave preload="none" in converted compositions.

Implementation detail: iframe media elements belong to the iframe's DOM realm. Fallback code in the parent page/player must not use the parent page's el instanceof HTMLMediaElement check for iframe nodes; in real browsers that fails and leaves videos audible. Use el.ownerDocument.defaultView.HTMLMediaElement or a tag/duck-type guard before setting muted or calling pause().

Raw lines 722 to 745
746### Mute toggle — built-in chrome control
748Add the `sound` boolean attribute to `<hyperframes-slideshow>` 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.
750```html
751<hyperframes-slideshow tabindex="0" sound style="..."> ... </hyperframes-slideshow>
752```
754The component:
756- Tracks `muted` state (default `false`); exposes a `muted` getter
757- Reflects to a `data-hf-muted` attribute on the host when muted
758- Applies mute globally to child `<hyperframes-player>` media and top-level page `<audio>` / `<video>` elements
759- Dispatches `CustomEvent("hf-sound", { detail: { muted }, bubbles: true, composed: true })` on every toggle
760- Browser-checks the actual iframe media state after changes; every composition `<video>` / `<audio>` should report `muted: true` after clicking the nav mute button
762Wrapper-owned `new Audio(...)` objects are not attached to the DOM, so the parent audio player must mirror the `hf-sound` event onto each clip:
764```js
765var muted = false;
766var slideshow = document.querySelector("hyperframes-slideshow");
767if (slideshow) {
768 slideshow.addEventListener("hf-sound", function (e) {
769 muted = e.detail && e.detail.muted === true;
770 Object.keys(clips).forEach(function (name) {
771 clips[name].muted = muted;
772 });
773 });
775// In message handler:
776if (muted) return; // skip play
777```
779If `sound` is **not** present on `<hyperframes-slideshow>` (decks without audio), the mute control is hidden — the capsule shows only nav.

Mute toggle — built-in chrome control

Add the sound boolean attribute to <hyperframes-slideshow> 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.

<hyperframes-slideshow tabindex="0" sound style="..."> ... </hyperframes-slideshow>

The component:

  • Tracks muted state (default false); exposes a muted getter
  • Reflects to a data-hf-muted attribute on the host when muted
  • Applies mute globally to child <hyperframes-player> media and top-level page <audio> / <video> elements
  • Dispatches CustomEvent("hf-sound", { detail: { muted }, bubbles: true, composed: true }) on every toggle
  • Browser-checks the actual iframe media state after changes; every composition <video> / <audio> should report muted: true after clicking the nav mute button

Wrapper-owned new Audio(...) objects are not attached to the DOM, so the parent audio player must mirror the hf-sound event onto each clip:

var muted = false;
var slideshow = document.querySelector("hyperframes-slideshow");
if (slideshow) {
  slideshow.addEventListener("hf-sound", function (e) {
    muted = e.detail && e.detail.muted === true;
    Object.keys(clips).forEach(function (name) {
      clips[name].muted = muted;
    });
  });
}
// In message handler:
if (muted) return; // skip play

If sound is not present on <hyperframes-slideshow> (decks without audio), the mute control is hidden — the capsule shows only nav.

Raw lines 746 to 780
Starts an agent Dispatches :759
781### Composition: post cues unconditionally
783The composition posts sfx cues **unconditionally** — it does not track mute state. The parent gates on `muted`:
785**In the composition (index.html):**
787```js
788// Post an sfx cue at transition points — unconditionally.
789// The parent audio player gates on the slideshow component's mute state.
790function playSfx(name) {
791 try {
792 parent.postMessage({ type: "hf-sfx", name: name }, "*");
793 } catch (e) {}
796// Fire at scene transitions:
797// playSfx("advance") — moving to the next main-line slide
798// playSfx("back") — returning from a branch
799// playSfx("branch-enter") — entering a branch
800// playSfx("fragment") — a fragment item is revealed
801```
803Do 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.
805**In the parent (demo.html):**
807```html
808<script>
809 (function () {
810 // Audio elements are preloaded here, in the frame that receives user gestures.
811 var clips = {
812 advance: new Audio("sfx/advance.mp3"),
813 fragment: new Audio("sfx/fragment.mp3"),
814 "branch-enter": new Audio("sfx/branch-enter.mp3"),
815 back: new Audio("sfx/back.mp3"),
816 };
817 clips.advance.volume = 0.45;
818 clips.fragment.volume = 0.4;
819 clips["branch-enter"].volume = 0.4;
820 clips.back.volume = 0.4;
821 Object.keys(clips).forEach(function (k) {
822 clips[k].preload = "auto";
823 });
825 // Track mute state from the slideshow component's hf-sound event.
826 var muted = false;
827 var slideshow = document.querySelector("hyperframes-slideshow");
828 if (slideshow) {
829 slideshow.addEventListener("hf-sound", function (e) {
830 muted = e.detail && e.detail.muted === true;
831 });
832 }
834 var unlocked = false;
836 function unlock() {
837 if (unlocked) return;
838 unlocked = true;
839 // Prime wrapper-owned SFX clips: play muted then immediately pause/reset.
840 // This moves the clip into the "allowed" state so later plays are instant.
841 Object.keys(clips).forEach(function (name) {
842 var el = clips[name];
843 var v = el.volume;
844 el.volume = 0;
845 el.play()
846 .then(function () {
847 el.pause();
848 el.currentTime = 0;
849 el.volume = v;
850 })
851 .catch(function () {
852 el.volume = v;
853 });
854 });
855 }
857 // Unlock on the first user gesture in the parent frame.
858 window.addEventListener("keydown", unlock, true);
859 window.addEventListener("pointerdown", unlock, true);
860 window.addEventListener("click", unlock, true);
862 window.addEventListener("message", function (e) {
863 var d = e.data;
864 if (!d || d.type !== "hf-sfx") return;
865 // Gate on mute state — the component owns this.
866 if (muted) return;
867 var el = clips[d.name];
868 if (!el || !unlocked) return;
869 try {
870 el.currentTime = 0;
871 el.play().catch(function () {});
872 } catch (err) {}
873 });
874 })();
875</script>
876```
878**Sourcing SFX files:** resolve each one with `npx hyperframes media-use resolve --type sfx --intent "<whoosh | click | transition>" --project .` (Openverse CC0 / CC BY, bundled library offline; CC BY credits land in a `CREDITS.md` beside the resolved files), copy the files 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.
880---

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):

// 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):

<script>
  (function () {
    // Audio elements are preloaded here, in the frame that receives user gestures.
    var clips = {
      advance: new Audio("sfx/advance.mp3"),
      fragment: new Audio("sfx/fragment.mp3"),
      "branch-enter": new Audio("sfx/branch-enter.mp3"),
      back: new Audio("sfx/back.mp3"),
    };
    clips.advance.volume = 0.45;
    clips.fragment.volume = 0.4;
    clips["branch-enter"].volume = 0.4;
    clips.back.volume = 0.4;
    Object.keys(clips).forEach(function (k) {
      clips[k].preload = "auto";
    });

    // Track mute state from the slideshow component's hf-sound event.
    var muted = false;
    var slideshow = document.querySelector("hyperframes-slideshow");
    if (slideshow) {
      slideshow.addEventListener("hf-sound", function (e) {
        muted = e.detail && e.detail.muted === true;
      });
    }

    var unlocked = false;

    function unlock() {
      if (unlocked) return;
      unlocked = true;
      // Prime wrapper-owned SFX clips: play muted then immediately pause/reset.
      // This moves the clip into the "allowed" state so later plays are instant.
      Object.keys(clips).forEach(function (name) {
        var el = clips[name];
        var v = el.volume;
        el.volume = 0;
        el.play()
          .then(function () {
            el.pause();
            el.currentTime = 0;
            el.volume = v;
          })
          .catch(function () {
            el.volume = v;
          });
      });
    }

    // Unlock on the first user gesture in the parent frame.
    window.addEventListener("keydown", unlock, true);
    window.addEventListener("pointerdown", unlock, true);
    window.addEventListener("click", unlock, true);

    window.addEventListener("message", function (e) {
      var d = e.data;
      if (!d || d.type !== "hf-sfx") return;
      // Gate on mute state — the component owns this.
      if (muted) return;
      var el = clips[d.name];
      if (!el || !unlocked) return;
      try {
        el.currentTime = 0;
        el.play().catch(function () {});
      } catch (err) {}
    });
  })();
</script>

Sourcing SFX files: resolve each one with npx hyperframes media-use resolve --type sfx --intent "<whoosh | click | transition>" --project . (Openverse CC0 / CC BY, bundled library offline; CC BY credits land in a CREDITS.md beside the resolved files), copy the files 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.


Raw lines 781 to 881
Names a skill media-use :878
882## 7. Three.js (optional)
884Add a Three.js scene behind the slides for ambient motion. The key rules:
886- **Own rAF loop** — do not integrate with the HF seek timeline. Three.js runs its own `requestAnimationFrame` loop independent of playhead position.
887- **One persistent canvas** — create the canvas once; update geometry/materials in-place per scene.
888- **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`.
889- **Full-bleed, behind content** — fix the canvas at `z-index: 0`, `pointer-events: none`, behind scene frames at `z-index: 1`.
890- **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.
891- **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.
893```js
894// In index.html — Three.js setup (module script)
895import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
897var canvas = document.getElementById("three-canvas");
898var renderer = null;
899var _err = console.error;
900console.error = function () {}; // silence THREE's multi-line GPU error during init
901try {
902 renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
903} catch (e) {
904 // renderer stays null
905} finally {
906 console.error = _err;
909if (!renderer) {
910 // Graceful degradation — branded layout is the fallback.
911 canvas.style.display = "none";
912 window.__threeApplyMood = function () {};
913 // Do NOT start the rAF loop.
914} else {
915 renderer.setSize(1920, 1080);
916 renderer.setPixelRatio(1);
917 canvas.style.cssText =
918 "position:fixed;top:0;left:0;width:1920px;height:1080px;z-index:0;pointer-events:none";
920 var scene = new THREE.Scene();
921 var camera = new THREE.PerspectiveCamera(60, 1920 / 1080, 0.1, 1000);
922 camera.position.set(0, 0, 5);
924 // --- build your particle system, meshes, etc. here ---
926 // Mood config: map sceneId → visual state (colors, sub-object visibility, bg color)
927 var MOODS = {
928 cover: {
929 /* particle color, opacity, bg ... */
930 },
931 problem: {
932 /* ... */
933 },
934 // one entry per scene key
935 };
937 window.__threeApplyMood = function (sceneKey) {
938 var m = MOODS[sceneKey] || MOODS["cover"];
939 // update geometry attributes, material opacity, sub-group visibility, etc.
940 };
941 window.__threeApplyMood("cover"); // apply initial state
943 // --- own rAF loop ---
944 var lastTime = null;
945 function animate(ts) {
946 requestAnimationFrame(animate);
947 if (lastTime === null) lastTime = ts;
948 var dt = Math.min((ts - lastTime) / 1000, 0.05);
949 lastTime = ts;
950 // update particles, rotate objects, etc.
951 renderer.render(scene, camera);
952 }
953 requestAnimationFrame(animate);
955```
957**CSS for transparent scene frames + scrim:**
959```css
960/* Three.js canvas — always behind everything */
961#three-canvas {
962 position: fixed;
963 top: 0;
964 left: 0;
965 width: 1920px;
966 height: 1080px;
967 z-index: 0;
968 pointer-events: none;
971/* Scene frames are transparent so the 3D canvas shows through */
972.scene-frame {
973 position: absolute;
974 top: 0;
975 left: 0;
976 width: 1920px;
977 height: 1080px;
978 background: transparent; /* NOT opaque — 3D would be occluded */
979 z-index: 1;
982/* Scrim on the TEXT container — not the scene frame.
983 Radial gradient: opaque in the center where text is, transparent at edges
984 so 3D shows in the whitespace margins. */
985.slide-inner.scrim-light {
986 background: radial-gradient(
987 ellipse 75% 80% at 50% 50%,
988 rgba(255, 255, 255, 0.88) 30%,
989 rgba(255, 255, 255, 0.6) 65%,
990 rgba(255, 255, 255, 0) 100%
991 );
993```
995---

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.
// 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:

/* 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%
  );
}

Raw lines 882 to 996
997## 8. Foot-gun checklist
999| Failure | Symptom | One-line fix |
1000| ----------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
1001| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
1002| Wrapper SFX in the iframe | Click/transition sounds silent | Move SFX Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
1003| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
1004| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
1005| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
1006| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
1007| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
1008| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
1009| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
1010| Custom media visualizer uses its own timer | Canvas/playhead drifts from the actual video or native controls | Drive visual state from media events and `media.currentTime`; do not use an independent `setTimeout` clock |
1011| One `data-wired` flag means two different things | Pre-rendered timeline HTML skips media listener setup | Use separate markers such as `data-timeline-rendered` and `data-media-sync-wired` |
1012| Presenter media events are not bridged | Audience follows slides but not play/pause/seek/mute | Mirror native media events over `BroadcastChannel("hf-slideshow:" + location.pathname)` in standalone wrappers with interactive media |
1013| Remote play is blocked in the audience window | Audience media time jumps but video never plays | Try muted playback first; if `media.play()` rejects, show an audience unlock button and ignore live `timeupdate` chasing until playback succeeds |
1014| Audience muted autoplay publishes back to presenter | Presenter audio starts, then mutes or cuts out | Publish media events only from presenter mode; audience mute is a local browser-autoplay workaround, not shared media state |
1015| Copied media lacks HyperFrames timing | Lint errors on untimed media; preview/render diverge | Add `data-start`, `data-duration`, and `data-has-audio="true"` when audible; avoid `preload="none"` |
1016| Source font CSS variables kept as font-family values | StaticGuard font-family contract errors | Replace with concrete render-safe stacks or add local `@font-face` declarations |
1017| Converted scroll/camera source jumps between slides | Per-slide focal points are correct but manual navigation snaps | Add a standalone navigation-camera transition hook; disable it for measurement, initial load, resize, and render/validation seeks |

8. Foot-gun checklist

FailureSymptomOne-line fix
Island not duplicated in wrapperSlideshow chrome never renders; no slide counter, no prev/nextCopy the <script type="application/hyperframes-slideshow+json"> block verbatim into the <hyperframes-slideshow> element in demo.html
Wrapper SFX in the iframeClick/transition sounds silentMove SFX Audio elements and unlock logic to demo.html; post {type:'hf-sfx',name} from index.html
No self-clock in compositionAll scene frames stacked / wrong slide visible at loadAdd the root GSAP timeline (window.__timelines["root"]) and the onUpdate visibility controller as shown in Section 3
Content opacity:0 with no engineBlank slides — [data-anim] elements invisible at restCall updateVisibility(0) synchronously after defining the controller so the first slide is shown immediately
Keydown bound to the element without focusArrowLeft/Right deadAdd tabindex="0" to <hyperframes-slideshow> so it can receive keyboard focus
Opaque scene background occluding Three.js canvas3D never visibleSet background: transparent on .scene-frame; put the visual fill on the text scrim container instead
WebGL renderer creation spams errors in headless envsConsole noise, rAF loop starts anywaySilence console.error during new THREE.WebGLRenderer(...), restore in finally, guard the rAF start on renderer !== null
Branch scene missing from postMessage manifestHotspot navigates but slide is blankInclude every scene — main line and branch — in the scenes array of the postTimeline() message
Prominent 3D/content in nav-capsule zoneBright element bleeds behind/beside the nav pillKeep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner
Custom media visualizer uses its own timerCanvas/playhead drifts from the actual video or native controlsDrive visual state from media events and media.currentTime; do not use an independent setTimeout clock
One data-wired flag means two different thingsPre-rendered timeline HTML skips media listener setupUse separate markers such as data-timeline-rendered and data-media-sync-wired
Presenter media events are not bridgedAudience follows slides but not play/pause/seek/muteMirror native media events over BroadcastChannel("hf-slideshow:" + location.pathname) in standalone wrappers with interactive media
Remote play is blocked in the audience windowAudience media time jumps but video never playsTry muted playback first; if media.play() rejects, show an audience unlock button and ignore live timeupdate chasing until playback succeeds
Audience muted autoplay publishes back to presenterPresenter audio starts, then mutes or cuts outPublish media events only from presenter mode; audience mute is a local browser-autoplay workaround, not shared media state
Copied media lacks HyperFrames timingLint errors on untimed media; preview/render divergeAdd data-start, data-duration, and data-has-audio="true" when audible; avoid preload="none"
Source font CSS variables kept as font-family valuesStaticGuard font-family contract errorsReplace with concrete render-safe stacks or add local @font-face declarations
Converted scroll/camera source jumps between slidesPer-slide focal points are correct but manual navigation snapsAdd a standalone navigation-camera transition hook; disable it for measurement, initial load, resize, and render/validation seeks
Raw lines 997 to 1017