vexy-stax-js 3.0.0 → 3.0.1

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,115 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.0.6] — issue 327
8
+
9
+ ### Fixed
10
+
11
+ - **Caption font fell back to serif (Times New Roman) in the playwright render**
12
+ (327.1): `makeCaptionSprite` now builds the canvas font as
13
+ `"<family>", system-ui, sans-serif`, so an unloaded/unknown family never falls
14
+ back to the canvas default serif. `_ensureCaptionFonts` now awaits the Google
15
+ Fonts `<link>` `onload` BEFORE calling `document.fonts.load`, so REM's
16
+ `@font-face` rules exist when the load is requested (previously a race resolved
17
+ the load against the system fallback, leaving captions in a serif font).
18
+
19
+ ## [3.0.5] — issue 326
20
+
21
+ ### Changed
22
+
23
+ - **Plate + caption borders OFF by default** (326): `parseEdge` width default
24
+ `0.004 → 0.0` (and schema default). A border (around both the slide plates and
25
+ the caption plates, which share `edge.width`) now draws only when `width > 0`.
26
+ Border color default is unchanged and applies only when a border is enabled.
27
+
28
+ ## [3.0.4] — issues 324–325
29
+
30
+ ### Changed
31
+
32
+ - **Caption + border colors default `#f2f2f2`, each overridable** (324):
33
+ `scene.edge.color` default `#cccccc → #f2f2f2` (the slide-plate border and, by
34
+ default, the caption-plate fill + border). New `caption_defaults.fill_color` and
35
+ `caption_defaults.border_color` (each defaulting to `scene.edge.color`) make the
36
+ caption plate fill and border independently overridable; `caption_defaults.color`
37
+ still sets the caption text color. New `geometry.captionFillColor()` /
38
+ `captionBorderColor()` helpers; `makeCaptionSprite` now takes separate
39
+ `fillColor` / `borderColor` (each falling back to `edgeColor`) and the stage
40
+ passes the resolved colors. Schema gained the `edge.color` default and
41
+ `captionStyle.fill_color` / `border_color`.
42
+ - **Caption font 1/3 larger by default** (324): `CAPTION_PLATE_HEIGHT_FRAC`
43
+ `0.10 → 0.10·4/3 ≈ 0.1333`, so `CAPTION_DEFAULT_SIZE_FRAC` `0.075 → 0.10` of the
44
+ scene height.
45
+
46
+ ### Fixed
47
+
48
+ - **Blender compact view rendered black / transition translucent** (325):
49
+ fixed in the Python `blender` engine (Cycles `transparent_max_bounces` was
50
+ exhausted by the dense head-on plate stack). No changes to the browser package;
51
+ the JS/three.js engine was already correct (it draws single planes with
52
+ `depthWrite:false` + explicit render orders).
53
+
54
+ ## [3.0.3] — issues 321–323
55
+
56
+ ### Changed
57
+
58
+ - **Caption plate touches slide plate** (321/323): `CAPTION_GAP_EM` reduced from
59
+ `2.0` to `0.0` in `geometry.js` so the caption plate right edge aligns exactly
60
+ with the slide plate left edge — no visual gap.
61
+
62
+ ## [3.0.2] — issue 320
63
+
64
+ ### Changed
65
+
66
+ - **Caption plate fill = border color** (320.7): caption plate background is now
67
+ `edgeColor` (= `scene.edge.color`, default `#cccccc`) instead of white, so the
68
+ caption plate matches the slide-plate border.
69
+ - **Plate/reflection `depthWrite: false`** (320.9): THREE.js plate and reflection
70
+ materials set `depthWrite: false` to eliminate z-fighting flicker at the bottom
71
+ of each slide during Playwright-recorded transitions.
72
+ - **JS outputs use py testdata** (320.5–6): `verify/example.mjs`, harness HTML
73
+ files, and `verify/server.mjs` now read scene + slides from
74
+ `vexy-stax-py/testdata/` instead of the removed `vexy-stax-js/testdata/`.
75
+ - **Testdata removed** (320.6): `vexy-stax-js/testdata/` directory deleted; the
76
+ shared source of truth is `vexy-stax-py/testdata/`.
77
+
78
+ ## [3.0.1] — issues 303–318
79
+
80
+ ### Added
81
+
82
+ - **Smoked-glass floor + blurry reflections** (303 §1): floor defaults to ~4%
83
+ smoked glass; the mirror reflection texture is Gaussian-blurred (`ctx.filter`)
84
+ so it reads soft, not crisp.
85
+ - **Plate edge border** (305): `Edge` scene model (`width` fraction of plate
86
+ height, `color`), default-on; 4 thin perimeter quads per plate in `stage.js`.
87
+ - **Caption plates** (311, typography revised by 315): captions render as small
88
+ **white opaque bordered plates** (same edge as the slide plates), text centered;
89
+ plate height = 10% of plate height, text = 75% of that (→ 7.5%), width = text +
90
+ 0.75em padding each side.
91
+ - **`seek(t)`** on `VexyStax` + `<vexy-stax>`: apply an arbitrary morph factor
92
+ (0 compact → 1 expanded). `scrollspy({map})` accepts a custom progress→morph map.
93
+ `compactCamera`/`expandedCamera` accept an optional viewport aspect (issue 314).
94
+ - **`outputs/scrollable.html`** (304.2 + 314): a **full-width 2:1 white** scene
95
+ (no rounding/box-shadow) between intro and outro copy. Compact = plate centered
96
+ with side padding; it morphs to expanded once **80% of the scene is visible**
97
+ scrolling in, then **latches** expanded (further scrolling does not collapse it).
98
+
99
+ ### Fixed
100
+
101
+ - **playable.html scale at HiDPI** (304.1): `renderer.setSize(w, h)` (updateStyle
102
+ default) so the canvas displays at its CSS size, not the 2× backing size.
103
+ - **npm publish version conflict** (318): bumped package version to `3.0.1` since
104
+ `3.0.0` was already published on npm.
105
+
106
+ ### Changed
107
+
108
+ - Default `camera.distance` is `"100%"` (compact fit-tight); default caption size
109
+ is 7.5% of plate height (75% of the caption-plate height — issues 311/315, supersede
110
+ 308's 5%); `caption_fade.stagger_frames` for frame-based stagger.
111
+
112
+ ### Removed
113
+
114
+ - **Floor shadows** (312): the short pale plate shadows on the floor were eliminated.
115
+
7
116
  ## [Unreleased]
8
117
 
9
118
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexy-stax-js",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
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": {
@@ -30,7 +30,7 @@
30
30
  "distance": {
31
31
  "description": "Camera distance: absolute points as a number/string, or viewport-fit percentage like \"90%\".",
32
32
  "anyOf": [{ "type": "number" }, { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?%?$" }],
33
- "default": "90%"
33
+ "default": "100%"
34
34
  },
35
35
  "angle": { "type": "number", "default": 60, "description": "Azimuth degrees for expanded view." },
36
36
  "elevation": { "type": "number", "default": 0, "description": "Degrees above horizon for expanded view." },
@@ -53,14 +53,33 @@
53
53
  "type": "object",
54
54
  "additionalProperties": false,
55
55
  "properties": {
56
- "color": { "type": "string", "default": "#f2f2f2" },
57
- "opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 1.0 },
56
+ "color": { "type": "string", "default": "#1a1a1a" },
57
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.04, "description": "Smoked glass — ~4% (issue 303)." },
58
58
  "reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }
59
59
  }
60
60
  },
61
+ "edge": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "description": "Visible plate border (issue 305), default-on.",
65
+ "properties": {
66
+ "width": { "type": "number", "minimum": 0, "default": 0.0, "description": "Border thickness as a fraction of plate height; 0 = off (issue 326: slide + caption borders off by default)." },
67
+ "color": { "type": "string", "default": "#f2f2f2", "description": "Slide-plate border color; also the default caption plate fill + border (issue 324)." }
68
+ }
69
+ },
61
70
  "background": { "type": "string", "default": "#ffffff" },
