@jgengine/react 0.11.0 → 0.13.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 (53) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/actionHud.d.ts +5 -5
  3. package/dist/actionHud.js +14 -12
  4. package/dist/bars.d.ts +103 -0
  5. package/dist/bars.js +180 -0
  6. package/dist/barsPreview.d.ts +4 -0
  7. package/dist/barsPreview.js +47 -0
  8. package/dist/characterSheet.d.ts +120 -0
  9. package/dist/characterSheet.js +134 -0
  10. package/dist/components.d.ts +8 -1
  11. package/dist/components.js +8 -1
  12. package/dist/hooks.d.ts +31 -0
  13. package/dist/hooks.js +53 -0
  14. package/dist/hud.d.ts +21 -4
  15. package/dist/hud.js +38 -27
  16. package/dist/hudFrame.d.ts +47 -0
  17. package/dist/hudFrame.js +80 -0
  18. package/dist/hudLayout.d.ts +13 -1
  19. package/dist/hudLayout.js +18 -12
  20. package/dist/hudLayoutPersist.d.ts +23 -0
  21. package/dist/hudLayoutPersist.js +19 -0
  22. package/dist/hudTheme.d.ts +76 -0
  23. package/dist/hudTheme.js +156 -0
  24. package/dist/hudThemePreview.d.ts +4 -0
  25. package/dist/hudThemePreview.js +37 -0
  26. package/dist/iconTreatment.d.ts +50 -0
  27. package/dist/iconTreatment.js +84 -0
  28. package/dist/iconsPreview.d.ts +4 -0
  29. package/dist/iconsPreview.js +29 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.js +12 -0
  32. package/dist/inventoryGrid.d.ts +20 -0
  33. package/dist/inventoryGrid.js +135 -0
  34. package/dist/map.d.ts +72 -10
  35. package/dist/map.js +84 -15
  36. package/dist/panels.d.ts +107 -0
  37. package/dist/panels.js +215 -0
  38. package/dist/selectionHud.d.ts +11 -5
  39. package/dist/selectionHud.js +11 -5
  40. package/dist/slots.d.ts +47 -0
  41. package/dist/slots.js +37 -0
  42. package/package.json +2 -2
  43. package/skills/jgengine/SKILL.md +8 -4
  44. package/skills/jgengine/api.md +182 -81
  45. package/skills/jgengine/capabilities.md +104 -2
  46. package/skills/jgengine/recipes/minimal-game.md +88 -0
  47. package/skills/jgengine-ui/SKILL.md +17 -10
  48. package/skills/jgengine-ui/api.md +316 -51
  49. package/skills/jgengine-ui/capabilities.md +135 -2
  50. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  51. package/skills/jgengine-ui/reference.md +59 -9
  52. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  53. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,135 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { moveGridFocus } from "@jgengine/core/ui/actionModel";
