@jgengine/react 0.24.0 → 0.25.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
@@ -26,6 +26,33 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.25.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.25.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.22.0`; `@jgengine/github` is `0.12.0`.
34
+ - The frame panel from `@jgengine/react/hudPanel` is now `HudSurface` in `@jgengine/react/hudSurface` (`HudSurfaceProps`, `HudSurfaceElevation`). `HudPanel`, `HudPanelProps` and `HudPanelElevation` from `@jgengine/react/hudPanel` stay as deprecated aliases for this release. The `HudCanvas` layout slot keeps the name `HudPanel`.
35
+ - Engine HUD control sounds (button click/hover, panel and window open/close) no longer turn off with `notices: false` or `notices.cues: false`. Set `audio.hudCues: false` to silence them; `notices.cues` now covers notice sounds only.
36
+ - The notice stack now defaults to z-index 85 (was 45), above `ModalHost`. Pass `notices.zIndex` to put it back under a game overlay.
37
+
38
+ ### Added
39
+
40
+ - `HudSurface` takes `as` (`section`, `aside`, `nav`, ...) to render as a landmark.
41
+ - `notices.offset`, `notices.avoidHud` and `notices.zIndex`, with matching `NoticeStack` props. The offset adds to the device safe-area inset.
42
+ - `ctx.game.notices.push({ text, silent: true })` shows a notice without its sound.
43
+
44
+ ### Changed
45
+
46
+ - The engine stylesheet (`@jgengine/react/styles.css` and the injected copy) sits in the `components.jgengine` cascade layer, after a `theme, base, components, utilities` layer order, so plain game CSS and Tailwind utilities override engine buttons and panels without `!important`.
47
+ - `HudSurface` padding comes from `--jg-panel-padding` (default 12px); the `padding` prop sets that variable instead of an inline `padding`, so a game class or media query can override it.
48
+ - `HudButton` labels wrap (`white-space: normal`, line height 1.2, 4px vertical padding) instead of overflowing on one line.
49
+ - The shell renders the notice stack inside the HUD theme root, so notices use the game's `hudTheme` colours and font.
50
+ - A top or bottom notice stack moves clear of `HudCanvas` panels and touch controls registered in its column (Harbor Heat's courier card, action bars).
51
+
52
+ ### Fixed
53
+
54
+ - Flowing `HudCanvas` panels now register their layout region once their anchor region mounts; before, most never registered, so layout collision checks and region consumers missed them.
55
+
29
56
  ## 0.24.0
30
57
 
31
58
  ### Migrate
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate
3
- * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the
3
+ * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudSurface}, the
4
4
  * quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion
5
5
  * handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`).
6
- * Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that
7
- * mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
6
+ * Every rule sits in the `components.jgengine` cascade layer, so unlayered game CSS, a game's own
7
+ * `components` rules and Tailwind utilities win without `!important`. It first declares
8
+ * `theme, base, components, utilities` so Tailwind v4's preflight stays below engine controls even
9
+ * when the engine sheet loads first. The shell injects it; games that mount outside the shell import
10
+ * `@jgengine/react/styles.css`, which holds the same text.
8
11
  */
9
12
  export declare const ENGINE_STYLESHEET: string;
10
13
  /** Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op). */
@@ -16,13 +16,18 @@ const ENABLED = ':not(:disabled, [aria-disabled="true"])';
16
16
  const KILL_MOTION = "animation-duration: 1ms !important; animation-delay: 0ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; transition-delay: 0ms !important;";
17
17
  /**
18
18
  * The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate
19
- * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the
19
+ * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudSurface}, the
20
20
  * quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion
21
21
  * handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`).
22
- * Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that
23
- * mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
22
+ * Every rule sits in the `components.jgengine` cascade layer, so unlayered game CSS, a game's own
23
+ * `components` rules and Tailwind utilities win without `!important`. It first declares
24
+ * `theme, base, components, utilities` so Tailwind v4's preflight stays below engine controls even
25
+ * when the engine sheet loads first. The shell injects it; games that mount outside the shell import
26
+ * `@jgengine/react/styles.css`, which holds the same text.
24
27
  */
25
28
  export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
29
+ @layer theme, base, components, utilities;
30
+ @layer components.jgengine {
26
31
  :root { --jg-motion-scale: 1; }
27
32
  @media (prefers-reduced-motion: reduce) { :root { --jg-motion-scale: 0; } }
28
33
  [data-reduced-motion="true"] { --jg-motion-scale: 0; }
@@ -56,7 +61,7 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
56
61
  }
57
62
  :where([data-jg-hud]) :where(button, [role="button"]):where(${ENABLED}):active { scale: var(--jg-press-scale, 0.97); }
58
63
 
59
- [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, ${HUD_FONT_STACK}); 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: ${HIGHLIGHT}, ${RAISED}; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: translate ${FAST} ${EASE}, scale ${FAST} ${SPRING}, box-shadow ${FAST} ${EASE}, border-color ${FAST} ${EASE}, background-color ${FAST} ${EASE}; }
64
+ [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, ${HUD_FONT_STACK}); 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: ${HIGHLIGHT}, ${RAISED}; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: translate ${FAST} ${EASE}, scale ${FAST} ${SPRING}, box-shadow ${FAST} ${EASE}, border-color ${FAST} ${EASE}, background-color ${FAST} ${EASE}; }
60
65
  [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 ${FAST} ${EASE}; }
61
66
  @media (hover: hover) {
62
67
  [data-jg-button]${ENABLED}:hover { translate: 0 var(--jg-hover-lift, -1px); box-shadow: ${HIGHLIGHT}, ${OVERLAY}; }
@@ -70,12 +75,12 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
70
75
  [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); }
71
76
  [data-jg-button][data-variant="ghost"] { background: transparent; border-color: transparent; box-shadow: none; }
72
77
  [data-jg-button][data-variant="ghost"]${ENABLED}:hover { box-shadow: none; }
