vexy-stax-js 3.1.2 → 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,96 @@
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
+
7
97
  ## [3.0.13] — issues 341, 342
8
98
 
9
99
  ### Added
package/README.md CHANGED
@@ -44,13 +44,33 @@ Three ways to drop a deck on a page, easiest first (issues 341 / 342).
44
44
 
45
45
  <!-- …or point at a full scene JSON (with captions, camera, transition, …): -->
46
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>
47
57
  ```
48
58
 
49
59
  **Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
50
60
  toggles compact↔expanded. Opt out with `click-toggle="false"`.
51
61
 
52
- **Scene-in-init** (issue 342): assign an inline scene **object** without a URL —
53
- `el.scene = { version: 1, slides: [{ src: "a.png" }, …] }` (or the `config` property).
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.
54
74
 
55
75
  ### ES Module — `createStax`
56
76
 
@@ -75,7 +95,7 @@ const low = new VexyStax(container, scene); // the low-level path is still avai
75
95
  ```
76
96
 
77
97
  `createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
78
- height, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
98
+ height, aspect, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
79
99
  `transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
80
100
 
81
101
  ### Global script — `window.VexyStax`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexy-stax-js",
3
- "version": "3.1.2",
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": {
@@ -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
@@ -3,9 +3,12 @@
3
3
  //
4
4
  // <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
5
5
  // (space/newline-separated image URLs — issue 341), captions (bool), view,
6
- // mode (static|playable|scrollspy), width, height. Property `config` accepts an
7
- // inline scene object (overrides `scene`/`slides`). Events: ready, transitionstart,
8
- // transitionend. Mounts a VexyStax in light DOM. Auto-registers on import.
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.
9
12
 
10
13
  import { VexyStax, loadScene, makeScene } from "./index.js";
11
14
 
@@ -19,7 +22,11 @@ export class VexyStaxElement extends HTMLElement {
19
22
  static get observedAttributes() {
20
23
  // `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
21
24
  // `click-toggle` is the issue-342 opt-out for the default click-to-toggle behavior.
22
- return ["scene", "slides", "captions", "view", "mode", "trigger", "width", "height", "click-toggle"];
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
+ ];
23
30
  }
24
31
 