4
+ import { DragGhost, DraggableCard, DropZone, useDragLayer } from "./dragLayer.js";
5
+ import { iconForItemId } from "./gameIcons.js";
6
+ import { IconTreatment, schoolForItem } from "./iconTreatment.js";
7
+ import { useInventoryGrid } from "./hooks.js";
8
+ /**
9
+ * A drop-in NxN inventory grid bound to a live inventory: `<InventoryGrid inventoryId="bag" columns={4} />`
10
+ * renders every slot (painted {@link IconTreatment} tile + count badge for filled, a themed `--jg-slot-*`
11
+ * placeholder for empty), and wires the real mechanics on top of the engine's inventory model —
12
+ * drag-to-move, swap, stack-merge with remainder (all via the `inventory.move` command), and stack-split
13
+ * (shift/right-click a stack, via `inventory.split`). Fully keyboard-accessible: `role="grid"`, arrow-key
14
+ * roving focus, Enter/Space to pick up and drop. Games own placement, skin, and terminology; the engine
15
+ * ships the working grid.
16
+ *
17
+ * @capability inventory-grid-hud drop-in NxN inventory grid with drag/swap/stack/split bound to a live inventory
18
+ */
19
+ const ARROW_DIRECTIONS = {
20
+ ArrowRight: "right",
21
+ ArrowLeft: "left",
22
+ ArrowUp: "up",
23
+ ArrowDown: "down",
24
+ Home: "first",
25
+ End: "last",
26
+ };
27
+ function EmptySlot({ size, picked }) {
28
+ return (_jsx("div", { "data-slot-empty": "", style: {
29
+ width: size,
30
+ height: size,
31
+ borderRadius: "var(--jg-slot-radius, 9px)",
32
+ background: "var(--jg-slot-bg, rgba(12,14,20,0.5))",
33
+ border: picked
34
+ ? "1px solid var(--jg-accent, #38bdf8)"
35
+ : "var(--jg-slot-border, 1px solid rgba(255,255,255,0.1))",
36
+ boxShadow: "inset 0 2px 6px rgba(0,0,0,0.45)",
37
+ boxSizing: "border-box",
38
+ } }));
39
+ }
40
+ /** A drop-in inventory grid with drag/swap/stack-merge/split and full keyboard control, bound to `inventoryId`. */
41
+ export function InventoryGrid({ inventoryId, columns, size = 44, gap = 6, itemIcon, onActivate, className, style, }) {
42
+ const { slots, move, split } = useInventoryGrid(inventoryId);
43
+ const cols = columns ?? Math.max(1, Math.ceil(Math.sqrt(slots.length)));
44
+ const [focus, setFocus] = useState(0);
45
+ const [picked, setPicked] = useState(null);
46
+ const cellRefs = useRef(new Map());
47
+ const layer = useDragLayer({
48
+ onDrop: (info) => {
49
+ const target = info.target === null ? null : Number(info.target);
50
+ if (target !== null && Number.isInteger(target) && target !== info.payload.value) {
51
+ move(info.payload.value, target);
52
+ }
53
+ },
54
+ });
55
+ useEffect(() => {
56
+ if (focus < slots.length)
57
+ cellRefs.current.get(focus)?.focus();
58
+ }, [focus, slots.length]);
59
+ const splitHalf = useCallback((index) => {
60
+ const slot = slots[index];
61
+ if (slot !== null && slot !== undefined && slot.count > 1)
62
+ split(index, Math.floor(slot.count / 2));
63
+ }, [slots, split]);
64
+ const onKeyDown = useCallback((event) => {
65
+ const dir = ARROW_DIRECTIONS[event.key];
66
+ if (dir !== undefined) {
67
+ const next = moveGridFocus(focus, slots.length, cols, dir);
68
+ if (next >= 0) {
69
+ event.preventDefault();
70
+ setFocus(next);
71
+ }
72
+ return;
73
+ }
74
+ if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
75
+ event.preventDefault();
76
+ if (picked === null) {
77
+ if (slots[focus] !== null && slots[focus] !== undefined)
78
+ setPicked(focus);
79
+ }
80
+ else {
81
+ if (picked !== focus)
82
+ move(picked, focus);
83
+ setPicked(null);
84
+ }
85
+ return;
86
+ }
87
+ if (event.key === "Escape" && picked !== null) {
88
+ event.preventDefault();
89
+ setPicked(null);
90
+ }
91
+ }, [focus, slots, cols, picked, move]);
92
+ const rows = useMemo(() => {
93
+ const out = [];
94
+ for (let i = 0; i < slots.length; i += cols) {
95
+ out.push(Array.from({ length: Math.min(cols, slots.length - i) }, (_, k) => i + k));
96
+ }
97
+ return out;
98
+ }, [slots.length, cols]);
99
+ const renderTile = (index) => {
100
+ const slot = slots[index] ?? null;
101
+ const isPicked = picked === index;
102
+ const label = slot === null
103
+ ? `Empty slot ${index + 1}`
104
+ : `${slot.itemId}${slot.count > 1 ? ` (${slot.count})` : ""}, slot ${index + 1}`;
105
+ const custom = slot !== null ? itemIcon?.(slot.itemId) : undefined;
106
+ const named = slot !== null ? iconForItemId(slot.itemId) : null;
107
+ return (_jsx(DropZone, { id: String(index), layer: layer, children: _jsx("div", { role: "gridcell", "aria-label": label, "aria-selected": isPicked, tabIndex: focus === index ? 0 : -1, "data-slot": index, ref: (node) => {
108
+ if (node === null)
109
+ cellRefs.current.delete(index);
110
+ else
111
+ cellRefs.current.set(index, node);
112
+ }, onFocus: () => setFocus(index), onKeyDown: onKeyDown, onDoubleClick: () => {
113
+ if (slot !== null)
114
+ onActivate?.(index);
115
+ }, onClick: (event) => {
116
+ if (event.shiftKey) {
117
+ event.preventDefault();
118
+ splitHalf(index);
119
+ }
120
+ }, onContextMenu: (event) => {
121
+ if (slot !== null && slot.count > 1) {
122
+ event.preventDefault();
123
+ splitHalf(index);
124
+ }
125
+ }, style: { width: size, height: size, position: "relative", cursor: slot !== null ? "grab" : "default" }, children: slot !== null ? (_jsx(DraggableCard, { id: String(index), value: index, layer: layer, onRotate: false, children: custom !== undefined && custom !== null ? (_jsx(IconTreatment, { glyph: custom, size: size, count: slot.count, active: isPicked })) : (_jsx(IconTreatment, { ...(named === null ? {} : { icon: named }), school: schoolForItem(slot.itemId), size: size, count: slot.count, active: isPicked })) })) : (_jsx(EmptySlot, { size: size, picked: isPicked })) }) }, index));
126
+ };
127
+ return (_jsxs("div", { role: "grid", "aria-label": `${inventoryId} inventory`, "aria-colcount": cols, className: className, "data-inventory-grid": inventoryId, style: { display: "grid", gap, ...style }, children: [rows.map((row, r) => (_jsx("div", { role: "row", style: { display: "grid", gridTemplateColumns: `repeat(${cols}, ${size}px)`, gap }, children: row.map(renderTile) }, r))), _jsx(DragGhost, { layer: layer, children: (payload) => {
128
+ const slot = slots[payload.value] ?? null;
129
+ if (slot === null)
130
+ return null;
131
+ const named = iconForItemId(slot.itemId);
132
+ const custom = itemIcon?.(slot.itemId);
133
+ return custom !== undefined && custom !== null ? (_jsx(IconTreatment, { glyph: custom, size: size, count: slot.count })) : (_jsx(IconTreatment, { ...(named === null ? {} : { icon: named }), school: schoolForItem(slot.itemId), size: size, count: slot.count }));
134
+ } })] }));
135
+ }
package/dist/map.d.ts CHANGED
@@ -1,12 +1,16 @@
1
- import { type ReactNode } from "react";
1
+ import { type CSSProperties, type ReactNode } from "react";
2
2
  import type { FogField } from "@jgengine/core/world/fog";
