@jgengine/react 0.9.0 → 0.11.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 (62) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +6 -4
  9. package/dist/chat.js +16 -7
  10. package/dist/chatBubbles.js +2 -1
  11. package/dist/components.d.ts +20 -0
  12. package/dist/components.js +29 -1
  13. package/dist/display.d.ts +1 -0
  14. package/dist/display.js +1 -0
  15. package/dist/engineStore.d.ts +3 -0
  16. package/dist/engineStore.js +3 -0
  17. package/dist/fogOverlay.d.ts +3 -0
  18. package/dist/fogOverlay.js +3 -0
  19. package/dist/gameViewport.d.ts +54 -0
  20. package/dist/gameViewport.js +356 -0
  21. package/dist/hooks.d.ts +22 -0
  22. package/dist/hooks.js +58 -12
  23. package/dist/hud.d.ts +87 -0
  24. package/dist/hud.js +144 -0
  25. package/dist/hudLayout.d.ts +39 -4
  26. package/dist/hudLayout.js +152 -11
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.js +10 -0
  29. package/dist/keyHint.d.ts +30 -0
  30. package/dist/keyHint.js +31 -0
  31. package/dist/map.d.ts +69 -1
  32. package/dist/map.js +54 -1
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +4 -0
  35. package/dist/rotateDevice.d.ts +24 -0
  36. package/dist/rotateDevice.js +83 -0
  37. package/dist/save.d.ts +25 -0
  38. package/dist/save.js +37 -0
  39. package/dist/selectSnapshot.d.ts +2 -0
  40. package/dist/selectSnapshot.js +2 -0
  41. package/dist/selectionHud.d.ts +133 -0
  42. package/dist/selectionHud.js +182 -0
  43. package/dist/settings.d.ts +3 -0
  44. package/dist/skillCheckPaint.d.ts +2 -0
  45. package/dist/skillCheckPaint.js +2 -0
  46. package/dist/social.d.ts +3 -3
  47. package/dist/social.js +31 -10
  48. package/dist/startScreen.d.ts +62 -0
  49. package/dist/startScreen.js +62 -0
  50. package/dist/store.d.ts +24 -0
  51. package/dist/store.js +9 -0
  52. package/package.json +4 -4
  53. package/skills/jgengine/SKILL.md +51 -0
  54. package/skills/jgengine/api.md +434 -0
  55. package/skills/jgengine/capabilities.md +29 -0
  56. package/skills/jgengine/reference-cartridge.md +72 -0
  57. package/skills/jgengine-ui/SKILL.md +36 -0
  58. package/skills/jgengine-ui/api.md +1852 -0
  59. package/skills/jgengine-ui/capabilities.md +142 -0
  60. package/skills/jgengine-ui/reference.md +500 -0
  61. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  62. package/llms.txt +0 -1633
