@jgengine/react 0.37.0 → 0.38.1

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,88 @@ 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.38.1
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.38.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.1`; `@jgengine/github` is `0.25.1`.
34
+ - Nothing to migrate. `variantLevels()` was renamed to `ModelVariantConfig.shadows` before any release shipped it, so no published version ever exported it and no consumer can be holding one. A level that wants to stop paying the near level's shadow cost writes `{ url, shadows: "none" }`; leaving `shadows` out keeps the model's own answer.
35
+
36
+ ### Added
37
+
38
+ - `ModelVariantConfig.shadows` (`@jgengine/core/world/modelLod`): a model variant level declares its own shadow participation as a plain field, `"inherit" | "cast" | "receive" | "both" | "none"`. `"inherit"`, the default, keeps the model's own `shadows`, so every existing model draws as before; a far level set to `"none"` leaves the shadow pass while still drawing its mesh.
39
+ - `ModelShadowMode` (`@jgengine/core/world/modelLod`) names the four-value shadow vocabulary, and `ModelConfig.shadows` uses it.
40
+ - `variantLevelModel` (`@jgengine/shell/render/singleVariantLod`) is the one place that resolves what a level draws and how it shadows; the single prop, batched prop and scatter paths all ask it, so a level's asset and its shadow answer cannot disagree.
41
+
42
+ ### Fixed
43
+
44
+ - `SaveSlotsPane` cards are one box each: equal tracks, `grid-auto-rows: 1fr`, a card edge that reads over a bright scene, and a name that ellipsises instead of widening the card. A save with a long name and one with none produce the same card box.
45
+ - `GameMenu` puts the primary verbs on their own scrim, so their labels stay legible over whatever the live scene is showing instead of relying on a text shadow.
46
+ - The theme emits three menu tokens the slot card and the verb surface read: `--jg-menu-card-height` (`clamp(244px, 27vh, 300px)`), `--jg-menu-card-thumb` (`clamp(88px, 14vh, 160px)`) and `--jg-menu-surface-radius` (`8px`). A game overrides them in its own `HudTheme` instead of patching the menu components.
47
+
48
+ ## 0.38.0
49
+
50
+ ### Migrate
51
+
52
+ - **Bump lockstep SDK packages to `^0.38.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.0`; `@jgengine/github` is `0.25.0`.
53
+ - Composing a session loop directly: pass your rule as `settle`, `carries: false` when nothing is carried, and your authored options as `offers`. `createRunLoop` is removed one minor release after Beacon Bastion and Drift Foundry stop using it.
54
+
55
+ ### Added
56
+
57
+ - `GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size.
58
+ - Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`).
59
+ - `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.
60
+ - `createSessionLoop` (`@jgengine/core/run/sessionLoop`) is one session loop for every game: a session opens, may carry a quantity, closes at its boundary, and the game's own `settle` callback decides what happens to what it carried. `carries: false` for a session with nothing at stake, and an optional `offers` table (with `present`/`choose`) for a game whose next session opens with an authored change; without a table nothing is drawn. `installSessionProbe` reports the live session on `window.__jgengineRun`.
61
+
62
+ ### Changed
63
+
64
+ - `switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes.
65
+ - The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.
66
+ - `createRunLoop` is now a thin composition of `createSessionLoop` and is deprecated: bank-or-lose and a per-run change table are one game type's rules, so they live in the composition instead of the base. Its API, state shape and tests are unchanged. One difference: the option draw is seeded under `:offer:` rather than `:run:` (`seededRng(\`${seed}:offer:${index}\`)`), so a career that keeps its seed draws a different change on a given session than 0.37.0 did for the same seed. Ids, payloads and saves stay valid: a saved `changeId` still restores the same change.
67
+
68
+ ### Fixed
69
+
70
+ - `shoot` fails instead of writing a PNG when the whole frame is one flat colour, and when a scene is still behind its loading cover. Both used to write a plausible screenshot that read as evidence of a rendered frame.
71
+ - The editor's play-mode pixel test waits for the warmup cover to clear before screenshotting. It shot the cover, so it read `0.9988` one-colour locally and passed in CI where the race fell the other way.
72
+ - `shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.
73
+
74
+ Root cause: capture readiness was trusted to mean "the scene has rendered", but a loading cover and a canvas that never committed both photograph as a flat frame, and nothing measured the frame after the fact. Headless Chrome on this PC was never the problem: it renders with the GPU (`ANGLE (NVIDIA GeForce RTX 3070, D3D11)`) and with SwiftShader when asked.
75
+
76
+ Before: `bun run shoot --url <flat canvas page>` exited 0 and wrote a one-colour PNG, and `packages/editor/src/CreatorApp.test.ts` failed locally at `dominantColorShare 0.9988`.
77
+ After: the same shoot exits 1 with `the whole frame is one flat color (100.0%) - no scene reached the capture - nothing was written`, and the editor test passes in 12s with sky and ground in the PNG.
78
+
79
+ Cost: forcing SwiftShader on the same game shot costs 2.4x the capture (16.0s vs 6.7s warm, beacon-bastion play at half size), so the launch keeps the native GPU. The two checks add about 10ms to a shot.
80
+
81
+ Verification:
82
+ - `bun test scripts/shot-metrics.test.ts` - pass
83
+ - `bun test packages/editor/src/CreatorApp.test.ts` - pass
84
+ - `bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders
85
+ - `bun run shoot --url <flat canvas page>` - exit 1, no PNG written
86
+ - `bun run verify:fast` - pass
87
+
88
+ The CI reliability fix this lane also carried (a browser setup hook shorter than Playwright's launch default) is #2335, so either change can be reverted without the other.
89
+ - The publish workflow's last step no longer reports a whole release as unpublished because it ran seconds after `npm publish`. `check:release-artifacts --wait <seconds>` re-checks while the only failures are absent packages (no metadata, version missing, tarball 404) and gives up when the budget runs out; a wrong version in the tarball, a missing subpath or a main entry that will not import still fails on the first check. The 0.37.0 publish run failed this step at 04:48 listing all 13 packages, then npm served every one of them. The step now passes `-- --wait 300`.
90
+ - A Playwright test file's `beforeAll` now outlives the Chrome launch it waits on, and `browser-test-budget` rejects a file that leaves the hook at or under Playwright's 30 s launch default. On `test (g)` in #2326 the hook's 30 s budget and that default expired together, so CI reported only `(unnamed) hook timed out` beside `killed 1 dangling process` and nothing named the launch.
91
+ - `packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.
92
+
93
+ Root cause: Bun prints a `beforeAll` timeout as an unnamed hook timeout, so a setup budget equal to Playwright's own 30 s launch default reports the anonymous timer instead of the launch that stalled.
94
+
95
+ Before: `test (g)` on #2326 reported `(fail) (unnamed) [30001.51ms] ^ a beforeEach/afterEach hook timed out` and `killed 1 dangling process`.
96
+ After: a stall now surfaces as Playwright's own `Timeout ... exceeded`, and `bun scripts/browser-test-budget.ts` names any file whose setup hook sits at or under the launch default.
97
+
98
+ Verification:
99
+ - `bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip
100
+ - `bun test scripts/browser-test-budget.test.ts` - pass
101
+ - `bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass
102
+ - `bun run verify:fast` - pass
103
+ - A prop placed at runtime honours its model's `variants` levels. `StaticObjectModels` applied the level switch only inside its batched branch, so a prop placed with `scene.object.place` — Harbor Heat's palm line — drew its full asset at every camera distance: 15 palms at 9,134 triangles each, and pointing the far level at a dispatch booth changed nothing on screen. `IsolatedEntityModel` now mounts `SingleVariantModel`, which asks the same `createModelVariantSelector` and `lodBandIndex` authority the batched path uses, at the prop's own camera distance, and keeps only the active level mounted so both levels never draw at once. The switch carries the selector's hysteresis, warms every level at mount, measures only the near level, and reads the placement from the scene store through scratch vectors, so a frame allocates nothing. One palm past 60 m draws 4,110 triangles instead of 9,134 (5,024 saved, 45% of the full level).
104
+
105
+ - `singleVariantLevel` and `singleVariantModel` (`@jgengine/shell` internal, `render/singleVariantLod`) are the isolated path's level decision and the model it draws, so a fixture pins the same switch the component runs.
106
+
107
+ `bun test packages/shell/src/render/singleVariantLod.test.ts` — 6 pass. With the old render decision restored, the wiring test fails (`Expected: SingleVariantModel, Received: EntityModel`).
108
+ `bun --cwd=packages/shell run test` — 1,229 pass, 0 fail.
109
+ `bun --cwd=packages/shell run check-types` — clean.
110
+
29
111
  ## 0.37.0
30
112
 
31
113
  ### Migrate
@@ -52,6 +52,11 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
52
52
  @keyframes jg-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
53
53
  @keyframes jg-delta-up { 0% { color: var(--jg-success, #3fa34d); text-shadow: 0 0 10px var(--jg-success, #3fa34d); scale: 1.12; } 100% { scale: 1; } }
54
54
  @keyframes jg-delta-down { 0% { color: var(--jg-danger, #e5484d); text-shadow: 0 0 10px var(--jg-danger, #e5484d); scale: 0.94; } 100% { scale: 1; } }
55
+ @keyframes jg-menu-drift { from { transform: scale(1.06) translate3d(-1.4%, -1%, 0); } to { transform: scale(1.12) translate3d(1.6%, 1.2%, 0); } }
56
+ @keyframes jg-menu-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
57
+ @keyframes jg-menu-pane-in { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: translateX(0); } }
58
+ [data-jg-game-menu] * { scrollbar-width: none; }
59
+ [data-jg-game-menu] *::-webkit-scrollbar { width: 0; height: 0; }
55
60
 
56
61
  [data-jg-hud] { font-family: var(--jg-font-body, ${HUD_FONT_STACK}); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; }
57
62
  :where([data-jg-hud]) :where(button, [role="button"], a[href], summary, [data-jg-interactive]):focus-visible { outline: 2px solid var(--jg-accent, #38bdf8); outline-offset: 2px; }
@@ -0,0 +1,120 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type GameMenuSound } from "./gameMenuSound.js";
3
+ /**
4
+ * A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on
5
+ * the left that animate as the selection moves, and a detail pane on the right that changes with the
6
+ * highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from,
7
+ * so a game composes one menu system instead of a stack of modal panels.
8
+ *
9
+ * The surface is exactly the viewport: `position: fixed` with `overflow: hidden`, and the document
10
+ * scroll is locked while it is open, so a tall menu can never grow a page scrollbar. Content that
11
+ * genuinely scrolls (credits) scrolls inside its own pane.
12
+ */
13
+ /** One primary verb in a {@link GameMenu}. */
14
+ export interface GameMenuItem {
15
+ /** Stable id the game routes on. */
16
+ id: string;
17
+ /** Player-facing label. */
18
+ label: string;
19
+ /** One short line under the label — a save summary, a state note. Omit for a clean row. */
20
+ hint?: string;
21
+ /** Show the row but skip it in navigation and clicks. */
22
+ disabled?: boolean;
23
+ /** Small marker at the end of the row (a badge, a count, a key glyph). */
24
+ badge?: ReactNode;
25
+ }
26
+ /** The logo slot. Give it `image` for art, or `text` for a styled wordmark. */
27
+ export interface GameMenuLogo {
28
+ /** Image URL for the game's logo. Sized by the `--jg-menu-logo` token, never by a heading tag. */
29
+ image?: string;
30
+ /** Alt text for the logo image. Defaults to `title` when that is a string. */
31
+ alt?: string;
32
+ /** Styled type instead of an image. */
33
+ text?: ReactNode;
34
+ /** Plain title, used for the accessible name and as the alt fallback. */
35
+ title?: string;
36
+ }
37
+ /** How a {@link GameMenu} is used. `pause` is the same system at a smaller scale. */
38
+ export type GameMenuVariant = "title" | "pause";
39
+ /** Props for {@link GameMenu}. */
40
+ export interface GameMenuProps {
41
+ /** Primary verbs, in focus order. */
42
+ items: readonly GameMenuItem[];
43
+ /** The verb focused on mount. Default the first enabled one. */
44
+ initialId?: string;
45
+ /** A verb was confirmed (Enter, Space, A, or a click). */
46
+ onSelect: (id: string) => void;
47
+ /** Back (Escape, Backspace, B, Start). On a pause menu this is the resume action. */
48
+ onBack?: () => void;
49
+ /** The highlighted verb changed — use it to load the detail for `id`. */
50
+ onHighlight?: (id: string | null) => void;
51
+ /** The right-hand pane's content for the highlighted verb. */
52
+ detail?: ReactNode | ((id: string | null) => ReactNode);
53
+ /** Logo slot: an image or styled type, sized by the logo token. */
54
+ logo?: GameMenuLogo | ReactNode;
55
+ /** Small line above the logo (studio, chapter, mode). */
56
+ eyebrow?: ReactNode;
57
+ /** One line under the logo. */
58
+ tagline?: ReactNode;
59
+ /**
60
+ * Layer painted across the whole viewport behind the menu: a live scene component, key art, a
61
+ * video. Leave it out when the engine's own 3D canvas sits behind the menu — the menu stays
62
+ * transparent and only tints and vignettes it, so play keeps rendering underneath.
63
+ */
64
+ background?: ReactNode;
65
+ /**
66
+ * Key-art URL for the drifting background layer. Omit when the live scene behind the menu is the
67
+ * background. With neither, the menu still tints and vignettes whatever is behind it.
68
+ */
69
+ art?: string;
70
+ /** Secondary verbs at the bottom-left (quit, back). Same navigation, quieter styling. */
71
+ secondary?: readonly GameMenuItem[];
72
+ /** Footer left slot: build version, studio. */
73
+ footer?: ReactNode;
74
+ /** Footer right slot: replaces the default control hint. */
75
+ controlHint?: ReactNode;
76
+ /** Selection sound. Default on. Pass `false` for silence, or a player from `createGameMenuSound`. */
77
+ sound?: boolean | GameMenuSound;
78
+ /** Title menu or the smaller pause menu. Default `"title"`. */
79
+ variant?: GameMenuVariant;
80
+ /** Open state the game owns. A closed menu renders nothing and releases the document scroll. */
81
+ open?: boolean;
82
+ /** Force motion off. Reduced motion is also read from the accessibility preference and the OS. */
83
+ reducedMotion?: boolean;
84
+ /** Accessible name of the surface. Default `"Main menu"` (`"Paused"` for the pause variant). */
85
+ label?: string;
86
+ className?: string;
87
+ style?: CSSProperties;
88
+ detailClassName?: string;
89
+ detailStyle?: CSSProperties;
90
+ }
91
+ /**
92
+ * Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the
93
+ * document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous
94
+ * inline styles are restored when the last one releases.
95
+ *
96
+ * @capability use-viewport-scroll-lock count-aware document scroll lock for full-bleed surfaces
97
+ */
98
+ export declare function useViewportScrollLock(active: boolean): void;
99
+ /**
100
+ * A game front-end menu: full-bleed over a living background, with a logo, an animated primary list
101
+ * and a detail pane that follows the highlighted verb. One system covers the main menu and the pause
102
+ * menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation
103
+ * ticks are generated (no audio asset), and the document never scrolls while it is open.
104
+ *
105
+ * The engine owns the surface, layout, motion, focus order, sound and the save/character panes it
106
+ * exports next to this component. The game owns the art, the logo, the wording, the verbs and what
107
+ * each detail pane shows.
108
+ *
109
+ * @capability game-menu full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar
110
+ */
111
+ export declare function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound, variant, open, reducedMotion, label, className, style, detailClassName, detailStyle, }: GameMenuProps): ReactNode;
112
+ /**
113
+ * A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a
114
+ * game composes one menu system rather than a second one. Escape (or B/Start) resumes.
115
+ *
116
+ * @capability game-pause-menu pause menu from the shared game-menu system — same navigation and surface, sized down
117
+ */
118
+ export declare function GamePauseMenu(props: Omit<GameMenuProps, "variant"> & {
119
+ onResume?: () => void;
120
+ }): ReactNode;
@@ -0,0 +1,400 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, } from "react";
3
+ import { useReducedMotion } from "./accessibility.js";
4
+ import { createGameMenuSound } from "./gameMenuSound.js";
5
+ import { hudDuration, hudEase, hudFont, hudType } from "./hudTokens.js";
6
+ import { useMenuNav } from "./menuNav.js";
7
+ let scrollLocks = 0;
8
+ let savedDocumentOverflow = "";
9
+ let savedBodyOverflow = "";
10
+ function lockDocumentScroll() {
11
+ if (typeof document === "undefined")
12
+ return () => { };
13
+ if (scrollLocks === 0) {
14
+ savedDocumentOverflow = document.documentElement.style.overflow;
15
+ savedBodyOverflow = document.body.style.overflow;
16
+ document.documentElement.style.overflow = "hidden";
17
+ document.body.style.overflow = "hidden";
18
+ }
19
+ scrollLocks += 1;
20
+ let released = false;
21
+ return () => {
22
+ if (released)
23
+ return;
24
+ released = true;
25
+ scrollLocks -= 1;
26
+ if (scrollLocks > 0 || typeof document === "undefined")
27
+ return;
28
+ document.documentElement.style.overflow = savedDocumentOverflow;
29
+ document.body.style.overflow = savedBodyOverflow;
30
+ };
31
+ }
32
+ /**
33
+ * Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the
34
+ * document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous
35
+ * inline styles are restored when the last one releases.
36
+ *
37
+ * @capability use-viewport-scroll-lock count-aware document scroll lock for full-bleed surfaces
38
+ */
39
+ export function useViewportScrollLock(active) {
40
+ useEffect(() => {
41
+ if (!active)
42
+ return;
43
+ return lockDocumentScroll();
44
+ }, [active]);
45
+ }
46
+ const ROW_HEIGHT = { title: 58, pause: 46 };
47
+ const VIGNETTE = "radial-gradient(125% 110% at 26% 42%, rgba(4,7,12,0.02) 0%, rgba(4,7,12,0.34) 66%, rgba(2,4,7,0.8) 100%)";
48
+ const TINT = "linear-gradient(100deg, rgba(6,10,16,0.9) 0%, rgba(6,10,16,0.74) 34%, rgba(6,10,16,0.34) 56%, rgba(6,10,16,0.08) 78%, rgba(6,10,16,0.28) 100%)";
49
+ function logoNode(logo, title, reduced) {
50
+ if (logo === undefined || logo === null || logo === false)
51
+ return null;
52
+ if (typeof logo === "object" && !Array.isArray(logo) && !("type" in logo)) {
53
+ const spec = logo;
54
+ const size = "var(--jg-menu-logo-size, clamp(30px, 4.6vw, 66px))";
55
+ if (spec.image !== undefined) {
56
+ return (_jsx("img", { "data-jg-game-menu-logo": "", src: spec.image, alt: spec.alt ?? spec.title ?? title ?? "", style: {
57
+ display: "block",
58
+ width: "auto",
59
+ height: `calc(${size} * 1.15)`,
60
+ maxWidth: "min(420px, 46vw)",
61
+ objectFit: "contain",
62
+ filter: "drop-shadow(0 8px 30px rgba(0,0,0,0.6))",
63
+ } }));
64
+ }
65
+ return (_jsx("div", { "data-jg-game-menu-logo": "", style: {
66
+ fontFamily: hudFont.display,
67
+ fontSize: size,
68
+ fontWeight: 800,
69
+ lineHeight: 0.98,
70
+ letterSpacing: "-0.02em",
71
+ color: "var(--jg-text, #f7f9fc)",
72
+ textShadow: "0 2px 24px rgba(0,0,0,0.65), 0 0 60px var(--jg-accent-glow, rgba(88,196,255,0.25))",
73
+ animation: reduced ? undefined : `jg-menu-rise 620ms ${hudEase("easeOut")} both`,
74
+ }, children: spec.text ?? spec.title ?? title }));
75
+ }
76
+ return (_jsx("div", { "data-jg-game-menu-logo": "", style: {
77
+ fontFamily: hudFont.display,
78
+ fontSize: "var(--jg-menu-logo-size, clamp(30px, 4.6vw, 66px))",
79
+ fontWeight: 800,
80
+ lineHeight: 0.98,
81
+ letterSpacing: "-0.02em",
82
+ color: "var(--jg-text, #f7f9fc)",
83
+ textShadow: "0 2px 24px rgba(0,0,0,0.65), 0 0 60px var(--jg-accent-glow, rgba(88,196,255,0.25))",
84
+ animation: reduced ? undefined : `jg-menu-rise 620ms ${hudEase("easeOut")} both`,
85
+ }, children: logo }));
86
+ }
87
+ /**
88
+ * A game front-end menu: full-bleed over a living background, with a logo, an animated primary list
89
+ * and a detail pane that follows the highlighted verb. One system covers the main menu and the pause
90
+ * menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation
91
+ * ticks are generated (no audio asset), and the document never scrolls while it is open.
92
+ *
93
+ * The engine owns the surface, layout, motion, focus order, sound and the save/character panes it
94
+ * exports next to this component. The game owns the art, the logo, the wording, the verbs and what
95
+ * each detail pane shows.
96
+ *
97
+ * @capability game-menu full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar
98
+ */
99
+ export function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound = true, variant = "title", open = true, reducedMotion, label, className, style, detailClassName, detailStyle, }) {
100
+ const prefersReduced = useReducedMotion();
101
+ const reduced = reducedMotion === undefined ? prefersReduced : reducedMotion || prefersReduced;
102
+ useViewportScrollLock(open);
103
+ const allItems = useMemo(() => [...items, ...(secondary ?? [])], [items, secondary]);
104
+ const disabled = useMemo(() => allItems.filter((item) => item.disabled === true).map((item) => item.id), [allItems]);
105
+ const soundRef = useRef(null);
106
+ useEffect(() => {
107
+ if (sound === false)
108
+ return;
109
+ const player = typeof sound === "object" ? sound : createGameMenuSound();
110
+ soundRef.current = player;
111
+ return () => {
112
+ if (typeof sound !== "object")
113
+ player.dispose();
114
+ soundRef.current = null;
115
+ };
116
+ }, [sound]);
117
+ const onHighlightRef = useRef(onHighlight);
118
+ onHighlightRef.current = onHighlight;
119
+ const nav = useMenuNav({
120
+ ids: allItems.map((item) => item.id),
121
+ disabled,
122
+ enabled: open,
123
+ ...(initialId === undefined ? {} : { initialId }),
124
+ onConfirm: (id) => {
125
+ const item = allItems.find((candidate) => candidate.id === id);
126
+ if (item === undefined || item.disabled === true)
127
+ return;
128
+ soundRef.current?.play("confirm");
129
+ onSelect(id);
130
+ },
131
+ ...(onBack === undefined
132
+ ? {}
133
+ : {
134
+ onBack: () => {
135
+ soundRef.current?.play("back");
136
+ onBack();
137
+ },
138
+ }),
139
+ });
140
+ // A cursor that happens to rest over a row when the menu opens must not select it: hover only
141
+ // counts once the pointer has actually moved in this session.
142
+ const pointerMoved = useRef(false);
143
+ useEffect(() => {
144
+ if (typeof window === "undefined")
145
+ return;
146
+ const mark = () => { pointerMoved.current = true; };
147
+ window.addEventListener("pointermove", mark, { passive: true });
148
+ return () => window.removeEventListener("pointermove", mark);
149
+ }, []);
150
+ const hoverProps = (id) => {
151
+ const props = nav.itemProps(id);
152
+ return { ...props, onPointerEnter: () => { if (pointerMoved.current)
153
+ props.onPointerEnter(); } };
154
+ };
155
+ const activeId = nav.activeId;
156
+ const activeIndex = allItems.findIndex((item) => item.id === activeId);
157
+ const mounted = useRef(false);
158
+ const appliedInitial = useRef(initialId);
159
+ // `initialId` is the verb the game wants focused (Continue when a save exists). When that changes
160
+ // after mount — a save finished loading, a screen swapped — move the selection rather than
161
+ // remounting the whole menu, which would replay the entry animation and drop focus.
162
+ useEffect(() => {
163
+ if (initialId === undefined || appliedInitial.current === initialId || disabled.includes(initialId))
164
+ return;
165
+ appliedInitial.current = initialId;
166
+ nav.setActive(initialId);
167
+ }, [initialId, disabled, nav.activeId, nav.setActive]);
168
+ // The first pass can run before item refs exist, so focus the selection once after paint when the
169
+ // menu owns an idle document. Arrow keys then land on the menu itself instead of the page.
170
+ useEffect(() => {
171
+ if (!open)
172
+ return;
173
+ if (typeof requestAnimationFrame !== "function")
174
+ return;
175
+ const frame = requestAnimationFrame(() => {
176
+ const active = typeof document === "undefined" ? null : document.activeElement;
177
+ const insideMenu = active !== null && typeof active.closest === "function" && active.closest("[data-jg-game-menu]") !== null;
178
+ if (insideMenu && active !== document.body)
179
+ return;
180
+ const target = document.querySelector("[data-jg-game-menu] [data-jg-menu-item][aria-current='true']");
181
+ target?.focus({ preventScroll: true });
182
+ });
183
+ return () => cancelAnimationFrame(frame);
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ }, [open, activeId]);
186
+ useEffect(() => {
187
+ if (!mounted.current) {
188
+ mounted.current = true;
189
+ }
190
+ else if (activeId !== null) {
191
+ soundRef.current?.play("move");
192
+ }
193
+ onHighlightRef.current?.(activeId);
194
+ }, [activeId]);
195
+ if (!open)
196
+ return null;
197
+ const isPause = variant === "pause";
198
+ const rowHeight = ROW_HEIGHT[variant];
199
+ const primaryCount = items.length;
200
+ const detailNode = typeof detail === "function" ? detail(activeId) : detail;
201
+ const title = typeof logo === "object" && logo !== null && !Array.isArray(logo) ? logo.title : undefined;
202
+ const surfaceLabel = label ?? (isPause ? "Paused" : "Main menu");
203
+ return (_jsxs("div", { "data-jg-game-menu": "", "data-variant": variant, "data-open": "", role: "dialog", "aria-modal": "true", "aria-label": surfaceLabel, className: className, style: {
204
+ position: "fixed",
205
+ inset: 0,
206
+ width: "100vw",
207
+ height: "100dvh",
208
+ maxWidth: "100vw",
209
+ maxHeight: "100dvh",
210
+ overflow: "hidden",
211
+ overscrollBehavior: "contain",
212
+ display: "grid",
213
+ gridTemplateRows: "auto minmax(0, 1fr) auto",
214
+ gridTemplateColumns: "minmax(232px, clamp(232px, 27vw, 360px)) minmax(0, 1fr)",
215
+ columnGap: "clamp(16px, 3vw, 56px)",
216
+ padding: isPause
217
+ ? "clamp(16px, 2.4vw, 34px) clamp(20px, 3.2vw, 56px)"
218
+ : "clamp(18px, 3.2vw, 56px) clamp(20px, 3.6vw, 64px)",
219
+ boxSizing: "border-box",
220
+ color: "var(--jg-text, #f5f7fa)",
221
+ fontFamily: hudFont.body,
222
+ animation: reduced ? undefined : `jg-fade-in ${hudDuration("slow")} ${hudEase("easeOut")} both`,
223
+ ...style,
224
+ }, children: [_jsxs("div", { "data-jg-game-menu-backdrop": "", "aria-hidden": "true", style: { position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 0 }, children: [background === undefined ? null : (_jsx("div", { "data-jg-game-menu-live": "", style: { position: "absolute", inset: 0 }, children: background })), art === undefined ? null : (_jsx("div", { "data-jg-game-menu-art": "", style: {
225
+ position: "absolute",
226
+ inset: "-8%",
227
+ backgroundImage: `url(${art})`,
228
+ backgroundSize: "cover",
229
+ backgroundPosition: "center",
230
+ transform: "scale(1.06)",
231
+ animation: reduced ? undefined : "jg-menu-drift 46s ease-in-out infinite alternate",
232
+ willChange: "transform",
233
+ } })), _jsx("div", { "data-jg-game-menu-tint": "", style: { position: "absolute", inset: 0, background: TINT, opacity: isPause ? 1 : 0.94 } }), _jsx("div", { "data-jg-game-menu-vignette": "", style: { position: "absolute", inset: 0, background: VIGNETTE } }), _jsx("div", { "data-jg-game-menu-grain": "", style: {
234
+ position: "absolute",
235
+ inset: 0,
236
+ opacity: 0.06,
237
+ backgroundImage: "repeating-linear-gradient(0deg, rgba(255,255,255,0.5) 0px, rgba(255,255,255,0.5) 1px, transparent 1px, transparent 3px)",
238
+ mixBlendMode: "overlay",
239
+ } })] }), _jsxs("header", { "data-jg-game-menu-header": "", style: {
240
+ position: "relative",
241
+ zIndex: 1,
242
+ gridColumn: "1 / -1",
243
+ display: "flex",
244
+ flexDirection: "column",
245
+ gap: 6,
246
+ minWidth: 0,
247
+ }, children: [eyebrow === undefined ? null : (_jsx("div", { "data-jg-game-menu-eyebrow": "", style: {
248
+ fontSize: hudType("xs"),
249
+ letterSpacing: "0.3em",
250
+ textTransform: "uppercase",
251
+ color: "var(--jg-accent, #7dd3fc)",
252
+ fontWeight: 700,
253
+ }, children: eyebrow })), logoNode(logo, title, reduced), tagline === undefined ? null : (_jsx("div", { "data-jg-game-menu-tagline": "", style: {
254
+ fontSize: hudType(isPause ? "sm" : "md"),
255
+ color: "var(--jg-text-dim, rgba(226,235,245,0.68))",
256
+ maxWidth: "46ch",
257
+ }, children: tagline }))] }), _jsx("div", { "data-jg-game-menu-nav-wrap": "", style: {
258
+ position: "relative",
259
+ zIndex: 1,
260
+ display: "flex",
261
+ flexDirection: "column",
262
+ justifyContent: isPause ? "center" : "flex-start",
263
+ minHeight: 0,
264
+ paddingTop: isPause ? 0 : "clamp(8px, 2vh, 28px)",
265
+ }, children: _jsxs("nav", { "data-jg-game-menu-items": "", ...nav.containerProps, style: { position: "relative", display: "flex", flexDirection: "column", gap: 2 }, onKeyDown: (event) => {
266
+ nav.containerProps.onKeyDown(event);
267
+ if (event.key === "ArrowDown" || event.key === "ArrowUp")
268
+ event.stopPropagation();
269
+ }, children: [_jsx("div", { "data-jg-game-menu-nav-surface": "", "aria-hidden": "true", style: {
270
+ position: "absolute",
271
+ inset: "-16px -22px",
272
+ borderRadius: "var(--jg-menu-surface-radius, 8px)",
273
+ background: "linear-gradient(96deg, rgba(4,7,11,0.6) 0%, rgba(4,7,11,0.34) 56%, rgba(4,7,11,0) 100%)",
274
+ maskImage: "linear-gradient(180deg, transparent 0, #000 9%, #000 91%, transparent 100%)",
275
+ WebkitMaskImage: "linear-gradient(180deg, transparent 0, #000 9%, #000 91%, transparent 100%)",
276
+ backdropFilter: "blur(2px)",
277
+ WebkitBackdropFilter: "blur(2px)",
278
+ pointerEvents: "none",
279
+ } }), _jsx("div", { "data-jg-game-menu-rail": "", "aria-hidden": "true", style: {
280
+ position: "absolute",
281
+ left: 0,
282
+ right: 8,
283
+ top: 0,
284
+ height: rowHeight,
285
+ borderRadius: 10,
286
+ opacity: activeIndex < 0 ? 0 : 1,
287
+ transform: `translate3d(0, ${Math.max(0, activeIndex) * (rowHeight + 2)}px, 0) scale(${activeIndex >= primaryCount ? 0.98 : 1})`,
288
+ transition: reduced
289
+ ? undefined
290
+ : `transform ${hudDuration("base")} ${hudEase("spring")}, opacity ${hudDuration("fast")} ${hudEase("easeOut")}`,
291
+ background: "linear-gradient(90deg, var(--jg-accent-glow, rgba(94,196,255,0.22)) 0%, rgba(255,255,255,0.05) 62%, transparent 100%)",
292
+ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.14), 0 10px 30px -14px rgba(0,0,0,0.9)",
293
+ borderLeft: "2px solid var(--jg-accent, #7dd3fc)",
294
+ } }), items.map((item) => {
295
+ const active = item.id === activeId;
296
+ return (_jsxs("button", { type: "button", ...hoverProps(item.id), "data-jg-game-menu-item": item.id, "data-active": active ? "" : undefined, disabled: item.disabled === true, onClick: () => {
297
+ if (item.disabled === true)
298
+ return;
299
+ soundRef.current?.play("confirm");
300
+ onSelect(item.id);
301
+ }, style: {
302
+ position: "relative",
303
+ display: "flex",
304
+ alignItems: "center",
305
+ justifyContent: "space-between",
306
+ gap: 10,
307
+ height: rowHeight,
308
+ paddingLeft: active ? 18 : 12,
309
+ paddingRight: 12,
310
+ border: "none",
311
+ background: "transparent",
312
+ color: item.disabled === true ? "rgba(226,235,245,0.32)" : active ? "var(--jg-text, #ffffff)" : "rgba(226,235,245,0.78)",
313
+ fontFamily: hudFont.display,
314
+ fontSize: hudType(isPause ? "md" : "xl"),
315
+ fontWeight: active ? 700 : 600,
316
+ letterSpacing: "0.01em",
317
+ textAlign: "left",
318
+ cursor: item.disabled === true ? "not-allowed" : "pointer",
319
+ transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}, color ${hudDuration("fast")} linear`,
320
+ textShadow: active
321
+ ? "0 1px 12px rgba(0,0,0,0.8), 0 0 22px var(--jg-accent-glow, rgba(94,196,255,0.4))"
322
+ : "0 1px 10px rgba(0,0,0,0.72)",
323
+ outline: "none",
324
+ }, children: [_jsxs("span", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [_jsx("span", { style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: item.label }), item.hint === undefined ? null : (_jsx("span", { style: {
325
+ fontSize: hudType("xs"),
326
+ fontWeight: 500,
327
+ letterSpacing: 0,
328
+ color: "var(--jg-text-dim, rgba(203,213,225,0.66))",
329
+ whiteSpace: "nowrap",
330
+ overflow: "hidden",
331
+ textOverflow: "ellipsis",
332
+ opacity: active ? 1 : 0.72,
333
+ }, children: item.hint }))] }), item.badge === undefined ? null : (_jsx("span", { style: { fontSize: hudType("xs"), color: "var(--jg-accent, #7dd3fc)", fontWeight: 700 }, children: item.badge }))] }, item.id));
334
+ }), secondary === undefined || secondary.length === 0 ? null : (_jsx("div", { style: { height: 1, margin: "10px 12px", background: "rgba(255,255,255,0.12)" }, "aria-hidden": "true" })), (secondary ?? []).map((item) => {
335
+ const active = item.id === activeId;
336
+ return (_jsx("button", { type: "button", ...hoverProps(item.id), "data-jg-game-menu-item": item.id, "data-secondary": "", "data-active": active ? "" : undefined, disabled: item.disabled === true, onClick: () => {
337
+ if (item.disabled === true)
338
+ return;
339
+ soundRef.current?.play("confirm");
340
+ onSelect(item.id);
341
+ }, style: {
342
+ position: "relative",
343
+ display: "flex",
344
+ alignItems: "center",
345
+ height: rowHeight,
346
+ paddingLeft: active ? 18 : 12,
347
+ border: "none",
348
+ background: "transparent",
349
+ color: active ? "var(--jg-text, #ffffff)" : "rgba(203,213,225,0.6)",
350
+ fontFamily: hudFont.body,
351
+ fontSize: hudType("md"),
352
+ fontWeight: 600,
353
+ textAlign: "left",
354
+ cursor: item.disabled === true ? "not-allowed" : "pointer",
355
+ transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}`,
356
+ outline: "none",
357
+ }, children: item.label }, item.id));
358
+ })] }) }), _jsx("div", { "data-jg-game-menu-detail": "", "data-detail-id": activeId ?? "", className: detailClassName, style: {
359
+ position: "relative",
360
+ zIndex: 1,
361
+ minWidth: 0,
362
+ minHeight: 0,
363
+ maxHeight: "100%",
364
+ overflow: "hidden",
365
+ display: "flex",
366
+ flexDirection: "column",
367
+ paddingTop: isPause ? 0 : "clamp(6px, 1.6vh, 20px)",
368
+ ...detailStyle,
369
+ }, children: detailNode === undefined || detailNode === null ? null : (_jsx("div", { "data-jg-game-menu-pane": "", style: {
370
+ minHeight: 0,
371
+ flex: 1,
372
+ display: "flex",
373
+ flexDirection: "column",
374
+ animation: reduced ? undefined : `jg-menu-pane-in ${hudDuration("slow")} ${hudEase("easeOut")} both`,
375
+ }, children: detailNode }, activeId ?? "none")) }), _jsxs("footer", { "data-jg-game-menu-footer": "", style: {
376
+ position: "relative",
377
+ zIndex: 1,
378
+ gridColumn: "1 / -1",
379
+ display: "flex",
380
+ alignItems: "center",
381
+ justifyContent: "space-between",
382
+ gap: 16,
383
+ paddingTop: 8,
384
+ fontSize: hudType("xs"),
385
+ letterSpacing: "0.08em",
386
+ textTransform: "uppercase",
387
+ color: "rgba(203,213,225,0.45)",
388
+ }, children: [_jsx("span", { "data-jg-game-menu-footer-left": "", children: footer }), _jsx("span", { "data-jg-kbd-hint": "", "data-jg-game-menu-footer-hint": "", children: controlHint ??
389
+ (isPause ? "Esc resume · Enter select" : "↑↓ move · Enter select · Esc back") })] })] }));
390
+ }
391
+ /**
392
+ * A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a
393
+ * game composes one menu system rather than a second one. Escape (or B/Start) resumes.
394
+ *
395
+ * @capability game-pause-menu pause menu from the shared game-menu system — same navigation and surface, sized down
396
+ */
397
+ export function GamePauseMenu(props) {
398
+ const { onResume, onBack, ...rest } = props;
399
+ return _jsx(GameMenu, { ...rest, variant: "pause", onBack: onBack ?? onResume, label: rest.label ?? "Paused" });
400
+ }