@jgengine/react 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -10,7 +10,53 @@ let consumers pick up the better stuff, not just to list what moved.
10
10
  Agents building on the published SDK can also read this programmatically:
11
11
  `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
12
12
  same data as typed values, so an updater can diff its installed version against
13
- the latest and surface the migration steps.
13
+ the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
14
+ run inside a game project diffs the installed `@jgengine/*` versions against the
15
+ latest release and prints every Migrate step and Adopt-worthy addition in
16
+ between (`--json` for structured output).
17
+
18
+ ## [Unreleased]
19
+
20
+ <!--
21
+ Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
+ the next release's notes are complete by construction. Add a bullet under the right
23
+ subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
24
+ is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
25
+ At publish, rename this heading to the new version and mirror the entries into
26
+ `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
+ -->
28
+
29
+ ### Migrate
30
+
31
+ - _Nothing yet._
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Removed
38
+
39
+ ## 0.14.0
40
+
41
+ ### Migrate
42
+
43
+ - **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
44
+ - One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
45
+
46
+ ### Added
47
+
48
+ - One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
49
+ - **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
50
+ - Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
51
+ - **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
52
+ - **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
53
+ - **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
54
+
55
+ ### Changed
56
+
57
+ - **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
58
+ - **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
59
+ - **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
14
60
 
15
61
  ## 0.13.0
16
62
 
package/dist/hudLayout.js CHANGED
@@ -22,6 +22,91 @@ const EDIT_BAR_Z = 100000;
22
22
  const REGION_GAP = 10;
23
23
  const COMPACT_HUD_SCALE = 0.85;
24
24
  const HUD_ANCHORS = Object.keys(HUD_ANCHOR_FRACTIONS);
25
+ /**
26
+ * Attribute a game puts on any HUD surface it *wants* to bleed past the viewport
27
+ * (a full-bleed backdrop, a deliberately clipped ticker), so the boundary check
28
+ * skips it. Opt-out, on purpose: the default is that shown UI must fit on screen.
29
+ */
30
+ const ALLOW_OVERFLOW_ATTR = "data-hud-allow-overflow";
31
+ /** Opt-in marker for a custom window mounted deeper than a direct HudCanvas child. */
32
+ const WINDOW_ATTR = "data-hud-window";
33
+ /**
34
+ * Dev-only, prod-safe. The on-screen boundary overlay must never reach players,
35
+ * so anything we cannot positively identify as a dev build resolves to `false`.
36
+ */
37
+ function isDevBuild() {
38
+ try {
39
+ const meta = import.meta;
40
+ if (meta.env?.DEV === true)
41
+ return true;
42
+ if (meta.env?.PROD === true)
43
+ return false;
44
+ }
45
+ catch {
46
+ /* import.meta.env unavailable under this bundler */
47
+ }
48
+ const nodeEnv = globalThis.process
49
+ ?.env?.NODE_ENV;
50
+ if (typeof nodeEnv === "string")
51
+ return nodeEnv !== "production";
52
+ return false;
53
+ }
54
+ /** True unless the element (or an ancestor) is hidden, collapsed, or removed from the a11y tree. */
55
+ function isSurfaceShown(el) {
56
+ if (el.closest('[aria-hidden="true"]') !== null)
57
+ return false;
58
+ const checkable = el;
59
+ if (typeof checkable.checkVisibility === "function") {
60
+ return checkable.checkVisibility({
61
+ contentVisibilityAuto: true,
62
+ opacityProperty: true,
63
+ visibilityProperty: true,
64
+ });
65
+ }
66
+ if (typeof getComputedStyle !== "function")
67
+ return true;
68
+ const style = getComputedStyle(el);
69
+ return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0.01;
70
+ }
71
+ function surfaceId(el) {
72
+ return (el.getAttribute("data-hud-panel") ??
73
+ el.getAttribute(WINDOW_ATTR) ??
74
+ el.getAttribute("data-hud-id") ??
75
+ el.getAttribute("aria-label") ??
76
+ (el.id.length > 0 ? el.id : null) ??
77
+ "<ui>");
78
+ }
79
+ /**
80
+ * Every HUD surface whose bounds we hold to the viewport: registered panels,
81
+ * any element tagged as a window, and the game's own direct children of the
82
+ * canvas (custom modals dropped straight into the HUD layer). Opted-out and
83
+ * hidden surfaces are dropped here so a closed drawer never reaches the check.
84
+ */
85
+ function collectBoundarySurfaces(canvas) {
86
+ const seen = new Set();
87
+ const out = [];
88
+ const consider = (el) => {
89
+ if (seen.has(el))
90
+ return;
91
+ seen.add(el);
92
+ if (el.closest(`[${ALLOW_OVERFLOW_ATTR}]`) !== null)
93
+ return;
94
+ if (!isSurfaceShown(el))
95
+ return;
96
+ out.push({ id: surfaceId(el), el });
97
+ };
98
+ for (const el of canvas.querySelectorAll(`[data-hud-panel],[${WINDOW_ATTR}]`))
99
+ consider(el);
100
+ for (const el of Array.from(canvas.children)) {
101
+ if (el.hasAttribute("data-hud-region") ||
102
+ el.hasAttribute("data-hud-edit-bar") ||
103
+ el.hasAttribute("data-hud-overflow-overlay")) {
104
+ continue;
105
+ }
106
+ consider(el);
107
+ }
108
+ return out;
109
+ }
25
110
  function sizeStyle(panel) {
26
111
  if (panel === undefined)
27
112
  return {};
@@ -200,6 +285,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
200
285
  const fitEnabled = hudViewport?.fitEnabled === true;
201
286
  const [viewport, setViewport] = useState(null);
202
287
  const [regions, setRegions] = useState({});
288
+ const [offenders, setOffenders] = useState([]);
203
289
  const regionRefs = useMemo(() => {
204
290
  const refs = {};
205
291
  for (const anchor of HUD_ANCHORS) {
@@ -285,18 +371,20 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
285
371
  return;
286
372
  let frame = 0;
287
373
  let lastReport = "";
374
+ const dev = isDevBuild();
288
375
  const check = () => {
289
376
  frame = 0;
290
377
  const hostRect = host.getBoundingClientRect();
291
378
  if (hostRect.width <= 0 || hostRect.height <= 0)
292
379
  return;
293
- const panels = [];
294
- for (const el of canvas.querySelectorAll("[data-hud-panel]")) {
295
- const rect = el.getBoundingClientRect();
380
+ const viewportSize = { width: hostRect.width, height: hostRect.height };
381
+ const surfaces = [];
382
+ for (const surface of collectBoundarySurfaces(canvas)) {
383
+ const rect = surface.el.getBoundingClientRect();
296
384
  if (rect.width === 0 && rect.height === 0)
297
385
  continue;
298
- panels.push({
299
- id: el.getAttribute("data-hud-panel") ?? "?",
386
+ surfaces.push({
387
+ id: surface.id,
300
388
  rect: {
301
389
  x: rect.left - hostRect.left,
302
390
  y: rect.top - hostRect.top,
@@ -305,8 +393,10 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
305
393
  },
306
394
  });
307
395
  }
308
- const offenders = overflowingPanels(panels, { width: hostRect.width, height: hostRect.height });
309
- const report = offenders.length === 0 ? "" : JSON.stringify(offenders);
396
+ // overflowingPanels reports only surfaces that are partly on screen *and*
397
+ // cross an edge — a closed/parked/off-screen menu is never an offender.
398
+ const escaped = overflowingPanels(surfaces, viewportSize);
399
+ const report = escaped.length === 0 ? "" : JSON.stringify(escaped);
310
400
  if (report === lastReport)
311
401
  return;
312
402
  lastReport = report;
@@ -315,7 +405,13 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
315
405
  }
316
406
  else {
317
407
  canvas.setAttribute("data-hud-overflow", report);
318
- console.warn(`[jgengine] HUD panels overflow the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
408
+ console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
409
+ }
410
+ if (dev) {
411
+ setOffenders(escaped.map((overflow) => {
412
+ const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
413
+ return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
414
+ }));
319
415
  }
320
416
  };
321
417
  const schedule = () => {
@@ -365,7 +461,40 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
365
461
  pointerEvents: "none",
366
462
  zIndex: 1,
367
463
  ...REGION_LAYOUT[anchor],
368
- } }, anchor))), children, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
464
+ } }, anchor))), children, offenders.length > 0 ? (_jsx("div", { "data-hud-overflow-overlay": "", style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: EDIT_BAR_Z - 1 }, children: offenders.map((o) => (_jsx("div", { style: {
465
+ position: "absolute",
466
+ left: o.x / scale,
467
+ top: o.y / scale,
468
+ width: o.width / scale,
469
+ height: o.height / scale,
470
+ border: "2px solid rgba(255, 64, 64, 0.95)",
471
+ boxShadow: "0 0 0 2px rgba(255, 64, 64, 0.3)",
472
+ boxSizing: "border-box",
473
+ }, children: _jsxs("span", { style: {
474
+ position: "absolute",
475
+ left: 0,
476
+ bottom: "100%",
477
+ marginBottom: 2,
478
+ padding: "2px 6px",
479
+ borderRadius: 4,
480
+ background: "rgba(180, 20, 20, 0.95)",
481
+ color: "#fff",
482
+ font: "600 10px/1.4 system-ui, sans-serif",
483
+ whiteSpace: "nowrap",
484
+ }, children: ["\u26A0 ", o.id, " escapes viewport (", overflowEdges(o), ")"] }) }, o.id))) })) : null, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
485
+ }
486
+ /** Human-readable edge list for the dev boundary badge, e.g. `right +260 · bottom +56`. */
487
+ function overflowEdges(overflow) {
488
+ const parts = [];
489
+ if (overflow.left > 0)
490
+ parts.push(`left +${overflow.left}`);
491
+ if (overflow.top > 0)
492
+ parts.push(`top +${overflow.top}`);
493
+ if (overflow.right > 0)
494
+ parts.push(`right +${overflow.right}`);
495
+ if (overflow.bottom > 0)
496
+ parts.push(`bottom +${overflow.bottom}`);
497
+ return parts.length > 0 ? parts.join(" · ") : "edge";
369
498
  }
370
499
  function chipAlignment(anchor) {
371
500
  if (anchor.endsWith("right"))
package/dist/panels.d.ts CHANGED
@@ -61,9 +61,15 @@ export declare function usePanels(defs: readonly PanelDef[], options?: UsePanels
61
61
  * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
62
62
  * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
63
63
  *
64
- * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
64
+ * The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
65
+ * *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
66
+ * rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
67
+ * window focus order still resolves inside that context, so clicking a window raises it over its peers.
68
+ * Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
69
+ *
70
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
65
71
  */
66
- export declare function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: {
72
+ export declare function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase, className, windowClassName, windowStyle, bodyStyle, }: {
67
73
  manager: PanelsManager;
68
74
  /** Content for a panel by id. Takes precedence over `panels`. */
69
75
  render?: (id: string) => ReactNode;
@@ -73,6 +79,8 @@ export declare function PanelHost({ manager, render, panels, variation, shape, w
73
79
  shape?: HudFrameShape;
74
80
  /** Default window width (per-panel unless overridden by the panel content). */
75
81
  width?: number | string;
82
+ /** z-index of the host's stacking context — the floor every window paints above. Default 40 (above the HUD). */
83
+ zIndexBase?: number;
76
84
  className?: string;
77
85
  windowClassName?: string;
78
86
  windowStyle?: CSSProperties;
package/dist/panels.js CHANGED
@@ -186,12 +186,18 @@ function cascade(index) {
186
186
  * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
187
187
  * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
188
188
  *
189
- * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
189
+ * The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
190
+ * *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
191
+ * rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
192
+ * window focus order still resolves inside that context, so clicking a window raises it over its peers.
193
+ * Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
194
+ *
195
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
190
196
  */
191
- export function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }) {
197
+ export function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }) {
192
198
  const open = orderedOpen(manager.state, manager.defs);
193
199
  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))) }));
200
+ return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, zIndex: zIndexBase, 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
201
  }
196
202
  /**
197
203
  * A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
@@ -4,6 +4,21 @@ export declare function GameProvider({ context, children }: {
4
4
  context: GameContext;
5
5
  children?: ReactNode;
6
6
  }): import("react").JSX.Element;
7
+ /**
8
+ * Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
9
+ * of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
10
+ * outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
11
+ * fix the shell already uses internally: capture the context outside the boundary (with
12
+ * {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
13
+ * renders children unchanged, so the same code works in showcases with no running game. This is the
14
+ * seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
15
+ *
16
+ * @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
17
+ */
18
+ export declare function GameContextBridge({ context, children, }: {
19
+ context: GameContext | null;
20
+ children?: ReactNode;
21
+ }): import("react").JSX.Element;
7
22
  export declare function useGameContext(): GameContext;
8
23
  /** The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews). */
9
24
  export declare function useOptionalGameContext(): GameContext | null;
package/dist/provider.js CHANGED
@@ -1,9 +1,25 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
3
  const GameReactContext = createContext(null);
4
4
  export function GameProvider({ context, children }) {
5
5
  return _jsx(GameReactContext.Provider, { value: context, children: children });
6
6
  }
7
+ /**
8
+ * Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>`
9
+ * of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the
10
+ * outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The
11
+ * fix the shell already uses internally: capture the context outside the boundary (with
12
+ * {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context
13
+ * renders children unchanged, so the same code works in showcases with no running game. This is the
14
+ * seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
15
+ *
16
+ * @capability game-context-bridge re-provide the GameContext across a nested Canvas/reconciler boundary
17
+ */
18
+ export function GameContextBridge({ context, children, }) {
19
+ if (context === null)
20
+ return _jsx(_Fragment, { children: children });
21
+ return _jsx(GameReactContext.Provider, { value: context, children: children });
22
+ }
7
23
  export function useGameContext() {
8
24
  const ctx = useContext(GameReactContext);
9
25
  if (ctx === null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0"
34
+ "@jgengine/core": "^0.14.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^19.0.0",
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
5
5
 
6
6
  # JGengine intake and router
7
7
 
8
- Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
8
+ Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
9
9
 
10
10
  ## 1. Establish the target
11
11
 
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
20
20
 
21
21
  For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
22
 
23
+ For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
24
+
23
25
  ## 2. Select domains
24
26
 
25
27
  Load only rows the target needs. Do not preload every domain.
@@ -43,7 +45,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
43
45
  Within each selected domain:
44
46
 
45
47
  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.
46
- 1. Search `capabilities.md` by intent; it maps needs to imports.
48
+ 1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
47
49
  2. Open `api.md` only when exact signatures or export inventory are needed.
48
50
  3. Open the linked reference only for a deeper recipe or trap.
49
51
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
@@ -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.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -220,7 +220,7 @@
220
220
 
221
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
222
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
223
- - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
224
224
 
225
225
  ## @jgengine/core/runtime/adapter
226
226
 
@@ -16,7 +16,7 @@ Search [capabilities.md](capabilities.md), use [api.md](api.md) for signatures,
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
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 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).
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 — and `PanelHost` already stacks windows above the HUD (`zIndexBase`) and wires hotkeys/ESC, so never hand-roll a `z-*` or keydown listener for them. A live 3D character-screen portrait is `EntityPreview` (`@jgengine/shell/render/EntityPreview`): it owns the nested `<Canvas>`, the `GameContextBridge` across the R3F boundary, and the `StudioStage` lighting — pass the game's own `renderEntity` as children (or a native `model`+`instanceId`) and it mirrors the live entity; don't re-derive the context bridge by hand. 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.
@@ -31,6 +31,8 @@ Existing React games keep their entity store and use the focused
31
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
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
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
+ - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the `--jg-*` custom properties that the atomic bars, `HudFrame variation="themed"`, painted slots, and the minimap ring all read, so one theme spread on any HUD ancestor reskins the whole subtree — palette, frame material, corner radius, glow, and slot chrome. Author your own theme (tower-guard's `fieldkitVars` is the model); `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. Caller `style`/`renderItem` overrides still win over the theme default.
35
+ - **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph (or any node) over a school/element-keyed radial gradient with a gloss + vignette and optional count/keycap badges, and reads the `--jg-slot-*` / `--jg-accent` `HudTheme` tokens so a theme change reskins every treated icon. `schoolForItem`/`schoolForAction` infer the gradient from an id; `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters. Use it in hotbars and ability bars so slots read as painted icons, not debug text.
34
36
  - 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.
35
37
  - Layout and skin remain caller-controlled; shared primitives own reusable behavior, not product look.
36
38
  - SSR-visible output is hydration-stable; round computed SVG values at the boundary.
@@ -41,3 +43,4 @@ Existing React games keep their entity store and use the focused
41
43
  - Do not duplicate inventory, combat, quest, or selection state inside components.
42
44
  - A static facsimile preview does not test the real UI; compose production components with fixtures.
43
45
  - Visual quality is judged from rendered evidence, not component counts or prose.
46
+ - Shown HUD UI must fit the player viewport. `HudCanvas` runs a live boundary check on every registered `HudPanel`, any element tagged `data-hud-window`, and its own direct children: if a *visible* surface crosses a viewport edge it draws a red dev overlay on the offender, `console.warn`s, and sets `data-hud-overflow` (which fails the `bun run shoot` gate). A closed/parked/hidden surface (off-screen drawer, unopened menu, `visibility:hidden`, `aria-hidden`) never trips it — only surfaces partly on screen and partly past an edge. Deliberate bleed opts out with `data-hud-allow-overflow`. Mount custom windows inside `HudCanvas` (tag deeper ones `data-hud-window`) so the check can see them.
@@ -172,7 +172,7 @@
172
172
  - `orderedOpen` (function): function orderedOpen(state: PanelState, defs: readonly PanelDef[]): PanelDef[] — The open panels' defs sorted ascending by z — the render order for a window host (map to elements so the last, highest-z window paints on top). Defs unknown to the state are skipped.
173
173
  - `orientationGateActive` (function): function orientationGateActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean — The rotate gate blocks gameplay: a hard requirement (or `unsupported`) the live orientation doesn't satisfy.
174
174
  - `orientationHintActive` (function): function orientationHintActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean — An advisory rotate hint applies: a preference (not a hard gate) the live orientation doesn't satisfy.
175
- - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate.
175
+ - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate. A rect is only reported when it is partly visible *and* crosses an edge; a fully off-screen (closed/hidden) surface is skipped, so parked drawers and unopened menus never raise a false alarm.
176
176
  - `panelByHotkey` (function): function panelByHotkey(defs: readonly PanelDef[], code: string): string | null — Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
177
177
  - `placePopover` (function): function placePopover(anchor: UiRect, content: UiSize, viewport: UiSize, options?: PopoverOptions): PopoverPlacement — Position a popover/tooltip of `content` size against its `anchor` rectangle inside `viewport`: open on the `preferred` side, flip to the opposite when that side lacks room and the opposite has more, then clamp the top-left so the box stays fully on screen (respecting `margin`). Pure and deterministic — no DOM reads — so it is SSR-safe and unit-testable, and the React tooltip is a thin shell over it.
178
178
  - `registerHudPanelType` (function): function registerHudPanelType(def: HudPanelTypeDef): void — Register a HUD panel type so the editor can list/add/configure it and canvas resize knows axes.
@@ -256,7 +256,7 @@
256
256
  - `HudPlatform` (type): type HudPlatform = "web" | "mobile" — Where a game is meant to be played. `"web"` alone keeps today's desktop-first HUD; adding `"mobile"` turns on design-resolution fit scaling on compact displays.
257
257
  - `HudViewportConfig` (interface): interface HudViewportConfig extends HudFitConfig — Per-game HUD viewport declaration carried on `PlayableGame.hudFit`; `mobile` overrides the fit on compact displays so the owner can tune the phone layout separately.
258
258
  - `hudScaleForViewport` (function): function hudScaleForViewport(fit: Required<HudFitConfig>, viewport: HudSize): number — The one scaling rule for every display: the ratio of the live viewport to the authored design size along the limiting axis, clamped. 1 on a viewport at or above design size; smoothly below 1 down to `minScale` on phones.
259
- - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate.
259
+ - `overflowingPanels` (function): function overflowingPanels(panels: readonly { id: string; rect: HudRect }[], viewport: HudSize, tolerance = 1.5): HudOverflow[] — Every panel rect that escapes the viewport — the data behind the HUD overflow gate. A rect is only reported when it is partly visible *and* crosses an edge; a fully off-screen (closed/hidden) surface is skipped, so parked drawers and unopened menus never raise a false alarm.
260
260
  - `rectOverflow` (function): function rectOverflow(rect: HudRect, viewport: HudSize, tolerance = 1.5): Omit<HudOverflow, "id"> | null — Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside).
261
261
  - `resolveHudFit` (function): function resolveHudFit(config: HudViewportConfig | undefined, mobile: boolean): Required<HudFitConfig> — ⚠ undocumented
262
262
 
@@ -392,6 +392,7 @@
392
392
  - `FriendRequestsList` (function): function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: { className?: string; rowClassName?: string; acceptClassName?: string; declineClassName?: string; emptyState?: ReactNode; renderRequest?: (request: FriendRequestEntry) => ReactNode… — ⚠ undocumented
393
393
  - `FriendRow` (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
394
394
  - `FriendsList` (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element — ⚠ undocumented
395
+ - `GameContextBridge` (function): function GameContextBridge({ context, children, }: { context: GameContext | null; children?: ReactNode; }): React.JSX.Element — Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>` of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The fix the shell already uses internally: capture the context outside the boundary (with {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context renders children unchanged, so the same code works in showcases with no running game. This is the seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
395
396
  - `GameIdentityProvider` (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
396
397
  - `GamePreviewComponent` (type): type GamePreviewComponent = ComponentType<GamePreviewProps> — ⚠ undocumented
397
398
  - `GamePreviewProps` (type): type GamePreviewProps = { className?: string; } — ⚠ undocumented
@@ -450,7 +451,7 @@
450
451
  - `MinimapTrackPip` (interface): interface MinimapTrackPip — A point marker on the {@link MinimapTrack} rail at a 0..1 fraction (e.g. a gate, the exit, or the player).
451
452
  - `MinimapTrackProps` (interface): interface MinimapTrackProps — Props for {@link MinimapTrack}.
452
453
  - `MinimapTrackSpan` (interface): interface MinimapTrackSpan — A colored span across the {@link MinimapTrack} rail, given by 0..1 `start`/`end` fractions (e.g. from core `trackFraction`).
453
- - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by panel id, used when … — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
454
+ - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by pan… — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
454
455
  - `PanelKeyResult` (interface): interface PanelKeyResult — React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable, draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close). `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
455
456
  - `PanelsManager` (interface): interface PanelsManager — The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}.
456
457
  - `Paperdoll` (function): function Paperdoll({ slots, portrait, slotSize = 44, onSlotActivate, className, style }: PaperdollProps): ReactNode — Arranges caller-supplied equip slots as a paperdoll — worn gear in two flanking columns around a center `portrait` (or a silhouette placeholder), with any `bottom` slots in a row beneath it. Each slot reuses the atomic {@link EquipmentSlots} tile (filled icon or themed empty placeholder), carries a label, and is a focusable `role="group"` with an `aria-label`; `onSlotActivate` fires on click/Enter/Space so games can wire drag or an equip picker. Composition and skin stay the game's.
@@ -949,7 +950,7 @@
949
950
 
950
951
  ## @jgengine/react/panels
951
952
 
952
- - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by panel id, used when … — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
953
+ - `PanelHost` (function): function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }: { manager: PanelsManager; /** Content for a panel by id. Takes precedence over `panels`. */ render?: (id: string) => ReactNode; /** Static content map by pan… — Renders the open panels of a {@link usePanels} manager as absolutely-positioned, draggable, closable windows in z-order — the WoW window layer. Each window uses {@link HudFrame} chrome, is dragged by its title bar, raises focus on pointer-down, and gets its content from the `render` prop (or a `panels` map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button; ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
953
954
  - `PanelKeyResult` (interface): interface PanelKeyResult — React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable, draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close). `usePanels` is the DATA/HOOK layer (open/close/toggle/focus + a keybind listener), `PanelHost` renders the open windows in z-order, and `Window` is a standalone one-off window. All unskinned and token-driven: games reskin via `HudTheme` tokens and by swapping the `HudFrame` variation.
954
955
  - `PanelsManager` (interface): interface PanelsManager — The live panel manager returned by {@link usePanels} and consumed by {@link PanelHost}.
955
956
  - `UsePanelsOptions` (interface): interface UsePanelsOptions — Options for {@link usePanels}.
@@ -965,6 +966,7 @@
965
966
 
966
967
  ## @jgengine/react/provider
967
968
 
969
+ - `GameContextBridge` (function): function GameContextBridge({ context, children, }: { context: GameContext | null; children?: ReactNode; }): React.JSX.Element — Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `<Canvas>` of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The fix the shell already uses internally: capture the context outside the boundary (with {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context renders children unchanged, so the same code works in showcases with no running game. This is the seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas.
968
970
  - `GameProvider` (function): function GameProvider({ context, children }: { context: GameContext; children?: ReactNode }): React.JSX.Element — ⚠ undocumented
969
971
  - `useGameContext` (function): function useGameContext(): GameContext — ⚠ undocumented
970
972
  - `useOptionalGameContext` (function): function useOptionalGameContext(): GameContext | null — The game context if a `GameProvider` is present, otherwise `null` — for chrome that may render outside a running game (showcases, previews).
@@ -1494,6 +1496,13 @@
1494
1496
 
1495
1497
  - `createGradePass` (function): function createGradePass(config: GradeConfig = {}): ShaderPass — Build the display-space colour-grade pass (lift/gain/gamma, saturation, vignette, grain). Advance `uniforms.uTime.value` each frame to animate the grain.
1496
1498
 
1499
+ ## @jgengine/shell/postfx/postfxOverlay
1500
+
1501
+ - `POSTFX_OVERLAY_USERDATA` (const): const POSTFX_OVERLAY_USERDATA: { readonly jgPostfxOverlay: true } — Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect — additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
1502
+ - `hidePostfxOverlays` (function): function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void — Hide every currently visible marked object under `scene`, recording what was hidden into `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo exactly that set.
1503
+ - `isPostfxOverlay` (function): function isPostfxOverlay(object: { userData?: Record<string, unknown> }): boolean — True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}.
1504
+ - `restorePostfxOverlays` (function): function restorePostfxOverlays(hidden: THREE.Object3D[]): void — Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list.
1505
+
1497
1506
  ## @jgengine/shell/registry
1498
1507
 
1499
1508
  - `GameRegistry` (type): type GameRegistry = Record<string, () => Promise<PlayableGame>> — ⚠ undocumented
@@ -1502,6 +1511,11 @@
1502
1511
  - `RenderObject` (type): type RenderObject = (object: SceneObject) => ReactNode — ⚠ undocumented
1503
1512
  - `resolveGameLoader` (function): function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined — ⚠ undocumented
1504
1513
 
1514
+ ## @jgengine/shell/render/EntityPreview
1515
+
1516
+ - `EntityPreview` (function): function EntityPreview({ children, model, instanceId, mood = "studio", backdrop, turntable = 0, faceCamera = false, forward, environment = true, cameraPosition = [0, 1, 3], fov = 35, dpr = [1, 2], className, style, }: EntityPreviewProps): React.JSX.Element — Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks, flinches, and topples in lockstep with the world instead of a canned spin.
1517
+ - `EntityPreviewProps` (interface): interface EntityPreviewProps — Props for {@link EntityPreview}.
1518
+
1505
1519
  ## @jgengine/shell/render/PartMotion
1506
1520
 
1507
1521
  - `PartMotionRig` (function): function PartMotionRig({ parts, model, instanceId, renderPart, children, }: { parts: readonly ModelPart[]; model: ModelConfig; instanceId?: string; /** Renders one part's model content (the shell passes its part renderer to avoid an import cycle). */ renderPart: (part: ModelPart, index: number) => R… — Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs, breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each role-tagged part swings around its authored transform — legs/arms counter-phase from the entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render as static kit pieces. Children are the base model content (primitive, attachments).
@@ -1531,6 +1545,12 @@
1531
1545
  - `measureLocalBounds` (function): function measureLocalBounds(root: THREE.Object3D): MeasuredLocalBounds | null — Measure the meshes under `root` in `root`'s own frame (root's transform is the measuring frame, children compose their local matrices below it). Returns `null` when nothing measurable is mounted. Skinned meshes measure at bind pose — the standard engine approximation.
1532
1546
  - `reportMeasuredBounds` (function): function reportMeasuredBounds(ctx: GameContext, target: "entity" | "object", key: string, bounds: MeasuredLocalBounds): void — Forward a measured result into the context (entity kind or object catalog id), deduping repeat reports of the same shape so remounts and multi-instance kinds don't churn collider resolution.
1533
1547
 
1548
+ ## @jgengine/shell/render/measureCollisionMesh
1549
+
1550
+ - `MeasuredCollisionTriangles` (interface): interface MeasuredCollisionTriangles extends CollisionMeshSource — Entity-local triangle soup of the meshes under a measured root, plus the contributing mesh count (same async fill-in detection as measured bounds). Satisfies {@link CollisionMeshSource}.
1551
+ - `measureLocalCollisionTriangles` (function): function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: { scale: number; offset: readonly [number, number, number] }): MeasuredCollisionTriangles | null — Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY} exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset` maps the result into entity-local space when the caller mounts the root scaled/translated (the model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the triangle budget — callers keep their box fallback.
1552
+ - `reportMeasuredCollisionMesh` (function): function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void — Forward measured triangles into the context (entity kind or object catalog id), deduping repeat reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH.
1553
+
1534
1554
  ## @jgengine/shell/render/modelRender
1535
1555
 
1536
1556
  - `MaterialCache` (interface): interface MaterialCache — ⚠ undocumented
@@ -65,6 +65,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
65
65
 
66
66
  - `EntityPortrait` (function) · `import { EntityPortrait } from "@jgengine/react"`
67
67
 
68
+ ## entity-preview — live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
69
+
70
+ - `EntityPreview` (function) · `import { EntityPreview } from "@jgengine/shell/render/EntityPreview"`
71
+
68
72
  ## entity-render-cues — live velocity/bob/fire/reload/hit/death cues for a custom renderEntity or viewmodel component
69
73
 
70
74
  - `useEntityRenderCues` (function) · `import { useEntityRenderCues } from "@jgengine/shell/render/useEntityRenderCues"`
@@ -77,6 +81,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
77
81
 
78
82
  - `useEventMeter` (function) · `import { useEventMeter } from "@jgengine/react"`
79
83
 
84
+ ## game-context-bridge — re-provide the GameContext across a nested Canvas/reconciler boundary
85
+
86
+ - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`
87
+
80
88
  ## heightfield-mesh-update — in-place partial update of a displaced ground plane mesh
81
89
 
82
90
  - `displaceHeightfieldGeometry` (function) · `import { displaceHeightfieldGeometry } from "@jgengine/shell/terrain"`
@@ -186,7 +194,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
186
194
 
187
195
  - `focusPanel` (function) · `import { focusPanel } from "@jgengine/core/ui"`
188
196
 
189
- ## panel-host — render a manager's open windows as draggable, closable, z-stacked dialogs
197
+ ## panel-host — render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
190
198
 
191
199
  - `PanelHost` (function) · `import { PanelHost } from "@jgengine/react"`
192
200