From b7a1163753adc7efff0f15ba79ef0caaf0f06aa4 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 19 Jun 2026 01:35:09 -0700 Subject: [PATCH] docs(skill): slideshow authoring guidance + standalone harness reference (#1583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(player): slideshow controller state machine Stack-split from the original ss-player PR (#1581): the SlideshowController state machine (stack-based slide/fragment/branch navigation) and its tests. The web component follows in the next PR. * feat(player): web component + presenter Stack-split from the original ss-player PR (#1581): the custom element (wraps , drives the controller), presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook. * feat(studio): slideshow manifest persistence + panel helpers Stack-split from the original ss-studio PR (#1582): the data layer — setSlideshowManifest, the useSlideshowPersist hook, and panel helpers. The editor panel UI follows in the next PR. * feat(studio): slideshow branching editor panel UI Stack-split from the original ss-studio PR (#1582): the SlideshowPanel / SlideshowSubPanels editor UI, right-panel wiring, and app integration. * docs(skill): slideshow authoring guidance + standalone harness reference New /slideshow skill (island schema, slide rules, fragments, branching, validation) + a standalone-harness reference doc, and a router entry in the /hyperframes skill. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- skills/hyperframes/SKILL.md | 1 + skills/slideshow/SKILL.md | 396 ++++++++++++ .../references/standalone-harness.md | 597 ++++++++++++++++++ 3 files changed, 994 insertions(+) create mode 100644 skills/slideshow/SKILL.md create mode 100644 skills/slideshow/references/standalone-harness.md diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index efbb0bae2..fd60abb06 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -28,6 +28,7 @@ metadata: | You want to… | Go to | | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | | **Make a video** (from a URL, brief, topic, GitHub PR, existing footage, or a single element to animate) | the **video router below** (§ Video routing) | +| **Author a slideshow / presentation / pitch deck** — discrete slides, fragments, branching, hotspots | `/slideshow` | | **Author / edit an HTML composition** — the `data-*` contract, clips, tracks, sub-compositions, variables | `/hyperframes-core` | | **Animate** — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU) | `/hyperframes-animation` | | **Creative direction** — `design.md`, palettes, typography, narration, beat planning, audio-reactive | `/hyperframes-creative` | diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md new file mode 100644 index 000000000..0bce7d8d6 --- /dev/null +++ b/skills/slideshow/SKILL.md @@ -0,0 +1,396 @@ +--- +name: slideshow +description: > + Author a HyperFrames slideshow composition — a presentation, pitch deck, or + interactive deck with discrete slides, fragment reveals, branching sequences, + and hotspot navigation. Read when the request is to build or edit a slideshow, + presentation, or pitch deck as a HyperFrames composition. +--- + +# Slideshow authoring contract + +A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, GSAP timelines — with one extra ingredient: a **JSON island** that declares which scenes are slides and how they connect. The player's `SlideshowController` reads the island and turns the continuous GSAP timeline into a discrete, navigable deck. + +**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component. + +--- + +## The two pieces + +### 1. Scenes — declared the normal way + +Every slide is backed by a scene. Declare scenes with `data-composition-id`, `data-start`, `data-duration`, and `data-label`: + +```html +
+ +
+``` + +Branch slides (reachable only via a hotspot, excluded from the main line) are declared exactly the same way — they just appear only in a `slideSequences` entry in the island, not in the main `slides` array. + +### 2. The JSON island — one script block per composition + +Add exactly one ` +``` + +The island is the single source of truth for slide order, notes, fragment hold-points, hotspots, and branch sequences. Keep it near the top of the ``, 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 +

+
+
+ + +
+
+

+ 2.3M SMBs × $17k ACV = $39B serviceable market +

+
+
+ + + +``` + +### 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 000000000..cef61303e --- /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 `