@jgengine/react 0.24.0 → 0.26.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/notices.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState, useSyncExternalStore } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { useGameViewportLayout } from "./gameViewport.js";
3
4
  import { useOptionalGameContext } from "./provider.js";
4
5
  const EXIT_MS = 200;
5
6
  const EMPTY = [];
@@ -11,11 +12,48 @@ const TONE_COLOR = {
11
12
  deny: "var(--jg-danger, #f26d6d)",
12
13
  };
13
14
  const TONE_LABEL = { info: "Info", success: "Done", warn: "Warning", deny: "Can't do that" };
14
- const PLACEMENT_STYLE = {
15
- top: { top: "clamp(64px, 14%, 150px)" },
16
- center: { top: "34%" },
17
- bottom: { bottom: "clamp(120px, 22%, 230px)", flexDirection: "column-reverse" },
15
+ const DEFAULT_OFFSET = {
16
+ top: "clamp(64px, 14%, 150px)",
17
+ center: "34%",
18
+ bottom: "clamp(120px, 22%, 230px)",
18
19
  };
20
+ const AVOID_GAP = 8;
21
+ function placementStyle(placement, offset) {
22
+ const edge = offset === undefined ? DEFAULT_OFFSET[placement] : typeof offset === "number" ? `${offset}px` : offset;
23
+ if (placement === "bottom")
24
+ return { bottom: `calc(var(--jg-safe-bottom, 0px) + ${edge})`, flexDirection: "column-reverse" };
25
+ if (placement === "center")
26
+ return { top: edge };
27
+ return { top: `calc(var(--jg-safe-top, 0px) + ${edge})` };
28
+ }
29
+ /**
30
+ * How far a top or bottom notice stack must move toward the screen middle to clear the registered
31
+ * layout regions in its column. `stack` is its current rect, already moved by `shift`; regions
32
+ * reaching past the container's middle (full-height sidebars, screens) are ignored. @internal
33
+ */
34
+ export function noticeAvoidShift(placement, stack, shift, container, regions, gap = AVOID_GAP) {
35
+ if (placement === "center")
36
+ return 0;
37
+ const middle = (container.top + container.bottom) / 2;
38
+ let need = 0;
39
+ for (const { kind, rect } of regions) {
40
+ if (kind === "screen" || rect.right <= rect.left || rect.bottom <= rect.top)
41
+ continue;
42
+ if (rect.right <= stack.left || rect.left >= stack.right)
43
+ continue;
44
+ if (placement === "top") {
45
+ if (rect.bottom > middle)
46
+ continue;
47
+ need = Math.max(need, rect.bottom + gap - (stack.top - shift));
48
+ }
49
+ else {
50
+ if (rect.top < middle)
51
+ continue;
52
+ need = Math.max(need, stack.bottom + shift - (rect.top - gap));
53
+ }
54
+ }
55
+ return Math.max(0, Math.round(need));
56
+ }
19
57
  const KEYFRAMES = `
20
58
  @keyframes jg-notice-in { from { opacity: 0; transform: translateY(-8px) scale(0.97); } to { opacity: 1; transform: none; } }
21
59
  @keyframes jg-notice-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-6px) scale(0.98); } }
@@ -79,11 +117,13 @@ export function useNoticeList(feed) {
79
117
  }
80
118
  /**
81
119
  * Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for
82
- * `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
120
+ * `ctx.game.notices` inside the HUD theme root. Uses `--jg-*` theme vars with fallbacks, 14px text, and
121
+ * never takes pointer input. A top or bottom stack keeps clear of HUD panels registered in its column
122
+ * and sits above modal overlays.
83
123
  *
84
- * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
124
+ * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance)
85
125
  */