73
- [data-jg-button][data-size="sm"] { min-height: 28px; padding: 0 var(--jg-space-md, 12px); font-size: var(--jg-type-sm, 13px); }
74
- [data-jg-button][data-size="lg"] { min-height: 44px; padding: 0 var(--jg-space-xl, 24px); font-size: var(--jg-type-lg, 16px); }
78
+ [data-jg-button][data-size="sm"] { min-height: 28px; padding-inline: var(--jg-space-md, 12px); font-size: var(--jg-type-sm, 13px); }
79
+ [data-jg-button][data-size="lg"] { min-height: 44px; padding-inline: var(--jg-space-xl, 24px); font-size: var(--jg-type-lg, 16px); }
75
80
  [data-jg-button][data-size="icon"] { min-height: 0; width: 28px; height: 28px; padding: 0; font-size: var(--jg-type-sm, 13px); }
76
81
  [data-jg-button][data-block] { display: flex; width: 100%; }
77
82
 
78
- [data-jg-panel] { box-sizing: border-box; color: var(--jg-text, #f5f7fa); font-family: var(--jg-font-body, ${HUD_FONT_STACK}); 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, ${HIGHLIGHT}, ${RAISED}); }
83
+ [data-jg-panel] { box-sizing: border-box; padding: var(--jg-panel-padding, 12px); color: var(--jg-text, #f5f7fa); font-family: var(--jg-font-body, ${HUD_FONT_STACK}); 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, ${HIGHLIGHT}, ${RAISED}); }
79
84
  [data-jg-panel][data-elevation="overlay"] { box-shadow: var(--jg-frame-glow, ${HIGHLIGHT}), ${OVERLAY}; }
80
85
  [data-jg-panel][data-elevation="modal"] { box-shadow: var(--jg-frame-glow, ${HIGHLIGHT}), ${MODAL}; }
81
86
  [data-jg-panel][data-state="open"] { animation: jg-panel-in ${BASE} ${EASE_OUT} both; }
@@ -94,6 +99,7 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
94
99
  @media (hover: hover) { [data-action-id][data-enabled]:hover { translate: 0 var(--jg-hover-lift, -1px); } }
95
100
  [data-action-id][data-enabled]:active { translate: 0 0; scale: var(--jg-press-scale, 0.97); }
96
101
  [data-action-id]:focus-visible { outline: none; box-shadow: ${FOCUS}; }
102
+ }
97
103
  `;
98
104
  const STYLE_ID = "jg-engine-styles";
99
105
  /** Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op). */
@@ -4,7 +4,7 @@ import { ActionButton } from "./actionHud.js";
4
4
  import { HealthBar, ManaBar } from "./bars.js";
5
5
  import { HudButton } from "./hudButton.js";
6
6
  import { HudFrame } from "./hudFrame.js";
7
- import { HudPanel } from "./hudPanel.js";
7
+ import { HudSurface } from "./hudSurface.js";
8
8
  import { HudThemeRoot } from "./hudThemeRoot.js";
9
9
  import { ConfirmDialog, PauseMenu } from "./modals.js";
10
10
  import { Window } from "./panels.js";
@@ -23,5 +23,5 @@ const noop = () => undefined;
23
23
  * variants. Screenshots identically; the before/after evidence for theme and primitive changes.
24
24
  */
25
25
  export function HudChromePreview({ className }) {
26
- return (_jsx("div", { className: className, style: { position: "relative", width: "100%", height: "100%", minHeight: 720, overflow: "hidden", background: "radial-gradient(ellipse at 30% 20%, #3b5a46, #1b2a24 55%, #0f1714)" }, children: _jsxs(HudThemeRoot, { children: [_jsx("div", { style: { position: "absolute", left: 24, top: 24, width: 280 }, children: _jsx(HudFrame, { title: "Warden \u00B7 Lv 12", aside: _jsx(TweenNumber, { value: 12480 }), padding: 12, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx(HealthBar, { value: 78, max: 100, label: "HP", width: "100%" }), _jsx(ManaBar, { value: 50, max: 80, label: "MP", width: "100%" })] }) }) }), _jsx("div", { style: { position: "absolute", left: 24, bottom: 24, display: "flex", gap: 8 }, children: ACTIONS.map((action, index) => (_jsx(ActionButton, { action: action, focused: index === 0 }, action.id))) }), _jsx(Window, { title: "Inventory", x: 340, y: 24, width: 260, closable: true, onClose: noop, children: _jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 6 }, children: Array.from({ length: 10 }, (_, index) => (_jsx("div", { style: { aspectRatio: "1", background: "var(--jg-slot-bg)", border: "var(--jg-slot-border)", borderRadius: "var(--jg-slot-radius)" } }, index))) }) }), _jsx("div", { style: { position: "absolute", left: 640, top: 24 }, children: _jsx(ConfirmDialog, { title: "Abandon the expedition?", body: "Your haul stays in the wagon, but the crew returns home.", confirmLabel: "Abandon", danger: true, onConfirm: noop, onCancel: noop }) }), _jsx("div", { style: { position: "absolute", left: 1040, top: 24 }, children: _jsx(PauseMenu, { onResume: noop, items: [{ id: "settings", label: "Settings", onSelect: noop }, { id: "quit", label: "Quit to title", onSelect: noop, danger: true }] }) }), _jsx("div", { style: { position: "absolute", left: 340, top: 300 }, children: _jsx(HudPanel, { title: "Buttons", padding: 14, children: _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", width: 420 }, children: [_jsx(HudButton, { variant: "primary", children: "Deploy" }), _jsx(HudButton, { children: "Inspect" }), _jsx(HudButton, { variant: "ghost", children: "Skip" }), _jsx(HudButton, { variant: "danger", children: "Scrap" }), _jsx(HudButton, { disabled: true, children: "Locked" }), _jsx(HudButton, { size: "sm", children: "Small" })] }) }) })] }) }));
26
+ return (_jsx("div", { className: className, style: { position: "relative", width: "100%", height: "100%", minHeight: 720, overflow: "hidden", background: "radial-gradient(ellipse at 30% 20%, #3b5a46, #1b2a24 55%, #0f1714)" }, children: _jsxs(HudThemeRoot, { children: [_jsx("div", { style: { position: "absolute", left: 24, top: 24, width: 280 }, children: _jsx(HudFrame, { title: "Warden \u00B7 Lv 12", aside: _jsx(TweenNumber, { value: 12480 }), padding: 12, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx(HealthBar, { value: 78, max: 100, label: "HP", width: "100%" }), _jsx(ManaBar, { value: 50, max: 80, label: "MP", width: "100%" })] }) }) }), _jsx("div", { style: { position: "absolute", left: 24, bottom: 24, display: "flex", gap: 8 }, children: ACTIONS.map((action, index) => (_jsx(ActionButton, { action: action, focused: index === 0 }, action.id))) }), _jsx(Window, { title: "Inventory", x: 340, y: 24, width: 260, closable: true, onClose: noop, children: _jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 6 }, children: Array.from({ length: 10 }, (_, index) => (_jsx("div", { style: { aspectRatio: "1", background: "var(--jg-slot-bg)", border: "var(--jg-slot-border)", borderRadius: "var(--jg-slot-radius)" } }, index))) }) }), _jsx("div", { style: { position: "absolute", left: 640, top: 24 }, children: _jsx(ConfirmDialog, { title: "Abandon the expedition?", body: "Your haul stays in the wagon, but the crew returns home.", confirmLabel: "Abandon", danger: true, onConfirm: noop, onCancel: noop }) }), _jsx("div", { style: { position: "absolute", left: 1040, top: 24 }, children: _jsx(PauseMenu, { onResume: noop, items: [{ id: "settings", label: "Settings", onSelect: noop }, { id: "quit", label: "Quit to title", onSelect: noop, danger: true }] }) }), _jsx("div", { style: { position: "absolute", left: 340, top: 300 }, children: _jsx(HudSurface, { title: "Buttons", padding: 14, children: _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", width: 420 }, children: [_jsx(HudButton, { variant: "primary", children: "Deploy" }), _jsx(HudButton, { children: "Inspect" }), _jsx(HudButton, { variant: "ghost", children: "Skip" }), _jsx(HudButton, { variant: "danger", children: "Scrap" }), _jsx(HudButton, { disabled: true, children: "Locked" }), _jsx(HudButton, { size: "sm", children: "Small" })] }) }) })] }) }));
27
27
  }
package/dist/hudLayout.js CHANGED
@@ -579,7 +579,9 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
579
579
  allowOverlapWith,
580
580
  collisionGroup,
581
581
  }), [id, mobileBehavior, priority, allowOverlapWith, collisionGroup]);
582
- useRegisterLayoutRegion(regionSpec, rootRef, region && !regionHidden);
582
+ // A flowing panel portals into its anchor region, which HudCanvas only provides after its first commit.
583
+ const awaitingRegion = panel !== undefined && (compact || !panel.moved) && regions[compact ? anchor : panel.placement.anchor] === undefined;
584
+ useRegisterLayoutRegion(regionSpec, rootRef, region && !regionHidden && !awaitingRegion);
583
585
  const onPointerDown = (event) => {
584
586
  if (!draggable || event.button !== 0)
585
587
  return;
@@ -1,31 +1,17 @@
1
- import { type HTMLAttributes, type ReactNode } from "react";
2
- import { type HudInteractListener } from "./hudInteract.js";
3
- /** Shadow height of a {@link HudPanel}. */
4
- export type HudPanelElevation = "raised" | "overlay" | "modal";
5
- /** Props for {@link HudPanel}. */
6
- export interface HudPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
7
- /** Show or hide with an enter/exit transition. Unset renders a static panel with no transition. */
8
- open?: boolean;
9
- /** Uppercase header label at the top-left. */
10
- title?: ReactNode;
11
- /** Header content at the top-right, opposite `title`. */
12
- aside?: ReactNode;
13
- /** Shadow height. Default `raised`. */
14
- elevation?: HudPanelElevation;
15
- padding?: number | string;
16
- width?: number | string;
17
- /** Accept pointer events. Default `true`. */
18
- interactive?: boolean;
19
- /** Feedback cue name, rendered as `data-jg-cue` and passed to `onInteract`. */
20
- cue?: string;
21
- /** Called with `open` and `close` as the panel shows and hides. */
22
- onInteract?: HudInteractListener;
23
- }
1
+ import { type HudSurfaceElevation, type HudSurfaceProps } from "./hudSurface.js";
24
2
  /**
25
- * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
26
- * a short rise-and-fade and stays mounted through its exit when `open` turns false
27
- * (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
28
- *
29
- * @capability hud-panel themed panel frame with elevation, header slots, and open/close transitions
3
+ * Old name of {@link HudSurfaceElevation} (`@jgengine/react/hudSurface`).
4
+ * @deprecated Use `HudSurfaceElevation`; this alias goes in the next release.
30
5
  */
31
- export declare const HudPanel: import("react").ForwardRefExoticComponent<HudPanelProps & import("react").RefAttributes<HTMLDivElement>>;
6
+ export type HudPanelElevation = HudSurfaceElevation;
7
+ /**
8
+ * Old name of {@link HudSurfaceProps} (`@jgengine/react/hudSurface`).
9
+ * @deprecated Use `HudSurfaceProps`; this alias goes in the next release.
10
+ */
11
+ export type HudPanelProps = HudSurfaceProps;
12
+ /**
13
+ * Old name of the themed frame {@link HudSurface} (`@jgengine/react/hudSurface`), renamed so it no longer
14
+ * shares a name with the `HudCanvas` layout slot `HudPanel`.
15
+ * @deprecated Use `HudSurface`; this alias goes in the next release.
16
+ */
17
+ export declare const HudPanel: import("react").ForwardRefExoticComponent<HudSurfaceProps & import("react").RefAttributes<HTMLElement>>;
package/dist/hudPanel.js CHANGED
@@ -1,32 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useRef } from "react";
3
- import { useReducedMotion } from "./accessibility.js";
4
- import { useEngineStyles } from "./engineStyles.js";
5
- import { useHudInteract } from "./hudInteract.js";
6
- import { usePresence } from "./presence.js";
7
- const EXIT_MS = 140;
1
+ import { HudSurface } from "./hudSurface.js";
8
2
  /**
9
- * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
10
- * a short rise-and-fade and stays mounted through its exit when `open` turns false
11
- * (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
12
- *
13
- * @capability hud-panel themed panel frame with elevation, header slots, and open/close transitions
3
+ * Old name of the themed frame {@link HudSurface} (`@jgengine/react/hudSurface`), renamed so it no longer
4
+ * shares a name with the `HudCanvas` layout slot `HudPanel`.
5
+ * @deprecated Use `HudSurface`; this alias goes in the next release.
14
6
  */
15
- export const HudPanel = forwardRef(function HudPanel({ open, title, aside, elevation = "raised", padding = 12, width, interactive = true, cue, onInteract, style, children, ...rest }, ref) {
16
- useEngineStyles();
17
- const reduced = useReducedMotion();
18
- const shown = open ?? true;
19
- const presence = usePresence(shown, reduced ? 0 : EXIT_MS);
20
- const report = useHudInteract("panel", cue, onInteract);
21
- const wasOpen = useRef(shown);
22
- useEffect(() => {
23
- if (wasOpen.current === shown)
24
- return;
25
- wasOpen.current = shown;
26
- report(shown ? "open" : "close");
27
- }, [shown, report]);
28
- if (presence === "unmounted")
29
- return null;
30
- const hasHeader = title !== undefined || aside !== undefined;
31
- return (_jsxs("div", { ...rest, ref: ref, "data-jg-panel": "", "data-state": open === undefined ? undefined : presence, "data-elevation": elevation, "data-jg-cue": cue, style: { padding, width, pointerEvents: interactive ? "auto" : undefined, ...style }, children: [hasHeader ? (_jsxs("div", { "data-jg-panel-header": "", style: { marginBottom: 8 }, children: [_jsx("span", { children: title }), aside !== undefined ? _jsx("span", { children: aside }) : null] })) : null, children] }));
32
- });
7
+ export const HudPanel = HudSurface;
@@ -0,0 +1,38 @@
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
+ import { type HudInteractListener } from "./hudInteract.js";
3
+ /** Shadow height of a {@link HudSurface}. */
4
+ export type HudSurfaceElevation = "raised" | "overlay" | "modal";
5
+ /** Block elements a {@link HudSurface} can render as, so it can be a landmark. */
6
+ export type HudSurfaceElement = "div" | "section" | "aside" | "nav" | "header" | "footer" | "article" | "main" | "form" | "dialog";
7
+ /** Props for {@link HudSurface}. */
8
+ export interface HudSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
9
+ /** Element to render. Default `div`; `section`, `aside`, `nav` and the like make it a landmark. */
10
+ as?: HudSurfaceElement;
11
+ /** Show or hide with an enter/exit transition. Unset renders a static panel with no transition. */
12
+ open?: boolean;
13
+ /** Uppercase header label at the top-left. */
14
+ title?: ReactNode;
15
+ /** Header content at the top-right, opposite `title`. */
16
+ aside?: ReactNode;
17
+ /** Shadow height. Default `raised`. */
18
+ elevation?: HudSurfaceElevation;
19
+ /** Sets `--jg-panel-padding` (default 12px from the stylesheet), so a game class or media query still overrides it. */
20
+ padding?: number | string;
21
+ width?: number | string;
22
+ /** Accept pointer events. Default `true`. */
23
+ interactive?: boolean;
24
+ /** Feedback cue name, rendered as `data-jg-cue` and passed to `onInteract`. */
25
+ cue?: string;
26
+ /** Called with `open` and `close` as the panel shows and hides. */
27
+ onInteract?: HudInteractListener;
28
+ }
29
+ /**
30
+ * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
31
+ * a short rise-and-fade and stays mounted through its exit when `open` turns false
32
+ * (`data-state="open" | "closed"`); reduced motion skips both. Padding comes from
33
+ * `--jg-panel-padding`, and the engine rules sit in a cascade layer, so a game's `className`
34
+ * restyles it without `!important`. Not the `HudCanvas` layout slot (`HudPanel` in `@jgengine/react/hudLayout`).
35
+ *
36
+ * @capability hud-surface themed panel frame with elevation, header slots, landmark element, and open/close transitions
37
+ */
38
+ export declare const HudSurface: import("react").ForwardRefExoticComponent<HudSurfaceProps & import("react").RefAttributes<HTMLElement>>;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useRef } from "react";
3
+ import { useReducedMotion } from "./accessibility.js";
4
+ import { useEngineStyles } from "./engineStyles.js";
5
+ import { useHudInteract } from "./hudInteract.js";
6
+ import { usePresence } from "./presence.js";
7
+ const EXIT_MS = 140;
8
+ /**
9
+ * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
10
+ * a short rise-and-fade and stays mounted through its exit when `open` turns false
11
+ * (`data-state="open" | "closed"`); reduced motion skips both. Padding comes from
12
+ * `--jg-panel-padding`, and the engine rules sit in a cascade layer, so a game's `className`
13
+ * restyles it without `!important`. Not the `HudCanvas` layout slot (`HudPanel` in `@jgengine/react/hudLayout`).
14
+ *
15
+ * @capability hud-surface themed panel frame with elevation, header slots, landmark element, and open/close transitions
16
+ */
17
+ export const HudSurface = forwardRef(function HudSurface({ as: Element = "div", open, title, aside, elevation = "raised", padding, width, interactive = true, cue, onInteract, style, children, ...rest }, ref) {
18
+ useEngineStyles();
19
+ const reduced = useReducedMotion();
20
+ const shown = open ?? true;
21
+ const presence = usePresence(shown, reduced ? 0 : EXIT_MS);
22
+ const report = useHudInteract("panel", cue, onInteract);
23
+ const wasOpen = useRef(shown);
24
+ useEffect(() => {
25
+ if (wasOpen.current === shown)
26
+ return;
27
+ wasOpen.current = shown;
28
+ report(shown ? "open" : "close");
29
+ }, [shown, report]);
30
+ if (presence === "unmounted")
31
+ return null;
32
+ const hasHeader = title !== undefined || aside !== undefined;
33
+ const frameStyle = {
34
+ width,
35
+ pointerEvents: interactive ? "auto" : undefined,
36
+ ...(padding === undefined ? {} : { "--jg-panel-padding": typeof padding === "number" ? `${padding}px` : padding }),
37
+ ...style,
38
+ };
39
+ return (_jsxs(Element, { ...rest, ref: ref, "data-jg-panel": "", "data-state": open === undefined ? undefined : presence, "data-elevation": elevation, "data-jg-cue": cue, style: frameStyle, children: [hasHeader ? (_jsxs("div", { "data-jg-panel-header": "", style: { marginBottom: 8 }, children: [_jsx("span", { children: title }), aside !== undefined ? _jsx("span", { children: aside }) : null] })) : null, children] }));
40
+ });
package/dist/modals.d.ts CHANGED
@@ -88,7 +88,7 @@ export interface ConfirmDialogProps {
88
88
  }
89
89
  /**
90
90
  * A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
91
- * a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a
91
+ * a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a
92
92
  * modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
93
93
  * `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
94
94
  * the copy.
@@ -124,7 +124,7 @@ export interface PauseMenuProps {
124
124
  }
125
125
  /**
126
126
  * A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
127
- * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships
127
+ * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships
128
128
  * the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
129
129
  * title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
130
130
  * a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
package/dist/modals.js CHANGED
@@ -6,7 +6,7 @@ import { useDialogBehavior } from "./dialogBehavior.js";
6
6
  import { useEngineStyles } from "./engineStyles.js";
7
7
  import { HudButton } from "./hudButton.js";
8
8
  import { useHudInteract } from "./hudInteract.js";
9
- import { HudPanel } from "./hudPanel.js";
9
+ import { HudSurface } from "./hudSurface.js";
10
10
  import { hudType } from "./hudTokens.js";
11
11
  import { usePresence } from "./presence.js";
12
12
  /**
@@ -79,7 +79,7 @@ const DIALOG_BODY = { margin: "10px 0 0", fontSize: hudType("md"), lineHeight: 1
79
79
  const BUTTON_ROW = { display: "flex", gap: 10, marginTop: 20, justifyContent: "flex-end" };
80
80
  /**
81
81
  * A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
82
- * a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a
82
+ * a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a
83
83
  * modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
84
84
  * `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
85
85
  * the copy.
@@ -87,7 +87,7 @@ const BUTTON_ROW = { display: "flex", gap: 10, marginTop: 20, justifyContent: "f
87
87
  * @capability confirm-dialog generic themeable confirm/cancel dialog — title, body, two buttons, danger styling
88
88
  */
89
89
  export function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }) {
90
- return (_jsxs(HudPanel, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("h2", { "data-confirm-title": "", style: DIALOG_TITLE, children: title }), body !== undefined ? _jsx("p", { "data-confirm-body": "", style: DIALOG_BODY, children: body }) : null, _jsxs("div", { style: BUTTON_ROW, children: [_jsx(HudButton, { "data-confirm-cancel": "", cue: "cancel", onClick: onCancel, children: cancelLabel }), _jsx(HudButton, { "data-confirm-accept": "", cue: "confirm", variant: danger ? "danger" : "primary", onClick: onConfirm, children: confirmLabel })] })] }));
90
+ return (_jsxs(HudSurface, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("h2", { "data-confirm-title": "", style: DIALOG_TITLE, children: title }), body !== undefined ? _jsx("p", { "data-confirm-body": "", style: DIALOG_BODY, children: body }) : null, _jsxs("div", { style: BUTTON_ROW, children: [_jsx(HudButton, { "data-confirm-cancel": "", cue: "cancel", onClick: onCancel, children: cancelLabel }), _jsx(HudButton, { "data-confirm-accept": "", cue: "confirm", variant: danger ? "danger" : "primary", onClick: onConfirm, children: confirmLabel })] })] }));
91
91
  }
