vexy-stax-js 3.1.1 → 3.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,166 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.1.4] — issue 701: inline scene + aspect
8
+
9
+ ### Added
10
+
11
+ - **Inline scene via a `<script type="application/json">` child** (701): drop the entire scene JSON
12
+ *inside* the `<vexy-stax>` element — no external `scene` URL and no giant `config` attribute to
13
+ escape. Precedence: `config` object → inline `<script>` scene → `scene` URL → `slides`. Inline
14
+ slide `src` paths resolve against the host page, so use absolute URLs for off-page images. This is
15
+ the clean way to "specify the full scene right where you load the component" (also keeps working
16
+ with the existing `el.scene = {…}` / `config` object/JSON-string paths).
17
+ - **`aspect` attribute / `createStax({ aspect })` option** (701): shapes the embed box via CSS
18
+ `aspect-ratio` so a deck is easy to make short and wide. `aspect="3"` (or `"3/1"`, `"3:1"`,
19
+ `"16:9"`) → a 3:1 box; `:` and `x` are normalized to `/`. The camera reframes the deck to the
20
+ resolved box (the existing ResizeObserver path). `width`/`height` still set explicit CSS sizes;
21
+ the scene's own `size` controls the internal plate aspect.
22
+
23
+ ## [3.0.17] — control-button + docs-scene follow-ups
24
+
25
+ ### Fixed
26
+
27
+ - **Control-button label was stale during scrollspy** (343/344): the single toggle button only
28
+ refreshed on a click-toggle's `transitionend`, so while the deck morphed by SCROLL (`seek`) the
29
+ label lagged (said "Explain" while already expanded). `VexyStax` now emits a **`viewchange`**
30
+ CustomEvent whenever `_currentView` flips — from `seek` (scroll), `setView`, or a transition — and
31
+ the buttons listen to it, so the label always reflects the current view (and thus the action a
32
+ click will perform). Verified: `seek(0.7)` → "Preview", `seek(0.2)` → "Explain".
33
+ - **Demo scene edits were silently overwritten** (build): `docs/airbl-demo.scene.json` and
34
+ `docs/airbl-scrollable.scene.json` were REGENERATED from the shared py testdata on every
35
+ `build:docs`, clobbering hand edits. The demos now have **editable source files** under
36
+ `vexy-stax-js/demo-scenes/` that the build merely COPIES into `docs/` (seeding them once from the
37
+ testdata + default floor if absent). Edit `demo-scenes/airbl-scrollable.scene.json` to customize
38
+ the scrollable demo — it persists. (The slide PNGs are still copied from the py testdata.)
39
+
40
+ ## [3.0.16] — issue 344
41
+
42
+ ### Fixed
43
+
44
+ - **Back slides' caption plates painted over front slides** (344): in the expanded view a caption
45
+ attached to a slide behind the front (e.g. "Halftone fill: Dots") drew ON TOP OF the frontmost
46
+ slide. The three.js plates use `depthWrite:false` and the captions `depthTest:false`, so DRAW
47
+ ORDER — not depth — decides compositing, but every plate shared `renderOrder 0` and every caption
48
+ `renderOrder 2`, so all captions painted over all plates. Each slide now owns a **per-slide
49
+ render-order block** `i*4` (index 0 = backmost): plate `i*4`, border `i*4+1`, caption `i*4+2`. A
50
+ front slide's plate (`(i+1)*4`) therefore paints AFTER — and its opaque pixels cover — a back
51
+ slide's caption (`i*4+2`), while transparent areas still let it show through. Mirrors the pygfx
52
+ engine's issue-327 ordering. (The pygfx + Blender engines occlude correctly via the depth buffer /
53
+ real 3D depth and were unaffected.) Verified: the frontmost slide is no longer overdrawn by the
54
+ caption of the slide behind it.
55
+
56
+ ## [3.0.15] — issue 343
57
+
58
+ ### Added
59
+
60
+ - **Built-in control buttons** (343): an opt-in overlay over the deck that switches views via the
61
+ smooth click-toggle. Two layouts: `buttons="toggle"` — ONE relabeling button ("Explain" while
62
+ compact → expand, then "Preview" while expanded → collapse); `buttons="pair"` — two side-by-side
63
+ buttons ("Explain" / "Preview"). Default placement is just above the bottom, horizontally
64
+ centered; default styling is black text on a barely-there (5 %) blurred black pill. Fully
65
+ customizable: labels via `explain-label`/`preview-label`, placement via `buttons-position`
66
+ (`bottom`|`top`|`bottom-left`|…|`center`), and every visual via `--vexy-btn-*` CSS custom
67
+ properties (`--vexy-btn-color`/`-bg`/`-blur`/`-radius`/`-pad`/…). Exposed three ways:
68
+ the `<vexy-stax buttons="…">` attributes, `createStax(el, { buttons, explainLabel, previewLabel,
69
+ buttonsPosition, buttonStyle })`, and the `VexyStax.controls(opts)` method. New `src/controls.js`
70
+ (`attachControls`). Showcased on the scrollable demo + demo-component. Verified headless: the
71
+ toggle relabels Explain⇄Preview across clicks, the pair renders with custom labels, no errors.
72
+
73
+ ## [3.0.14] — issue 342 (scrollspy polish)
74
+
75
+ ### Fixed
76
+
77
+ - **Click-toggle on the scrollspy demo snapped back to expanded** (342): after a click collapsed
78
+ the deck, the next scroll event re-seeked it to the scroll-position-derived value (expanded). The
79
+ `scrollable.html` scroll handler now uses a **manual-hold** model — a click-toggled state is held
80
+ until the scroll position naturally reaches the matching endpoint, then control hands back to
81
+ scroll seamlessly. The deck no longer snaps back.
82
+ - **Post-toggle compact view was framed "too close" (plates cropped)** (342): `playTransition` ran
83
+ `frameStateAt(scene, t)` **without** the live container aspect, so animated transition frames fell
84
+ back to the scene aspect and the compact endpoint was framed closer than `setView`/`seek` (which
85
+ pass the live aspect). `transition()` now threads `this.stage.camera.aspect` through to
86
+ `playTransition` → `frameStateAt`. Verified: the post-toggle compact camera matches the initial
87
+ compact camera exactly on a wide container.
88
+ - **Click-toggle transition was too slow** (342): a toggle reused the scene's `transition.duration`
89
+ (up to 3 s for scroll-story scenes). `toggleView()` now plays a snappy fixed **0.7 s** leg via a
90
+ new `opts.duration` override on `transition()`/`playTransition()`, independent of the scene timing.
91
+
92
+ ### Changed
93
+
94
+ - **Scrollable demo stage is `100vw × 60vh`** (user request): shorter and wider than the previous
95
+ 2:1 box. The camera fits the deck to this live aspect (see the aspect fix above).
96
+
97
+ ## [3.0.13] — issues 341, 342
98
+
99
+ ### Added
100
+
101
+ - **`makeScene(slides, opts)`** (341): the "extremely easy to use" entry point — build a valid scene
102
+ from a bare list of slide image URLs (or `{src, caption, opacity, gap}` objects) plus a flat
103
+ options bag (`size`, `camera`, `gap`, `transition`, `view`, `background`, `captions`, `floor`,
104
+ `edge`, …). Defaults are filled so `makeScene(["a.png", "b.png"])` renders. Goes through the same
105
+ strict `parseScene` (illegal scenes still throw) and resolves slide srcs against `opts.baseUrl`.
106
+ Exported from `vexy-stax-js`, the element bundle, and the global build.
107
+ - **`createStax(elOrSelector, opts)`** (341): an ESM factory mirroring lines-nano's `createNano`.
108
+ Resolves the element, builds the scene (from `slides` via `makeScene`, or `scene` via `loadScene`
109
+ — a URL **or** an inline object), mounts a `VexyStax`, waits for `ready`, optionally starts a mode
110
+ (`playable`/`scrollspy`), and returns the ready instance. Re-exported from the element bundle so a
111
+ single CDN `<script>` import gives `createStax`/`makeScene`/`loadScene`/`VexyStax`.
112
+ - **`slides` + `captions` attributes on `<vexy-stax>`** (341): `<vexy-stax slides="a.png b.png c.png"
113
+ view="compact" mode="playable">` builds a scene from a space/newline-separated URL list (no scene
114
+ JSON needed). `captions` toggles caption plates. The existing `scene`/`config` paths are unchanged.
115
+ - **Remote slide images** (341): the three.js `TextureLoader` now sets `crossOrigin="anonymous"`, so
116
+ slide `src` may be a remote `http(s)` URL — the image loads cross-origin **and** the canvas stays
117
+ exportable (`toImage`/`toVideo`) when the server sends CORS headers. `resolveSrc` already preserved
118
+ absolute URLs; this is the last piece. Verified headless (a slide referenced by an absolute http
119
+ URL loads and exports a non-trivial PNG).
120
+ - **Click-to-toggle** (342): clicking anywhere inside an interactive container fluently transitions
121
+ between views — not-compact → collapse to compact, compact → expand. It reuses the morph driver (a
122
+ smooth `expand`/`collapse` leg — never a snap) and is **ON by default** for the `<vexy-stax>`
123
+ element and every `createStax` instance, layered **on top of** scrollspy (scroll drives the morph;
124
+ a click still toggles). New methods: `VexyStax.toggleView()` / `.enableClickToggle()` /
125
+ `.disableClickToggle()` and `el.toggleView()`. Opt out via `click-toggle="false"` (attribute) or
126
+ `createStax(el, { clickToggle: false })`.
127
+ - **Scene-in-init** (342): pass a full inline scene **object** at initialization — `createStax(el,
128
+ { scene: {…} })` and the `<vexy-stax>` `el.scene = {…}` property (alongside the existing `config`
129
+ property). No URL / fetch required.
130
+ - **Step-by-step how-to demos in `docs/`** (341): `scripts/build-docs.mjs` now also emits
131
+ `demo-component.html` (declarative `<vexy-stax>`), `demo-module.html` (ESM `createStax`/inline scene
132
+ /click-to-toggle), and `demo-library.html` (global `window.VexyStax.create`) — each a side-by-side
133
+ "minimal code + live element" page modeled on i.vexy.art/dev/lines-nano. The landing page gains a
134
+ "Use it — three ways" section linking to them, and every page documents **both** the co-located
135
+ local bundle and the **jsDelivr CDN** URL (`https://cdn.jsdelivr.net/npm/vexy-stax-js@<version>/…`,
136
+ version read from `package.json`). The existing `playable.html` / `scrollable.html` are unchanged
137
+ except the scrollspy demo now lets a click toggle on top of the scroll.
138
+
139
+ ### Changed
140
+
141
+ - **Default floor → invisible white pane with faint reflections** (`#ffffff` / opacity `0.0` /
142
+ reflectivity `0.1`): `scene.js` `parseFloor`, the JSON schema, and the docs demos now default to a
143
+ floor with **no visible grey rectangle** (opacity 0) and only a whisper of mirror (reflectivity
144
+ 0.1). Kept in exact lockstep with `vexy_stax.scene.Floor` (PY↔JS parity). The previous default was
145
+ a smoked-glass dark tint (`#1a1a1a` / `0.04` / `0.5`).
146
+
147
+ ### Notes
148
+
149
+ - The package version is `3.1.2` (the next patch after the published `3.1.1`); this CHANGELOG entry
150
+ follows the repo's `3.0.x` issue-tracking heading convention for issues 341/342. CDN URLs in the
151
+ docs/README pin to `3.1.2`.
152
+
153
+ ## [3.0.12] — issue 341
154
+
155
+ ### Changed
156
+
157
+ - **`docs/` is now a proper landing page** (341): https://vexy.dev/vexy-stax-js/ used to embed the
158
+ playable animation directly (with the dated grey-reflection floor) and linked to nothing.
159
+ `scripts/build-docs.mjs` now emits `index.html` as a LANDING PAGE — a hero + three cards linking
160
+ to the **Animated demo** (`playable.html`), the **Scrollspy demo** (`scrollable.html`), and the
161
+ **Documentation** at https://vexy.dev/vexy-stax-py/ — plus install/usage snippets, and no embedded
162
+ animation. The two demos are emitted as their own pages using clean-floor scene variants
163
+ (`airbl-demo.scene.json` / `airbl-scrollable.scene.json`, reflectivity 0) so neither shows the grey
164
+ reflection "shadows". All paths are relative so the site works both locally and under
165
+ `/vexy-stax-js/`. Verified headless: both demos mount without errors and the landing cards resolve.
166
+
7
167
  ## [3.0.11] — issues 335, 336, 337