3
- import { type MapMarker, type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
3
+ import { type MapMarker, type MarkerCollection, type MarkerKindStyle, type MarkerSource, type MarkerSet, type MarkerView } from "@jgengine/core/world/markers";
4
4
  import { type MinimapView, type WorldXZ } from "@jgengine/core/world/minimap";
5
5
  import { type MapCellStates, type MapRoute, type MapZone } from "@jgengine/core/world/mapLayers";
6
- export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
6
+ /** Subscribe to a native marker set or external marker source, or read a static marker array. */
7
+ export declare function useMarkers<TMeta = unknown>(markers: MarkerSet<TMeta>): readonly MapMarker<TMeta>[];
8
+ export declare function useMarkers<TMarker extends MarkerView>(markers: readonly TMarker[] | MarkerSource<TMarker>): readonly TMarker[];
9
+ export declare function useMarkers(markers: MarkerCollection): readonly MarkerView[];
7
10
  export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
8
11
  export interface MinimapProps {
9
- markers: MarkerSet;
12
+ /** Static views, an external marker source, or a native JGengine MarkerSet. */
13
+ markers: MarkerCollection;
10
14
  center: WorldXZ;
11
15
  worldRadius: number;
12
16
  fog?: FogField;
@@ -27,6 +31,8 @@ export interface MinimapProps {
27
31
  /** Click-to-pin seam (#285.6): receives the clicked world XZ via `unprojectFromMinimap`. */
28
32
  onWorldClick?: (world: WorldXZ) => void;
29
33
  className?: string;
34
+ /** Caller-owned outer chrome overrides, applied after the built-in defaults. */
35
+ style?: CSSProperties;
30
36
  title?: string;
31
37
  children?: ReactNode;
32
38
  }
@@ -38,12 +44,13 @@ export interface MapBounds {
38
44
  }
39
45
  /**
40
46
  * Framed circular minimap: optional baked terrain background, reveal-on-event
41
- * fog overlay, categorized marker icons, and a facing arrow. Reads a core
42
- * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
47
+ * fog overlay, categorized marker icons, and a facing arrow. Reads static
48
+ * marker views, an external marker source, or a native `MarkerSet`; supply
49
+ * your own `kindStyles` palette to reskin. Does not require `GameProvider`.
43
50
  *
44
51
  * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
45
52
  */
46
- export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title, children, }: MinimapProps): ReactNode;
53
+ export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, style, title, children, }: MinimapProps): ReactNode;
47
54
  /** One dot (or, with `heading`, a rotated arrow) drawn by `MinimapChrome`. */