62
71
  "juicy": { "type": "boolean", "default": false, "description": "Python-only per-channel color match." },
63
72
  "caption_defaults": { "$ref": "#/$defs/captionStyle" },
73
+ "caption_fade": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "Caption fade-in timing during a transition (issue 302 §B.4 / 309).",
77
+ "properties": {
78
+ "window": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "default": 0.9, "description": "Fraction of the morph (from the end) over which captions fade in." },
79
+ "stagger": { "type": "number", "minimum": 0, "exclusiveMaximum": 1, "default": 0.3, "description": "Back→front succession spread as a fraction of the fade window." },
80
+ "stagger_frames": { "type": "integer", "minimum": 0, "description": "Issue 309: back→front per-caption step in transition frames; overrides stagger when set." }
81
+ }
82
+ },
64
83
  "slides": {
65
84
  "type": "array",
66
85
  "minItems": 1,
@@ -113,9 +132,27 @@
113
132
  "type": "object",
114
133
  "additionalProperties": false,
115
134
  "properties": {
116
- "size": { "type": "number", "exclusiveMinimum": 0 },
117
- "color": { "type": "string" },
118
- "font": { "type": "string" }
135
+ "size": {
136
+ "type": "number",
137
+ "exclusiveMinimum": 0,
138
+ "description": "Font size for the caption text (1em) in scene-point units."
139
+ },
140
+ "color": {
141
+ "type": "string",
142
+ "description": "Color for the caption text (e.g., hex string like '#222222')."
143
+ },
144
+ "font": {
145
+ "type": "string",
146
+ "description": "Font family name (e.g., 'sans-serif', 'Arial') or a path to a TrueType/OpenType font file."
147
+ },
148
+ "fill_color": {
149
+ "type": "string",
150
+ "description": "Caption plate FILL color (issue 324); defaults to scene.edge.color when omitted."
151
+ },
152
+ "border_color": {
153
+ "type": "string",
154
+ "description": "Caption plate BORDER color (issue 324); defaults to scene.edge.color when omitted."
155
+ }
119
156
  }
120
157
  }
