@jgengine/react 0.12.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.
package/dist/panels.js ADDED
@@ -0,0 +1,215 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from "react";
3
+ import { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen as isPanelOpen, movePanel, openPanel, orderedOpen, panelByHotkey, } from "@jgengine/core/ui/panelModel";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ /**
6
+ * Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
7
+ * any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
8
+ * the intent without touching the DOM, so it unit-tests headless and `usePanels` is a thin shell over it.
9
+ */
10
+ export function panelKeyAction(defs, state, event) {
11
+ if (event.key === "Escape" || event.code === "Escape") {
12
+ return closeTopPanel(state) === state ? { handled: false } : { handled: true, type: "closeTop" };
13
+ }
14
+ const id = (event.code !== undefined ? panelByHotkey(defs, event.code) : null) ??
15
+ (event.key !== undefined ? panelByHotkey(defs, event.key) : null);
16
+ return id === null ? { handled: false } : { handled: true, type: "toggle", id };
17
+ }
18
+ /** @internal True when a key event targets a text input / textarea / contenteditable, where hotkeys must not fire. */
19
+ function isEditableTarget(target) {
20
+ const el = target;
21
+ if (el === null || typeof el.tagName !== "string")
22
+ return false;
23
+ const tag = el.tagName.toLowerCase();
24
+ return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable === true;
25
+ }
26
+ /**
27
+ * The DATA/HOOK layer: wrap the core panel model in state and install a window keybind listener —
28
+ * per-panel hotkeys toggle their window and ESC closes the topmost closable one. Hotkeys are ignored
29
+ * while the player is typing in an input/textarea/contenteditable. The listener is cleaned up on
30
+ * unmount and SSR-safe (armed in an effect). Feed the returned manager to {@link PanelHost}, or drive a
31
+ * bespoke window layout from it directly.
32
+ *
33
+ * @capability use-panels headless toggleable-window manager with keybind + ESC handling over the core panel model
34
+ */
35
+ export function usePanels(defs, options) {
36
+ const [state, setState] = useState(() => createPanelState(defs));
37
+ const stateRef = useRef(state);
38
+ stateRef.current = state;
39
+ const defsRef = useRef(defs);
40
+ defsRef.current = defs;
41
+ const onOpenRef = useRef(options?.onOpen);
42
+ onOpenRef.current = options?.onOpen;
43
+ const onCloseRef = useRef(options?.onClose);
44
+ onCloseRef.current = options?.onClose;
45
+ const open = useCallback((id) => {
46
+ const wasOpen = isPanelOpen(stateRef.current, id);
47
+ setState((s) => openPanel(s, id));
48
+ if (!wasOpen)
49
+ onOpenRef.current?.(id);
50
+ }, []);
51
+ const close = useCallback((id) => {
52
+ const wasOpen = isPanelOpen(stateRef.current, id);
53
+ setState((s) => closePanel(s, id));
54
+ if (wasOpen)
55
+ onCloseRef.current?.(id);
56
+ }, []);
57
+ const toggle = useCallback((id) => {
58
+ if (isPanelOpen(stateRef.current, id))
59
+ close(id);
60
+ else
61
+ open(id);
62
+ }, [open, close]);
63
+ const focus = useCallback((id) => setState((s) => focusPanel(s, id)), []);
64
+ const move = useCallback((id, pos) => setState((s) => movePanel(s, id, pos)), []);
65
+ const byHotkey = useCallback((code) => panelByHotkey(defsRef.current, code), []);
66
+ const hotkeysEnabled = options?.hotkeys !== false;
67
+ useEffect(() => {
68
+ if (!hotkeysEnabled || typeof window === "undefined")
69
+ return undefined;
70
+ const onKeyDown = (event) => {
71
+ if (event.defaultPrevented)
72
+ return;
73
+ if (event.key !== "Escape" && isEditableTarget(event.target))
74
+ return;
75
+ const result = panelKeyAction(defsRef.current, stateRef.current, {
76
+ code: event.code,
77
+ key: event.key,
78
+ });
79
+ if (!result.handled)
80
+ return;
81
+ event.preventDefault();
82
+ if (result.type === "closeTop") {
83
+ const topId = topClosableOpen(stateRef.current);
84
+ setState((s) => closeTopPanel(s));
85
+ if (topId !== null)
86
+ onCloseRef.current?.(topId);
87
+ }
88
+ else if (result.type === "toggle" && result.id !== undefined) {
89
+ toggle(result.id);
90
+ }
91
+ };
92
+ window.addEventListener("keydown", onKeyDown);
93
+ return () => window.removeEventListener("keydown", onKeyDown);
94
+ }, [hotkeysEnabled, toggle]);
95
+ return {
96
+ state,
97
+ defs,
98
+ isOpen: (id) => isPanelOpen(state, id),
99
+ open,
100
+ close,
101
+ toggle,
102
+ focus,
103
+ move,
104
+ byHotkey,
105
+ };
106
+ }
107
+ /** @internal The id the next ESC would close, so the caller can fire `onClose` with it. */
108
+ function topClosableOpen(state) {
109
+ const next = closeTopPanel(state);
110
+ if (next === state)
111
+ return null;
112
+ for (const id of Object.keys(state.open)) {
113
+ if (next.open[id] !== true)
114
+ return id;
115
+ }
116
+ return null;
117
+ }
118
+ // ---- Window chrome ---------------------------------------------------------
119
+ /** @internal Begin a pointer drag from the title bar, streaming new positions to `onMove` until release. */
120
+ function beginWindowDrag(event, origin, onMove) {
121
+ if (typeof window === "undefined")
122
+ return;
123
+ const startX = event.clientX;
124
+ const startY = event.clientY;
125
+ const base = { x: origin.x, y: origin.y };
126
+ const onPointerMove = (moveEvent) => {
127
+ onMove({ x: base.x + (moveEvent.clientX - startX), y: base.y + (moveEvent.clientY - startY) });
128
+ };
129
+ const onPointerUp = () => {
130
+ window.removeEventListener("pointermove", onPointerMove);
131
+ window.removeEventListener("pointerup", onPointerUp);
132
+ window.removeEventListener("pointercancel", onPointerUp);
133
+ };
134
+ window.addEventListener("pointermove", onPointerMove);
135
+ window.addEventListener("pointerup", onPointerUp);
136
+ window.addEventListener("pointercancel", onPointerUp);
137
+ }
138
+ const TITLEBAR_STYLE = {
139
+ display: "flex",
140
+ alignItems: "center",
141
+ justifyContent: "space-between",
142
+ gap: 8,
143
+ padding: "6px 8px 6px 10px",
144
+ cursor: "grab",
145
+ touchAction: "none",
146
+ userSelect: "none",
147
+ fontSize: 12,
148
+ fontWeight: 600,
149
+ letterSpacing: 0.3,
150
+ borderBottom: "var(--jg-frame-border, 1px solid rgba(255,255,255,0.10))",
151
+ };
152
+ const CLOSE_BUTTON_STYLE = {
153
+ display: "inline-flex",
154
+ alignItems: "center",
155
+ justifyContent: "center",
156
+ width: 20,
157
+ height: 20,
158
+ padding: 0,
159
+ borderRadius: 6,
160
+ border: "1px solid transparent",
161
+ background: "transparent",
162
+ color: "inherit",
163
+ font: "inherit",
164
+ lineHeight: 1,
165
+ cursor: "pointer",
166
+ opacity: 0.75,
167
+ };
168
+ /** @internal Shared, fully-controlled window view — title bar (drag + close) plus a HudFrame body. */
169
+ function WindowView({ title, ariaLabel, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, className, style, bodyStyle, children, }) {
170
+ return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: _jsxs(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
171
+ if (event.button !== 0)
172
+ return;
173
+ onFocus?.();
174
+ if (onMove !== undefined)
175
+ beginWindowDrag(event, pos, onMove);
176
+ }, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx("button", { type: "button", "data-jg-window-close": "", "aria-label": "Close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 10, ...bodyStyle }, children: children })] }) }));
177
+ }
178
+ /** @internal Fallback cascade position for a window whose state has no override yet. */
179
+ function cascade(index) {
180
+ return { x: 56 + index * 28, y: 56 + index * 28 };
181
+ }
182
+ /**
183
+ * Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable
184
+ * windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its
185
+ * title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels`
186
+ * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
187
+ * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
188
+ *
189
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
190
+ */
191
+ export function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }) {
192
+ const open = orderedOpen(manager.state, manager.defs);
193
+ const content = (id) => render !== undefined ? render(id) : (panels?.[id] ?? null);
194
+ return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: open.map((def, index) => (_jsx(WindowView, { title: def.title ?? def.id, ariaLabel: def.title ?? def.id, closable: def.closable !== false, onClose: () => manager.close(def.id), onFocus: () => manager.focus(def.id), onMove: (pos) => manager.move(def.id, pos), pos: manager.state.pos[def.id] ?? cascade(index), z: manager.state.z[def.id] ?? 1, width: width, variation: variation, shape: shape, className: windowClassName, style: windowStyle, bodyStyle: bodyStyle, children: content(def.id) }, def.id))) }));
195
+ }
196
+ /**
197
+ * A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
198
+ * usable without the {@link usePanels} manager for a one-off dialog. Position is uncontrolled (seeded by
199
+ * `x`/`y`) unless both `x`/`y` and `onMove` are supplied, in which case the caller owns placement.
200
+ * Accessible: `role="dialog"` with an `aria-label` and a focusable close button.
201
+ *
202
+ * @capability window standalone draggable, closable window primitive over HudFrame — no manager required
203
+ */
204
+ export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }) {
205
+ const controlled = x !== undefined && y !== undefined && onMove !== undefined;
206
+ const [localPos, setLocalPos] = useState({ x: x ?? 56, y: y ?? 56 });
207
+ const pos = controlled ? { x: x, y: y } : localPos;
208
+ const handleMove = useCallback((next) => {
209
+ if (!controlled)
210
+ setLocalPos(next);
211
+ onMove?.(next);
212
+ }, [controlled, onMove]);
213
+ const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
214
+ return (_jsx(WindowView, { title: title, ariaLabel: label, closable: closable, onClose: onClose, onMove: handleMove, pos: pos, z: z, width: width, variation: variation, shape: shape, className: className, style: style, bodyStyle: bodyStyle, children: children }));
215
+ }
package/dist/slots.d.ts CHANGED
@@ -3,10 +3,13 @@ import { type IconSchool } from "./iconTreatment.js";
3
3
  import type { GameIconName } from "./gameIcons.js";