8
168
 
9
169
  ### Added
package/LICENSE CHANGED
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright 2025 Adam Twardoch / VexyArt
189
+ Copyright 2025 Fontlab Ltd.
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -28,30 +28,109 @@ node verify/run.mjs && python3 verify/gate.py
28
28
 
29
29
  ## Usage
30
30
 
31
- ESM:
31
+ Three ways to drop a deck on a page, easiest first (issues 341 / 342).
32
32
 
33
- ```js
34
- import { VexyStax, loadScene } from "vexy-stax-js";
35
- const scene = await loadScene("scene.json");
36
- const stax = new VexyStax(container, scene);
37
- await stax.setView("compact");
38
- const blob = await stax.toImage({ scale: 2 });
39
- ```
40
-
41
- Web Component:
33
+ ### Web Component — just a list of slides
42
34
 
43
35
  ```html
44
36
  <script type="module" src="./dist/vexy-stax.element.js"></script>
37
+
38
+ <!-- The `slides` attribute: a space/newline-separated list of image URLs (local, data:, or
39
+ remote http(s)). Captions off by default; mode="playable" plays the transition once. -->
40
+ <vexy-stax
41
+ slides="layer-0.png layer-1.png https://example.com/layer-2.png"
42
+ view="compact" mode="playable">
43
+ </vexy-stax>
44
+
45
+ <!-- …or point at a full scene JSON (with captions, camera, transition, …): -->
45
46
  <vexy-stax scene="scene.json" view="expanded"></vexy-stax>
47
+
48
+ <!-- …or drop the WHOLE scene inline, right where you load the component (issue 701) —
49
+ a `<script type="application/json">` child, no external URL: -->
50
+ <vexy-stax view="compact" mode="playable" aspect="3">
51
+ <script type="application/json">
52
+ { "version": 1, "transition": { "kind": "expand_collapse" },
53
+ "slides": [ { "src": "https://example.com/layer-0.png" },
54
+ { "src": "https://example.com/layer-1.png" } ] }
55
+ </script>
56
+ </vexy-stax>
46
57
  ```
47
58
 