121
158
  }
package/src/element.js CHANGED
@@ -124,6 +124,9 @@ export class VexyStaxElement extends HTMLElement {
124
124
  scrollspy(opts) {
125
125
  return this._stax?.scrollspy(opts);
126
126
  }
127
+ seek(t) {
128
+ return this._stax?.seek(t);
129
+ }
127
130
  }
128
131
 
129
132
  if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
package/src/geometry.js CHANGED
@@ -18,6 +18,96 @@ import { resolvedOpacity } from "./scene.js";
18
18
 
19
19
  export const MIN_GAP = 3.0;
20
20
  export const FILL = 0.85;
21
+ export const V_FILL = 0.98; // expanded: max fraction of frame height the deck may occupy (no crop)
22
+
23
+ // Caption fade defaults (issue 302 §B.4): captions fade in over the final
24
+ // CAPTION_FADE_WINDOW fraction of the morph, staggered back->front by CAPTION_STAGGER.
25
+ export const CAPTION_FADE_WINDOW = 0.9;
26
+ export const CAPTION_STAGGER = 0.3;
27
+ // Caption layout (issue 302 §B, em-based): captions sit to the LEFT of the plates with
28
+ // their RIGHT edges aligned CAPTION_GAP_EM em (em == caption size) from the plate left
29
+ // edge (0 = touching, issues 321/323), and the text BASELINE CAPTION_BASELINE_EM em above
30
+ // the virtual ground (the floor at the bottom of the plates). The nominal "em" is the
31
+ // caption size in scene points.
32
+ export const CAPTION_GAP_EM = 0.0; // issues 321/323: caption plate right edge touches slide plate left edge
33
+ export const CAPTION_BASELINE_EM = 1.0;
34
+ // Caption plate (issues 311, 315): each caption sits on a small white opaque bordered plate.
35
+ // Plate height = CAPTION_PLATE_HEIGHT_FRAC of the plate height; text 1em =
36
+ // CAPTION_FONT_FRAC_OF_PLATE of the caption-plate height; plate padded CAPTION_PLATE_PAD_EM
37
+ // em on each side of the text. Default caption size = 0.10*0.75 = 0.075 of scene height
38
+ // (issue 315 revised plate height 20%->10% and pad 1.5em->0.75em). Mirrors geometry.py.
39
+ export const CAPTION_PLATE_HEIGHT_FRAC = (0.1 * 4) / 3; // ≈0.1333 (issue 324: font 1/3 larger; was 0.10)
40
+ export const CAPTION_FONT_FRAC_OF_PLATE = 0.75;
41
+ export const CAPTION_PLATE_PAD_EM = 0.75;
42
+ export const CAPTION_DEFAULT_SIZE_FRAC = CAPTION_PLATE_HEIGHT_FRAC * CAPTION_FONT_FRAC_OF_PLATE; // ≈0.10 (issue 324; was 0.075)
43
+
44
+ // Floor reflection (issue 303 §1) — shared so the blurry reflection is consistent across
45
+ // engines. Fraction of the plate-image height (px). (Floor shadows removed per issue 312.)
46
+ export const REFLECTION_BLUR_FRAC = 0.02; // Gaussian blur radius of the mirror reflection
47
+
48
+ /**
49
+ * Plate border thickness in scene points (issue 305): edge.width × plate height. Mirrors
50
+ * plate_edge_width in geometry.py. Caption plates (issue 311) reuse this same border.
51
+ */
52
+ export function plateEdgeWidth(scene) {
53
+ return scene.size.height * scene.edge.width;
54
+ }
55
+
56
+ /**
57
+ * Nominal caption text size in scene points (1em). Resolves caption_defaults.size when set,
58
+ * else CAPTION_DEFAULT_SIZE_FRAC of scene height (issue 311). Mirrors caption_size.
59
+ */
60
+ export function captionSize(scene) {
61
+ const cd = scene.caption_defaults;
62
+ if (cd && cd.size !== null && cd.size !== undefined) return Number(cd.size);
63
+ return Math.max(8.0, scene.size.height * CAPTION_DEFAULT_SIZE_FRAC);
64
+ }
65
+
66
+ /** Caption plate FILL color (issue 324): caption_defaults.fill_color else scene.edge.color. */
67
+ export function captionFillColor(scene) {
68
+ const cd = scene.caption_defaults;
69
+ return cd && cd.fill_color ? cd.fill_color : scene.edge.color;
70
+ }
71
+
72
+ /** Caption plate BORDER color (issue 324): caption_defaults.border_color else scene.edge.color. */
73
+ export function captionBorderColor(scene) {
74
+ const cd = scene.caption_defaults;
75
+ return cd && cd.border_color ? cd.border_color : scene.edge.color;
76
+ }
77
+
78
+ /**
79
+ * Height of a caption plate in scene points (issue 311): caption_size / 0.75 so the text
80
+ * 1em stays 75% of the plate height. Mirrors caption_plate_height in geometry.py.
81
+ */
82
+ export function captionPlateHeight(scene) {
83
+ return captionSize(scene) / CAPTION_FONT_FRAC_OF_PLATE;
84
+ }
85
+
86
+ /**
87
+ * World Y of a caption plate's vertical center (issue 311): the plate sits on the virtual
88
+ * ground (Y = -height/2), so its center is half its height above it. Mirrors geometry.py.
89
+ */
90
+ export function captionPlateCenterY(scene) {
91
+ return -(scene.size.height / 2.0) + captionPlateHeight(scene) / 2.0;
92
+ }
93
+
94
+ /**
95
+ * World X where every caption's RIGHT edge aligns — CAPTION_GAP_EM em left of the plate
96
+ * left edge (-width/2). All plates share scene.size width centered at X=0. Mirrors
97
+ * caption_anchor_x in geometry.py.
98
+ */
99
+ export function captionAnchorX(scene) {
100
+ return -(scene.size.width / 2.0 + CAPTION_GAP_EM * captionSize(scene));
101
+ }
102
+
103
+ /**
104
+ * World Y of the caption text BASELINE — CAPTION_BASELINE_EM em above the virtual ground
105
+ * (the floor at the bottom of the plates, Y = -height/2). Mirrors caption_baseline_y in
106
+ * geometry.py.
107
+ */
108
+ export function captionBaselineY(scene) {
109
+ return -(scene.size.height / 2.0) + CAPTION_BASELINE_EM * captionSize(scene);
110
+ }
21
111
 
