@jgengine/react 0.18.1 → 0.21.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/actionHud.js +1 -1
  3. package/dist/bars.d.ts +1 -1
  4. package/dist/bars.js +1 -1
  5. package/dist/barsPreview.d.ts +1 -1
  6. package/dist/barsPreview.js +9 -2
  7. package/dist/browserLifecycle.d.ts +26 -0
  8. package/dist/browserLifecycle.js +42 -0
  9. package/dist/dialogBehavior.d.ts +22 -0
  10. package/dist/dialogBehavior.js +109 -0
  11. package/dist/dragLayer.d.ts +7 -0
  12. package/dist/dragLayer.js +156 -100
  13. package/dist/hooks.d.ts +10 -1
  14. package/dist/hooks.js +8 -1
  15. package/dist/hudTheme.d.ts +1 -1
  16. package/dist/hudTheme.js +3 -2
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.js +3 -0
  19. package/dist/map.js +106 -42
  20. package/dist/modals.js +8 -52
  21. package/dist/panels.js +45 -14
  22. package/dist/previewFixtures.js +3 -1
  23. package/dist/scrollRail.d.ts +18 -0
  24. package/dist/scrollRail.js +55 -0
  25. package/dist/scrollRailPreview.d.ts +4 -0
  26. package/dist/scrollRailPreview.js +8 -0
  27. package/dist/useServerSession.d.ts +1 -1
  28. package/dist/useServerSession.js +5 -3
  29. package/dist/voice.js +120 -13
  30. package/package.json +2 -2
  31. package/skills/ce-handoff/SKILL.md +2 -2
  32. package/skills/jgengine/SKILL.md +28 -6
  33. package/skills/jgengine/api.md +139 -99
  34. package/skills/jgengine/capabilities.md +28 -0
  35. package/skills/jgengine/recipes/minimal-game.md +10 -2
  36. package/skills/jgengine-ui/SKILL.md +16 -17
  37. package/skills/jgengine-ui/api.md +603 -365
  38. package/skills/jgengine-ui/capabilities.md +131 -1
  39. package/skills/jgengine-ui/reference-primitives.md +179 -0
  40. package/skills/jgengine-ui/reference.md +4 -81
package/dist/hooks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { AxisChannel } from "@jgengine/core/input/axisInput";
3
3
  import { createMarkerSet } from "@jgengine/core/world/markers";
4
+ import { dispatchCommand } from "@jgengine/core/runtime/commandDispatch";
4
5
  import { browseSessions, } from "@jgengine/core/multiplayer/matchmaking";
5
6
  import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
6
7
  import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
