vexy-stax-js 3.1.1 → 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,76 @@
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
+
7
77
  ## [3.0.11] — issues 335, 336, 337
8
78
 
9
79
  ### 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,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.1.1",
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": {
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/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;
@@ -301,8 +374,91 @@ export class VexyStax {
301
374
  destroy() {
302
375
  this._cancelTransition?.();
303
376
  this._scrollspy?.disconnect?.();
377
+ this.disableClickToggle();
304
378
  this._ro?.disconnect?.();
305
379
  this._ro = null;
306
380
  this.stage?.dispose();
307
381
  }
308
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