48
55
  export interface MinimapChromeMarker {
49
56
  id: string;
@@ -91,7 +98,7 @@ export interface CompassProps {
91
98
  /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
92
99
  facingYaw: number;
93
100
  center?: WorldXZ;
94
- markers?: MarkerSet;
101
+ markers?: MarkerCollection;
95
102
  width?: number;
96
103
  fov?: number;
97
104
  kindStyles?: Record<string, MarkerKindStyle>;
@@ -99,7 +106,8 @@ export interface CompassProps {
99
106
  }
100
107
  /**
101
108
  * Horizontal compass strip centered on the player's facing direction, with the
102
- * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
109
+ * eight cardinals and optional marker pips from static views, an external
110
+ * source, or a native `MarkerSet`.
103
111
  */
104
112
  export declare function Compass({ facingYaw, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
105
113
  /** Props for `MinimapPanel` — all `MinimapProps` plus the zone-label/clock header and compass slots. */
@@ -125,8 +133,62 @@ export interface MinimapPanelProps extends MinimapProps {
125
133
  * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
126
134
  */
127
135
  export declare function MinimapPanel({ zoneLabel, clock, showCompass, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }: MinimapPanelProps): ReactNode;
136
+ /** A colored span across the {@link MinimapTrack} rail, given by 0..1 `start`/`end` fractions (e.g. from core `trackFraction`). */
137
+ export interface MinimapTrackSpan {
138
+ id: string;
139
+ /** 0..1 fraction where the span starts. */
140
+ start: number;
141
+ /** 0..1 fraction where the span ends. */
142
+ end: number;
143
+ /** Fill color; defaults to a translucent white. */
144
+ color?: string;
145
+ /** Tooltip / accessible label for the span. */
146
+ label?: string;
147
+ }
148
+ /** A point marker on the {@link MinimapTrack} rail at a 0..1 fraction (e.g. a gate, the exit, or the player). */
149
+ export interface MinimapTrackPip {
150
+ id: string;
151
+ /** 0..1 fraction along the rail. */
152
+ at: number;
153
+ /** Marker color; defaults to the rail foreground. */
154
+ color?: string;
155
+ /** Accessible label for the marker. */
156
+ label?: string;
157
+ /** Tooltip text (rendered as the `title` attribute). */
158
+ title?: string;
159
+ /** Silhouette: `gate` (thin tall bar) or a `dot`/`exit`/`player` circle of increasing size (default `dot`). */
160
+ shape?: "gate" | "dot" | "exit" | "player";
161
+ }
162
+ /** Props for {@link MinimapTrack}. */
163
+ export interface MinimapTrackProps {
164
+ /** Colored zone spans painted along the rail. */
165
+ spans?: readonly MinimapTrackSpan[];
166
+ /** Point markers (gates, exit, player) placed by 0..1 fraction. */
167
+ pips?: readonly MinimapTrackPip[];
168
+ /** Rail width (default `100%`). */
169
+ width?: number | string;
170
+ /** Rail thickness in px (default 12). */
171
+ height?: number;
172
+ /** Rail track color (default `rgba(255,255,255,0.10)`). */
173
+ railColor?: string;
174
+ className?: string;
175
+ style?: CSSProperties;
176
+ children?: ReactNode;
177
+ }
178
+ /**
179
+ * Horizontal linear track minimap — a rounded progress rail with colored zone
180
+ * `spans` and gate/exit/player `pips` positioned by 0..1 fraction. The
181
+ * structural counterpart to the radial {@link Minimap} for corridor/route
182
+ * games: fractions are supplied by the caller (via core `trackFraction`), so it
183
+ * reads no store and stays presentation-only, sharing chrome with the radial
184
+ * minimap.
185
+ *
186
+ * @capability minimap-track-hud horizontal linear track minimap (corridor/route progress) — colored zone spans and gate/player pips positioned by 0..1 fraction, sharing chrome with the radial Minimap
187
+ */
188
+ export declare function MinimapTrack({ spans, pips, width, height, railColor, className, style, children, }: MinimapTrackProps): ReactNode;
128
189
  export interface WorldMapProps {
129
- markers: MarkerSet;
190
+ /** Static views, an external marker source, or a native JGengine MarkerSet. */
191
+ markers: MarkerCollection;
130
192
  bounds: MapBounds;
131
193
  player?: WorldXZ;
132
194
  /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the player — pass it raw, never pre-converted to a bearing. */
package/dist/map.js CHANGED
@@ -68,20 +68,42 @@ function cellStateNodes(layers, project, scale, keyPrefix) {
68
68
  }
69
69
  return nodes;
70
70
  }
71
+ function markerAccess(markers) {
72
+ if (Array.isArray(markers)) {
73
+ const snapshot = markers;
74
+ return { subscribe: NO_SUBSCRIBE, getSnapshot: () => snapshot, getServerSnapshot: () => snapshot };
75
+ }
76
+ if ("getSnapshot" in markers) {
77
+ const source = markers;
78
+ return {
79
+ subscribe: (listener) => source.subscribe(listener),
80
+ getSnapshot: () => source.getSnapshot(),
81
+ getServerSnapshot: () => (source.getServerSnapshot ?? source.getSnapshot).call(source),
82
+ };
83
+ }
84
+ const markerSet = markers;
85
+ return {
86
+ subscribe: (listener) => markerSet.subscribe(listener),
87
+ getSnapshot: () => markerSet.snapshot(),
88
+ getServerSnapshot: () => markerSet.snapshot(),
89
+ };
90
+ }
71
91
  export function useMarkers(markers) {
72
- return useSyncExternalStore(markers.subscribe, markers.snapshot, markers.snapshot);
92
+ const access = useMemo(() => markerAccess(markers), [markers]);
93
+ return useSyncExternalStore(access.subscribe, access.getSnapshot, access.getServerSnapshot);
73
94
  }
74
95
  export function useFog(fog) {
75
96
  return useSyncExternalStore(fog.subscribe, fog.cells, fog.cells);
76
97
  }
77
98
  /**
78
99
  * Framed circular minimap: optional baked terrain background, reveal-on-event
79
- * fog overlay, categorized marker icons, and a facing arrow. Reads a core
80
- * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
100
+ * fog overlay, categorized marker icons, and a facing arrow. Reads static
101
+ * marker views, an external marker source, or a native `MarkerSet`; supply
102
+ * your own `kindStyles` palette to reskin. Does not require `GameProvider`.
81
103
  *
82
104
  * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
83
105
  */
84
- export function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title = "Map", children, }) {
106
+ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, style, title = "Map", children, }) {
85
107
  const markerList = useMarkers(markers);
86
108
  const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
87
109
  const bearing = headingToBearing(facingYaw);
@@ -93,7 +115,7 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
93
115
  };
94
116
  const half = size / 2;
95
117
  const clipId = `mm-clip-${size}`;
96
- const sorted = [...markerList].sort((a, b) => markerKindStyle(a.kind, kindStyles).priority - markerKindStyle(b.kind, kindStyles).priority);
118
+ const sorted = useMemo(() => [...markerList].sort((a, b) => markerKindStyle(a.kind, kindStyles).priority - markerKindStyle(b.kind, kindStyles).priority), [markerList, kindStyles]);
97
119
  const fogImage = useMemo(() => {
98
120
  if (fogCells === null)
99
121
  return null;
@@ -117,10 +139,12 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
117
139
  borderRadius: 14,
118
140
  padding: 8,
119
141
  background: "linear-gradient(160deg, rgba(24,28,36,0.94), rgba(12,15,20,0.94))",
120
- border: "1px solid rgba(148,163,184,0.28)",
142
+ // Ring color reads the shared HudTheme `--jg-ring` token (#1034); fallback = the built-in ring.
143
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.28))",
121
144
  boxShadow: "0 10px 30px rgba(0,0,0,0.45)",
122
145
  color: "#e2e8f0",
123
146
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
147
+ ...style,
124
148
  }, children: [_jsxs("div", { style: {
125
149
  display: "flex",
126
150
  justifyContent: "space-between",
@@ -158,10 +182,11 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
158
182
  ...routeNodes(routes, projectXZ, "mm"),
159
183
  ];
160
184
  })(), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
161
- const style = markerKindStyle(marker.kind, kindStyles);
185
+ const kind = marker.kind ?? "marker";
186
+ const style = markerKindStyle(kind, kindStyles);
162
187
  const projected = projectToMinimap(marker.position, view);
163
188
  const at = projected.inside ? projected : clampToMinimapEdge(projected, size);
164
- return (_jsxs("g", { "data-marker-kind": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
189
+ return (_jsxs("g", { "data-marker-kind": kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
165
190
  }), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : bearing) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-9 L6,7 L0,3 L-6,7 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 0.75 }) })] }), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.4)", strokeWidth: 1.5 }), _jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.9)", style: { fontWeight: 700 }, children: "N" })] }), children] }));