92
92
  const PAUSE_TITLE = {
93
93
  margin: "0 0 14px",
@@ -102,7 +102,7 @@ const PAUSE_ITEM = { marginTop: 8 };
102
102
  const PAUSE_DANGER_ITEM = { ...PAUSE_ITEM, color: "var(--jg-danger, #e5484d)", borderColor: "rgba(229,72,77,0.45)" };
103
103
  /**
104
104
  * A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
105
- * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships
105
+ * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships
106
106
  * the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
107
107
  * title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
108
108
  * a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
@@ -110,5 +110,5 @@ const PAUSE_DANGER_ITEM = { ...PAUSE_ITEM, color: "var(--jg-danger, #e5484d)", b
110
110
  * @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
111
111
  */
112
112
  export function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }) {
113
- return (_jsxs(HudPanel, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx(HudButton, { "data-pause-resume": "", cue: "resume", variant: "primary", size: "lg", block: true, onClick: onResume, children: resumeLabel }), items.map((item) => (_jsx(HudButton, { "data-pause-item": item.id, cue: `pause:${item.id}`, size: "lg", block: true, onClick: item.onSelect, disabled: item.disabled, style: item.danger ? PAUSE_DANGER_ITEM : PAUSE_ITEM, children: item.label }, item.id)))] }));
113
+ return (_jsxs(HudSurface, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx(HudButton, { "data-pause-resume": "", cue: "resume", variant: "primary", size: "lg", block: true, onClick: onResume, children: resumeLabel }), items.map((item) => (_jsx(HudButton, { "data-pause-item": item.id, cue: `pause:${item.id}`, size: "lg", block: true, onClick: item.onSelect, disabled: item.disabled, style: item.danger ? PAUSE_DANGER_ITEM : PAUSE_ITEM, children: item.label }, item.id)))] }));
114
114
  }