22
112
  /** Per-slide gap (points), falling back to camera.gap when unset (null). */
23
113
  export function plateGaps(scene) {
@@ -70,15 +160,19 @@ function parseDistance(distance, viewportWidth) {
70
160
  }
71
161
 
72
162
  /**
73
- * Angled hero camera framing the expanded *deck* (SPEC.md §3). Fits the plate
74
- * bounding box (not the floor diagonal) so the deck fills FILL of the frame on
75
- * its tighter axis. Plate size = scene.size so JS and Python agree exactly.
163
+ * Angled hero camera framing the expanded *deck* (SPEC.md §3, issue 302 §2). The
164
+ * distance is chosen and the camera horizontally re-centered (panned) by a
165
+ * deterministic bisection so that, in the projected image, the left margin (frame
166
+ * edge → leftmost plate) and the right margin (rightmost plate → frame edge) each
167
+ * equal the projected inter-plate gap (mean adjacent plate-center horizontal stagger).
168
+ * A vertical-fit floor keeps the deck from cropping top/bottom. Plate size = scene.size
169
+ * so JS and Python agree exactly (same iterations/brackets → identical numbers).
76
170
  * Returns { position:[x,y,z], target:[x,y,z], fov, near }.
77
171
  */
78
- export function expandedCamera(scene) {
172
+ export function expandedCamera(scene, viewportAspect) {
79
173
  const cam = scene.camera;
80
174
  const depth = stackDepth(scene, "expanded");
81
- const target = [0.0, 0.0, -depth / 2.0];
175
+ const baseTarget = [0.0, 0.0, -depth / 2.0];
82
176
 
83
177
  // Direction target -> camera (azimuth swings toward -X, elevation lifts +Y).
84
178
  const az = (cam.angle * Math.PI) / 180.0;
@@ -95,28 +189,107 @@ export function expandedCamera(scene) {
95
189
  right = Math.abs(dot(look, upWorld)) < 0.999 ? normalize(right) : [1.0, 0.0, 0.0];
96
190
  const up = normalize(cross(right, look));
97
191
 
192
+ const hfov = (cam.fov * Math.PI) / 180.0;
193
+ const aspect = viewportAspect || scene.size.width / scene.size.height;
194
+ const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
195
+ const th = Math.tan(hfov / 2.0);
196
+ const tv = Math.tan(vfov / 2.0);
197
+
98
198
  const halfWPlate = scene.size.width / 2.0;
99
199
  const halfHPlate = scene.size.height / 2.0;
100
200
  const zPositions = stackPositions(plateGaps(scene));
101
- let halfW = 0.0;
102
- let halfH = 0.0;
201
+
202
+ // Precompute each corner's (right, up, look) offsets relative to baseTarget so
203
+ // projecting at a candidate (distance D, horizontal pan) is cheap and exact.
204
+ // ndc_x = (cr - pan)/((cl + D)*th). Mirrors geometry.py expanded_camera.
205
+ const corners = []; // [cr, cu, cl]
206
+ const centers = []; // [cr, cl] of each plate center
103
207
  for (const z of zPositions) {
208
+ const relC = sub([0.0, 0.0, z], baseTarget);
209
+ centers.push([dot(relC, right), dot(relC, look)]);
104
210
  for (const sx of [-halfWPlate, halfWPlate]) {
105
211
  for (const sy of [-halfHPlate, halfHPlate]) {
106
- const rel = sub([sx, sy, z], target);
107
- halfW = Math.max(halfW, Math.abs(dot(rel, right)));
108
- halfH = Math.max(halfH, Math.abs(dot(rel, up)));
212
+ const rel = sub([sx, sy, z], baseTarget);
213
+ corners.push([dot(rel, right), dot(rel, up), dot(rel, look)]);
109
214
  }
110
215
  }
111
216
  }
112
217
 
113
- const hfov = (cam.fov * Math.PI) / 180.0;
114
- const aspect = scene.size.width / scene.size.height;
115
- const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
116
- const dW = halfW / (FILL * Math.tan(hfov / 2.0));
117
- const dH = halfH / (FILL * Math.tan(vfov / 2.0));
118
- const distance = Math.max(dW, dH);
218
+ const span = (D, pan) => {
219
+ let a = Infinity;
220
+ let b = -Infinity;
221
+ let ymax = 0.0;
222
+ for (const [cr, cu, cl] of corners) {
223
+ const zv = cl + D;
224
+ const nx = (cr - pan) / (zv * th);
225
+ a = Math.min(a, nx);
226
+ b = Math.max(b, nx);
227
+ ymax = Math.max(ymax, Math.abs(cu / (zv * tv)));
228
+ }
229
+ return [a, b, ymax];
230
+ };
231
+
232
+ const gapOf = (D, pan) => {
233
+ const xs = centers.map(([cr, cl]) => (cr - pan) / ((cl + D) * th));
234
+ if (xs.length < 2) return 0.0;
235
+ let s = 0.0;
236
+ for (let i = 0; i < xs.length - 1; i++) s += Math.abs(xs[i + 1] - xs[i]);
237
+ return s / (xs.length - 1);
238
+ };
239
+
240
+ const recenter = (D) => {
241
+ let lo = -halfWPlate * 8.0;
242
+ let hi = halfWPlate * 8.0;
243
+ for (let i = 0; i < 64; i++) {
244
+ const pan = 0.5 * (lo + hi);
245
+ const [a, b] = span(D, pan);
246
+ if (a + b > 0.0) lo = pan;
247
+ else hi = pan;
248
+ }
249
+ return 0.5 * (lo + hi);
250
+ };
251
+
252
+ // Bracket scale: the legacy bounding-box fit distance.
253
+ let halfW = 0.0;
254
+ let halfH = 0.0;
255
+ for (const [cr, cu] of corners) {
256
+ halfW = Math.max(halfW, Math.abs(cr));
257
+ halfH = Math.max(halfH, Math.abs(cu));
258
+ }
259
+ const d0 = Math.max(halfW / (FILL * th), halfH / (FILL * tv));
260
+
261
+ // margin grows with distance, gap shrinks => (margin - gap) increasing; bisect.
262
+ let lo = d0 * 0.1;
263
+ let hi = d0 * 20.0;
264
+ let distance = d0;
265
+ for (let i = 0; i < 80; i++) {
266
+ distance = 0.5 * (lo + hi);
267
+ const pan = recenter(distance);
268
+ const [a, b] = span(distance, pan);
269
+ const margin = 0.5 * (a + 1.0 + (1.0 - b));
270
+ if (margin - gapOf(distance, pan) > 0.0) hi = distance;
271
+ else lo = distance;
272
+ }
273
+ distance = 0.5 * (lo + hi);
274
+
275
+ // Vertical-fit floor: never let the gap pull the camera so close the deck crops.
276
+ let vlo = d0 * 0.05;
277
+ let vhi = d0 * 40.0;
278
+ for (let i = 0; i < 80; i++) {
279
+ const dv = 0.5 * (vlo + vhi);
280
+ const [, , ymax] = span(dv, 0.0); // vertical extent is independent of pan
281
+ if (ymax > V_FILL) vlo = dv;
282
+ else vhi = dv;
283
+ }
284
+ distance = Math.max(distance, 0.5 * (vlo + vhi));
285
+
286
+ const pan = recenter(distance);
119
287
  const near = Math.max(1.0, distance * 0.005);
288
+ const target = [
289
+ baseTarget[0] + right[0] * pan,
290
+ baseTarget[1] + right[1] * pan,
291
+ baseTarget[2] + right[2] * pan,
292
+ ];
120
293
  const position = [
121
294
  target[0] + toCam[0] * distance,
122
295
  target[1] + toCam[1] * distance,
@@ -130,11 +303,39 @@ export function expandedCamera(scene) {
130
303
  * width or absolute points; near plane scales with distance.
131
304
  * Returns { position:[x,y,z], target:[x,y,z], fov, near }.
132
305
  */
133
- export function compactCamera(scene) {
306
+ export function compactCamera(scene, viewportAspect) {
134
307
  const cam = scene.camera;
135
308
  const depth = stackDepth(scene, "compact");
136
309
  const target = [0.0, 0.0, -depth / 2.0];
137
- const distance = parseDistance(cam.distance, scene.size.width);
310
+
311
+ let isPercent = false;
312
+ let pctVal = 90.0;
313
+ if (typeof cam.distance === "string") {
314
+ const text = cam.distance.trim();
315
+ if (text.endsWith("%")) {
316
+ isPercent = true;
317
+ const parsed = parseFloat(text.slice(0, -1));
318
+ if (!isNaN(parsed)) pctVal = parsed;
319
+ }
320
+ }
321
+
322
+ let distance;
323
+ if (isPercent) {
324
+ // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost plate
325
+ // (scene.size) so the limiting axis touches P% and the other axis only ever has
326
+ // extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
327
+ const hfov = (cam.fov * Math.PI) / 180.0;
328
+ const aspect = viewportAspect || scene.size.width / scene.size.height;
329
+ const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
330
+ const frac = pctVal / 100.0;
331
+ const dW = scene.size.width / (2.0 * Math.tan(hfov / 2.0) * frac);
332
+ const dH = scene.size.height / (2.0 * Math.tan(vfov / 2.0) * frac);
333
+ const distToZ0 = Math.max(dW, dH);
334
+ distance = distToZ0 + depth / 2.0;
335
+ } else {
336
+ distance = parseDistance(cam.distance, scene.size.width);
337
+ }
338
+
138
339
  const near = Math.max(1.0, distance * 0.005);
139
340
  const position = [target[0], target[1], target[2] + distance];
140
341
  return { position, target, fov: cam.fov, near };
@@ -169,6 +370,47 @@ export function interpolateOpacity(slide, tExpanded) {
169
370
  return Math.max(0.0, Math.min(1.0, value));
170
371
  }
171
372
 
373
+ /**
374
+ * Per-slide caption opacity at morph factor tExpanded (0=compact, 1=expanded).
375
+ * Honors caption.show_in and the staggered fade (issue 302 §B.4): an `expanded`
376
+ * caption stays invisible until the final `window` fraction of the morph, then fades
377
+ * in — staggered back (index 0) → front so the frontmost reaches full opacity exactly
378
+ * at t=1 (full opacity ONLY in expanded). `both`→1, `none`→0, `compact`→fades out.
379
+ * Slides without a caption → 0. Mirrors caption_opacities in geometry.py.
380
+ */
381
+ export function captionOpacities(scene, tExpanded) {
382
+ const t = Math.max(0.0, Math.min(1.0, tExpanded));
383
+ const cf = scene.caption_fade;
384
+ const window = cf ? cf.window : CAPTION_FADE_WINDOW;
385
+ const stagger = cf ? cf.stagger : CAPTION_STAGGER;
386
+ const n = scene.slides.length;
387
+ const denom = n > 1 ? n - 1 : 1;
388
+
389
+ // Total back->front spread (fraction of the morph). Default: `stagger` of the window.
390
+ // Issue 309: if stagger_frames is set + a transition exists, the per-caption step is
391
+ // that many frames of one leg; spread = (n-1) steps, capped so the frontmost finishes
392
+ // fading at t=1 (ramp stays positive).
393
+ let spread = stagger * window;
394
+ if (cf && cf.stagger_frames !== null && cf.stagger_frames !== undefined && scene.transition) {
395
+ const legFrames = Math.round(scene.transition.duration * scene.transition.fps);
396
+ if (legFrames > 0) {
397
+ const stepT = cf.stagger_frames / legFrames;
398
+ spread = Math.min((n - 1) * stepT, window * 0.95);
399
+ }
400
+ }
401
+ const ramp = Math.max(1e-6, window - spread);
402
+
403
+ return scene.slides.map((slide, i) => {
404
+ const cap = slide.caption;
405
+ if (!cap || cap.show_in === "none") return 0.0;
406
+ if (cap.show_in === "both") return 1.0;
407
+ if (cap.show_in === "compact") return 1.0 - t;
408
+ // expanded: staggered window fade-in, backmost (i=0) first, frontmost last.
409
+ const startI = 1.0 - window + (i / denom) * spread;
410
+ return Math.max(0.0, Math.min(1.0, (t - startI) / ramp));
411
+ });
412
+ }
413
+
172
414
  function lerp3(a, b, t) {
173
415
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
174
416
  }
@@ -187,7 +429,12 @@ function frameState(scene, compact, expanded, t) {
187
429
  const expandedGaps = plateGaps(scene);
188
430
  const gaps = expandedGaps.map((g) => MIN_GAP + (g - MIN_GAP) * t);
189
431
  const opacities = scene.slides.map((s) => interpolateOpacity(s, t));
190
- return { camera: poseAt(compact, expanded, t), gaps, opacities };
432
+ return {
433
+ camera: poseAt(compact, expanded, t),
434
+ gaps,
435
+ opacities,
436
+ captionOpacities: captionOpacities(scene, t),
437
+ };
191
438
  }
192
439
 
193
440
  /**
@@ -198,9 +445,12 @@ function frameState(scene, compact, expanded, t) {
198
445
  * @param {object} scene parsed scene
199
446
  * @param {number} t eased morph factor
200
447
  */
201
- export function frameStateAt(scene, t) {
202
- const compact = compactCamera(scene);
203
- const expanded = expandedCamera(scene);
448
+ export function frameStateAt(scene, t, viewportAspect) {
449
+ // viewportAspect (the live element's container aspect) defaults to the scene aspect, so
450
+ // the rendered/aspect-locked paths are unchanged; the scrollable passes its 2:1 container
451
+ // aspect so compact fits with side padding and expanded fills vertically (issue 314).
452
+ const compact = compactCamera(scene, viewportAspect);
453
+ const expanded = expandedCamera(scene, viewportAspect);
204
454
  return frameState(scene, compact, expanded, Math.max(0, Math.min(1, t)));
205
455
  }
206
456
 
package/src/index.js CHANGED
@@ -49,8 +49,30 @@ export class VexyStax {
49
49
  this.container = container;
50
50
  this.scene = scene;
51
51
  this.stage = new Stage(container, scene);
52
+ this._ro = null; // ResizeObserver for post-layout resize
52
53
  this._ready = this.stage.init().then(() => {
53
54
  this.stage.render();
55
+ // After mount, observe the container for its first actual layout dimensions.
56
+ // When the container uses height:auto + aspect-ratio, clientHeight is 0 at
57
+ // init time; the ResizeObserver fires once the CSS engine resolves the size,
58
+ // ensuring the canvas fits the container rather than the raw scene pixel size.
59
+ if (typeof ResizeObserver !== "undefined") {
60
+ let observed = false;
61
+ this._ro = new ResizeObserver((entries) => {
62
+ for (const entry of entries) {
63
+ const w = entry.contentRect?.width;
64
+ const h = entry.contentRect?.height;
65
+ if (w > 0 && h > 0) {
66
+ this.stage.resize(w, h);
67
+ this.stage.render();
68
+ // After the first real resize we can stop if the element has a
69
+ // fixed CSS size; keep observing for responsive containers.
70
+ observed = true;
71
+ }
72
+ }
73
+ });
74
+ this._ro.observe(container);
75
+ }
54
76
  return this;
55
77
  });
56
78
  }
@@ -68,6 +90,18 @@ export class VexyStax {
68
90
  return this;
69
91
  }
70
92
 
93
+ /**
94
+ * Apply an arbitrary morph factor `t` (0 = compact, 1 = expanded) and render. The
95
+ * low-level scrub primitive behind scroll-driven stories (e.g. the scrollable demo
96
+ * computes its own tent mapping and calls seek each scroll frame). Clamped to [0,1].
97
+ */
98
+ seek(t) {
99
+ const tt = Math.max(0, Math.min(1, Number(t) || 0));
100
+ this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
101
+ this.stage.render();
102
+ return this;
103
+ }
104
+
71
105
  /** Resize the renderer/camera to the container (or explicit size). */
72
106
  resize(width, height) {
73
107
  const w = width ?? this.container.clientWidth;
@@ -127,7 +161,7 @@ export class VexyStax {
127
161
  }
128
162
  // Snap to the starting endpoint so the first frame is correct.
129
163
  const { startMorph } = transitionEndpoints(resolvedKind);
130
- this.stage.applyFrameState(frameStateAt(this.scene, startMorph), startMorph);
164
+ this.stage.applyFrameState(frameStateAt(this.scene, startMorph, this.stage.camera.aspect), startMorph);
131
165
  this.stage.render();
132
166
 
133
167
  this._cancelTransition?.();
@@ -213,9 +247,14 @@ export class VexyStax {
213
247
  const wait = this.scene.transition?.wait ?? 0.0;
214
248
 
215
249
  const apply = (p) => {
216
- // Map global scroll progress through the transition timeline morph t.
217
- const t = this._scrollMorph(kind, p, easing, duration, wait);
218
- this.stage.applyFrameState(frameStateAt(this.scene, t), t);
250
+ // Map scroll progress [0,1] -> morph t. By default through the transition
251
+ // timeline; a custom `opts.map` (p -> t) enables non-linear scroll stories such
252
+ // as the scrollable demo's tent (compact at the edges, expanded when centered).
253
+ const t =
254
+ typeof opts.map === "function"
255
+ ? Math.max(0, Math.min(1, opts.map(p)))
256
+ : this._scrollMorph(kind, p, easing, duration, wait);
257
+ this.stage.applyFrameState(frameStateAt(this.scene, t, this.stage.camera.aspect), t);
219
258
  this.stage.render();
220
259
  };
221
260
 
@@ -241,6 +280,8 @@ export class VexyStax {
241
280
  destroy() {
242
281
  this._cancelTransition?.();
243
282
  this._scrollspy?.disconnect?.();
283
+ this._ro?.disconnect?.();
284
+ this._ro = null;
244
285
  this.stage?.dispose();
245
286
  }
246
287
  }