Skip to main content

@realitycollective/webxr-uiextensions

The core of the Reality Collective UI Extensions. It provides:

  • Windows - movable, resizable panels with a title bar and pin, dock, minimise and close buttons.
  • Docking - snapping a window into a named region of the scene, such as a row along a wall.
  • Layout regions - the named areas that windows snap into.
  • Drag maths - the pure calculations behind dragging, dropping and following the user.
  • Controls - the state behind steppers, toggles, expandable sections and log views.
  • Markup upgrading - code that turns a plain element carrying a data-uix attribute into a working control, so you write markup rather than components.
  • Adapter interfaces - what an engine package must implement to host all of the above.

This package imports no 3D engine. A test, test/architecture.test.ts, fails the moment three, @iwsdk/*, @pmndrs/* or xrblocks appears anywhere in src/. That is what lets the same interface run unchanged on every three.js WebXR runtime.

It carries exactly one runtime dependency, @realitycollective/webxr-input, and the same test fails on any other. That package is the shared contracts vocabulary: plain tuples and records, no engine imports and no dependencies of its own. Vec3Tuple, QuatTuple, HeadPose, HeadPoseSource and PointerSample come from there rather than being redeclared here, so a pose or a ray means the same thing to the Interactions family and to this one, and one input stack drives both. All five are re-exported from this package, so importing them from here keeps working.

You probably want an adapter, not this package

Your engineInstallStatus
Meta IWSDK (Quest / Horizon OS)@realitycollective/iwsdk-uiextensionsreference adapter, full feature set
Google XR Blocks / plain three.js@realitycollective/xrblocks-uiextensionsexperimental, partial feature set

Both adapters re-export everything here, so an app installs one package only. Depend on the core directly when writing headless logic, tests, tooling - or a new adapter.

What lives here

src/core/ window manager, dock state machine, region slot math,
drag math, hold-to-drag, control models (stepper/toggle/
expandable/log) - pure logic, 100% coverage gated
src/controls/ data-uix markup upgraders, driven through the structural
UixElement interface (works on ANY conforming element tree)
src/chrome/ window chrome conventions: contractual element ids
(uix-titlebar, uix-pin, ...) + reference UIKitML snippet
src/adapter.ts the platform-adapter contract: PanelHost, PanelHandle,
WindowHost, WindowHandle, WindowOptionsBase, HeadPoseSource,
PointerInputSource (plain tuples, no engine)
src/contract-cases.ts
windowHostContractCases() - the WindowHost conformance suite
as data, for an adapter to run in its own test runner

Writing an adapter

An adapter supplies three capabilities and drives the core from its frame loop:

  1. Panels - implement PanelHost.createPanel(configJson): turn compiled UIKitML JSON into a live panel whose element tree satisfies UixElement (uikit does out of the box; ids land in userData).
  2. Input - deliver press/move/release into the core's HoldToDrag + drag math, or wire chrome clicks straight to WindowManager.
  3. Viewer pose - implement HeadPoseSource for follow mode and body-locked regions.

The IWSDK adapter is the reference implementation; the XR Blocks adapter shows the same contract bound without an ECS. When yours runs, prove it with the shipped conformance suite below.

The window surface

WindowHost is what app code writes against once panels exist. Four members carry the whole contract, and both shipped adapters honour all four:

  • supportsStandalonePanels: boolean - whether createPanel works here. IWSDK reports false because the ECS owns panel lifecycles and createPanel throws; the three.js host reports true. Check it rather than guessing, and spawn a window when it is false.
  • onPanelReady(listener) - fires as each panel becomes wireable and replays the ones already live, so wiring order never matters. The event's kind says what became ready: window for one created through the window factory, panel for a bare panel the adapter noticed. A bare panel's id is the adapter's best stable identifier for it, which on IWSDK is the config path. Only a host that discovers panels the app created outside the window factory ever reports panel; the three.js and XR Blocks host reports window only, because a standalone createPanel document is handed back to the caller and never announced.
  • createWindow(options) - adapter-specific, because the config payload differs per engine, but it always returns a WindowHandle.
  • 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.
  • 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.

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.

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.

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.

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.

Proving a new adapter conforms

windowHostContractCases() is the WindowHost conformance suite, shipped as data rather than as tests. Each case is a name plus a run(setup) that returns silently on success and throws an Error describing the failure otherwise, so an adapter runs them in whatever test runner it already has. It ships runner-free because an adapter written outside this repository cannot reach into this one's test/ folder, and because no adapter should have to install this repo's runner to prove itself.

An adapter's test file is a loop:

import { windowHostContractCases } from '@realitycollective/webxr-uiextensions';
import type { WindowHostContractSetup } from '@realitycollective/webxr-uiextensions';

function makeSetup(): WindowHostContractSetup {
const host = createMyHost();
return {
host,
manager: host.manager, // the WindowManager the host applies
createWindow: (id) => host.createWindow({ id, config: myConfig() }),
// Only where the panel arrives after the window does:
attach: (id) => deliverThePanelFor(id),
// Only where supportsStandalonePanels is true:
panelConfig: myConfig(),
};
}

for (const contractCase of windowHostContractCases()) {
it(contractCase.name, () => contractCase.run(makeSetup()));
}

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.

Testing

npm test # from the workspace root - vitest, 100% thresholds on src/core

Live demos

License

MIT © Reality Collective

Classes

ClassDescription
Emitter-
ExpandableLabelHandle-
ExpandableModel-
HoldToDrag-
LogModel-
LogViewHandle-
PanelControls-
RegionRegistry-
StepperHandle-
StepperModel-
ToggleHandle-
ToggleModelToggle model - pure boolean state for the data-uix="toggle" control.
TouchPress-
WindowManager-

Interfaces

InterfaceDescription
DockRecipeEngine-agnostic description of what a dock mode requires.
DockTransitionA transition plan: what to add and what to remove, in engine terms.
DragSession-
ExpandableOptionsExpandable label model - pure truncation/expansion state for the data-uix="expandable-label" control (a multi-line label that collapses to a preview with an ellipsis and a "more/less" affordance).
HandMenuOptions-
HandMenuPlacementWhat the adapter applies this frame.
HandPoseSourceSupplies a hand's pose each frame as a WebXR GRIP space, the frame hand-menu.ts documents (-Z toward the thumb, +Y up the arm, the palm at -X on the right hand and +X on the left). A controller's grip and a tracked hand's gripSpace both are one; hand JOINT spaces are not, and must be converted. Returns undefined while that hand is not tracked; a hand menu on it is then hidden. An adapter without hands at all (a desktop) supplies no source and falls back to body-follow placement for hand-locked windows.
HoldUpdate-
LogEntryLog model - pure ring buffer + viewport for the data-uix="log-view" control (scrollable log/list windows).
LogModelOptions-
OpenWindowOptions-
PanelHandleA live spatial panel created from compiled UIKitML JSON. The root is traversable with the core's walk/findRole helpers and the data-uix control upgraders - identical markup works on every adapter.
PanelHostCreates spatial panels - the engine-specific half of UIKitML rendering.
PanelReadyEventA window whose panel has finished loading and is ready to be wired. Delivered by WindowHost.onPanelReady.
PointerInputSourceDelivers press-move-release for one interaction source (a controller ray, a hand pinch, a mouse). The core's hold-to-drag and drag-math consume these; the adapter decides what constitutes press/release.
RegionCandidate-
RegionDefinition-
RegisteredRegion-
SceneDescriptorA complete, engine-free scene definition.
SceneRegionOne dock region in a scene.
SceneTargetWhat an adapter must provide for applyScene to build a scene. Both shipped adapters implement this; a new adapter only needs these two methods to gain full scene portability.
SceneWindowOne window in a scene.
StepperOptionsStepper model - pure numeric state for the data-uix="stepper" control.
TouchPressOptionsTouch press - the press / hold / release state machine for a near (poke) pointer against a panel, pure logic with no engine imports.
TouchSampleWhat the adapter measured this frame. undefined means no contact at all.
TouchUpdateThe transitions the adapter acts on this frame.
UixElementStructural view of a uikit element as the controls layer needs it.
WindowChromeWhich title-bar buttons are enabled. Keys match the chrome element ids in WINDOW_CHROME_IDS. A disabled button is hidden and its click ignored. Every button is OFF unless the app turns it on.
WindowHandleA window an adapter spawned, before its panel necessarily exists.
WindowHostThe engine-agnostic surface an app needs to build a UI: spawn windows and regions from portable data, observe when panels become wireable, and reach the shared WindowManager.
WindowHostContractCaseOne check a WindowHost implementation must pass. run returns silently on success and throws an Error describing the failure otherwise, so any test runner can host it.
WindowHostContractSetupEverything a case needs to drive one adapter. Build a FRESH one per case: cases spawn windows of their own and do not clean up after themselves.
WindowManagerEvents-
WindowOptionsBaseThe window options every adapter understands.
WindowRecord-

Type Aliases

Type AliasDescription
ControlHandle-
DockModeValue-
Hand-
HandMenuAnchorWhere the panel sits relative to the palm.
HandPosesThis frame's tracked hands; a hand that is not tracked is absent.
HoldPhaseHold-to-drag threshold - pure timing state for title-bar dragging.
ListenerMinimal typed event emitter.
PointerSampleOne pointer/ray interaction stream, engine-normalised: a world-space origin (ray origin or touch point) and a normalised direction. It is the Input package's RayTuple, which is what lets a provider written against @realitycollective/webxr-input feed this contract unchanged.
RegionFlow-
TouchPhase-
Vec3Layout regions - pure slot math for docking windows into named regions.

Variables

VariableDescription
DEFAULT_HAND_MENU-
DEFAULT_REGION-
DEFAULT_TOUCH_PRESS-
DockModeDock state machine - pure logic, no engine imports.
ELLIPSIS-
HAND_MENU_SNIPPETReference hand-menu markup: the same root and content ids, no title bar, a vertical stack of buttons that sizes to its content. Spawn it with dockMode: 'hand-locked' and it rides the hand; without a title bar there is nothing to drag, which is what a hand menu wants. Give each button an id and wire it to a WindowManager call.
NO_CHROME-
WINDOW_CHROME_IDSWindow chrome conventions.
WINDOW_CHROME_SNIPPET-

Functions

FunctionDescription
anchorOffsetHand-local offset for an anchor. The thumb is -Z and the fingertips -Y for both hands in the grip frame, so no mirroring is needed.
applySceneApply a descriptor to an adapter. Regions are created before windows so a window that spawns docked (region: 'x') always finds its region.
attrBoolean-
attrNumber-
attrString-
beginDragBegin a drag: capture how far along the ray the grab landed and where the window origin sits relative to the grab point.
captureDropWhich region captures a window dropped at point? The nearest region whose snapRadius contains the point and which still has capacity; undefined when the drop lands in open space (the window stays world-locked where it was released).
dragPositionWindow position for the current ray. rayDirection must be normalized (pointer rays from the input layer already are).
evaluateHandMenuThe per-frame entry point an adapter calls for each hand-locked window.
faceViewerOrientation whose local +Z (the face of a panel) points from position to viewer, upright against world +Y. Straight above or below the viewer there is no upright, so world +Z stands in for "up" there.
faceViewerYawYaw (radians, three.js Y-up convention) that turns the window's -Z face toward viewer - the "PivotY" billboard used while dragging so a window never gets parked edge-on. Returns current when the viewer is directly above/below (degenerate on the Y axis).
findRole-
findRolesAll descendants (including self) declared as <uix-{role}>.
handMenuPoseThe menu's world pose for a hand: anchored in the hand frame, turned to face the viewer.
hasCapacity-
isDockMode-
isPalmFacing-
minimizeLabelForWhat the minimize affordance should read for a window's current state - the label always names what the NEXT click does, matching pinLabelFor: - open → "MIN" (click collapses the content) - minimized → "MAX" (click restores it)
normalize-
normalizeRegion-
palmFacingCosine of the angle between the palm normal and the direction to the viewer: 1 is palm square on to the viewer, -1 is the back of the hand.
palmNormalThe palm normal in the hand frame: -X on the right hand, +X on the left.
panelControlsForControls of an upgraded panel; undefined before UIControlsSystem ran.
pickHandPick the hand for this frame: the configured one when it is tracked, or for either the tracked hand whose palm faces the viewer most. The gate is applied here too, so a fixed hand that is tracked but turned away still yields undefined.
pinLabelForWhat the pin affordance should read for a window's current state: - dragging → "PIN" (the window is loose in your hand) - placed/world-locked → "UNPIN" (click releases it to follow) - following → "PIN" (click pins it where it is)
planTransitionCompute the minimal set of engine changes to move between dock modes. Returns undefined for a no-op (same mode).
quaternionFromBasisQuaternion for the rotation whose columns are the given orthonormal axes.
recipeFor-
resolveHandMenuFill a partial set of options from the defaults.
resolveTouchPress-
rotateRotate a vector by a unit quaternion [x, y, z, w].
sceneConfigPathsEvery distinct panel config path in a scene (for preloading).
slotOffsetLocal-space offset of slot index within a region.
tagOfThe custom-element tag this element was declared with, lowercased, or undefined for a plain built-in element.
togglePinnedThe "pin" affordance on a window's title bar toggles between following the player and being placed in space. Head-locked and hand-locked windows unpin to world-locked too - pinning something rigidly to the user's face is never the toggle target you want, and a hand menu pinned in place is simply a window again.
upgradeExpandableLabel-
upgradeLogView-
upgradePanelUpgrade every data-uix control under root and remember the result against documentKey (the panel's UIKitDocument). Idempotent per key.
upgradeStepper-
upgradeToggle-
validateSceneValidate a descriptor: unique window/region ids, and every region reference resolvable. Returns the problems found (empty = valid) rather than throwing, so callers can report them all at once.
walkDepth-first traversal over uikit element children.
windowHostContractCasesThe shared host conformance suite. An adapter's test file is a loop: