@displayxr/inline3d 1.38.0 → 1.39.0

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/js/inline3d.js CHANGED
@@ -422,6 +422,70 @@ function nowMs() {
422
422
  // report. Per-window override: `firstWovenHoldMs` on any add*() call.
423
423
  const FIRST_WOVEN_HOLD_MS = 1200;
424
424
 
425
+ // The browser's own join report (DisplayXR Browser PR #258, Windows, behind a feature flag):
426
+ // `XRDisplayLayer.wovenState` — 'pending' (no presented frame has reported this layer yet: from
427
+ // construction, again after close(), and whenever the reply does not list it), 'woven' (the
428
+ // latest swapped frame put this layer's rect into the weave) or 'withheld' (with a why-token in
429
+ // `withheldReason`: no-quad / no-identity / no-join / cross-pass:mono / cross-pass:mono(cover,2:1)
430
+ // / resolve-dropped). It is a per-frame LEVEL, nothing latched, read off the GetLatestViews reply,
431
+ // and the layer is not an EventTarget — so polling it once per session frame is the whole API.
432
+ //
433
+ // Where it exists, firstWoven/rewoven settle on it (`confirmed: true`) instead of on the hold.
434
+ // Where it does not (mac, Linux, Android, and every Windows build without the patch: the
435
+ // attribute is deliberately absent there, so `in` on the prototype is an honest feature test)
436
+ // the hold below is used exactly as before.
437
+ const WOVEN_STATES = new Set(['pending', 'woven', 'withheld']);
438
+ // The report trails the join by 1-3 renderer frames. After a rect change that never reads
439
+ // anything but 'woven' (a move the browser rejoins without a gap), a 'woven' read only means the
440
+ // NEW rect once this many session frames have passed since the change was seen; before that it
441
+ // can still be the old rect's.
442
+ const WOVEN_STATE_LAG_FRAMES = 3;
443
+
444
+ // The display calls that wait for a layer's first frame (see Inline3D._layerCall). All three
445
+ // answer from the browser's weave session, which only exists once a layer has delivered a frame:
446
+ // before that getDisplayInfo() is null, getRenderingModes() is [] and requestRenderingMode() is
447
+ // `false` ("not forwarded").
448
+ const LAYER_CALLS_AFTER_FIRST_FRAME = new Set(['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode']);
449
+ // The two READS among them. A read that comes back null / empty before the cap is not an answer
450
+ // yet (the weave session can lag the release signal by a few frames): it is asked again on the
451
+ // next release signal. requestRenderingMode resolves void, so it is forwarded once, not retried.
452
+ const LAYER_READS_RETRIED_WHEN_EMPTY = new Set(['getDisplayInfo', 'getRenderingModes']);
453
+
454
+ /** A display read's "nothing yet" shape: null / undefined, or an empty list. */
455
+ function isEmptyLayerAnswer(r) {
456
+ return r === null || r === undefined || (Array.isArray(r) && r.length === 0);
457
+ }
458
+
459
+ /** Does this browser build report `XRDisplayLayer.wovenState`? Feature-tested on the prototype. */
460
+ function hasWovenState() {
461
+ try {
462
+ const L = typeof XRDisplayLayer === 'function' ? XRDisplayLayer : null;
463
+ return !!(L && L.prototype && 'wovenState' in L.prototype);
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
468
+
469
+ /** This layer's woven state, or null when the read fails or returns something not in the contract. */
470
+ function readWovenState(layer) {
471
+ try {
472
+ const s = layer ? layer.wovenState : null;
473
+ return WOVEN_STATES.has(s) ? s : null;
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+
479
+ /** The why-token while 'withheld', else null. */
480
+ function readWithheldReason(layer) {
481
+ try {
482
+ const r = layer ? layer.withheldReason : null;
483
+ return typeof r === 'string' && r ? r : null;
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+
425
489
  // The easing option, validated here rather than in the state machine: the sequencer falls back
426
490
  // silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth
427
491
  // exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know.
@@ -693,10 +757,51 @@ function chromeTextPlates(root) {
693
757
  /** A pending rewoven() settles anyway ('hold-capped') after this many holds from the call. */
694
758
  const REWOVEN_MAX_HOLDS = 4;
695
759
 
696
- /** The CSS size + dpr of a window's canvas: what a pending rewoven() restarts on. */
760
+ /**
761
+ * The CSS size + dpr + PAGE position of a window's canvas: what a pending rewoven() restarts on,
762
+ * and what a rect cover goes up on.
763
+ *
764
+ * Position joined the key in 1.38: a canvas moved to another place on the page, at the same size,
765
+ * goes through the same identity gap as a resized one (a kiosk demo moving ONE woven canvas between
766
+ * screens measured 0.4-1.2 s of black at the new rect). It is measured in PAGE coordinates
767
+ * (viewport rect + document scroll) so that scrolling the document is not a move: a scrolled
768
+ * canvas keeps its identity (rule 11). Rounded to whole CSS px so sub-pixel layout jitter is not
769
+ * a move either. A scroll inside a nested scroller still reads as one; that is the price of one
770
+ * getBoundingClientRect per frame instead of walking the scroll ancestors.
771
+ */
697
772
  function boxKeyOf(canvas) {
698
773
  const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
699
- return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}`;
774
+ let pos = '';
775
+ if (typeof canvas.getBoundingClientRect === 'function') {
776
+ const r = canvas.getBoundingClientRect() || {};
777
+ const sx = (typeof window !== 'undefined' && window.scrollX) || 0;
778
+ const sy = (typeof window !== 'undefined' && window.scrollY) || 0;
779
+ pos = `+${Math.round((r.left ?? r.x ?? 0) + sx)},${Math.round((r.top ?? r.y ?? 0) + sy)}`;
780
+ }
781
+ return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}${pos}`;
782
+ }
783
+
784
+ /** A scene canvas's CSS box and dpr, as addScene's `onResize` receives it. */
785
+ function sceneBoxOf(canvas) {
786
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
787
+ return { width: canvas.clientWidth || 0, height: canvas.clientHeight || 0, dpr };
788
+ }
789
+
790
+ /**
791
+ * The `rectCover` option of every add*(): null (off) or `{ color, snapshot }`. Off by default in
792
+ * the core (frozen defaults); `./splat` and `./model` pass 'auto'. A bad value throws at the call:
793
+ * a typo here would otherwise silently leave a page without the cover it asked for.
794
+ */
795
+ function resolveRectCover(v) {
796
+ if (v === undefined || v === null || v === false || v === 'off') return null;
797
+ if (v === 'auto' || v === true) return { color: '#000', snapshot: true };
798
+ if (typeof v === 'object') {
799
+ if (v.color !== undefined && typeof v.color !== 'string') {
800
+ throw new TypeError('[inline3d] rectCover.color must be a CSS color string.');
801
+ }
802
+ return { color: v.color || '#000', snapshot: v.snapshot !== false };
803
+ }
804
+ throw new TypeError(`[inline3d] rectCover: expected 'auto', 'off' or { color?, snapshot? }; got ${JSON.stringify(v)}.`);
700
805
  }
701
806
 
702
807
  class Inline3D {
@@ -709,6 +814,11 @@ class Inline3D {
709
814
  this.session = session;
710
815
  this.refSpace = refSpace;
711
816
  this._windows = new Map(); // canvas -> window record
817
+ // Double-attach detection. `_claims`: canvases a scene subpath entry (addSplat / addModel) has
818
+ // taken before its core window exists (canvas -> { token, method }); `_doubleWarned`:
819
+ // canvases already warned about, so a page calling add*() per render warns once.
820
+ this._claims = new Map();
821
+ this._doubleWarned = new WeakSet();
712
822
  this._globalOverlays = new Set(); // page-global overlays excluded from EVERY window
713
823
  // el -> Set(window) currently excluding it. Isolation (will-change) is a GLOBAL
714
824
  // property of the element while exclusion is PER-WINDOW, so the promotion has to
@@ -946,6 +1056,8 @@ class Inline3D {
946
1056
  * @param {number} [opts.cornerRadius=0] round each eye's corners in buffer px (CSS
947
1057
  * border-radius can't: it would round the packed SBS square's outer corners and
948
1058
  * come out lopsided after the eye-split).
1059
+ * @param {'auto'|'off'|{color?:string, snapshot?:boolean}} [opts.rectCover='off'] (every add*())
1060
+ * cover the canvas across a later move/resize until rewoven() settles; see _rectCoverCheck.
949
1061
  * @param {number} [opts.feather=0] fade each eye's outer edges to transparent over this
950
1062
  * many buffer px, so the 3D window dissolves into the page instead of ending at a
951
1063
  * hard rectangle. Same reason CSS can't do it: a mask/filter on the canvas applies
@@ -954,7 +1066,9 @@ class Inline3D {
954
1066
  * @returns {{remove():void}}
955
1067
  */
956
1068
  addImage(canvas, source, opts = {}) {
1069
+ this._warnDoubleAttach(canvas, 'addImage', opts._dxrClaim);
957
1070
  const win = this._register(canvas, 'image', opts);
1071
+ win.method = 'addImage';
958
1072
  win.ready = loadImage(source).then((img) => {
959
1073
  win.img = img;
960
1074
  win.repaint();
@@ -970,7 +1084,9 @@ class Inline3D {
970
1084
  * @returns {{remove():void}}
971
1085
  */
972
1086
  addVideo(canvas, video, opts = {}) {
1087
+ this._warnDoubleAttach(canvas, 'addVideo', opts._dxrClaim);
973
1088
  const win = this._register(canvas, 'video', opts);
1089
+ win.method = 'addVideo';
974
1090
  win.video = video;
975
1091
  return this._handle(canvas, win);
976
1092
  }
@@ -999,6 +1115,10 @@ class Inline3D {
999
1115
  * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
1000
1116
  * to pass both) — either way the window still weaves.
1001
1117
  * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
1118
+ * @param {(box:{width:number, height:number, dpr:number}) => void} [opts.onResize] called
1119
+ * inside the ResizeObserver callback when the canvas's CSS box or dpr changes, i.e.
1120
+ * before the paint: resize your backing store and draw one frame here and the old store
1121
+ * is never shown stretched onto the new box. Only on a real change; errors are caught.
1002
1122
  * @param {() => void} [opts.onLayerLost] called once when this window's weave layer goes away
1003
1123
  * for good — the session ended, or the layer could not be created. YOU own a scene
1004
1124
  * canvas's pixels, so this is the SDK's only way to tell you that the side-by-side pair
@@ -1011,8 +1131,10 @@ class Inline3D {
1011
1131
  // The rig and the height describe the same one slot in the layer init, so warn where a
1012
1132
  // caller has said it twice — silently dropping one of two things the page explicitly asked
1013
1133
  // for is how a scene ends up framed at a scale nobody chose.
1134
+ this._warnDoubleAttach(canvas, 'addScene', opts._dxrClaim);
1014
1135
  if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight();
1015
1136
  const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts });
1137
+ win.method = 'addScene';
1016
1138
  win.onFrame = onFrame;
1017
1139
  win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height
1018
1140
  // The SDK's own renderers (./viewer, ./splat, ./model) clamp their buffers before sizing and
@@ -1021,6 +1143,76 @@ class Inline3D {
1021
1143
  return this._handle(canvas, win);
1022
1144
  }
1023
1145
 
1146
+ // ── double-attach detection ───────────────────────────────────────────────────────────
1147
+ //
1148
+ // A second add*() on a canvas that is still registered closes its layer and builds a new one: a
1149
+ // fresh identity gap (rule 2), and for ./splat and ./model a second renderer on the same
1150
+ // context. A kiosk demo attached its stage twice per screen visit this way without noticing.
1151
+ // The SDK says so, once per canvas, and then does exactly what it always did: a WARNING, so no
1152
+ // page changes behavior (a page that re-adds to swap pictures keeps working).
1153
+
1154
+ /** The live registration on `canvas` other than the one `token` belongs to: its method, or null. */
1155
+ _liveRegistration(canvas, token) {
1156
+ const claim = this._claims.get(canvas);
1157
+ if (claim && claim.token !== token) return claim.method;
1158
+ const win = this._windows.get(canvas);
1159
+ return win ? win.method || 'add*' : null;
1160
+ }
1161
+
1162
+ _warnDoubleAttach(canvas, method, token) {
1163
+ const live = this._liveRegistration(canvas, token);
1164
+ if (!live || this._doubleWarned.has(canvas)) return;
1165
+ this._doubleWarned.add(canvas);
1166
+ console.warn(
1167
+ `[inline3d] ${method}() on a canvas that is already registered on this inline-3D session ` +
1168
+ `(by ${live}(), not yet removed). This call is rebuilding the canvas's layer (a fresh ` +
1169
+ '0.4-1.2 s identity gap) or putting a second renderer on its context. Change what is IN ' +
1170
+ 'the canvas instead (setSource, your onFrame, a redrawn source canvas), or call ' +
1171
+ 'handle.remove() on the first registration first. (Warned once per canvas.)',
1172
+ canvas
1173
+ );
1174
+ }
1175
+
1176
+ /**
1177
+ * For the scene subpath entries (via guardedAttach in ./inline3d-splat-shared.js), which build
1178
+ * their renderer BEFORE the core window exists, so the core's own check would come too late.
1179
+ * Warns on a live registration, then claims the canvas for this call either way: returns
1180
+ * `{ opts, own }` — attach with `opts` (it carries the claim token down to this entry's own
1181
+ * addScene, which then does not warn a second time), then `own(handle)`. A nested entry
1182
+ * (./model's engine:'three' route calls ./model/three's addModel) inherits the claim.
1183
+ */
1184
+ _attachGuard(canvas, method, opts = {}) {
1185
+ const claim = this._claims.get(canvas);
1186
+ if (opts && opts._dxrClaim && claim && claim.token === opts._dxrClaim) return { opts, own: () => {} };
1187
+ this._warnDoubleAttach(canvas, method, null);
1188
+ const token = {};
1189
+ return { opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) };
1190
+ }
1191
+
1192
+ /**
1193
+ * Remember that a subpath handle holds `canvas` until that handle's remove() (the latest call
1194
+ * wins). The adapters REASSIGN `out.remove` when their module loads, so the release rides an
1195
+ * accessor that wraps whatever is assigned.
1196
+ */
1197
+ _claim(canvas, method, out, token) {
1198
+ if (!this._running || !out || typeof out !== 'object') return;
1199
+ const claim = { token, method };
1200
+ this._claims.set(canvas, claim);
1201
+ let impl = out.remove;
1202
+ const remove = (...args) => {
1203
+ if (this._claims.get(canvas) === claim) this._claims.delete(canvas);
1204
+ return typeof impl === 'function' ? impl.apply(out, args) : undefined;
1205
+ };
1206
+ Object.defineProperty(out, 'remove', {
1207
+ configurable: true,
1208
+ enumerable: true,
1209
+ get: () => remove,
1210
+ set: (fn) => {
1211
+ impl = fn;
1212
+ },
1213
+ });
1214
+ }
1215
+
1024
1216
  /**
1025
1217
  * The handle every add*() returns. `exclude(el)` marks 2D DOM painted over this window
1026
1218
  * (a hover plate, a play badge) so the weave leaves it crisp 2D instead of garbling it
@@ -1220,15 +1412,36 @@ class Inline3D {
1220
1412
  // Read-only counters, for pages that want to see the load-induced mono fallback rather
1221
1413
  // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
1222
1414
  stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
1415
+ /**
1416
+ * The browser's own report for this window's layer, read live: 'pending' | 'woven' |
1417
+ * 'withheld', or null on a browser that does not report it (the attribute is absent: mac,
1418
+ * Linux, Android, Windows builds without it). 'pending' too while the window has no live
1419
+ * layer (lazy, scrolled away), matching what the browser says of a closed layer. For
1420
+ * diagnostics and pages; firstWoven/rewoven already settle on it.
1421
+ */
1422
+ get wovenState() {
1423
+ if (!hasWovenState()) return null;
1424
+ if (!win.layer) return 'pending';
1425
+ return readWovenState(win.layer);
1426
+ },
1427
+ /** The browser's why-token while `wovenState` is 'withheld' (e.g. 'no-identity'), else null. */
1428
+ get withheldReason() {
1429
+ if (!hasWovenState() || !win.layer) return null;
1430
+ return readWovenState(win.layer) === 'withheld' ? readWithheldReason(win.layer) : null;
1431
+ },
1223
1432
  /**
1224
1433
  * Resolves ONCE, never rejects: `{ woven, confirmed, reason, ms }`.
1225
1434
  *
1226
1435
  * `woven: true` — the window has drawn a stereo frame on a layer that has existed for
1227
1436
  * `firstWovenHoldMs` (default 1200). That is the moment to drop a poster covering the
1228
- * canvas. `confirmed` is `false` today, always: no browser reports when its compositor
1229
- * actually joined a canvas, so this is the browser's worst case, measured by the SDK so
1230
- * pages stop measuring it themselves. It becomes a reported fact (`confirmed: true`, no
1231
- * hold) when a browser can say so, with no change to the page.
1437
+ * canvas. On a browser without `XRDisplayLayer.wovenState` this is the browser's worst
1438
+ * case, measured by the SDK (`confirmed: false`, reason 'hold-elapsed'). On one that has it,
1439
+ * it is the browser's report: `confirmed: true`, reason 'woven', on the first 'woven' read
1440
+ * once a stereo frame is drawn, with no hold. Same page code on both.
1441
+ *
1442
+ * On the report, a layer that stays 'withheld' still settles after a cap (four holds, at
1443
+ * least 4.8 s): `woven: false, confirmed: true, reason: <withheldReason>` (or 'pending').
1444
+ * That is a level, not a loss: the canvas is NOT taken flat, and `rewoven()` keeps working.
1232
1445
  *
1233
1446
  * `woven: false` — this window will not weave: `reason` is `'layer-failed'`,
1234
1447
  * `'session-ended'` or `'removed'`. The SDK has already taken an image/video canvas flat,
@@ -1264,12 +1477,19 @@ class Inline3D {
1264
1477
  * already woven but whose rect is about to change (fullscreen, a layout resize): the
1265
1478
  * browser re-registers the moved rect and the same identity gap as a fresh canvas
1266
1479
  * applies, so cover the canvas across the change and release on this. A change of the
1267
- * canvas's CSS size or devicePixelRatio while it is pending restarts the hold (checked every
1268
- * frame, for every window kind; a move without a resize is not detected), and it settles
1480
+ * canvas's CSS size, devicePixelRatio or page position (since 1.38: a moved canvas goes
1481
+ * through the same gap; document scroll is not a move) while it is pending restarts the
1482
+ * hold (checked every frame, for every window kind), and it settles
1269
1483
  * anyway with `reason: 'hold-capped'` four holds after the call, so a size that never stops
1270
1484
  * animating cannot keep a cover up. Calling it again while pending returns the same
1271
1485
  * promise, restarted. Before the first join it IS `firstWoven`; on a window that will not
1272
1486
  * weave it resolves that `woven: false` result. Same shape, never rejects.
1487
+ *
1488
+ * On a browser with `XRDisplayLayer.wovenState` it settles on the report (`confirmed: true`,
1489
+ * reason 'woven'): on a 'woven' read that follows a 'withheld'/'pending' read seen after the
1490
+ * call, or three session frames after the last box change, so a stale 'woven' from the old
1491
+ * rect never settles it. The cap stays: a layer that never leaves 'withheld' settles `woven:
1492
+ * false, confirmed: true, reason: <withheldReason>` four holds after the call.
1273
1493
  */
1274
1494
  rewoven: () => this._rewoven(win),
1275
1495
  };
@@ -1482,13 +1702,104 @@ class Inline3D {
1482
1702
  )
1483
1703
  );
1484
1704
  }
1705
+ // A layer that has not delivered a frame yet answers getDisplayInfo() with null and
1706
+ // getRenderingModes() with an empty list, and null is documented as "no glasses-free
1707
+ // display". A page that asks right after add*() (the natural place) would be told there is
1708
+ // no display. So wait for this layer's release signal (see _tickLayerFramed) and ask then.
1709
+ // Capped at the first-woven cap, so a layer that never frames still answers (null/empty, as
1710
+ // a real absence). Re-entered after the wait: the layer may have closed (-> the rejection
1711
+ // above) or been rebuilt (-> wait for the new one) meanwhile.
1712
+ const deferred = LAYER_CALLS_AFTER_FIRST_FRAME.has(method);
1713
+ if (deferred && !win.layerFramed && this._layerReadPending(win)) {
1714
+ return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args));
1715
+ }
1485
1716
  // Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError
1486
1717
  // that way for a non-2-view mode) arrives as a rejection like every other failure.
1718
+ let p;
1487
1719
  try {
1488
- return Promise.resolve(win.layer[method](...args));
1720
+ p = Promise.resolve(win.layer[method](...args));
1489
1721
  } catch (e) {
1490
1722
  return Promise.reject(e);
1491
1723
  }
1724
+ if (!LAYER_READS_RETRIED_WHEN_EMPTY.has(method) || !this._layerReadPending(win)) return p;
1725
+ // Released, but not answered for real yet on this layer. The weave session can trail the
1726
+ // release signal, so a null / empty answer before the cap is "not ready", never returned:
1727
+ // wait for the next release signal (the next 'woven' read, or the next stereo frame) and ask
1728
+ // again. The first real answer marks the layer answered, and later calls go straight through.
1729
+ const layer = win.layer;
1730
+ return p.then((r) => {
1731
+ if (win.layer !== layer) return r; // that layer's own answer; the window moved on
1732
+ if (!isEmptyLayerAnswer(r)) {
1733
+ win.lfAnswered = true;
1734
+ return r;
1735
+ }
1736
+ if (!this._layerReadPending(win)) return r; // at the cap: null / [] is a real absence
1737
+ return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args));
1738
+ });
1739
+ }
1740
+
1741
+ /** The first-frame wait cap: the first-woven cap (four holds, never under four default holds). */
1742
+ _layerFramedCap(win) {
1743
+ return REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS);
1744
+ }
1745
+
1746
+ /**
1747
+ * Is a display read on `win` still owed a real answer by its current layer — not yet answered
1748
+ * non-empty, and the cap not passed? (Whether it waits for the FIRST release is `layerFramed`.)
1749
+ */
1750
+ _layerReadPending(win) {
1751
+ if (!win.layer || win.lfAnswered || win.lfCapped) return false;
1752
+ if (nowMs() - win.lfAt >= this._layerFramedCap(win)) {
1753
+ win.lfCapped = true;
1754
+ return false;
1755
+ }
1756
+ return true;
1757
+ }
1758
+
1759
+ /**
1760
+ * Resolves once `win`'s current layer has delivered its first frame, when the layer goes away
1761
+ * or is rebuilt, or at the cap (measured from the layer's construction, as firstWoven's is).
1762
+ * Never rejects. The cap is checked per session frame on the same clock as firstWoven, and
1763
+ * backed by a real timer for a session whose frames have stopped altogether.
1764
+ */
1765
+ _whenLayerFramed(win) {
1766
+ return new Promise((resolve) => {
1767
+ const layer = win.layer;
1768
+ const waiter = { resolve, timer: null };
1769
+ const left = Math.max(0, this._layerFramedCap(win) - (nowMs() - win.lfAt));
1770
+ waiter.timer = setTimeout(() => {
1771
+ const i = win.framedWaiters.indexOf(waiter);
1772
+ if (i >= 0) win.framedWaiters.splice(i, 1);
1773
+ if (win.layer === layer) win.lfCapped = true;
1774
+ resolve();
1775
+ }, left);
1776
+ if (waiter.timer && typeof waiter.timer.unref === 'function') waiter.timer.unref();
1777
+ win.framedWaiters.push(waiter);
1778
+ });
1779
+ }
1780
+
1781
+ /**
1782
+ * Per session frame, after the draw, until the current layer has framed and while reads wait.
1783
+ * The release signal: where the browser reports `wovenState`, a 'woven' read (a 'withheld'
1784
+ * read right after construction comes before the weave session can answer, so it does not
1785
+ * count); elsewhere, a frame once this layer has carried a stereo frame (the firstWoven signal,
1786
+ * with no hold). Every such frame releases the waiting reads, so a read that came back empty
1787
+ * is asked again on the next one. Capped at _layerFramedCap from the layer's construction.
1788
+ */
1789
+ _tickLayerFramed(win) {
1790
+ const ws = win.wsLive ? readWovenState(win.layer) : null;
1791
+ if (ws ? ws === 'woven' : win.lfStereo) this._noteLayerFramed(win, true);
1792
+ else if (win.framedWaiters.length && !this._layerReadPending(win)) this._noteLayerFramed(win, false);
1793
+ }
1794
+
1795
+ /** The layer framed (mark = true), or went away / capped (false): release every waiting read. */
1796
+ _noteLayerFramed(win, mark = true) {
1797
+ if (mark) win.layerFramed = true;
1798
+ const ws = win.framedWaiters.splice(0);
1799
+ for (const w of ws) {
1800
+ clearTimeout(w.timer);
1801
+ w.resolve();
1802
+ }
1492
1803
  }
1493
1804
 
1494
1805
  /** The first window currently holding a live layer, or null. Every display call needs one. */
@@ -2002,6 +2313,12 @@ class Inline3D {
2002
2313
  // therefore already fail to build its layer) inside this call.
2003
2314
  onLayerLost: typeof opts.onLayerLost === 'function' ? opts.onLayerLost : null,
2004
2315
  layerLostSent: false,
2316
+ // Scene windows only (addScene's `onResize`): called inside the ResizeObserver callback, so
2317
+ // a page-drawn window can resize its store and draw before the paint. Read here for the same
2318
+ // reason as onLayerLost: a non-lazy window starts its size watch inside this call.
2319
+ onResize: kind === 'scene' && typeof opts.onResize === 'function' ? opts.onResize : null,
2320
+ resizeBox: null,
2321
+ inResize: false,
2005
2322
  ready: null,
2006
2323
  ownsBuffer: kind !== 'scene',
2007
2324
  cornerRadius: opts.cornerRadius || 0,
@@ -2054,7 +2371,29 @@ class Inline3D {
2054
2371
  // firstWoven's two halves again, counted from the call (and from each later box change).
2055
2372
  rw: null,
2056
2373
  rwGone: null, // the reason this window will never weave again, once one arrived
2374
+ // Whether the CURRENT layer is on a browser that reports `wovenState` (set per layer in
2375
+ // _activate). False keeps firstWoven/rewoven on the hold, exactly as before the report.
2376
+ wsLive: false,
2377
+ // The rect cover (opts.rectCover; see _rectCoverCheck), or null when off.
2378
+ rc: null,
2379
+ // Display reads wait for the current layer's first frame (see _layerCall). `layerFramed`:
2380
+ // its release signal has been seen; `lfAnswered`: a read got a real (non-empty) answer from
2381
+ // it; `lfStereo`: it has carried a stereo frame; `lfAt`: when it was built; `lfCapped`: the
2382
+ // wait cap ran out for it; `framedWaiters`: reads queued meanwhile.
2383
+ layerFramed: false,
2384
+ lfAnswered: false,
2385
+ lfStereo: false,
2386
+ lfAt: 0,
2387
+ lfCapped: false,
2388
+ framedWaiters: [],
2057
2389
  };
2390
+ const rcCfg = resolveRectCover(opts.rectCover);
2391
+ if (rcCfg) {
2392
+ // box: the last key seen (null = not looked yet); el: the cover element, made on first use;
2393
+ // up: showing; snapped/snapWanted: this cycle's one snapshot; waiting: the rewoven promise
2394
+ // that takes it down; w/h: the CSS box at the last look (the snapshot's source aspect).
2395
+ win.rc = { cfg: rcCfg, box: null, el: null, up: false, snapped: false, snapWanted: false, waiting: null, w: 0, h: 0 };
2396
+ }
2058
2397
  win.fwPromise = new Promise((resolve) => {
2059
2398
  win.fwResolve = resolve;
2060
2399
  });
@@ -2074,6 +2413,7 @@ class Inline3D {
2074
2413
  this._deactivate(win);
2075
2414
  this._windows.delete(canvas);
2076
2415
  this._settleFirstWoven(win, false, 'removed');
2416
+ this._dropCover(win);
2077
2417
  }
2078
2418
 
2079
2419
  _onIntersect(entries) {
@@ -2148,6 +2488,14 @@ class Inline3D {
2148
2488
  win.fwLayerAt = nowMs();
2149
2489
  win.fwStereo = false;
2150
2490
  }
2491
+ win.wsLive = hasWovenState();
2492
+ // A new layer has delivered nothing yet (see _layerCall). Reads queued on the previous one
2493
+ // were released by _deactivate and re-enter against this one.
2494
+ win.layerFramed = false;
2495
+ win.lfAnswered = false;
2496
+ win.lfStereo = false;
2497
+ win.lfCapped = false;
2498
+ win.lfAt = nowMs();
2151
2499
  this._restartRewoven(win);
2152
2500
  win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
2153
2501
  // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
@@ -2189,6 +2537,7 @@ class Inline3D {
2189
2537
  }
2190
2538
 
2191
2539
  _deactivate(win) {
2540
+ this._noteLayerFramed(win, false); // queued display reads re-check and fail as before
2192
2541
  this._stopOverlayScan(win);
2193
2542
  this._stopSizeWatch(win);
2194
2543
  if (win.layer) {
@@ -2570,6 +2919,14 @@ class Inline3D {
2570
2919
  _startSizeWatch(win) {
2571
2920
  if (typeof ResizeObserver !== 'function') return;
2572
2921
  if (win.sizeObserver) return;
2922
+ if (win.kind === 'scene') {
2923
+ // The SDK never sizes a scene canvas; it only hands the page the moment to (opts.onResize).
2924
+ if (!win.onResize) return;
2925
+ win.resizeBox = sceneBoxOf(win.canvas);
2926
+ win.sizeObserver = new ResizeObserver(() => this._onSceneResize(win));
2927
+ win.sizeObserver.observe(win.canvas);
2928
+ return;
2929
+ }
2573
2930
  // Scene canvases are the app's (ownsBuffer false) — never touch their width/height.
2574
2931
  // An explicit {width, height} is box-independent by definition, so nothing to watch.
2575
2932
  if (!win.ownsBuffer || (win.reqW && win.reqH)) return;
@@ -2577,6 +2934,33 @@ class Inline3D {
2577
2934
  win.sizeObserver.observe(win.canvas);
2578
2935
  }
2579
2936
 
2937
+ /**
2938
+ * addScene's `onResize(box)`, run INSIDE the ResizeObserver callback. That callback runs after
2939
+ * the frame's animation callbacks and before its paint, so a page that resizes its store and
2940
+ * draws here never shows the old backing store stretched onto the new box for a frame — which
2941
+ * is what deferring to the next animation frame shows. Only on a real change of the CSS box or
2942
+ * dpr (an observer fires on plenty that moves neither), never re-entrantly, and a throw is
2943
+ * contained and warned about once: this is a browser callback and must not take the page down.
2944
+ */
2945
+ _onSceneResize(win) {
2946
+ if (!win.layer || !win.onResize || win.inResize) return;
2947
+ const box = sceneBoxOf(win.canvas);
2948
+ const last = win.resizeBox;
2949
+ if (last && last.width === box.width && last.height === box.height && last.dpr === box.dpr) return;
2950
+ win.resizeBox = box;
2951
+ win.inResize = true;
2952
+ try {
2953
+ win.onResize(box);
2954
+ } catch (err) {
2955
+ if (!win.resizeThrewWarned) {
2956
+ win.resizeThrewWarned = true;
2957
+ console.warn("[inline3d] a scene window's onResize threw; further throws from it are silent.", err);
2958
+ }
2959
+ } finally {
2960
+ win.inResize = false;
2961
+ }
2962
+ }
2963
+
2580
2964
  _stopSizeWatch(win) {
2581
2965
  if (win.sizeObserver) {
2582
2966
  win.sizeObserver.disconnect();
@@ -2842,6 +3226,8 @@ class Inline3D {
2842
3226
  this._trackBakedStereo(t);
2843
3227
  for (const win of this._windows.values()) {
2844
3228
  if (!win.layer) continue;
3229
+ // Before the draw: a moved/resized canvas is covered before anything else happens to it.
3230
+ if (win.rc) this._rectCoverCheck(win);
2845
3231
  if (win.kind === 'scene') {
2846
3232
  if (views && win.onFrame) {
2847
3233
  // Count the SHORT view lists and hand them over unchanged. Under GPU load the session
@@ -2877,6 +3263,7 @@ class Inline3D {
2877
3263
  // fallback (a mono frame), which is not what a poster is waiting for.
2878
3264
  if (views.length >= 2) {
2879
3265
  win.fwStereo = true;
3266
+ win.lfStereo = true;
2880
3267
  if (!win.warnedBufMismatch) this._checkSceneBuffer(win);
2881
3268
  if (win.rw) win.rw.stereo = true;
2882
3269
  }
@@ -2902,9 +3289,15 @@ class Inline3D {
2902
3289
  // video has never had a frame) the tile holds nothing worth revealing yet.
2903
3290
  if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
2904
3291
  win.fwStereo = true;
3292
+ win.lfStereo = true;
2905
3293
  if (win.rw) win.rw.stereo = true;
2906
3294
  }
2907
3295
  }
3296
+ // After the draw: the one snapshot of this cover cycle reads the frame just committed to
3297
+ // the backing store, in the same task, which is the one moment a WebGL drawing buffer is
3298
+ // guaranteed readable without preserveDrawingBuffer.
3299
+ if (win.rc && win.rc.snapWanted) this._rectCoverSnap(win);
3300
+ if (!win.layerFramed || win.framedWaiters.length) this._tickLayerFramed(win);
2908
3301
  this._tickFirstWoven(win);
2909
3302
  }
2910
3303
  }
@@ -2920,6 +3313,13 @@ class Inline3D {
2920
3313
  * which knows nothing about whether the compositor has matched this canvas yet.
2921
3314
  */
2922
3315
  _tickFirstWoven(win) {
3316
+ // The browser's report, where it has one. An unreadable or out-of-contract value falls back
3317
+ // to the hold for this frame rather than guessing.
3318
+ const ws = win.wsLive && win.layer ? readWovenState(win.layer) : null;
3319
+ if (ws) {
3320
+ this._tickWovenState(win, ws);
3321
+ return;
3322
+ }
2923
3323
  if (win.rw) {
2924
3324
  // Every frame, for every window kind: the ResizeObserver only watches SDK-sized buffers, and
2925
3325
  // no observer sees a dpr-only change.
@@ -2939,16 +3339,70 @@ class Inline3D {
2939
3339
  this._settleFirstWoven(win, true, 'hold-elapsed');
2940
3340
  }
2941
3341
 
2942
- /** One-shot: the first call wins, later ones are ignored. */
2943
- _settleFirstWoven(win, woven, reason) {
3342
+ /**
3343
+ * firstWoven / rewoven on the browser's report (`wovenState`, see WOVEN_STATES). Per session
3344
+ * frame, per live window, one attribute read.
3345
+ *
3346
+ * firstWoven: the first 'woven' read once the layer has carried a stereo frame — no hold.
3347
+ *
3348
+ * rewoven(): a 'woven' read is only trusted as the NEW rect's once it follows a 'withheld' or
3349
+ * 'pending' read seen after the call (the rect left the weave and came back), or once
3350
+ * WOVEN_STATE_LAG_FRAMES frames have passed since the last box change seen after the call (a
3351
+ * move rejoined without a gap). A call that sees no box change and no gap settles on a 'woven'
3352
+ * read WOVEN_STATE_LAG_FRAMES session frames after the call: the report is a live per-frame
3353
+ * level trailing the join by at most that many frames, so such a read reflects the rect as it
3354
+ * was at the call (the usual case: the page moved the canvas, THEN called rewoven()). Never on
3355
+ * the frame of the call itself. Either way it needs a stereo frame since the last restart, as
3356
+ * on the hold path.
3357
+ *
3358
+ * The cap stays, as a safety: the report is a level, and a layer that never leaves 'withheld'
3359
+ * (a CSS effect on an ancestor, rule 7) must not hold a cover up for good. Four holds (never
3360
+ * fewer than four default holds, so `firstWovenHoldMs: 0` does not mean "give up at once")
3361
+ * after the call, it settles anyway: `woven: false` with the browser's reason while withheld,
3362
+ * or `'hold-capped'` if the last read was 'woven' but never qualified.
3363
+ */
3364
+ _tickWovenState(win, state) {
3365
+ const t = nowMs();
3366
+ const cap = REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS);
3367
+ if (win.rw) {
3368
+ this._noteRewovenBox(win); // first, so a change seen this frame is not credited to this read
3369
+ const rw = win.rw;
3370
+ if (state !== 'woven') rw.gap = true;
3371
+ else if (
3372
+ rw.stereo &&
3373
+ (rw.gap ||
3374
+ (rw.boxFrame !== null && this._frameCount - rw.boxFrame >= WOVEN_STATE_LAG_FRAMES) ||
3375
+ (rw.boxFrame === null && this._frameCount - rw.callFrame >= WOVEN_STATE_LAG_FRAMES))
3376
+ ) {
3377
+ this._settleRewoven(win, true, 'woven', true);
3378
+ }
3379
+ if (win.rw && t - rw.calledAt >= cap) {
3380
+ if (state === 'woven') this._settleRewoven(win, true, 'hold-capped', true);
3381
+ else this._settleRewoven(win, false, readWithheldReason(win.layer) || state, true);
3382
+ }
3383
+ }
3384
+ if (win.fwResult || win.fwLayerAt === null) return;
3385
+ if (win.fwStereo && state === 'woven') this._settleFirstWoven(win, true, 'woven', true);
3386
+ else if (t - win.fwLayerAt >= cap) {
3387
+ if (state === 'woven') this._settleFirstWoven(win, true, 'hold-capped', true);
3388
+ else this._settleFirstWoven(win, false, readWithheldReason(win.layer) || state, true);
3389
+ }
3390
+ }
3391
+
3392
+ /**
3393
+ * One-shot: the first call wins, later ones are ignored. `confirmed` marks a result read off
3394
+ * the browser's report; a confirmed `woven: false` is a capped 'withheld' level, which can still
3395
+ * recover, so it is NOT a terminal "will not weave" and leaves rewoven() usable.
3396
+ */
3397
+ _settleFirstWoven(win, woven, reason, confirmed = false) {
2944
3398
  // Every "will not weave" path lands here, so a pending rewoven() is released with it, and a
2945
3399
  // later one answers at once (firstWoven may have settled woven:true long before).
2946
- if (!woven) {
3400
+ if (!woven && !confirmed) {
2947
3401
  if (!win.rwGone) win.rwGone = reason;
2948
3402
  this._settleRewoven(win, false, reason);
2949
3403
  }
2950
3404
  if (win.fwResult) return;
2951
- win.fwResult = Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - win.fwRegAt) });
3405
+ win.fwResult = Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - win.fwRegAt) });
2952
3406
  win.fwResolve(win.fwResult);
2953
3407
  win.fwResolve = null;
2954
3408
  }
@@ -2956,7 +3410,9 @@ class Inline3D {
2956
3410
  /** handle.rewoven(): see the handle's doc comment. */
2957
3411
  _rewoven(win) {
2958
3412
  if (!win.fwResult) return win.fwPromise; // still on the first join: the same question
2959
- if (!win.fwResult.woven) return Promise.resolve(win.fwResult);
3413
+ // A terminal no-weave answers at once. A CONFIRMED woven:false (the first join capped while
3414
+ // the browser reported 'withheld') is a level that can recover, so it is measured again.
3415
+ if (!win.fwResult.woven && !win.fwResult.confirmed) return Promise.resolve(win.fwResult);
2960
3416
  if (win.rwGone) return Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: win.rwGone, ms: 0 }));
2961
3417
  if (win.rw) {
2962
3418
  this._restartRewoven(win);
@@ -2967,7 +3423,20 @@ class Inline3D {
2967
3423
  resolve = r;
2968
3424
  });
2969
3425
  const t = nowMs();
2970
- win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas) };
3426
+ // `gap` / `boxFrame` / `callFrame` are the wovenState path's (see _tickWovenState): a
3427
+ // 'withheld'/'pending' read since the last restart, the session frame of the last box change
3428
+ // (null = none), and the session frame the call was made in.
3429
+ win.rw = {
3430
+ promise,
3431
+ resolve,
3432
+ calledAt: t,
3433
+ at: t,
3434
+ stereo: false,
3435
+ box: boxKeyOf(win.canvas),
3436
+ gap: false,
3437
+ boxFrame: null,
3438
+ callFrame: this._frameCount,
3439
+ };
2971
3440
  return promise;
2972
3441
  }
2973
3442
 
@@ -2986,13 +3455,191 @@ class Inline3D {
2986
3455
  if (!win.rw) return;
2987
3456
  win.rw.at = nowMs();
2988
3457
  win.rw.stereo = false;
3458
+ win.rw.gap = false;
3459
+ win.rw.boxFrame = this._frameCount;
2989
3460
  }
2990
3461
 
2991
- _settleRewoven(win, woven, reason) {
3462
+ _settleRewoven(win, woven, reason, confirmed = false) {
2992
3463
  const rw = win.rw;
2993
3464
  if (!rw) return;
2994
3465
  win.rw = null;
2995
- rw.resolve(Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - rw.calledAt) }));
3466
+ rw.resolve(Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - rw.calledAt) }));
3467
+ }
3468
+
3469
+ // ── rect cover (opts.rectCover) ──────────────────────────────────────────────────────
3470
+ //
3471
+ // A canvas that is already woven and then moves or resizes goes back through the identity gap
3472
+ // (rule 11): until the browser rejoins it at the new rect, the screen shows the page's own
3473
+ // raster of it — black, or the squeezed pair. A kiosk demo that moves ONE woven canvas between
3474
+ // screens measured 0.4-1.2 s of that, and every page that hit it hand-rolled the same cover.
3475
+ // This is that cover, owned by the SDK: a sibling element over the canvas (never a second woven
3476
+ // canvas: two 2:1 candidates in one region are refused as ambiguous, rule 6), raised on the
3477
+ // frame the change is seen and cut when rewoven() settles.
3478
+
3479
+ /** Per frame, before the draw: has the box (size, dpr, page position) changed since the last look? */
3480
+ _rectCoverCheck(win) {
3481
+ const rc = win.rc;
3482
+ const key = boxKeyOf(win.canvas);
3483
+ if (key === rc.box) return;
3484
+ const first = rc.box === null;
3485
+ const prevW = rc.w;
3486
+ const prevH = rc.h;
3487
+ rc.box = key;
3488
+ rc.w = win.canvas.clientWidth || 0;
3489
+ rc.h = win.canvas.clientHeight || 0;
3490
+ if (first) return; // the first look is the baseline, not a change
3491
+ if (rc.up) this._placeCover(win); // already up: follow the canvas
3492
+ // Before the first join the page's own poster covers the canvas (rule 5), and a window that
3493
+ // will not weave has nothing to wait for. A confirmed withheld first result still covers.
3494
+ const fw = win.fwResult;
3495
+ if (!fw || win.rwGone || !(fw.woven || fw.confirmed)) return;
3496
+ if (!rc.up) {
3497
+ if (!rc.snapped) rc.aspect = prevH > 0 ? prevW / prevH : 0; // what the eye looked like on screen
3498
+ this._raiseCover(win);
3499
+ }
3500
+ if (rc.cfg.snapshot && !rc.snapped && rc.w > 0 && rc.h > 0) rc.snapWanted = true;
3501
+ let p;
3502
+ if (win.rw) {
3503
+ this._noteRewovenBox(win); // a second change while pending restarts it
3504
+ p = win.rw.promise;
3505
+ } else {
3506
+ p = this._rewoven(win);
3507
+ if (win.rw) win.rw.boxFrame = this._frameCount; // the change was seen THIS frame
3508
+ }
3509
+ if (rc.waiting !== p) {
3510
+ rc.waiting = p;
3511
+ p.then(() => {
3512
+ if (rc.waiting !== p) return;
3513
+ rc.waiting = null;
3514
+ this._lowerCover(win);
3515
+ });
3516
+ }
3517
+ }
3518
+
3519
+ _raiseCover(win) {
3520
+ const rc = win.rc;
3521
+ if (!rc.el) {
3522
+ if (typeof document === 'undefined' || !document || typeof document.createElement !== 'function') return;
3523
+ if (!win.canvas.parentElement) return;
3524
+ const el = document.createElement('canvas');
3525
+ el.setAttribute?.('aria-hidden', 'true');
3526
+ if (el.dataset) el.dataset.inline3dCover = '';
3527
+ const st = el.style;
3528
+ st.position = 'absolute';
3529
+ st.pointerEvents = 'none'; // the canvas under it keeps its input (orbit, clicks)
3530
+ st.margin = '0';
3531
+ st.border = '0';
3532
+ st.padding = '0';
3533
+ st.background = rc.cfg.color;
3534
+ st.display = 'none';
3535
+ // Stack with the canvas, not under it: a canvas the page raised gets the same z-index.
3536
+ try {
3537
+ const z = typeof getComputedStyle === 'function' ? getComputedStyle(win.canvas).zIndex : 'auto';
3538
+ if (z && z !== 'auto') st.zIndex = z;
3539
+ } catch {
3540
+ /* no computed style: DOM order alone puts it on top */
3541
+ }
3542
+ rc.el = el;
3543
+ }
3544
+ this._placeCover(win);
3545
+ rc.el.style.display = 'block';
3546
+ rc.up = true;
3547
+ // Declared through the existing exclusion path. On a draw-order browser that is a no-op and
3548
+ // the cover is crisp 2D anyway (it sits over the tile in draw order). On an older one the
3549
+ // full-tile guard refuses it, on purpose: a congruent plate would stage the CANVAS as the
3550
+ // overlay. Pre-marked as warned so the SDK's own cover does not trip the page-facing warning.
3551
+ this._fullTileWarned.add(rc.el);
3552
+ this._applyExclusion(win, rc.el);
3553
+ }
3554
+
3555
+ /** Put the cover exactly over the canvas, as its next sibling (the canvas may have been re-parented). */
3556
+ _placeCover(win) {
3557
+ const rc = win.rc;
3558
+ const el = rc.el;
3559
+ const c = win.canvas;
3560
+ if (!el) return;
3561
+ const parent = c.parentElement;
3562
+ if (parent && (el.parentElement !== parent || el.previousSibling !== c)) {
3563
+ parent.insertBefore(el, c.nextSibling);
3564
+ }
3565
+ const st = el.style;
3566
+ if (c.offsetParent) {
3567
+ // Same containing block as the canvas (its offsetParent), so offsets are directly usable.
3568
+ st.position = 'absolute';
3569
+ st.left = `${c.offsetLeft}px`;
3570
+ st.top = `${c.offsetTop}px`;
3571
+ st.width = `${c.offsetWidth}px`;
3572
+ st.height = `${c.offsetHeight}px`;
3573
+ } else {
3574
+ // A fixed canvas (no offsetParent): cover its viewport rect.
3575
+ const r = c.getBoundingClientRect();
3576
+ st.position = 'fixed';
3577
+ st.left = `${r.left ?? r.x ?? 0}px`;
3578
+ st.top = `${r.top ?? r.y ?? 0}px`;
3579
+ st.width = `${r.width}px`;
3580
+ st.height = `${r.height}px`;
3581
+ }
3582
+ }
3583
+
3584
+ /**
3585
+ * The last frame, once per cover cycle: the LEFT eye of the side-by-side buffer, cover-fit into a
3586
+ * 2D canvas the size of the new CSS box (CSS px, not device px: it is on screen for about a second
3587
+ * and must not cost a full-resolution copy). The eye's on-screen aspect was the OLD box's (each
3588
+ * eye is stretched to the box by the weave), so the crop is computed from that aspect, not from
3589
+ * the buffer's. Never toDataURL: one drawImage, at this moment only. Any failure (a lost
3590
+ * context, a tainted source) leaves the solid color.
3591
+ */
3592
+ _rectCoverSnap(win) {
3593
+ const rc = win.rc;
3594
+ rc.snapWanted = false;
3595
+ const el = rc.el;
3596
+ const src = win.canvas;
3597
+ if (!el || !rc.up || !(src.width > 1) || !(src.height > 0)) return;
3598
+ const bw = Math.max(1, Math.round(rc.w));
3599
+ const bh = Math.max(1, Math.round(rc.h));
3600
+ const target = bw / bh;
3601
+ // A canvas that was hidden (0x0) before the change has no old box to read: the eye half of the
3602
+ // buffer, still at its old size at this point, has the same aspect.
3603
+ const aspect = rc.aspect > 0 ? rc.aspect : src.width / 2 / src.height;
3604
+ let fw = 1;
3605
+ let fh = 1;
3606
+ if (target > aspect) fh = aspect / target; // wider box: keep the width, crop height
3607
+ else fw = target / aspect; // taller box: keep the height, crop width
3608
+ const sw = (src.width / 2) * fw;
3609
+ const sh = src.height * fh;
3610
+ const sx = (src.width / 2 - sw) / 2;
3611
+ const sy = (src.height - sh) / 2;
3612
+ try {
3613
+ el.width = bw;
3614
+ el.height = bh;
3615
+ const ctx = el.getContext('2d');
3616
+ if (!ctx) return;
3617
+ ctx.drawImage(src, sx, sy, sw, sh, 0, 0, bw, bh);
3618
+ rc.snapped = true;
3619
+ } catch {
3620
+ /* the solid color stays */
3621
+ }
3622
+ }
3623
+
3624
+ _lowerCover(win) {
3625
+ const rc = win.rc;
3626
+ if (!rc) return;
3627
+ rc.up = false;
3628
+ rc.snapped = false; // the next cycle takes its own snapshot
3629
+ rc.snapWanted = false;
3630
+ if (!rc.el) return;
3631
+ rc.el.style.display = 'none';
3632
+ this._dropExclusion(win, rc.el);
3633
+ }
3634
+
3635
+ /** remove() / session end: the cover leaves the DOM with its window. */
3636
+ _dropCover(win) {
3637
+ const rc = win.rc;
3638
+ if (!rc) return;
3639
+ rc.waiting = null;
3640
+ this._lowerCover(win);
3641
+ if (rc.el && rc.el.parentElement) rc.el.parentElement.removeChild(rc.el);
3642
+ rc.el = null;
2996
3643
  }
2997
3644
 
2998
3645
  // ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
@@ -3127,8 +3774,11 @@ class Inline3D {
3127
3774
  this._paintMono(win);
3128
3775
  this._notifyLayerLost(win);
3129
3776
  this._settleFirstWoven(win, false, 'session-ended');
3777
+ this._dropCover(win);
3778
+ this._noteLayerFramed(win, false);
3130
3779
  }
3131
3780
  this._windows.clear();
3781
+ this._claims.clear();
3132
3782
  // Nobody is tracked through a session that has ended: say so ONCE, before the listeners are
3133
3783
  // dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A
3134
3784
  // browser that never reported one is already 'unknown' and this is silent.