86
- export function NoticeStack({ feed, placement = "top", scale = 1, renderNotice, }) {
126
+ export function NoticeStack({ feed, placement = "top", offset, avoidHud = true, zIndex = 85, scale = 1, renderNotice, }) {
87
127
  const ctx = useOptionalGameContext();
88
128
  const source = feed ?? ctx?.game.notices ?? null;
89
129
  const live = useNoticeList(source);
@@ -93,6 +133,19 @@ export function NoticeStack({ feed, placement = "top", scale = 1, renderNotice,
93
133
  setSeenLive(live);
94
134
  setShown(reconcileShown(shown, live));
95
135
  }
136
+ const { regions } = useGameViewportLayout();
137
+ const stackRef = useRef(null);
138
+ const [shift, setShift] = useState(0);
139
+ const visible = shown.length > 0;
140
+ useLayoutEffect(() => {
141
+ const element = stackRef.current;
142
+ const container = element?.offsetParent;
143
+ const next = element == null || container == null || !avoidHud
144
+ ? 0
145
+ : noticeAvoidShift(placement, element.getBoundingClientRect(), shift, container.getBoundingClientRect(), regions);
146
+ if (next !== shift)
147
+ setShift(next);
148
+ }, [regions, visible, placement, avoidHud, shift]);
96
149
  const anyLeaving = shown.some((entry) => entry.leaving);
97
150
  useEffect(() => {
98
151
  if (!anyLeaving)
@@ -102,18 +155,19 @@ export function NoticeStack({ feed, placement = "top", scale = 1, renderNotice,
102
155
  }, [anyLeaving]);
103
156
  if (shown.length === 0)
104
157
  return null;
105
- return (_jsxs("div", { "data-jg-notices": placement, role: "status", "aria-live": "polite", style: {
158
+ return (_jsxs("div", { ref: stackRef, "data-jg-notices": placement, role: "status", "aria-live": "polite", style: {
106
159
  position: "absolute",
107
160
  left: "50%",
108
161
  transform: "translateX(-50%)",
109
- zIndex: 45,
162
+ zIndex,
110
163
  display: "flex",
111
164
  flexDirection: "column",
112
165
  alignItems: "center",
113
166
  gap: 6 * scale,
114
167
  width: "min(460px, calc(100% - 32px))",
115
168
  pointerEvents: "none",
116
- ...PLACEMENT_STYLE[placement],
169
+ ...placementStyle(placement, offset),
170
+ ...(shift === 0 ? {} : placement === "bottom" ? { marginBottom: shift } : { marginTop: shift }),
117
171
  }, children: [_jsx("style", { children: KEYFRAMES }), shown.map(({ notice, leaving }) => (_jsx("div", { "data-jg-notice": notice.tone, "data-leaving": leaving ? "" : undefined, style: { maxWidth: "100%" }, children: renderNotice !== undefined ? renderNotice(notice) : (_jsxs("div", { "data-jg-notice-body": true, "data-bump": notice.count > 1 ? (notice.tone === "deny" ? "deny" : "other") : undefined, style: {
118
172
  display: "flex",
119
173
  alignItems: "center",
package/dist/panels.js CHANGED
@@ -5,7 +5,7 @@ import { useReducedMotion } from "./accessibility.js";
5
5
  import { HudButton } from "./hudButton.js";
6
6
  import { HudFrame } from "./hudFrame.js";
7
7
  import { useHudInteract } from "./hudInteract.js";
8
- import { HudPanel } from "./hudPanel.js";
8
+ import { HudSurface } from "./hudSurface.js";
9
9
  import { hudType } from "./hudTokens.js";
10
10
  import { observeBrowserSuspension } from "./browserLifecycle.js";
11
11
  import { usePresence } from "./presence.js";
@@ -214,7 +214,7 @@ function WindowView({ title, ariaLabel, open = true, closable, onClose, onFocus,
214
214
  retireDrag.current = beginWindowDrag(event, pos, onMove, () => { retireDrag.current = null; });
215
215
  }
216
216
  }, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx(HudButton, { variant: "ghost", size: "icon", "data-jg-window-close": "", "aria-label": "Close", cue: "window-close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 12, ...bodyStyle }, children: children })] }));
217
- return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", "data-state": presence, "data-jg-cue": cue, className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: variation === undefined ? (_jsx(HudPanel, { elevation: "modal", padding: 0, width: width, style: shape === "square" ? { borderRadius: 0 } : undefined, children: frameBody })) : (_jsx(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: frameBody })) }));
217
+ return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", "data-state": presence, "data-jg-cue": cue, className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: variation === undefined ? (_jsx(HudSurface, { elevation: "modal", padding: 0, width: width, style: shape === "square" ? { borderRadius: 0 } : undefined, children: frameBody })) : (_jsx(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: frameBody })) }));
218
218
  }
219
219
  /** @internal Keeps just-closed panels rendered (closing) for `exitMs` so their exit transition plays. */
220
220
  function useLeavingPanels(open, exitMs) {
package/dist/styles.css CHANGED
@@ -1,4 +1,6 @@
1
1
  /* @jgengine/react engine stylesheet */
2
+ @layer theme, base, components, utilities;
3
+ @layer components.jgengine {
2
4
  :root { --jg-motion-scale: 1; }
3
5
  @media (prefers-reduced-motion: reduce) { :root { --jg-motion-scale: 0; } }
4
6
  [data-reduced-motion="true"] { --jg-motion-scale: 0; }
@@ -32,7 +34,7 @@
32
34
  }
33
35
  :where([data-jg-hud]) :where(button, [role="button"]):where(:not(:disabled, [aria-disabled="true"])):active { scale: var(--jg-press-scale, 0.97); }
34
36
 
35
- [data-jg-button] { appearance: none; box-sizing: border-box; position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: var(--jg-space-sm, 8px); min-height: 36px; padding: 0 var(--jg-space-lg, 16px); margin: 0; font-family: var(--jg-font-body, "Inter Variable", Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); font-size: var(--jg-type-md, 14px); font-weight: 600; line-height: 1; letter-spacing: 0.01em; white-space: nowrap; color: var(--jg-text, #f5f7fa); background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03)), var(--jg-slot-bg, rgba(12,15,21,0.78)); border: var(--jg-slot-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-slot-radius, 8px); box-shadow: var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08)), var(--jg-elevation-raised, 0 1px 2px rgba(0,0,0,0.45), 0 4px 12px -2px rgba(0,0,0,0.4)); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: translate calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), scale calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)), box-shadow calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), border-color calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), background-color calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)); }
37
+ [data-jg-button] { appearance: none; box-sizing: border-box; position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: var(--jg-space-sm, 8px); min-height: 36px; padding: var(--jg-space-xs, 4px) var(--jg-space-lg, 16px); margin: 0; font-family: var(--jg-font-body, "Inter Variable", Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); font-size: var(--jg-type-md, 14px); font-weight: 600; line-height: 1.2; letter-spacing: 0.01em; text-align: center; white-space: normal; color: var(--jg-text, #f5f7fa); background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03)), var(--jg-slot-bg, rgba(12,15,21,0.78)); border: var(--jg-slot-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-slot-radius, 8px); box-shadow: var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08)), var(--jg-elevation-raised, 0 1px 2px rgba(0,0,0,0.45), 0 4px 12px -2px rgba(0,0,0,0.4)); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: translate calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), scale calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)), box-shadow calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), border-color calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)), background-color calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)); }
36
38
  [data-jg-button]::after { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: var(--jg-hover-overlay, rgba(255,255,255,0.07)); opacity: 0; pointer-events: none; transition: opacity calc(var(--jg-motion-fast, 120ms) * var(--jg-motion-scale, 1)) var(--jg-ease, cubic-bezier(0.2, 0, 0, 1)); }
37
39
  @media (hover: hover) {
38
40
  [data-jg-button]:not(:disabled, [aria-disabled="true"]):hover { translate: 0 var(--jg-hover-lift, -1px); box-shadow: var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08)), var(--jg-elevation-overlay, 0 2px 4px rgba(0,0,0,0.4), 0 10px 24px -4px rgba(0,0,0,0.5)); }
@@ -46,12 +48,12 @@
46
48
  [data-jg-button][data-variant="danger"] { color: #fff; background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0) 60%), var(--jg-danger, #e5484d); border: 1px solid rgba(255,255,255,0.22); }
47
49
  [data-jg-button][data-variant="ghost"] { background: transparent; border-color: transparent; box-shadow: none; }
48
50
  [data-jg-button][data-variant="ghost"]:not(:disabled, [aria-disabled="true"]):hover { box-shadow: none; }
49
- [data-jg-button][data-size="sm"] { min-height: 28px; padding: 0 var(--jg-space-md, 12px); font-size: var(--jg-type-sm, 13px); }
50
- [data-jg-button][data-size="lg"] { min-height: 44px; padding: 0 var(--jg-space-xl, 24px); font-size: var(--jg-type-lg, 16px); }
51
+ [data-jg-button][data-size="sm"] { min-height: 28px; padding-inline: var(--jg-space-md, 12px); font-size: var(--jg-type-sm, 13px); }
52
+ [data-jg-button][data-size="lg"] { min-height: 44px; padding-inline: var(--jg-space-xl, 24px); font-size: var(--jg-type-lg, 16px); }
51
53
  [data-jg-button][data-size="icon"] { min-height: 0; width: 28px; height: 28px; padding: 0; font-size: var(--jg-type-sm, 13px); }
52
54
  [data-jg-button][data-block] { display: flex; width: 100%; }
53
55
 
54
- [data-jg-panel] { box-sizing: border-box; color: var(--jg-text, #f5f7fa); font-family: var(--jg-font-body, "Inter Variable", Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); background: var(--jg-frame-bg, rgba(12,15,21,0.9)); border: var(--jg-frame-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-frame-radius, 10px); box-shadow: var(--jg-frame-glow, var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08)), var(--jg-elevation-raised, 0 1px 2px rgba(0,0,0,0.45), 0 4px 12px -2px rgba(0,0,0,0.4))); }
56
+ [data-jg-panel] { box-sizing: border-box; padding: var(--jg-panel-padding, 12px); color: var(--jg-text, #f5f7fa); font-family: var(--jg-font-body, "Inter Variable", Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); background: var(--jg-frame-bg, rgba(12,15,21,0.9)); border: var(--jg-frame-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-frame-radius, 10px); box-shadow: var(--jg-frame-glow, var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08)), var(--jg-elevation-raised, 0 1px 2px rgba(0,0,0,0.45), 0 4px 12px -2px rgba(0,0,0,0.4))); }
55
57
  [data-jg-panel][data-elevation="overlay"] { box-shadow: var(--jg-frame-glow, var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08))), var(--jg-elevation-overlay, 0 2px 4px rgba(0,0,0,0.4), 0 10px 24px -4px rgba(0,0,0,0.5)); }
56
58
  [data-jg-panel][data-elevation="modal"] { box-shadow: var(--jg-frame-glow, var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08))), var(--jg-elevation-modal, 0 4px 8px rgba(0,0,0,0.35), 0 24px 56px -12px rgba(0,0,0,0.7)); }
57
59
  [data-jg-panel][data-state="open"] { animation: jg-panel-in calc(var(--jg-motion-base, 180ms) * var(--jg-motion-scale, 1)) var(--jg-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
@@ -70,3 +72,4 @@
70
72
  @media (hover: hover) { [data-action-id][data-enabled]:hover { translate: 0 var(--jg-hover-lift, -1px); } }
71
73
  [data-action-id][data-enabled]:active { translate: 0 0; scale: var(--jg-press-scale, 0.97); }
72
74
  [data-action-id]:focus-visible { outline: none; box-shadow: var(--jg-focus-ring, 0 0 0 2px #0a0c10, 0 0 0 4px var(--jg-accent, #38bdf8)); }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",
@@ -35,7 +35,7 @@
35
35
  "styles": "bun scripts/styles.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.24.0"
38
+ "@jgengine/core": "^0.26.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^19.0.0",
@@ -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.24.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -82,25 +82,42 @@
82
82
  - `Devtools` (interface): interface Devtools — Top-level devtools facade aggregating frame, profile, render, logs, latency, controls, discover, overrides, and probes.
83
83
  - `DevtoolsControl` (interface): interface DevtoolsControl — Registered tunable control with its resolved schema metadata and read/write/reset accessors.
84
84
  - `DevtoolsControlKind` (type): type DevtoolsControlKind = | "slider" | "toggle" | "color" | "select" | "text" | "vec2" | "vec3" | "vec4" | "interval" | "angle" | "enum" — ⚠ undocumented
85
- - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, and formatted message.
85
+ - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, owning system tag, and formatted message.
86
86
  - `DevtoolsLogLevel` (type): type DevtoolsLogLevel = "log" | "info" | "warn" | "error" — Severity level for a captured devtools log entry.
87
+ - `DevtoolsLogs` (interface): interface DevtoolsLogs — Bounded log ring with per-system tags, a warning/error ring, and lifetime counts.
87
88
  - `DevtoolsOverrides` (interface): interface DevtoolsOverrides { version: number; enabled: string[]; values: Record<string, unknown>; schemas?: Record<string, {kind: DevtoolsControlKind; schemaVersion?: number}> } — ⚠ undocumented
88
89
  - `DevtoolsSnapshot` (interface): interface DevtoolsSnapshot — Serializable point-in-time snapshot of devtools state: frame, render, latency, logs, controls, and discovered fields.
89
90
  - `DiscoveredEntry` (interface): interface DiscoveredEntry — A control auto-discovered by scanning an object/table, with its binding metadata and current reader.
90
91
  - `DiscoverySkip` (interface): interface DiscoverySkip { readonly path: string; readonly reason: string } — ⚠ undocumented
91
92
  - `FrameRecordSample` (interface): interface FrameRecordSample — Per-frame timing input recorded by callers: total frame time, sim time, and optional named phase durations.
92
93
  - `FrameStats` (interface): interface FrameStats — Aggregated frame-timing statistics over the recent sampling window, including per-phase breakdown.
93
- - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — Frame duration in milliseconds above which a frame is recorded as a long-frame spike (~30fps budget).
94
+ - `FrameWindow` (interface): interface FrameWindow — Frame timings over the measured window, as the player sees them.
95
+ - `GlReport` (interface): interface GlReport — GPU-object creation counts. `total` is since the page loaded, the named fields since warmup.
96
+ - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — The one perf record shape. `bun run perf` (CDP, out of process) and the in-page telemetry report (`window.__JG.report()`, the dev-server route, `bun run debug`) both produce exactly this object, so a budget check and a diff read either one.
94
97
  - `LatencyStats` (interface): interface LatencyStats — Aggregated latency statistics (last/avg/min/max) over recorded latency samples.
98
+ - `LogBusCounts` (interface): interface LogBusCounts — Lifetime totals per level, plus how many lines the ring dropped.
99
+ - `LogBusEntry` (interface): interface LogBusEntry — One captured line: when, how bad, which system said it, and what it said.
100
+ - `LogBusSnapshot` (interface): interface LogBusSnapshot — Read-out of the log bus: newest-window entries, the warning/error ring, totals, and evictions.
95
101
  - `LongFrameEvent` (interface): interface LongFrameEvent — Recorded spike event for a frame that exceeded the long-frame threshold, with culprit attribution.
96
102
  - `NormalizedColor` (interface): interface NormalizedColor { readonly hex: string; readonly rgb: string; readonly alpha: number; readonly hasAlpha: boolean } — ⚠ undocumented
97
103
  - `OVERRIDES_FORMAT_VERSION` (const): const OVERRIDES_FORMAT_VERSION: 1 — ⚠ undocumented
98
104
  - `OverrideApplyDiagnostic` (interface): interface OverrideApplyDiagnostic { readonly id: string; readonly reason: string } — ⚠ undocumented
99
105
  - `OverrideApplyResult` (interface): interface OverrideApplyResult — Outcome of applying a devtools overrides payload: counts of applied and skipped entries plus diagnostics.
106
+ - `PerfQuality` (type): type PerfQuality = "full" | "draft" — Graphics tier the record was measured at.
107
+ - `PerfReport` (interface): interface PerfReport — One measured window of one game: the record `bun run perf`, `window.__JG.report()` and `bun run debug` print.
100
108
  - `PhaseStats` (interface): interface PhaseStats — Timing summary for a single named profiling phase across the sampling window.
109
+ - `ProfileEntry` (interface): interface ProfileEntry — One sampled stack frame reduced to a readable key and its self time.
110
+ - `ProfileReport` (interface): interface ProfileReport — CPU profile summary; only the CDP harness can produce one.
111
+ - `RenderReport` (interface): interface RenderReport — Renderer-side counts for the measured window; `null` where the source could not read them.
101
112
  - `RenderSample` (interface): interface RenderSample — Snapshot of renderer resource counts (draw calls, triangles, geometries, textures) for a frame.
113
+ - `ReportLogLine` (interface): interface ReportLogLine — One log line as it appears in the report.
114
+ - `ReportLogs` (interface): interface ReportLogs — Bounded log read-out carried by the report: warn/error lines survive even a flood of info logs.
115
+ - `ReportTelemetry` (interface): interface ReportTelemetry — Who produced the report, and how much producing it cost.
102
116
  - `ScanFieldMeta` (interface): interface ScanFieldMeta extends TunableOptions { kind?: DevtoolsControlKind } — ⚠ undocumented
103
117
  - `ScanMeta` (type): type ScanMeta = Readonly<Record<string, ScanFieldMeta>> — ⚠ undocumented
118
+ - `SceneNodeSource` (type): type SceneNodeSource = "scene-graph" | "heap-object3d" | "unavailable" — Where the scene-node count came from: the live scene graph, a heap walk, or nowhere.
119
+ - `SystemProbe` (interface): interface SystemProbe — What one system reports about itself: totals it keeps anyway, named timings, and one state line.
120
+ - `SystemProbeReading` (interface): interface SystemProbeReading — One system's probe read out on demand; missing sections stay empty rather than absent.
104
121
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
105
122
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
106
123
  - `TunableChoice` (interface): interface TunableChoice<T = unknown> { readonly value: T; readonly label?: string } — ⚠ undocumented
@@ -139,9 +156,20 @@
139
156
  ## @jgengine/core/devtools/frame
140
157
 
141
158
  - `FrameModule` (interface): interface FrameModule — Frame subsystem exposing the frame-timing, profiling, and render-sample facades.
142
- - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — Frame duration in milliseconds above which a frame is recorded as a long-frame spike (~30fps budget).
143
159
  - `createFrameModule` (const): const createFrameModule: (deps: { readProbes: () => Record<string, unknown>; }) => FrameModule — Create the frame subsystem that records frame/sim/phase timings, computes stats, and captures long-frame events.
144
160
 
161
+ ## @jgengine/core/devtools/logBus
162
+
163
+ - `LOG_BUS_CAPACITY` (const): const LOG_BUS_CAPACITY: 200 — Default measured window for the log ring; older lines are evicted, never grown.
164
+ - `LOG_BUS_MESSAGE_MAX` (const): const LOG_BUS_MESSAGE_MAX: 400 — Longest kept message; a longer one is cut once, at write time.
165
+ - `LOG_BUS_SEVERE_CAPACITY` (const): const LOG_BUS_SEVERE_CAPACITY: 64 — Warnings and errors also land in their own ring, so an error is never pushed out by info traffic.
166
+ - `LogBus` (interface): interface LogBus — A fixed-capacity ring of log lines tagged by owning system. `write` reuses a preallocated slot — no array growth, no splice, no per-write object — so a hot path can call it and only the message string (already built by the caller) costs an allocation.
167
+ - `LogBusCounts` (interface): interface LogBusCounts — Lifetime totals per level, plus how many lines the ring dropped.
168
+ - `LogBusEntry` (interface): interface LogBusEntry — One captured line: when, how bad, which system said it, and what it said.
169
+ - `LogBusOptions` (interface): interface LogBusOptions — Tunables for the log ring: capacity, the severity ring's capacity, and the clock.
170
+ - `LogBusSnapshot` (interface): interface LogBusSnapshot — Read-out of the log bus: newest-window entries, the warning/error ring, totals, and evictions.
171
+ - `createLogBus` (function): function createLogBus(options: LogBusOptions = {}): LogBus — Create the bounded log bus the devtools log facade writes through.
172
+
145
173
  ## @jgengine/core/devtools/logs
146
174
 
147
175
  - `LogsModule` (interface): interface LogsModule — Logs subsystem exposing the log-capture and latency-tracking facades.
@@ -153,6 +181,27 @@
153
181
  - `OverridesModule` (interface): interface OverridesModule — Overrides subsystem exposing the export/apply facade for persisting and restoring control values.
154
182
  - `createOverridesModule` (const): const createOverridesModule: (deps: { signal: ChangeSignal; controlRecords: Map<string, ControlRecord>; writeControl: (record: ControlRecord, raw: unknown) => boolean; discoveredRecords: Map<string, DiscoveredRecord>; enableDiscovered: (id: string) => void; }) => OverridesModule — Create the overrides subsystem that serializes changed control values and re-applies a saved overrides payload.
155
183
 
184
+ ## @jgengine/core/devtools/report
185
+
186
+ - `FrameWindow` (interface): interface FrameWindow — Frame timings over the measured window, as the player sees them.
187
+ - `GlReport` (interface): interface GlReport — GPU-object creation counts. `total` is since the page loaded, the named fields since warmup.
188
+ - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — The one perf record shape. `bun run perf` (CDP, out of process) and the in-page telemetry report (`window.__JG.report()`, the dev-server route, `bun run debug`) both produce exactly this object, so a budget check and a diff read either one.
189
+ - `PerfQuality` (type): type PerfQuality = "full" | "draft" — Graphics tier the record was measured at.
190
+ - `PerfReport` (interface): interface PerfReport — One measured window of one game: the record `bun run perf`, `window.__JG.report()` and `bun run debug` print.
191
+ - `ProfileEntry` (interface): interface ProfileEntry — One sampled stack frame reduced to a readable key and its self time.
192
+ - `ProfileReport` (interface): interface ProfileReport — CPU profile summary; only the CDP harness can produce one.
193
+ - `RenderReport` (interface): interface RenderReport — Renderer-side counts for the measured window; `null` where the source could not read them.
194
+ - `ReportLogLine` (interface): interface ReportLogLine — One log line as it appears in the report.
195
+ - `ReportLogs` (interface): interface ReportLogs — Bounded log read-out carried by the report: warn/error lines survive even a flood of info logs.
196
+ - `ReportTelemetry` (interface): interface ReportTelemetry — Who produced the report, and how much producing it cost.
197
+ - `SceneNodeSource` (type): type SceneNodeSource = "scene-graph" | "heap-object3d" | "unavailable" — Where the scene-node count came from: the live scene graph, a heap walk, or nowhere.
198
+
199
+ ## @jgengine/core/devtools/reportEndpoint
200
+
201
+ - `REPORT_ENDPOINT_PATH` (const): const REPORT_ENDPOINT_PATH: "/__jgengine/report" — The dev-only route a running game pushes its in-page report to, and that `bun run debug <game>` reads back. One process: the page gathers, the dev server holds the newest record per game, the CLI fetches it. Nothing here ships in a production build — the Vite plugin mounts it under `apply: "serve"`.
202
+ - `ReportPushBody` (interface): interface ReportPushBody — Body the page POSTs: the game it measured and the record itself.
203
+ - `ReportRouteError` (interface): interface ReportRouteError — Body sent when there is nothing to return yet.
204
+
156
205
  ## @jgengine/core/devtools/rewriteTunables
157
206
 
158
207
  - `TunableDelta` (interface): interface TunableDelta — One changed tunable to write back into game source: which table, key path, and new value.
@@ -203,8 +252,10 @@
203
252
 
204
253
  - `Devtools` (interface): interface Devtools — Top-level devtools facade aggregating frame, profile, render, logs, latency, controls, discover, overrides, and probes.
205
254
  - `DevtoolsControl` (interface): interface DevtoolsControl — Registered tunable control with its resolved schema metadata and read/write/reset accessors.
206
- - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, and formatted message.
255
+ - `DevtoolsLatency` (interface): interface DevtoolsLatency — Latency samples recorded by instrumented transport methods.
256
+ - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, owning system tag, and formatted message.
207
257
  - `DevtoolsLogLevel` (type): type DevtoolsLogLevel = "log" | "info" | "warn" | "error" — Severity level for a captured devtools log entry.
258
+ - `DevtoolsLogs` (interface): interface DevtoolsLogs — Bounded log ring with per-system tags, a warning/error ring, and lifetime counts.
208
259
  - `DevtoolsSnapshot` (interface): interface DevtoolsSnapshot — Serializable point-in-time snapshot of devtools state: frame, render, latency, logs, controls, and discovered fields.
209
260
  - `DiscoveredEntry` (interface): interface DiscoveredEntry — A control auto-discovered by scanning an object/table, with its binding metadata and current reader.
210
261
  - `FrameRecordSample` (interface): interface FrameRecordSample — Per-frame timing input recorded by callers: total frame time, sim time, and optional named phase durations.
@@ -214,6 +265,8 @@
214
265
  - `OverrideApplyResult` (interface): interface OverrideApplyResult — Outcome of applying a devtools overrides payload: counts of applied and skipped entries plus diagnostics.
215
266
  - `PhaseStats` (interface): interface PhaseStats — Timing summary for a single named profiling phase across the sampling window.
216
267
  - `RenderSample` (interface): interface RenderSample — Snapshot of renderer resource counts (draw calls, triangles, geometries, textures) for a frame.
268
+ - `SystemProbe` (interface): interface SystemProbe — What one system reports about itself: totals it keeps anyway, named timings, and one state line.
269
+ - `SystemProbeReading` (interface): interface SystemProbeReading — One system's probe read out on demand; missing sections stay empty rather than absent.
217
270
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
218
271
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
219
272
 
@@ -232,7 +285,7 @@
232
285
 
233
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.24.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
289
 
237
290
  ## @jgengine/core/runtime/adapter
238
291
 
@@ -48,6 +48,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
48
48
 
49
49
  - `defineSystem` (function) · `import { defineSystem } from "@jgengine/shell/gameKit"`
50
50
 
51
+ ## devtools-log-bus — a bounded, per-system-tagged log ring a system writes to and a report reads
52
+
53
+ - `LogBus` (interface) · `import { LogBus } from "@jgengine/core/devtools/logBus"`
54
+
51
55
  ## editor-catalogs — Export typed gameplay catalogs for in-editor tuning.
52
56
 
53
57
  - `EditorCatalogDefinition` (interface) · `import { EditorCatalogDefinition } from "@jgengine/shell/gameKit"`
@@ -32,8 +32,8 @@ Existing React games keep their entity store and use the focused
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
  - **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
34
34
  - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.
35
- - **The shell applies `defaultHudTheme` at the GameUI root.** `HudThemeRoot` wraps every `GameUI` in a `display: contents` `[data-jg-hud]` element carrying the theme vars and injects the engine stylesheet (`jg-*` keyframes, `[data-jg-*]` interaction states, reduced motion). Set `defineGame({ hudTheme })` to swap the root theme, `hudTheme: false` to opt out; a theme spread inside `GameUI` still wins. Games outside the shell add `@import "@jgengine/react/styles.css";` to `src/index.css` (the `jgengine create` template does). The theme also carries a type scale with a 12px floor, spacing, motion, elevation, and hover/press/focus/disabled tokens; read them in inline styles through `hudType`, `hudSpace`, `hudTransition`, `hudElevation`, and `HUD_FOCUS_RING` (`@jgengine/react/hudTokens`) — never hardcode text under 12px.
36
- - **Build interactive chrome from `HudButton`, `HudPanel`, and `TweenNumber`.** `HudButton` gives hover lift, press scale, focus ring, and disabled from tokens; `HudPanel` gives elevation, header slots, and an `open` enter/exit transition; `TweenNumber` / `useTweenedValue` roll counters with an up/down delta flash. Each takes `cue` (`data-jg-cue`) and `onInteract`; `HudInteractProvider` routes every primitive's hover/press/activate/open/close to one listener; the shell mounts one that plays the matching UI cue, so engine primitives sound with no wiring (`cue="confirm"` picks the activate sound).
35
+ - **The shell applies `defaultHudTheme` at the GameUI root.** `HudThemeRoot` wraps every `GameUI` in a `display: contents` `[data-jg-hud]` element carrying the theme vars and injects the engine stylesheet (`jg-*` keyframes, `[data-jg-*]` interaction states, reduced motion). Set `defineGame({ hudTheme })` to swap the root theme, `hudTheme: false` to opt out; a theme spread inside `GameUI` still wins. Games outside the shell add `@import "@jgengine/react/styles.css";` to `src/index.css` (the `jgengine create` template does). Every engine rule sits in the `components.jgengine` cascade layer, so plain game CSS and Tailwind utilities override engine buttons and panels without `!important`. The theme also carries a type scale with a 12px floor, spacing, motion, elevation, and hover/press/focus/disabled tokens; read them in inline styles through `hudType`, `hudSpace`, `hudTransition`, `hudElevation`, and `HUD_FOCUS_RING` (`@jgengine/react/hudTokens`) — never hardcode text under 12px.
36
+ - **Build interactive chrome from `HudButton`, `HudSurface`, and `TweenNumber`.** `HudButton` gives hover lift, press scale, focus ring, and disabled from tokens, and wraps long labels; `HudSurface` (`@jgengine/react/hudSurface`; `HudPanel` from `@jgengine/react/hudPanel` is its deprecated old name, not the `HudCanvas` slot) gives elevation, header slots, an `as` landmark element, padding from `--jg-panel-padding`, and an `open` enter/exit transition; `TweenNumber` / `useTweenedValue` roll counters with an up/down delta flash. Each takes `cue` (`data-jg-cue`) and `onInteract`; `HudInteractProvider` routes every primitive's hover/press/activate/open/close to one listener; the shell mounts one that plays the matching UI cue, so engine primitives sound with no wiring (`cue="confirm"` picks the activate sound). `audio.hudCues: false` silences those control sounds; notice sounds are separate (`notices.cues`).
37
37
  - **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph over a school/element-keyed gradient with gloss, vignette, and optional count/keycap badges, reading the `--jg-slot-*` / `--jg-accent` tokens. `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters for hotbars and ability bars.
38
38
  - **Player death gets a visible screen and respawn feedback — never a silent teleport.** Build the moment `game-design` requires: an authored death/downed overlay that reads the lethal event, communicates the consequence, and surfaces the way back (respawn/revive/restart). Drive it from `createDownedState` (`@jgengine/core/combat`) rather than a hand-rolled flag, and mount it inside `HudCanvas`.
39
39
  - **Browser interruption listeners are shared.** Use `useBrowserSuspension` from `@jgengine/react/browserLifecycle` for blur, hidden-page and pointer-lock loss. Keep the decision to pause, release controls or preserve multiplayer simulation in the callback; use options to disable unwanted triggers.
@@ -43,7 +43,7 @@ Existing React games keep their entity store and use the focused
43
43
 
44
44
  ## Shared-world interaction rules
45
45
 
46
- - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. Tune or hide the stock feed with `notices: { placement, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself).
46
+ - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. The stack renders inside the HUD theme root, so it takes the game's `hudTheme` colours and font; a top or bottom stack moves clear of `HudCanvas` panels and touch controls in its column and sits above `ModalHost` (z 85). Tune or hide it with `notices: { placement, offset, avoidHud, zIndex, cues, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself); `push({ text, silent: true })` skips one notice's sound.
47
47
  - **UI sounds come from `useUiCue()`.** Engine primitives already sound through the shell; for game-owned markup call `cue("click" | "hover" | "open" | "close" | "confirm" | "deny" | "notify")` from buttons and panels; replace a default by declaring `audio.sounds["ui.click"]`, or silence with `audio.uiCues: { hover: false }` / `false`.
48
48
  - Every rejected verb displays its reason on the triggering surface. Use `useServerSession(transport, gameId)` for join lifecycle. Join failure blocks play with `JoinGate` and retry; returning `{ ok: false }` without feedback is incomplete.
49
49
  - Placement previews display claim cost and affordability inline with `TerritoryOverlay`.