@@ -21,9 +22,10 @@ export function useGameStore(selector, isEqual = Object.is) {
21
22
  export function useGameStoreValue(key, fallback) {
22
23
  return useGameStore((ctx) => ctx.game.store.get(key) ?? fallback);
23
24
  }
25
+ /** UI command surface. Hosted commands return a promise; await it to display acknowledgement or rejection. Registration and runAs remain trusted local APIs. */
24
26
  export function useGame() {
25
27
  const ctx = useGameContext();
26
- return useMemo(() => ({ commands: ctx.game.commands, events: ctx.game.events }), [ctx]);
28
+ return useMemo(() => ({ commands: { ...ctx.game.commands, run: (name, input) => dispatchCommand(ctx, name, input) }, events: ctx.game.events }), [ctx]);
27
29
  }
28
30
  export function usePlayer() {
29
31
  const ctx = useGameContext();
@@ -81,6 +83,11 @@ export function useNearestWorldItem(radius) {
81
83
  export function useEntityStat(instanceId, statId) {
82
84
  return useGameStore((ctx) => ctx.scene.entity.stats.get(instanceId, statId));
83
85
  }
86
+ /**
87
+ * The entity `fromInstanceId` currently targets (set by `ctx.scene.entity.setTarget` or `cycleTarget`), for a target frame.
88
+ *
89
+ * @capability current-target read the player's selected target (click or tab target) to drive a target frame
90
+ */
84
91
  export function useTarget(fromInstanceId) {
85
92
  return useGameStore((ctx) => ctx.scene.entity.getTarget(fromInstanceId));
86
93
  }
@@ -110,7 +110,7 @@ export interface HudTheme {
110
110
  ring: string;
111
111
  skin?: HudSkin;
112
112
  }
113
- /** Emits the CSS custom properties for a theme — spread onto any HUD ancestor to re-skin the subtree. */
113
+ /** Emits theme CSS properties, resetting inherited bar fills when absent. Spread onto a HUD ancestor. */
114
114
  export declare function hudThemeVars(theme: HudTheme): CSSProperties;
115
115
  /** Default theme tokens for previews/scaffold — not a finished game look; author a custom theme. */
116
116
  export declare const defaultHudTheme: HudTheme;
package/dist/hudTheme.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {} from "react";
2
2
  import { hudSkinVars } from "./hudSkin.js";
3
3
  export { hudSkinVars } from "./hudSkin.js";
4
- /** Emits the CSS custom properties for a theme — spread onto any HUD ancestor to re-skin the subtree. */
4
+ /** Emits theme CSS properties, resetting inherited bar fills when absent. Spread onto a HUD ancestor. */
5
5
  export function hudThemeVars(theme) {
6
6
  const { palette, surface, status, font, rarity, frame, bar, slot } = theme;
7
7
  return {
@@ -13,7 +13,8 @@ export function hudThemeVars(theme) {
13
13
  "--jg-slot-slice": "0",
14
14
  "--jg-slot-scale": "1",
15
15
  "--jg-bar-skin-track": "none",
16
- "--jg-bar-skin-fill": "none",
16
+ // `initial` clears inherited skins and lets var() fall back to the bar's palette color.
17
+ "--jg-bar-skin-fill": "initial",
17
18
  "--jg-skin-cursor": "auto",
18
19
  ...hudSkinVars(theme.skin),
19
20
  // atomic-bar tokens (same names the bars read — #1033)
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./chatBubbles.js";
5
5
  export * from "./voice.js";
6
6
  export * from "./social.js";
7
7
  export * from "./hooks.js";
8
+ export * from "./browserLifecycle.js";
8
9
  export * from "./useDebouncedCommit.js";
9
10
  export * from "./store.js";
10
11
  export * from "./settings.js";
@@ -72,3 +73,5 @@ export * from "./previewFixtures.js";
72
73
  export * from "./streetGeometryPreview.js";
73
74
  export * from "./saveSlots.js";
74
75
  export * from "./cameraShake.js";
76
+ export * from "./dialogBehavior.js";
77
+ export * from "./scrollRail.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./chatBubbles.js";
5
5
  export * from "./voice.js";
6
6
  export * from "./social.js";
7
7
  export * from "./hooks.js";
8
+ export * from "./browserLifecycle.js";
8
9
  export * from "./useDebouncedCommit.js";
9
10
  export * from "./store.js";
10
11
  export * from "./settings.js";
@@ -72,3 +73,5 @@ export * from "./previewFixtures.js";
72
73
  export * from "./streetGeometryPreview.js";
73
74
  export * from "./saveSlots.js";
74
75
  export * from "./cameraShake.js";
76
+ export * from "./dialogBehavior.js";
77
+ export * from "./scrollRail.js";
package/dist/map.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
4
4
  import { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "@jgengine/core/world/minimap";
5
5
  import { mapLayerColor, } from "@jgengine/core/world/mapLayers";
@@ -457,6 +457,15 @@ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, backgrou
457
457
  function clampScale(value, min, max) {
458
458
  return Math.max(min, Math.min(max, value));
459
459
  }
460
+ function mapControlEvent(event) {
461
+ for (const target of event.nativeEvent.composedPath()) {
462
+ if (target === event.currentTarget)
463
+ break;
464
+ if (target instanceof Element && target.matches('button, a[href], input, textarea, select, label, [role="button"], [role="link"], [role="slider"], [role="switch"], [role="checkbox"], [contenteditable]:not([contenteditable=false])'))
465
+ return true;
466
+ }
467
+ return false;
468
+ }
460
469
  /**
461
470
  * Fullscreen pan/zoom world-map overlay — the enlarged "press M" map. Wheel to
462
471
  * zoom (toward the cursor), drag to pan, and click to place (wire `onWorldClick`
@@ -477,8 +486,28 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
477
486
  const moved = useRef(false);
478
487
  const draftRef = useRef(null);
479
488
  const [draft, setDraft] = useState(null);
489
+ const [panning, setPanning] = useState(false);
490
+ const ownerRef = useRef(null);
491
+ const gestureRef = useRef(null);
480
492
  const drawing = tool === "draw";
481
493
  const minSpacing = (worldW + worldD) / 400;
494
+ const retireGesture = (notify = true) => {
495
+ const owner = ownerRef.current;
496
+ ownerRef.current = null;
497
+ owner?.detach();
498
+ drag.current = null;
499
+ draftRef.current = null;
500
+ if (notify) {
501
+ setDraft(null);
502
+ setPanning(false);
503
+ }
504
+ if (owner?.source.hasPointerCapture(owner.pointerId))
505
+ owner.source.releasePointerCapture(owner.pointerId);
506
+ };
507
+ const cancelGesture = () => { if (ownerRef.current !== null)
508
+ moved.current = true; retireGesture(); };
509
+ useEffect(() => { cancelGesture(); }, [open, tool]);
510
+ useEffect(() => () => retireGesture(false), []);
482
511
  if (!open)
483
512
  return null;
484
513
  const zoomBy = (factor, sx, sy) => {
@@ -508,12 +537,43 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
508
537
  draftRef.current = [...points, point];
509
538
  setDraft(draftRef.current);
510
539
  };
511
- const commitDraft = () => {
512
- const points = draftRef.current;
513
- draftRef.current = null;
514
- setDraft(null);
515
- if (points !== null && points.length >= 2)
516
- onStrokeComplete?.(points);
540
+ gestureRef.current = {
541
+ move: (event) => {
542
+ const owner = ownerRef.current;
543
+ if (owner === null || event.pointerId !== owner.pointerId)
544
+ return;
545
+ if (!owner.source.isConnected) {
546
+ cancelGesture();
547
+ return;
548
+ }
549
+ if (owner.tool === "draw") {
550
+ if (draftRef.current !== null)
551
+ pushDraftPoint(screenToWorld({ currentTarget: owner.source, clientX: event.clientX, clientY: event.clientY }));
552
+ return;
553
+ }
554
+ const state = drag.current;
555
+ if (state === null)
556
+ return;
557
+ const rect = owner.source.getBoundingClientRect();
558
+ const dx = (event.clientX - state.x) * contentWidth / rect.width;
559
+ const dy = (event.clientY - state.y) * contentHeight / rect.height;
560
+ if (Math.abs(dx) + Math.abs(dy) > 3)
561
+ moved.current = true;
562
+ setViewport({ scale: state.vp.scale, tx: state.vp.tx + dx, ty: state.vp.ty + dy });
563
+ },
564
+ finish: (event) => {
565
+ const owner = ownerRef.current;
566
+ if (owner === null || event.pointerId !== owner.pointerId)
567
+ return;
568
+ if (!owner.source.isConnected) {
569
+ cancelGesture();
570
+ return;
571
+ }
572
+ const points = owner.tool === "draw" ? draftRef.current : null;
573
+ retireGesture();
574
+ if (points !== null && points.length >= 2)
575
+ onStrokeComplete?.(points);
576
+ },
517
577
  };
518
578
  const draftRoute = draft !== null && draft.length >= 2 ? { id: "__draft-stroke", points: draft, tone: drawTone, width: drawWidth } : null;
519
579
  const forwardedRoutes = draftRoute === null ? surfaceProps.routes : [...(surfaceProps.routes ?? []), draftRoute];
@@ -551,55 +611,59 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
551
611
  minHeight: 0,
552
612
  overflow: "hidden",
553
613
  touchAction: "none",
554
- cursor: drawing ? "crosshair" : drag.current !== null ? "grabbing" : "grab",
614
+ cursor: drawing ? "crosshair" : panning ? "grabbing" : "grab",
555
615
  }, onWheel: (event) => {
556
616
  const at = canvasPoint(event);
557
617
  zoomBy(event.deltaY < 0 ? 1.15 : 1 / 1.15, at.x, at.y);
558
618
  }, onPointerDown: (event) => {
559
- if (event.button !== 0)
619
+ if (event.button !== 0 || !event.isPrimary || ownerRef.current !== null || mapControlEvent(event))
560
620
  return;
561
- event.currentTarget.setPointerCapture(event.pointerId);
562
- if (drawing) {
563
- draftRef.current = [screenToWorld(event)];
564
- setDraft(draftRef.current);
621
+ const source = event.currentTarget;
622
+ const view = source.ownerDocument.defaultView;
623
+ if (view === null)
565
624
  return;
625
+ const onMove = (move) => gestureRef.current?.move(move);
626
+ const onUp = (up) => { if (up.button === 0)
627
+ gestureRef.current?.finish({ pointerId: event.pointerId }); };
628
+ const onCancel = (cancel) => { if (cancel.pointerId === ownerRef.current?.pointerId)
629
+ cancelGesture(); };
630
+ const mouse = event.pointerType === "mouse";
631
+ ownerRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, source, tool, detach: () => {
632
+ if (!mouse)
633
+ return;
634
+ view.removeEventListener("pointermove", onMove);
635
+ view.removeEventListener("mouseup", onUp);
636
+ view.removeEventListener("pointercancel", onCancel);
637
+ } };
638
+ if (mouse) {
639
+ // Mouse capture can be dropped on an auxiliary release while the primary button is still held.
640
+ view.addEventListener("pointermove", onMove);
641
+ view.addEventListener("mouseup", onUp);
642
+ view.addEventListener("pointercancel", onCancel);
566
643
  }
567
- drag.current = { x: event.clientX, y: event.clientY, vp: viewport };
644
+ else
645
+ source.setPointerCapture(event.pointerId);
568
646
  moved.current = false;
569
- }, onPointerMove: (event) => {
570
647
  if (drawing) {
571
- if (draftRef.current !== null)
572
- pushDraftPoint(screenToWorld(event));
648
+ draftRef.current = [screenToWorld(event)];
649
+ setDraft(draftRef.current);
573
650
  return;
574
651
  }
575
- const state = drag.current;
576
- if (state === null)
652
+ drag.current = { x: event.clientX, y: event.clientY, vp: viewport };
653
+ setPanning(true);
654
+ }, onPointerMove: (event) => { if (ownerRef.current?.pointerType !== "mouse")
655
+ gestureRef.current?.move(event); }, onPointerUp: (event) => { if (ownerRef.current?.pointerType !== "mouse")
656
+ gestureRef.current?.finish(event); }, onPointerCancel: (event) => { if (event.pointerId === ownerRef.current?.pointerId)
657
+ cancelGesture(); }, onLostPointerCapture: (event) => { if (event.pointerId === ownerRef.current?.pointerId)
658
+ cancelGesture(); }, onClick: (event) => {
659
+ if (ownerRef.current !== null || onWorldClick === undefined || drawing || mapControlEvent(event))
577
660
  return;
578
- const rect = event.currentTarget.getBoundingClientRect();
579
- const kx = contentWidth / rect.width;
580
- const ky = contentHeight / rect.height;
581
- const dx = (event.clientX - state.x) * kx;
582
- const dy = (event.clientY - state.y) * ky;
583
- if (Math.abs(dx) + Math.abs(dy) > 3)
584
- moved.current = true;
585
- setViewport({ scale: state.vp.scale, tx: state.vp.tx + dx, ty: state.vp.ty + dy });
586
- }, onPointerUp: (event) => {
587
- if (event.currentTarget.hasPointerCapture(event.pointerId))
588
- event.currentTarget.releasePointerCapture(event.pointerId);
589
- if (drawing) {
590
- commitDraft();
661
+ if (moved.current) {
662
+ moved.current = false;
591
663
  return;
592
664
  }
593
- drag.current = null;
594
- }, children: [_jsx(WorldMapSurface, { ...surfaceProps, routes: forwardedRoutes, bounds: bounds, width: contentWidth, height: contentHeight, canvasWidth: contentWidth, canvasHeight: contentHeight, viewport: viewport, style: { width: "100%", height: "100%", borderRadius: 0, display: "block" }, onWorldClick: onWorldClick === undefined || drawing
595
- ? undefined
596
- : (world) => {
597
- if (moved.current) {
598
- moved.current = false;
599
- return;
600
- }
601
- onWorldClick(world);
602
- } }), children] })] }));
665
+ onWorldClick(screenToWorld(event));
666
+ }, children: [_jsx(WorldMapSurface, { ...surfaceProps, routes: forwardedRoutes, bounds: bounds, width: contentWidth, height: contentHeight, canvasWidth: contentWidth, canvasHeight: contentHeight, viewport: viewport, style: { width: "100%", height: "100%", borderRadius: 0, display: "block", ...(onWorldClick === undefined || drawing ? {} : { cursor: "crosshair" }) } }), children] })] }));
603
667
  }
604
668
  /**
605
669
  * Marker-kind key for a map/minimap — glyph + color swatch per kind, labelled.
package/dist/modals.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useReducer, useRef, } from "react";
2
+ import { useCallback, useEffect, useReducer, } from "react";
3
3
  import { MODAL_CANCEL, MODAL_CONFIRM, } from "@jgengine/core/ui/modalStack";
4
4
  import { HudFrame } from "./hudFrame.js";
5
+ import { useDialogBehavior } from "./dialogBehavior.js";
5
6
  /**
6
7
  * Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
7
8
  *
@@ -12,11 +13,6 @@ export function useModalStack(stack) {
12
13
  useEffect(() => stack.subscribe(bump), [stack]);
13
14
  return { top: stack.top(), depth: stack.depth() };
14
15
  }
15
- /** @internal Focusable descendants of `root`, in DOM order, skipping disabled/hidden ones. */
16
- function focusableWithin(root) {
17
- const selector = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
18
- return Array.from(root.querySelectorAll(selector)).filter((el) => el.offsetParent !== null || el === document.activeElement);
19
- }
20
16
  /**
21
17
  * Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
22
18
  * backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
@@ -28,60 +24,20 @@ function focusableWithin(root) {
28
24
  */
29
25
  export function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, className, style, }) {
30
26
  const { top } = useModalStack(stack);
31
- const panelRef = useRef(null);
32
- const restoreRef = useRef(null);
33
- const stepId = top?.id ?? null;
34
27
  const controls = {
35
28
  resolve: useCallback((result, payload) => stack.resolve(result, payload !== undefined ? { payload } : undefined), [stack]),
36
29
  confirm: useCallback((payload) => stack.resolve(MODAL_CONFIRM, payload !== undefined ? { payload } : undefined), [stack]),
37
30
  cancel: useCallback(() => stack.resolve(MODAL_CANCEL), [stack]),
38
31
  };
39
- // Move focus into the dialog on open; restore it on close.
40
- useEffect(() => {
41
- if (stepId === null || typeof document === "undefined")
42
- return undefined;
43
- restoreRef.current = document.activeElement;
44
- const panel = panelRef.current;
45
- if (panel !== null) {
46
- const first = focusableWithin(panel)[0] ?? panel;
47
- first.focus();
48
- }
49
- return () => {
50
- restoreRef.current?.focus?.();
51
- };
52
- }, [stepId]);
32
+ const panelRef = useDialogBehavior({
33
+ open: top !== null,
34
+ focusKey: top?.id,
35
+ onClose: closeOnEsc ? controls.cancel : undefined,
36
+ });
53
37
  if (top === null)
54
38
  return null;
55
39
  const dim = backdrop ?? "var(--jg-backdrop, rgba(4,7,12,0.62))";
56
- const onKeyDown = (event) => {
57
- if (closeOnEsc && event.key === "Escape") {
58
- event.stopPropagation();
59
- controls.cancel();
60
- return;
61
- }
62
- if (event.key !== "Tab")
63
- return;
64
- const panel = panelRef.current;
65
- if (panel === null)
66
- return;
67
- const focusable = focusableWithin(panel);
68
- if (focusable.length === 0) {
69
- event.preventDefault();
70
- return;
71
- }
72
- const first = focusable[0];
73
- const last = focusable[focusable.length - 1];
74
- const active = document.activeElement;
75
- if (event.shiftKey && active === first) {
76
- event.preventDefault();
77
- last.focus();
78
- }
79
- else if (!event.shiftKey && active === last) {
80
- event.preventDefault();
81
- first.focus();
82
- }
83
- };
84
- return (_jsxs("div", { "data-modal-host": "", className: className, onKeyDown: onKeyDown, style: {
40
+ return (_jsxs("div", { "data-modal-host": "", className: className, style: {
85
41
  position: "fixed",
86
42
  inset: 0,
87
43
  zIndex: zIndexBase,
package/dist/panels.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState, } from "react";
3
3
  import { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen as isPanelOpen, movePanel, openPanel, orderedOpen, panelByHotkey, } from "@jgengine/core/ui/panelModel";
4
4
  import { HudFrame } from "./hudFrame.js";
5
+ import { observeBrowserSuspension } from "./browserLifecycle.js";
5
6
  /**
6
7
  * Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
7
8
  * any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
@@ -116,24 +117,51 @@ function topClosableOpen(state) {
116
117
  return null;
117
118
  }
118
119
  // ---- Window chrome ---------------------------------------------------------
119
- /** @internal Begin a pointer drag from the title bar, streaming new positions to `onMove` until release. */
120
- function beginWindowDrag(event, origin, onMove) {
121
- if (typeof window === "undefined")
122
- return;
120
+ /** @internal Own a title-bar pointer until release or interruption, streaming positions to `onMove`. */
121
+ function beginWindowDrag(event, origin, onMove, onRetire) {
122
+ const source = event.currentTarget;
123
+ const view = source.ownerDocument.defaultView;
124
+ if (view === null)
125
+ return null;
126
+ const pointerId = event.pointerId;
123
127
  const startX = event.clientX;
124
128
  const startY = event.clientY;
125
129
  const base = { x: origin.x, y: origin.y };
130
+ let active = true;
131
+ const retire = () => {
132
+ if (!active)
133
+ return;
134
+ active = false;
135
+ view.removeEventListener("pointermove", onPointerMove);
136
+ view.removeEventListener("pointerup", onEnd);
137
+ view.removeEventListener("pointercancel", onEnd);
138
+ detachSuspension();
139
+ source.removeEventListener("lostpointercapture", onEnd);
140
+ onRetire();
141
+ if (source.hasPointerCapture(pointerId))
142
+ source.releasePointerCapture(pointerId);
143
+ };
126
144
  const onPointerMove = (moveEvent) => {
145
+ if (moveEvent.pointerId !== pointerId)
146
+ return;
147
+ if (!source.isConnected) {
148
+ retire();
149
+ return;
150
+ }
127
151
  onMove({ x: base.x + (moveEvent.clientX - startX), y: base.y + (moveEvent.clientY - startY) });
128
152
  };
129
- const onPointerUp = () => {
130
- window.removeEventListener("pointermove", onPointerMove);
131
- window.removeEventListener("pointerup", onPointerUp);
132
- window.removeEventListener("pointercancel", onPointerUp);
153
+ const onEnd = (endEvent) => {
154
+ if (endEvent.pointerId !== pointerId || (endEvent.type === "pointerup" && endEvent.button !== 0))
155
+ return;
156
+ retire();
133
157
  };
134
- window.addEventListener("pointermove", onPointerMove);
135
- window.addEventListener("pointerup", onPointerUp);
136
- window.addEventListener("pointercancel", onPointerUp);
158
+ view.addEventListener("pointermove", onPointerMove);
159
+ view.addEventListener("pointerup", onEnd);
160
+ view.addEventListener("pointercancel", onEnd);
161
+ const detachSuspension = observeBrowserSuspension(retire, { hidden: false, pointerLockLost: false }, { window: view, document: source.ownerDocument });
162
+ source.addEventListener("lostpointercapture", onEnd);
163
+ source.setPointerCapture(pointerId);
164
+ return retire;
137
165
  }
138
166
  const TITLEBAR_STYLE = {
139
167
  display: "flex",
@@ -167,12 +195,15 @@ const CLOSE_BUTTON_STYLE = {
167
195
  };
168
196
  /** @internal Shared, fully-controlled window view — title bar (drag + close) plus a HudFrame body. */
169
197
  function WindowView({ title, ariaLabel, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, className, style, bodyStyle, children, }) {
198
+ const retireDrag = useRef(null);
199
+ useEffect(() => () => retireDrag.current?.(), []);
170
200
  return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: _jsxs(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
171
- if (event.button !== 0)
201
+ if (event.button !== 0 || !event.isPrimary || retireDrag.current !== null)
172
202
  return;
173
203
  onFocus?.();
174
- if (onMove !== undefined)
175
- beginWindowDrag(event, pos, onMove);
204
+ if (onMove !== undefined) {
205
+ retireDrag.current = beginWindowDrag(event, pos, onMove, () => { retireDrag.current = null; });
206
+ }
176
207
  }, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx("button", { type: "button", "data-jg-window-close": "", "aria-label": "Close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 10, ...bodyStyle }, children: children })] }) }));
177
208
  }
178
209
  /** @internal Fallback cascade position for a window whose state has no override yet. */
@@ -5,6 +5,7 @@ import { StreetGeometryPreview } from "./streetGeometryPreview.js";
5
5
  import { SpriteClipPreview } from "./spriteClipPreview.js";
6
6
  import { TileLayerPreview } from "./tileLayerPreview.js";
7
7
  import { HudSkinPreview } from "./hudSkinPreview.js";
8
+ import { ScrollRailPreview } from "./scrollRailPreview.js";
8
9
  /**
9
10
  * Registry of deterministic, engine-level preview fixtures — the *real* exported
10
11
  * `@jgengine/react` components (`HudThemePreview`, `BarsPreview`, `IconsPreview`, …) that render
@@ -18,6 +19,7 @@ import { HudSkinPreview } from "./hudSkinPreview.js";
18
19
  * declares.
19
20
  */
20
21
  export const PREVIEW_FIXTURES = {
22
+ ScrollRailPreview: { name: "ScrollRailPreview", description: "Reachable caller-owned settings categories in shared scrolling chrome.", component: ScrollRailPreview },
21
23
  HudThemePreview: {
22
24
  name: "HudThemePreview",
23
25
  description: "Every HudTheme genre preset × atomic bars + a themed frame + a slot row.",
@@ -25,7 +27,7 @@ export const PREVIEW_FIXTURES = {
25
27
  },
26
28
  BarsPreview: {
27
29
  name: "BarsPreview",
28
- description: "Every atomic vitals bar rendered twice under different BarTokens blocks.",
30
+ description: "Atomic vitals under BarTokens blocks, explicit fills, skins, and nested theme resets.",
29
31
  component: BarsPreview,
30
32
  },
31
33
  IconsPreview: {
@@ -0,0 +1,18 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /** Props for caller-composed horizontally scrolling controls. */
3
+ export interface ScrollRailProps {
4
+ children: ReactNode;
5
+ /** Accessible name for the rail and its overflow navigation. */
6
+ label: string;
7
+ className?: string;
8
+ style?: CSSProperties;
9
+ /** Spacing between caller-owned items. Default 6 pixels. */
10
+ gap?: number;
11
+ }
12
+ /**
13
+ * Native horizontal scrolling with overflow-only navigation and automatic focus reveal.
14
+ * Callers own item markup, selection, keyboard semantics, and skin through HudTheme or style.
15
+ *
16
+ * @capability scroll-rail reachable horizontal controls with visible overflow navigation and keyboard focus reveal
17
+ */
18
+ export declare function ScrollRail({ children, label, className, style, gap }: ScrollRailProps): import("react").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ /**
4
+ * Native horizontal scrolling with overflow-only navigation and automatic focus reveal.
5
+ * Callers own item markup, selection, keyboard semantics, and skin through HudTheme or style.
6
+ *
7
+ * @capability scroll-rail reachable horizontal controls with visible overflow navigation and keyboard focus reveal
8
+ */
9
+ export function ScrollRail({ children, label, className, style, gap = 6 }) {
10
+ const id = useId();
11
+ const ref = useRef(null);
12
+ const [edges, setEdges] = useState({ start: true, end: true });
13
+ const measure = () => {
14
+ const rail = ref.current;
15
+ if (rail === null)
16
+ return;
17
+ const next = { start: rail.scrollLeft <= 1, end: rail.scrollWidth - rail.clientWidth - rail.scrollLeft <= 1 };
18
+ setEdges(current => current.start === next.start && current.end === next.end ? current : next);
19
+ };
20
+ useEffect(() => {
21
+ const rail = ref.current;
22
+ if (rail === null)
23
+ return;
24
+ const observer = new ResizeObserver(measure);
25
+ observer.observe(rail);
26
+ for (const child of rail.children)
27
+ observer.observe(child);
28
+ measure();
29
+ return () => observer.disconnect();
30
+ }, [children]);
31
+ const scroll = (direction) => {
32
+ const rail = ref.current;
33
+ if (rail === null)
34
+ return;
35
+ rail.scrollBy({ left: direction * Math.max(44, rail.clientWidth * 0.8), behavior: "instant" });
36
+ measure();
37
+ };
38
+ const overflow = !edges.start || !edges.end;
39
+ const buttonStyle = {
40
+ flex: "0 0 44px", minHeight: 44, border: "1px solid var(--jg-edge, #454550)",
41
+ borderRadius: "var(--jg-slot-radius, 6px)", background: "var(--jg-surface-deep, #15151d)",
42
+ color: "var(--jg-text, #ececef)", font: "inherit", cursor: "pointer",
43
+ };
44
+ return _jsxs("div", { className: className, "data-scroll-rail": "", style: { display: "flex", minWidth: 0, flexShrink: 0, gap, ...style }, children: [overflow && _jsx("button", { type: "button", "aria-label": `Show earlier ${label}`, "aria-controls": id, disabled: edges.start, onClick: () => scroll(-1), style: { ...buttonStyle, opacity: edges.start ? 0.45 : 1 }, children: "\u2190" }), _jsx("div", { ref: ref, id: id, role: "group", "aria-label": label, onScroll: measure, onFocusCapture: event => {
45
+ const rail = ref.current;
46
+ if (rail === null || event.target === rail)
47
+ return;
48
+ const bounds = rail.getBoundingClientRect(), target = event.target.getBoundingClientRect();
49
+ if (target.left < bounds.left)
50
+ rail.scrollLeft += target.left - bounds.left;
51
+ else if (target.right > bounds.right)
52
+ rail.scrollLeft += target.right - bounds.right;
53
+ measure();
54
+ }, style: { display: "flex", flex: "1 1 auto", minWidth: 0, overflowX: "auto", gap }, children: children }), overflow && _jsx("button", { type: "button", "aria-label": `Show later ${label}`, "aria-controls": id, disabled: edges.end, onClick: () => scroll(1), style: { ...buttonStyle, opacity: edges.end ? 0.45 : 1 }, children: "\u2192" })] });
55
+ }
@@ -0,0 +1,4 @@
1
+ /** Deterministic preview of caller-owned controls in the shared scrolling chrome. @internal */
2
+ export declare function ScrollRailPreview({ className }: {
3
+ className?: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { ScrollRail } from "./scrollRail.js";
4
+ /** Deterministic preview of caller-owned controls in the shared scrolling chrome. @internal */
5
+ export function ScrollRailPreview({ className }) {
6
+ const [selected, setSelected] = useState("Game");
7
+ return _jsxs("div", { className: className, style: { padding: 24, boxSizing: "border-box", background: "#18252b", color: "#f2e6c9", font: "16px system-ui" }, children: [_jsx("h1", { style: { fontSize: 20 }, children: "Scrolling controls" }), _jsx("div", { style: { width: "min(100%, 340px)" }, children: _jsx(ScrollRail, { label: "settings categories", children: ["Game", "Graphics", "Audio", "Accessibility", "Controls"].map(label => _jsx("button", { type: "button", onClick: () => setSelected(label), "aria-pressed": selected === label, style: { flexShrink: 0, minHeight: 44, padding: "0 16px", font: "inherit", color: "inherit", border: "1px solid #b2a27e", borderRadius: 2, background: selected === label ? "#526951" : "#20363c" }, children: label }, label)) }) }), _jsxs("p", { "aria-live": "polite", children: ["Selected: ", selected] })] });
8
+ }
@@ -1,4 +1,4 @@
1
- import type { GameRuntimeTransport } from "@jgengine/core/runtime/transport";
1
+ import { type GameRuntimeTransport } from "@jgengine/core/runtime/transport";
2
2
  /**
3
3
  * Joins a host and exposes a retryable blocking state until membership is confirmed.
4
4
  * @capability server-session join a multiplayer host with status, retry, and teardown
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { createTransportSessionId } from "@jgengine/core/runtime/transport";
2
3
  /**
3
4
  * Joins a host and exposes a retryable blocking state until membership is confirmed.
4
5
  * @capability server-session join a multiplayer host with status, retry, and teardown
@@ -10,15 +11,16 @@ export function useServerSession(transport, gameId, preferredServerId) {
10
11
  useEffect(() => {
11
12
  let disposed = false;
12
13
  let serverId = null;
14
+ const sessionId = createTransportSessionId();
13
15
  setSession({ serverId: null, status: "joining", failureReason: null });
14
- void transport.joinServer({ gameId, serverId: preferredServerId }).then((result) => {
16
+ void transport.joinServer({ gameId, serverId: preferredServerId, sessionId }).then((result) => {
15
17
  if (!result.ok) {
16
18
  if (!disposed)
17
19
  setSession({ serverId: null, status: "failed", failureReason: result.reason });
18
20
  return;
19
21
  }
20
22
  if (disposed) {
21
- void transport.leaveServer({ serverId: result.serverId }).catch(() => undefined);
23
+ void transport.leaveServer({ serverId: result.serverId, sessionId }).catch(() => undefined);
22
24
  return;
23
25
  }
24
26
  serverId = result.serverId;
@@ -30,7 +32,7 @@ export function useServerSession(transport, gameId, preferredServerId) {
30
32
  return () => {
31
33
  disposed = true;
32
34
  if (serverId !== null)
33
- void transport.leaveServer({ serverId }).catch(() => undefined);
35
+ void transport.leaveServer({ serverId, sessionId }).catch(() => undefined);
34
36
  };
35
37
  }, [transport, gameId, preferredServerId, attempt]);
36
38
  return { ...session, retry };