@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/actionHud.js CHANGED
@@ -193,7 +193,7 @@ export function ActionButton({ action, focused = false, hovered = false, size =
193
193
  export function ActionBarChrome({ model, layout = "grid", itemSize = 52, gap = 6, ariaLabel = "Actions", renderItem, className, style, }) {
194
194
  const refs = useRef(new Map());
195
195
  const { actions, focusedId, hoveredId, columns } = model;
196
- const rovingId = focusedId ?? actions[0]?.id ?? null;
196
+ const rovingId = actions.some(action => action.id === focusedId) ? focusedId : actions[0]?.id ?? null;
197
197
  useEffect(() => {
198
198
  if (focusedId === null)
199
199
  return;
package/dist/bars.d.ts CHANGED
@@ -49,7 +49,7 @@ export type BarShape = "rect" | "pill" | "skew" | "chamfer";
49
49
  export interface AtomicBarProps {
50
50
  /** Trough shape language. Default `rect`. */
51
51
  shape?: BarShape;
52
- /** Explicit fill color, overriding the token (and the low-threshold swap) — for per-instance
52
+ /** Explicit fill color, overriding the skin, token, and low-threshold swap — for per-instance
53
53
  * colors a token can't express (e.g. a class-colored resource bar). Caller overrides win. */
54
54
  fill?: string;
55
55
  /** Explicit current value; when set the bar is pure and needs no game provider. */
package/dist/bars.js CHANGED
@@ -96,7 +96,7 @@ function BarView({ fraction, valueText, config, label, width = 200, segments, en
96
96
  position: "absolute",
97
97
  inset: 0,
98
98
  width: `${fraction * 100}%`,
99
- background: "var(--jg-bar-skin-fill, " + fill + ")",
99
+ background: fillOverride ?? `var(--jg-bar-skin-fill, ${fill})`,
100
100
  transition: "width 160ms ease",
101
101
  } }), _jsx("div", { style: {
102
102
  position: "absolute",
@@ -1,4 +1,4 @@
1
- /** Renders the atomic bar matrix twice under different token blocks to prove global re-theming. */
1
+ /** Renders atomic bars under token blocks and nested themes to prove fill and skin precedence. */
2
2
  export declare function BarsPreview({ className }: {
3
3
  className?: string;
4
4
  }): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "react";
3
3
  import { AmmoCounter, barTokens, BossBar, ExperienceBar, HealthBar, ManaBar, ShieldBar, SoulBar, StaminaBar, } from "./bars.js";
4
+ import { hudTheme, hudThemeVars } from "./hudTheme.js";
4
5
  /**
5
6
  * A deterministic preview of every atomic vitals bar plus a composed example — the fixture behind
6
7
  * #1033's visual evidence. All values are static (no game provider), so it renders identically every
@@ -29,7 +30,13 @@ function Portrait({ color }) {
29
30
  function AtomicMatrix({ label, tokens }) {
30
31
  return (_jsxs("div", { style: { ...PANEL, ...(tokens === undefined ? {} : barTokens(tokens)) }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: "rgba(255,255,255,0.6)" }, children: label }), _jsx(HealthBar, { value: 82, max: 100, label: "HP" }), _jsx(HealthBar, { value: 14, max: 100, label: "HP" }), _jsx(ShieldBar, { value: 40, max: 60, label: "SH" }), _jsx(ManaBar, { value: 55, max: 80, label: "MP" }), _jsx(StaminaBar, { value: 90, max: 100, label: "ST" }), _jsx(ExperienceBar, { value: 3, max: 10, segments: 10, showValue: false, label: "XP" }), _jsx(SoulBar, { value: 3, max: 5, segments: 5, showValue: false, label: "SOUL" }), _jsx(AmmoCounter, { loaded: 17, reserve: 90, magazine: 30 }), _jsx(BossBar, { value: 640, max: 1000, name: "Ancient Wyrm", width: "100%" }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 4 }, children: [_jsx(Portrait, { color: tokens?.health ?? "#e5484d" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4, flex: "1 1 auto" }, children: [_jsx(HealthBar, { value: 82, max: 100, width: "100%", showValue: false }), _jsx(ShieldBar, { value: 40, max: 60, width: "100%", showValue: false })] })] })] }));
31
32
  }
32
- /** Renders the atomic bar matrix twice under different token blocks to prove global re-theming. */
33
+ function ThemeFills() {
34
+ const themed = hudTheme({ palette: { health: "#7fb06d", healthLow: "#d89b38" } });
35
+ const skinned = hudTheme({ skin: { bar: { fill: "linear-gradient(90deg, #136fbc, #68c9ee)", track: "none" } } });
36
+ const nestedSkin = hudTheme({ skin: { bar: { fill: "#a855f7", track: "none" } } });
37
+ return (_jsxs("div", { style: { ...PANEL, ...hudThemeVars(themed) }, children: [_jsx("span", { children: "Theme fill precedence" }), _jsx(HealthBar, { value: 270, max: 270, fill: "#7fb06d", label: "Explicit" }), _jsx(HealthBar, { value: 80, max: 100, label: "Palette" }), _jsx(HealthBar, { value: 15, max: 100, label: "Low palette" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, ...hudThemeVars(skinned) }, children: [_jsx(HealthBar, { value: 80, max: 100, label: "Skin" }), _jsx(HealthBar, { value: 80, max: 100, fill: "#7fb06d", label: "Explicit over skin" }), _jsx("div", { style: hudThemeVars(themed), children: _jsx(HealthBar, { value: 80, max: 100, label: "Nested reset" }) }), _jsx("div", { style: hudThemeVars(nestedSkin), children: _jsx(HealthBar, { value: 80, max: 100, label: "Nested skin" }) })] })] }));
38
+ }
39
+ /** Renders atomic bars under token blocks and nested themes to prove fill and skin precedence. */
33
40
  export function BarsPreview({ className }) {
34
41
  return (_jsxs("div", { "data-bars-preview": true, className: className, style: { display: "flex", gap: 16, flexWrap: "wrap", padding: 20, fontFamily: "system-ui, sans-serif", color: "#f5f7fa" }, children: [_jsx(AtomicMatrix, { label: "default tokens" }), _jsx(AtomicMatrix, { label: "restyled tokens", tokens: {
35
42
  health: "#2fbf71",
@@ -43,5 +50,5 @@ export function BarsPreview({ className }) {
43
50
  frame: "#0a0a0a",
44
51
  radius: "2px",
45
52
  height: "20px",
46
- } })] }));
53
+ } }), _jsx(ThemeFills, {})] }));
47
54
  }
@@ -0,0 +1,26 @@
1
+ /** Browser events that can interrupt active play. The caller decides whether to pause. */
2
+ export type BrowserSuspensionReason = "blur" | "hidden" | "pointer-lock-lost";
3
+ /** Select suspension events; all are observed by default. No callback runs on attachment. */
4
+ export interface BrowserSuspensionOptions {
5
+ blur?: boolean;
6
+ hidden?: boolean;
7
+ pointerLockLost?: boolean;
8
+ }
9
+ /** Event sources for a browser window, an iframe, or a test. */
10
+ export interface BrowserSuspensionSources {
11
+ window: Pick<EventTarget, "addEventListener" | "removeEventListener">;
12
+ document: Pick<EventTarget, "addEventListener" | "removeEventListener"> & {
13
+ readonly hidden: boolean;
14
+ readonly pointerLockElement: Element | null;
15
+ };
16
+ }
17
+ /**
18
+ * Observe interruption events until detached. Lock loss requires a preceding lock; SSR attaches nothing.
19
+ * @capability browser-suspension-observer observe browser interruptions outside React with caller-owned pause policy and event sources
20
+ */
21
+ export declare function observeBrowserSuspension(onSuspend: (reason: BrowserSuspensionReason) => void, options?: BrowserSuspensionOptions, sources?: BrowserSuspensionSources | null): () => void;
22
+ /**
23
+ * Observe browser interruptions while mounted, invoking the latest callback without replacing listeners.
24
+ * @capability browser-suspension observe blur, hidden-page and pointer-lock loss while the game owns pause and input policy
25
+ */
26
+ export declare function useBrowserSuspension(onSuspend: (reason: BrowserSuspensionReason) => void, options?: BrowserSuspensionOptions): void;
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from "react";
2
+ /**
3
+ * Observe interruption events until detached. Lock loss requires a preceding lock; SSR attaches nothing.
4
+ * @capability browser-suspension-observer observe browser interruptions outside React with caller-owned pause policy and event sources
5
+ */
6
+ export function observeBrowserSuspension(onSuspend, options = {}, sources = typeof window === "undefined" || typeof document === "undefined" ? null : { window, document }) {
7
+ if (sources === null)
8
+ return () => { };
9
+ const { window: win, document: doc } = sources;
10
+ let locked = doc.pointerLockElement !== null;
11
+ const blur = () => onSuspend("blur");
12
+ const visibility = () => { if (doc.hidden)
13
+ onSuspend("hidden"); };
14
+ const lock = () => {
15
+ const next = doc.pointerLockElement !== null;
16
+ const lost = locked && !next;
17
+ locked = next;
18
+ if (lost)
19
+ onSuspend("pointer-lock-lost");
20
+ };
21
+ if (options.blur !== false)
22
+ win.addEventListener("blur", blur);
23
+ if (options.hidden !== false)
24
+ doc.addEventListener("visibilitychange", visibility);
25
+ if (options.pointerLockLost !== false)
26
+ doc.addEventListener("pointerlockchange", lock);
27
+ return () => {
28
+ win.removeEventListener("blur", blur);
29
+ doc.removeEventListener("visibilitychange", visibility);
30
+ doc.removeEventListener("pointerlockchange", lock);
31
+ };
32
+ }
33
+ /**
34
+ * Observe browser interruptions while mounted, invoking the latest callback without replacing listeners.
35
+ * @capability browser-suspension observe blur, hidden-page and pointer-lock loss while the game owns pause and input policy
36
+ */
37
+ export function useBrowserSuspension(onSuspend, options = {}) {
38
+ const callback = useRef(onSuspend);
39
+ useEffect(() => { callback.current = onSuspend; }, [onSuspend]);
40
+ const { blur, hidden, pointerLockLost } = options;
41
+ useEffect(() => observeBrowserSuspension((reason) => callback.current(reason), { blur, hidden, pointerLockLost }), [blur, hidden, pointerLockLost]);
42
+ }
@@ -0,0 +1,22 @@
1
+ import { type RefObject } from "react";
2
+ /** Options for controlled dialog focus behavior; callers own markup, state, and close policy. */
3
+ export interface DialogBehaviorOptions {
4
+ /** Whether the dialog is mounted and open. */
5
+ open: boolean;
6
+ /** Escape requests a close; leaving `open` true keeps the dialog active. Omit to disable Escape. */
7
+ onClose?: () => void;
8
+ /** Preferred initial focus, falling back to the first available control or the dialog root. */
9
+ initialFocus?: "root" | (() => HTMLElement | null);
10
+ /** Refocus when the content identity changes, without replacing the original return target. */
11
+ focusKey?: unknown;
12
+ }
13
+ /**
14
+ * Focus entry, Tab trapping, Escape requests, and focus restoration for a caller-owned dialog.
15
+ * Attach the returned ref to the dialog root and supply `role="dialog"`, `aria-modal`, an accessible
16
+ * name, and `tabIndex={-1}`. The most recently opened dialog handles keys, including portal roots.
17
+ * Closing an inner dialog restores its opener; cleanup never steals focus from another active dialog.
18
+ * Replacing the root while open keeps its position and original return target.
19
+ *
20
+ * @capability dialog-behavior controlled dialog focus and keyboard behavior without owning markup or screen state
21
+ */
22
+ export declare function useDialogBehavior<T extends HTMLElement = HTMLDivElement>(options: DialogBehaviorOptions): RefObject<T | null>;
@@ -0,0 +1,109 @@
1
+ import { useEffect, useRef } from "react";
2
+ const owners = new WeakMap();
3
+ function focusableWithin(root) {
4
+ return Array.from(root.querySelectorAll('a[href],button,textarea,input,select,[tabindex]')).filter((element) => {
5
+ if (element.tabIndex < 0 || element.matches(":disabled") || element.closest("[hidden],[inert]"))
6
+ return false;
7
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
8
+ return style?.visibility !== "hidden" && style?.visibility !== "collapse" && element.getClientRects().length > 0;
9
+ });
10
+ }
11
+ function focusInitial(root, options) {
12
+ const controls = focusableWithin(root);
13
+ const preferred = options.initialFocus === "root" ? root : options.initialFocus?.();
14
+ (preferred !== null && preferred !== undefined && (preferred === root || controls.includes(preferred))
15
+ ? preferred : controls[0] ?? root).focus();
16
+ }
17
+ /**
18
+ * Focus entry, Tab trapping, Escape requests, and focus restoration for a caller-owned dialog.
19
+ * Attach the returned ref to the dialog root and supply `role="dialog"`, `aria-modal`, an accessible
20
+ * name, and `tabIndex={-1}`. The most recently opened dialog handles keys, including portal roots.
21
+ * Closing an inner dialog restores its opener; cleanup never steals focus from another active dialog.
22
+ * Replacing the root while open keeps its position and original return target.
23
+ *
24
+ * @capability dialog-behavior controlled dialog focus and keyboard behavior without owning markup or screen state
25
+ */
26
+ export function useDialogBehavior(options) {
27
+ const ref = useRef(null);
28
+ const activeOwner = useRef(null);
29
+ const latest = useRef(options);
30
+ latest.current = options;
31
+ useEffect(() => {
32
+ const root = ref.current;
33
+ if (!options.open || root === null)
34
+ return;
35
+ const document = root.ownerDocument;
36
+ const stack = owners.get(document) ?? [];
37
+ owners.set(document, stack);
38
+ const owner = { root, restore: document.activeElement };
39
+ activeOwner.current = owner;
40
+ const child = stack.findIndex((entry) => root.contains(entry.root));
41
+ if (child < 0)
42
+ stack.push(owner);
43
+ else
44
+ stack.splice(child, 0, owner);
45
+ const keydown = (event) => {
46
+ if (stack.at(-1) !== owner || event.defaultPrevented)
47
+ return;
48
+ if (event.key === "Escape" && latest.current.onClose !== undefined) {
49
+ event.preventDefault();
50
+ event.stopPropagation();
51
+ latest.current.onClose();
52
+ return;
53
+ }
54
+ if (event.key !== "Tab")
55
+ return;
56
+ const root = owner.root;
57
+ const controls = focusableWithin(root);
58
+ const first = controls[0], last = controls.at(-1);
59
+ const active = document.activeElement;
60
+ if (first === undefined || !root.contains(active) || active === root ||
61
+ (event.shiftKey ? active === first : active === last)) {
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ (event.shiftKey ? last : first)?.focus();
65
+ if (first === undefined)
66
+ root.focus();
67
+ }
68
+ };
69
+ document.addEventListener("keydown", keydown, true);
70
+ return () => {
71
+ document.removeEventListener("keydown", keydown, true);
72
+ const wasTop = stack.at(-1) === owner;
73
+ const index = stack.indexOf(owner);
74
+ if (index >= 0)
75
+ stack.splice(index, 1);
76
+ for (const entry of stack) {
77
+ if (entry.restore !== null && owner.root.contains(entry.restore))
78
+ entry.restore = owner.restore;
79
+ }
80
+ if (wasTop && owner.restore?.isConnected && !owner.restore.closest("[hidden],[inert]")) {
81
+ owner.restore.focus();
82
+ }
83
+ if (stack.length === 0)
84
+ owners.delete(document);
85
+ activeOwner.current = null;
86
+ };
87
+ }, [options.open]);
88
+ useEffect(() => {
89
+ const root = ref.current;
90
+ const owner = activeOwner.current;
91
+ if (!options.open || root === null || owner === null || owner.root === root)
92
+ return;
93
+ const stack = owners.get(owner.root.ownerDocument);
94
+ for (const entry of stack ?? []) {
95
+ if (entry !== owner && entry.restore !== null && owner.root.contains(entry.restore))
96
+ entry.restore = root;
97
+ }
98
+ owner.root = root;
99
+ if (stack?.at(-1) === owner)
100
+ focusInitial(root, latest.current);
101
+ });
102
+ useEffect(() => {
103
+ const root = ref.current;
104
+ if (!options.open || root === null || owners.get(root.ownerDocument)?.at(-1)?.root !== root)
105
+ return;
106
+ focusInitial(root, latest.current);
107
+ }, [options.open, options.focusKey]);
108
+ return ref;
109
+ }
@@ -45,6 +45,13 @@ export interface DragLayer<T> {
45
45
  cancel(): void;
46
46
  attachGhost(element: HTMLElement | null): void;
47
47
  }
48
+ /**
49
+ * Pointer-owned drag state shared by cards and inventory grids. Registered DropZones are hit-tested
50
+ * through pointer capture; release finishes once, while cancellation, blur and unmount release ownership.
51
+ * Callers own drop policy, rotation and presentation; pointer movement paints the ghost without rerendering it.
52
+ *
53
+ * @capability drag-layer composable pointer-owned drag, drop targets and cancellation for custom cards and inventory UI
54
+ */
48
55
  export declare function useDragLayer<T>(options?: {
49
56
  onDrop?: (info: DropInfo<T>) => void;
50
57
  }): DragLayer<T>;
package/dist/dragLayer.js CHANGED
@@ -2,25 +2,41 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { cellFromPoint, } from "@jgengine/core/inventory/shapedGrid";
4
4
  const EMPTY_POINT = { x: 0, y: 0 };
5
+ const targetRegistries = new WeakMap();
6
+ /**
7
+ * Pointer-owned drag state shared by cards and inventory grids. Registered DropZones are hit-tested
8
+ * through pointer capture; release finishes once, while cancellation, blur and unmount release ownership.
9
+ * Callers own drop policy, rotation and presentation; pointer movement paints the ghost without rerendering it.
10
+ *
11
+ * @capability drag-layer composable pointer-owned drag, drop targets and cancellation for custom cards and inventory UI
12
+ */
5
13
  export function useDragLayer(options) {
6
14
  const [state, setState] = useState({
7
- payload: null,
8
- point: EMPTY_POINT,
9
- origin: EMPTY_POINT,
10
- overTarget: null,
11
- overCell: null,
15
+ payload: null, point: EMPTY_POINT, origin: EMPTY_POINT, overTarget: null, overCell: null,
12
16
  });
13
17
  const pointRef = useRef(EMPTY_POINT);
14
18
  const ghostRef = useRef(null);
15
- const moveRef = useRef(null);
16
19
  const payloadRef = useRef(null);
17
20
  const overRef = useRef({ target: null, cell: null });
21
+ const overElement = useRef(null);
22
+ const targets = useRef(new Map());
23
+ const ownerRef = useRef(null);
24
+ const latest = useRef(options);
25
+ latest.current = options;
18
26
  const detach = useCallback(() => {
19
- if (moveRef.current !== null) {
20
- window.removeEventListener("pointermove", moveRef.current);
21
- moveRef.current = null;
22
- }
27
+ const owner = ownerRef.current;
28
+ ownerRef.current = null;
29
+ if (owner === null)
30
+ return;
31
+ owner.detach();
32
+ if (owner.source.hasPointerCapture(owner.pointerId))
33
+ owner.source.releasePointerCapture(owner.pointerId);
23
34
  }, []);
35
+ useEffect(() => () => {
36
+ detach();
37
+ payloadRef.current = null;
38
+ ghostRef.current = null;
39
+ }, [detach]);
24
40
  const writeGhost = useCallback((point, rotation) => {
25
41
  const el = ghostRef.current;
26
42
  if (el === null)
@@ -29,91 +45,139 @@ export function useDragLayer(options) {
29
45
  el.style.top = `${point.y}px`;
30
46
  el.style.transform = `translate(-50%, -50%) rotate(${rotation * 90}deg)`;
31
47
  }, []);
32
- const beginDrag = useCallback((payload, event) => {
33
- const point = { x: event.clientX, y: event.clientY };
34
- const nextPayload = { id: payload.id, value: payload.value, rotation: payload.rotation ?? 0 };
35
- payloadRef.current = nextPayload;
36
- overRef.current = { target: null, cell: null };
37
- pointRef.current = point;
38
- setState({
39
- payload: nextPayload,
40
- point,
41
- origin: point,
42
- overTarget: null,
43
- overCell: null,
44
- });
45
- writeGhost(point, nextPayload.rotation);
46
- const onMove = (moveEvent) => {
47
- if (payloadRef.current === null)
48
- return;
49
- const next = { x: moveEvent.clientX, y: moveEvent.clientY };
50
- pointRef.current = next;
51
- writeGhost(next, payloadRef.current.rotation);
52
- };
53
- detach();
54
- moveRef.current = onMove;
55
- window.addEventListener("pointermove", onMove);
56
- }, [detach, writeGhost]);
57
- const rotate = useCallback((quarterTurns = 1) => {
58
- setState((prev) => {
59
- if (prev.payload === null)
60
- return prev;
61
- const rotation = (((prev.payload.rotation + quarterTurns) % 4) + 4) % 4;
62
- const payload = { ...prev.payload, rotation };
63
- payloadRef.current = payload;
64
- writeGhost(pointRef.current, rotation);
65
- return { ...prev, payload, point: pointRef.current };
66
- });
67
- }, [writeGhost]);
68
48
  const setTarget = useCallback((target, cell = null) => {
49
+ const previous = overRef.current;
50
+ if (previous.target === target && previous.cell?.[0] === cell?.[0] && previous.cell?.[1] === cell?.[1])
51
+ return;
69
52
  overRef.current = { target, cell };
70
53
  setState((prev) => ({ ...prev, overTarget: target, overCell: cell, point: pointRef.current }));
71
54
  }, []);
55
+ const resolveTarget = useCallback((document, point) => {
56
+ if (targets.current.size === 0)
57
+ return;
58
+ let element = document.elementFromPoint(point.x, point.y);
59
+ while (element !== null) {
60
+ const target = targets.current.get(element);
61
+ if (target !== undefined) {
62
+ overElement.current = element;
63
+ const rect = element.getBoundingClientRect();
64
+ setTarget(target.id, target.cellSize === undefined ? null
65
+ : cellFromPoint({ x: point.x - rect.left, y: point.y - rect.top }, target.cellSize));
66
+ return;
67
+ }
68
+ element = element.parentElement;
69
+ }
70
+ overElement.current = null;
71
+ setTarget(null);
72
+ }, [setTarget]);
72
73
  const reset = useCallback(() => {
73
74
  detach();
74
75
  payloadRef.current = null;
75
76
  overRef.current = { target: null, cell: null };
77
+ overElement.current = null;
76
78
  pointRef.current = EMPTY_POINT;
77
- setState({
78
- payload: null,
79
- point: EMPTY_POINT,
80
- origin: EMPTY_POINT,
81
- overTarget: null,
82
- overCell: null,
83
- });
79
+ setState({ payload: null, point: EMPTY_POINT, origin: EMPTY_POINT, overTarget: null, overCell: null });
84
80
  }, [detach]);
85
81
  const endDrag = useCallback(() => {
86
82
  const payload = payloadRef.current;
87
- let info = null;
88
- if (payload !== null) {
89
- info = {
90
- payload,
91
- target: overRef.current.target,
92
- cell: overRef.current.cell,
93
- point: pointRef.current,
94
- };
95
- options?.onDrop?.(info);
96
- }
83
+ if (payload === null)
84
+ return null;
85
+ const info = {
86
+ payload, target: overRef.current.target, cell: overRef.current.cell, point: pointRef.current,
87
+ };
97
88
  reset();
89
+ latest.current?.onDrop?.(info);
98
90
  return info;
99
- }, [options, reset]);
91
+ }, [reset]);
92
+ const beginDrag = useCallback((payload, event) => {
93
+ if (event.button !== 0 || !event.isPrimary || payloadRef.current !== null)
94
+ return;
95
+ const source = event.currentTarget;
96
+ const view = source.ownerDocument.defaultView;
97
+ if (view === null)
98
+ return;
99
+ const point = { x: event.clientX, y: event.clientY };
100
+ const nextPayload = { id: payload.id, value: payload.value, rotation: payload.rotation ?? 0 };
101
+ payloadRef.current = nextPayload;
102
+ overRef.current = { target: null, cell: null };
103
+ pointRef.current = point;
104
+ setState({ payload: nextPayload, point, origin: point, overTarget: null, overCell: null });
105
+ writeGhost(point, nextPayload.rotation);
106
+ resolveTarget(source.ownerDocument, point);
107
+ const onMove = (move) => {
108
+ if (move.pointerId !== ownerRef.current?.pointerId || payloadRef.current === null)
109
+ return;
110
+ if (!source.isConnected) {
111
+ reset();
112
+ return;
113
+ }
114
+ const next = { x: move.clientX, y: move.clientY };
115
+ pointRef.current = next;
116
+ writeGhost(next, payloadRef.current.rotation);
117
+ resolveTarget(source.ownerDocument, next);
118
+ };
119
+ const onUp = (up) => {
120
+ if (up.pointerId !== ownerRef.current?.pointerId || up.button !== 0)
121
+ return;
122
+ if (!source.isConnected) {
123
+ reset();
124
+ return;
125
+ }
126
+ pointRef.current = { x: up.clientX, y: up.clientY };
127
+ resolveTarget(source.ownerDocument, pointRef.current);
128
+ endDrag();
129
+ };
130
+ const onCancel = (cancel) => { if (cancel.pointerId === ownerRef.current?.pointerId)
131
+ reset(); };
132
+ const onKey = (key) => {
133
+ if (key.key !== "Escape" || key.defaultPrevented)
134
+ return;
135
+ key.preventDefault();
136
+ key.stopPropagation();
137
+ reset();
138
+ };
139
+ ownerRef.current = { source, pointerId: event.pointerId, detach: () => {
140
+ view.removeEventListener("pointermove", onMove, true);
141
+ view.removeEventListener("pointerup", onUp, true);
142
+ view.removeEventListener("pointercancel", onCancel, true);
143
+ view.removeEventListener("lostpointercapture", onCancel, true);
144
+ view.removeEventListener("blur", reset);
145
+ view.removeEventListener("keydown", onKey, true);
146
+ } };
147
+ view.addEventListener("pointermove", onMove, true);
148
+ view.addEventListener("pointerup", onUp, true);
149
+ view.addEventListener("pointercancel", onCancel, true);
150
+ view.addEventListener("lostpointercapture", onCancel, true);
151
+ view.addEventListener("blur", reset);
152
+ view.addEventListener("keydown", onKey, true);
153
+ source.setPointerCapture(event.pointerId);
154
+ }, [endDrag, reset, resolveTarget, writeGhost]);
155
+ targetRegistries.set(beginDrag, (element, id, cellSize) => {
156
+ targets.current.set(element, { id, cellSize });
157
+ return () => {
158
+ targets.current.delete(element);
159
+ if (overElement.current === element) {
160
+ overElement.current = null;
161
+ setTarget(null);
162
+ }
163
+ };
164
+ });
165
+ const rotate = useCallback((quarterTurns = 1) => {
166
+ const payload = payloadRef.current;
167
+ if (payload === null)
168
+ return;
169
+ const rotation = (((payload.rotation + quarterTurns) % 4) + 4) % 4;
170
+ const next = { ...payload, rotation };
171
+ payloadRef.current = next;
172
+ writeGhost(pointRef.current, rotation);
173
+ setState((prev) => ({ ...prev, payload: next, point: pointRef.current }));
174
+ }, [writeGhost]);
100
175
  const attachGhost = useCallback((element) => {
101
176
  ghostRef.current = element;
102
- if (element !== null && payloadRef.current !== null) {
177
+ if (element !== null && payloadRef.current !== null)
103
178
  writeGhost(pointRef.current, payloadRef.current.rotation);
104
- }
105
179
  }, [writeGhost]);
106
- return {
107
- state,
108
- dragging: state.payload !== null,
109
- pointRef,
110
- beginDrag,
111
- rotate,
112
- setTarget,
113
- endDrag,
114
- cancel: reset,
115
- attachGhost,
116
- };
180
+ return { state, dragging: state.payload !== null, pointRef, beginDrag, rotate, setTarget, endDrag, cancel: reset, attachGhost };
117
181
  }
