@jgengine/react 0.10.0 → 0.12.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 (72) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +294 -0
  6. package/dist/bars.d.ts +103 -0
  7. package/dist/bars.js +180 -0
  8. package/dist/barsPreview.d.ts +4 -0
  9. package/dist/barsPreview.js +47 -0
  10. package/dist/cards.d.ts +59 -0
  11. package/dist/cards.js +116 -0
  12. package/dist/chat.d.ts +3 -1
  13. package/dist/chat.js +2 -2
  14. package/dist/components.d.ts +28 -1
  15. package/dist/components.js +36 -1
  16. package/dist/display.d.ts +1 -0
  17. package/dist/display.js +1 -0
  18. package/dist/engineStore.d.ts +3 -0
  19. package/dist/engineStore.js +3 -0
  20. package/dist/fogOverlay.d.ts +3 -0
  21. package/dist/fogOverlay.js +3 -0
  22. package/dist/gameViewport.js +16 -0
  23. package/dist/hooks.d.ts +40 -0
  24. package/dist/hooks.js +69 -1
  25. package/dist/hud.d.ts +104 -0
  26. package/dist/hud.js +155 -0
  27. package/dist/hudFrame.d.ts +47 -0
  28. package/dist/hudFrame.js +80 -0
  29. package/dist/hudLayout.d.ts +31 -2
  30. package/dist/hudLayout.js +127 -10
  31. package/dist/hudLayoutPersist.d.ts +23 -0
  32. package/dist/hudLayoutPersist.js +19 -0
  33. package/dist/hudTheme.d.ts +76 -0
  34. package/dist/hudTheme.js +156 -0
  35. package/dist/hudThemePreview.d.ts +4 -0
  36. package/dist/hudThemePreview.js +37 -0
  37. package/dist/iconTreatment.d.ts +50 -0
  38. package/dist/iconTreatment.js +84 -0
  39. package/dist/iconsPreview.d.ts +4 -0
  40. package/dist/iconsPreview.js +29 -0
  41. package/dist/index.d.ts +17 -0
  42. package/dist/index.js +17 -0
  43. package/dist/keyHint.d.ts +30 -0
  44. package/dist/keyHint.js +31 -0
  45. package/dist/map.d.ts +141 -11
  46. package/dist/map.js +138 -16
  47. package/dist/save.d.ts +25 -0
  48. package/dist/save.js +37 -0
  49. package/dist/selectSnapshot.d.ts +2 -0
  50. package/dist/selectSnapshot.js +2 -0
  51. package/dist/selectionHud.d.ts +139 -0
  52. package/dist/selectionHud.js +188 -0
  53. package/dist/skillCheckPaint.d.ts +2 -0
  54. package/dist/skillCheckPaint.js +2 -0
  55. package/dist/slots.d.ts +44 -0
  56. package/dist/slots.js +37 -0
  57. package/dist/startScreen.d.ts +62 -0
  58. package/dist/startScreen.js +62 -0
  59. package/dist/store.d.ts +24 -0
  60. package/dist/store.js +9 -0
  61. package/package.json +4 -4
  62. package/skills/jgengine/SKILL.md +54 -0
  63. package/skills/jgengine/api.md +527 -0
  64. package/skills/jgengine/capabilities.md +131 -0
  65. package/skills/jgengine/recipes/minimal-game.md +88 -0
  66. package/skills/jgengine-ui/SKILL.md +41 -0
  67. package/skills/jgengine-ui/api.md +2007 -0
  68. package/skills/jgengine-ui/capabilities.md +211 -0
  69. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  70. package/skills/jgengine-ui/reference.md +513 -0
  71. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  72. package/llms.txt +0 -1724