package/dist/map.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { FogField } from "@jgengine/core/world/fog";
3
3
  import { type MapMarker, type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
4
- import { type WorldXZ } from "@jgengine/core/world/minimap";
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
6
  export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
7
7
  export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
@@ -40,8 +40,53 @@ export interface MapBounds {
40
40
  * Framed circular minimap: optional baked terrain background, reveal-on-event
41
41
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
42
42
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
43
+ *
44
+ * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
43
45
  */
44
46
  export declare function Minimap({ markers, center, worldRadius, fog, size, facingYaw, rotate, kindStyles, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title, children, }: MinimapProps): ReactNode;
47
+ /** One dot (or, with `heading`, a rotated arrow) drawn by `MinimapChrome`. */
48
+ export interface MinimapChromeMarker {
49
+ id: string;
50
+ position: WorldXZ | readonly [number, number, number];
51
+ /** Bearing (radians) to draw an arrow instead of a dot — pass `headingToBearing(yaw)` for the player/other facing blips. */
52
+ heading?: number;
53
+ color?: string;
54
+ radius?: number;
55
+ glyph?: string;
56
+ glyphColor?: string;
57
+ strokeColor?: string;
58
+ strokeWidth?: number;
59
+ /** Clamp to the minimap edge when outside `view.worldRadius` (off-screen indicator); `false` hides it instead. Default `true`. */
60
+ clampToEdge?: boolean;
61
+ }
62
+ /** Props for `MinimapChrome`. */
63
+ export interface MinimapChromeProps {
64
+ /** Shared with `projectToMinimap`/`clampToMinimapEdge` — pass `view.rotate` to keep a rotating map's arrows pointed correctly. */
65
+ view: MinimapView;
66
+ /** Ring outline (+ `cardinalLabel`). Default `false` — most games draw their own frame/panel around the chrome. */
67
+ frame?: boolean;
68
+ frameColor?: string;
69
+ frameStrokeWidth?: number;
70
+ /** Label drawn at the top of the ring when `frame` is on; `false` omits it. */
71
+ cardinalLabel?: string | false;
72
+ markers?: readonly MinimapChromeMarker[];
73
+ markerColor?: string;
74
+ markerRadius?: number;
75
+ className?: string;
76
+ }
77
+ /**
78
+ * Headless minimap chrome — nests inside a game's own `<svg>`: an optional
79
+ * ring frame, and edge-clamped marker dots that draw as directional arrows
80
+ * when a marker carries a `heading` (the compass-arrow / player-blip layer
81
+ * ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
82
+ * Renders a bare `<g>` — no background, panel, or title; compose your own
83
+ * content layer (routes, zones, terrain) as sibling SVG nodes for full control
84
+ * of z-order, or call `MinimapChrome` twice (once for `frame`, again for
85
+ * `markers`) to sandwich custom content between the ring and the blips.
86
+ *
87
+ * @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
88
+ */
89
+ export declare function MinimapChrome({ view, frame, frameColor, frameStrokeWidth, cardinalLabel, markers, markerColor, markerRadius, className, }: MinimapChromeProps): ReactNode;
45
90
  export interface CompassProps {
46
91
  /** Facing yaw (`rotationY`, forward = `(sin yaw, cos yaw)`) of the tracked entity — pass it raw, never pre-converted to a bearing. */
47
92
  facingYaw: number;
@@ -57,6 +102,29 @@ export interface CompassProps {
57
102
  * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
58
103
  */
59
104
  export declare function Compass({ facingYaw, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
105
+ /** Props for `MinimapPanel` — all `MinimapProps` plus the zone-label/clock header and compass slots. */
106
+ export interface MinimapPanelProps extends MinimapProps {
107
+ /** Zone/area name slot rendered in the chrome header — the game supplies its own zone lookup. */
108
+ zoneLabel?: ReactNode;
109
+ /** Clock readout slot rendered in the chrome header — pair with `useGameClock()` + `calendar()`. */
110
+ clock?: ReactNode;
111
+ /** Compass strip beneath the circle. Default true; pass `false` to omit it entirely. */
112
+ showCompass?: boolean;
113
+ compassProps?: Partial<Omit<CompassProps, "className">>;
114
+ headerClassName?: string;
115
+ zoneLabelClassName?: string;
116
+ clockClassName?: string;
117
+ compassClassName?: string;
118
+ }
119
+ /**
120
+ * Composed circular-minimap chrome: optional zone-label + clock header above
121
+ * the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
122
+ * the existing primitives — the game supplies the zone name and clock text,
123
+ * this only places them; omit either slot and the header disappears.
124
+ *
125
+ * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
126
+ */
127
+ export declare function MinimapPanel({ zoneLabel, clock, showCompass, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }: MinimapPanelProps): ReactNode;
60
128
  export interface WorldMapProps {
61
129
  markers: MarkerSet;
62
130
  bounds: MapBounds;
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";
@@ -78,6 +78,8 @@ export function useFog(fog) {
78
78
  * Framed circular minimap: optional baked terrain background, reveal-on-event
79
79
  * fog overlay, categorized marker icons, and a facing arrow. Reads a core
80
80
  * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
81
+ *
82
+ * @capability minimap framed circular minimap with terrain bake, fog, markers, and facing arrow
81
83
  */
82
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, }) {
83
85
  const markerList = useMarkers(markers);
@@ -162,6 +164,36 @@ export function Minimap({ markers, center, worldRadius, fog, size = 176, facingY
162
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));
163
165
  }), _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
166
  }
167
+ /**
168
+ * Headless minimap chrome — nests inside a game's own `<svg>`: an optional
169
+ * ring frame, and edge-clamped marker dots that draw as directional arrows
170
+ * when a marker carries a `heading` (the compass-arrow / player-blip layer
171
+ * ~8 games were re-drawing by hand over `projectToMinimap`/`clampToMinimapEdge`).
172
+ * Renders a bare `<g>` — no background, panel, or title; compose your own
173
+ * content layer (routes, zones, terrain) as sibling SVG nodes for full control
174
+ * of z-order, or call `MinimapChrome` twice (once for `frame`, again for
175
+ * `markers`) to sandwich custom content between the ring and the blips.
176
+ *
177
+ * @capability minimap-chrome ring frame plus edge-clamped, heading-aware marker dots to nest inside a game's own `<svg>`
178
+ */
179
+ export function MinimapChrome({ view, frame = false, frameColor = "rgba(148,163,184,0.4)", frameStrokeWidth = 1.5, cardinalLabel = "N", markers = [], markerColor = "#e2e8f0", markerRadius = 5, className, }) {
180
+ const half = view.size / 2;
181
+ const rotate = view.rotate ?? 0;
182
+ 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) => {
183
+ const projected = projectToMinimap(marker.position, view);
184
+ const clamp = marker.clampToEdge ?? true;
185
+ if (!projected.inside && !clamp)
186
+ return null;
187
+ const at = projected.inside ? projected : clampToMinimapEdge(projected, view.size);
188
+ const color = marker.color ?? markerColor;
189
+ const radius = marker.radius ?? markerRadius;
190
+ if (marker.heading !== undefined) {
191
+ const deg = (marker.heading - rotate) * (180 / Math.PI);
192
+ 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));
193
+ }
194
+ 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));
195
+ })] }));
196
+ }
165
197
  const NO_SUBSCRIBE = () => () => undefined;