118
182
  export function DragGhost({ layer, className, style, children, }) {
119
183
  const ref = useRef(null);
@@ -136,12 +200,17 @@ export function DragGhost({ layer, className, style, children, }) {
136
200
  }
137
201
  export function DraggableCard({ id, value, layer, className, children, onRotate, }) {
138
202
  const isDragging = layer.state.payload?.id === id;
139
- return (_jsx("div", { className: className, "data-card": id, "data-dragging": isDragging ? "" : undefined, onPointerDown: (event) => {
203
+ const latest = useRef(layer);
204
+ latest.current = layer;
205
+ useEffect(() => () => {
206
+ if (latest.current.state.payload?.id === id)
207
+ latest.current.cancel();
208
+ }, [id, layer.cancel]);
209
+ return (_jsx("div", { className: className, "data-card": id, "data-dragging": isDragging ? "" : undefined, style: { touchAction: "none" }, onPointerDown: (event) => {
210
+ if (event.button !== 0 || !event.isPrimary)
211
+ return;
140
212
  event.preventDefault();
141
213
  layer.beginDrag({ id, value }, event);
142
- }, onPointerUp: () => {
143
- if (layer.dragging)
144
- layer.endDrag();
145
214
  }, onContextMenu: (event) => {
146
215
  if (onRotate === false)
147
216
  return;
@@ -153,24 +222,11 @@ export function DraggableCard({ id, value, layer, className, children, onRotate,
153
222
  export function DropZone({ id, layer, className, activeClassName, cellSize, children, }) {
154
223
  const ref = useRef(null);
155
224
  const active = layer.state.overTarget === id && layer.dragging;
156
- const resolveCell = useCallback((event) => {
157
- if (cellSize === undefined || ref.current === null)
158
- return null;
159
- const rect = ref.current.getBoundingClientRect();
160
- return cellFromPoint({ x: event.clientX - rect.left, y: event.clientY - rect.top }, cellSize);
161
- }, [cellSize]);
225
+ useEffect(() => {
226
+ const element = ref.current;
227
+ if (element !== null)
228
+ return targetRegistries.get(layer.beginDrag)?.(element, id, cellSize);
229
+ }, [layer.beginDrag, id, cellSize]);
162
230
  const composed = useMemo(() => [className, active ? activeClassName : undefined].filter(Boolean).join(" ") || undefined, [className, active, activeClassName]);
163
- return (_jsx("div", { ref: ref, className: composed, "data-dropzone": id, "data-active": active ? "" : undefined, onPointerEnter: (event) => {
164
- if (layer.dragging)
165
- layer.setTarget(id, resolveCell(event));
166
- }, onPointerMove: (event) => {
167
- if (layer.dragging && layer.state.overTarget === id)
168
- layer.setTarget(id, resolveCell(event));
169
- }, onPointerLeave: () => {
170
- if (layer.dragging && layer.state.overTarget === id)
171
- layer.setTarget(null, null);
172
- }, onPointerUp: () => {
173
- if (layer.dragging)
174
- layer.endDrag();
175
- }, children: children }));
231
+ return (_jsx("div", { ref: ref, className: composed, "data-dropzone": id, "data-active": active ? "" : undefined, children: children }));
176
232
  }
package/dist/hooks.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type DependencyList } from "react";
2
2
  import { AxisChannel, type AxisChannelConfig } from "@jgengine/core/input/axisInput";
3
3
  import { type MarkerSet } from "@jgengine/core/world/markers";
4
4
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
5
+ import { type DispatchedCommandResult } from "@jgengine/core/runtime/commandDispatch";
5
6
  import type { AbilityKit, AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
6
7
  import type { EventMeter } from "@jgengine/core/stats/eventMeter";
7
8
  import type { GameEvents } from "@jgengine/core/game/events";
@@ -23,8 +24,11 @@ import { type GamePhase } from "@jgengine/core/game/gamePhase";
23
24
  export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
24
25
  /** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
25
26
  export declare function useGameStoreValue<T>(key: string, fallback: T): T;
27
+ /** UI command surface. Hosted commands return a promise; await it to display acknowledgement or rejection. Registration and runAs remain trusted local APIs. */
26
28
  export declare function useGame(): {
27
- commands: GameContext["game"]["commands"];
29
+ commands: Omit<GameContext["game"]["commands"], "run"> & {
30
+ run(name: string, input: unknown): DispatchedCommandResult;
31
+ };
28
32
  events: GameEvents;
29
33
  };
30
34
  export declare function usePlayer(): {
@@ -53,6 +57,11 @@ export declare function useWorldItems(): readonly WorldItemRecord[];
53
57
  /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
54
58
  export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
55
59
  export declare function useEntityStat(instanceId: string, statId: string): StatValue | null;
60
+ /**
61
+ * The entity `fromInstanceId` currently targets (set by `ctx.scene.entity.setTarget` or `cycleTarget`), for a target frame.
62
+ *
63
+ * @capability current-target read the player's selected target (click or tab target) to drive a target frame
64
+ */
56
65
  export declare function useTarget(fromInstanceId: string): string | null;
57
66
  export declare function useInventory(inventoryId: string): readonly InventorySlot[];
58
67
  /** Live slots plus `move`/`split` actions bound to `inventoryId`, routed through the notifying `inventory.move`/`inventory.split` commands so React re-renders. */