@realitycollective/webxr-uiextensions 0.1.0-preview.7 → 0.1.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
@@ -4,10 +4,20 @@ Change log for the Reality Collective WebXR UI Extensions packages. All four pac
4
4
 
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Preview builds are not listed separately. The entry for a version accumulates while its previews are published, and is dated when that version is released.
6
6
 
7
- ## [0.1.0]
7
+ ## [0.1.0] - 2026-09-17
8
8
 
9
9
  ### Added
10
10
 
11
+ - `WindowManager` is now the complete state API for a window, so a hand menu, shortcut or voice command can drive a targeted window without touching the engine. New on the record: `hidden`, `region` and `chrome`. New calls: `hide(id)`, `show(id)`, `toggleHidden(id)`; `dockTo(id, regionId)`, `undock(id)`, `returnHome(id)`; `setChrome(id, partial)`. New events: `hidden`, `shown`, `regionChanged`, `returnHome`, `chromeChanged`. `OpenWindowOptions` takes `hidden`, `region` and `chrome`. A hidden window is neither drawn nor hittable but keeps its dock mode, region slot and minimized state, and `show` puts it back in front; `returnHome` announces only, because the adapter owns the spawn snapshot. Both adapters apply every event: the IWSDK `UIWindowSystem` toggles visibility and strips or restores `RayInteractable`/`PokeInteractable`, adds or removes `UIDockedTo`, and mirrors the chrome flags onto `UIWindow`; the XR Blocks host toggles the group, docks through its registry and keeps a home snapshot. The reverse direction is kept in step too: a drag-drop dock, a rejected dock (unknown or full region) and a dock-mode change applied by the ECS are written back into the record, so `windows.get(id)` is always what the scene shows.
12
+ - `dockable` on `WindowOptionsBase`, `SceneWindow`, the IWSDK `UIWindow` component and both adapters' create options. The DOCK button previously had no flag at all and was always live.
13
+ - Near drag on IWSDK. `UIDragSystem` now lets the near `grab` pointer pick a window up by its title bar: a controller squeeze, or a hand pinch while the hand is on the bar. IWSDK only ever tests the grab pointer against `OneHandGrabbable`/`TwoHandsGrabbable` entities and only enables it when `features.grabbing` is on, so the system, which runs after `InputSystem`, appends every movable title bar to `scene.grabDescendants` each frame, enables the `grab` sub-pointer itself, and forwards a pinch to it only while the grab pointer is on one of its title bars (leaving pinches elsewhere to whatever the app decided, and deferring entirely when `GrabSystem` is registered with `useHandPinchForGrab`). A near grab drags at once, with no hold delay; a ray press still waits out `dragDelay` so title-bar clicks stay clicks. Off with `registerUIExtensions(world, { nearDrag: false })` or `configData: { nearDrag: false }` on the system. Putting `OneHandGrabbable` on the window was not an option: IWSDK would grab the whole panel and set `pointerEventsType = { deny: 'ray' }` on it, ending far interaction.
14
+ - `HoldToDrag.update` takes an optional per-press `delaySeconds` override, which is what lets a near grab skip the click window without a second store.
15
+ - A sixth contract case, `closing through the manager takes the window out of onPanelReady replay`, and a required `manager` on `WindowHostContractSetup`. Both shipped adapters pass it; the IWSDK setup now registers `UIWindowSystem` so the record opens and the entity is destroyed for real in the headless world.
16
+ - Headless tests for the IWSDK systems, `packages/iwsdk-uiextensions/test/window-system.test.ts`: `UIWindowSystem`, `UIDockSystem`, `UIDockRegionSystem` and `UIDragSystem` run against a real `World` with fake documents. They cover close in both directions, hide/show, chrome gating and runtime enablement, regions through the manager and back, return-home, and the grab-list registration. Every world in a test file now registers the same components in the same order, because elics keeps `typeId` on the component object and `addComponent` only registers a component whose `typeId` is still -1: a component first registered lazily (IWSDK's `Transform`) keeps the id it got in an earlier world, and a later world that hands that id to another component makes the two share a bit.
17
+ - Hand menus, as a fourth dock mode, `hand-locked`, in the manner of MRTK 2's hand menu. A hand-locked window rides a hand and shows while that palm is raised toward the viewer. The window's `handMenu` options (on `WindowOptionsBase`, `SceneWindow`, `OpenWindowOptions` and the record, with `setHandMenu` and a `handMenuChanged` event) choose the hand (`left`, `right`, or `either` for whichever palm is raised), the anchor (`above` the fingertips, `inside` on the thumb side, `outside`, or `wrist`), the distance and an extra hand-local offset, and the palm gate with its angle; defaults are left hand, above, gated at 60 degrees. The core's `hand-menu.ts` owns the placement and the gate in the WebXR grip frame as the Device API defines it (`-Z` toward the thumb, `+Y` up the arm, the palm at `-X` on the right hand and `+X` on the left; hand joint spaces differ and are not used), so every adapter reads the same offsets: `evaluateHandMenu`, `handMenuPose`, `pickHand`, `palmFacing`, `faceViewer`. A `HandPoseSource` joins the adapter contract, with an optional `hasHands()` so a page serving both a desktop and a headset can fall back to body-follow placement outside a session. On IWSDK, `UIDockSystem` reads the player rig's grip spaces (a controller's grip or the tracked hand) and writes the gate into `UIWindowState.gateOpen`; `UIWindowSystem` combines that with `hidden` in one presentation reconcile, so a shut gate strips the interaction tags the same way `hide()` does. `UIDockSystem` now registers at priority -1 so the gate it writes is applied the same frame. The `UIWindow` component carries `hand`, `handAnchor`, `handAnchorDistance`, `handOffset`, `palmGate` and `palmAngle`. On XR Blocks, `UixWindowHost` takes an optional `handPose`, `connectUIExtensions` takes `xr: renderer.xr`, and `webxrHandPoseSource` reads the session's input sources (grip space, or the target ray space for a hand without one). `HAND_MENU_SNIPPET` is the reference markup: the same root and content ids, no title bar, a vertical stack of buttons that sizes to its content. Pinning or dragging a hand menu lands it world-locked where it was.
18
+ - `TouchPress` in `@realitycollective/webxr-uiextensions` (`core/touch-press.ts`, with `TouchPressOptions`, `DEFAULT_TOUCH_PRESS`, `resolveTouchPress`, `TouchSample`, `TouchUpdate`): the press / hold / release state machine for a near (poke) pointer over a signed distance. A press starts only when the fingertip enters the press band from the front (or is first seen in front of the surface); the touch is then held whatever the finger does until it comes back out past the release distance or contact is lost, so a second press cannot start before a release; a fingertip arriving from behind never presses unless `allowFromBehind` is set. Defaults: press at 2 cm, release at 3 cm. The target under the finger is recorded at the press and reported again at the release, because they can differ when a finger enters one button and leaves through another.
19
+ - `UITouchGuardSystem` in `@realitycollective/iwsdk-uiextensions`, registered by `registerUIExtensions` at priority -3.9 (straight after `InputSystem`) unless `touchGuard: false`, with thresholds through `touchGuard: { pressDistance, releaseDistance, allowFromBehind }`. It takes over the two IWSDK touch pointers' `down` and `up` (IWSDK's own calls, including its forced release on lost hover, become no-ops) and issues them from `TouchPress` fed with the signed distance from the fingertip to the surface it is over, using the intersection's normal in world space (a target without one keeps the unsigned behaviour). `pointerdown`, `pointerup` and `click` still reach elements through the pointer as before, so IWSDK's own panels and every `PokeInteractable` are covered. IWSDK's 800 ms click window is unchanged.
20
+ - The showcase's `Window Control` is that hand menu, on the left hand, wired in the engine-free `playground-behaviour.ts` so it runs on the IWSDK, XR Blocks and desktop pipelines alike (following the body on the desktop, where there are no hands). The scene also shows the three ways to use opt-in chrome, named in the titles: `Click Machine (all buttons)`, `Gallery (no buttons)` and `Player Status (menu-driven)`. `Examples/basic-window/` in `@realitycollective/iwsdk-uiextensions` now spawns its window with no title-bar buttons and drives it from a hand menu (HIDE, PIN, HOME, MIN, BUTTONS, CLOSE), with labels read back from the record.
11
21
  - `verify:pack` now lints the shape of what ships: publint over every package directory, with warnings counted as errors, and attw (Are The Types Wrong) over every packed tarball, resolving the published types under node10, node16 and bundler resolution. `cjs-resolves-to-esm` is ignored by design, because every package is ESM-only and a require() caller is expected to use a dynamic import. Both run offline on the tarballs the script already builds; `publint` and `@arethetypeswrong/cli` are dev dependencies. The script stays identical across the Reality Collective repositories.
12
22
  - `verify:pack` now also type-checks the published declarations themselves, with library checking on, through `scripts/declaration-check.mjs`, runnable on its own as `node scripts/declaration-check.mjs`. Each package's declaration entry is compiled as a strict consumer would compile it, with `skipLibCheck: false`, under nodenext and then bundler resolution; a diagnostic inside the package fails the run, and diagnostics inside upstream declaration files are counted and ignored, because they are not ours to fix and would drown the signal. attw proves the published types resolve; this proves they compile, which is what a consumer with library checking on, or a package emitting declarations on top of ours, needs. The check is opt-in per repository, through `declarationCheck` in `scripts/release.config.json`, because only a foreign declaration can put a name into our emitted types that the build did not already check. This repository reaches around 950 of them, from the IWSDK and three.js typings the adapter is built against, and every run prints that count so the opt-in stays measured rather than habitual. It is the check that would have caught the bare `World` under Fixed.
13
23
  - `@realitycollective/webxr-uiextensions` - engine-free core: window manager, dock state and regions, drag maths, hold-to-drag, control models (stepper/toggle/expandable/log), the `SceneDescriptor` scene format, window chrome conventions and the platform-adapter contract.
@@ -25,6 +35,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
25
35
 
26
36
  ### Changed
27
37
 
38
+ - **Every title-bar button is off by default.** `closable`, `minimizable` and `pinnable` defaulted to `true` on the IWSDK `UIWindow` component, `createUIWindow`, the scene host and the XR Blocks host, so a window showed every button its markup carried whether the app wanted it or not; the new `dockable` joins them at `false`. Pass `true` for the buttons a window should have, or enable them later with `WindowManager.setChrome`. The reference markup keeps all four elements so they can be switched on at runtime; a disabled button is hidden and its click ignored. Migration: windows that relied on the old default need `closable: true, minimizable: true, pinnable: true` (and `dockable: true` for DOCK, which was previously always on). The shipped examples, the showcase scene and the devtools playground now say which buttons they want.
39
+ - Chrome buttons are wired once, at adoption, and gated on the record at click time, so enabling one later needs no rewiring. The IWSDK X button now closes through `manager.close(id)`, which destroys the entity, rather than destroying the entity directly, so there is one teardown path. On XR Blocks, `host.dock(windowId, regionId)` is now a forwarder to `manager.dockTo`/`manager.undock`; existing callers read the same.
28
40
  - **Controls are declared as custom elements rather than attributes.** `<uix-stepper>` replaces `<div data-uix="stepper">`, and each part is its own element: `<uix-value>`, `<uix-line>`, `<uix-label>` and so on, replacing `data-uix-role`. Parameters are unchanged and stay `data-uix-*`. This is what makes one markup file portable: IWSDK 0.5 parses with `@drawcall/uikitml`, which rejects every `data-*` attribute on a built-in tag but accepts custom tags declared in a component set, while the three.js and XR Blocks parser accepts custom tags with no registration at all. Both expose the declared tag on `userData.customElement.componentName`, so the engine-free upgraders read one contract on every adapter.
29
41
  - The expandable label's `toggle` part is now `<uix-more>`. As an element the old name would have collided with the `<uix-toggle>` control.
30
42
  - `@realitycollective/iwsdk-uiextensions` targets **IWSDK 0.5.x**: peer range `>=0.5.0 <0.6.0`, developed against `@iwsdk/core` 0.5.3. `PanelUI` in 0.5 declares a single field, `config`, holding the URL of the UIKitML **source**; the compiled JSON it took on 0.4.x, and the `@iwsdk/vite-plugin-uikitml` step that produced it, are both gone. `maxWidth` and `maxHeight` were removed from the component schema, and ECS ignores unknown fields silently, so a panel that still writes them is mis-sized with no error. Size through `createUIWindow`, which routes to `UIWindow.targetWidth/Height`.
@@ -40,10 +52,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
40
52
  - `@realitycollective/webxr-uiextensions` takes its geometry vocabulary from `@realitycollective/webxr-input` at `^0.1.1` rather than redeclaring it. `Vec3Tuple`, `QuatTuple`, `HeadPose`, `HeadPoseSource` and `PointerSample` are now that package's types, re-exported under the same names, so no import changes for a consumer. `PointerSample` is its `RayTuple`, which is what lets an input provider written against the shared contracts feed this contract unchanged. It is the core's only runtime dependency: the contracts package is engine-free and carries none of its own, and `test/architecture.test.ts` now allows exactly that one name and fails on any other.
41
53
  - `PointerSample` states its ownership rule: a delivered sample belongs to the listener and the source never writes to it again, so the core's hold-to-drag and drag maths may keep a press-time sample without copying. It mirrors the rule `@realitycollective/webxr-input` 0.1.3 writes on `InputSourceSnapshot`, so a provider feeding both contracts has one promise to keep.
42
54
 
43
- [0.1.0]: https://github.com/realitycollective/WebXR-UIExtensions/commits/main
55
+ [0.1.0]: https://github.com/realitycollective/WebXR-UIExtensions/releases/tag/v0.1.0
44
56
 
45
57
  ### Fixed
46
58
 
59
+ - `manager.close(id)` on IWSDK removed the record and emitted `closed`, but `UIWindowSystem` had no `closed` listener, so the entity and its panel stayed in the scene, orphaned, with chrome wired to a dead id. The XR Blocks host did dispose on `closed`, so the two adapters disagreed, and the devtools playground's "replace last" path hit it on every use. The window system now destroys the entity on `closed` (guarded against the entity-side path re-entering), and the scene host forgets the window so `onPanelReady` stops replaying it.
47
60
  - `@realitycollective/iwsdk-uiextensions` shipped five declaration files (`controls-system`, `dock-region-system`, `dock-system`, `drag-system` and `window-system` under `dist/systems/`) whose `createSystem` base type named `World` without importing it, so a consumer with `skipLibCheck: false`, or one emitting declarations on top of the package, failed with TS2304 on its first import. Reported by the Anatomy Atlas XR client on 9 September 2026 against preview.4 and preview.6. The cause is upstream: `@iwsdk/core` 0.5.3's `dist/ecs/system.d.ts` imports `World` from `'./world'` with no extension, the only such import in the package, and under the NodeNext resolution this repository builds with that import does not resolve (TS2835), so `World` was an unresolved name inside the host's own declaration and TypeScript's declaration emitter preserved it verbatim. `skipLibCheck` hid the error on both sides. The adapter now takes `createSystem` from its own `src/create-system.ts`, a wrapper whose return type names `World` through `@iwsdk/core`'s barrel, so every emitted base type reads `import("@iwsdk/core").World`. The wrapper goes the day IWSDK ships `./world.js` there.
48
61
  - `createSceneHost(world)` returns the same host on every call for a world, where it previously built a new one each time. A second host registered a second readiness ECS system on the same world and repeated the panel upgrade pass, so two modules each asking for the host quietly doubled that work. It now memoises per world exactly as the window manager registry does, which also means separate modules can ask for the host without coordinating or passing it around.
49
62
  - Documented in `@realitycollective/iwsdk-uiextensions` that `onPanelReady` announces windows spawned by `createUIWindow`, not only by `host.createWindow`, so code holding factory entities has a readiness signal without changing how it spawns them. The readiness section now also warns against polling `getPanelHandle` on a timer, because a poll that gives up early leaves a window that draws and responds to nothing with no error, and records that a factory window created without an `id` is announced as `kind: 'panel'` with its config path, which a listener filtering on `kind === 'window'` will never see.
package/README.md CHANGED
@@ -61,7 +61,13 @@ The IWSDK adapter is the reference implementation; the XR Blocks adapter shows t
61
61
  - `WindowHandle` - `id`, `panel` (`undefined` until the document is attached) and `onReady(listener)`, which runs once and fires immediately if the panel is already there. It is the per-window form of `onPanelReady`, for when you hold a handle and want only that window.
62
62
  - Getting the panel later, when you did not keep the handle - on IWSDK call `getPanelHandle(entity)` with the window's entity; on the three.js and XR Blocks host call `host.window(id)?.panel`. Both return the same `PanelHandle`.
63
63
 
64
- Options are shared even though `createWindow` is not: every adapter's option type extends `WindowOptionsBase` (`id`, `title`, `dockMode`, `position`, `maxWidth`/`maxHeight`, `movable`, `closable`, `minimizable`, `pinnable`, `followOffset`/`followSpeed`/`followTolerance`, `region`). An option means the same thing everywhere, so one `SceneWindow` maps onto every adapter with no translation table.
64
+ Options are shared even though `createWindow` is not: every adapter's option type extends `WindowOptionsBase` (`id`, `title`, `dockMode`, `position`, `maxWidth`/`maxHeight`, `movable`, `closable`, `minimizable`, `pinnable`, `dockable`, `followOffset`/`followSpeed`/`followTolerance`, `region`). An option means the same thing everywhere, so one `SceneWindow` maps onto every adapter with no translation table. The four chrome flags are all off unless set: a window shows only the title-bar buttons it asked for, and `WindowManager.setChrome` changes that later.
65
+
66
+ The `WindowManager` is the state API app code drives, and every adapter applies every one of its events. Beyond focus, minimize and dock mode it holds `hidden` (`hide`/`show`), `region` (`dockTo`/`undock`, plus `returnHome`), `chrome` (`setChrome`) and `handMenu` (`setHandMenu`), each with a typed event, and `close` is the one teardown call - an adapter must dispose on `closed`. A menu written against the manager therefore runs unchanged on every engine.
67
+
68
+ Near touch has a press / hold / release state machine, `TouchPress` in `core/touch-press.ts`: fed a signed distance (positive in front of the surface) and the target under the finger each frame, it presses only on entering from the front, holds until the finger comes back out past a release distance or contact is lost, and cannot press again before that release. It reports the target at the press and at the release separately, because a finger can enter one button and leave through another; what that means for a click is the adapter's rule. The IWSDK adapter drives IWSDK's touch pointers from it.
69
+
70
+ Hand menus are the fourth dock mode, `hand-locked`. The core owns all of it except the hand pose: `hand-menu.ts` turns the window's `handMenu` options (hand, anchor, palm gate) and this frame's hand and head poses into "visible, and where", in the WebXR grip frame (`-Z` toward the thumb, `+Y` up the arm, palm at `-X` on the right hand and `+X` on the left), which a controller's grip and a tracked hand's `gripSpace` share. An adapter supplies a `HandPoseSource` and applies the result; one that has no hands falls back to body-follow placement. `HAND_MENU_SNIPPET` is the reference markup: a title-bar-free vertical stack that sizes to its content.
65
71
 
66
72
  ### Proving a new adapter conforms
67
73
 
@@ -77,6 +83,7 @@ function makeSetup(): WindowHostContractSetup {
77
83
  const host = createMyHost();
78
84
  return {
79
85
  host,
86
+ manager: host.manager, // the WindowManager the host applies
80
87
  createWindow: (id) => host.createWindow({ id, config: myConfig() }),
81
88
  // Only where the panel arrives after the window does:
82
89
  attach: (id) => deliverThePanelFor(id),
@@ -90,7 +97,7 @@ for (const contractCase of windowHostContractCases()) {
90
97
  }
91
98
  ```
92
99
 
93
- `makeSetup()` runs once per case, because the cases spawn windows of their own and do not clean up after themselves. `attach` and `panelConfig` are both optional: leave `attach` out when a window's panel exists as soon as the window does, and `panelConfig` out when the host reports `supportsStandalonePanels: false`. Both shipped adapters run this suite, so a case failing on yours is a real difference in behaviour, not a difference in test style.
100
+ `makeSetup()` runs once per case, because the cases spawn windows of their own and do not clean up after themselves. `manager` is required: one case closes a window through it and checks the host stops replaying it. `attach` and `panelConfig` are both optional: leave `attach` out when a window's panel exists as soon as the window does, and `panelConfig` out when the host reports `supportsStandalonePanels: false`. Both shipped adapters run this suite, so a case failing on yours is a real difference in behaviour, not a difference in test style.
94
101
 
95
102
  ## Testing
96
103
 
package/dist/adapter.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * - {@link PanelHost}: turn compiled UIKitML JSON into a live spatial panel
10
10
  * - {@link PointerInputSource}: deliver ray/pointer press-move-release
11
11
  * - {@link HeadPoseSource}: the viewer pose, for follow mode & body-lock
12
+ * - {@link HandPoseSource}: the tracked hands, for hand menus (optional)
12
13
  *
13
14
  * On top of those, {@link WindowHost} adds the portable window surface:
14
15
  * {@link WindowHost.onPanelReady} for readiness, {@link PanelReadyEvent.kind}
@@ -32,8 +33,9 @@
32
33
  * stack drives both. The names below are re-exported, so importing them from
33
34
  * this package keeps working.
34
35
  */
35
- import type { RayTuple, Vec3Tuple } from '@realitycollective/webxr-input';
36
+ import type { PoseTuple, RayTuple, Vec3Tuple } from '@realitycollective/webxr-input';
36
37
  import type { DockModeValue } from './core/dock-state.js';
38
+ import type { Hand, HandMenuOptions } from './core/hand-menu.js';
37
39
  import type { UixElement } from './controls/element.js';
38
40
  /**
39
41
  * The shared geometry vocabulary, re-exported so this package stays the one
@@ -45,7 +47,28 @@ import type { UixElement } from './controls/element.js';
45
47
  * - `HeadPoseSource` - supplies that pose each frame, camera on desktop and
46
48
  * HMD in XR
47
49
  */
48
- export type { HeadPose, HeadPoseSource, QuatTuple, Vec3Tuple, } from '@realitycollective/webxr-input';
50
+ export type { HeadPose, HeadPoseSource, PoseTuple, QuatTuple, Vec3Tuple, } from '@realitycollective/webxr-input';
51
+ /**
52
+ * Supplies a hand's pose each frame as a WebXR GRIP space, the frame
53
+ * `hand-menu.ts` documents (`-Z` toward the thumb, `+Y` up the arm, the palm
54
+ * at `-X` on the right hand and `+X` on the left). A controller's grip and a
55
+ * tracked hand's `gripSpace` both are one; hand JOINT spaces are not, and
56
+ * must be converted. Returns `undefined` while that hand is not tracked;
57
+ * a hand menu on it is then hidden. An adapter without hands at all (a
58
+ * desktop) supplies no source and falls back to body-follow placement for
59
+ * hand-locked windows.
60
+ */
61
+ export interface HandPoseSource {
62
+ getHandPose(hand: Hand): PoseTuple | undefined;
63
+ /**
64
+ * Whether hands can be tracked at all right now - an XR session with hand
65
+ * or controller input. Off (or absent from the session) means "no hands
66
+ * here", and hand-locked windows fall back to body-follow placement rather
67
+ * than staying hidden, so one page can serve a desktop and a headset.
68
+ * Omit it when the source is always inside a session.
69
+ */
70
+ hasHands?(): boolean;
71
+ }
49
72
  /**
50
73
  * A live spatial panel created from compiled UIKitML JSON.
51
74
  * The `root` is traversable with the core's `walk`/`findRole` helpers and
@@ -162,11 +185,25 @@ export interface WindowOptionsBase {
162
185
  /** Fit the panel into this box in meters, preserving aspect ratio. */
163
186
  maxWidth?: number;
164
187
  maxHeight?: number;
165
- /** Whether the title bar drags the window. */
188
+ /** Whether the title bar drags the window. Default `true`. */
166
189
  movable?: boolean;
190
+ /**
191
+ * Title-bar buttons. Every button is OFF unless enabled here, or later
192
+ * through `WindowManager.setChrome`. The chrome markup may still contain
193
+ * the elements; a disabled button is hidden and its click ignored.
194
+ */
167
195
  closable?: boolean;
168
196
  minimizable?: boolean;
169
197
  pinnable?: boolean;
198
+ /** The DOCK button, which returns the window to where it spawned. */
199
+ dockable?: boolean;
200
+ /**
201
+ * Where a `hand-locked` window rides: which hand, which side of the palm,
202
+ * and whether the palm must face the viewer. Defaults: left hand, above
203
+ * the fingertips, gated. Ignored in the other dock modes but kept, so a
204
+ * later `setDockMode(id, 'hand-locked')` uses it.
205
+ */
206
+ handMenu?: Partial<HandMenuOptions>;
170
207
  /** Head-relative offset used in body-follow mode (meters). */
171
208
  followOffset?: Vec3Tuple;
172
209
  followSpeed?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Platform-adapter contract.\n *\n * The core package owns every UX decision - window lifecycle, dock state,\n * region slot math, drag math, control models - and knows nothing about any\n * engine. An engine adapter supplies the three capabilities the core cannot\n * provide for itself, and drives the core from its own frame loop:\n *\n * - {@link PanelHost}: turn compiled UIKitML JSON into a live spatial panel\n * - {@link PointerInputSource}: deliver ray/pointer press-move-release\n * - {@link HeadPoseSource}: the viewer pose, for follow mode & body-lock\n *\n * On top of those, {@link WindowHost} adds the portable window surface:\n * {@link WindowHost.onPanelReady} for readiness, {@link PanelReadyEvent.kind}\n * to tell a managed window from a bare panel, and\n * {@link WindowHost.supportsStandalonePanels} to say whether `createPanel` is\n * usable at all. Each adapter's own `createWindow` returns a\n * {@link WindowHandle} and takes options extending {@link WindowOptionsBase},\n * so window code reads the same on every engine even though the `config`\n * payload does not.\n *\n * Known adapters:\n * - `@realitycollective/iwsdk-uiextensions` - Meta IWSDK (ECS systems bind\n * these capabilities to `@iwsdk/core` components)\n * - `@realitycollective/xrblocks-uiextensions` - Google XR Blocks / plain\n * three.js (experimental)\n *\n * The interfaces use plain tuples/records only - no engine, no three.js. The\n * geometry vocabulary itself comes from `@realitycollective/webxr-input`, the\n * engine-free contracts package both extension families share, so a pose or a\n * ray means the same thing to Interactions and to UI Extensions and one input\n * stack drives both. The names below are re-exported, so importing them from\n * this package keeps working.\n */\nimport type { RayTuple, Vec3Tuple } from '@realitycollective/webxr-input';\nimport type { DockModeValue } from './core/dock-state.js';\nimport type { UixElement } from './controls/element.js';\n\n/**\n * The shared geometry vocabulary, re-exported so this package stays the one\n * import an adapter needs:\n *\n * - `Vec3Tuple` - position as [x, y, z] in meters, world space unless stated\n * - `QuatTuple` - orientation quaternion as [x, y, z, w]\n * - `HeadPose` - a viewer (head) pose sample\n * - `HeadPoseSource` - supplies that pose each frame, camera on desktop and\n * HMD in XR\n */\nexport type {\n HeadPose,\n HeadPoseSource,\n QuatTuple,\n Vec3Tuple,\n} from '@realitycollective/webxr-input';\n\n/**\n * A live spatial panel created from compiled UIKitML JSON.\n * The `root` is traversable with the core's `walk`/`findRole` helpers and\n * the `data-uix` control upgraders - identical markup works on every\n * adapter.\n */\nexport interface PanelHandle {\n /** Root element of the interpreted panel (UixElement-conformant). */\n readonly root: UixElement;\n /** Look up an element by its markup `id`. */\n getElementById(id: string): UixElement | undefined;\n /** Constrain the panel to fit within width × height meters. */\n setTargetDimensions(width: number, height: number): void;\n /** Release panel resources. */\n dispose(): void;\n}\n\n/** Creates spatial panels - the engine-specific half of UIKitML rendering. */\nexport interface PanelHost {\n /**\n * Create a panel from compiled UIKitML JSON (the `{ element, classes }`\n * shape produced by the build plugin or by\n * `@realitycollective/uix-devtools`' `compilePanelSource`).\n */\n createPanel(configJson: unknown): PanelHandle;\n}\n\n/**\n * A window whose panel has finished loading and is ready to be wired.\n * Delivered by {@link WindowHost.onPanelReady}.\n */\nexport interface PanelReadyEvent {\n /** The window's id, as given to the scene descriptor / create call. */\n id: string;\n /** The live panel - traverse it, or look elements up by markup id. */\n panel: PanelHandle;\n /**\n * What became ready.\n *\n * - `window` - created through the adapter's window factory and managed by\n * the window manager, so `id` is the id the caller asked for.\n * - `panel` - a bare panel the adapter noticed. `id` is then the adapter's\n * best stable identifier for it, which on IWSDK is the panel's config\n * path.\n *\n * Left optional so existing listeners keep compiling; adapters set it.\n */\n kind?: 'window' | 'panel';\n}\n\n/**\n * The engine-agnostic surface an app needs to build a UI: spawn windows and\n * regions from portable data, observe when panels become wireable, and reach\n * the shared `WindowManager`.\n *\n * Panels load asynchronously on every adapter (IWSDK fetches the config;\n * uikit lays out over following frames), so app code must never assume a\n * panel exists immediately after creating its window. {@link onPanelReady}\n * is the portable answer - it replaces engine-specific discovery (ECS\n * queries on IWSDK, polling anywhere else) and fires for panels that became\n * ready before the listener was registered, so wiring order never matters.\n */\nexport interface WindowHost extends PanelHost {\n /**\n * Whether {@link PanelHost.createPanel} works on this host. When `false`\n * the method is not available and throws; spawn a window instead, so the\n * engine owns the panel lifecycle. IWSDK is `false`, three.js/XR Blocks is\n * `true`.\n */\n readonly supportsStandalonePanels: boolean;\n /**\n * Subscribe to panel readiness. Late subscribers are replayed the windows\n * that are already live. Returns an unsubscribe function.\n */\n onPanelReady(listener: (event: PanelReadyEvent) => void): () => void;\n}\n\n/**\n * A window an adapter spawned, before its panel necessarily exists.\n *\n * `createWindow` itself stays adapter-specific because the `config` payload\n * differs per engine, but what it hands back is the same everywhere: an id, a\n * panel once there is one, and a one-shot readiness callback.\n */\nexport interface WindowHandle {\n /** The window's id - the one passed in, or one the adapter generated. */\n readonly id: string;\n /**\n * The live panel, or `undefined` until the adapter has attached the\n * document. IWSDK loads and parses the markup over later frames; the\n * three.js host interprets it during `createWindow`, so there it is set\n * straight away.\n */\n readonly panel: PanelHandle | undefined;\n /**\n * Run `listener` once, when the panel is attached. Fires immediately if it\n * already is, so wiring order never matters. Returns an unsubscribe\n * function for the case where the caller gives up first.\n */\n onReady(listener: (panel: PanelHandle) => void): () => void;\n}\n\n/**\n * The window options every adapter understands.\n *\n * An adapter's own `CreateWindowOptions` extends this and adds only what its\n * engine needs - chiefly `config`, whose type differs (IWSDK takes a source\n * path, the three.js host takes parsed markup). Keeping the rest here is what\n * lets one `SceneWindow` map onto every adapter without a translation table.\n */\nexport interface WindowOptionsBase {\n /** Stable window id. Adapters generate one when it is absent. */\n id?: string;\n /** Title text written into the window chrome's title element. */\n title?: string;\n dockMode?: DockModeValue;\n /** World position for world-locked windows. */\n position?: Vec3Tuple;\n /** Fit the panel into this box in meters, preserving aspect ratio. */\n maxWidth?: number;\n maxHeight?: number;\n /** Whether the title bar drags the window. */\n movable?: boolean;\n closable?: boolean;\n minimizable?: boolean;\n pinnable?: boolean;\n /** Head-relative offset used in body-follow mode (meters). */\n followOffset?: Vec3Tuple;\n followSpeed?: number;\n followTolerance?: number;\n /** Dock straight into this region on spawn. */\n region?: string;\n}\n\n/**\n * One pointer/ray interaction stream, engine-normalised: a world-space\n * `origin` (ray origin or touch point) and a normalised `direction`. It is\n * the Input package's `RayTuple`, which is what lets a provider written\n * against `@realitycollective/webxr-input` feed this contract unchanged.\n *\n * Once delivered, a sample belongs to the listener: the source never writes\n * to it again, so `hold-to-drag` and `drag-math` may keep a press-time\n * sample without copying it. This is the rule `@realitycollective/webxr-input`\n * states on `InputSourceSnapshot`, so a provider feeding both contracts has\n * one promise to keep.\n */\nexport type PointerSample = RayTuple;\n\n/**\n * Delivers press-move-release for one interaction source (a controller ray,\n * a hand pinch, a mouse). The core's `hold-to-drag` and `drag-math` consume\n * these; the adapter decides what constitutes press/release.\n */\nexport interface PointerInputSource {\n onPress(listener: (sample: PointerSample) => void): () => void;\n onMove(listener: (sample: PointerSample) => void): () => void;\n onRelease(listener: (sample: PointerSample) => void): () => void;\n}\n"]}
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Platform-adapter contract.\n *\n * The core package owns every UX decision - window lifecycle, dock state,\n * region slot math, drag math, control models - and knows nothing about any\n * engine. An engine adapter supplies the three capabilities the core cannot\n * provide for itself, and drives the core from its own frame loop:\n *\n * - {@link PanelHost}: turn compiled UIKitML JSON into a live spatial panel\n * - {@link PointerInputSource}: deliver ray/pointer press-move-release\n * - {@link HeadPoseSource}: the viewer pose, for follow mode & body-lock\n * - {@link HandPoseSource}: the tracked hands, for hand menus (optional)\n *\n * On top of those, {@link WindowHost} adds the portable window surface:\n * {@link WindowHost.onPanelReady} for readiness, {@link PanelReadyEvent.kind}\n * to tell a managed window from a bare panel, and\n * {@link WindowHost.supportsStandalonePanels} to say whether `createPanel` is\n * usable at all. Each adapter's own `createWindow` returns a\n * {@link WindowHandle} and takes options extending {@link WindowOptionsBase},\n * so window code reads the same on every engine even though the `config`\n * payload does not.\n *\n * Known adapters:\n * - `@realitycollective/iwsdk-uiextensions` - Meta IWSDK (ECS systems bind\n * these capabilities to `@iwsdk/core` components)\n * - `@realitycollective/xrblocks-uiextensions` - Google XR Blocks / plain\n * three.js (experimental)\n *\n * The interfaces use plain tuples/records only - no engine, no three.js. The\n * geometry vocabulary itself comes from `@realitycollective/webxr-input`, the\n * engine-free contracts package both extension families share, so a pose or a\n * ray means the same thing to Interactions and to UI Extensions and one input\n * stack drives both. The names below are re-exported, so importing them from\n * this package keeps working.\n */\nimport type { PoseTuple, RayTuple, Vec3Tuple } from '@realitycollective/webxr-input';\nimport type { DockModeValue } from './core/dock-state.js';\nimport type { Hand, HandMenuOptions } from './core/hand-menu.js';\nimport type { UixElement } from './controls/element.js';\n\n/**\n * The shared geometry vocabulary, re-exported so this package stays the one\n * import an adapter needs:\n *\n * - `Vec3Tuple` - position as [x, y, z] in meters, world space unless stated\n * - `QuatTuple` - orientation quaternion as [x, y, z, w]\n * - `HeadPose` - a viewer (head) pose sample\n * - `HeadPoseSource` - supplies that pose each frame, camera on desktop and\n * HMD in XR\n */\nexport type {\n HeadPose,\n HeadPoseSource,\n PoseTuple,\n QuatTuple,\n Vec3Tuple,\n} from '@realitycollective/webxr-input';\n\n/**\n * Supplies a hand's pose each frame as a WebXR GRIP space, the frame\n * `hand-menu.ts` documents (`-Z` toward the thumb, `+Y` up the arm, the palm\n * at `-X` on the right hand and `+X` on the left). A controller's grip and a\n * tracked hand's `gripSpace` both are one; hand JOINT spaces are not, and\n * must be converted. Returns `undefined` while that hand is not tracked;\n * a hand menu on it is then hidden. An adapter without hands at all (a\n * desktop) supplies no source and falls back to body-follow placement for\n * hand-locked windows.\n */\nexport interface HandPoseSource {\n getHandPose(hand: Hand): PoseTuple | undefined;\n /**\n * Whether hands can be tracked at all right now - an XR session with hand\n * or controller input. Off (or absent from the session) means \"no hands\n * here\", and hand-locked windows fall back to body-follow placement rather\n * than staying hidden, so one page can serve a desktop and a headset.\n * Omit it when the source is always inside a session.\n */\n hasHands?(): boolean;\n}\n\n/**\n * A live spatial panel created from compiled UIKitML JSON.\n * The `root` is traversable with the core's `walk`/`findRole` helpers and\n * the `data-uix` control upgraders - identical markup works on every\n * adapter.\n */\nexport interface PanelHandle {\n /** Root element of the interpreted panel (UixElement-conformant). */\n readonly root: UixElement;\n /** Look up an element by its markup `id`. */\n getElementById(id: string): UixElement | undefined;\n /** Constrain the panel to fit within width × height meters. */\n setTargetDimensions(width: number, height: number): void;\n /** Release panel resources. */\n dispose(): void;\n}\n\n/** Creates spatial panels - the engine-specific half of UIKitML rendering. */\nexport interface PanelHost {\n /**\n * Create a panel from compiled UIKitML JSON (the `{ element, classes }`\n * shape produced by the build plugin or by\n * `@realitycollective/uix-devtools`' `compilePanelSource`).\n */\n createPanel(configJson: unknown): PanelHandle;\n}\n\n/**\n * A window whose panel has finished loading and is ready to be wired.\n * Delivered by {@link WindowHost.onPanelReady}.\n */\nexport interface PanelReadyEvent {\n /** The window's id, as given to the scene descriptor / create call. */\n id: string;\n /** The live panel - traverse it, or look elements up by markup id. */\n panel: PanelHandle;\n /**\n * What became ready.\n *\n * - `window` - created through the adapter's window factory and managed by\n * the window manager, so `id` is the id the caller asked for.\n * - `panel` - a bare panel the adapter noticed. `id` is then the adapter's\n * best stable identifier for it, which on IWSDK is the panel's config\n * path.\n *\n * Left optional so existing listeners keep compiling; adapters set it.\n */\n kind?: 'window' | 'panel';\n}\n\n/**\n * The engine-agnostic surface an app needs to build a UI: spawn windows and\n * regions from portable data, observe when panels become wireable, and reach\n * the shared `WindowManager`.\n *\n * Panels load asynchronously on every adapter (IWSDK fetches the config;\n * uikit lays out over following frames), so app code must never assume a\n * panel exists immediately after creating its window. {@link onPanelReady}\n * is the portable answer - it replaces engine-specific discovery (ECS\n * queries on IWSDK, polling anywhere else) and fires for panels that became\n * ready before the listener was registered, so wiring order never matters.\n */\nexport interface WindowHost extends PanelHost {\n /**\n * Whether {@link PanelHost.createPanel} works on this host. When `false`\n * the method is not available and throws; spawn a window instead, so the\n * engine owns the panel lifecycle. IWSDK is `false`, three.js/XR Blocks is\n * `true`.\n */\n readonly supportsStandalonePanels: boolean;\n /**\n * Subscribe to panel readiness. Late subscribers are replayed the windows\n * that are already live. Returns an unsubscribe function.\n */\n onPanelReady(listener: (event: PanelReadyEvent) => void): () => void;\n}\n\n/**\n * A window an adapter spawned, before its panel necessarily exists.\n *\n * `createWindow` itself stays adapter-specific because the `config` payload\n * differs per engine, but what it hands back is the same everywhere: an id, a\n * panel once there is one, and a one-shot readiness callback.\n */\nexport interface WindowHandle {\n /** The window's id - the one passed in, or one the adapter generated. */\n readonly id: string;\n /**\n * The live panel, or `undefined` until the adapter has attached the\n * document. IWSDK loads and parses the markup over later frames; the\n * three.js host interprets it during `createWindow`, so there it is set\n * straight away.\n */\n readonly panel: PanelHandle | undefined;\n /**\n * Run `listener` once, when the panel is attached. Fires immediately if it\n * already is, so wiring order never matters. Returns an unsubscribe\n * function for the case where the caller gives up first.\n */\n onReady(listener: (panel: PanelHandle) => void): () => void;\n}\n\n/**\n * The window options every adapter understands.\n *\n * An adapter's own `CreateWindowOptions` extends this and adds only what its\n * engine needs - chiefly `config`, whose type differs (IWSDK takes a source\n * path, the three.js host takes parsed markup). Keeping the rest here is what\n * lets one `SceneWindow` map onto every adapter without a translation table.\n */\nexport interface WindowOptionsBase {\n /** Stable window id. Adapters generate one when it is absent. */\n id?: string;\n /** Title text written into the window chrome's title element. */\n title?: string;\n dockMode?: DockModeValue;\n /** World position for world-locked windows. */\n position?: Vec3Tuple;\n /** Fit the panel into this box in meters, preserving aspect ratio. */\n maxWidth?: number;\n maxHeight?: number;\n /** Whether the title bar drags the window. Default `true`. */\n movable?: boolean;\n /**\n * Title-bar buttons. Every button is OFF unless enabled here, or later\n * through `WindowManager.setChrome`. The chrome markup may still contain\n * the elements; a disabled button is hidden and its click ignored.\n */\n closable?: boolean;\n minimizable?: boolean;\n pinnable?: boolean;\n /** The DOCK button, which returns the window to where it spawned. */\n dockable?: boolean;\n /**\n * Where a `hand-locked` window rides: which hand, which side of the palm,\n * and whether the palm must face the viewer. Defaults: left hand, above\n * the fingertips, gated. Ignored in the other dock modes but kept, so a\n * later `setDockMode(id, 'hand-locked')` uses it.\n */\n handMenu?: Partial<HandMenuOptions>;\n /** Head-relative offset used in body-follow mode (meters). */\n followOffset?: Vec3Tuple;\n followSpeed?: number;\n followTolerance?: number;\n /** Dock straight into this region on spawn. */\n region?: string;\n}\n\n/**\n * One pointer/ray interaction stream, engine-normalised: a world-space\n * `origin` (ray origin or touch point) and a normalised `direction`. It is\n * the Input package's `RayTuple`, which is what lets a provider written\n * against `@realitycollective/webxr-input` feed this contract unchanged.\n *\n * Once delivered, a sample belongs to the listener: the source never writes\n * to it again, so `hold-to-drag` and `drag-math` may keep a press-time\n * sample without copying it. This is the rule `@realitycollective/webxr-input`\n * states on `InputSourceSnapshot`, so a provider feeding both contracts has\n * one promise to keep.\n */\nexport type PointerSample = RayTuple;\n\n/**\n * Delivers press-move-release for one interaction source (a controller ray,\n * a hand pinch, a mouse). The core's `hold-to-drag` and `drag-math` consume\n * these; the adapter decides what constitutes press/release.\n */\nexport interface PointerInputSource {\n onPress(listener: (sample: PointerSample) => void): () => void;\n onMove(listener: (sample: PointerSample) => void): () => void;\n onRelease(listener: (sample: PointerSample) => void): () => void;\n}\n"]}
@@ -5,6 +5,11 @@
5
5
  * plain markup discovered by well-known element ids, not a parallel widget
6
6
  * tree. Copy `WINDOW_CHROME_SNIPPET` into a `.uikitml` file and put the
7
7
  * window body inside the `uix-content` element.
8
+ *
9
+ * The four title-bar buttons are OFF by default: an adapter hides any button
10
+ * the window has not enabled (`closable`, `minimizable`, `pinnable`,
11
+ * `dockable` at spawn, or `WindowManager.setChrome` later) and ignores its
12
+ * clicks. Keep them in the markup so they can be switched on at runtime.
8
13
  */
9
14
  export declare const WINDOW_CHROME_IDS: {
10
15
  /** Root container of the window. */
@@ -37,4 +42,12 @@ export declare const WINDOW_CHROME_IDS: {
37
42
  * compact title-bar chrome. Plain containers stay fully styleable and
38
43
  * still receive click events.
39
44
  */
45
+ /**
46
+ * Reference hand-menu markup: the same root and content ids, no title bar,
47
+ * a vertical stack of buttons that sizes to its content. Spawn it with
48
+ * `dockMode: 'hand-locked'` and it rides the hand; without a title bar there
49
+ * is nothing to drag, which is what a hand menu wants. Give each button an
50
+ * id and wire it to a `WindowManager` call.
51
+ */
52
+ export declare const HAND_MENU_SNIPPET = "\n<div id=\"uix-window\" class=\"uix-hand-menu\">\n <div id=\"uix-content\" class=\"uix-hand-menu-stack\">\n <div id=\"menu-first\" class=\"uix-hand-menu-button\">FIRST</div>\n <div id=\"menu-second\" class=\"uix-hand-menu-button\">SECOND</div>\n </div>\n</div>\n";
40
53
  export declare const WINDOW_CHROME_SNIPPET = "\n<div id=\"uix-window\" class=\"uix-window\">\n <div id=\"uix-titlebar\" class=\"uix-titlebar\">\n <span id=\"uix-title\" class=\"uix-title\">.</span>\n <div class=\"uix-titlebar-buttons\">\n <div id=\"uix-pin\" class=\"uix-titlebar-button\">PIN</div>\n <div id=\"uix-dock\" class=\"uix-titlebar-button\">DOCK</div>\n <div id=\"uix-minimize\" class=\"uix-titlebar-button\">MIN</div>\n <div id=\"uix-close\" class=\"uix-titlebar-button\">X</div>\n </div>\n </div>\n <div id=\"uix-content\" class=\"uix-content\">\n <!-- window body goes here -->\n </div>\n</div>\n";
@@ -5,6 +5,11 @@
5
5
  * plain markup discovered by well-known element ids, not a parallel widget
6
6
  * tree. Copy `WINDOW_CHROME_SNIPPET` into a `.uikitml` file and put the
7
7
  * window body inside the `uix-content` element.
8
+ *
9
+ * The four title-bar buttons are OFF by default: an adapter hides any button
10
+ * the window has not enabled (`closable`, `minimizable`, `pinnable`,
11
+ * `dockable` at spawn, or `WindowManager.setChrome` later) and ignores its
12
+ * clicks. Keep them in the markup so they can be switched on at runtime.
8
13
  */
9
14
  export const WINDOW_CHROME_IDS = {
10
15
  /** Root container of the window. */
@@ -37,6 +42,21 @@ export const WINDOW_CHROME_IDS = {
37
42
  * compact title-bar chrome. Plain containers stay fully styleable and
38
43
  * still receive click events.
39
44
  */
45
+ /**
46
+ * Reference hand-menu markup: the same root and content ids, no title bar,
47
+ * a vertical stack of buttons that sizes to its content. Spawn it with
48
+ * `dockMode: 'hand-locked'` and it rides the hand; without a title bar there
49
+ * is nothing to drag, which is what a hand menu wants. Give each button an
50
+ * id and wire it to a `WindowManager` call.
51
+ */
52
+ export const HAND_MENU_SNIPPET = `
53
+ <div id="uix-window" class="uix-hand-menu">
54
+ <div id="uix-content" class="uix-hand-menu-stack">
55
+ <div id="menu-first" class="uix-hand-menu-button">FIRST</div>
56
+ <div id="menu-second" class="uix-hand-menu-button">SECOND</div>
57
+ </div>
58
+ </div>
59
+ `;
40
60
  export const WINDOW_CHROME_SNIPPET = `
41
61
  <div id="uix-window" class="uix-window">
42
62
  <div id="uix-titlebar" class="uix-titlebar">
@@ -1 +1 @@
1
- {"version":3,"file":"markup.js","sourceRoot":"","sources":["../../src/chrome/markup.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,oCAAoC;IACpC,MAAM,EAAE,YAAY;IACpB,sDAAsD;IACtD,QAAQ,EAAE,cAAc;IACxB,+CAA+C;IAC/C,KAAK,EAAE,WAAW;IAClB,yEAAyE;IACzE,GAAG,EAAE,SAAS;IACd,2EAA2E;IAC3E,IAAI,EAAE,UAAU;IAChB,+BAA+B;IAC/B,QAAQ,EAAE,cAAc;IACxB,oCAAoC;IACpC,KAAK,EAAE,WAAW;IAClB,iCAAiC;IACjC,OAAO,EAAE,aAAa;CACd,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;CAepC,CAAC","sourcesContent":["/**\n * Window chrome conventions.\n *\n * Windows are authored in UIKitML like any other IWSDK panel - the chrome is\n * plain markup discovered by well-known element ids, not a parallel widget\n * tree. Copy `WINDOW_CHROME_SNIPPET` into a `.uikitml` file and put the\n * window body inside the `uix-content` element.\n */\nexport const WINDOW_CHROME_IDS = {\n /** Root container of the window. */\n window: 'uix-window',\n /** Drag surface; also focuses the window on press. */\n titlebar: 'uix-titlebar',\n /** Text element receiving `UIWindow.title`. */\n title: 'uix-title',\n /** Toggles body-follow ⇄ world-locked; label auto-syncs to PIN/UNPIN. */\n pin: 'uix-pin',\n /** Returns the window to its home (spawn region or original placement). */\n dock: 'uix-dock',\n /** Minimize/restore toggle. */\n minimize: 'uix-minimize',\n /** Closes (destroys) the window. */\n close: 'uix-close',\n /** Collapsed while minimized. */\n content: 'uix-content',\n} as const;\n\n/**\n * Reference chrome markup. The styles are intentionally minimal - restyle\n * freely, only the element ids are contractual.\n *\n * Notes:\n * - The placeholder \".\" in `uix-title`: UIKitML only creates a Text node for\n * elements with a literal string child, so dynamic text needs one.\n * - The chrome buttons are `<div>`s on purpose: when an app registers a\n * component kit (e.g. `@pmndrs/uikit-horizon`), lowercase `<button>` tags\n * resolve to the kit's Button component, whose intrinsic sizing fights\n * compact title-bar chrome. Plain containers stay fully styleable and\n * still receive click events.\n */\nexport const WINDOW_CHROME_SNIPPET = `\n<div id=\"uix-window\" class=\"uix-window\">\n <div id=\"uix-titlebar\" class=\"uix-titlebar\">\n <span id=\"uix-title\" class=\"uix-title\">.</span>\n <div class=\"uix-titlebar-buttons\">\n <div id=\"uix-pin\" class=\"uix-titlebar-button\">PIN</div>\n <div id=\"uix-dock\" class=\"uix-titlebar-button\">DOCK</div>\n <div id=\"uix-minimize\" class=\"uix-titlebar-button\">MIN</div>\n <div id=\"uix-close\" class=\"uix-titlebar-button\">X</div>\n </div>\n </div>\n <div id=\"uix-content\" class=\"uix-content\">\n <!-- window body goes here -->\n </div>\n</div>\n`;\n"]}
1
+ {"version":3,"file":"markup.js","sourceRoot":"","sources":["../../src/chrome/markup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,oCAAoC;IACpC,MAAM,EAAE,YAAY;IACpB,sDAAsD;IACtD,QAAQ,EAAE,cAAc;IACxB,+CAA+C;IAC/C,KAAK,EAAE,WAAW;IAClB,yEAAyE;IACzE,GAAG,EAAE,SAAS;IACd,2EAA2E;IAC3E,IAAI,EAAE,UAAU;IAChB,+BAA+B;IAC/B,QAAQ,EAAE,cAAc;IACxB,oCAAoC;IACpC,KAAK,EAAE,WAAW;IAClB,iCAAiC;IACjC,OAAO,EAAE,aAAa;CACd,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;CAOhC,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;CAepC,CAAC","sourcesContent":["/**\n * Window chrome conventions.\n *\n * Windows are authored in UIKitML like any other IWSDK panel - the chrome is\n * plain markup discovered by well-known element ids, not a parallel widget\n * tree. Copy `WINDOW_CHROME_SNIPPET` into a `.uikitml` file and put the\n * window body inside the `uix-content` element.\n *\n * The four title-bar buttons are OFF by default: an adapter hides any button\n * the window has not enabled (`closable`, `minimizable`, `pinnable`,\n * `dockable` at spawn, or `WindowManager.setChrome` later) and ignores its\n * clicks. Keep them in the markup so they can be switched on at runtime.\n */\nexport const WINDOW_CHROME_IDS = {\n /** Root container of the window. */\n window: 'uix-window',\n /** Drag surface; also focuses the window on press. */\n titlebar: 'uix-titlebar',\n /** Text element receiving `UIWindow.title`. */\n title: 'uix-title',\n /** Toggles body-follow ⇄ world-locked; label auto-syncs to PIN/UNPIN. */\n pin: 'uix-pin',\n /** Returns the window to its home (spawn region or original placement). */\n dock: 'uix-dock',\n /** Minimize/restore toggle. */\n minimize: 'uix-minimize',\n /** Closes (destroys) the window. */\n close: 'uix-close',\n /** Collapsed while minimized. */\n content: 'uix-content',\n} as const;\n\n/**\n * Reference chrome markup. The styles are intentionally minimal - restyle\n * freely, only the element ids are contractual.\n *\n * Notes:\n * - The placeholder \".\" in `uix-title`: UIKitML only creates a Text node for\n * elements with a literal string child, so dynamic text needs one.\n * - The chrome buttons are `<div>`s on purpose: when an app registers a\n * component kit (e.g. `@pmndrs/uikit-horizon`), lowercase `<button>` tags\n * resolve to the kit's Button component, whose intrinsic sizing fights\n * compact title-bar chrome. Plain containers stay fully styleable and\n * still receive click events.\n */\n/**\n * Reference hand-menu markup: the same root and content ids, no title bar,\n * a vertical stack of buttons that sizes to its content. Spawn it with\n * `dockMode: 'hand-locked'` and it rides the hand; without a title bar there\n * is nothing to drag, which is what a hand menu wants. Give each button an\n * id and wire it to a `WindowManager` call.\n */\nexport const HAND_MENU_SNIPPET = `\n<div id=\"uix-window\" class=\"uix-hand-menu\">\n <div id=\"uix-content\" class=\"uix-hand-menu-stack\">\n <div id=\"menu-first\" class=\"uix-hand-menu-button\">FIRST</div>\n <div id=\"menu-second\" class=\"uix-hand-menu-button\">SECOND</div>\n </div>\n</div>\n`;\n\nexport const WINDOW_CHROME_SNIPPET = `\n<div id=\"uix-window\" class=\"uix-window\">\n <div id=\"uix-titlebar\" class=\"uix-titlebar\">\n <span id=\"uix-title\" class=\"uix-title\">.</span>\n <div class=\"uix-titlebar-buttons\">\n <div id=\"uix-pin\" class=\"uix-titlebar-button\">PIN</div>\n <div id=\"uix-dock\" class=\"uix-titlebar-button\">DOCK</div>\n <div id=\"uix-minimize\" class=\"uix-titlebar-button\">MIN</div>\n <div id=\"uix-close\" class=\"uix-titlebar-button\">X</div>\n </div>\n </div>\n <div id=\"uix-content\" class=\"uix-content\">\n <!-- window body goes here -->\n </div>\n</div>\n`;\n"]}
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The shared `WindowHost` contract, shipped as data rather than as tests.
3
3
  *
4
- * Every adapter promises the same four things, whatever engine sits behind
4
+ * Every adapter promises the same five things, whatever engine sits behind
5
5
  * it: it says whether bare panels work and behaves accordingly,
6
6
  * `createWindow` hands back a {@link WindowHandle}, `onReady` fires exactly
7
- * once and replays for a late subscriber, and `onPanelReady` replays too.
7
+ * once and replays for a late subscriber, `onPanelReady` replays too, and
8
+ * closing a window through the `WindowManager` tears it down so it is not
9
+ * replayed afterwards.
8
10
  * Running one suite from every adapter is what keeps those promises from
9
11
  * drifting apart, and gives a new adapter a starting test for free.
10
12
  *
@@ -18,6 +20,7 @@
18
20
  * {@link WindowHostContractSetup} that wraps those differences.
19
21
  */
20
22
  import type { WindowHandle, WindowHost } from './adapter.js';
23
+ import type { WindowManager } from './core/window-manager.js';
21
24
  /**
22
25
  * Everything a case needs to drive one adapter. Build a FRESH one per case:
23
26
  * cases spawn windows of their own and do not clean up after themselves.
@@ -25,6 +28,11 @@ import type { WindowHandle, WindowHost } from './adapter.js';
25
28
  export interface WindowHostContractSetup {
26
29
  /** The host under test. */
27
30
  host: WindowHost;
31
+ /**
32
+ * The manager the host applies. `close()` on it is the one teardown call
33
+ * app code has, so the suite proves the host honours it.
34
+ */
35
+ manager: WindowManager;
28
36
  /** Spawn one window with this id, using whatever config the adapter needs. */
29
37
  createWindow(id: string): WindowHandle;
30
38
  /**
@@ -74,6 +74,19 @@ const CASES = [
74
74
  detach(stop, 'onPanelReady');
75
75
  },
76
76
  },
77
+ {
78
+ name: 'closing through the manager takes the window out of onPanelReady replay',
79
+ run(setup) {
80
+ setup.createWindow('contract-e');
81
+ setup.attach?.('contract-e');
82
+ assert(setup.manager.has('contract-e'), 'the host must open the window on the manager it was given');
83
+ setup.manager.close('contract-e');
84
+ assert(!setup.manager.has('contract-e'), 'the manager must forget a closed window');
85
+ const ids = [];
86
+ detach(setup.host.onPanelReady((event) => ids.push(event.id)), 'onPanelReady');
87
+ assert(!ids.includes('contract-e'), `onPanelReady must not replay a window closed through the manager, got [${ids.join(', ')}]`);
88
+ },
89
+ },
77
90
  ];
78
91
  function assert(condition, message) {
79
92
  if (!condition)
@@ -1 +1 @@
1
- {"version":3,"file":"contract-cases.js","sourceRoot":"","sources":["../src/contract-cases.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared `WindowHost` contract, shipped as data rather than as tests.\n *\n * Every adapter promises the same four things, whatever engine sits behind\n * it: it says whether bare panels work and behaves accordingly,\n * `createWindow` hands back a {@link WindowHandle}, `onReady` fires exactly\n * once and replays for a late subscriber, and `onPanelReady` replays too.\n * Running one suite from every adapter is what keeps those promises from\n * drifting apart, and gives a new adapter a starting test for free.\n *\n * The suite is runner-free on purpose. Every adapter repository already has\n * its own test runner, and an adapter written outside this repository cannot\n * reach into this one's `test/` folder, so the checks ship as plain objects\n * that throw an `Error` on failure and the adapter iterates them.\n *\n * `createWindow` itself is adapter-specific - IWSDK takes a config PATH, the\n * three.js host takes parsed markup - so the caller supplies a\n * {@link WindowHostContractSetup} that wraps those differences.\n */\nimport type { PanelHandle, WindowHandle, WindowHost } from './adapter.js';\n\n/**\n * Everything a case needs to drive one adapter. Build a FRESH one per case:\n * cases spawn windows of their own and do not clean up after themselves.\n */\nexport interface WindowHostContractSetup {\n /** The host under test. */\n host: WindowHost;\n /** Spawn one window with this id, using whatever config the adapter needs. */\n createWindow(id: string): WindowHandle;\n /**\n * Attach the panel for a window, where the adapter attaches asynchronously.\n * Omit it when the panel exists as soon as the window does.\n */\n attach?: (id: string) => void;\n /** A config `createPanel` accepts, for hosts that support bare panels. */\n panelConfig?: unknown;\n}\n\n/**\n * One check a {@link WindowHost} implementation must pass. `run` returns\n * silently on success and throws an `Error` describing the failure\n * otherwise, so any test runner can host it.\n */\nexport interface WindowHostContractCase {\n name: string;\n run(setup: WindowHostContractSetup): void;\n}\n\n/**\n * The shared host conformance suite. An adapter's test file is a loop:\n *\n * ```ts\n * for (const contractCase of windowHostContractCases()) {\n * it(contractCase.name, () => contractCase.run(makeSetup()));\n * }\n * ```\n *\n * `makeSetup()` runs per case, so each case gets a host of its own.\n */\nexport function windowHostContractCases(): readonly WindowHostContractCase[] {\n return CASES;\n}\n\nconst CASES: readonly WindowHostContractCase[] = [\n {\n name: 'reports whether bare panels work, and createPanel agrees',\n run(setup) {\n const supported = setup.host.supportsStandalonePanels;\n assert(\n typeof supported === 'boolean',\n `supportsStandalonePanels must be a boolean, got ${typeof supported}`,\n );\n if (supported) {\n try {\n setup.host.createPanel(setup.panelConfig);\n } catch (error) {\n throw new Error(\n `supportsStandalonePanels is true, so createPanel() must work, it threw: ${String(error)}`,\n );\n }\n return;\n }\n assert(\n threw(() => setup.host.createPanel(setup.panelConfig)),\n 'supportsStandalonePanels is false, so createPanel() must throw rather than return an unusable panel',\n );\n },\n },\n {\n name: 'createWindow returns a handle with an id and onReady',\n run(setup) {\n const handle = setup.createWindow('contract-a');\n assert(\n typeof handle.id === 'string',\n `WindowHandle.id must be a string, got ${typeof handle.id}`,\n );\n assert(\n handle.id === 'contract-a',\n `createWindow(\"contract-a\") must keep the id it was given, got \"${handle.id}\"`,\n );\n assert(\n typeof handle.onReady === 'function',\n 'a WindowHandle must implement onReady()',\n );\n },\n },\n {\n name: 'onReady fires once when the panel attaches',\n run(setup) {\n const handle = setup.createWindow('contract-b');\n const seen: PanelHandle[] = [];\n handle.onReady((panel) => seen.push(panel));\n setup.attach?.('contract-b');\n assert(\n seen.length === 1,\n `onReady must fire exactly once when the panel attaches, it fired ${String(seen.length)} time(s)`,\n );\n assert(\n handle.panel === seen[0],\n 'the panel passed to onReady must be the same one the handle reports',\n );\n },\n },\n {\n name: 'onReady replays for a subscriber that arrives late',\n run(setup) {\n const handle = setup.createWindow('contract-c');\n setup.attach?.('contract-c');\n const seen: PanelHandle[] = [];\n const stop = handle.onReady((panel) => seen.push(panel));\n assert(\n seen.length === 1,\n `onReady must replay for a subscriber that arrives after the panel, it fired ${String(seen.length)} time(s)`,\n );\n // Unsubscribing after the fact is a no-op, not an error.\n detach(stop, 'onReady');\n assert(\n seen.length === 1,\n 'unsubscribing after onReady has replayed must not deliver the panel again',\n );\n },\n },\n {\n name: 'onPanelReady replays for a subscriber that arrives late',\n run(setup) {\n setup.createWindow('contract-d');\n setup.attach?.('contract-d');\n const ids: string[] = [];\n const stop = setup.host.onPanelReady((event) => ids.push(event.id));\n assert(\n ids.includes('contract-d'),\n `onPanelReady must replay the windows already live, expected \"contract-d\" among [${ids.join(', ')}]`,\n );\n detach(stop, 'onPanelReady');\n },\n },\n];\n\nfunction assert(condition: boolean, message: string): void {\n if (!condition) throw new Error(message);\n}\n\n/** Whether `run` threw, without caring what it threw. */\nfunction threw(run: () => unknown): boolean {\n try {\n run();\n return false;\n } catch {\n return true;\n }\n}\n\nfunction detach(stop: () => void, label: string): void {\n try {\n stop();\n } catch (error) {\n throw new Error(\n `the unsubscribe returned by ${label}() threw: ${String(error)}`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"contract-cases.js","sourceRoot":"","sources":["../src/contract-cases.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared `WindowHost` contract, shipped as data rather than as tests.\n *\n * Every adapter promises the same five things, whatever engine sits behind\n * it: it says whether bare panels work and behaves accordingly,\n * `createWindow` hands back a {@link WindowHandle}, `onReady` fires exactly\n * once and replays for a late subscriber, `onPanelReady` replays too, and\n * closing a window through the `WindowManager` tears it down so it is not\n * replayed afterwards.\n * Running one suite from every adapter is what keeps those promises from\n * drifting apart, and gives a new adapter a starting test for free.\n *\n * The suite is runner-free on purpose. Every adapter repository already has\n * its own test runner, and an adapter written outside this repository cannot\n * reach into this one's `test/` folder, so the checks ship as plain objects\n * that throw an `Error` on failure and the adapter iterates them.\n *\n * `createWindow` itself is adapter-specific - IWSDK takes a config PATH, the\n * three.js host takes parsed markup - so the caller supplies a\n * {@link WindowHostContractSetup} that wraps those differences.\n */\nimport type { PanelHandle, WindowHandle, WindowHost } from './adapter.js';\nimport type { WindowManager } from './core/window-manager.js';\n\n/**\n * Everything a case needs to drive one adapter. Build a FRESH one per case:\n * cases spawn windows of their own and do not clean up after themselves.\n */\nexport interface WindowHostContractSetup {\n /** The host under test. */\n host: WindowHost;\n /**\n * The manager the host applies. `close()` on it is the one teardown call\n * app code has, so the suite proves the host honours it.\n */\n manager: WindowManager;\n /** Spawn one window with this id, using whatever config the adapter needs. */\n createWindow(id: string): WindowHandle;\n /**\n * Attach the panel for a window, where the adapter attaches asynchronously.\n * Omit it when the panel exists as soon as the window does.\n */\n attach?: (id: string) => void;\n /** A config `createPanel` accepts, for hosts that support bare panels. */\n panelConfig?: unknown;\n}\n\n/**\n * One check a {@link WindowHost} implementation must pass. `run` returns\n * silently on success and throws an `Error` describing the failure\n * otherwise, so any test runner can host it.\n */\nexport interface WindowHostContractCase {\n name: string;\n run(setup: WindowHostContractSetup): void;\n}\n\n/**\n * The shared host conformance suite. An adapter's test file is a loop:\n *\n * ```ts\n * for (const contractCase of windowHostContractCases()) {\n * it(contractCase.name, () => contractCase.run(makeSetup()));\n * }\n * ```\n *\n * `makeSetup()` runs per case, so each case gets a host of its own.\n */\nexport function windowHostContractCases(): readonly WindowHostContractCase[] {\n return CASES;\n}\n\nconst CASES: readonly WindowHostContractCase[] = [\n {\n name: 'reports whether bare panels work, and createPanel agrees',\n run(setup) {\n const supported = setup.host.supportsStandalonePanels;\n assert(\n typeof supported === 'boolean',\n `supportsStandalonePanels must be a boolean, got ${typeof supported}`,\n );\n if (supported) {\n try {\n setup.host.createPanel(setup.panelConfig);\n } catch (error) {\n throw new Error(\n `supportsStandalonePanels is true, so createPanel() must work, it threw: ${String(error)}`,\n );\n }\n return;\n }\n assert(\n threw(() => setup.host.createPanel(setup.panelConfig)),\n 'supportsStandalonePanels is false, so createPanel() must throw rather than return an unusable panel',\n );\n },\n },\n {\n name: 'createWindow returns a handle with an id and onReady',\n run(setup) {\n const handle = setup.createWindow('contract-a');\n assert(\n typeof handle.id === 'string',\n `WindowHandle.id must be a string, got ${typeof handle.id}`,\n );\n assert(\n handle.id === 'contract-a',\n `createWindow(\"contract-a\") must keep the id it was given, got \"${handle.id}\"`,\n );\n assert(\n typeof handle.onReady === 'function',\n 'a WindowHandle must implement onReady()',\n );\n },\n },\n {\n name: 'onReady fires once when the panel attaches',\n run(setup) {\n const handle = setup.createWindow('contract-b');\n const seen: PanelHandle[] = [];\n handle.onReady((panel) => seen.push(panel));\n setup.attach?.('contract-b');\n assert(\n seen.length === 1,\n `onReady must fire exactly once when the panel attaches, it fired ${String(seen.length)} time(s)`,\n );\n assert(\n handle.panel === seen[0],\n 'the panel passed to onReady must be the same one the handle reports',\n );\n },\n },\n {\n name: 'onReady replays for a subscriber that arrives late',\n run(setup) {\n const handle = setup.createWindow('contract-c');\n setup.attach?.('contract-c');\n const seen: PanelHandle[] = [];\n const stop = handle.onReady((panel) => seen.push(panel));\n assert(\n seen.length === 1,\n `onReady must replay for a subscriber that arrives after the panel, it fired ${String(seen.length)} time(s)`,\n );\n // Unsubscribing after the fact is a no-op, not an error.\n detach(stop, 'onReady');\n assert(\n seen.length === 1,\n 'unsubscribing after onReady has replayed must not deliver the panel again',\n );\n },\n },\n {\n name: 'onPanelReady replays for a subscriber that arrives late',\n run(setup) {\n setup.createWindow('contract-d');\n setup.attach?.('contract-d');\n const ids: string[] = [];\n const stop = setup.host.onPanelReady((event) => ids.push(event.id));\n assert(\n ids.includes('contract-d'),\n `onPanelReady must replay the windows already live, expected \"contract-d\" among [${ids.join(', ')}]`,\n );\n detach(stop, 'onPanelReady');\n },\n },\n {\n name: 'closing through the manager takes the window out of onPanelReady replay',\n run(setup) {\n setup.createWindow('contract-e');\n setup.attach?.('contract-e');\n assert(\n setup.manager.has('contract-e'),\n 'the host must open the window on the manager it was given',\n );\n setup.manager.close('contract-e');\n assert(\n !setup.manager.has('contract-e'),\n 'the manager must forget a closed window',\n );\n const ids: string[] = [];\n detach(setup.host.onPanelReady((event) => ids.push(event.id)), 'onPanelReady');\n assert(\n !ids.includes('contract-e'),\n `onPanelReady must not replay a window closed through the manager, got [${ids.join(', ')}]`,\n );\n },\n },\n];\n\nfunction assert(condition: boolean, message: string): void {\n if (!condition) throw new Error(message);\n}\n\n/** Whether `run` threw, without caring what it threw. */\nfunction threw(run: () => unknown): boolean {\n try {\n run();\n return false;\n } catch {\n return true;\n }\n}\n\nfunction detach(stop: () => void, label: string): void {\n try {\n stop();\n } catch (error) {\n throw new Error(\n `the unsubscribe returned by ${label}() threw: ${String(error)}`,\n );\n }\n}\n"]}
@@ -8,9 +8,14 @@
8
8
  * with a deadzone so it doesn't jitter.
9
9
  * - `head-locked` - rigidly attached to the view (IWSDK `ScreenSpace` outside
10
10
  * XR / a zero-tolerance follow inside XR). Use sparingly.
11
+ * - `hand-locked` - rides on a hand and shows while the palm is raised toward
12
+ * the viewer: a hand menu. Placement and the palm gate are
13
+ * `hand-menu.ts`; the window's `handMenu` options say which
14
+ * hand and where.
11
15
  *
12
16
  * The state machine itself only decides WHICH engine ingredients a mode needs;
13
- * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`).
17
+ * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`, or
18
+ * placing the window from a hand pose each frame).
14
19
  * Keeping the decision pure makes every transition unit-testable without a
15
20
  * headset or a renderer.
16
21
  */
@@ -18,6 +23,7 @@ export declare const DockMode: {
18
23
  readonly WorldLocked: "world-locked";
19
24
  readonly BodyFollow: "body-follow";
20
25
  readonly HeadLocked: "head-locked";
26
+ readonly HandLocked: "hand-locked";
21
27
  };
22
28
  export type DockModeValue = (typeof DockMode)[keyof typeof DockMode];
23
29
  /** Engine-agnostic description of what a dock mode requires. */
@@ -26,6 +32,8 @@ export interface DockRecipe {
26
32
  follower: boolean;
27
33
  /** Requires an IWSDK `ScreenSpace` component (non-XR HUD lock). */
28
34
  screenSpace: boolean;
35
+ /** Placed from a hand pose each frame, and gated on the palm (a hand menu). */
36
+ handAnchor: boolean;
29
37
  /**
30
38
  * When true the window should re-sync its follow position immediately on
31
39
  * entering the mode (jump to the ideal spot instead of drifting there).
@@ -42,6 +50,8 @@ export interface DockTransition {
42
50
  removeFollower: boolean;
43
51
  addScreenSpace: boolean;
44
52
  removeScreenSpace: boolean;
53
+ addHandAnchor: boolean;
54
+ removeHandAnchor: boolean;
45
55
  snap: boolean;
46
56
  }
47
57
  /**
@@ -51,8 +61,9 @@ export interface DockTransition {
51
61
  export declare function planTransition(from: DockModeValue, to: DockModeValue): DockTransition | undefined;
52
62
  /**
53
63
  * The "pin" affordance on a window's title bar toggles between following the
54
- * player and being placed in space. Head-locked windows unpin to world-locked
55
- * too - pinning something rigidly to the user's face is never the toggle
56
- * target you want.
64
+ * player and being placed in space. Head-locked and hand-locked windows unpin
65
+ * to world-locked too - pinning something rigidly to the user's face is never
66
+ * the toggle target you want, and a hand menu pinned in place is simply a
67
+ * window again.
57
68
  */
58
69
  export declare function togglePinned(mode: DockModeValue): DockModeValue;
@@ -8,9 +8,14 @@
8
8
  * with a deadzone so it doesn't jitter.
9
9
  * - `head-locked` - rigidly attached to the view (IWSDK `ScreenSpace` outside
10
10
  * XR / a zero-tolerance follow inside XR). Use sparingly.
11
+ * - `hand-locked` - rides on a hand and shows while the palm is raised toward
12
+ * the viewer: a hand menu. Placement and the palm gate are
13
+ * `hand-menu.ts`; the window's `handMenu` options say which
14
+ * hand and where.
11
15
  *
12
16
  * The state machine itself only decides WHICH engine ingredients a mode needs;
13
- * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`).
17
+ * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`, or
18
+ * placing the window from a hand pose each frame).
14
19
  * Keeping the decision pure makes every transition unit-testable without a
15
20
  * headset or a renderer.
16
21
  */
@@ -18,16 +23,19 @@ export const DockMode = {
18
23
  WorldLocked: 'world-locked',
19
24
  BodyFollow: 'body-follow',
20
25
  HeadLocked: 'head-locked',
26
+ HandLocked: 'hand-locked',
21
27
  };
22
28
  const RECIPES = {
23
- [DockMode.WorldLocked]: { follower: false, screenSpace: false, snapOnEnter: false },
24
- [DockMode.BodyFollow]: { follower: true, screenSpace: false, snapOnEnter: true },
25
- [DockMode.HeadLocked]: { follower: true, screenSpace: true, snapOnEnter: true },
29
+ [DockMode.WorldLocked]: { follower: false, screenSpace: false, handAnchor: false, snapOnEnter: false },
30
+ [DockMode.BodyFollow]: { follower: true, screenSpace: false, handAnchor: false, snapOnEnter: true },
31
+ [DockMode.HeadLocked]: { follower: true, screenSpace: true, handAnchor: false, snapOnEnter: true },
32
+ [DockMode.HandLocked]: { follower: false, screenSpace: false, handAnchor: true, snapOnEnter: false },
26
33
  };
27
34
  export function isDockMode(value) {
28
35
  return (value === DockMode.WorldLocked ||
29
36
  value === DockMode.BodyFollow ||
30
- value === DockMode.HeadLocked);
37
+ value === DockMode.HeadLocked ||
38
+ value === DockMode.HandLocked);
31
39
  }
32
40
  export function recipeFor(mode) {
33
41
  return RECIPES[mode];
@@ -49,14 +57,17 @@ export function planTransition(from, to) {
49
57
  removeFollower: a.follower && !b.follower,
50
58
  addScreenSpace: !a.screenSpace && b.screenSpace,
51
59
  removeScreenSpace: a.screenSpace && !b.screenSpace,
60
+ addHandAnchor: !a.handAnchor && b.handAnchor,
61
+ removeHandAnchor: a.handAnchor && !b.handAnchor,
52
62
  snap: b.snapOnEnter,
53
63
  };
54
64
  }
55
65
  /**
56
66
  * The "pin" affordance on a window's title bar toggles between following the
57
- * player and being placed in space. Head-locked windows unpin to world-locked
58
- * too - pinning something rigidly to the user's face is never the toggle
59
- * target you want.
67
+ * player and being placed in space. Head-locked and hand-locked windows unpin
68
+ * to world-locked too - pinning something rigidly to the user's face is never
69
+ * the toggle target you want, and a hand menu pinned in place is simply a
70
+ * window again.
60
71
  */
61
72
  export function togglePinned(mode) {
62
73
  return mode === DockMode.WorldLocked ? DockMode.BodyFollow : DockMode.WorldLocked;
@@ -1 +1 @@
1
- {"version":3,"file":"dock-state.js","sourceRoot":"","sources":["../../src/core/dock-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,WAAW,EAAE,cAAc;IAC3B,UAAU,EAAE,aAAa;IACzB,UAAU,EAAE,aAAa;CACjB,CAAC;AAiBX,MAAM,OAAO,GAAsC;IACjD,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE;IACnF,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE;IAChF,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE;CAChF,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,CACL,KAAK,KAAK,QAAQ,CAAC,WAAW;QAC9B,KAAK,KAAK,QAAQ,CAAC,UAAU;QAC7B,KAAK,KAAK,QAAQ,CAAC,UAAU,CAC9B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAmB;IAC3C,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;AACvB,CAAC;AAaD;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAmB,EACnB,EAAiB;IAEjB,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;QAChB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IACxB,OAAO;QACL,IAAI;QACJ,EAAE;QACF,WAAW,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,QAAQ;QACtC,cAAc,EAAE,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,QAAQ;QACzC,cAAc,EAAE,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,WAAW;QAC/C,iBAAiB,EAAE,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,CAAC,WAAW;QAClD,IAAI,EAAE,CAAC,CAAC,WAAW;KACpB,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,IAAmB;IAC9C,OAAO,IAAI,KAAK,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC;AACpF,CAAC","sourcesContent":["/**\n * Dock state machine - pure logic, no engine imports.\n *\n * A window is always in exactly one dock mode:\n *\n * - `world-locked` - placed in space; the window keeps its world transform.\n * - `body-follow` - lazily follows the player (IWSDK `Follower` on the head),\n * with a deadzone so it doesn't jitter.\n * - `head-locked` - rigidly attached to the view (IWSDK `ScreenSpace` outside\n * XR / a zero-tolerance follow inside XR). Use sparingly.\n *\n * The state machine itself only decides WHICH engine ingredients a mode needs;\n * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`).\n * Keeping the decision pure makes every transition unit-testable without a\n * headset or a renderer.\n */\n\nexport const DockMode = {\n WorldLocked: 'world-locked',\n BodyFollow: 'body-follow',\n HeadLocked: 'head-locked',\n} as const;\n\nexport type DockModeValue = (typeof DockMode)[keyof typeof DockMode];\n\n/** Engine-agnostic description of what a dock mode requires. */\nexport interface DockRecipe {\n /** Requires an IWSDK `Follower` targeting the player's head. */\n follower: boolean;\n /** Requires an IWSDK `ScreenSpace` component (non-XR HUD lock). */\n screenSpace: boolean;\n /**\n * When true the window should re-sync its follow position immediately on\n * entering the mode (jump to the ideal spot instead of drifting there).\n */\n snapOnEnter: boolean;\n}\n\nconst RECIPES: Record<DockModeValue, DockRecipe> = {\n [DockMode.WorldLocked]: { follower: false, screenSpace: false, snapOnEnter: false },\n [DockMode.BodyFollow]: { follower: true, screenSpace: false, snapOnEnter: true },\n [DockMode.HeadLocked]: { follower: true, screenSpace: true, snapOnEnter: true },\n};\n\nexport function isDockMode(value: unknown): value is DockModeValue {\n return (\n value === DockMode.WorldLocked ||\n value === DockMode.BodyFollow ||\n value === DockMode.HeadLocked\n );\n}\n\nexport function recipeFor(mode: DockModeValue): DockRecipe {\n return RECIPES[mode];\n}\n\n/** A transition plan: what to add and what to remove, in engine terms. */\nexport interface DockTransition {\n from: DockModeValue;\n to: DockModeValue;\n addFollower: boolean;\n removeFollower: boolean;\n addScreenSpace: boolean;\n removeScreenSpace: boolean;\n snap: boolean;\n}\n\n/**\n * Compute the minimal set of engine changes to move between dock modes.\n * Returns `undefined` for a no-op (same mode).\n */\nexport function planTransition(\n from: DockModeValue,\n to: DockModeValue,\n): DockTransition | undefined {\n if (from === to) {\n return undefined;\n }\n const a = recipeFor(from);\n const b = recipeFor(to);\n return {\n from,\n to,\n addFollower: !a.follower && b.follower,\n removeFollower: a.follower && !b.follower,\n addScreenSpace: !a.screenSpace && b.screenSpace,\n removeScreenSpace: a.screenSpace && !b.screenSpace,\n snap: b.snapOnEnter,\n };\n}\n\n/**\n * The \"pin\" affordance on a window's title bar toggles between following the\n * player and being placed in space. Head-locked windows unpin to world-locked\n * too - pinning something rigidly to the user's face is never the toggle\n * target you want.\n */\nexport function togglePinned(mode: DockModeValue): DockModeValue {\n return mode === DockMode.WorldLocked ? DockMode.BodyFollow : DockMode.WorldLocked;\n}\n"]}
1
+ {"version":3,"file":"dock-state.js","sourceRoot":"","sources":["../../src/core/dock-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,WAAW,EAAE,cAAc;IAC3B,UAAU,EAAE,aAAa;IACzB,UAAU,EAAE,aAAa;IACzB,UAAU,EAAE,aAAa;CACjB,CAAC;AAmBX,MAAM,OAAO,GAAsC;IACjD,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE;IACtG,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE;IACnG,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE;IAClG,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE;CACrG,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,CACL,KAAK,KAAK,QAAQ,CAAC,WAAW;QAC9B,KAAK,KAAK,QAAQ,CAAC,UAAU;QAC7B,KAAK,KAAK,QAAQ,CAAC,UAAU;QAC7B,KAAK,KAAK,QAAQ,CAAC,UAAU,CAC9B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAmB;IAC3C,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;AACvB,CAAC;AAeD;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAmB,EACnB,EAAiB;IAEjB,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;QAChB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IACxB,OAAO;QACL,IAAI;QACJ,EAAE;QACF,WAAW,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,QAAQ;QACtC,cAAc,EAAE,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,QAAQ;QACzC,cAAc,EAAE,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,WAAW;QAC/C,iBAAiB,EAAE,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,CAAC,WAAW;QAClD,aAAa,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,UAAU;QAC5C,gBAAgB,EAAE,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC,UAAU;QAC/C,IAAI,EAAE,CAAC,CAAC,WAAW;KACpB,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,IAAmB;IAC9C,OAAO,IAAI,KAAK,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC;AACpF,CAAC","sourcesContent":["/**\n * Dock state machine - pure logic, no engine imports.\n *\n * A window is always in exactly one dock mode:\n *\n * - `world-locked` - placed in space; the window keeps its world transform.\n * - `body-follow` - lazily follows the player (IWSDK `Follower` on the head),\n * with a deadzone so it doesn't jitter.\n * - `head-locked` - rigidly attached to the view (IWSDK `ScreenSpace` outside\n * XR / a zero-tolerance follow inside XR). Use sparingly.\n * - `hand-locked` - rides on a hand and shows while the palm is raised toward\n * the viewer: a hand menu. Placement and the palm gate are\n * `hand-menu.ts`; the window's `handMenu` options say which\n * hand and where.\n *\n * The state machine itself only decides WHICH engine ingredients a mode needs;\n * `DockSystem` applies them (adding/removing `Follower` / `ScreenSpace`, or\n * placing the window from a hand pose each frame).\n * Keeping the decision pure makes every transition unit-testable without a\n * headset or a renderer.\n */\n\nexport const DockMode = {\n WorldLocked: 'world-locked',\n BodyFollow: 'body-follow',\n HeadLocked: 'head-locked',\n HandLocked: 'hand-locked',\n} as const;\n\nexport type DockModeValue = (typeof DockMode)[keyof typeof DockMode];\n\n/** Engine-agnostic description of what a dock mode requires. */\nexport interface DockRecipe {\n /** Requires an IWSDK `Follower` targeting the player's head. */\n follower: boolean;\n /** Requires an IWSDK `ScreenSpace` component (non-XR HUD lock). */\n screenSpace: boolean;\n /** Placed from a hand pose each frame, and gated on the palm (a hand menu). */\n handAnchor: boolean;\n /**\n * When true the window should re-sync its follow position immediately on\n * entering the mode (jump to the ideal spot instead of drifting there).\n */\n snapOnEnter: boolean;\n}\n\nconst RECIPES: Record<DockModeValue, DockRecipe> = {\n [DockMode.WorldLocked]: { follower: false, screenSpace: false, handAnchor: false, snapOnEnter: false },\n [DockMode.BodyFollow]: { follower: true, screenSpace: false, handAnchor: false, snapOnEnter: true },\n [DockMode.HeadLocked]: { follower: true, screenSpace: true, handAnchor: false, snapOnEnter: true },\n [DockMode.HandLocked]: { follower: false, screenSpace: false, handAnchor: true, snapOnEnter: false },\n};\n\nexport function isDockMode(value: unknown): value is DockModeValue {\n return (\n value === DockMode.WorldLocked ||\n value === DockMode.BodyFollow ||\n value === DockMode.HeadLocked ||\n value === DockMode.HandLocked\n );\n}\n\nexport function recipeFor(mode: DockModeValue): DockRecipe {\n return RECIPES[mode];\n}\n\n/** A transition plan: what to add and what to remove, in engine terms. */\nexport interface DockTransition {\n from: DockModeValue;\n to: DockModeValue;\n addFollower: boolean;\n removeFollower: boolean;\n addScreenSpace: boolean;\n removeScreenSpace: boolean;\n addHandAnchor: boolean;\n removeHandAnchor: boolean;\n snap: boolean;\n}\n\n/**\n * Compute the minimal set of engine changes to move between dock modes.\n * Returns `undefined` for a no-op (same mode).\n */\nexport function planTransition(\n from: DockModeValue,\n to: DockModeValue,\n): DockTransition | undefined {\n if (from === to) {\n return undefined;\n }\n const a = recipeFor(from);\n const b = recipeFor(to);\n return {\n from,\n to,\n addFollower: !a.follower && b.follower,\n removeFollower: a.follower && !b.follower,\n addScreenSpace: !a.screenSpace && b.screenSpace,\n removeScreenSpace: a.screenSpace && !b.screenSpace,\n addHandAnchor: !a.handAnchor && b.handAnchor,\n removeHandAnchor: a.handAnchor && !b.handAnchor,\n snap: b.snapOnEnter,\n };\n}\n\n/**\n * The \"pin\" affordance on a window's title bar toggles between following the\n * player and being placed in space. Head-locked and hand-locked windows unpin\n * to world-locked too - pinning something rigidly to the user's face is never\n * the toggle target you want, and a hand menu pinned in place is simply a\n * window again.\n */\nexport function togglePinned(mode: DockModeValue): DockModeValue {\n return mode === DockMode.WorldLocked ? DockMode.BodyFollow : DockMode.WorldLocked;\n}\n"]}