25
32
  constructor() {
@@ -80,7 +87,7 @@ export class VexyStaxElement extends HTMLElement {
80
87
 
81
88
  attributeChangedCallback(name) {
82
89
  if (!this.isConnected) return;
83
- if (name === "width" || name === "height") {
90
+ if (name === "width" || name === "height" || name === "aspect") {
84
91
  this._applySize();
85
92
  this._stax?.resize();
86
93
  return;
@@ -104,6 +111,36 @@ export class VexyStaxElement extends HTMLElement {
104
111
  const h = this.getAttribute("height");
105
112
  if (w) this.style.width = /^\d+$/.test(w) ? `${w}px` : w;
106
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
+ }
107
144
  }
108
145
 
109
146
  async _mount() {
@@ -114,11 +151,12 @@ export class VexyStaxElement extends HTMLElement {
114
151
  this._stax = null;
115
152
 
116
153
  const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
117
- // Source precedence (issue 341): inline `config` object → `scene` URL `slides` list.
118
- // `slides` is the easy path: a space/newline-separated list of image URLs (local, data:,
119
- // or remote http(s)) built into a scene via makeScene. `captions` (bool attr) toggles
120
- // caption plates (default on; here off unless slides carry their own — empty by default).
121
- const sceneSrc = this._config ?? this.getAttribute("scene");
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");
122
160
  const slidesAttr = this.getAttribute("slides");
123
161
  let scene;
124
162
  if (sceneSrc) {
@@ -158,6 +196,19 @@ export class VexyStaxElement extends HTMLElement {
158
196
  this._stax?.enableClickToggle();
159
197
  }
160
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
+
161
212
  this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
162
213
  } catch (err) {
163
214
  this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
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/index.js CHANGED
@@ -11,6 +11,7 @@ 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 {
@@ -57,6 +58,7 @@ export class VexyStax {
57
58
  this._currentView = scene.view === "compact" ? "compact" : "expanded";
58
59
  this._morphT = this._currentView === "compact" ? 0 : 1;
59
60
  this._clickToggle = null; // { handler } when wired (enableClickToggle)
61
+ this._controls = null; // { destroy } when wired (controls(), issue 343)
60
62
  this._toggling = false; // guard against overlapping toggle transitions
61
63
  this._ready = this.stage.init().then(() => {
62
64
  this.stage.render();
@@ -95,8 +97,8 @@ export class VexyStax {
95
97
  await this._ready;
96
98
  this.stage.setView(view);
97
99
  this.stage.render();
98
- this._currentView = view === "compact" ? "compact" : "expanded";
99
- this._morphT = this._currentView === "compact" ? 0 : 1;
100
+ this._morphT = view === "compact" ? 0 : 1;
101
+ this._setView_(view === "compact" ? "compact" : "expanded");
100
102
  return this;
101
103
  }
102
104
 
@@ -112,10 +114,19 @@ export class VexyStax {
112
114
  // Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
113
115
  // whether it is currently nearer compact (→ expand) or expanded (→ collapse).
114
116
  this._morphT = tt;
115
- this._currentView = tt >= 0.5 ? "expanded" : "compact";
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");
116
120
  return this;
117
121
  }
118
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
+
119
130
  /** Resize the renderer/camera to the container (or explicit size). */
120
131
  resize(width, height) {
121
132
  const w = width ?? this.container.clientWidth;
@@ -186,7 +197,10 @@ export class VexyStax {
186
197
  const t = this._morphFromGaps(state.gaps);
187
198
  this.stage.applyFrameState(state, t);
188
199
  this.stage.render();
189
- }, { 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 });
190
204
 
191
205
  this._cancelTransition = controller.cancel;
192
206
  this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
@@ -195,7 +209,7 @@ export class VexyStax {
195
209
  // Settle the tracked view to the transition's end endpoint (issue 342).
196
210
  const { endMorph } = transitionEndpoints(resolvedKind);
197
211
  this._morphT = endMorph;
198
- this._currentView = endMorph >= 0.5 ? "expanded" : "compact";
212
+ this._setView_(endMorph >= 0.5 ? "expanded" : "compact");
199
213
  this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
200
214
  } finally {
201
215
  this._cancelTransition = null;
@@ -217,11 +231,13 @@ export class VexyStax {
217
231
  this._toggling = true;
218
232
  try {
219
233
  // The scene may have no `transition` section (e.g. a slides-only deck) — supply timing so
220
- // toggle still animates. transition() reads scene.transition for timing; ensure one exists.
234
+ // toggle still animates. transition() reads scene.transition for the easing; ensure one exists.
221
235
  if (!this.scene.transition) {
222
- this.scene.transition = { kind, duration: 0.9, wait: 0, fps: 30, easing: "easeInOutCubic" };
236
+ this.scene.transition = { kind, duration: 0.7, wait: 0, fps: 30, easing: "easeInOutCubic" };
223
237
  }
224
- await this.transition(kind);
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 });
225
241
  } finally {
226
242
  this._toggling = false;
227
243
  }
@@ -249,6 +265,19 @@ export class VexyStax {
249
265
  return this;
250
266
  }
251
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
+
252
281
  /** Remove the click-to-toggle handler (issue 342 opt-out). */
253
282
  disableClickToggle() {
254
283
  if (this._clickToggle) {
@@ -268,11 +297,26 @@ export class VexyStax {
268
297
  }
269
298
 
270
299
  /**
271
- * Record the transition to a video Blob (WebCodecs preferred, MediaRecorder
272
- * 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
+ *
273
317
  * @param {object} [opts]
274
318
  * @param {string} [opts.kind] override scene.transition.kind
275
- * @returns {Promise<Blob>}
319
+ * @returns {Promise<Blob>} mp4 Blob (WebCodecs path) or webm Blob (MediaRecorder fallback)
276
320
  */
277
321
  async toVideo(opts = {}) {
278
322
  await this._ready;
@@ -375,6 +419,8 @@ export class VexyStax {
375
419
  this._cancelTransition?.();
376
420
  this._scrollspy?.disconnect?.();
377
421
  this.disableClickToggle();
422
+ this._controls?.destroy();
423
+ this._controls = null;
378
424
  this._ro?.disconnect?.();
379
425
  this._ro = null;
380
426
  this.stage?.dispose();
@@ -385,7 +431,7 @@ export class VexyStax {
385
431
  // MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
386
432
  // `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
387
433
  const MOUNT_KEYS = new Set([
388
- "slides", "scene", "view", "mode", "trigger", "width", "height", "baseUrl", "clickToggle",
434
+ "slides", "scene", "view", "mode", "trigger", "width", "height", "aspect", "baseUrl", "clickToggle",
389
435
  ]);
390
436
 
391
437
  /**
@@ -405,6 +451,7 @@ const MOUNT_KEYS = new Set([
405
451
  * "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
406
452
  * @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
407
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").
408
455
  * @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
409
456
  * ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
410
457
  * transition, background, captions, floor, edge, caption_defaults, …).
@@ -419,6 +466,9 @@ export async function createStax(elOrSelector, opts = {}) {
419
466
  // Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
420
467
  if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
421
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, " / ");
422
472
  if (typeof el.style === "object") {
423
473
  el.style.position = el.style.position || "relative";
424
474
  el.style.display = el.style.display || "block";
@@ -460,5 +510,19 @@ export async function createStax(elOrSelector, opts = {}) {
460
510
  // and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
461
511
  // with `clickToggle: false`.
462
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
+ }
463
527
  return stax;
464
528
  }
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
@@ -277,11 +282,14 @@ export class Stage {
277
282
  opacity: 1,
278
283
  });
279
284
  const mesh = new THREE.Mesh(geometry, material);
280
- // Explicit renderOrder so the transparent draw order is STABLE (reflection -2 <
281
- // floor -1 < plate 0 < border 1 < caption 2). Equal renderOrders (plate==floor==0)
282
- // let three.js distance-sort them, which flips frame-to-frame at the floor line and
283
- // flickers at the bottom of each slide (issue 320 §9).
284
- 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;
285
293
  this.threeScene.add(mesh);
286
294
 
287
295
  // BLURRY mirror reflection (issue 303 §1): a mirror copy below the floor line
@@ -319,7 +327,7 @@ export class Stage {
319
327
  let border = null;
320
328
  if (this._edgeWidth > 0) {
321
329
  border = this._makeBorder(w, h, this._edgeWidth, this._edgeColor);
322
- 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)
323
331
  this.threeScene.add(border.group);
324
332
  }
325
333
 
@@ -450,7 +458,10 @@ export class Stage {
450
458
  borderColor,
451
459
  };
452
460
  const { mesh, material, worldWidth } = makeCaptionSprite(caption.text, style);
453
- 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;
454
465
  this.threeScene.add(mesh);
455
466
  this.captions.push({ sprite: mesh, material, plateIndex: i, caption, worldWidth });
456
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();