@jgengine/react 0.36.0 → 0.38.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,390 @@
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-rail": "", "aria-hidden": "true", style: {
270
+ position: "absolute",
271
+ left: 0,
272
+ right: 8,
273
+ top: 0,
274
+ height: rowHeight,
275
+ borderRadius: 10,
276
+ opacity: activeIndex < 0 ? 0 : 1,
277
+ transform: `translate3d(0, ${Math.max(0, activeIndex) * (rowHeight + 2)}px, 0) scale(${activeIndex >= primaryCount ? 0.98 : 1})`,
278
+ transition: reduced
279
+ ? undefined
280
+ : `transform ${hudDuration("base")} ${hudEase("spring")}, opacity ${hudDuration("fast")} ${hudEase("easeOut")}`,
281
+ background: "linear-gradient(90deg, var(--jg-accent-glow, rgba(94,196,255,0.22)) 0%, rgba(255,255,255,0.05) 62%, transparent 100%)",
282
+ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.14), 0 10px 30px -14px rgba(0,0,0,0.9)",
283
+ borderLeft: "2px solid var(--jg-accent, #7dd3fc)",
284
+ } }), items.map((item) => {
285
+ const active = item.id === activeId;
286
+ return (_jsxs("button", { type: "button", ...hoverProps(item.id), "data-jg-game-menu-item": item.id, "data-active": active ? "" : undefined, disabled: item.disabled === true, onClick: () => {
287
+ if (item.disabled === true)
288
+ return;
289
+ soundRef.current?.play("confirm");
290
+ onSelect(item.id);
291
+ }, style: {
292
+ position: "relative",
293
+ display: "flex",
294
+ alignItems: "center",
295
+ justifyContent: "space-between",
296
+ gap: 10,
297
+ height: rowHeight,
298
+ paddingLeft: active ? 18 : 12,
299
+ paddingRight: 12,
300
+ border: "none",
301
+ background: "transparent",
302
+ color: item.disabled === true ? "rgba(226,235,245,0.32)" : active ? "var(--jg-text, #ffffff)" : "rgba(226,235,245,0.78)",
303
+ fontFamily: hudFont.display,
304
+ fontSize: hudType(isPause ? "md" : "xl"),
305
+ fontWeight: active ? 700 : 600,
306
+ letterSpacing: "0.01em",
307
+ textAlign: "left",
308
+ cursor: item.disabled === true ? "not-allowed" : "pointer",
309
+ transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}, color ${hudDuration("fast")} linear`,
310
+ textShadow: active
311
+ ? "0 1px 12px rgba(0,0,0,0.8), 0 0 22px var(--jg-accent-glow, rgba(94,196,255,0.4))"
312
+ : "0 1px 10px rgba(0,0,0,0.72)",
313
+ outline: "none",
314
+ }, 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: {
315
+ fontSize: hudType("xs"),
316
+ fontWeight: 500,
317
+ letterSpacing: 0,
318
+ color: "var(--jg-text-dim, rgba(203,213,225,0.66))",
319
+ whiteSpace: "nowrap",
320
+ overflow: "hidden",
321
+ textOverflow: "ellipsis",
322
+ opacity: active ? 1 : 0.72,
323
+ }, 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));
324
+ }), 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) => {
325
+ const active = item.id === activeId;
326
+ 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: () => {
327
+ if (item.disabled === true)
328
+ return;
329
+ soundRef.current?.play("confirm");
330
+ onSelect(item.id);
331
+ }, style: {
332
+ position: "relative",
333
+ display: "flex",
334
+ alignItems: "center",
335
+ height: rowHeight,
336
+ paddingLeft: active ? 18 : 12,
337
+ border: "none",
338
+ background: "transparent",
339
+ color: active ? "var(--jg-text, #ffffff)" : "rgba(203,213,225,0.6)",
340
+ fontFamily: hudFont.body,
341
+ fontSize: hudType("md"),
342
+ fontWeight: 600,
343
+ textAlign: "left",
344
+ cursor: item.disabled === true ? "not-allowed" : "pointer",
345
+ transition: reduced ? undefined : `padding ${hudDuration("base")} ${hudEase("spring")}`,
346
+ outline: "none",
347
+ }, children: item.label }, item.id));
348
+ })] }) }), _jsx("div", { "data-jg-game-menu-detail": "", "data-detail-id": activeId ?? "", className: detailClassName, style: {
349
+ position: "relative",
350
+ zIndex: 1,
351
+ minWidth: 0,
352
+ minHeight: 0,
353
+ maxHeight: "100%",
354
+ overflow: "hidden",
355
+ display: "flex",
356
+ flexDirection: "column",
357
+ paddingTop: isPause ? 0 : "clamp(6px, 1.6vh, 20px)",
358
+ ...detailStyle,
359
+ }, children: detailNode === undefined || detailNode === null ? null : (_jsx("div", { "data-jg-game-menu-pane": "", style: {
360
+ minHeight: 0,
361
+ flex: 1,
362
+ display: "flex",
363
+ flexDirection: "column",
364
+ animation: reduced ? undefined : `jg-menu-pane-in ${hudDuration("slow")} ${hudEase("easeOut")} both`,
365
+ }, children: detailNode }, activeId ?? "none")) }), _jsxs("footer", { "data-jg-game-menu-footer": "", style: {
366
+ position: "relative",
367
+ zIndex: 1,
368
+ gridColumn: "1 / -1",
369
+ display: "flex",
370
+ alignItems: "center",
371
+ justifyContent: "space-between",
372
+ gap: 16,
373
+ paddingTop: 8,
374
+ fontSize: hudType("xs"),
375
+ letterSpacing: "0.08em",
376
+ textTransform: "uppercase",
377
+ color: "rgba(203,213,225,0.45)",
378
+ }, 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 ??
379
+ (isPause ? "Esc resume · Enter select" : "↑↓ move · Enter select · Esc back") })] })] }));
380
+ }
381
+ /**
382
+ * A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a
383
+ * game composes one menu system rather than a second one. Escape (or B/Start) resumes.
384
+ *
385
+ * @capability game-pause-menu pause menu from the shared game-menu system — same navigation and surface, sized down
386
+ */
387
+ export function GamePauseMenu(props) {
388
+ const { onResume, onBack, ...rest } = props;
389
+ return _jsx(GameMenu, { ...rest, variant: "pause", onBack: onBack ?? onResume, label: rest.label ?? "Paused" });
390
+ }
@@ -0,0 +1,159 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { CreditsSection } from "@jgengine/core/game/credits";
3
+ import type { SaveSlotMeta, SaveSlots } from "@jgengine/core/game/saveSlots";
4
+ /**
5
+ * The save slot `meta` key a card reads as its thumbnail: a data URL or image URL captured when the
6
+ * save was written. Every other meta key is shown as a progress chip and never interpreted.
7
+ */
8
+ export declare const SAVE_SLOT_THUMBNAIL_KEY = "thumbnail";
9
+ /** The character name a save card shows first, when the game supplies one as its own meta key. */
10
+ export declare const SAVE_SLOT_CHARACTER_KEY = "character";
11
+ /** Props for {@link SaveSlotCard}. */
12
+ export interface SaveSlotCardProps {
13
+ /** The slot to render. An empty slot renders as an empty slot, not a hidden row. */
14
+ slot: SaveSlotMeta;
15
+ /** Clock (ms) for the relative "saved" line. */
16
+ now?: () => number;
17
+ /** Highlight this slot as the one Continue would resume. */
18
+ recent?: boolean;
19
+ /** Load this filled slot, or start a new game in this empty slot. */
20
+ onSelect?: (slot: SaveSlotMeta) => void;
21
+ /** Delete this slot. Only called after the player confirms. */
22
+ onDelete?: (slot: SaveSlotMeta) => void;
23
+ /** Label of the primary action on a filled card. Default `"Load"`. */
24
+ loadLabel?: string;
25
+ className?: string;
26
+ style?: CSSProperties;
27
+ }
28
+ /**
29
+ * One save slot as a card: a captured thumbnail, the character name, playtime, the game's progress
30
+ * summary and when it was saved — or, for an empty slot, an empty slot with a New Game affordance.
31
+ * Deleting takes two presses: the first swaps the button for a confirm pair, so a mis-click cannot
32
+ * erase a run.
33
+ *
34
+ * @capability save-slot-card save slot as a card — thumbnail, character, playtime, progress, timestamp, confirmed delete
35
+ */
36
+ export declare function SaveSlotCard({ slot, now, recent, onSelect, onDelete, loadLabel, className, style, }: SaveSlotCardProps): ReactNode;
37
+ /** Props for {@link SaveSlotsPane}. */
38
+ export interface SaveSlotsPaneProps {
39
+ /** The save-slot index to render. */
40
+ model: SaveSlots;
41
+ /** Resume the most recent save. Continue targets `model.mostRecent()`. */
42
+ onContinue?: (slot: SaveSlotMeta) => void;
43
+ /** Load a chosen filled slot. */
44
+ onLoad?: (slot: SaveSlotMeta) => void;
45
+ /** Start a new game in a chosen empty slot. */
46
+ onNew?: (slot: SaveSlotMeta) => void;
47
+ /** Delete a confirmed slot. */
48
+ onDelete?: (slot: SaveSlotMeta) => void;
49
+ /** Heading over the grid. Default `"Save slots"`. */
50
+ heading?: ReactNode;
51
+ /** Clock (ms) for relative times. */
52
+ now?: () => number;
53
+ /** Cards per row, or `"auto"` to fit as many as the pane allows (min 230px each). Default 2. */
54
+ columns?: number | "auto";
55
+ className?: string;
56
+ style?: CSSProperties;
57
+ }
58
+ /**
59
+ * The slot grid for a {@link GameMenu} detail pane: Continue over the most recent save, then one card
60
+ * per slot. Arrow keys move between cards, Enter loads, and the grid scrolls inside its own pane
61
+ * rather than the page.
62
+ *
63
+ * @capability save-slots-pane slot grid pane for the game menu — Continue newest, per-slot cards, keyboard navigation
64
+ */
65
+ export declare function SaveSlotsPane({ model, onContinue, onLoad, onNew, onDelete, heading, now, columns, className, style, }: SaveSlotsPaneProps): ReactNode;
66
+ /** Props for {@link ContinuePane}. */
67
+ export interface ContinuePaneProps {
68
+ /** The save Continue would resume. `null` when nothing is saved yet. */
69
+ slot: SaveSlotMeta | null;
70
+ /** Resume it. */
71
+ onContinue?: (slot: SaveSlotMeta) => void;
72
+ /** Shown instead of the card when `slot` is null. */
73
+ emptyMessage?: ReactNode;
74
+ /** Clock (ms) for relative times. */
75
+ now?: () => number;
76
+ className?: string;
77
+ style?: CSSProperties;
78
+ }
79
+ /**
80
+ * The Continue detail pane: the save being continued, read big — captured thumbnail, character, time
81
+ * played, the game's progress summary, when it was saved, and the resume action.
82
+ *
83
+ * @capability continue-pane continue detail pane — the save being resumed with thumbnail, character, playtime and progress
84
+ */
85
+ export declare function ContinuePane({ slot, onContinue, emptyMessage, now, className, style, }: ContinuePaneProps): ReactNode;
86
+ /** One avatar/portrait choice in {@link NewCharacterPane}. */
87
+ export interface GameMenuAvatar {
88
+ id: string;
89
+ label: string;
90
+ /** A glyph or small image. Falls back to the label's first letter. */
91
+ glyph?: ReactNode;
92
+ }
93
+ /** One palette choice in {@link NewCharacterPane}. */
94
+ export interface GameMenuPalette {
95
+ id: string;
96
+ label: string;
97
+ /** Two or more CSS colours; rendered as a swatch chip. */
98
+ colors: readonly string[];
99
+ }
100
+ /** What {@link NewCharacterPane} reports when a run starts. */
101
+ export interface NewCharacterChoice {
102
+ name: string;
103
+ paletteId: string | null;
104
+ avatarId: string | null;
105
+ }
106
+ /** Props for {@link NewCharacterPane}. */
107
+ export interface NewCharacterPaneProps {
108
+ /** Current name value. */
109
+ name: string;
110
+ onNameChange: (name: string) => void;
111
+ /** Palette choices the game offers. Omit to hide the row. */
112
+ palettes?: readonly GameMenuPalette[];
113
+ paletteId?: string | null;
114
+ onPaletteChange?: (id: string) => void;
115
+ /** Avatar choices the game offers. Omit to hide the row. */
116
+ avatars?: readonly GameMenuAvatar[];
117
+ avatarId?: string | null;
118
+ onAvatarChange?: (id: string) => void;
119
+ /** Start the run with the current choice. */
120
+ onStart: (choice: NewCharacterChoice) => void;
121
+ /** Heading. Default `"New run"`. */
122
+ heading?: ReactNode;
123
+ /** Name field label. Default `"Character name"`. */
124
+ nameLabel?: string;
125
+ /** Name placeholder. */
126
+ namePlaceholder?: string;
127
+ /** Longest accepted name. Default 24. */
128
+ maxLength?: number;
129
+ /** Start button label. Default `"Begin"`. */
130
+ startLabel?: string;
131
+ className?: string;
132
+ style?: CSSProperties;
133
+ }
134
+ /**
135
+ * The New Game detail pane: a character name field, an optional palette and avatar choice, and the
136
+ * action that starts the run. The name field is a real form field, so menu key handling leaves typing
137
+ * alone (Enter inside the field starts the run).
138
+ *
139
+ * @capability new-character-pane character creation pane — name field, palette and avatar choice, start action
140
+ */
141
+ export declare function NewCharacterPane({ name, onNameChange, palettes, paletteId, onPaletteChange, avatars, avatarId, onAvatarChange, onStart, heading, nameLabel, namePlaceholder, maxLength, startLabel, className, style, }: NewCharacterPaneProps): ReactNode;
142
+ /** Props for {@link CreditsPane}. */
143
+ export interface CreditsPaneProps {
144
+ /** Sections, in order. */
145
+ sections: readonly CreditsSection[];
146
+ /** Heading over the scroller. Default `"Credits"`. */
147
+ heading?: ReactNode;
148
+ /** Closing line, usually the asset/attribution notice. */
149
+ notice?: ReactNode;
150
+ className?: string;
151
+ style?: CSSProperties;
152
+ }
153
+ /**
154
+ * The Credits detail pane: a scroller that belongs to the pane. Wheel, arrows, PageUp/PageDown, Home
155
+ * and End scroll it, the native scrollbar is hidden, and the page behind never moves.
156
+ *
157
+ * @capability credits-pane credits detail pane — sections scrolled inside the pane
158
+ */
159
+ export declare function CreditsPane({ sections, heading, notice, className, style }: CreditsPaneProps): ReactNode;