vexy-stax-js 3.0.10 → 3.1.2

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,98 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.0.13] — issues 341, 342
8
+
9
+ ### Added
10
+
11
+ - **`makeScene(slides, opts)`** (341): the "extremely easy to use" entry point — build a valid scene
12
+ from a bare list of slide image URLs (or `{src, caption, opacity, gap}` objects) plus a flat
13
+ options bag (`size`, `camera`, `gap`, `transition`, `view`, `background`, `captions`, `floor`,
14
+ `edge`, …). Defaults are filled so `makeScene(["a.png", "b.png"])` renders. Goes through the same
15
+ strict `parseScene` (illegal scenes still throw) and resolves slide srcs against `opts.baseUrl`.
16
+ Exported from `vexy-stax-js`, the element bundle, and the global build.
17
+ - **`createStax(elOrSelector, opts)`** (341): an ESM factory mirroring lines-nano's `createNano`.
18
+ Resolves the element, builds the scene (from `slides` via `makeScene`, or `scene` via `loadScene`
19
+ — a URL **or** an inline object), mounts a `VexyStax`, waits for `ready`, optionally starts a mode
20
+ (`playable`/`scrollspy`), and returns the ready instance. Re-exported from the element bundle so a
21
+ single CDN `<script>` import gives `createStax`/`makeScene`/`loadScene`/`VexyStax`.
22
+ - **`slides` + `captions` attributes on `<vexy-stax>`** (341): `<vexy-stax slides="a.png b.png c.png"
23
+ view="compact" mode="playable">` builds a scene from a space/newline-separated URL list (no scene
24
+ JSON needed). `captions` toggles caption plates. The existing `scene`/`config` paths are unchanged.
25
+ - **Remote slide images** (341): the three.js `TextureLoader` now sets `crossOrigin="anonymous"`, so
26
+ slide `src` may be a remote `http(s)` URL — the image loads cross-origin **and** the canvas stays
27
+ exportable (`toImage`/`toVideo`) when the server sends CORS headers. `resolveSrc` already preserved
28
+ absolute URLs; this is the last piece. Verified headless (a slide referenced by an absolute http
29
+ URL loads and exports a non-trivial PNG).
30
+ - **Click-to-toggle** (342): clicking anywhere inside an interactive container fluently transitions
31
+ between views — not-compact → collapse to compact, compact → expand. It reuses the morph driver (a
32
+ smooth `expand`/`collapse` leg — never a snap) and is **ON by default** for the `<vexy-stax>`
33
+ element and every `createStax` instance, layered **on top of** scrollspy (scroll drives the morph;
34
+ a click still toggles). New methods: `VexyStax.toggleView()` / `.enableClickToggle()` /
35
+ `.disableClickToggle()` and `el.toggleView()`. Opt out via `click-toggle="false"` (attribute) or
36
+ `createStax(el, { clickToggle: false })`.
37
+ - **Scene-in-init** (342): pass a full inline scene **object** at initialization — `createStax(el,
38
+ { scene: {…} })` and the `<vexy-stax>` `el.scene = {…}` property (alongside the existing `config`
39
+ property). No URL / fetch required.
40
+ - **Step-by-step how-to demos in `docs/`** (341): `scripts/build-docs.mjs` now also emits
41
+ `demo-component.html` (declarative `<vexy-stax>`), `demo-module.html` (ESM `createStax`/inline scene
42
+ /click-to-toggle), and `demo-library.html` (global `window.VexyStax.create`) — each a side-by-side
43
+ "minimal code + live element" page modeled on i.vexy.art/dev/lines-nano. The landing page gains a
44
+ "Use it — three ways" section linking to them, and every page documents **both** the co-located
45
+ local bundle and the **jsDelivr CDN** URL (`https://cdn.jsdelivr.net/npm/vexy-stax-js@<version>/…`,
46
+ version read from `package.json`). The existing `playable.html` / `scrollable.html` are unchanged
47
+ except the scrollspy demo now lets a click toggle on top of the scroll.
48
+
49
+ ### Changed
50
+
51
+ - **Default floor → invisible white pane with faint reflections** (`#ffffff` / opacity `0.0` /
52
+ reflectivity `0.1`): `scene.js` `parseFloor`, the JSON schema, and the docs demos now default to a
53
+ floor with **no visible grey rectangle** (opacity 0) and only a whisper of mirror (reflectivity
54
+ 0.1). Kept in exact lockstep with `vexy_stax.scene.Floor` (PY↔JS parity). The previous default was
55
+ a smoked-glass dark tint (`#1a1a1a` / `0.04` / `0.5`).
56
+
57
+ ### Notes
58
+
59
+ - The package version is `3.1.2` (the next patch after the published `3.1.1`); this CHANGELOG entry
60
+ follows the repo's `3.0.x` issue-tracking heading convention for issues 341/342. CDN URLs in the
61
+ docs/README pin to `3.1.2`.
62
+
63
+ ## [3.0.12] — issue 341
64
+
65
+ ### Changed
66
+
67
+ - **`docs/` is now a proper landing page** (341): https://vexy.dev/vexy-stax-js/ used to embed the
68
+ playable animation directly (with the dated grey-reflection floor) and linked to nothing.
69
+ `scripts/build-docs.mjs` now emits `index.html` as a LANDING PAGE — a hero + three cards linking
70
+ to the **Animated demo** (`playable.html`), the **Scrollspy demo** (`scrollable.html`), and the
71
+ **Documentation** at https://vexy.dev/vexy-stax-py/ — plus install/usage snippets, and no embedded
72
+ animation. The two demos are emitted as their own pages using clean-floor scene variants
73
+ (`airbl-demo.scene.json` / `airbl-scrollable.scene.json`, reflectivity 0) so neither shows the grey
74
+ reflection "shadows". All paths are relative so the site works both locally and under
75
+ `/vexy-stax-js/`. Verified headless: both demos mount without errors and the landing cards resolve.
76
+
77
+ ## [3.0.11] — issues 335, 336, 337
78
+
79
+ ### Added
80
+
81
+ - **`video` scene section** (335 / 336): `scene.js` `parseVideo()` + the JSON schema now accept and
82
+ validate the `video` section (`width`/`height`/`fps`/`frames`/`first_hold`/`last_hold`), mirroring
83
+ `vexy_stax.scene.Video`. Previously the strict parser rejected the key with "Unknown key 'video' in
84
+ scene", which broke the Python `playwright` engine (it mounts the scene in `<vexy-stax>`) — issue
85
+ 336. The element now accepts a scene carrying `video`.
86
+ - **Held first/last still frames in `toVideo()`** (335 §2): the video export now bookends the clip
87
+ with held stills — it renders the start endpoint and captures it `video.first_hold` times (default
88
+ 10), plays the transition, then captures the end endpoint `video.last_hold` times (still →
89
+ transition → still). Mirrors `geometry.py`'s `frame_plan` holds. Verified: the exported
90
+ `airbl-transition.mp4` first/last frames are static.
91
+
92
+ ### Fixed
93
+
94
+ - **Compact view reserved empty caption space** (337): `compactCamera` now fits ONLY the frontmost
95
+ slide plate (height `H`, aimed at the slide center `Y = lift`) instead of the slide+caption
96
+ composite, so the compact view fills the frame with no caption padding. Mirrors `geometry.py`
97
+ (issue 337). Verified: the playwright-rendered compact still fills the frame; geometry test updated.
98
+
7
99
  ## [3.0.10] — issues 331