4
4
  /**
5
5
  * Atomic, purpose-named slot grids (#1033): one component per category — `EquipmentSlots`,
6
- * `WeaponSlots`, `ArmorSlots`, `PotionSlots` — never a mega inventory that bundles every category.
7
- * Each renders a grid of painted {@link IconTreatment} tiles (glyph + gradient + count badge) for
8
- * filled slots and a themed placeholder for empty ones, all reading the shared `--jg-slot-*` /
9
- * `--jg-accent` HudTheme tokens (#1034). Composition stays the game's; the engine ships the grid.
6
+ * `WeaponSlots`, `ArmorSlots`, `PotionSlots` — for paperdoll / equip layouts where each grid holds a
7
+ * single gear category. A full multi-category backpack that binds to a live inventory with
8
+ * drag/swap/stack/split is a different, supported shape: reach for `<InventoryGrid>` (`inventoryGrid.tsx`)
9
+ * there, and use these atomic grids for the equip slots around it. Each renders a grid of painted
10
+ * {@link IconTreatment} tiles (glyph + gradient + count badge) for filled slots and a themed placeholder
11
+ * for empty ones, all reading the shared `--jg-slot-*` / `--jg-accent` HudTheme tokens (#1034).
12
+ * Composition stays the game's; the engine ships the grid.
10
13
  *
11
14
  * @capability hud-slots atomic themed equipment/weapon/armor/potion slot grids with painted icons
12
15
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.12.0"
34
+ "@jgengine/core": "^0.13.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^19.0.0",
@@ -42,6 +42,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
42
42
 
43
43
  Within each selected domain:
44
44
 
45
+ 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
45
46
  1. Search `capabilities.md` by intent; it maps needs to imports.
46
47
  2. Open `api.md` only when exact signatures or export inventory are needed.
47
48
  3. Open the linked reference only for a deeper recipe or trap.
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -38,7 +38,7 @@
38
38
  - `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/world/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
39
39
  - `auditManifest` (function): function auditManifest(manifest: SceneOwnershipManifest): OwnershipDiagnostic[] — Audit every declaration in a manifest, keying diagnostics by each object's provenance id. An empty result means the manifest declares all of its content cleanly — every object is authored, generated, bakeable, or a reasoned runtime/transient object.
40
40
  - `biomes` (function): function biomes(config: BiomesWorldConfig): WorldFeature — Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain.
41
- - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style.
41
+ - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
42
42
  - `classifyOwnership` (function): function classifyOwnership(declaration: SceneOwnershipDeclaration): OwnershipVerdict — Resolve one declaration into a single boundary verdict.
43
43
  - `collectOwnershipDiagnostics` (function): function collectOwnershipDiagnostics(entries: Iterable<readonly [string, SceneOwnershipDeclaration]>): OwnershipDiagnostic[] — Audit a keyed set of declarations and return one diagnostic per object that breaks the boundary contract (a `reject` verdict carrying a `violation`). Deterministic: diagnostics come back in the iteration order of `entries`.
44
44
  - `contextVerb` (function): function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb — Builds a {@link ContextVerb} for a right-click menu entry.
@@ -209,11 +209,18 @@
209
209
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
210
210
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
211
211
 
212
+ ## @jgengine/core/devtools/urlFlags
213
+
214
+ - `readUrlFlag` (function): function readUrlFlag(param: string): boolean — True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`).
215
+ - `readUrlParam` (function): function readUrlParam(param: string): string | null — The current query value of `param`, or null when absent (or no DOM).
216
+ - `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
217
+ - `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.
218
+
212
219
  ## @jgengine/core/meta/changelog
213
220
 
214
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
215
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
216
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
217
224
 
218
225
  ## @jgengine/core/runtime/adapter
219
226
 
@@ -276,7 +283,7 @@
276
283
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
277
284
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
278
285
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
279
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
286
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
280
287
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
281
288
  - `createGameContext` (function): function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext — ⚠ undocumented
282
289
 
@@ -307,7 +314,7 @@
307
314
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
308
315
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
309
316
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
310
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
317
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
311
318
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
312
319
 
313
320
  ## @jgengine/core/runtime/gameRuntime
@@ -322,6 +329,7 @@
322
329
 
323
330
  ## @jgengine/core/runtime/headlessRunner
324
331
 
332
+ - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
325
333
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
326
334
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
327
335
  - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — ⚠ undocumented
@@ -14,9 +14,9 @@ Search [capabilities.md](capabilities.md), use [api.md](api.md) for signatures,
14
14
  ## Canonical workflow
15
15
 
16
16
  1. Write the game's UI art direction first (see [reference.md](reference.md) §1) — fantasy, shape/material language, hierarchy, forbidden patterns. Stock glass widgets are not a stand-in for this step.
17
- 2. Define the real UI states: attract/menu, live play, pause, results, empty/error, and relevant overlays.
17
+ 2. Define the real UI states: attract, main menu (the game's front-end), character create/select where relevant, live play, pause, settings, credits, results, empty/error, and relevant overlays.
18
18
  3. Read state through selectors/hooks; keep simulation mutation behind commands.
19
- 4. Compose custom markup for this game. Prefer headless data + game-owned chrome. When a second presentation is plausible, split data / renderer / chrome (minimap stack example: `useLiveMarkers` → track/renderer → game frame; see reference §6).
19
+ 4. Compose this game's UI, reaching first for the shipped building blocks — `InventoryGrid`, `usePanels`/`PanelHost`/`Window`, `CharacterSheet`/`Paperdoll`, and the action/selection bars — and composing + reskinning them; only hand-roll custom markup where no building block fits. Toggleable windows (bag `B`, character `C`, etc.) come from `usePanels`, not hand-rolled open/close state. Prefer headless data + game-owned chrome. When a second presentation is plausible, split data / renderer / chrome (minimap stack example: `useLiveMarkers` → track/renderer → game frame; see reference §6).
20
20
  5. Make keyboard, pointer, touch, controller, focus, and screen-reader behavior explicit.
21
21
  6. Add preview fixtures using the real components for fast deterministic capture.
22
22
  7. Verify desktop and mobile layouts through `jgengine-verify`. A HUD that could pass for another game's default chrome fails visual review.
@@ -28,8 +28,10 @@ Existing React games keep their entity store and use the focused
28
28
  ## Product rules
29
29
 
30
30
  - **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader.
31
- - Engine packages do not supply a finished game face. Shared UI is headless or unskinned building blocks: layout (`HudCanvas`/`HudPanel`), data hooks, interaction models, tokens. Optional styled widgets exist for previews and scaffolding only — shipping them unskinned as the product is out of policy (see [AGENTS.md](../../../AGENTS.md)).
32
- - Never reach for genre HUD kits, theme presets, or "default RPG/FPS chrome" as the game's identity. Build the look this pitch needs.
31
+ - **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md) §"Main menu".
32
+ - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
33
+ - Engine packages ship both headless seams *and* good drop-in building blocks. The seams are layout (`HudCanvas`/`HudPanel`), data hooks, interaction models, and tokens; the building blocks are the parts almost every game needs — inventory grids (`InventoryGrid`), toggleable windows (`usePanels`/`PanelHost`/`Window`), character sheets (`CharacterSheet`/`Paperdoll`), stat/vitals bars, and action/selection bars. Composing and reskinning these is using the engine correctly, not out of policy. What stays out of scope is a whole finished game *face* or a genre theme preset shipped as the product (see [AGENTS.md](../../../AGENTS.md)).
34
+ - Never reach for a genre HUD kit, theme preset, or "default RPG/FPS chrome" as the game's *identity*. Composing the shared building blocks (bag, character, spellbook, action-bar windows) is fine and expected — it's a prefab genre *look/identity* dropped in unchanged that's discouraged. Build the look this pitch needs on top of them.
33
35
  - Layout and skin remain caller-controlled; shared primitives own reusable behavior, not product look.
34
36
  - SSR-visible output is hydration-stable; round computed SVG values at the boundary.
35
37