@jgengine/react 0.26.0 → 0.28.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.
@@ -0,0 +1,85 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { SettingsCategoryView, SettingsKeybindRow, SettingsRow } from "./settings.js";
3
+ /**
4
+ * One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
5
+ * drag never storms the settings store or the graphics rebuild it drives.
6
+ *
7
+ * @capability settings-slider a settings slider row that mirrors locally and persists on release
8
+ */
9
+ export declare function SettingsSlider({ row }: {
10
+ row: SettingsRow;
11
+ }): ReactNode;
12
+ /** One on/off row from a {@link SettingsRow}. */
13
+ export declare function SettingsToggle({ row }: {
14
+ row: SettingsRow;
15
+ }): ReactNode;
16
+ /** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
17
+ export declare function SettingsSelect({ row }: {
18
+ row: SettingsRow;
19
+ }): ReactNode;
20
+ /** Render the control a {@link SettingsRow} kind asks for. */
21
+ export declare function SettingsRowControl({ row }: {
22
+ row: SettingsRow;
23
+ }): ReactNode;
24
+ /** One rebindable action row: current key glyph, armed-capture state and a reset button. */
25
+ export interface SettingsKeybindControlProps {
26
+ row: SettingsKeybindRow;
27
+ /** This row is waiting for a key. */
28
+ armed?: boolean;
29
+ /** Click on the binding — the caller arms capture. */
30
+ onArm?: () => void;
31
+ /** Props the caller can spread onto the binding button for focus tracking. */
32
+ buttonProps?: Record<string, unknown>;
33
+ }
34
+ /**
35
+ * A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
36
+ * and reset. The caller owns capture; this is the row's presentation.
37
+ *
38
+ * @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
39
+ */
40
+ export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode;
41
+ /** How a {@link SettingsSection} handles a rebind capture. */
42
+ export interface SettingsSectionProps {
43
+ /** The category to render: its rows and its rebindable actions. */
44
+ category: SettingsCategoryView;
45
+ /** Render the category label above the rows. Default true. */
46
+ heading?: boolean;
47
+ /** Allow click-to-rebind on the controls rows. Default true. */
48
+ capture?: boolean;
49
+ className?: string;
50
+ style?: CSSProperties;
51
+ /** Called with the armed action id (or `null`) so a game can suspend its own keys. */
52
+ onCaptureChange?: (action: string | null) => void;
53
+ }
54
+ /**
55
+ * One settings category as a block: its rows, then its rebindable actions with click-to-capture. The
56
+ * game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
57
+ * settings controller hands it, and writes every change straight back to that controller's store.
58
+ *
59
+ * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
60
+ */
61
+ export declare function SettingsSection({ category, heading, capture, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
62
+ /** The live category tabs for a settings screen: a scrollable rail with pressed state. */
63
+ export interface SettingsTabsProps {
64
+ /** The live categories from the settings controller. */
65
+ categories: readonly SettingsCategoryView[];
66
+ /** Id of the category on screen. */
67
+ activeId: string;
68
+ onSelect: (id: string) => void;
69
+ /** Extra leading tab (the controller's game actions). */
70
+ leading?: {
71
+ id: string;
72
+ label: string;
73
+ };
74
+ /** Per-tab props from a `useMenuNav` controller, so a tab rail is keyboard/gamepad navigable. */
75
+ tabProps?: (id: string) => object;
76
+ className?: string;
77
+ style?: CSSProperties;
78
+ }
79
+ /**
80
+ * The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The
81
+ * game owns the surrounding layout; this block keeps every category reachable on narrow screens.
82
+ *
83
+ * @capability settings-tabs scrollable settings category tabs with pressed state
84
+ */
85
+ export declare function SettingsTabs({ categories, activeId, onSelect, leading, tabProps, className, style }: SettingsTabsProps): ReactNode;
@@ -0,0 +1,204 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { hudSpace, hudType, hudTransition } from "./hudTokens.js";
4
+ import { ScrollRail } from "./scrollRail.js";
5
+ import { useDebouncedCommit } from "./useDebouncedCommit.js";
6
+ const rowStyle = {
7
+ display: "flex",
8
+ alignItems: "center",
9
+ justifyContent: "space-between",
10
+ gap: hudSpace("md"),
11
+ padding: `${hudSpace("sm")} ${hudSpace("md")}`,
12
+ borderRadius: 10,
13
+ background: "var(--jg-surface-deep, rgba(255,255,255,0.04))",
14
+ border: "1px solid var(--jg-edge, rgba(255,255,255,0.08))",
15
+ };
16
+ const labelStyle = {
17
+ fontSize: hudType("sm"),
18
+ color: "var(--jg-text, #ececef)",
19
+ };
20
+ const valueStyle = {
21
+ fontSize: hudType("sm"),
22
+ color: "var(--jg-text, #ececef)",
23
+ fontVariantNumeric: "tabular-nums",
24
+ minWidth: 48,
25
+ textAlign: "right",
26
+ };
27
+ function RowShell({ label, children }) {
28
+ return (_jsxs("div", { "data-settings-row": "", style: rowStyle, children: [_jsx("span", { style: labelStyle, children: label }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("sm"), minWidth: 0 }, children: children })] }));
29
+ }
30
+ /**
31
+ * One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
32
+ * drag never storms the settings store or the graphics rebuild it drives.
33
+ *
34
+ * @capability settings-slider a settings slider row that mirrors locally and persists on release
35
+ */
36
+ export function SettingsSlider({ row }) {
37
+ const { value, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
38
+ return (_jsxs(RowShell, { label: row.label, children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, style: { flex: 1, minWidth: 120, accentColor: "var(--jg-accent, #6ee7a8)" } }), _jsx("span", { style: valueStyle, children: row.format?.(value) ?? String(value) })] }));
39
+ }
40
+ /** One on/off row from a {@link SettingsRow}. */
41
+ export function SettingsToggle({ row }) {
42
+ const on = Boolean(row.value);
43
+ return (_jsx(RowShell, { label: row.label, children: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), style: {
44
+ position: "relative",
45
+ width: 48,
46
+ height: 26,
47
+ borderRadius: 999,
48
+ border: "1px solid var(--jg-edge-bright, rgba(255,255,255,0.16))",
49
+ background: on ? "var(--jg-accent, #6ee7a8)" : "var(--jg-surface-deep, rgba(15,18,24,0.9))",
50
+ boxShadow: on ? "0 0 12px var(--jg-accent-glow, rgba(110,231,168,0.35))" : "none",
51
+ cursor: "pointer",
52
+ transition: hudTransition(["background", "box-shadow"]),
53
+ }, children: _jsx("span", { style: {
54
+ position: "absolute",
55
+ top: 3,
56
+ left: on ? 25 : 3,
57
+ width: 18,
58
+ height: 18,
59
+ borderRadius: 999,
60
+ background: "var(--jg-text, #fff)",
61
+ transition: hudTransition("left"),
62
+ } }) }) }));
63
+ }
64
+ /** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
65
+ export function SettingsSelect({ row }) {
66
+ const current = String(row.value);
67
+ return (_jsx(RowShell, { label: row.label, children: _jsx("div", { role: "group", "aria-label": row.label, style: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 4 }, children: (row.options ?? []).map((option) => {
68
+ const selected = option.value === current;
69
+ return (_jsx("button", { type: "button", "aria-pressed": selected, onClick: () => row.set(option.value), style: {
70
+ minHeight: 30,
71
+ padding: `0 ${hudSpace("sm")}`,
72
+ borderRadius: 7,
73
+ border: "1px solid transparent",
74
+ background: selected ? "var(--jg-accent, #6ee7a8)" : "transparent",
75
+ color: selected ? "var(--jg-surface-deep, #0b0b0f)" : "var(--jg-text-dim, #94a3a0)",
76
+ fontSize: hudType("sm"),
77
+ fontWeight: 600,
78
+ cursor: "pointer",
79
+ transition: hudTransition(["background", "color"]),
80
+ }, children: option.label }, option.value));
81
+ }) }) }));
82
+ }
83
+ /** Render the control a {@link SettingsRow} kind asks for. */
84
+ export function SettingsRowControl({ row }) {
85
+ if (row.kind === "slider")
86
+ return _jsx(SettingsSlider, { row: row });
87
+ if (row.kind === "toggle")
88
+ return _jsx(SettingsToggle, { row: row });
89
+ return _jsx(SettingsSelect, { row: row });
90
+ }
91
+ /**
92
+ * A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
93
+ * and reset. The caller owns capture; this is the row's presentation.
94
+ *
95
+ * @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
96
+ */
97
+ export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }) {
98
+ return (_jsxs("div", { "data-settings-keybind": row.action, "data-armed": armed ? "" : undefined, style: rowStyle, children: [_jsx("span", { style: labelStyle, children: row.label }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("xs") }, children: [_jsx("button", { type: "button", "data-settings-capture": "", ...buttonProps, onClick: onArm, disabled: armed, title: "Click, then press a key \u2014 Escape cancels", style: {
99
+ minWidth: 84,
100
+ minHeight: 30,
101
+ padding: `0 ${hudSpace("sm")}`,
102
+ borderRadius: 7,
103
+ border: `1px solid ${armed ? "var(--jg-accent, #6ee7a8)" : "var(--jg-edge-bright, rgba(255,255,255,0.14))"}`,
104
+ background: armed ? "var(--jg-accent-glow, rgba(110,231,168,0.25))" : "var(--jg-surface-deep, rgba(15,18,24,0.9))",
105
+ color: armed ? "var(--jg-accent, #6ee7a8)" : "var(--jg-text, #ececef)",
106
+ fontFamily: "var(--jg-font-numeric, monospace)",
107
+ fontSize: hudType("sm"),
108
+ cursor: "pointer",
109
+ }, children: armed ? "Press…" : row.bindingLabel }), _jsx("button", { type: "button", "data-settings-reset": "", onClick: () => row.reset(), "aria-label": `Reset ${row.label}`, tabIndex: row.isDefault ? -1 : 0, style: {
110
+ width: 30,
111
+ height: 30,
112
+ borderRadius: 7,
113
+ border: "none",
114
+ background: "transparent",
115
+ color: "var(--jg-text-dim, #94a3a0)",
116
+ visibility: row.isDefault ? "hidden" : "visible",
117
+ cursor: "pointer",
118
+ }, children: "\u21BA" })] })] }));
119
+ }
120
+ /**
121
+ * One settings category as a block: its rows, then its rebindable actions with click-to-capture. The
122
+ * game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
123
+ * settings controller hands it, and writes every change straight back to that controller's store.
124
+ *
125
+ * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
126
+ */
127
+ export function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }) {
128
+ const [armed, setArmed] = useState(null);
129
+ const callbacks = useRef({ onCaptureChange });
130
+ callbacks.current = { onCaptureChange };
131
+ useEffect(() => {
132
+ callbacks.current.onCaptureChange?.(armed);
133
+ }, [armed]);
134
+ useEffect(() => {
135
+ if (armed === null)
136
+ return;
137
+ const row = category.keybinds.find((candidate) => candidate.action === armed);
138
+ if (row === undefined) {
139
+ setArmed(null);
140
+ return;
141
+ }
142
+ const onKey = (event) => {
143
+ event.preventDefault();
144
+ event.stopPropagation();
145
+ if (event.code === "Escape") {
146
+ setArmed(null);
147
+ return;
148
+ }
149
+ row.rebind(event.code);
150
+ setArmed(null);
151
+ };
152
+ const onPointer = (event) => {
153
+ event.preventDefault();
154
+ event.stopPropagation();
155
+ row.rebind(`mouse${event.button}`);
156
+ setArmed(null);
157
+ };
158
+ const onContext = (event) => event.preventDefault();
159
+ window.addEventListener("keydown", onKey, true);
160
+ window.addEventListener("pointerdown", onPointer, true);
161
+ window.addEventListener("contextmenu", onContext, true);
162
+ return () => {
163
+ window.removeEventListener("keydown", onKey, true);
164
+ window.removeEventListener("pointerdown", onPointer, true);
165
+ window.removeEventListener("contextmenu", onContext, true);
166
+ };
167
+ }, [armed, category.keybinds]);
168
+ return (_jsxs("section", { "data-settings-section": category.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), ...style }, children: [heading ? (_jsx("h3", { style: {
169
+ margin: 0,
170
+ marginBottom: hudSpace("xs"),
171
+ fontFamily: "var(--jg-font-display, inherit)",
172
+ fontSize: hudType("xs"),
173
+ fontWeight: 700,
174
+ letterSpacing: "0.18em",
175
+ textTransform: "uppercase",
176
+ color: "var(--jg-accent, #6ee7a8)",
177
+ }, children: category.label })) : null, category.rows.map((row) => (_jsx(SettingsRowControl, { row: row }, row.id))), category.keybinds.map((row) => (_jsx(SettingsKeybindControl, { row: row, armed: armed === row.action, onArm: capture ? () => setArmed(row.action) : undefined }, row.action)))] }));
178
+ }
179
+ /**
180
+ * The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The
181
+ * game owns the surrounding layout; this block keeps every category reachable on narrow screens.
182
+ *
183
+ * @capability settings-tabs scrollable settings category tabs with pressed state
184
+ */
185
+ export function SettingsTabs({ categories, activeId, onSelect, leading, tabProps, className, style }) {
186
+ const tabs = leading === undefined ? categories : [{ id: leading.id, label: leading.label }, ...categories];
187
+ return (_jsx("div", { className: className, style: style, children: _jsx(ScrollRail, { label: "settings categories", children: tabs.map((tab) => {
188
+ const selected = tab.id === activeId;
189
+ return (_jsx("button", { type: "button", "data-settings-tab": tab.id, ...(tabProps === undefined ? {} : tabProps(tab.id)), "aria-pressed": selected, onClick: () => onSelect(tab.id), style: {
190
+ flexShrink: 0,
191
+ minHeight: 34,
192
+ padding: `0 ${hudSpace("md")}`,
193
+ marginRight: hudSpace("xs"),
194
+ borderRadius: 8,
195
+ border: "1px solid transparent",
196
+ background: selected ? "var(--jg-accent, #6ee7a8)" : "transparent",
197
+ color: selected ? "var(--jg-surface-deep, #0b0b0f)" : "var(--jg-text-dim, #94a3a0)",
198
+ fontSize: hudType("sm"),
199
+ fontWeight: 600,
200
+ whiteSpace: "nowrap",
201
+ cursor: "pointer",
202
+ }, children: tab.label }, tab.id));
203
+ }) }) }));
204
+ }
@@ -0,0 +1,60 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { type MenuScreenScrim, type MenuScreenTransition } from "./menuScreen.js";
3
+ /** One verb on a {@link TitleMenu}: New Game, Continue, Settings, Credits, Quit. */
4
+ export interface TitleMenuItem {
5
+ /** Stable id the game routes on. */
6
+ id: string;
7
+ /** Player-facing label. */
8
+ label: string;
9
+ /** One line under the label (save summary, a "no save yet" note). */
10
+ hint?: string;
11
+ /** Render as the screen's primary action. */
12
+ primary?: boolean;
13
+ /** Show but skip it in navigation and clicks. */
14
+ disabled?: boolean;
15
+ }
16
+ /** Props for {@link TitleMenu}. */
17
+ export interface TitleMenuProps {
18
+ /** The game's wordmark or title. Free markup, so a game can art-direct it. */
19
+ title: ReactNode;
20
+ /** Small line above the title (studio, genre, chapter). */
21
+ eyebrow?: ReactNode;
22
+ /** One line under the title. */
23
+ tagline?: ReactNode;
24
+ /** The front-end verbs, in focus order. */
25
+ items: readonly TitleMenuItem[];
26
+ /** A verb was chosen. */
27
+ onSelect: (id: string) => void;
28
+ /** Back/Escape. Omit at the root screen, where there is nowhere to go. */
29
+ onBack?: () => void;
30
+ /** Footer left slot — build version, copyright. */
31
+ footer?: ReactNode;
32
+ /** Footer right slot — replaces the default control hint. */
33
+ controlHint?: ReactNode;
34
+ /** Artwork, a poster, or a live 3D scene painted behind the menu. */
35
+ backdrop?: ReactNode;
36
+ /** Extra content under the verbs (a save summary, a control legend). */
37
+ children?: ReactNode;
38
+ open?: boolean;
39
+ transition?: MenuScreenTransition;
40
+ /** Dim/blur treatment over the scene behind. Default `"dim"`. */
41
+ scrim?: MenuScreenScrim;
42
+ enterMs?: number;
43
+ exitMs?: number;
44
+ reducedMotion?: boolean;
45
+ onClosed?: () => void;
46
+ className?: string;
47
+ style?: CSSProperties;
48
+ contentClassName?: string;
49
+ contentStyle?: CSSProperties;
50
+ }
51
+ /**
52
+ * A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable
53
+ * list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit
54
+ * animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme
55
+ * tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a
56
+ * blank page.
57
+ *
58
+ * @capability title-menu title-screen block — wordmark, tagline, navigable verb list, footer, backdrop slot, animated over a live scene
59
+ */
60
+ export declare function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open, transition, scrim, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { hudSpace, hudType } from "./hudTokens.js";
3
+ import { HudButton } from "./hudButton.js";
4
+ import { useMenuNav } from "./menuNav.js";
5
+ import { MenuScreen } from "./menuScreen.js";
6
+ const column = {
7
+ display: "flex",
8
+ flexDirection: "column",
9
+ gap: hudSpace("md"),
10
+ width: "min(92vw, 460px)",
11
+ };
12
+ const itemButton = {
13
+ display: "flex",
14
+ flexDirection: "column",
15
+ alignItems: "flex-start",
16
+ gap: 2,
17
+ textAlign: "left",
18
+ };
19
+ /**
20
+ * A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable
21
+ * list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit
22
+ * animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme
23
+ * tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a
24
+ * blank page.
25
+ *
26
+ * @capability title-menu title-screen block — wordmark, tagline, navigable verb list, footer, backdrop slot, animated over a live scene
27
+ */
28
+ export function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }) {
29
+ const disabled = items.filter((item) => item.disabled === true).map((item) => item.id);
30
+ const nav = useMenuNav({
31
+ ids: items.map((item) => item.id),
32
+ disabled,
33
+ enabled: open,
34
+ onConfirm: onSelect,
35
+ ...(onBack === undefined ? {} : { onBack }),
36
+ });
37
+ return (_jsx(MenuScreen, { open: open, transition: transition, scrim: scrim, ...(enterMs === undefined ? {} : { enterMs }), ...(exitMs === undefined ? {} : { exitMs }), ...(reducedMotion === undefined ? {} : { reducedMotion }), ...(onClosed === undefined ? {} : { onClosed }), label: "Title menu", ...(backdrop === undefined ? {} : { backdrop }), className: className, style: style, contentClassName: contentClassName, contentStyle: contentStyle, children: _jsxs("div", { "data-title-menu": "", style: column, ...nav.containerProps, children: [eyebrow === undefined ? null : (_jsx("p", { "data-title-eyebrow": "", style: {
38
+ margin: 0,
39
+ fontSize: hudType("xs"),
40
+ letterSpacing: "0.24em",
41
+ textTransform: "uppercase",
42
+ color: "var(--jg-accent, #6ee7a8)",
43
+ }, children: eyebrow })), _jsx("div", { "data-title-wordmark": "", style: {
44
+ fontFamily: "var(--jg-font-display, inherit)",
45
+ fontSize: hudType("display"),
46
+ fontWeight: 800,
47
+ lineHeight: 1.02,
48
+ letterSpacing: "-0.01em",
49
+ color: "var(--jg-text, #f5f7fa)",
50
+ textShadow: "0 2px 18px rgba(0,0,0,0.55)",
51
+ }, children: title }), tagline === undefined ? null : (_jsx("p", { style: { margin: 0, fontSize: hudType("sm"), color: "var(--jg-text-dim, #94a3a0)", maxWidth: "34ch" }, children: tagline })), _jsx("nav", { "data-title-items": "", style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), marginTop: hudSpace("sm") }, children: items.map((item) => {
52
+ const props = nav.itemProps(item.id);
53
+ return (_jsxs(HudButton, { ...props, variant: item.primary === true ? "primary" : "secondary", size: "lg", block: true, disabled: item.disabled === true, "data-title-item": item.id, onClick: () => {
54
+ if (item.disabled !== true)
55
+ onSelect(item.id);
56
+ }, style: itemButton, children: [_jsx("span", { style: { fontSize: hudType("md"), fontWeight: 700 }, children: item.label }), item.hint === undefined ? null : (_jsx("span", { style: { fontSize: hudType("xs"), fontWeight: 500, opacity: 0.78 }, children: item.hint }))] }, item.id));
57
+ }) }), children, _jsxs("footer", { "data-title-footer": "", style: {
58
+ display: "flex",
59
+ alignItems: "center",
60
+ justifyContent: "space-between",
61
+ gap: hudSpace("md"),
62
+ marginTop: hudSpace("md"),
63
+ fontSize: hudType("xs"),
64
+ color: "var(--jg-text-dim, #94a3a0)",
65
+ }, children: [_jsx("span", { children: footer }), _jsx("span", { "data-jg-kbd-hint": controlHint === undefined ? "" : undefined, children: controlHint ?? "↑↓ move · Enter select · Esc back" })] })] }) }));
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.26.0",
3
+ "version": "0.28.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.26.0"
38
+ "@jgengine/core": "^0.28.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.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -29,6 +29,7 @@ Existing React games keep their entity store and use the focused
29
29
 
30
30
  - **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader. Composing and reskinning the shipped blocks is correct use; a genre HUD kit, theme preset, or unarranged default stack dropped in as the game's identity is not.
31
31
  - **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md#main-menu).
32
+ - **Menus come from the shipped blocks, navigated by `useMenuNav`.** `TitleMenu`/`MenuScreen` (`@jgengine/react`) give the animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` the slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology.
32
33
  - **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
34
  - **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
35
  - **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.