8
100
 
9
101
  ### Fixed
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,89 @@ 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>
46
47
  ```
47
48
 
48
- Global:
49
+ **Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
50
+ toggles compact↔expanded. Opt out with `click-toggle="false"`.
51
+
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).
54
+
55
+ ### ES Module — `createStax`
56
+
57
+ ```js
58
+ import { createStax, makeScene, VexyStax, loadScene } from "vexy-stax-js";
59
+
60
+ // One call: build a scene from a URL list, mount, wait for ready.
61
+ const stax = await createStax("#stage", {
62
+ slides: ["layer-0.png", "https://example.com/layer-1.png"],
63
+ gap: 480, transition: "expand_collapse", mode: "playable",
64
+ });
65
+
66
+ await stax.toggleView(); // fluent compact↔expanded (the default click behavior)
67
+ const mp4 = await stax.toVideo(); // seekable mp4
68
+
69
+ // createStax also accepts an inline scene object (scene-in-init):
70
+ await createStax("#hero", { scene: { version: 1, slides: [{ src: "a.png" }] }, view: "expanded" });
71
+
72
+ // makeScene builds a valid scene from a bare URL list, filling sensible defaults:
73
+ const scene = makeScene(["a.png", "b.png", "c.png"], { gap: 480, transition: "expand_collapse" });
74
+ const low = new VexyStax(container, scene); // the low-level path is still available
75
+ ```
76
+
77
+ `createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
78
+ height, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
79
+ `transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
80
+
81
+ ### Global script — `window.VexyStax`
49
82
 
50
83
  ```html
