@displayxr/inline3d 1.3.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,145 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.5.0 — 2026-09-06
9
+
10
+ ### Added
11
+
12
+ - **The 2D↔3D switch is EASED, and every page gets it for free.** 1.4.0 made the panel's mode a
13
+ page-facing control and collapsed the stereo rig the moment a 1-view mode went active — correct,
14
+ and a snap. The transition now ramps: a **page-initiated** switch walks every window's
15
+ `ipdFactor`/`parallaxFactor` between 0 and what the page asked for over **180 ms**, **smoothstep**
16
+ (Hermite `3t^2 - 2t^3`) — the defaults the native DisplayXR apps configure, because this is a port
17
+ of the sequencer they already use (`dxr::ModeSwitch`, displayxr-common) rather than a second
18
+ design. New dependency-free module `js/inline3d-mode-switch.js` holds the state machine;
19
+ `test/mode-switch.test.mjs` mirrors the C++ smoke test case for case. *(core tier — additive)*
20
+
21
+ - **The ORDER is the feature, and it is asymmetric.** Going flat (a `viewCount === 1` target) ramps
22
+ the disparity **out first** and forwards the mode request only when it lands, so the panel flips
23
+ on already-flat content instead of snapping a stereo image flat. Coming back (a 2-view target)
24
+ forwards the request **first** and eases the disparity in **only once the panel REPORTS 3D** —
25
+ ramping up any earlier would put stereo on a still-flat panel, which is the blurry double image
26
+ the whole mode API exists to make unreachable. A hand-rolled tween gets exactly this wrong.
27
+
28
+ - **`createInline3D({ modeSwitch: { durationMs, easing, enabled } })`** — `durationMs` default
29
+ `180` (`0` keeps the ordering and lands in one frame), `easing` default `'smoothstep'` (also
30
+ `'linear'`, `'easeoutcubic'`; an unknown name warns once and falls back), `enabled: false`
31
+ restores 1.4.0's snap exactly. **`wall.modeSwitch`** is the read-only live state
32
+ `{active, factor}` for a page that wants to move its own 2D chrome alongside the panel. The SDK
33
+ adds **no UI** — which key or button toggles the display stays the page's call.
34
+ *(core tier — additive)*
35
+
36
+ - **What a page can feel, spelled out.** The restore is to the **configured** steady factors (each
37
+ window's own rig, never a hardcoded 1) and the ramp is a **copy** on the way to the layer, so the
38
+ 1.4.0 guarantees hold unchanged: a per-frame `setViewRig` loop cannot walk the page out of 2D,
39
+ and a lazy tile that rebuilds mid-transition comes back at the current factor. The ramp is driven
40
+ by **wall-clock dt** from the session's frame loop (never frame counts, so it lasts the same at
41
+ 30 fps and 144 fps), with a timer fallback so a held request still lands when frames stop.
42
+ Reversing mid-flight retargets from the disparity **in force** — the first press never snaps —
43
+ and a reversed going-flat switch **never fires**: nothing is asked of the display at all.
44
+
45
+ ### Changed
46
+
47
+ - **`requestRenderingMode(i)` / `setStereoEnabled(false)` for a 1-view target now resolve when the
48
+ request has been FORWARDED**, i.e. after the ramp (~`durationMs`), not on the call. They reject
49
+ as before if the browser refuses — and a refusal ramps the disparity back **up**, because a
50
+ refused switch must leave the page in 3D rather than flat. One new failure: a request dropped by
51
+ a reversal before it ever fired rejects with an `Error` named **`superseded`**. Nothing changes
52
+ for a page that only awaits the promise it already awaited; `{ enabled: false }` restores the old
53
+ timing. *(core tier — behaviour change, opt-out)*
54
+
55
+ - A mode change the page did **not** request (another tab, the shell, a page that opens with the
56
+ panel already flat) still **snaps** — there is nothing to ramp from, and the reported state stays
57
+ the sole authority for the rig. `wall.stereoCollapsed` continues to mean what the display last
58
+ **reported**, never what is mid-ramp.
59
+
60
+ ## 1.4.0 — 2026-09-06
61
+
62
+ ### Added
63
+
64
+ - **Display modes — the page can read what the panel IS, and ask it to change.** Until now a page
65
+ could describe its own framing (a view rig) but knew nothing about the display it was framing
66
+ *for*: not its size in metres, not its pixel count, not which of the runtime's rendering modes it
67
+ was in, and it had no way to ask for a different one. Three pass-throughs close that:
68
+ **`getDisplayInfo()`** (physical size, pixel size, recommended view scale; `null` where there is
69
+ no glasses-free display), **`getRenderingModes()`** (every mode the runtime can put the display
70
+ in — view count, tile grid, per-view pixels, `hardwareDisplay3D`, `isActive`, `isRequestable`)
71
+ and **`requestRenderingMode(i)`**. They sit on the **`createInline3D()` result** — the panel is
72
+ the document's, not a tile's — and under the same names on every tile handle, routed to whichever
73
+ window currently holds a live layer. Gate the group with
74
+ **`inline3dDisplayModesSupported()`**, which requires all three methods on
75
+ `XRDisplayLayer.prototype` — a browser shipping half the set is one mid-implementation, and
76
+ calling it supported would surface as a `not a function` inside a click handler.
77
+ *(core tier — additive)*
78
+
79
+ - **The 2D/3D hardware state is a consequence of the mode, not a control.** There is deliberately
80
+ **no page-facing request** for it. Asking for a mode with `viewCount === 1` puts the panel in its
81
+ 2D state and the browser reports that mode active — the runtime carries on weaving the same fixed
82
+ two-view atlas — and asking for the 2-view mode puts it back. Tying the two together makes the
83
+ one bad state (a flat panel showing a stereo atlas, i.e. a blurry double image rather than 2D)
84
+ unreachable. **`requestDisplayMode('2d'|'3d')` is gone**, and so is every mention of a "lens".
85
+
86
+ - **The SDK collapses the stereo rig automatically.** When a 1-view mode goes **active** every
87
+ window's `ipdFactor`/`parallaxFactor` is pushed to 0 (both eyes render from one place), and a
88
+ 2-view mode going active restores them. This is driven by the `renderingmodechange` event — plus
89
+ the first `getRenderingModes()` read, so a page that OPENS with the panel already flat is
90
+ collapsed too — and **not** by the request. So it happens however the mode changed (this page,
91
+ another one, the shell), the page's own render loop is untouched, and **a refused request changes
92
+ nothing in either direction**, which is now structural rather than a rollback.
93
+
94
+ **The restore is exact, and that is a design property, not luck.** The flattening is a *copy*
95
+ pushed at the layer — a page driving a rig every frame reuses one descriptor object, so zeroing
96
+ it in place would write the flattening into the page's own state and the restore would restore
97
+ 0. So each window keeps what the page asked for, untouched, and the flat rig is derived on the
98
+ way out. That latch also means a per-frame `setViewRig` loop cannot walk the page out of 2D, a
99
+ lazy tile that scrolls away and rebuilds its layer comes back flat rather than in 3D, and a
100
+ window that never set a rig at all is flattened (and restored) via the exact rig equivalent of
101
+ its `virtualDisplayHeight`. *(core tier — additive)*
102
+
103
+ - **`setStereoEnabled(bool)` is now SUGAR, and only sugar** — `false` requests the first mode with
104
+ `viewCount === 1 && isRequestable`, `true` the first with `viewCount === 2 && isRequestable`. It
105
+ touches neither the hardware state (nothing can) nor the rig (the event does that). It rejects
106
+ rather than inventing a mode when the panel lists none. *(core tier — behaviour change on an
107
+ unreleased API)*
108
+
109
+ - **`on(type, cb)` / `off(type, cb)`** — the two events, which fire on the **XRSession** and not on
110
+ the layer, re-emitted on the wall and on every handle: `renderingmodechange`
111
+ `{type, modeIndex, viewCount, mode, detail}` and `hardwaredisplaystatechange`
112
+ `{type, state:'2d'|'3d', detail}`. `on` returns an unsubscribe function;
113
+ **`onDisplayModeChange(cb)`** remains as the both-events-one-callback shape.
114
+ `wall.hardwareDisplayState`, `wall.activeMode` and `wall.stereoCollapsed` expose the last
115
+ **reported** state — never the last requested one. *(core tier — additive)*
116
+
117
+ - **Undock — lift a window's asset into a floating native viewer over the desktop.**
118
+ **`wall.undock`** is `{model, splat}` on a browser with `XRDisplayLayer.undock` and **`null`** on
119
+ one without (plain Chrome, or an older DisplayXR browser) — that null is what a page branches on;
120
+ **`wall.refreshUndock()`** re-reads it, **`inline3dUndockSupported()`** is the sync probe. The
121
+ action is **`await undock(element, {src, type, env?, pose?, margin?, title?})`** (new module
122
+ `js/inline3d-undock.js`, re-exported from the main entry): API-first through `layer.undock()`,
123
+ falling back to the `displayxr-view:` OS protocol (hidden-iframe navigation, Chrome's one-time
124
+ "Open DisplayXR…?" prompt) where the layer method is absent. **Call it synchronously inside the
125
+ click** — both paths need the transient activation. Resolves to `{ended, viewer, detached}`;
126
+ rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
127
+ *(core tier — additive)*
128
+
129
+ - **[`samples/display-modes/`](samples/display-modes/)** — the whole surface on one page: the
130
+ `getDisplayInfo()` fields, the `getRenderingModes()` table with the active row marked, a request
131
+ button on every requestable row (`viewCount` 1 **or** 2) and the rows needing more than two views
132
+ greyed with the reason, one `setStereoEnabled` convenience button, a **read-only** hardware
133
+ display state badge fed by the event, and a live event log. Every action prints a greppable
134
+ `[display-modes] …` line so a harness can drive it from the console.
135
+
136
+ ### Notes
137
+
138
+ - **Advisory scales.** `viewScaleX/Y` and `recommendedViewScaleX/Y` are what the runtime would like
139
+ the per-view resolution to be. The browser cannot resize a page's canvas, so nothing applies them
140
+ for you — a page honours them by sizing its own backing store. Ignoring them costs sharpness or
141
+ fill rate, never correctness.
142
+ - **The browser is fixed at two views.** No view synthesis exists anywhere in this stack, so a mode
143
+ needing MORE than two is listed (the panel really can do it) and refused. Show those rows; mark
144
+ them. A **one**-view mode is requestable — the browser still submits two views and the runtime
145
+ still weaves them; it is the panel that goes flat.
146
+
8
147
  ## 1.3.0 — 2026-09-04