package/dist/map.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, 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";
@@ -68,18 +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`.
103
+ *
104
+ * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
81
105
  */
82
- 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, }) {
83
107
  const markerList = useMarkers(markers);
84
108
  const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
85
109
  const bearing = headingToBearing(facingYaw);
@@ -91,7 +115,7 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
91
115
  };
92
116
  const half = size / 2;
93
117
  const clipId = `mm-clip-${size}`;
94
- 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]);
95
119
  const fogImage = useMemo(() => {
96
120
  if (fogCells === null)
97
121
  return null;
@@ -115,10 +139,12 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
115
139
  borderRadius: 14,
116
140
  padding: 8,
117
141
  background: "linear-gradient(160deg, rgba(24,28,36,0.94), rgba(12,15,20,0.94))",
118
- 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))",
119
144
  boxShadow: "0 10px 30px rgba(0,0,0,0.45)",
120
145
  color: "#e2e8f0",
121
146
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
147
+ ...style,
122
148
  }, children: [_jsxs("div", { style: {
123
149
  display: "flex",
124
150
  justifyContent: "space-between",
@@ -156,16 +182,46 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
156
182
  ...routeNodes(routes, projectXZ, "mm"),
157
183
  ];
158
184
  })(), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
159
- const style = markerKindStyle(marker.kind, kindStyles);
185
+ const kind = marker.kind ?? "marker";
186
+ const style = markerKindStyle(kind, kindStyles);
160
187
  const projected = projectToMinimap(marker.position, view);
161
188
  const at = projected.inside ? projected : clampToMinimapEdge(projected, size);
162
- 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));
163
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] }));
164
191
  }
192
+ /**
193
+ * Headless minimap chrome — nests inside a game's own `<svg>`: an optional
194
+ * ring frame, and edge-clamped marker dots that draw as directional arrows
195
+ * when a marker carries a `heading` (the compass-arrow / player-blip layer
196
+ * ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
197
+ * Renders a bare `<g>` — no background, panel, or title; compose your own
198
+ * content layer (routes, zones, terrain) as sibling SVG nodes for full control
199
+ * of z-order, or call `MinimapChrome` twice (once for `frame`, again for
200
+ * `markers`) to sandwich custom content between the ring and the blips.
201
+ *
202
+ * @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
203
+ */
204
+ export function MinimapChrome({ view, frame = false, frameColor = "rgba(148,163,184,0.4)", frameStrokeWidth = 1.5, cardinalLabel = "N", markers = [], markerColor = "#e2e8f0", markerRadius = 5, className, }) {
205
+ const half = view.size / 2;
206
+ const rotate = view.rotate ?? 0;
207
+ return (_jsxs("g", { className: className, "data-minimap-chrome": true, children: [frame ? (_jsxs(_Fragment, { children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: frameColor, strokeWidth: frameStrokeWidth }), cardinalLabel !== false ? (_jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: frameColor, style: { fontWeight: 700 }, children: cardinalLabel })) : null] })) : null, markers.map((marker) => {
208
+ const projected = projectToMinimap(marker.position, view);
209
+ const clamp = marker.clampToEdge ?? true;
210
+ if (!projected.inside && !clamp)
211
+ return null;
212
+ const at = projected.inside ? projected : clampToMinimapEdge(projected, view.size);
213
+ const color = marker.color ?? markerColor;
214
+ const radius = marker.radius ?? markerRadius;
215
+ if (marker.heading !== undefined) {
216
+ const deg = (marker.heading - rotate) * (180 / Math.PI);
217
+ return (_jsx("g", { "data-minimap-marker": marker.id, transform: `translate(${at.x} ${at.y}) rotate(${deg})`, children: _jsx("path", { d: `M0,${-radius} L${radius * 0.7},${radius * 0.8} L0,${radius * 0.35} L${-radius * 0.7},${radius * 0.8} Z`, fill: color, stroke: marker.strokeColor ?? "rgba(0,0,0,0.6)", strokeWidth: marker.strokeWidth ?? 0.75 }) }, marker.id));
218
+ }
219
+ return (_jsxs("g", { "data-minimap-marker": marker.id, children: [_jsx("circle", { cx: at.x, cy: at.y, r: radius, fill: color, stroke: marker.strokeColor, strokeWidth: marker.strokeWidth }), marker.glyph !== undefined ? (_jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: marker.glyphColor ?? "#0f172a", style: { fontWeight: 700 }, children: marker.glyph })) : null] }, marker.id));
220
+ })] }));
221
+ }
165
222
  const NO_SUBSCRIBE = () => () => undefined;
166
223
  const NULL_CELLS = () => null;
167
224
  const EMPTY_MARKERS_VALUE = [];
168
- const EMPTY_MARKERS = () => EMPTY_MARKERS_VALUE;
169
225
  const COMPASS_TICKS = [
170
226
  { cardinal: "N", bearing: 0 },
171
227
  { cardinal: "NE", bearing: Math.PI / 4 },
@@ -178,10 +234,11 @@ const COMPASS_TICKS = [
178
234
  ];
179
235
  /**
180
236
  * Horizontal compass strip centered on the player's facing direction, with the
181
- * 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`.
182
239
  */
183
240
  export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