51
84
  <script src="./dist/vexy-stax.global.js"></script>
52
- <script>const stax = new VexyStax.VexyStax(el, scene);</script>
85
+ <script>
86
+ VexyStax.create("#stage", { slides: ["a.png", "b.png", "c.png"] });
87
+ </script>
88
+ ```
89
+
90
+ ### CDN (no build step)
91
+
92
+ Every snippet above also works verbatim from the jsDelivr CDN — swap the local bundle path for:
93
+
94
+ ```html
95
+ <!-- Web Component / ESM -->
96
+ <script type="module" src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.element.js"></script>
97
+ <!-- Global script -->
98
+ <script src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.global.js"></script>
53
99
  ```
54
100
 
101
+ ### Remote slide images
102
+
103
+ Slide `src` may be a local path, a `data:` URI, **or a remote `http(s)` URL**. The texture loader
104
+ requests cross-origin images with `crossOrigin="anonymous"`, so a server that sends CORS headers
105
+ lets the image load **and** keeps the canvas exportable (`toImage` / `toVideo`).
106
+
107
+ ### Live demos & how-to pages
108
+
109
+ `npm run build:docs` emits a [docs site](https://vexy.dev/vexy-stax-js/): a landing page, the
110
+ **Animated** (`playable.html`) and **Scrollspy** (`scrollable.html`) demos, plus three side-by-side
111
+ "how to use" pages — `demo-component.html`, `demo-module.html`, `demo-library.html` — each showing
112
+ the minimal code beside the live result (modeled on i.vexy.art/dev/lines-nano).
113
+
55
114
  ## Layout
56
115
 
57
116
  ```
@@ -74,4 +133,4 @@ for the same scene; both are tested against the same fixture vectors.
74
133
 
75
134
  ## License
76
135
 
77
- Apache-2.0 — Copyright 2026 Adam Twardoch / VexyArt
136
+ 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.0.10",
3
+ "version": "3.1.2",
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": {
@@ -70,6 +70,19 @@
70
70
  "background": { "type": "string", "default": "#ffffff" },
71
71
  "juicy": { "type": "boolean", "default": false, "description": "Python-only per-channel color match." },
72
72
  "captions": { "type": "boolean", "default": true, "description": "Global captions toggle (issue 332). ON: each slide plate sits on top of its on-floor caption plate (stacked layout). OFF: no caption plates; slide plates sit directly on the floor." },
73
+ "video": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "Video render params (issue 335 §3): dimensions/fps/frame counts/held stills. transition still owns the animation (kind/easing/wait/duration); video owns the output framing. Defaults preserve prior behavior.",
77
+ "properties": {
78
+ "width": { "type": "integer", "minimum": 1, "description": "Video width; omitted ⇒ size.width." },
79
+ "height": { "type": "integer", "minimum": 1, "description": "Video height; omitted ⇒ size.height." },
80
+ "fps": { "type": "integer", "minimum": 1, "description": "Video frames per second; omitted ⇒ transition.fps (else 30). Overrides transition.fps when set." },
81
+ "frames": { "type": "integer", "minimum": 1, "description": "Transition frames PER LEG; omitted ⇒ round(transition.duration × fps)." },
82
+ "first_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the FIRST frame (still intro)." },
83
+ "last_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the LAST frame (still outro)." }
84
+ }
85
+ },
73
86
  "caption_defaults": { "$ref": "#/$defs/captionStyle" },