9
148
 
10
149
  ### Added
package/index.d.ts CHANGED
@@ -52,6 +52,114 @@ export interface XRViewRigInit {
52
52
  metersToVirtual?: number;
53
53
  }
54
54
 
55
+ /**
56
+ * The panel's hardware display state, as reported by `hardwaredisplaystatechange`.
57
+ *
58
+ * There is no page-facing request for it: it is a CONSEQUENCE of the active rendering mode —
59
+ * a `viewCount === 1` mode puts the panel flat, the 2-view mode puts it back.
60
+ */
61
+ export type XRHardwareDisplayMode = '2d' | '3d';
62
+
63
+ /**
64
+ * What the glasses-free display physically is. Resolved by {@link TileHandle.getDisplayInfo};
65
+ * `null` there means the machine has no such display.
66
+ *
67
+ * `recommendedViewScaleX/Y` are **advisory**. The browser cannot resize a page's canvas, so
68
+ * nothing applies them for you — a page honours them by sizing its own backing store. Ignoring
69
+ * them costs sharpness or fill rate, never correctness.
70
+ */
71
+ export interface XRDisplayInfo {
72
+ displayWidthMeters: number;
73
+ displayHeightMeters: number;
74
+ displayPixelWidth: number;
75
+ displayPixelHeight: number;
76
+ recommendedViewScaleX: number;
77
+ recommendedViewScaleY: number;
78
+ }
79
+
80
+ /**
81
+ * One rendering mode the DISPLAY can be put in, as reported by the runtime.
82
+ *
83
+ * The list is the display's, not the browser's. The DisplayXR Browser renders exactly **two**
84
+ * views — no view synthesis exists anywhere in the stack — so a mode needing MORE than two is
85
+ * reported with `isRequestable: false` and {@link TileHandle.requestRenderingMode} refuses it.
86
+ * Show such rows (they are what the panel can do) but mark them unavailable. A `viewCount === 1`
87
+ * mode IS requestable: the browser still submits two views and the runtime still weaves them —
88
+ * it is the PANEL that goes flat, which is how a page reaches the 2D hardware state.
89
+ */
90
+ export interface XRDisplayRenderingMode {
91
+ modeIndex: number;
92
+ name: string;
93
+ /** @deprecated the browser reports `name`; kept for pages written against the earlier build. */
94
+ modeName?: string;
95
+ viewCount: number;
96
+ /** Per-view render scale the runtime recommends for this mode — advisory, like the display's. */
97
+ viewScaleX: number;
98
+ viewScaleY: number;
99
+ tileColumns: number;
100
+ tileRows: number;
101
+ viewWidthPixels: number;
102
+ viewHeightPixels: number;
103
+ /** True for a glasses-free 3D mode; false for a flat one. */
104
+ hardwareDisplay3D: boolean;
105
+ isActive: boolean;
106
+ /** False when the browser cannot drive it — in practice, `viewCount > 2`. */
107
+ isRequestable: boolean;
108
+ }
109
+
110
+ /** A rendering mode went active. Re-emitted on the wall and on every tile handle. */
111
+ export interface RenderingModeChange {
112
+ type: 'renderingmodechange';
113
+ /** The mode now active, or -1 if the browser named none and the list could not be read. */
114
+ modeIndex: number;
115
+ /** Its view count — the thing the automatic rig collapse turns on. Null if unknown. */
116
+ viewCount: number | null;
117
+ /** The full mode row, when it could be read back. */
118
+ mode: XRDisplayRenderingMode | null;
119
+ /** The browser's own event payload, unreshaped. */
120
+ detail: unknown;
121
+ }
122
+
123
+ /** The panel's hardware display state changed. */
124
+ export interface HardwareDisplayStateChange {
125
+ type: 'hardwaredisplaystatechange';
126
+ state: XRHardwareDisplayMode | null;
127
+ detail: unknown;
128
+ }
129
+
130
+ export type DisplayModeChange = RenderingModeChange | HardwareDisplayStateChange;
131
+
132
+ /** What a page may lift into the floating native viewer. `null` = no `XRDisplayLayer.undock`. */
133
+ export interface UndockCapabilities {
134
+ model: boolean;
135
+ splat: boolean;
136
+ }
137
+
138
+ export interface UndockOptions {
139
+ /** Absolute https URL (or http on loopback) of the asset the NATIVE viewer loads. */
140
+ src: string;
141
+ type: 'model' | 'splat';
142
+ /** Lighting the page rendered with, so the viewer can match it. */
143
+ env?: 'room' | 'studio' | 'sky' | 'none';
144
+ /** The angle the page opened the asset at, so the undocked view opens at the same one. */
145
+ pose?: { yaw: number; pitch?: number; zoom?: number };
146
+ /** The page's fit margin, when it overrides the default. */
147
+ margin?: number;
148
+ title?: string;
149
+ }
150
+
151
+ export interface UndockHandle {
152
+ /**
153
+ * Resolves when the viewer exits. On the protocol FALLBACK it resolves immediately and
154
+ * `detached` is true — a protocol launch is fire-and-forget and the page never hears back.
155
+ */
156
+ ended: Promise<void>;
157
+ viewer: 'model' | 'splat';
158
+ detached: boolean;
159
+ /** The `displayxr-view:` URL, on the fallback path only. */
160
+ url?: string;
161
+ }
162
+
55
163
  /** Extra options for {@link Inline3D.addScene}. */