166
198
  const NULL_CELLS = () => null;
167
199
  const EMPTY_MARKERS_VALUE = [];
@@ -240,6 +272,27 @@ export function Compass({ facingYaw, center, markers, width = 340, fov = (Math.P
240
272
  })
241
273
  : null] }));
242
274
  }
275
+ /**
276
+ * Composed circular-minimap chrome: optional zone-label + clock header above
277
+ * the `Minimap`, optional `Compass` strip below. Purely a wiring layer over
278
+ * the existing primitives — the game supplies the zone name and clock text,
279
+ * this only places them; omit either slot and the header disappears.
280
+ *
281
+ * @capability minimap-panel circular minimap with zone label, clock, and compass composed together
282
+ */
283
+ export function MinimapPanel({ zoneLabel, clock, showCompass = true, compassProps, headerClassName, zoneLabelClassName, clockClassName, compassClassName, children, ...minimapProps }) {
284
+ const hasHeader = zoneLabel !== undefined || clock !== undefined;
285
+ return (_jsxs("div", { "data-minimap-chrome": true, children: [hasHeader ? (_jsxs("div", { className: headerClassName, "data-minimap-chrome-header": true, style: {
286
+ display: "flex",
287
+ justifyContent: "space-between",
288
+ alignItems: "baseline",
289
+ gap: 8,
290
+ marginBottom: 4,
291
+ width: minimapProps.size ?? 176,
292
+ color: "#e2e8f0",
293
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
294
+ }, 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
+ }
243
296
  function WorldMapFogImage({ fogCells, project, worldW, worldD, width, mapH, }) {
244
297
  const fogImage = useMemo(() => {
245
298
  if (fogCells === null)
@@ -5,3 +5,5 @@ export declare function GameProvider({ context, children }: {
5
5
  children?: ReactNode;
6
6
  }): import("react").JSX.Element;
7
7
  export declare function useGameContext(): GameContext;
8
+ /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
9
+ export declare function useOptionalGameContext(): GameContext | null;
package/dist/provider.js CHANGED
@@ -10,3 +10,7 @@ export function useGameContext() {
10
10
  throw new Error("useGameContext must be used within <GameProvider>");
11
11
  return ctx;
12
12
  }
13
+ /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
14
+ export function useOptionalGameContext() {
15
+ return useContext(GameReactContext);
16
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Engine-owned rotate-device screen. A polished, non-dismissible full-viewport
3
+ * gate shown when a game requires an orientation the device isn't in. It owns
4
+ * the visible viewport (visualViewport-sized), respects safe areas, blocks all
5
+ * pointer/touch input to the game beneath, and is themeable through `--jg-*`
6
+ * tokens. Reduced-motion respected. This is game UI, not a website toast.
7
+ */
8
+ import type { CSSProperties, ReactNode } from "react";
9
+ import type { LayoutOrientation } from "@jgengine/core/ui/orientation";
10
+ /** Full-viewport, non-dismissible rotate-device gate shown when a game requires an orientation the device isn't in. */
11
+ export declare function RotateDeviceScreen({ title, description, requiredOrientation, accent, icon, className, style, }: {
12
+ /** Short headline. */
13
+ title?: string;
14
+ /** One concise explanatory line. Defaults from `requiredOrientation`. */
15
+ description?: string;
16
+ /** Orientation the game needs. Drives the rotation animation direction. */
17
+ requiredOrientation?: LayoutOrientation;
18
+ /** Illustration/token color. */
19
+ accent?: string;
20
+ /** Custom illustration; defaults to an animated phone glyph. */
21
+ icon?: ReactNode;
22
+ className?: string;
23
+ style?: CSSProperties;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const STYLE_ID = "jg-rotate-device-style";
3
+ const ROTATE_CSS = `
4
+ .jg-rotate-root {
5
+ position: fixed;
6
+ inset: 0;
7
+ z-index: 2147483000;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ width: var(--jg-visual-viewport-width, 100vw);
12
+ height: var(--jg-visual-viewport-height, 100dvh);
13
+ min-height: var(--jg-visual-viewport-height, 100vh);
14
+ padding: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-right, 0px) + 24px)
15
+ calc(env(safe-area-inset-bottom, 0px) + 24px) calc(env(safe-area-inset-left, 0px) + 24px);
16
+ color: var(--jg-text, #f4f4f6);
17
+ background:
18
+ radial-gradient(120% 120% at 50% 0%, var(--jg-accent-dim, rgba(120, 130, 160, 0.18)), transparent 60%),
19
+ var(--jg-surface, #0b0d13);
20
+ touch-action: none;
21
+ user-select: none;
22
+ -webkit-user-select: none;
23
+ overflow: hidden;
24
+ }
25
+ .jg-rotate-inner {
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ gap: 22px;
30
+ text-align: center;
31
+ max-width: 22rem;
32
+ }
33
+ .jg-rotate-phone {
34
+ transform-origin: 50% 50%;
35
+ }
36
+ .jg-rotate-title {
37
+ margin: 0;
38
+ font: 700 clamp(1.25rem, 5vw, 1.75rem) / 1.15 system-ui, -apple-system, "Segoe UI", sans-serif;
39
+ letter-spacing: -0.01em;
40
+ }
41
+ .jg-rotate-desc {
42
+ margin: 0;
43
+ font: 400 clamp(0.9rem, 3.4vw, 1.05rem) / 1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
44
+ color: var(--jg-text-dim, rgba(244, 244, 246, 0.66));
45
+ }
46
+ @media (prefers-reduced-motion: no-preference) {
47
+ .jg-rotate-phone--to-landscape { animation: jg-rotate-to-landscape 2.6s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
48
+ .jg-rotate-phone--to-portrait { animation: jg-rotate-to-portrait 2.6s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
49
+ }
50
+ @keyframes jg-rotate-to-landscape {
51
+ 0%, 22% { transform: rotate(0deg); }
52
+ 55%, 78% { transform: rotate(-90deg); }
53
+ 100% { transform: rotate(-90deg); }
54
+ }
55
+ @keyframes jg-rotate-to-portrait {
56
+ 0%, 22% { transform: rotate(-90deg); }
57
+ 55%, 78% { transform: rotate(0deg); }
58
+ 100% { transform: rotate(0deg); }
59
+ }
60
+ `;
61
+ function ensureStyle() {
62
+ if (typeof document === "undefined")
63
+ return;
64
+ if (document.getElementById(STYLE_ID) !== null)
65
+ return;
66
+ const el = document.createElement("style");
67
+ el.id = STYLE_ID;
68
+ el.textContent = ROTATE_CSS;
69
+ document.head.appendChild(el);
70
+ }
71
+ function PhoneGlyph({ accent }) {
72
+ return (_jsxs("svg", { width: "96", height: "96", viewBox: "0 0 96 96", fill: "none", "aria-hidden": true, focusable: "false", children: [_jsx("rect", { x: "34", y: "10", width: "28", height: "52", rx: "6", stroke: accent, strokeWidth: "3", fill: "none" }), _jsx("rect", { x: "39", y: "18", width: "18", height: "34", rx: "2", fill: accent, opacity: "0.22" }), _jsx("circle", { cx: "48", cy: "57.5", r: "1.8", fill: accent }), _jsx("path", { d: "M22 74a30 30 0 0 1 52 0", stroke: accent, strokeWidth: "3", strokeLinecap: "round", fill: "none", opacity: "0.5" }), _jsx("path", { d: "M70 68l6 8-10 1z", fill: accent, opacity: "0.5" })] }));
73
+ }
74
+ /** Full-viewport, non-dismissible rotate-device gate shown when a game requires an orientation the device isn't in. */
75
+ export function RotateDeviceScreen({ title = "Turn your device", description, requiredOrientation = "landscape", accent = "var(--jg-accent, #8ea2ff)", icon, className, style, }) {
76
+ ensureStyle();
77
+ const line = description ??
78
+ (requiredOrientation === "landscape"
79
+ ? "Rotate to landscape to play."
80
+ : "Rotate to portrait to play.");
81
+ const spin = requiredOrientation === "landscape" ? "jg-rotate-phone--to-landscape" : "jg-rotate-phone--to-portrait";
82
+ return (_jsx("div", { className: className === undefined ? "jg-rotate-root" : `jg-rotate-root ${className}`, style: style, "data-jg-orientation-gate": requiredOrientation, role: "dialog", "aria-modal": "true", "aria-label": title, onPointerDownCapture: (e) => e.stopPropagation(), onTouchStartCapture: (e) => e.stopPropagation(), children: _jsxs("div", { className: "jg-rotate-inner", children: [_jsx("div", { className: `jg-rotate-phone ${spin}`, children: icon ?? _jsx(PhoneGlyph, { accent: accent }) }), _jsx("h2", { className: "jg-rotate-title", children: title }), _jsx("p", { className: "jg-rotate-desc", children: line })] }) }));
83
+ }
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,133 @@
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
+ * @capability vital-bar compact current/max vital meter for an entity summary
39
+ */
40
+ export declare function VitalBar({ vital, width, showLabel, className, style, }: {
41
+ vital: EntityVital;
42
+ width?: number;
43
+ showLabel?: boolean;
44
+ className?: string;
45
+ style?: CSSProperties;
46
+ }): import("react").JSX.Element;
47
+ /**
48
+ * A selectable entity portrait tile — icon/initials, an optional thin health strip, and complete
49
+ * selected/hover/focus states as a real `<button>` (`aria-pressed`). The unit-of-a-selection chrome;
50
+ * swap the face via `renderIcon`. Accessible and keyboard-focusable.
51
+ *
52
+ * @capability entity-portrait selectable entity portrait tile with vitals and selected/focus states
53
+ */
54
+ export declare function EntityPortrait({ entity, selected, size, vitalId, tabIndex, onSelect, renderIcon, className, style, }: {
55
+ entity: EntitySummaryDef;
56
+ selected?: boolean;
57
+ size?: number;
58
+ vitalId?: string;
59
+ tabIndex?: number;
60
+ onSelect?: (id: string) => void;
61
+ renderIcon?: (entity: EntitySummaryDef) => ReactNode;
62
+ className?: string;
63
+ style?: CSSProperties;
64
+ }): import("react").JSX.Element;
65
+ /**
66
+ * The primary-entity detail chrome — name, tags, and full vital bars for the focused member. The
67
+ * "who is selected" summary a single-selection or the head of a multi-selection shows. Swap the vital
68
+ * rows via `renderVital`.
69
+ *
70
+ * @capability entity-summary primary selected-entity detail panel (name, tags, vitals)
71
+ */
72
+ export declare function EntitySummary({ entity, renderVital, className, style, }: {
73
+ entity: EntitySummaryDef;
74
+ renderVital?: (vital: EntityVital) => ReactNode;
75
+ className?: string;
76
+ style?: CSSProperties;
77
+ }): import("react").JSX.Element;
78
+ /**
79
+ * A same-kind selection chip — icon plus count — for the grouped large-selection view. The default
80
+ * chrome {@link SelectionCollectionChrome} renders per group; swap it via `renderGroup`.
81
+ *
82
+ * @capability selection-group-chip same-kind selection chip (icon + count) for a grouped selection
83
+ */
84
+ export declare function SelectionGroupChip({ group, onSelect, renderIcon, className, style, }: {
85
+ group: SelectionGroup;
86
+ onSelect?: (group: SelectionGroup) => void;
87
+ renderIcon?: (group: SelectionGroup) => ReactNode;
88
+ className?: string;
89
+ style?: CSSProperties;
90
+ }): import("react").JSX.Element;
91
+ /**
92
+ * The composable RENDERER for the member collection: a portrait strip for a small selection, or the
93
+ * same-kind group chips once the model is `grouped`. Roving tabindex + arrow-key focus. Swap
94
+ * `renderMember`/`renderGroup` for custom chrome, or read the model and lay it out yourself. No panel
95
+ * skin — a behavior/accessibility layer only.
96
+ *
97
+ * @capability selection-collection accessible portrait-strip / group-chip renderer over a selection model
98
+ */
99
+ export declare function SelectionCollectionChrome({ model, itemSize, gap, windowSize, scroll, ariaLabel, renderMember, renderGroup, onGroupSelect, className, style, }: {
100
+ model: SelectionModel;
101
+ itemSize?: number;
102
+ gap?: number;
103
+ /** Cap the mounted portraits; the model virtualizes past this many members. */
104
+ windowSize?: number;
105
+ scroll?: number;
106
+ ariaLabel?: string;
107
+ renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
108
+ renderGroup?: (group: SelectionGroup) => ReactNode;
109
+ onGroupSelect?: (group: SelectionGroup) => void;
110
+ className?: string;
111
+ style?: CSSProperties;
112
+ }): import("react").JSX.Element;
113
+ /**
114
+ * The batteries-included default: a skinned glass panel composing {@link EntitySummary} for the
115
+ * primary member beside {@link SelectionCollectionChrome} for the rest. Pass `members` (or a prebuilt
116
+ * `model`) and it renders a working RTS-style selection panel; every sub-renderer stays swappable, and
117
+ * a game that wants a different frame composes the hook and chrome directly.
118
+ *
119
+ * @capability selection-panel drop-in selection panel: primary summary beside a portrait strip / group chips
120
+ */
121
+ export declare function SelectionPanel({ members, model: providedModel, groupThreshold, labelOf, windowSize, renderPrimary, renderMember, renderVital, onFocusChange, className, style, }: {
122
+ members?: readonly EntitySummaryDef[];
123
+ model?: SelectionModel;
124
+ groupThreshold?: number;
125
+ labelOf?: (kind: string) => string;
126
+ windowSize?: number;
127
+ renderPrimary?: (entity: EntitySummaryDef) => ReactNode;
128
+ renderMember?: (entity: EntitySummaryDef, selected: boolean) => ReactNode;
129
+ renderVital?: (vital: EntityVital) => ReactNode;
130
+ onFocusChange?: (id: string) => void;
131
+ className?: string;
132
+ style?: CSSProperties;
133
+ }): import("react").JSX.Element | null;