74
87
  "caption_fade": {
75
88
  "type": "object",
package/src/element.js CHANGED
@@ -1,16 +1,25 @@
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,
4
+ // <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
5
+ // (space/newline-separated image URLs — issue 341), captions (bool), view,
5
6
  // mode (static|playable|scrollspy), width, height. Property `config` accepts an
6
- // inline scene object (overrides `scene`). Events: ready, transitionstart,
7
+ // inline scene object (overrides `scene`/`slides`). Events: ready, transitionstart,
7
8
  // transitionend. Mounts a VexyStax in light DOM. Auto-registers on import.
8
9
 
9
- import { VexyStax, loadScene } from "./index.js";
10
+ import { VexyStax, loadScene, makeScene } from "./index.js";
11
+
12
+ // Re-export the public ESM API from the element bundle (issue 341): the built
13
+ // dist/vexy-stax.element.js has src/element.js as its entry, so a user who loads that single
14
+ // file can also `import { createStax, makeScene, loadScene, VexyStax }` from it (the how-to
15
+ // ESM demo + the documented CDN URL both rely on this).
16
+ export { VexyStax, loadScene, parseScene, makeScene, createStax } from "./index.js";
10
17
 
11
18
  export class VexyStaxElement extends HTMLElement {
12
19
  static get observedAttributes() {
13
- return ["scene", "view", "mode", "trigger", "width", "height"];
20
+ // `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
21
+ // `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"];
14
23
  }
15
24
 
16
25
  constructor() {
@@ -20,7 +29,7 @@ export class VexyStaxElement extends HTMLElement {
20
29
  this._mounting = false;
21
30
  }
22
31
 
23
- /** Inline scene object (or JSON string); overrides the `scene` attribute. */
32
+ /** Inline scene object (or JSON string); overrides the `scene`/`slides` attributes. */
24
33
  set config(value) {
25
34
  this._config = typeof value === "string" ? JSON.parse(value) : value;
26
35
  if (this.isConnected) this._mount();
@@ -29,6 +38,26 @@ export class VexyStaxElement extends HTMLElement {
29
38
  return this._config;
30
39
  }
31
40
 
41
+ /**
42
+ * Scene-in-init (issue 342): assigning an OBJECT (or JSON string) sets the inline scene
43
+ * (same as `config`), so `el.scene = {version:1, slides:[…]}` works at init. Assigning a
44
+ * STRING URL is treated as the `scene` attribute (a URL to fetch). This makes the property
45
+ * mirror the lines-nano-style "pass the data right in" ergonomics for the Web Component.
46
+ */
47
+ set scene(value) {
48
+ if (value && typeof value === "object") {
49
+ this.config = value; // inline scene object
50
+ } else if (typeof value === "string") {
51
+ // Looks like JSON? treat as an inline scene; otherwise it's a URL attribute.
52
+ const trimmed = value.trim();
53
+ if (trimmed.startsWith("{")) this.config = JSON.parse(trimmed);
54
+ else this.setAttribute("scene", value);
55
+ }
56
+ }
57
+ get scene() {
58
+ return this._config ?? this.getAttribute("scene");
59
+ }
60
+
32
61
  /** The underlying VexyStax instance (null until mounted). */
33
62
  get instance() {
34
63
  return this._stax;
@@ -60,6 +89,12 @@ export class VexyStaxElement extends HTMLElement {
60
89
  this._stax?.setView(this.getAttribute("view") || "expanded");
61
90
  return;
62
91
  }
92
+ if (name === "click-toggle") {
93
+ // Toggle the issue-342 behavior in place (no costly remount).
94
+ if (this.getAttribute("click-toggle") === "false") this._stax?.disableClickToggle();
95
+ else this._stax?.enableClickToggle();
96
+ return;
97
+ }
63
98
  // scene/mode changes re-mount.
64
99
  this._mount();
65
100
  }
@@ -78,10 +113,25 @@ export class VexyStaxElement extends HTMLElement {
78
113
  this._stax?.destroy();
79
114
  this._stax = null;
80
115
 
81
- const sceneSrc = this._config ?? this.getAttribute("scene");
82
- if (!sceneSrc) return; // nothing to render yet
83
116
  const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
84
- const scene = await loadScene(sceneSrc, { baseUrl });
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");
122
+ const slidesAttr = this.getAttribute("slides");
123
+ let scene;
124
+ if (sceneSrc) {
125
+ scene = await loadScene(sceneSrc, { baseUrl });
126
+ } else if (slidesAttr && slidesAttr.trim()) {
127
+ const urls = slidesAttr.split(/\s+/).filter(Boolean);
128
+ const captionsAttr = this.getAttribute("captions");
129
+ const opts = { baseUrl };
130
+ if (captionsAttr !== null) opts.captions = captionsAttr !== "false";
131
+ scene = makeScene(urls, opts);
132
+ } else {
133
+ return; // nothing to render yet
134
+ }
85
135
 
86
136
  const view = this.getAttribute("view") || scene.view || "expanded";
87
137
  scene.view = view;
@@ -99,6 +149,15 @@ export class VexyStaxElement extends HTMLElement {
99
149
  // mode="playable" leaves the deck at its initial view; the host calls
100
150
  // el.transition(...) (e.g. on a button) to play it.
101
151
 
152
+ // Issue 342: click-to-toggle is ON by default for the interactive container — a click
153
+ // anywhere inside fluently toggles compact↔expanded, layered on top of scrollspy. Opt
154
+ // out with the `click-toggle="false"` attribute (or mode="static" pages that don't want it
155
+ // can still opt out explicitly). Default ON for every mode so a generic <vexy-stax> just
156
+ // works.
157
+ if (this.getAttribute("click-toggle") !== "false") {
158
+ this._stax?.enableClickToggle();
159
+ }
160
+
102
161
  this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
103
162
  } catch (err) {
104
163
  this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
@@ -127,6 +186,10 @@ export class VexyStaxElement extends HTMLElement {
127
186
  seek(t) {
128
187
  return this._stax?.seek(t);
129
188
  }
189
+ /** Issue 342: fluently toggle compact↔expanded (the default click behavior, exposed). */
190
+ toggleView() {
191
+ return this._stax?.toggleView();
192
+ }
130
193
  }
131
194
 
132
195
  if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
package/src/geometry.js CHANGED
@@ -330,13 +330,14 @@ export function expandedCamera(scene, viewportAspect) {
330
330
  export function compactCamera(scene, viewportAspect) {
331
331
  const cam = scene.camera;
332
332
  const depth = stackDepth(scene, "compact");
333
- // Issue 332: the frontmost COMPOSITE the head-on camera frames is the slide plate plus
334
- // (captions on) its on-floor caption plate stacked below it: full width W, height H + lift
335
- // (lift == one caption-plate height), vertically centered at Y = lift/2. Aim at that
336
- // composite center so neither the slide nor the caption row crops. Mirrors geometry.py.
333
+ // Issue 337: the compact view frames ONLY the frontmost SLIDE plate not the composite with
334
+ // its caption row. Captions are invisible in compact (they fade in only as the deck expands),
335
+ // so reserving the caption-plate height just padded the frame. The slide is lifted by `lift`
336
+ // (issue 332: it sits on top of the on-floor caption plate), so its center is at Y = lift and
337
+ // it spans height H. Aim at the slide center and fit H so the slide fills the frame tight.
338
+ // Mirrors geometry.py.
337
339
  const lift = slideLift(scene);
338
- const compositeH = scene.size.height + lift;
339
- const target = [0.0, lift / 2.0, -depth / 2.0];
340
+ const target = [0.0, lift, -depth / 2.0];
340
341
 
341
342
  let isPercent = false;
342
343
  let pctVal = 90.0;
@@ -351,15 +352,16 @@ export function compactCamera(scene, viewportAspect) {
351
352
 
352
353
  let distance;
353
354
  if (isPercent) {
354
- // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost COMPOSITE
355
- // (width W, height H + lift) so the limiting axis touches P% and the other axis only
356
- // ever has extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
355
+ // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1, issue 337): fit the frontmost SLIDE
356
+ // plate (width W, height H NOT the caption composite) so the limiting axis touches P% and
357
+ // the other axis only ever has extra padding (never a crop). distance = max(d_w, d_h).
358
+ // Mirrors geometry.py.
357
359
  const hfov = (cam.fov * Math.PI) / 180.0;
358
360
  const aspect = viewportAspect || scene.size.width / scene.size.height;
359
361
  const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
360
362
  const frac = pctVal / 100.0;
361
363
  const dW = scene.size.width / (2.0 * Math.tan(hfov / 2.0) * frac);
362
- const dH = compositeH / (2.0 * Math.tan(vfov / 2.0) * frac);
364
+ const dH = scene.size.height / (2.0 * Math.tan(vfov / 2.0) * frac);
363
365
  const distToZ0 = Math.max(dW, dH);
364
366
  distance = distToZ0 + depth / 2.0;
365
367
  } else {
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,7 +7,7 @@
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";
@@ -16,6 +16,7 @@ import { canvasToPngBlob, recordVideo } from "./export.js";
16
16
  export {
17
17
  loadScene,
18
18
  parseScene,
19
+ makeScene,
19
20
  resolvedOpacity,
20
21
  };
21
22
 
@@ -50,6 +51,13 @@ export class VexyStax {
50
51
  this.scene = scene;
51
52
  this.stage = new Stage(container, scene);
52
53
  this._ro = null; // ResizeObserver for post-layout resize
54
+ // Issue 342: track the CURRENT view so click-to-toggle knows which way to go. Starts at the
55
+ // scene's initial view; updated by setView/seek/transition/toggleView. `_morphT` is the last
56
+ // applied morph factor (0=compact, 1=expanded) used to disambiguate mid-morph clicks.
57
+ this._currentView = scene.view === "compact" ? "compact" : "expanded";
58
+ this._morphT = this._currentView === "compact" ? 0 : 1;
59
+ this._clickToggle = null; // { handler } when wired (enableClickToggle)
60
+ this._toggling = false; // guard against overlapping toggle transitions
53
61
  this._ready = this.stage.init().then(() => {
54
62
  this.stage.render();
55
63
  // After mount, observe the container for its first actual layout dimensions.
@@ -87,6 +95,8 @@ export class VexyStax {
87
95
  await this._ready;
88
96
  this.stage.setView(view);
89
97
  this.stage.render();
98
+ this._currentView = view === "compact" ? "compact" : "expanded";
99
+ this._morphT = this._currentView === "compact" ? 0 : 1;
90
100
  return this;
91
101
  }
92
102
 
@@ -99,6 +109,10 @@ export class VexyStax {
99
109
  const tt = Math.max(0, Math.min(1, Number(t) || 0));
100
110
  this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
101
111
  this.stage.render();
112
+ // Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
113
+ // whether it is currently nearer compact (→ expand) or expanded (→ collapse).
114
+ this._morphT = tt;
115
+ this._currentView = tt >= 0.5 ? "expanded" : "compact";
102
116
  return this;
103
117
  }
104
118
 
@@ -178,6 +192,10 @@ export class VexyStax {
178
192
  this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
179
193
  try {
180
194
  await controller.promise;
195
+ // Settle the tracked view to the transition's end endpoint (issue 342).
196
+ const { endMorph } = transitionEndpoints(resolvedKind);
197
+ this._morphT = endMorph;
198
+ this._currentView = endMorph >= 0.5 ? "expanded" : "compact";
181
199
  this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
182
200
  } finally {
183
201
  this._cancelTransition = null;
@@ -185,6 +203,61 @@ export class VexyStax {
185
203
  return this;
186
204
  }
187
205
 
206
+ /**
207
+ * Click-to-toggle (issue 342): fluently transition between the two views. If the deck is
208
+ * currently expanded (or mid-morph past halfway), collapse to compact; otherwise expand.
209
+ * Reuses the existing morph driver (a smooth `collapse`/`expand` leg) — never a snap. Safe to
210
+ * call repeatedly: an in-flight toggle is ignored until it settles. Returns the played kind.
211
+ */
212
+ async toggleView() {
213
+ await this._ready;
214
+ if (this._toggling) return null;
215
+ const goCompact = this._currentView !== "compact"; // not compact ⇒ collapse to compact
216
+ const kind = goCompact ? "collapse" : "expand";
217
+ this._toggling = true;
218
+ try {
219
+ // 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.
221
+ if (!this.scene.transition) {
222
+ this.scene.transition = { kind, duration: 0.9, wait: 0, fps: 30, easing: "easeInOutCubic" };
223
+ }
224
+ await this.transition(kind);
225
+ } finally {
226
+ this._toggling = false;
227
+ }
228
+ return kind;
229
+ }
230
+
231
+ /**
232
+ * Enable click-to-toggle on the container (issue 342): a pointer click anywhere inside the
233
+ * element fluently toggles compact↔expanded. ON by default for interactive containers (the
234
+ * <vexy-stax> element + createStax) and layered ON TOP of scrollspy (scroll drives the morph;
235
+ * a click still toggles). Idempotent. Pass to disableClickToggle() to opt out.
236
+ */
237
+ enableClickToggle() {
238
+ if (this._clickToggle || !this.container?.addEventListener) return this;
239
+ const handler = (ev) => {
240
+ // Ignore clicks on interactive controls a host may overlay (buttons/links/inputs).
241
+ const tag = ev.target?.tagName;
242
+ if (tag && /^(BUTTON|A|INPUT|SELECT|TEXTAREA|LABEL)$/.test(tag)) return;
243
+ this.toggleView();
244
+ };
245
+ this.container.addEventListener("click", handler);
246
+ // Pointer affordance so it reads as clickable.
247
+ if (this.container.style && !this.container.style.cursor) this.container.style.cursor = "pointer";
248
+ this._clickToggle = { handler };
249
+ return this;
250
+ }
251
+
252
+ /** Remove the click-to-toggle handler (issue 342 opt-out). */
253
+ disableClickToggle() {
254
+ if (this._clickToggle) {
255
+ this.container.removeEventListener?.("click", this._clickToggle.handler);
256
+ this._clickToggle = null;
257
+ }
258
+ return this;
259
+ }
260
+
188
261
  /** Re-derive the morph factor t from a frame's gap[1] (for caption fade). */
189
262
  _morphFromGaps(gaps) {
190
263
  if (gaps.length < 2) return 0;
@@ -205,13 +278,28 @@ export class VexyStax {
205
278
  await this._ready;
206
279
  const kind = opts.kind ?? this.scene.transition?.kind;
207
280
  if (!kind) throw new Error("VexyStax.toVideo: no kind given and scene.transition is null");
208
- const fps = this.scene.transition?.fps ?? 30;
281
+ const fps = this.scene.video?.fps ?? this.scene.transition?.fps ?? 30;
209
282
  const canvas = this.stage.renderer.domElement;
283
+ // Issue 335 §2: bookend the clip with HELD STILLS — render the start frame and capture it
284
+ // `first_hold` times, then the transition, then capture the end frame `last_hold` times
285
+ // (still → transition → still). Defaults 10/10 from scene.video. Mirrors geometry.py's
286
+ // frame_plan holds (the Python engines get holds via frame_plan; here toVideo drives a
287
+ // real-time capture, so we hold by capturing the boundary frames repeatedly).
288
+ const firstHold = this.scene.video?.first_hold ?? 10;
289
+ const lastHold = this.scene.video?.last_hold ?? 10;
290
+ const { startMorph, endMorph } = transitionEndpoints(kind);
291
+ const aspect = this.stage.camera.aspect;
210
292
 
211
293
  return recordVideo({
212
294
  canvas,
213
295
  fps,
214
296
  run: async (onFrame) => {
297
+ // Held still intro: snap to the start endpoint and capture it `firstHold` times.
298
+ const startState = frameStateAt(this.scene, startMorph, aspect);
299
+ this.stage.applyFrameState(startState, startMorph);
300
+ this.stage.render();
301
+ for (let i = 0; i < firstHold; i++) onFrame(startState);
302
+
215
303
  const controller = playTransition(this.scene, (state) => {
216
304
  const t = this._morphFromGaps(state.gaps);
217
305
  this.stage.applyFrameState(state, t);
@@ -219,6 +307,12 @@ export class VexyStax {
219
307
  onFrame(state);
220
308
  }, { kind });
221
309
  await controller.promise;
310
+
311
+ // Held still outro: snap to the end endpoint and capture it `lastHold` times.
312
+ const endState = frameStateAt(this.scene, endMorph, aspect);
313
+ this.stage.applyFrameState(endState, endMorph);
314
+ this.stage.render();
315
+ for (let i = 0; i < lastHold; i++) onFrame(endState);
222
316
  },
223
317
  });
224
318
  }
@@ -280,8 +374,91 @@ export class VexyStax {
280
374
  destroy() {
281
375
  this._cancelTransition?.();
282
376
  this._scrollspy?.disconnect?.();
377
+ this.disableClickToggle();
283
378
  this._ro?.disconnect?.();
284
379
  this._ro = null;
285
380
  this.stage?.dispose();
286
381
  }
287
382
  }
383
+
384
+ // Scene-construction options consumed by createStax/makeScene rather than describing how to
385
+ // MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
386
+ // `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
387
+ const MOUNT_KEYS = new Set([
388
+ "slides", "scene", "view", "mode", "trigger", "width", "height", "baseUrl", "clickToggle",
389
+ ]);
390
+
391
+ /**
392
+ * The "extremely easy to use" ESM factory (issue 341), mirroring lines-nano's `createNano`.
393
+ * Resolve the mount element, build the scene (from a bare `slides` list via makeScene, or
394
+ * from a `scene` URL/object via loadScene), mount a VexyStax, wait until it's ready, optionally
395
+ * start a mode (playable/scrollspy), and return the ready instance.
396
+ *
397
+ * @param {HTMLElement|string} elOrSelector mount element or a CSS selector for it
398
+ * @param {object} [opts]
399
+ * @param {string[]|object[]} [opts.slides] slide image URLs (local/data:/remote) or slide
400
+ * objects — the easy path; built via makeScene with the remaining opts as overrides.
401
+ * @param {string|object} [opts.scene] a scene URL or inline scene object (via loadScene);
402
+ * used when `slides` is not given.
403
+ * @param {"expanded"|"compact"} [opts.view] initial view.
404
+ * @param {"static"|"playable"|"scrollspy"} [opts.mode] mount mode (default "static").
405
+ * "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
406
+ * @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
407
+ * @param {string} [opts.width] @param {string} [opts.height] CSS size overrides on the element.
408
+ * @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
409
+ * ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
410
+ * transition, background, captions, floor, edge, caption_defaults, …).
411
+ * @returns {Promise<VexyStax>} the ready instance.
412
+ */
413
+ export async function createStax(elOrSelector, opts = {}) {
414
+ const el =
415
+ typeof elOrSelector === "string" ? document.querySelector(elOrSelector) : elOrSelector;
416
+ if (!el) throw new Error(`createStax: element not found (${String(elOrSelector)})`);
417
+ if (opts === null || typeof opts !== "object") throw new Error("createStax: opts must be an object");
418
+
419
+ // Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
420
+ if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
421
+ if (opts.height) el.style.height = /^\d+$/.test(String(opts.height)) ? `${opts.height}px` : opts.height;
422
+ if (typeof el.style === "object") {
423
+ el.style.position = el.style.position || "relative";
424
+ el.style.display = el.style.display || "block";
425
+ }
426
+
427
+ const baseUrl = opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
428
+
429
+ let scene;
430
+ if (opts.slides) {
431
+ // Flat scene overrides = every opt that isn't a mount/source key.
432
+ const sceneOpts = { baseUrl };
433
+ for (const [k, v] of Object.entries(opts)) {
434
+ if (!MOUNT_KEYS.has(k)) sceneOpts[k] = v;
435
+ }
436
+ scene = makeScene(opts.slides, sceneOpts);
437
+ } else if (opts.scene !== undefined) {
438
+ scene = await loadScene(opts.scene, { baseUrl });
439
+ } else {
440
+ throw new Error("createStax: provide `slides` (URLs) or `scene` (URL/object)");
441
+ }
442
+
443
+ // The view override wins over the scene's own initial view.
444
+ if (opts.view) scene.view = opts.view;
445
+
446
+ const stax = new VexyStax(el, scene);
447
+ await stax.ready;
448
+
449
+ const mode = opts.mode ?? "static";
450
+ if (mode === "scrollspy") {
451
+ const trigger =
452
+ typeof opts.trigger === "string" ? document.querySelector(opts.trigger) : opts.trigger ?? el;
453
+ stax.scrollspy({ trigger });
454
+ } else if (mode === "playable" && scene.transition) {
455
+ // Kick off the scene's transition once (best-effort; ignore if it's cancelled by teardown).
456
+ stax.transition().catch(() => {});
457
+ }
458
+
459
+ // Issue 342: click-to-toggle is ON by default for the interactive container (every mode),
460
+ // and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
461
+ // with `clickToggle: false`.
462
+ if (opts.clickToggle !== false) stax.enableClickToggle();
463
+ return stax;
464
+ }
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
@@ -239,6 +239,12 @@ export class Stage {
239
239
 
240
240
  async _buildPlates() {
241
241
  const loader = new THREE.TextureLoader();
242
+ // Issue 341: load slide images from REMOTE http(s) URLs, not just local paths/data: URIs.
243
+ // setCrossOrigin("anonymous") makes the underlying <img> a CORS request, so a server that
244
+ // sends `Access-Control-Allow-Origin` lets the image load AND keeps the WebGL canvas
245
+ // un-tainted (toImage/toVideo stay exportable). Local same-origin and data: URIs are
246
+ // unaffected. resolveSrc already preserves absolute URLs, so this is the last piece.
247
+ loader.setCrossOrigin("anonymous");
242
248
  const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
243
249
  const reflectivity = this.scene.floor.reflectivity;
244
250