166
191
  }
167
192
  /**
@@ -197,7 +222,6 @@ export function MinimapChrome({ view, frame = false, frameColor = "rgba(148,163,
197
222
  const NO_SUBSCRIBE = () => () => undefined;
198
223
  const NULL_CELLS = () => null;
199
224
  const EMPTY_MARKERS_VALUE = [];
200
- const EMPTY_MARKERS = () => EMPTY_MARKERS_VALUE;
201
225
  const COMPASS_TICKS = [
202
226
  { cardinal: "N", bearing: 0 },
203
227
  { cardinal: "NE", bearing: Math.PI / 4 },
@@ -210,10 +234,11 @@ const COMPASS_TICKS = [
210
234
  ];
211
235
  /**
212
236
  * Horizontal compass strip centered on the player's facing direction, with the
213
- * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
237
+ * eight cardinals and optional marker pips from static views, an external
238
+ * source, or a native `MarkerSet`.
214
239
  */
215
240
  export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
216
- const markerList = useSyncExternalStore(markers?.subscribe ?? NO_SUBSCRIBE, markers?.snapshot ?? EMPTY_MARKERS, markers?.snapshot ?? EMPTY_MARKERS);
241
+ const markerList = useMarkers(markers ?? EMPTY_MARKERS_VALUE);
217
242
  const facingBearing = headingToBearing(facingYaw);