56
164
  export interface SceneOptions extends TileOptions {
57
165
  /**
@@ -115,8 +223,68 @@ export interface TileHandle {
115
223
  * identity-posed camera rig and parent your eye cameras under the app camera
116
224
  * (`cameraRigFromCamera(THREE, cam, { attach: true })` + `EyeCamera.setLocalFromView`), so the
117
225
  * scene graph supplies this frame's world pose with no lag at all.
226
+ *
227
+ * While a `viewCount === 1` mode is active the rig is stored **as given** and pushed **flat**
228
+ * (a copy with `ipdFactor`/`parallaxFactor` at 0), so a page driving a rig every frame cannot
229
+ * walk out of 2D, and the 2-view mode going active restores exactly this rig.
118
230
  */
119
231
  setViewRig(rig: XRViewRigInit): boolean;
232
+ /**
233
+ * The panel this window weaves on, or `null` where there is no glasses-free display.
234
+ *
235
+ * Rejects with an `Error` on a browser without the display-mode API
236
+ * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode,
237
+ * tile off screen).
238
+ */
239
+ getDisplayInfo(): Promise<XRDisplayInfo | null>;
240
+ /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
241
+ getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
242
+ /**
243
+ * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and
244
+ * rejects exactly as the browser does.
245
+ *
246
+ * Rejects with a `TypeError` for a mode whose `viewCount > 2` (the browser is fixed at two
247
+ * views) or an unknown index — the browser raises those synchronously, and this pass-through
248
+ * turns them into rejections so one `.catch()` covers every failure — and with a
249
+ * `NotSupportedError` `DOMException` when the request was not forwardable. Success is signalled
250
+ * by the session's `renderingmodechange` event, not by this promise.
251
+ *
252
+ * A `viewCount === 1` mode is requestable and is how a page goes flat.
253
+ *
254
+ * With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat
255
+ * request is HELD while the disparity ramps out, so the promise resolves when the request has
256
+ * been forwarded rather than on the call; a request dropped by a reversal in that window
257
+ * rejects with an `Error` named `superseded`.
258
+ */
259
+ requestRenderingMode(modeIndex: number): Promise<void>;
260
+ /**
261
+ * SUGAR over {@link TileHandle.requestRenderingMode}: `false` requests the first mode with
262
+ * `viewCount === 1 && isRequestable`, `true` the first with `viewCount === 2 && isRequestable`.
263
+ * It never touches the hardware display state directly (there is no such call) and never
264
+ * touches your rig.
265
+ *
266
+ * **The rig collapse is not part of this call.** When a 1-view mode actually goes ACTIVE the
267
+ * SDK zeroes every window's `ipdFactor`/`parallaxFactor` on the way to the layer and restores
268
+ * them when a 2-view mode does — driven by `renderingmodechange`, so it happens however the
269
+ * mode changed, and a **refused request changes nothing in either direction**. The flattening
270
+ * is a copy pushed at the layer, never a write into your descriptor, so the restore is literally
271
+ * the rig you last set — and it survives a per-frame `setViewRig` loop, a lazy tile rebuilding
272
+ * its layer, and a window that never set a rig at all.
273
+ *
274
+ * Rejects with an `Error` when no such mode is listed, otherwise as `requestRenderingMode` does.
275
+ */
276
+ setStereoEnabled(enabled: boolean): Promise<boolean>;
277
+ /** Listen for one display event, re-emitted on this handle. Returns an unsubscribe function. */
278
+ on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
279
+ on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
280
+ /** Drop a listener registered with {@link TileHandle.on}. */
281
+ off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
282
+ off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
283
+ /**
284
+ * Both display events through one callback — the older shape, still supported. Returns an
285
+ * unsubscribe function; inert (a no-op unsubscribe) on a browser without the API.
286
+ */
287
+ onDisplayModeChange(cb: (e: DisplayModeChange) => void): () => void;
120
288
  /**
121
289
  * Per-window frame counters, for diagnosing the load-induced mono fallback.
122
290
  *
@@ -141,6 +309,44 @@ export interface Inline3D {
141
309
  /** Number of currently-active (weaving) windows. */
142
310
  readonly liveCount: number;
143
311
 
312
+ // The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are
313
+ // on every tile handle, routed to whichever window currently holds a live layer.
314
+
315
+ /** The panel, or `null` where there is no glasses-free display. See {@link TileHandle.getDisplayInfo}. */
316
+ getDisplayInfo(): Promise<XRDisplayInfo | null>;
317
+ /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
318
+ getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
319
+ /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */
320
+ requestRenderingMode(modeIndex: number): Promise<void>;
321
+ /** Sugar: `false` -> a 1-view mode, `true` -> the 2-view mode. See {@link TileHandle.setStereoEnabled}. */
322
+ setStereoEnabled(enabled: boolean): Promise<boolean>;
323
+ on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
324
+ on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
325
+ off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
326
+ off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
327
+ /** As last REPORTED by `hardwaredisplaystatechange` — never what was last requested. */
328
+ readonly hardwareDisplayState: XRHardwareDisplayMode | null;
329
+ /** The active mode as last read/reported. `viewCount: 0` means "not read yet". */
330
+ readonly activeMode: { modeIndex: number; viewCount: number };
331
+ /** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
332
+ readonly stereoCollapsed: boolean;
333
+ /**
334
+ * The eased 2D<->3D transition, live. `factor` is what every window's
335
+ * `ipdFactor`/`parallaxFactor` is being multiplied by on the way to the layer (`1` in 3D, `0`
336
+ * flat, in between mid-ramp); `active` is true while a page-initiated switch is in any of its
337
+ * phases. Read-only and purely informational — the SDK adds no UI of its own for this.
338
+ */
339
+ readonly modeSwitch: { active: boolean; factor: number };
340
+
341
+ /**
342
+ * What this build can lift into the floating native viewer, or `null` on a browser with no
343
+ * `XRDisplayLayer.undock` — that null is what a page branches on. Read off the first live
344
+ * layer (`layer.getUndockCapabilities()`); `{model:false, splat:false}` is the pre-read value.
345
+ */
346
+ readonly undock: UndockCapabilities | null;
347
+ /** Re-read {@link Inline3D.undock} off a live layer. */
348
+ refreshUndock(): Promise<UndockCapabilities | null>;
349
+
144
350
  /** Weave a still side-by-side 3D photo from a URL or decoded image source. */
145
351
  addImage(
146
352
  canvas: HTMLCanvasElement,
@@ -217,6 +423,41 @@ export interface CreateInline3DOptions {
217
423
  * SDK never touches your DOM's `will-change`, because the chrome already occludes the tiles.
218
424
  */
219
425
  autoChrome?: boolean;
426
+ /** The eased 2D<->3D transition. On by default; see {@link ModeSwitchOptions}. */
427
+ modeSwitch?: ModeSwitchOptions;
428
+ }
429
+
430
+ /**
431
+ * The eased 2D<->3D transition — on by default, and the same sequencer (and the same defaults)
432
+ * the native DisplayXR apps use.
433
+ *
434
+ * Instead of snapping the stereo rig the moment the panel's mode changes, a **page-initiated**
435
+ * switch ramps every window's `ipdFactor`/`parallaxFactor` between 0 and what the page asked for,
436
+ * in the order that looks right:
437
+ *
438
+ * - **going flat** (a `viewCount === 1` target): the disparity ramps OUT first, and the mode
439
+ * request is forwarded only when it lands — so the panel flips on already-flat content. That is
440
+ * why `requestRenderingMode()` / `setStereoEnabled(false)` resolve a ramp later than they used
441
+ * to: they resolve when the request has actually been forwarded.
442
+ * - **coming back** (a 2-view target): the request goes out at once, and the disparity eases in
443
+ * only once the panel REPORTS 3D — disparity on a still-flat panel is the double image the
444
+ * whole mode API exists to prevent.
445
+ *
446
+ * Interruptible: pressing the toggle again mid-ramp retargets from the disparity in force, and
447
+ * reversing a going-flat switch that has not fired yet simply ramps back up without ever asking
448
+ * the panel for anything (the dropped request rejects with an `Error` named `superseded`).
449
+ *
450
+ * A mode change the page did **not** request (another tab, the shell, a panel that opens flat)
451
+ * always snaps — there is nothing to ramp from. This is aesthetic policy only; correctness is the
452
+ * runtime's either way.
453
+ */
454
+ export interface ModeSwitchOptions {
455
+ /** Ramp duration in ms (default `180`, matching the native default of 0.18 s). `0` = instant. */
456
+ durationMs?: number;
457
+ /** Easing curve (default `'smoothstep'`, Hermite `3t^2 - 2t^3`). */
458
+ easing?: 'smoothstep' | 'linear' | 'easeoutcubic';
459
+ /** `false` restores the plain snap of 1.4.0 (default `true`). */
460
+ enabled?: boolean;
220
461
  }
221
462
 
222
463
  /** The return of {@link startInline3D}. */
@@ -272,6 +513,48 @@ export function inline3dOcclusionByDrawOrder(): boolean;
272
513
  */
273
514
  export function inline3dViewRigSupported(): boolean;
274
515
 
516
+ /**
517
+ * True when this browser exposes the DISPLAY-MODE API — {@link TileHandle.getDisplayInfo},
518
+ * {@link TileHandle.getRenderingModes} and {@link TileHandle.requestRenderingMode}. Sync + cheap;
519
+ * implies {@link inline3DAvailable}.
520
+ *
521
+ * Reads a capability (all three methods present on `XRDisplayLayer.prototype`), never a version or
522
+ * UA string, and demands all three: a browser shipping half the set is one mid-implementation.
523
+ * Everything the API drives is optional enhancement, so branch on this only to decide whether to
524
+ * show display controls — the handle methods reject with a clear `Error` rather than throwing at
525
+ * import or create time.
526
+ */
527
+ export function inline3dDisplayModesSupported(): boolean;
528
+
529
+ /**
530
+ * True when this browser can undock through `XRDisplayLayer.undock()` — i.e. without the
531
+ * `displayxr-view:` protocol prompt the fallback needs. A page does not have to branch on it to
532
+ * undock (the helper falls back on its own); it is the probe for whether {@link Inline3D.undock}
533
+ * carries capabilities.
534
+ */
535
+ export function inline3dUndockSupported(): boolean;
536
+
537
+ /**
538
+ * Undock `target`'s asset into the floating native viewer over the desktop.
539
+ *
540
+ * **Call it synchronously inside the click.** Both paths need the transient user activation — the
541
+ * API path to be allowed at all, the fallback to get Chrome's protocol dialog — and an `await`
542
+ * before this call spends it.
543
+ *
544
+ * Rejects with an `Error` whose `name` is `'not-installed'`, `'src-not-allowed'`,
545
+ * `'no-activation'` or `'busy'`.
546
+ */
547
+ export function undock(target: Element, opts: UndockOptions): Promise<UndockHandle>;
548
+
549
+ /** True where a native DisplayXR viewer can exist at all (the viewers are Windows-only today). */
550
+ export function undockAvailable(): boolean;
551
+
552
+ /** The `displayxr-view:` URL the fallback path navigates to — exported for logging and tests. */
553
+ export function undockUrl(el: Element, opts: UndockOptions): string;
554
+
555
+ /** An element's rect in physical screen pixels — where the native viewer places its window. */
556
+ export function tileScreenRect(el: Element): { x: number; y: number; w: number; h: number; dpr: number };
557
+
275
558
  /** Open the page's inline-3D session and return a manager you add windows to. */
276
559
  export function createInline3D(
277
560
  opts?: CreateInline3DOptions,
@@ -316,5 +599,17 @@ export interface XRDisplayLayer {
316
599
  * all on the browser that has it.
317
600
  */
318
601
  setViewRig?(rig: XRViewRigInit): void;
602
+ /**
603
+ * The display-mode API. All three optional for the same reason as `setViewRig`: their presence
604
+ * on the prototype IS the capability signal ({@link inline3dDisplayModesSupported}), and the
605
+ * SDK demands all three before treating the browser as supporting any of them.
606
+ */
607
+ getDisplayInfo?(): Promise<XRDisplayInfo | null>;
608
+ getRenderingModes?(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
609
+ /** Throws `TypeError` **synchronously** for `viewCount > 2` or an unknown index. */
610
+ requestRenderingMode?(modeIndex: number): Promise<void>;
611
+ /** Undock this layer's asset into the floating native viewer. Optional; same probe rule. */
612
+ undock?(init: UndockOptions): Promise<unknown>;
613
+ getUndockCapabilities?(): Promise<UndockCapabilities>;
319
614
  close(): void;
320
615
  }