184
- const markerList = useSyncExternalStore(markers?.subscribe ?? NO_SUBSCRIBE, markers?.snapshot ?? EMPTY_MARKERS, markers?.snapshot ?? EMPTY_MARKERS);
241
+ const markerList = useMarkers(markers ?? EMPTY_MARKERS_VALUE);
185
242
  const facingBearing = headingToBearing(facingYaw);
186
243
  const half = fov / 2;
187
244
  const toX = (bearing) => {
@@ -227,8 +284,9 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
227
284
  const x = toX(bearing);
228
285
  if (x === null)
229
286
  return null;
230
- const style = markerKindStyle(marker.kind, kindStyles);
231
- 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: {
232
290
  position: "absolute",
233
291
  left: x,
234
292
  bottom: 2,
@@ -240,6 +298,69 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
240
298
  })
241
299
  : null] }));
242
300
  }
301
+ /**
302
+ * Composed circular-minimap chrome: optional zone-label + clock header above
303
+ * the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
304
+ * the existing primitives — the game supplies the zone name and clock text,
305
+ * this only places them; omit either slot and the header disappears.
306
+ *
307
+ * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
308
+ */
309
+ export function MinimapPanel({ zoneLabel, clock, showCompass = true, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }) {
310
+ const hasHeader = zoneLabel !== undefined || clock !== undefined;
311
+ return (_jsxs("div", { "data-minimap-chrome": true, children: [hasHeader ? (_jsxs("div", { className: headerClassName, "data-minimap-chrome-header": true, style: {
312
+ display: "flex",
313
+ justifyContent: "space-between",
314
+ alignItems: "baseline",
315
+ gap: 8,
316
+ marginBottom: 4,
317
+ width: minimapProps.size ?? 176,
318
+ color: "#e2e8f0",
319
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
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] }));
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
+ }
243
364
  function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
244
365
  const fogImage = useMemo(() => {
245
366
  if (fogCells === null)
@@ -308,8 +429,9 @@ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, backgrou
308
429
  ];
309
430
  })(), markerList.map((marker) => {
310
431
  const at = project(marker.position[0], marker.position[2]);
311
- const style = markerKindStyle(marker.kind, kindStyles);
312
- 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));
313
435
  }), player !== undefined
314
436
  ? (() => {
315
437
  const at = project(player[0], player[1]);
package/dist/save.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { SaveStatus, SaveStore } from "@jgengine/core/game/saveStore";
2
+ /** The reactive slice of a {@link SaveStore} returned by {@link useSaveStore} — the live value plus bound save actions, all re-rendering on change. */
3
+ export interface SaveStoreView<T> {
4
+ value: T;
5
+ status: SaveStatus;
6
+ slot: string;
7
+ /** True while the initial `load()` is in flight — gate a loading screen on it. */
8
+ loading: boolean;
9
+ /** True while a write is in flight — show a "Saving…" indicator. */
10
+ saving: boolean;
11
+ set: (value: T) => void;
12
+ patch: (mutate: (previous: T) => T) => void;
13
+ save: () => Promise<void>;
14
+ clear: () => Promise<void>;
15
+ }
16
+ /**
17
+ * Bind a {@link SaveStore} to React: returns the live `value` and `status` and
18
+ * re-renders whenever either changes. By default it calls `load()` once on
19
+ * mount (pass `{ load: false }` to hydrate yourself). The same hook works for
20
+ * offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
21
+ * differs.
22
+ */
23
+ export declare function useSaveStore<T>(store: SaveStore<T>, options?: {
24
+ load?: boolean;
25
+ }): SaveStoreView<T>;
package/dist/save.js ADDED
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
+ /**
3
+ * Bind a {@link SaveStore} to React: returns the live `value` and `status` and
4
+ * re-renders whenever either changes. By default it calls `load()` once on
5
+ * mount (pass `{ load: false }` to hydrate yourself). The same hook works for
6
+ * offline (localStorage) and cloud (Convex/DB) saves — only the store's backend
7
+ * differs.
8
+ */
9
+ export function useSaveStore(store, options) {
10
+ useSyncExternalStore(store.subscribe, store.revision, store.revision);
11
+ const shouldLoad = options?.load !== false;
12
+ const loadedFor = useRef(null);
13
+ useEffect(() => {
14
+ if (!shouldLoad)
15
+ return;
16
+ if (loadedFor.current === store)
17
+ return;
18
+ loadedFor.current = store;
19
+ void store.load();
20
+ }, [store, shouldLoad]);
21
+ const set = useCallback((value) => store.set(value), [store]);
22
+ const patch = useCallback((mutate) => void store.patch(mutate), [store]);
23
+ const save = useCallback(() => store.save(), [store]);
24
+ const clear = useCallback(() => store.clear(), [store]);
25
+ const status = store.status();
26
+ return {
27
+ value: store.value(),
28
+ status,
29
+ slot: store.slot(),
30
+ loading: status === "loading",
31
+ saving: status === "saving",
32
+ set,
33
+ patch,
34
+ save,
35
+ clear,
36
+ };
37
+ }
@@ -3,5 +3,7 @@ export type SelectCache<TSnapshot, TSelected> = {
3
3
  snapshot: TSnapshot;
4
4
  value: TSelected;
5
5
  };
6
+ /** @internal */
6
7
  export declare function createSelectCache<TSnapshot, TSelected>(): SelectCache<TSnapshot, TSelected>;
8
+ /** @internal */
7
9
  export declare function readSelectSnapshot<TSnapshot, TSelected>(cache: SelectCache<TSnapshot, TSelected>, snapshot: TSnapshot, select: (snapshot: TSnapshot) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
@@ -1,6 +1,8 @@
1
+ /** @internal */
1
2
  export function createSelectCache() {
2
3
  return { ready: false, snapshot: undefined, value: undefined };
3
4
  }
5
+ /** @internal */
4
6
  export function readSelectSnapshot(cache, snapshot, select, isEqual = Object.is) {
5
7
  if (cache.ready && Object.is(cache.snapshot, snapshot))
6
8
  return cache.value;
@@ -0,0 +1,139 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type EntitySummaryDef, type EntityVital, type SelectionGroup, type SelectionView, type SelectionWindow } from "@jgengine/core/ui/selectionModel";
3
+ /** A minimal keyboard-event shape — a real `KeyboardEvent`/React synthetic satisfies it. */
4
+ export interface SelectionKeyEvent {
5
+ key: string;
6
+ preventDefault(): void;
7
+ }
8
+ /** Options for {@link useSelectionView}. */
9
+ export interface UseSelectionViewOptions {
10
+ /** Member count above which `grouped` flips true (default 12). */
11
+ groupThreshold?: number;
12
+ /** Map a member `kind` to a display label for its group. */
13
+ labelOf?: (kind: string) => string;
14
+ /** Fired when the focused (primary) member changes. */
15
+ onFocusChange?: (id: string) => void;
16
+ }
17
+ /** The live selection model returned by {@link useSelectionView}. */
18
+ export interface SelectionModel {
19
+ view: SelectionView;
20
+ focusId: string | null;
21
+ setFocus: (id: string) => void;
22
+ onKeyDown: (event: SelectionKeyEvent) => void;
23
+ /** A virtualization window over the members, for a large selection strip. */
24
+ window: (scroll: number, size: number) => SelectionWindow;
25
+ }
26
+ /**
27
+ * The DATA/HOOK layer: summarize a selected-entity list into a live model with a focused primary,
28
+ * ordered members, same-kind groups, keyboard focus stepping, and a virtualization window.
29
+ * Rendering-agnostic — feed it to {@link SelectionPanel} or read the `view` directly.
30
+ *
31
+ * @capability selection-model headless multi-entity selection model with primary focus, grouping, and virtualization
32
+ */
33
+ export declare function useSelectionView(members: readonly EntitySummaryDef[], options?: UseSelectionViewOptions): SelectionModel;
34
+ /**
35
+ * A compact current/max vital meter for an entity summary; tone maps to a colour ramp. Reuse it as
36
+ * the vital row inside {@link EntitySummary} or any custom entity panel.
37
+ *
38
+ * @deprecated The `tone`/`vital`-API umbrella is retired (#1033). Use the atomic, purpose-named,
39
+ * token-themed bars from `@jgengine/react/bars` (`HealthBar`, `ShieldBar`, `ManaBar`, …) and compose
40
+ * your own panel — each does one readout and restyles globally via the shared `--jg-*` tokens.
41
+ * @capability vital-bar compact current/max vital meter for an entity summary
42
+ */
43
+ export declare function VitalBar({ vital, width, showLabel, className, style, }: {
44
+ vital: EntityVital;
45
+ width?: number;
46
+ showLabel?: boolean;
47
+ className?: string;
48
+ style?: CSSProperties;
49
+ }): import("react").JSX.Element;
50
+ /**
51
+ * A selectable entity portrait tile — icon/initials, an optional thin health strip, and complete
52
+ * selected/hover/focus states as a real `<button>` (`aria-pressed`). The unit-of-a-selection chrome;
53
+ * swap the face via `renderIcon`. Accessible and keyboard-focusable.
54
+ *
55
+ * @capability entity-portrait selectable entity portrait tile with vitals and selected/focus states
56
+ */
57
+ export declare function EntityPortrait({ entity, selected, size, vitalId, tabIndex, onSelect, renderIcon, className, style, }: {
58
+ entity: EntitySummaryDef;
59
+ selected?: boolean;
60
+ size?: number;
61
+ vitalId?: string;
62
+ tabIndex?: number;
63
+ onSelect?: (id: string) => void;
64
+ renderIcon?: (entity: EntitySummaryDef) => ReactNode;
65
+ className?: string;
66
+ style?: CSSProperties;
67
+ }): import("react").JSX.Element;
68
+ /**
69
+ * The primary-entity detail chrome — name, tags, and full vital bars for the focused member. The
70
+ * "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
71
+ * rows via `renderVital`.
72
+ *
73
+ * @deprecated Bundling a whole vitals array in one component is the combo anti-pattern #1033 retires.
74
+ * Compose your own summary — name/tags plus the atomic `@jgengine/react/bars` (`HealthBar`, …) you
75
+ * need — so no engine component packages multiple readouts by default. Kept during migration.
76
+ * @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
77
+ */
78
+ export declare function EntitySummary({ entity, renderVital, className, style, }: {
79
+ entity: EntitySummaryDef;
80
+ renderVital?: (vital: EntityVital) => ReactNode;
81
+ className?: string;
82
+ style?: CSSProperties;
83
+ }): import("react").JSX.Element;
84
+ /**
85
+ * A same-kind selection chip — icon plus count — for the grouped large-selection view. The default
86
+ * chrome {@link SelectionCollectionChrome} renders per group; swap it via `renderGroup`.
87
+ *
88
+ * @capability selection-group-chip same-kind selection chip (icon + count) for a grouped selection
89
+ */
90
+ export declare function SelectionGroupChip({ group, onSelect, renderIcon, className, style, }: {
91
+ group: SelectionGroup;
92
+ onSelect?: (group: SelectionGroup) => void;
93
+ renderIcon?: (group: SelectionGroup) => ReactNode;
94
+ className?: string;
95
+ style?: CSSProperties;
96
+ }): import("react").JSX.Element;
97
+ /**
98
+ * The composable RENDERER for the member collection: a portrait strip for a small selection, or the
99
+ * same-kind group chips once the model is `grouped`. Roving tabindex + arrow-key focus. Swap
100
+ * `renderMember`/`renderGroup` for custom chrome, or read the model and lay it out yourself. No panel
101
+ * skin — a behavior/accessibility layer only.
102
+ *
103
+ * @capability selection-collection accessible portrait-strip / group-chip renderer over a selection model
104
+ */
105
+ export declare function SelectionCollectionChrome({ model, itemSize, gap, windowSize, scroll, ariaLabel, renderMember, renderGroup, onGroupSelect, className, style, }: {
106
+ model: SelectionModel;
107
+ itemSize?: number;
108
+ gap?: number;
109
+ /** Cap the mounted portraits; the model virtualizes past this many members. */
110
+ windowSize?: number;
111
+ scroll?: number;
112
+ ariaLabel?: string;
113
+ renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
114
+ renderGroup?: (group: SelectionGroup) => ReactNode;
115
+ onGroupSelect?: (group: SelectionGroup) => void;
116
+ className?: string;
117
+ style?: CSSProperties;
118
+ }): import("react").JSX.Element;
119
+ /**
120
+ * Convenience composition: skinned glass panel with {@link EntitySummary} for the primary member
121
+ * beside {@link SelectionCollectionChrome} for the rest — demos/scaffold only, not a finished game
122
+ * face. Pass `members` (or a prebuilt `model`); swap sub-renderers or compose the headless pieces
123
+ * under game-owned chrome for product UI.
124
+ *
125
+ * @capability selection-panel optional selection-panel composition: summary + portrait strip / group chips
126
+ */
127
+ export declare function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }: {
128
+ members?: readonly EntitySummaryDef[];
129
+ model?: SelectionModel;
130
+ groupThreshold?: number;
131
+ labelOf?: (kind: string) => string;
132
+ windowSize?: number;
133
+ renderPrimary?: (entity: EntitySummaryDef) => ReactNode;
134
+ renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
135
+ renderVital?: (vital: EntityVital) => ReactNode;
136
+ onFocusChange?: (id: string) => void;
137
+ className?: string;
138
+ style?: CSSProperties;
139
+ }): import("react").JSX.Element | null;