218
243
  const half = fov / 2;
219
244
  const toX = (bearing) => {
@@ -259,8 +284,9 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
259
284
  const x = toX(bearing);
260
285
  if (x === null)
261
286
  return null;
262
- const style = markerKindStyle(marker.kind, kindStyles);
263
- return (_jsx("span", { "data-compass-marker": marker.kind, style: {
287
+ const kind = marker.kind ?? "marker";
288
+ const style = markerKindStyle(kind, kindStyles);
289
+ return (_jsx("span", { "data-compass-marker": kind, style: {
264
290
  position: "absolute",
265
291
  left: x,
266
292
  bottom: 2,
@@ -293,6 +319,48 @@ export function MinimapPanel({ zoneLabel, clock, showCompass = true, compassProp
293
319
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
294
320
  }, children: [zoneLabel !== undefined ? (_jsx("span", { className: zoneLabelClassName, "data-minimap-chrome-zone": true, style: { fontSize: 12, fontWeight: 700 }, children: zoneLabel })) : (_jsx("span", {})), clock !== undefined ? (_jsx("span", { className: clockClassName, "data-minimap-chrome-clock": true, style: { fontSize: 11, color: "rgba(226,232,240,0.75)" }, children: clock })) : null] })) : null, _jsx(Minimap, { ...minimapProps, children: children }), showCompass ? (_jsx("div", { className: compassClassName, "data-minimap-chrome-compass": true, style: { marginTop: 6 }, children: _jsx(Compass, { facingYaw: minimapProps.facingYaw ?? 0, center: minimapProps.center, markers: minimapProps.markers, width: minimapProps.size ?? 176, ...compassProps }) })) : null] }));
295
321
  }
322
+ const TRACK_PIP_DIAMETER = {
323
+ dot: 6,
324
+ exit: 8,
325
+ player: 12,
326
+ };
327
+ /**
328
+ * Horizontal linear track minimap — a rounded progress rail with colored zone
329
+ * `spans` and gate/exit/player `pips` positioned by 0..1 fraction. The
330
+ * structural counterpart to the radial {@link Minimap} for corridor/route
331
+ * games: fractions are supplied by the caller (via core `trackFraction`), so it
332
+ * reads no store and stays presentation-only, sharing chrome with the radial
333
+ * minimap.
334
+ *
335
+ * @capability minimap-track-hud horizontal linear track minimap (corridor/route progress) — colored zone spans and gate/player pips positioned by 0..1 fraction, sharing chrome with the radial Minimap
336
+ */
337
+ export function MinimapTrack({ spans = [], pips = [], width = "100%", height = 12, railColor = "rgba(255,255,255,0.10)", className, style, children, }) {
338
+ return (_jsxs("div", { className: className, "data-minimap-track": true, style: { position: "relative", width, height, borderRadius: 9999, background: railColor, ...style }, children: [spans.map((span) => (_jsx("div", { "data-track-span": span.id, title: span.label, style: {
339
+ position: "absolute",
340
+ top: 0,
341
+ bottom: 0,
342
+ left: `${span.start * 100}%`,
343
+ width: `${(span.end - span.start) * 100}%`,
344
+ background: span.color ?? "rgba(255,255,255,0.25)",
345
+ opacity: 0.4,
346
+ borderRadius: 9999,
347
+ } }, span.id))), pips.map((pip) => {
348
+ const shape = pip.shape ?? "dot";
349
+ const color = pip.color ?? "#f4f6fb";
350
+ const common = {
351
+ position: "absolute",
352
+ top: "50%",
353
+ left: `${pip.at * 100}%`,
354
+ transform: "translate(-50%, -50%)",
355
+ background: color,
356
+ };
357
+ if (shape === "gate") {
358
+ return (_jsx("div", { "data-track-pip": pip.id, "data-pip-shape": "gate", title: pip.title ?? pip.label, style: { ...common, width: 3, height: height + 6, borderRadius: 2 } }, pip.id));
359
+ }
360
+ const diameter = TRACK_PIP_DIAMETER[shape];
361
+ return (_jsx("div", { "data-track-pip": pip.id, "data-pip-shape": shape, title: pip.title ?? pip.label, style: { ...common, width: diameter, height: diameter, borderRadius: 9999, border: "1px solid rgba(0,0,0,0.6)" } }, pip.id));
362
+ }), children] }));
363
+ }
296
364
  function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
297
365
  const fogImage = useMemo(() => {
298
366
  if (fogCells === null)
@@ -361,8 +429,9 @@ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, backgrou
361
429
  ];
362
430
  })(), markerList.map((marker) => {
363
431
  const at = project(marker.position[0], marker.position[2]);
364
- const style = markerKindStyle(marker.kind, kindStyles);
365
- return (_jsxs("g", { "data-world-marker": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: style.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: style.color, style: { fontWeight: 700 }, children: style.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
432
+ const kind = marker.kind ?? "marker";
433
+ const style = markerKindStyle(kind, kindStyles);
434
+ return (_jsxs("g", { "data-world-marker": kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: style.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: style.color, style: { fontWeight: 700 }, children: style.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
366
435
  }), player !== undefined
367
436
  ? (() => {
368
437
  const at = project(player[0], player[1]);
@@ -0,0 +1,107 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type PanelDef, type PanelPosition, type PanelState } from "@jgengine/core/ui/panelModel";
3
+ import { type HudFrameShape, type HudFrameVariation } from "./hudFrame.js";
4
+ /**
5
+ * React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable,
6
+ * draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close).
7
+ * `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders
8
+ * the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and
9
+ * token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
10
+ */
11
+ /** Intent a key press resolves to over a panel set: toggle a panel, close the top window, or ignore. */
12
+ export interface PanelKeyResult {
13
+ handled: boolean;
14
+ type?: "toggle" | "closeTop";
15
+ id?: string;
16
+ }
17
+ /**
18
+ * Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
19
+ * any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
20
+ * the intent without touching the DOM, so it unit-tests headless and `usePanels` is a thin shell over it.
21
+ */
22
+ export declare function panelKeyAction(defs: readonly PanelDef[], state: PanelState, event: {
23
+ code?: string;
24
+ key?: string;
25
+ }): PanelKeyResult;
26
+ /** Options for {@link usePanels}. */
27
+ export interface UsePanelsOptions {
28
+ /** Fired when a panel transitions from closed to open (not on refocus of an already-open panel). */
29
+ onOpen?: (id: string) => void;
30
+ /** Fired when an open panel is closed. */
31
+ onClose?: (id: string) => void;
32
+ /** Set false to skip installing the window keybind/ESC listener (default true). */
33
+ hotkeys?: boolean;
34
+ }
35
+ /** The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}. */
36
+ export interface PanelsManager {
37
+ state: PanelState;
38
+ defs: readonly PanelDef[];
39
+ isOpen: (id: string) => boolean;
40
+ open: (id: string) => void;
41
+ close: (id: string) => void;
42
+ toggle: (id: string) => void;
43
+ focus: (id: string) => void;
44
+ move: (id: string, pos: PanelPosition) => void;
45
+ byHotkey: (code: string) => string | null;
46
+ }
47
+ /**
48
+ * The DATA/HOOK layer: wrap the core panel model in state and install a window keybind listener —
49
+ * per-panel hotkeys toggle their window and ESC closes the topmost closable one. Hotkeys are ignored
50
+ * while the player is typing in an input/textarea/contenteditable. The listener is cleaned up on
51
+ * unmount and SSR-safe (armed in an effect). Feed the returned manager to {@link PanelHost}, or drive a
52
+ * bespoke window layout from it directly.
53
+ *
54
+ * @capability use-panels headless toggleable-window manager with keybind + ESC handling over the core panel model
55
+ */
56
+ export declare function usePanels(defs: readonly PanelDef[], options?: UsePanelsOptions): PanelsManager;
57
+ /**
58
+ * Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable
59
+ * windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its
60
+ * title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels`
61
+ * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
62
+ * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
63
+ *
64
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
65
+ */
66
+ export declare function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: {
67
+ manager: PanelsManager;
68
+ /** Content for a panel by id. Takes precedence over `panels`. */
69
+ render?: (id: string) => ReactNode;
70
+ /** Static content map by panel id, used when `render` is omitted. */
71
+ panels?: Record<string, ReactNode>;
72
+ variation?: HudFrameVariation;
73
+ shape?: HudFrameShape;
74
+ /** Default window width (per-panel unless overridden by the panel content). */
75
+ width?: number | string;
76
+ className?: string;
77
+ windowClassName?: string;
78
+ windowStyle?: CSSProperties;
79
+ bodyStyle?: CSSProperties;
80
+ }): import("react").JSX.Element;
81
+ /**
82
+ * A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
83
+ * usable without the {@link usePanels} manager for a one-off dialog. Position is uncontrolled (seeded by
84
+ * `x`/`y`) unless both `x`/`y` and `onMove` are supplied, in which case the caller owns placement.
85
+ * Accessible: `role="dialog"` with an `aria-label` and a focusable close button.
86
+ *
87
+ * @capability window standalone draggable, closable window primitive over HudFrame — no manager required
88
+ */
89
+ export declare function Window({ title, ariaLabel, closable, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }: {
90
+ title: ReactNode;
91
+ ariaLabel?: string;
92
+ closable?: boolean;
93
+ onClose?: () => void;
94
+ /** Initial (uncontrolled) or current (controlled, with `onMove`) x position. Default 56. */
95
+ x?: number;
96
+ /** Initial (uncontrolled) or current (controlled, with `onMove`) y position. Default 56. */
97
+ y?: number;
98
+ onMove?: (pos: PanelPosition) => void;
99
+ z?: number;
100
+ width?: number | string;
101
+ variation?: HudFrameVariation;
102
+ shape?: HudFrameShape;
103
+ className?: string;
104
+ style?: CSSProperties;
105
+ bodyStyle?: CSSProperties;
106
+ children?: ReactNode;
107
+ }): import("react").JSX.Element;