package/dist/notices.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { Notice, NoticeFeed } from "@jgengine/core/game/notices";
3
+ import type { LayoutRect, LayoutRegion } from "@jgengine/core/ui/gameLayout";
3
4
  export type { Notice, NoticeTone } from "@jgengine/core/game/notices";
4
5
  /** Where {@link NoticeStack} sits: below top-edge HUD bars, over the play area, or above bottom action bars. */
5
6
  export type NoticePlacement = "top" | "center" | "bottom";
7
+ /**
8
+ * How far a top or bottom notice stack must move toward the screen middle to clear the registered
9
+ * layout regions in its column. `stack` is its current rect, already moved by `shift`; regions
10
+ * reaching past the container's middle (full-height sidebars, screens) are ignored. @internal
11
+ */
12
+ export declare function noticeAvoidShift(placement: NoticePlacement, stack: LayoutRect, shift: number, container: LayoutRect, regions: readonly Pick<LayoutRegion, "kind" | "rect">[], gap?: number): number;
6
13
  type Shown = {
7
14
  notice: Notice;
8
15
  leaving: boolean;
@@ -13,14 +20,22 @@ export declare function reconcileShown(previous: readonly Shown[], live: readonl
13
20
  export declare function useNoticeList(feed: NoticeFeed | null): readonly Notice[];
14
21
  /**
15
22
  * Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for
16
- * `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
23
+ * `ctx.game.notices` inside the HUD theme root. Uses `--jg-*` theme vars with fallbacks, 14px text, and
24
+ * never takes pointer input. A top or bottom stack keeps clear of HUD panels registered in its column
25
+ * and sits above modal overlays.
17
26
  *
18
- * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
27
+ * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance)
19
28
  */
20
- export declare function NoticeStack({ feed, placement, scale, renderNotice, }: {
29
+ export declare function NoticeStack({ feed, placement, offset, avoidHud, zIndex, scale, renderNotice, }: {
21
30
  /** Defaults to the surrounding game's `ctx.game.notices`. */
22
31
  feed?: NoticeFeed;
23
32
  placement?: NoticePlacement;
33
+ /** Distance from the band's screen edge (px or CSS length), added to the safe-area inset. */
34
+ offset?: number | string;
35
+ /** Move a top/bottom stack clear of registered HUD panels and touch controls in its column. Default true. */
36
+ avoidHud?: boolean;
37
+ /** Default 85: above `ModalHost` (80) and `PanelHost` (40). */
38
+ zIndex?: number;
24
39
  /** Multiplies text and spacing (the shell passes the player's UI scale). */
25
40
  scale?: number;
26
41
  renderNotice?: (notice: Notice) => ReactNode;
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.25.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.25.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.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -232,7 +232,7 @@
232
232
 
233
233
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
234
  - `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.
235
+ - `VERSION` (const): const VERSION: "0.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
236
 
237
237
  ## @jgengine/core/runtime/adapter
238
238
 
@@ -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`.
@@ -601,7 +601,7 @@
601
601
  - `ComboMeterTheme` (interface): interface ComboMeterTheme — Reskin tokens for {@link ComboMeterHud}.
602
602
  - `Compass` (function): function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode — Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips from static views, an external source, or a native `MarkerSet`.
603
603
  - `CompassProps` (interface): interface CompassProps { facingYaw: number; center?: WorldXZ; markers?: MarkerCollection; width?: number; fov?: number; kindStyles?: Record<string, MarkerKindStyle>; className?: string } — ⚠ undocumented · used by `Compass`: Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips from static views, an …
604
- - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
604
+ - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
605
605
  - `ConfirmDialogProps` (interface): interface ConfirmDialogProps — Props for {@link ConfirmDialog}.
606
606
  - `ControlHint` (interface): interface ControlHint — One row of a control legend. Name the game action(s) whose bound key(s) to show (`action`) so the glyphs come straight from the keybind map — never re-typed — or give literal `keys` for controls that live outside the map (`"Mouse"`, `"LMB"`). `label` says what the control does.
607
607
  - `ControlsList` (function): function ControlsList({ bindings, controls, hideOnCoarsePointer = true, separator = " / ", className, rowClassName, keysClassName, keyClassName, separatorClassName, labelClassName, renderKey, renderRow, }: { bindings?: ActionCodesMap; controls: readonly ControlHint[]; hideOnCoarsePointer?: boolean; … — Renders a controls legend whose key glyphs come from the game's keybind map, so bindings live in one place and never drift from a hand-typed table. Hides itself on coarse-pointer devices (a touchscreen has no keyboard) via the same `data-jg-kbd-hint` marker as `KeyHint`. Headless: every part carries a `className` slot and `data-*` hook; pass `renderKey`/`renderRow` to fully own the markup.
@@ -772,7 +772,7 @@
772
772
  - `PartyFrame` (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element — ⚠ undocumented
773
773
  - `PartyInviteToast` (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null — ⚠ undocumented
774
774
  - `PartyMemberRow` (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
775
- - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
775
+ - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
776
776
  - `PauseMenuItem` (interface): interface PauseMenuItem — One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …).
777
777
  - `PauseMenuProps` (interface): interface PauseMenuProps — Props for {@link PauseMenu}.
778
778
  - `PhotoModeControls` (function): function PhotoModeControls({ store, onCapture, className, style }: PhotoModeControlsProps): ReactNode — Photo-mode toolbar — a hide/show-HUD toggle, a capture button, and an exit button, bound to a `createPhotoModeStore`. Presentation-only: the game wires `onCapture` to shell `captureCanvas`/`downloadImage` and reads `hideHud` to drop its gameplay HUD.
@@ -1241,7 +1241,7 @@
1241
1241
 
1242
1242
  ## @jgengine/react/engineStyles
1243
1243
 
1244
- - `ENGINE_STYLESHEET` (const): const ENGINE_STYLESHEET: string — The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`). Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
1244
+ - `ENGINE_STYLESHEET` (const): const ENGINE_STYLESHEET: string — The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudSurface}, the quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`). Every rule sits in the `components.jgengine` cascade layer, so unlayered game CSS, a game's own `components` rules and Tailwind utilities win without `!important`. It first declares `theme, base, components, utilities` so Tailwind v4's preflight stays below engine controls even when the engine sheet loads first. The shell injects it; games that mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
1245
1245
  - `ensureEngineStyles` (function): function ensureEngineStyles(doc: Document | undefined = typeof document === "undefined" ? undefined : document): void — Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op).
1246
1246
  - `useEngineStyles` (function): function useEngineStyles(): void — Ensures the engine stylesheet is in the document before the calling component paints. Engine primitives call it, so they animate and show interaction states even outside the shell.
1247
1247
 
@@ -1407,9 +1407,9 @@
1407
1407
 
1408
1408
  ## @jgengine/react/hudPanel
1409
1409
 
1410
- - `HudPanel` (const): const HudPanel: React.ForwardRefExoticComponent<HudPanelProps & React.RefAttributes<HTMLDivElement>> — Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with a short rise-and-fade and stays mounted through its exit when `open` turns false (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
1411
- - `HudPanelElevation` (type): type HudPanelElevation = "raised" | "overlay" | "modal" — Shadow height of a {@link HudPanel}.
1412
- - `HudPanelProps` (interface): interface HudPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> — Props for {@link HudPanel}.
1410
+ - `HudPanel` (const): const HudPanel: React.ForwardRefExoticComponent<HudSurfaceProps & React.RefAttributes<HTMLElement>> — Old name of the themed frame {@link HudSurface} (`@jgengine/react/hudSurface`), renamed so it no longer shares a name with the `HudCanvas` layout slot `HudPanel`.
1411
+ - `HudPanelElevation` (type): type HudPanelElevation = HudSurfaceElevation — Old name of {@link HudSurfaceElevation} (`@jgengine/react/hudSurface`).
1412
+ - `HudPanelProps` (type): type HudPanelProps = HudSurfaceProps — Old name of {@link HudSurfaceProps} (`@jgengine/react/hudSurface`).
1413
1413
 
1414
1414
  ## @jgengine/react/hudSkin
1415
1415
 
@@ -1422,6 +1422,13 @@
1422
1422
 
1423
1423
  - `HudSkinPreview` (function): function HudSkinPreview({ className }: { className?: string }): React.JSX.Element — Procedural two-skin fixture for visual regression captures.
1424
1424
 
1425
+ ## @jgengine/react/hudSurface
1426
+
1427
+ - `HudSurface` (const): const HudSurface: React.ForwardRefExoticComponent<HudSurfaceProps & React.RefAttributes<HTMLElement>> — Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with a short rise-and-fade and stays mounted through its exit when `open` turns false (`data-state="open" | "closed"`); reduced motion skips both. Padding comes from `--jg-panel-padding`, and the engine rules sit in a cascade layer, so a game's `className` restyles it without `!important`. Not the `HudCanvas` layout slot (`HudPanel` in `@jgengine/react/hudLayout`).
1428
+ - `HudSurfaceElement` (type): type HudSurfaceElement = "div" | "section" | "aside" | "nav" | "header" | "footer" | "article" | "main" | "form" | "dialog" — Block elements a {@link HudSurface} can render as, so it can be a landmark.
1429
+ - `HudSurfaceElevation` (type): type HudSurfaceElevation = "raised" | "overlay" | "modal" — Shadow height of a {@link HudSurface}.
1430
+ - `HudSurfaceProps` (interface): interface HudSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, "title"> — Props for {@link HudSurface}.
1431
+
1425
1432
  ## @jgengine/react/hudTheme
1426
1433
 
1427
1434
  - `HUD_THEME_PRESETS` (const): const HUD_THEME_PRESETS: { "arcane-stone": HudTheme; "survival-wood": HudTheme; "military-flat": HudTheme; "sleek-hex": HudTheme; "elemental-circle": HudTheme; "sandbox-slot": HudTheme; ember: HudTheme; synthwave: HudTheme; fieldkit: HudTheme; } — Built-in theme presets for demos/scaffold only — not product identity; games author their own theme.
@@ -1600,13 +1607,13 @@
1600
1607
 
1601
1608
  ## @jgengine/react/modals
1602
1609
 
1603
- - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
1610
+ - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
1604
1611
  - `ConfirmDialogProps` (interface): interface ConfirmDialogProps — Props for {@link ConfirmDialog}.
1605
1612
  - `ModalControls` (interface): interface ModalControls — Stack-bound controls handed to a {@link ModalHost} render callback.
1606
1613
  - `ModalHost` (function): function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, onInteract, className, style, }: ModalHostProps): ReactNode — Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click cancel the top modal, and focus returns to the previously focused element on close. It renders nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens.
1607
1614
  - `ModalHostProps` (interface): interface ModalHostProps — Props for {@link ModalHost}.
1608
1615
  - `ModalStackView` (interface): interface ModalStackView — React chrome over the headless modal stack (`@jgengine/core/ui/modalStack`) — the blocking-overlay layer a game raises over its scene: a pause menu, a "quit to menu?" confirm, a reward popup. {@link useModalStack} is the DATA/HOOK layer (subscribe + the active record), {@link ModalHost} renders the top modal with a backdrop, focus trap, and Esc-to-cancel, and {@link ConfirmDialog} / {@link PauseMenu} are ready-to-wire, HudTheme-token-driven building blocks. All unskinned and game-owned: the model never interprets a modal's `kind`, so a game switches on it to pick which of these (or its own) content to show, and reskins everything via `HudTheme` tokens and the frame.
1609
- - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
1616
+ - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
1610
1617
  - `PauseMenuItem` (interface): interface PauseMenuItem — One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …).
1611
1618
  - `PauseMenuProps` (interface): interface PauseMenuProps — Props for {@link PauseMenu}.
1612
1619
  - `useModalStack` (function): function useModalStack(stack: ModalStack): ModalStackView — Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
@@ -1615,7 +1622,7 @@
1615
1622
 
1616
1623
  - `Notice` (interface): interface Notice — A live notice as the HUD renders it.
1617
1624
  - `NoticePlacement` (type): type NoticePlacement = "top" | "center" | "bottom" — Where {@link NoticeStack} sits: below top-edge HUD bars, over the play area, or above bottom action bars.
1618
- - `NoticeStack` (function): function NoticeStack({ feed, placement = "top", scale = 1, renderNotice, }: { /** Defaults to the surrounding game's `ctx.game.notices`. */ feed?: NoticeFeed; placement?: NoticePlacement; /** Multiplies text and spacing (the shell passes the player's UI scale). */ scale?: number; renderNotice?: (not… — Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
1625
+ - `NoticeStack` (function): function NoticeStack({ feed, placement = "top", offset, avoidHud = true, zIndex = 85, scale = 1, renderNotice, }: { /** Defaults to the surrounding game's `ctx.game.notices`. */ feed?: NoticeFeed; placement?: NoticePlacement; /** Distance from the band's screen edge (px or CSS length), added to the … — Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for `ctx.game.notices` inside the HUD theme root. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input. A top or bottom stack keeps clear of HUD panels registered in its column and sits above modal overlays.
1619
1626
  - `NoticeTone` (type): type NoticeTone = "info" | "success" | "warn" | "deny" — How a notice reads: neutral info, a win, a caution, or a refused action.
1620
1627
  - `useNoticeList` (function): function useNoticeList(feed: NoticeFeed | null): readonly Notice[] — Live notices of `feed`, re-rendering on change and pruning each one when it expires.
1621
1628
 
@@ -336,6 +336,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
336
336
 
337
337
  - `StatBar` (function) · `import { StatBar } from "@jgengine/react"`
338
338
 
339
+ ## hud-surface — themed panel frame with elevation, header slots, landmark element, and open/close transitions
340
+
341
+ - `HudSurface` (const) · `import { HudSurface } from "@jgengine/react/hudSurface"`
342
+
339
343
  ## hud-theme — game-authored token object covering bars, frames, slots, chrome, text, status colors, fonts, and rarity
340
344
 
341
345
  - `HudThemePalette` (interface) · `import { HudThemePalette } from "@jgengine/react"`
@@ -502,7 +506,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
502
506
 
503
507
  - `WorldNameplates` (function) · `import { WorldNameplates } from "@jgengine/shell/world/WorldHud"`
504
508
 
505
- ## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
509
+ ## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance)
506
510
 
507
511
  - `NoticeStack` (function) · `import { NoticeStack } from "@jgengine/react/notices"`
508
512
 
@@ -70,7 +70,7 @@ instead of mirroring external entities into `createMarkerSet`.
70
70
 
71
71
  **Toggleable game windows** are already shipped, not hand-rolled open/close state: `usePanels(defs)` + `<PanelHost>` + `<Window>` (`@jgengine/react`) over the headless `@jgengine/core/ui/panelModel` are THE way to build openable windows — bag (`B`), character (`C`), spellbook, quest log — with keybind toggles, drag-to-move, close buttons, ESC-to-close, and z-stack ordering all handled. Declare each window in `defs` (id, keybind, title), mount one `<PanelHost>`, and render each panel's body inside a `<Window>`; the game owns the body content and skin, the model owns focus/stacking/keybind wiring. Never re-derive window toggling from local `useState` booleans and hand-rolled keydown listeners.
72
72
 
73
- Window title-bar movement belongs to one primary pointer. Release, cancellation, capture loss, blur, and window unmount retire that gesture's listeners; another pointer cannot move it. Controlled positions and panel z-order remain caller-owned. Windows without a `variation` render on `HudPanel` (theme frame plus modal elevation) and rise in on open; `PanelHost` keeps a closed window mounted for its short exit transition, and closing retires any drag in progress. `ModalHost` fades its backdrop and lifts the dialog in, and plays the same exit when the stack empties; `ConfirmDialog` and `PauseMenu` are built from `HudPanel` and `HudButton`.
73
+ Window title-bar movement belongs to one primary pointer. Release, cancellation, capture loss, blur, and window unmount retire that gesture's listeners; another pointer cannot move it. Controlled positions and panel z-order remain caller-owned. Windows without a `variation` render on `HudSurface` (theme frame plus modal elevation) and rise in on open; `PanelHost` keeps a closed window mounted for its short exit transition, and closing retires any drag in progress. `ModalHost` fades its backdrop and lifts the dialog in, and plays the same exit when the stack empties; `ConfirmDialog` and `PauseMenu` are built from `HudSurface` and `HudButton`.
74
74
 
75
75
  **Character sheet / paperdoll** is already shipped, not hand-rolled: `<CharacterSheet>` / `<Paperdoll>` / `<StatList>` (`@jgengine/react`) compose the paperdoll equip layout (worn `EquipmentSlots` arranged around a portrait) and the derived-stat readout into one window body. `Paperdoll` is the equip-slot arrangement, `StatList` the derived-stat rows, `CharacterSheet` the batteries-included default that pairs them — the game composes and reskins them (slot layout, stat labels/terminology, skin) rather than re-deriving a paperdoll from raw divs and manual stat math.
76
76