48
- Global:
59
+ **Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
60
+ toggles compact↔expanded. Opt out with `click-toggle="false"`.
61
+
62
+ **Scene-in-init** (issue 342/701): supply an inline scene **without a URL** three ways — assign a
63
+ JS object (`el.scene = { version: 1, slides: [{ src: "a.png" }, …] }` or the `config` property), a
64
+ JSON-string `config` attribute, or a child **`<script type="application/json">`** holding the whole
65
+ scene (shown above; the no-escaping way to "specify the full scene right where you load the
66
+ component"). Inline slide `src` paths resolve against the host page, so use absolute URLs when the
67
+ images live elsewhere.
68
+
69
+ **Aspect / size** (issue 701): the `aspect` attribute shapes the box via CSS `aspect-ratio` so a
70
+ deck is easy to make short and wide — `aspect="3"` (or `"3/1"`, `"3:1"`, `"16:9"`) gives a 3:1 box;
71
+ the camera reframes the deck to fit. `width`/`height` still set an explicit CSS size, and the
72
+ scene's own `size` controls the internal plate aspect. `createStax(el, { aspect: "3" })` is the ESM
73
+ equivalent.
74
+
75
+ ### ES Module — `createStax`
76
+
77
+ ```js
78
+ import { createStax, makeScene, VexyStax, loadScene } from "vexy-stax-js";
79
+
80
+ // One call: build a scene from a URL list, mount, wait for ready.
81
+ const stax = await createStax("#stage", {
82
+ slides: ["layer-0.png", "https://example.com/layer-1.png"],
83
+ gap: 480, transition: "expand_collapse", mode: "playable",
84
+ });
85
+
86
+ await stax.toggleView(); // fluent compact↔expanded (the default click behavior)
87
+ const mp4 = await stax.toVideo(); // seekable mp4
88
+
89
+ // createStax also accepts an inline scene object (scene-in-init):
90
+ await createStax("#hero", { scene: { version: 1, slides: [{ src: "a.png" }] }, view: "expanded" });
91
+
92
+ // makeScene builds a valid scene from a bare URL list, filling sensible defaults:
93
+ const scene = makeScene(["a.png", "b.png", "c.png"], { gap: 480, transition: "expand_collapse" });
94
+ const low = new VexyStax(container, scene); // the low-level path is still available
95
+ ```
96
+
97
+ `createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
98
+ height, aspect, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
99
+ `transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
100
+
101
+ ### Global script — `window.VexyStax`
49
102
 
50
103
  ```html
51
104
  <script src="./dist/vexy-stax.global.js"></script>
52
- <script>const stax = new VexyStax.VexyStax(el, scene);</script>
105
+ <script>
106
+ VexyStax.create("#stage", { slides: ["a.png", "b.png", "c.png"] });
107
+ </script>
53
108
  ```
54
109
 
110
+ ### CDN (no build step)
111
+
112
+ Every snippet above also works verbatim from the jsDelivr CDN — swap the local bundle path for:
113
+
114
+ ```html
115
+ <!-- Web Component / ESM -->
116
+ <script type="module" src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.element.js"></script>
117
+ <!-- Global script -->
118
+ <script src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.global.js"></script>
119
+ ```
120
+
121
+ ### Remote slide images
122
+
123
+ Slide `src` may be a local path, a `data:` URI, **or a remote `http(s)` URL**. The texture loader
124
+ requests cross-origin images with `crossOrigin="anonymous"`, so a server that sends CORS headers
125
+ lets the image load **and** keeps the canvas exportable (`toImage` / `toVideo`).
126
+
127
+ ### Live demos & how-to pages
128
+
129
+ `npm run build:docs` emits a [docs site](https://vexy.dev/vexy-stax-js/): a landing page, the
130
+ **Animated** (`playable.html`) and **Scrollspy** (`scrollable.html`) demos, plus three side-by-side
131
+ "how to use" pages — `demo-component.html`, `demo-module.html`, `demo-library.html` — each showing
132
+ the minimal code beside the live result (modeled on i.vexy.art/dev/lines-nano).
133
+
55
134
  ## Layout
56
135
 
57
136
  ```
@@ -74,4 +153,4 @@ for the same scene; both are tested against the same fixture vectors.
74
153
 
75
154
  ## License
76
155
 
77
- Apache-2.0 — Copyright 2026 Adam Twardoch / VexyArt
156
+ Apache-2.0 — Copyright 2026 Fontlab Ltd.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexy-stax-js",
3
- "version": "3.1.1",
3
+ "version": "3.1.7",
4
4
  "description": "Browser renderer for the vexy-stax shared scene format: 3D glass plates in two views, with morphable opacity. Ships as ESM, Web Component, and a classic-script global.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,9 +53,9 @@
53
53
  "type": "object",
54
54
  "additionalProperties": false,
55
55
  "properties": {
56
- "color": { "type": "string", "default": "#1a1a1a" },
57
- "opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.04, "description": "Smoked glass ~4% (issue 303)." },
58
- "reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }
56
+ "color": { "type": "string", "default": "#ffffff" },
57
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.0, "description": "Invisible floor pane by default (no grey rectangle); faint reflections only." },
58
+ "reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }
59
59
  }
60
60
  },
61
61
  "edge": {
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: vexy-stax-js/src/controls.js
3
+ //
4
+ // Built-in control buttons (issue 343): a small overlay over the deck that switches between the
5
+ // compact ("Preview") and expanded ("Explain") views via the smooth click-toggle morph. Two layouts:
6
+ // - "toggle" (default): ONE relabeling button — "Explain" while compact (click → expand), then
7
+ // "Preview" while expanded (click → collapse back to compact).
8
+ // - "pair": TWO buttons side-by-side — "Explain" (→ expand) and "Preview" (→ compact).
9
+ // Default placement is just above the bottom, horizontally centered. Default styling is black text
10
+ // on a barely-there (5%) blurred black pill. Everything is themeable via CSS custom properties
11
+ // (`--vexy-btn-*` on the element) and the labels/position/type are options.
12
+
13
+ // Simple position presets (the `position` option). All are relative to the component box.
14
+ const POSITIONS = {
15
+ bottom: "left:50%;bottom:6%;transform:translateX(-50%);",
16
+ top: "left:50%;top:6%;transform:translateX(-50%);",
17
+ "bottom-left": "left:5%;bottom:6%;",
18
+ "bottom-right": "right:5%;bottom:6%;",
19
+ "top-left": "left:5%;top:6%;",
20
+ "top-right": "right:5%;top:6%;",
21
+ center: "left:50%;top:50%;transform:translate(-50%,-50%);",
22
+ };
23
+
24
+ let _styleInjected = false;
25
+
26
+ /** Safari does not apply backdrop-filter over a WebGL canvas; use a solid pill there instead. */
27
+ function isSafari(doc) {
28
+ const ua = doc.defaultView?.navigator?.userAgent ?? "";
29
+ return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|EdgiOS|Edg\//i.test(ua);
30
+ }
31
+
32
+ function injectStyle(doc) {
33
+ if (_styleInjected || !doc?.head) return;
34
+ _styleInjected = true;
35
+ const s = doc.createElement("style");
36
+ // All visual knobs are CSS custom properties so a host overrides them with one rule, e.g.
37
+ // vexy-stax { --vexy-btn-color:#fff; --vexy-btn-bg:rgba(0,0,0,.4); --vexy-btn-blur:14px; }
38
+ // Safari: backdrop-filter is ignored over canvas — use --vexy-btn-bg-solid instead (issue 343).
39
+ const safariBtn = isSafari(doc)
40
+ ? `
41
+ .vexy-stax-controls button{
42
+ -webkit-backdrop-filter:none;backdrop-filter:none;
43
+ background:var(--vexy-btn-bg-solid,rgba(255,255,255,0.92));
44
+ }
45
+ .vexy-stax-controls button:hover{background:var(--vexy-btn-bg-solid-hover,rgba(255,255,255,0.97))}`
46
+ : "";
47
+ s.textContent = `
48
+ .vexy-stax-controls{position:absolute;z-index:5;display:flex;gap:8px;pointer-events:none}
49
+ .vexy-stax-controls button{
50
+ pointer-events:auto;cursor:pointer;font:inherit;
51
+ font-size:var(--vexy-btn-font,14px);font-weight:var(--vexy-btn-weight,600);letter-spacing:.01em;line-height:1;
52
+ color:var(--vexy-btn-color,#000);
53
+ background:var(--vexy-btn-bg,rgba(0,0,0,0.05));
54
+ -webkit-backdrop-filter:blur(var(--vexy-btn-blur,10px));backdrop-filter:blur(var(--vexy-btn-blur,10px));
55
+ border:var(--vexy-btn-border,1px solid rgba(0,0,0,0.08));
56
+ border-radius:var(--vexy-btn-radius,999px);
57
+ padding:var(--vexy-btn-pad,9px 18px);
58
+ box-shadow:var(--vexy-btn-shadow,0 2px 10px rgba(0,0,0,0.06));
59
+ transition:background .15s,transform .12s;
60
+ }
61
+ .vexy-stax-controls button:hover{background:var(--vexy-btn-bg-hover,rgba(0,0,0,0.10))}
62
+ .vexy-stax-controls button:active{transform:translateY(1px)}${safariBtn}
63
+ `;
64
+ doc.head.appendChild(s);
65
+ }
66
+
67
+ /**
68
+ * Attach control buttons over the deck (issue 343).
69
+ * @param {object} stax the VexyStax instance (uses `_currentView` + `toggleView()`/`setView`)
70
+ * @param {HTMLElement} container the element the canvas lives in (the overlay is appended here)
71
+ * @param {object} [opts]
72
+ * @param {"toggle"|"pair"} [opts.type="toggle"] single relabeling button, or a side-by-side pair
73
+ * @param {string} [opts.explainLabel="Explain"] label for the compact→expand action
74
+ * @param {string} [opts.previewLabel="Preview"] label for the expand→compact action
75
+ * @param {string} [opts.position="bottom"] one of POSITIONS (bottom|top|bottom-left|…|center)
76
+ * @param {string} [opts.style] extra inline CSS appended to the overlay wrapper (full custom position)
77
+ * @returns {{el:HTMLElement, update:()=>void, destroy:()=>void}|null}
78
+ */
79
+ export function attachControls(stax, container, opts = {}) {
80
+ if (!container?.ownerDocument || !container.appendChild) return null;
81
+ const doc = container.ownerDocument;
82
+ injectStyle(doc);
83
+
84
+ const type = opts.type === "pair" ? "pair" : "toggle";
85
+ const explainLabel = opts.explainLabel ?? "Explain"; // compact → expand
86
+ const previewLabel = opts.previewLabel ?? "Preview"; // expanded → compact
87
+ const posKey = opts.position && POSITIONS[opts.position] ? opts.position : "bottom";
88
+
89
+ // The overlay is absolutely positioned, so the container must establish a positioning context.
90
+ if (container.style && typeof getComputedStyle === "function" && getComputedStyle(container).position === "static") {
91
+ container.style.position = "relative";
92
+ }
93
+
94
+ const wrap = doc.createElement("div");
95
+ wrap.className = "vexy-stax-controls";
96
+ wrap.setAttribute("style", POSITIONS[posKey] + (opts.style ?? ""));
97
+
98
+ const isCompact = () => stax._currentView === "compact";
99
+ let update;
100
+
101
+ if (type === "pair") {
102
+ const bExplain = doc.createElement("button");
103
+ bExplain.type = "button";
104
+ bExplain.textContent = explainLabel;
105
+ bExplain.addEventListener("click", () => { if (isCompact()) stax.toggleView(); });
106
+ const bPreview = doc.createElement("button");
107
+ bPreview.type = "button";
108
+ bPreview.textContent = previewLabel;
109
+ bPreview.addEventListener("click", () => { if (!isCompact()) stax.toggleView(); });
110
+ wrap.append(bExplain, bPreview);
111
+ update = () => {}; // both labels are always shown
112
+ } else {
113
+ const b = doc.createElement("button");
114
+ b.type = "button";
115
+ b.addEventListener("click", () => stax.toggleView());
116
+ wrap.append(b);
117
+ update = () => { b.textContent = isCompact() ? explainLabel : previewLabel; };
118
+ update();
119
+ }
120
+
121
+ container.appendChild(wrap);
122
+
123
+ // Keep the single-button label in sync with the CURRENT view however it changes — a click-toggle,
124
+ // a setView, OR a scroll-driven seek (issue 344 follow-up). VexyStax emits a "viewchange"
125
+ // CustomEvent on the container whenever `_currentView` flips, including mid-scrollspy, so the
126
+ // label is never stale (a click during a scroll always shows the action it will perform).
127
+ const onChange = () => update();
128
+ container.addEventListener("viewchange", onChange);
129
+
130
+ return {
131
+ el: wrap,
132
+ update,
133
+ destroy() {
134
+ container.removeEventListener("viewchange", onChange);
135
+ wrap.remove();
136
+ },
137
+ };
138
+ }
package/src/element.js CHANGED
@@ -1,16 +1,32 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // this_file: src/element.js
3
3
  //
4
- // <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), view,
5
- // mode (static|playable|scrollspy), width, height. Property `config` accepts an
6
- // inline scene object (overrides `scene`). Events: ready, transitionstart,
7
- // transitionend. Mounts a VexyStax in light DOM. Auto-registers on import.
8
-
9
- import { VexyStax, loadScene } from "./index.js";
4
+ // <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
5
+ // (space/newline-separated image URLs issue 341), captions (bool), view,
6
+ // mode (static|playable|scrollspy), width, height, aspect (CSS aspect-ratio,
7
+ // e.g. "3", "3/1", "3:1" issue 701). Property `config` accepts an inline scene
8
+ // object (overrides `scene`/`slides`); an inline `<script type="application/json">`
9
+ // child also supplies the scene (issue 701 — "specify the full scene right where
10
+ // you load the component"). Events: ready, transitionstart, transitionend. Mounts
11
+ // a VexyStax in light DOM. Auto-registers on import.
12
+
13
+ import { VexyStax, loadScene, makeScene } from "./index.js";
14
+
15
+ // Re-export the public ESM API from the element bundle (issue 341): the built
16
+ // dist/vexy-stax.element.js has src/element.js as its entry, so a user who loads that single
17
+ // file can also `import { createStax, makeScene, loadScene, VexyStax }` from it (the how-to
18
+ // ESM demo + the documented CDN URL both rely on this).
19
+ export { VexyStax, loadScene, parseScene, makeScene, createStax } from "./index.js";
10
20
 
11
21
  export class VexyStaxElement extends HTMLElement {
12
22
  static get observedAttributes() {
13
- return ["scene", "view", "mode", "trigger", "width", "height"];
23
+ // `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
24
+ // `click-toggle` is the issue-342 opt-out for the default click-to-toggle behavior.
25
+ // `buttons` + `explain-label`/`preview-label`/`buttons-position` are the issue-343 control buttons.
26
+ return [
27
+ "scene", "slides", "captions", "view", "mode", "trigger", "width", "height", "aspect",
28
+ "click-toggle", "buttons", "explain-label", "preview-label", "buttons-position",
29
+ ];
14
30
  }
15
31
 
16
32
  constructor() {
@@ -20,7 +36,7 @@ export class VexyStaxElement extends HTMLElement {
20
36
  this._mounting = false;
21
37
  }
22
38
 
23
- /** Inline scene object (or JSON string); overrides the `scene` attribute. */
39
+ /** Inline scene object (or JSON string); overrides the `scene`/`slides` attributes. */
24
40
  set config(value) {
25
41
  this._config = typeof value === "string" ? JSON.parse(value) : value;
26
42
  if (this.isConnected) this._mount();
@@ -29,6 +45,26 @@ export class VexyStaxElement extends HTMLElement {
29
45
  return this._config;
30
46
  }
31
47
 
48
+ /**
49
+ * Scene-in-init (issue 342): assigning an OBJECT (or JSON string) sets the inline scene
50
+ * (same as `config`), so `el.scene = {version:1, slides:[…]}` works at init. Assigning a
51
+ * STRING URL is treated as the `scene` attribute (a URL to fetch). This makes the property
52
+ * mirror the lines-nano-style "pass the data right in" ergonomics for the Web Component.
53
+ */
54
+ set scene(value) {
55
+ if (value && typeof value === "object") {
56
+ this.config = value; // inline scene object
57
+ } else if (typeof value === "string") {
58
+ // Looks like JSON? treat as an inline scene; otherwise it's a URL attribute.
59
+ const trimmed = value.trim();
60
+ if (trimmed.startsWith("{")) this.config = JSON.parse(trimmed);
61
+ else this.setAttribute("scene", value);
62
+ }
63
+ }
64
+ get scene() {
65
+ return this._config ?? this.getAttribute("scene");
66
+ }
67
+
32
68
  /** The underlying VexyStax instance (null until mounted). */
33
69
  get instance() {
34
70
  return this._stax;
@@ -51,7 +87,7 @@ export class VexyStaxElement extends HTMLElement {
51
87
 
52
88
  attributeChangedCallback(name) {
53
89
  if (!this.isConnected) return;
54
- if (name === "width" || name === "height") {
90
+ if (name === "width" || name === "height" || name === "aspect") {
55
91
  this._applySize();
56
92
  this._stax?.resize();
57
93
  return;
@@ -60,6 +96,12 @@ export class VexyStaxElement extends HTMLElement {
60
96
  this._stax?.setView(this.getAttribute("view") || "expanded");
61
97
  return;
62
98
  }
99
+ if (name === "click-toggle") {
100
+ // Toggle the issue-342 behavior in place (no costly remount).
101
+ if (this.getAttribute("click-toggle") === "false") this._stax?.disableClickToggle();
102
+ else this._stax?.enableClickToggle();
103
+ return;
104
+ }
63
105
  // scene/mode changes re-mount.
64
106
  this._mount();
65
107
  }
@@ -69,6 +111,36 @@ export class VexyStaxElement extends HTMLElement {
69
111
  const h = this.getAttribute("height");
70
112
  if (w) this.style.width = /^\d+$/.test(w) ? `${w}px` : w;
71
113
  if (h) this.style.height = /^\d+$/.test(h) ? `${h}px` : h;
114
+ // Issue 701: `aspect` sets the CSS aspect-ratio so the embed box is easy to shape
115
+ // (e.g. aspect="3" / "3/1" / "3:1" → a short, wide 3:1 deck). Accepts the CSS
116
+ // `<width>/<height>` or bare-ratio forms; ":" and "x" are normalized to "/". With
117
+ // only `aspect` (no height) the element's height follows from its width, and the
118
+ // ResizeObserver reframes the camera to the resolved box.
119
+ const aspect = this.getAttribute("aspect");
120
+ if (aspect && aspect.trim()) {
121
+ this.style.aspectRatio = aspect.trim().replace(/[:x]/i, " / ");
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Issue 701: an inline scene declared as a child `<script type="application/json">` (or
127
+ * `application/vexy-scene+json`). This is the no-escaping way to "specify the full scene
128
+ * right where you load the component" — drop the whole scene JSON inside the element instead
129
+ * of pointing `scene` at a URL. Returns the parsed object (later normalized by loadScene), or
130
+ * null when there is no such child. A `<script>` child is never rendered, so it is invisible.
131
+ */
132
+ _inlineScene() {
133
+ if (typeof this.querySelector !== "function") return null;
134
+ const tag = this.querySelector(
135
+ 'script[type="application/json"], script[type="application/vexy-scene+json"]'
136
+ );
137
+ const text = tag?.textContent?.trim();
138
+ if (!text) return null;
139
+ try {
140
+ return JSON.parse(text);
141
+ } catch (err) {
142
+ throw new Error(`<vexy-stax>: inline <script> scene is not valid JSON (${err.message})`);
143
+ }
72
144
  }
73
145
 
74
146
  async _mount() {
@@ -78,10 +150,26 @@ export class VexyStaxElement extends HTMLElement {
78
150
  this._stax?.destroy();
79
151
  this._stax = null;
80
152
 
81
- const sceneSrc = this._config ?? this.getAttribute("scene");
82
- if (!sceneSrc) return; // nothing to render yet
83
153
  const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
84
- const scene = await loadScene(sceneSrc, { baseUrl });
154
+ // Source precedence (issue 341 + 701): inline `config` object → inline <script> scene →
155
+ // `scene` URL → `slides` list. `slides` is the easy path: a space/newline-separated list
156
+ // of image URLs (local, data:, or remote http(s)) built into a scene via makeScene.
157
+ // `captions` (bool attr) toggles caption plates (default on; here off unless slides carry
158
+ // their own — empty by default).
159
+ const sceneSrc = this._config ?? this._inlineScene() ?? this.getAttribute("scene");
160
+ const slidesAttr = this.getAttribute("slides");
161
+ let scene;
162
+ if (sceneSrc) {
163
+ scene = await loadScene(sceneSrc, { baseUrl });
164
+ } else if (slidesAttr && slidesAttr.trim()) {
165
+ const urls = slidesAttr.split(/\s+/).filter(Boolean);
166
+ const captionsAttr = this.getAttribute("captions");
167
+ const opts = { baseUrl };
168
+ if (captionsAttr !== null) opts.captions = captionsAttr !== "false";
169
+ scene = makeScene(urls, opts);
170
+ } else {
171
+ return; // nothing to render yet
172
+ }
85
173
 
86
174
  const view = this.getAttribute("view") || scene.view || "expanded";
87
175
  scene.view = view;
@@ -99,6 +187,28 @@ export class VexyStaxElement extends HTMLElement {
99
187
  // mode="playable" leaves the deck at its initial view; the host calls
100
188
  // el.transition(...) (e.g. on a button) to play it.
101
189
 
190
+ // Issue 342: click-to-toggle is ON by default for the interactive container — a click
191
+ // anywhere inside fluently toggles compact↔expanded, layered on top of scrollspy. Opt
192
+ // out with the `click-toggle="false"` attribute (or mode="static" pages that don't want it
193
+ // can still opt out explicitly). Default ON for every mode so a generic <vexy-stax> just
194
+ // works.
195
+ if (this.getAttribute("click-toggle") !== "false") {
196
+ this._stax?.enableClickToggle();
197
+ }
198
+
199
+ // Issue 343: built-in control buttons. `buttons` = "toggle" (single relabeling button) or
200
+ // "pair" (two buttons); a bare `buttons` attribute defaults to "toggle". `explain-label`,
201
+ // `preview-label` and `buttons-position` customize text + placement (style via --vexy-btn-*).
202
+ const buttonsAttr = this.getAttribute("buttons");
203
+ if (buttonsAttr !== null && buttonsAttr !== "false") {
204
+ this._stax?.controls({
205
+ type: buttonsAttr === "pair" ? "pair" : "toggle",
206
+ explainLabel: this.getAttribute("explain-label") ?? undefined,
207
+ previewLabel: this.getAttribute("preview-label") ?? undefined,
208
+ position: this.getAttribute("buttons-position") ?? undefined,
209
+ });
210
+ }
211
+
102
212
  this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
103
213
  } catch (err) {
104
214
  this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
@@ -127,6 +237,10 @@ export class VexyStaxElement extends HTMLElement {
127
237
  seek(t) {
128
238
  return this._stax?.seek(t);
129
239
  }
240
+ /** Issue 342: fluently toggle compact↔expanded (the default click behavior, exposed). */
241
+ toggleView() {
242
+ return this._stax?.toggleView();
243
+ }
130
244
  }
131
245
 
132
246
  if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
package/src/geometry.js CHANGED
@@ -122,9 +122,18 @@ export function captionBaselineY(scene) {
122
122
  return -(scene.size.height / 2.0) + CAPTION_BASELINE_EM * captionSize(scene);
123
123
  }
124
124
 
125
- /** Per-slide gap (points), falling back to camera.gap when unset (null). */
125
+ /**
126
+ * Per-slide gap (points), falling back to camera.gap when unset (null).
127
+ * Resolves a gap of 0 to MIN_GAP.
128
+ */
126
129
  export function plateGaps(scene) {
127
- return scene.slides.map((s) => (s.gap === null || s.gap === undefined ? scene.camera.gap : s.gap));
130
+ return scene.slides.map((s) => {
131
+ let g = (s.gap === null || s.gap === undefined ? scene.camera.gap : s.gap);
132
+ if (g === 0) {
133
+ g = MIN_GAP;
134
+ }
135
+ return g;
136
+ });
128
137
  }
129
138
 
130
139
  /**
package/src/global.js CHANGED
@@ -4,13 +4,19 @@
4
4
  // Classic-script global entry (SPEC.md §6.3). Importing this auto-registers the
5
5
  // <vexy-stax> element (via element.js) and exposes window.VexyStax.
6
6
 
7
- import { VexyStax, loadScene } from "./index.js";
7
+ import { VexyStax, loadScene, makeScene, createStax } from "./index.js";
8
8
  import "./element.js";
9
9
 
10
- const api = { VexyStax, loadScene };
10
+ // Issue 341: expose the easy entry points on the global too. `window.VexyStax.create(el, opts)`
11
+ // mirrors lines-nano's `VexyLinesNano.create`, and makeScene builds a scene from a URL list.
12
+ const api = { VexyStax, loadScene, makeScene, createStax, create: createStax };
11
13
 
12
14
  if (typeof window !== "undefined") {
13
15
  window.VexyStax = api;
14
16
  }
15
17
 
16
- export { VexyStax, loadScene };
18
+ // Also export `create` as a NAMED binding (alias of createStax): Vite's IIFE lib mode assigns
19
+ // `window.VexyStax = <module exports namespace>` AFTER this file runs, overwriting the `api`
20
+ // object above — so `create` must be a real export to survive on `window.VexyStax.create`
21
+ // (the lines-nano-style entry point used by the global demo).
22
+ export { VexyStax, loadScene, makeScene, createStax, createStax as create };
package/src/index.js CHANGED
@@ -7,15 +7,17 @@
7
7
  // mapping, and exporters are in transition.js / scrollspy.js / export.js.
8
8
 
9
9
  import { Stage } from "./stage.js";
10
- import { loadScene, parseScene, resolvedOpacity } from "./scene.js";
10
+ import { loadScene, parseScene, makeScene, resolvedOpacity } from "./scene.js";
11
11
  import { frameStateAt, ease } from "./geometry.js";
12
12
  import { playTransition, transitionEndpoints, buildTimeline, morphAtProgress } from "./transition.js";
13
13
  import { attachScrollspy } from "./scrollspy.js";
14
+ import { attachControls } from "./controls.js";
14
15
  import { canvasToPngBlob, recordVideo } from "./export.js";
15
16
 
16
17
  export {
17
18
  loadScene,
18
19
  parseScene,
20
+ makeScene,
19
21
  resolvedOpacity,
20
22
  };
21
23
 
@@ -50,6 +52,14 @@ export class VexyStax {
50
52
  this.scene = scene;
51
53
  this.stage = new Stage(container, scene);
52
54
  this._ro = null; // ResizeObserver for post-layout resize
55
+ // Issue 342: track the CURRENT view so click-to-toggle knows which way to go. Starts at the
56
+ // scene's initial view; updated by setView/seek/transition/toggleView. `_morphT` is the last
57
+ // applied morph factor (0=compact, 1=expanded) used to disambiguate mid-morph clicks.
58
+ this._currentView = scene.view === "compact" ? "compact" : "expanded";
59
+ this._morphT = this._currentView === "compact" ? 0 : 1;
60
+ this._clickToggle = null; // { handler } when wired (enableClickToggle)
61
+ this._controls = null; // { destroy } when wired (controls(), issue 343)
62
+ this._toggling = false; // guard against overlapping toggle transitions
53
63
  this._ready = this.stage.init().then(() => {
54
64
  this.stage.render();
55
65
  // After mount, observe the container for its first actual layout dimensions.
@@ -87,6 +97,8 @@ export class VexyStax {
87
97
  await this._ready;
88
98
  this.stage.setView(view);
89
99
  this.stage.render();
100
+ this._morphT = view === "compact" ? 0 : 1;
101
+ this._setView_(view === "compact" ? "compact" : "expanded");
90
102
  return this;
91
103
  }
92
104
 
@@ -99,9 +111,22 @@ export class VexyStax {
99
111
  const tt = Math.max(0, Math.min(1, Number(t) || 0));
100
112
  this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
101
113
  this.stage.render();
114
+ // Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
115
+ // whether it is currently nearer compact (→ expand) or expanded (→ collapse).
116
+ this._morphT = tt;
117
+ // Issue 344 follow-up: emit a viewchange when the scroll crosses the compact/expanded midpoint
118
+ // so the control-button label (and any host UI) stays in sync during a scroll-driven morph.
119
+ this._setView_(tt >= 0.5 ? "expanded" : "compact");
102
120
  return this;
103
121
  }
104
122
 
123
+ /** Set `_currentView` and emit a "viewchange" CustomEvent on the container when it changes (343/344). */
124
+ _setView_(view) {
125
+ if (view === this._currentView) return;
126
+ this._currentView = view;
127
+ this.container?.dispatchEvent?.(new CustomEvent("viewchange", { detail: { view } }));
128
+ }
129
+
105
130
  /** Resize the renderer/camera to the container (or explicit size). */
106
131
  resize(width, height) {
107
132
  const w = width ?? this.container.clientWidth;
@@ -172,12 +197,19 @@ export class VexyStax {
172
197
  const t = this._morphFromGaps(state.gaps);
173
198
  this.stage.applyFrameState(state, t);
174
199
  this.stage.render();
175
- }, { kind: resolvedKind, onProgress: opts.onProgress });
200
+ // Pass the LIVE container aspect so the animated frames match setView()/seek() framing
201
+ // (issue 342: otherwise the compact endpoint is framed too close on a wide container).
202
+ // `opts.duration` lets a click-toggle play a snappy leg instead of the full scene timing.
203
+ }, { kind: resolvedKind, onProgress: opts.onProgress, aspect: this.stage.camera.aspect, duration: opts.duration });
176
204
 
177
205
  this._cancelTransition = controller.cancel;
178
206
  this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
179
207
  try {
180
208
  await controller.promise;
209
+ // Settle the tracked view to the transition's end endpoint (issue 342).
210
+ const { endMorph } = transitionEndpoints(resolvedKind);
211
+ this._morphT = endMorph;
212
+ this._setView_(endMorph >= 0.5 ? "expanded" : "compact");
181
213
  this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
182
214
  } finally {
183
215
  this._cancelTransition = null;
@@ -185,6 +217,76 @@ export class VexyStax {
185
217
  return this;
186
218
  }
187
219
 
220
+ /**
221
+ * Click-to-toggle (issue 342): fluently transition between the two views. If the deck is
222
+ * currently expanded (or mid-morph past halfway), collapse to compact; otherwise expand.
223
+ * Reuses the existing morph driver (a smooth `collapse`/`expand` leg) — never a snap. Safe to
224
+ * call repeatedly: an in-flight toggle is ignored until it settles. Returns the played kind.
225
+ */
226
+ async toggleView() {
227
+ await this._ready;
228
+ if (this._toggling) return null;
229
+ const goCompact = this._currentView !== "compact"; // not compact ⇒ collapse to compact
230
+ const kind = goCompact ? "collapse" : "expand";
231
+ this._toggling = true;
232
+ try {
233
+ // The scene may have no `transition` section (e.g. a slides-only deck) — supply timing so
234
+ // toggle still animates. transition() reads scene.transition for the easing; ensure one exists.
235
+ if (!this.scene.transition) {
236
+ this.scene.transition = { kind, duration: 0.7, wait: 0, fps: 30, easing: "easeInOutCubic" };
237
+ }
238
+ // Issue 342: a click-toggle plays a SNAPPY leg (0.7 s) regardless of the scene's
239
+ // transition.duration (which may be a long 3 s scroll-story ramp) — overriding it here.
240
+ await this.transition(kind, { duration: 0.7 });
241
+ } finally {
242
+ this._toggling = false;
243
+ }
244
+ return kind;
245
+ }
246
+
247
+ /**
248
+ * Enable click-to-toggle on the container (issue 342): a pointer click anywhere inside the
249
+ * element fluently toggles compact↔expanded. ON by default for interactive containers (the
250
+ * <vexy-stax> element + createStax) and layered ON TOP of scrollspy (scroll drives the morph;
251
+ * a click still toggles). Idempotent. Pass to disableClickToggle() to opt out.
252
+ */
253
+ enableClickToggle() {
254
+ if (this._clickToggle || !this.container?.addEventListener) return this;
255
+ const handler = (ev) => {
256
+ // Ignore clicks on interactive controls a host may overlay (buttons/links/inputs).
257
+ const tag = ev.target?.tagName;
258
+ if (tag && /^(BUTTON|A|INPUT|SELECT|TEXTAREA|LABEL)$/.test(tag)) return;
259
+ this.toggleView();
260
+ };
261
+ this.container.addEventListener("click", handler);
262
+ // Pointer affordance so it reads as clickable.
263
+ if (this.container.style && !this.container.style.cursor) this.container.style.cursor = "pointer";
264
+ this._clickToggle = { handler };
265
+ return this;
266
+ }
267
+
268
+ /**
269
+ * Add built-in control buttons over the deck (issue 343): a single relabeling toggle
270
+ * ("Explain" → expand, "Preview" → compact) or a side-by-side pair. Frosted, bottom-centered by
271
+ * default; themeable via `--vexy-btn-*` CSS custom properties on the element. Replaces any prior
272
+ * controls. Pass `false` to remove them. Options: `{type:"toggle"|"pair", explainLabel,
273
+ * previewLabel, position, style}`.
274
+ */
275
+ controls(opts = {}) {
276
+ this._controls?.destroy();
277
+ this._controls = opts === false ? null : attachControls(this, this.container, opts);
278
+ return this;
279
+ }
280
+
281
+ /** Remove the click-to-toggle handler (issue 342 opt-out). */
282
+ disableClickToggle() {
283
+ if (this._clickToggle) {
284
+ this.container.removeEventListener?.("click", this._clickToggle.handler);
285
+ this._clickToggle = null;
286
+ }
287
+ return this;
288
+ }
289
+
188
290
  /** Re-derive the morph factor t from a frame's gap[1] (for caption fade). */
189
291
  _morphFromGaps(gaps) {
190
292
  if (gaps.length < 2) return 0;
@@ -195,11 +297,26 @@ export class VexyStax {
195
297
  }
196
298
 
197
299
  /**
198
- * Record the transition to a video Blob (WebCodecs preferred, MediaRecorder
199
- * fallback). Plays the full transition while capturing the canvas.
300
+ * Record the transition to a video Blob.
301
+ *
302
+ * **Encoding path selection** (export.js `recordVideo`):
303
+ * 1. **PRIMARY — WebCodecs + mp4-muxer** (issue 331): uses `VideoEncoder` to
304
+ * encode each rendered frame directly into H.264/mp4 (preferred) or VP9/webm.
305
+ * Produces a fully seekable container with correct duration and per-stream
306
+ * frame-count metadata. Available in Chrome 94+, Edge 94+, and recent Safari.
307
+ * 2. **FALLBACK — MediaRecorder** (`canvas.captureStream`): used when
308
+ * `VideoEncoder` is unavailable (older browsers, some WebViews). Output is a
309
+ * non-seekable webm stream; duration/frame metadata may be absent. The
310
+ * recorded MIME type reflects the first supported codec from
311
+ * `[vp9, vp8, webm, mp4]`.
312
+ *
313
+ * The clip is bookended by held stills: `scene.video.first_hold` copies of the
314
+ * start frame and `scene.video.last_hold` copies of the end frame (default 10
315
+ * each, matching the Python `frame_plan` holds).
316
+ *
200
317
  * @param {object} [opts]
201
318
  * @param {string} [opts.kind] override scene.transition.kind
202
- * @returns {Promise<Blob>}
319
+ * @returns {Promise<Blob>} mp4 Blob (WebCodecs path) or webm Blob (MediaRecorder fallback)
203
320
  */
204
321
  async toVideo(opts = {}) {
205
322
  await this._ready;
@@ -301,8 +418,111 @@ export class VexyStax {
301
418
  destroy() {
302
419
  this._cancelTransition?.();
303
420
  this._scrollspy?.disconnect?.();
421
+ this.disableClickToggle();
422
+ this._controls?.destroy();
423
+ this._controls = null;
304
424
  this._ro?.disconnect?.();
305
425
  this._ro = null;
306
426
  this.stage?.dispose();
307
427
  }
308
428
  }
429
+
430
+ // Scene-construction options consumed by createStax/makeScene rather than describing how to
431
+ // MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
432
+ // `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
433
+ const MOUNT_KEYS = new Set([
434
+ "slides", "scene", "view", "mode", "trigger", "width", "height", "aspect", "baseUrl", "clickToggle",
435
+ ]);
436
+
437
+ /**
438
+ * The "extremely easy to use" ESM factory (issue 341), mirroring lines-nano's `createNano`.
439
+ * Resolve the mount element, build the scene (from a bare `slides` list via makeScene, or
440
+ * from a `scene` URL/object via loadScene), mount a VexyStax, wait until it's ready, optionally
441
+ * start a mode (playable/scrollspy), and return the ready instance.
442
+ *
443
+ * @param {HTMLElement|string} elOrSelector mount element or a CSS selector for it
444
+ * @param {object} [opts]
445
+ * @param {string[]|object[]} [opts.slides] slide image URLs (local/data:/remote) or slide
446
+ * objects — the easy path; built via makeScene with the remaining opts as overrides.
447
+ * @param {string|object} [opts.scene] a scene URL or inline scene object (via loadScene);
448
+ * used when `slides` is not given.
449
+ * @param {"expanded"|"compact"} [opts.view] initial view.
450
+ * @param {"static"|"playable"|"scrollspy"} [opts.mode] mount mode (default "static").
451
+ * "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
452
+ * @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
453
+ * @param {string} [opts.width] @param {string} [opts.height] CSS size overrides on the element.
454
+ * @param {string|number} [opts.aspect] CSS aspect-ratio for the box (e.g. 3, "3/1", "3:1").
455
+ * @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
456
+ * ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
457
+ * transition, background, captions, floor, edge, caption_defaults, …).
458
+ * @returns {Promise<VexyStax>} the ready instance.
459
+ */
460
+ export async function createStax(elOrSelector, opts = {}) {
461
+ const el =
462
+ typeof elOrSelector === "string" ? document.querySelector(elOrSelector) : elOrSelector;
463
+ if (!el) throw new Error(`createStax: element not found (${String(elOrSelector)})`);
464
+ if (opts === null || typeof opts !== "object") throw new Error("createStax: opts must be an object");
465
+
466
+ // Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
467
+ if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
468
+ if (opts.height) el.style.height = /^\d+$/.test(String(opts.height)) ? `${opts.height}px` : opts.height;
469
+ // Issue 701: `aspect` shapes the box via CSS aspect-ratio (e.g. 3, "3/1", "3:1" → a 3:1 deck);
470
+ // ":"/"x" are normalized to "/". Parity with the <vexy-stax aspect="…"> attribute.
471
+ if (opts.aspect) el.style.aspectRatio = String(opts.aspect).trim().replace(/[:x]/i, " / ");
472
+ if (typeof el.style === "object") {
473
+ el.style.position = el.style.position || "relative";
474
+ el.style.display = el.style.display || "block";
475
+ }
476
+
477
+ const baseUrl = opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
478
+
479
+ let scene;
480
+ if (opts.slides) {
481
+ // Flat scene overrides = every opt that isn't a mount/source key.
482
+ const sceneOpts = { baseUrl };
483
+ for (const [k, v] of Object.entries(opts)) {
484
+ if (!MOUNT_KEYS.has(k)) sceneOpts[k] = v;
485
+ }
486
+ scene = makeScene(opts.slides, sceneOpts);
487
+ } else if (opts.scene !== undefined) {
488
+ scene = await loadScene(opts.scene, { baseUrl });
489
+ } else {
490
+ throw new Error("createStax: provide `slides` (URLs) or `scene` (URL/object)");
491
+ }
492
+
493
+ // The view override wins over the scene's own initial view.
494
+ if (opts.view) scene.view = opts.view;
495
+
496
+ const stax = new VexyStax(el, scene);
497
+ await stax.ready;
498
+
499
+ const mode = opts.mode ?? "static";
500
+ if (mode === "scrollspy") {
501
+ const trigger =
502
+ typeof opts.trigger === "string" ? document.querySelector(opts.trigger) : opts.trigger ?? el;
503
+ stax.scrollspy({ trigger });
504
+ } else if (mode === "playable" && scene.transition) {
505
+ // Kick off the scene's transition once (best-effort; ignore if it's cancelled by teardown).
506
+ stax.transition().catch(() => {});
507
+ }
508
+
509
+ // Issue 342: click-to-toggle is ON by default for the interactive container (every mode),
510
+ // and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
511
+ // with `clickToggle: false`.
512
+ if (opts.clickToggle !== false) stax.enableClickToggle();
513
+
514
+ // Issue 343: built-in control buttons. `buttons: true` (or "toggle"/"pair") shows the overlay;
515
+ // `explainLabel`/`previewLabel`/`buttonsPosition`/`buttonStyle` customize it (or pass a full
516
+ // object as `buttons`).
517
+ if (opts.buttons) {
518
+ const c = typeof opts.buttons === "object" ? opts.buttons : { type: opts.buttons === "pair" ? "pair" : "toggle" };
519
+ stax.controls({
520
+ explainLabel: opts.explainLabel,
521
+ previewLabel: opts.previewLabel,
522
+ position: opts.buttonsPosition,
523
+ style: opts.buttonStyle,
524
+ ...c,
525
+ });
526
+ }
527
+ return stax;
528
+ }
package/src/scene.js CHANGED
@@ -136,15 +136,17 @@ function parseVideo(raw) {
136
136
  }
137
137
 
138
138
  function parseFloor(raw) {
139
- // Smoked glass: ~4% opacity, dark tint, reflective (issue 303 §1).
140
- if (raw === undefined) return { color: "#1a1a1a", opacity: 0.04, reflectivity: 0.5 };
139
+ // Default floor: an INVISIBLE white pane (opacity 0 no grey floor rectangle) with faint
140
+ // reflections (reflectivity 0.1) so decks read as floating with just a whisper of mirror.
141
+ // Kept in exact lockstep with vexy_stax.scene.Floor (PY↔JS parity).
142
+ if (raw === undefined) return { color: "#ffffff", opacity: 0.0, reflectivity: 0.1 };
141
143
  const o = asObject(raw, "floor");
142
144
  rejectExtraKeys(o, new Set(["color", "opacity", "reflectivity"]), "floor");
143
145
  return {
144
- color: o.color === undefined ? "#1a1a1a" : str(o.color, "floor.color"),
145
- opacity: o.opacity === undefined ? 0.04 : num(o.opacity, "floor.opacity", { min: 0, max: 1 }),
146
+ color: o.color === undefined ? "#ffffff" : str(o.color, "floor.color"),
147
+ opacity: o.opacity === undefined ? 0.0 : num(o.opacity, "floor.opacity", { min: 0, max: 1 }),
146
148
  reflectivity:
147
- o.reflectivity === undefined ? 0.5 : num(o.reflectivity, "floor.reflectivity", { min: 0, max: 1 }),
149
+ o.reflectivity === undefined ? 0.1 : num(o.reflectivity, "floor.reflectivity", { min: 0, max: 1 }),
148
150
  };
149
151
  }
150
152
 
@@ -290,6 +292,129 @@ export function parseScene(raw) {
290
292
  };
291
293
  }
292
294
 
295
+ // Issue 341: the "extremely easy to use" entry point. Build a valid Scene object from a
296
+ // bare list of slide image URLs (or {src, caption, opacity, gap} objects) + a flat options
297
+ // bag, filling sensible defaults so even `makeScene(["a.png", "b.png"])` renders. The result
298
+ // goes straight through parseScene (so the SAME strict invariants apply — illegal scenes
299
+ // still throw) and is returned normalized, ready for `new VexyStax(el, scene)`. Slide `src`
300
+ // may be a local path, a `data:` URI, OR a remote http(s) URL — resolveSrc/the TextureLoader
301
+ // preserve absolute URLs (and stage.js sets crossOrigin so remote images load + stay
302
+ // canvas-exportable). This keeps "easy scene customization" + "remote URL" in one helper.
303
+
304
+ /** One slide entry → a raw scene-slide object. A bare string is treated as `src`. */
305
+ function slideEntry(entry, index, defaults) {
306
+ if (typeof entry === "string") {
307
+ const slide = { src: entry };
308
+ if (defaults.caption) slide.caption = { text: "", show_in: "expanded" };
309
+ return slide;
310
+ }
311
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
312
+ throw new Error(`makeScene: slides[${index}] must be a string URL or an object`);
313
+ }
314
+ // Accept the friendly shape {src, caption, opacity, gap}. `caption` may be a plain
315
+ // string (→ {text, show_in:"expanded"}) for ergonomics, or the full caption object.
316
+ const slide = {};
317
+ if (entry.src === undefined) throw new Error(`makeScene: slides[${index}].src is required`);
318
+ slide.src = entry.src;
319
+ if (entry.gap !== undefined && entry.gap !== null) slide.gap = entry.gap;
320
+ if (entry.opacity !== undefined) slide.opacity = entry.opacity;
321
+ if (entry.caption !== undefined) {
322
+ slide.caption =
323
+ typeof entry.caption === "string"
324
+ ? { text: entry.caption, show_in: "expanded" }
325
+ : entry.caption;
326
+ }
327
+ return slide;
328
+ }
329
+
330
+ /**
331
+ * Build a normalized Scene from a list of slide image URLs (or slide objects) plus a flat
332
+ * options bag (issue 341). Returns a parsed scene (via parseScene), so it is ready to hand
333
+ * to `new VexyStax(container, scene)`. Sensible defaults are filled so a bare list of URLs
334
+ * renders. Slide `src` may be local, `data:`, or a remote http(s) URL.
335
+ *
336
+ * @param {Array<string|{src:string, caption?:string|object, opacity?:number|object, gap?:number}>} slides
337
+ * slide image URLs or slide objects (at least one).
338
+ * @param {object} [opts] flat scene options:
339
+ * @param {{width:number,height:number}|number[]} [opts.size] scene size (default 1920×1080)
340
+ * @param {object} [opts.camera] camera overrides (gap/distance/angle/elevation/fov)
341
+ * @param {string} [opts.gap] shortcut for camera.gap (expanded plate spacing)
342
+ * @param {string|object} [opts.transition] a transition KIND string, or a full transition object
343
+ * @param {string} [opts.view] initial view ("expanded" | "compact")
344
+ * @param {string} [opts.background] background CSS color
345
+ * @param {boolean} [opts.captions] global captions toggle (default: true)
346
+ * @param {object} [opts.floor] floor overrides (color/opacity/reflectivity)
347
+ * @param {object} [opts.edge] plate edge overrides (width/color)
348
+ * @param {object} [opts.caption_defaults] caption style defaults (size/color/font/…)
349
+ * @param {object} [opts.caption_fade] caption fade overrides
350
+ * @param {object} [opts.video] video render overrides
351
+ * @returns {object} a normalized scene (same shape as parseScene's output)
352
+ */
353
+ export function makeScene(slides, opts = {}) {
354
+ if (!Array.isArray(slides) || slides.length < 1) {
355
+ throw new Error("makeScene: pass a non-empty array of slide URLs or slide objects");
356
+ }
357
+ if (opts === null || typeof opts !== "object" || Array.isArray(opts)) {
358
+ throw new Error("makeScene: opts must be an object");
359
+ }
360
+ // Fail loud on unknown options (parse, don't validate): a typo'd override (e.g. `juicey`)
361
+ // shouldn't be silently dropped. `baseUrl`/`gap` are makeScene conveniences; the rest map
362
+ // 1:1 onto scene keys validated by parseScene.
363
+ const ALLOWED_OPTS = new Set([
364
+ "baseUrl", "gap", "size", "camera", "transition", "view", "background",
365
+ "captions", "floor", "edge", "juicy", "caption_defaults", "caption_fade", "video",
366
+ ]);
367
+ for (const key of Object.keys(opts)) {
368
+ if (!ALLOWED_OPTS.has(key)) throw new Error(`makeScene: unknown option ${JSON.stringify(key)}`);
369
+ }
370
+
371
+ // A bare list of objects with no caption text shouldn't auto-add empty caption plates;
372
+ // `captions` only forces empty captions for STRING slides when explicitly requested.
373
+ const wantCaptions = opts.captions === true;
374
+ const raw = { version: 1, slides: slides.map((s, i) => slideEntry(s, i, { caption: wantCaptions })) };
375
+
376
+ // size: accept {width,height} or [w,h].
377
+ if (opts.size !== undefined) {
378
+ if (Array.isArray(opts.size)) raw.size = { width: opts.size[0], height: opts.size[1] };
379
+ else raw.size = opts.size;
380
+ }
381
+
382
+ // camera: start from explicit overrides, then fold in the `gap` shortcut.
383
+ if (opts.camera !== undefined || opts.gap !== undefined) {
384
+ raw.camera = { ...(opts.camera ?? {}) };
385
+ if (opts.gap !== undefined) raw.camera.gap = opts.gap;
386
+ }
387
+
388
+ // transition: a bare kind string is expanded to a minimal transition object; an object
389
+ // passes through (parseScene fills its own defaults + validates the kind).
390
+ if (opts.transition !== undefined) {
391
+ raw.transition =
392
+ typeof opts.transition === "string" ? { kind: opts.transition } : opts.transition;
393
+ }
394
+
395
+ if (opts.view !== undefined) raw.view = opts.view;
396
+ if (opts.background !== undefined) raw.background = opts.background;
397
+ // captions: only forward an explicit boolean (string-slide empty captions handled above).
398
+ if (typeof opts.captions === "boolean") raw.captions = opts.captions;
399
+ if (opts.floor !== undefined) raw.floor = opts.floor;
400
+ if (opts.edge !== undefined) raw.edge = opts.edge;
401
+ if (opts.juicy !== undefined) raw.juicy = opts.juicy;
402
+ if (opts.caption_defaults !== undefined) raw.caption_defaults = opts.caption_defaults;
403
+ if (opts.caption_fade !== undefined) raw.caption_fade = opts.caption_fade;
404
+ if (opts.video !== undefined) raw.video = opts.video;
405
+
406
+ const scene = parseScene(raw);
407
+ // Resolve slide srcs against an optional base (the host page / scene URL). Absolute
408
+ // http(s) URLs and `data:` URIs are preserved (resolveSrc uses `new URL(src, base)`), so
409
+ // remote slide images keep working; relative paths resolve against the base (issue 341).
410
+ const base =
411
+ opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
412
+ if (base) {
413
+ for (const slide of scene.slides) slide.src = resolveSrc(slide.src, base);
414
+ }
415
+ return scene;
416
+ }
417
+
293
418
  /** Resolve a slide `src` against `base` (a URL string). `data:` URIs pass through. */
294
419
  function resolveSrc(src, base) {
295
420
  if (src.startsWith("data:")) return src;
package/src/stage.js CHANGED
@@ -30,6 +30,11 @@ import {
30
30
  } from "./geometry.js";
31
31
  import { resolvedOpacity } from "./scene.js";
32
32
 
33
+ // Per-slide render-order block size (issue 344): each slide owns [i*BLOCK, i*BLOCK+BLOCK) for its
34
+ // plate (+0), border (+1) and caption (+2). Blocks grow with the slide index (front slides last),
35
+ // so a front slide's plate occludes the captions of slides behind it. Reflections/floor sit below.
36
+ const RENDER_BLOCK = 4;
37
+
33
38
  /**
34
39
  * Build a white OPAQUE bordered caption PLATE (issue 311). The whole caption is one
35
40
  * rectangle drawn on a single canvas texture: solid white fill, a stroked border of
@@ -239,6 +244,12 @@ export class Stage {
239
244
 
240
245
  async _buildPlates() {
241
246
  const loader = new THREE.TextureLoader();
247
+ // Issue 341: load slide images from REMOTE http(s) URLs, not just local paths/data: URIs.
248
+ // setCrossOrigin("anonymous") makes the underlying <img> a CORS request, so a server that
249
+ // sends `Access-Control-Allow-Origin` lets the image load AND keeps the WebGL canvas
250
+ // un-tainted (toImage/toVideo stay exportable). Local same-origin and data: URIs are
251
+ // unaffected. resolveSrc already preserves absolute URLs, so this is the last piece.
252
+ loader.setCrossOrigin("anonymous");
242
253
  const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
243
254
  const reflectivity = this.scene.floor.reflectivity;
244
255
 
@@ -271,11 +282,14 @@ export class Stage {
271
282
  opacity: 1,
272
283
  });
273
284
  const mesh = new THREE.Mesh(geometry, material);
274
- // Explicit renderOrder so the transparent draw order is STABLE (reflection -2 <
275
- // floor -1 < plate 0 < border 1 < caption 2). Equal renderOrders (plate==floor==0)
276
- // let three.js distance-sort them, which flips frame-to-frame at the floor line and
277
- // flickers at the bottom of each slide (issue 320 §9).
278
- mesh.renderOrder = 0;
285
+ // PER-SLIDE render-order blocks (issue 344, mirrors pygfx issue 327): the plates and
286
+ // captions are alpha-blended with depthWrite/Test off, so DRAW ORDER not depth decides
287
+ // compositing. Give each slide a contiguous block `i*BLOCK` that grows with the slide index
288
+ // (index 0 = backmost, last = frontmost), so a FRONT slide's plate (i*BLOCK) paints AFTER —
289
+ // and its opaque pixels cover — a BACK slide's caption (`(i-1)*BLOCK + 2`). Within a block:
290
+ // plate(+0) < border(+1) < caption(+2). Reflections/floor stay below all of it (-2 / -1).
291
+ // Equal renderOrders would let three.js distance-sort and flip frame-to-frame (issue 320 §9).
292
+ mesh.renderOrder = i * RENDER_BLOCK;
279
293
  this.threeScene.add(mesh);
280
294
 
281
295
  // BLURRY mirror reflection (issue 303 §1): a mirror copy below the floor line
@@ -313,7 +327,7 @@ export class Stage {
313
327
  let border = null;
314
328
  if (this._edgeWidth > 0) {
315
329
  border = this._makeBorder(w, h, this._edgeWidth, this._edgeColor);
316
- border.group.renderOrder = 1; // draw on top of the plate
330
+ border.group.renderOrder = i * RENDER_BLOCK + 1; // on top of this slide's plate (issue 344)
317
331
  this.threeScene.add(border.group);
318
332
  }
319
333
 
@@ -444,7 +458,10 @@ export class Stage {
444
458
  borderColor,
445
459
  };
446
460
  const { mesh, material, worldWidth } = makeCaptionSprite(caption.text, style);
447
- mesh.renderOrder = 2; // draw the caption plate on top of the deck + borders
461
+ // Issue 344: this slide's caption draws on top of its OWN plate + border (i*BLOCK + 2), but
462
+ // BELOW the next (more frontward) slide's plate ((i+1)*BLOCK), so front slides occlude the
463
+ // captions of slides behind them.
464
+ mesh.renderOrder = i * RENDER_BLOCK + 2;
448
465
  this.threeScene.add(mesh);
449
466
  this.captions.push({ sprite: mesh, material, plateIndex: i, caption, worldWidth });
450
467
  });
package/src/transition.js CHANGED
@@ -114,8 +114,12 @@ export function playTransition(scene, apply, opts = {}) {
114
114
  const tr = scene.transition;
115
115
  const kind = opts.kind ?? tr?.kind;
116
116
  if (!kind) throw new Error("playTransition: no transition kind (scene.transition is null and no kind given)");
117
- const duration = tr?.duration ?? 3.0;
118
- const wait = tr?.wait ?? 0.0;
117
+ // `opts.duration` overrides the scene timing (issue 342: a click-toggle plays a SNAPPY leg, not
118
+ // the scene's full transition.duration). `opts.aspect` is the LIVE container aspect — without it
119
+ // frameStateAt() falls back to the scene aspect and the compact endpoint is framed too close for
120
+ // a non-scene-aspect container (e.g. the wide scrollable demo).
121
+ const duration = opts.duration ?? tr?.duration ?? 3.0;
122
+ const wait = opts.duration != null ? 0.0 : tr?.wait ?? 0.0;
119
123
  const easing = tr?.easing ?? "easeInOutCubic";
120
124
  const timeline = buildTimeline(kind, { duration, wait });
121
125
 
@@ -147,7 +151,7 @@ export function playTransition(scene, apply, opts = {}) {
147
151
  const elapsed = now() - start;
148
152
  const p = totalMs > 0 ? Math.min(1, elapsed / totalMs) : 1;
149
153
  const t = morphAtProgress(timeline, p, (x) => ease(easing, x));
150
- apply(frameStateAt(scene, t));
154
+ apply(frameStateAt(scene, t, opts.aspect));
151
155
  opts.onProgress?.(p);
152
156
  if (p >= 1) {
153
157
  resolveFn();