@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,468 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { formatSavePlaytime } from "@jgengine/core/game/saveSlotIndex";
4
+ import { hudDuration, hudEase, hudFont, hudSpace, hudType } from "./hudTokens.js";
5
+ import { useMenuNav } from "./menuNav.js";
6
+ import { relativeSavedAt, useSaveSlots } from "./saveSlots.js";
7
+ /**
8
+ * The save slot `meta` key a card reads as its thumbnail: a data URL or image URL captured when the
9
+ * save was written. Every other meta key is shown as a progress chip and never interpreted.
10
+ */
11
+ export const SAVE_SLOT_THUMBNAIL_KEY = "thumbnail";
12
+ /** The character name a save card shows first, when the game supplies one as its own meta key. */
13
+ export const SAVE_SLOT_CHARACTER_KEY = "character";
14
+ const cardSurface = {
15
+ position: "relative",
16
+ width: "100%",
17
+ boxSizing: "border-box",
18
+ display: "flex",
19
+ flexDirection: "column",
20
+ gap: 8,
21
+ minWidth: 0,
22
+ padding: 10,
23
+ borderRadius: "var(--jg-menu-card-radius, 12px)",
24
+ background: "linear-gradient(180deg, rgba(16,22,30,0.78), rgba(8,11,16,0.86))",
25
+ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.09), 0 14px 34px -18px rgba(0,0,0,0.9)",
26
+ backdropFilter: "blur(6px)",
27
+ color: "var(--jg-text, #f2f6fb)",
28
+ fontFamily: hudFont.body,
29
+ textAlign: "left",
30
+ };
31
+ function thumbnailOf(slot) {
32
+ const value = slot.meta[SAVE_SLOT_THUMBNAIL_KEY];
33
+ if (typeof value !== "string" || value.length === 0)
34
+ return null;
35
+ return value;
36
+ }
37
+ function progressEntries(slot) {
38
+ return Object.entries(slot.meta)
39
+ .filter(([key, value]) => key !== SAVE_SLOT_THUMBNAIL_KEY && key !== "playtime" && String(value).length > 0)
40
+ .map(([key, value]) => [key, String(value)]);
41
+ }
42
+ function playtimeOf(slot) {
43
+ const value = slot.meta.playtime;
44
+ if (value === undefined)
45
+ return null;
46
+ if (typeof value === "number")
47
+ return formatSavePlaytime(value);
48
+ return String(value);
49
+ }
50
+ /**
51
+ * One save slot as a card: a captured thumbnail, the character name, playtime, the game's progress
52
+ * summary and when it was saved — or, for an empty slot, an empty slot with a New Game affordance.
53
+ * Deleting takes two presses: the first swaps the button for a confirm pair, so a mis-click cannot
54
+ * erase a run.
55
+ *
56
+ * @capability save-slot-card save slot as a card — thumbnail, character, playtime, progress, timestamp, confirmed delete
57
+ */
58
+ export function SaveSlotCard({ slot, now = Date.now, recent = false, onSelect, onDelete, loadLabel, className, style, }) {
59
+ const [confirming, setConfirming] = useState(false);
60
+ const thumb = thumbnailOf(slot);
61
+ const playtime = playtimeOf(slot);
62
+ const progress = progressEntries(slot);
63
+ const character = slot.meta[SAVE_SLOT_CHARACTER_KEY];
64
+ if (slot.empty) {
65
+ return (_jsxs("button", { type: "button", "data-save-slot-card": "", "data-save-slot-id": slot.id, "data-empty": "", className: className, onClick: () => onSelect?.(slot), style: {
66
+ ...cardSurface,
67
+ cursor: onSelect === undefined ? "default" : "pointer",
68
+ alignItems: "flex-start",
69
+ justifyContent: "flex-end",
70
+ border: "none",
71
+ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.07), inset 0 0 40px -18px rgba(125,211,252,0.4), 0 10px 26px -20px rgba(0,0,0,0.9)",
72
+ background: "linear-gradient(180deg, rgba(12,17,24,0.6), rgba(6,9,13,0.72))",
73
+ ...style,
74
+ }, children: [_jsx("div", { "data-save-slot-thumb": "", "data-empty": "", style: {
75
+ width: "100%",
76
+ aspectRatio: "16 / 9",
77
+ borderRadius: 8,
78
+ border: "1px dashed rgba(203,213,225,0.28)",
79
+ display: "flex",
80
+ alignItems: "center",
81
+ justifyContent: "center",
82
+ color: "rgba(203,213,225,0.5)",
83
+ fontSize: hudType("xs"),
84
+ letterSpacing: "0.12em",
85
+ textTransform: "uppercase",
86
+ }, children: "Empty slot" }), _jsx("span", { style: { fontSize: hudType("md"), fontWeight: 700 }, children: "New game" }), _jsxs("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.6)" }, children: ["Slot ", slot.id] })] }));
87
+ }
88
+ return (_jsxs("div", { "data-save-slot-card": "", "data-save-slot-id": slot.id, "data-recent": recent ? "" : undefined, className: className, style: {
89
+ ...cardSurface,
90
+ boxShadow: recent
91
+ ? "inset 0 0 0 1px var(--jg-accent, #7dd3fc), 0 0 0 1px rgba(125,211,252,0.35), 0 18px 40px -18px rgba(0,0,0,0.95)"
92
+ : cardSurface.boxShadow,
93
+ ...style,
94
+ }, children: [_jsxs("div", { "data-save-slot-thumb": "", style: {
95
+ position: "relative",
96
+ width: "100%",
97
+ aspectRatio: "16 / 9",
98
+ borderRadius: 8,
99
+ overflow: "hidden",
100
+ background: "radial-gradient(120% 120% at 30% 20%, rgba(125,211,252,0.22), rgba(8,12,18,0.9))",
101
+ }, children: [thumb === null ? (_jsx("span", { style: {
102
+ position: "absolute",
103
+ inset: 0,
104
+ display: "flex",
105
+ alignItems: "center",
106
+ justifyContent: "center",
107
+ fontSize: hudType("xs"),
108
+ letterSpacing: "0.12em",
109
+ textTransform: "uppercase",
110
+ color: "rgba(203,213,225,0.45)",
111
+ }, children: "No capture" })) : (_jsx("img", { "data-save-slot-thumb-img": "", src: thumb, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })), _jsx("span", { style: {
112
+ position: "absolute",
113
+ left: 0,
114
+ right: 0,
115
+ bottom: 0,
116
+ height: "42%",
117
+ background: "linear-gradient(180deg, transparent, rgba(4,7,11,0.92))",
118
+ } }), _jsx("span", { "data-save-slot-name": "", style: {
119
+ position: "absolute",
120
+ left: 8,
121
+ bottom: 5,
122
+ fontFamily: hudFont.display,
123
+ fontSize: hudType("lg"),
124
+ fontWeight: 800,
125
+ letterSpacing: "0.01em",
126
+ textShadow: "0 2px 12px rgba(0,0,0,0.8)",
127
+ }, children: slot.name ?? slot.id }), recent ? (_jsx("span", { "data-save-slot-recent": "", style: {
128
+ position: "absolute",
129
+ top: 6,
130
+ right: 6,
131
+ padding: "2px 7px",
132
+ borderRadius: 999,
133
+ background: "var(--jg-accent, #7dd3fc)",
134
+ color: "#04121b",
135
+ fontSize: 10,
136
+ fontWeight: 800,
137
+ letterSpacing: "0.09em",
138
+ textTransform: "uppercase",
139
+ }, children: "Latest" })) : null] }), _jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8 }, children: [_jsxs("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.66)", display: "flex", gap: 6, flexWrap: "wrap" }, children: [typeof character === "string" ? _jsx("span", { "data-save-slot-character": "", children: character }) : null, playtime === null ? null : _jsxs("span", { "data-save-slot-playtime": "", children: [playtime, " played"] })] }), slot.savedAt === undefined ? null : (_jsx("span", { "data-save-slot-saved-at": "", title: new Date(slot.savedAt).toLocaleString(), style: { fontSize: hudType("xs"), color: "var(--jg-accent, #7dd3fc)", fontWeight: 600, whiteSpace: "nowrap" }, children: relativeSavedAt(slot.savedAt, now()) }))] }), progress.length === 0 ? null : (_jsx("div", { style: { display: "flex", gap: 5, flexWrap: "wrap" }, children: progress.map(([key, value]) => (_jsxs("span", { "data-save-slot-chip": key, style: {
140
+ padding: "2px 7px",
141
+ borderRadius: 999,
142
+ background: "rgba(148,163,184,0.14)",
143
+ color: "rgba(226,235,245,0.82)",
144
+ fontSize: 11,
145
+ fontWeight: 600,
146
+ whiteSpace: "nowrap",
147
+ }, children: [_jsx("span", { style: { opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em", fontSize: 10 }, children: key }), " ", value] }, key))) })), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: "auto" }, children: [_jsx("button", { type: "button", "data-save-slot-load": "", onClick: () => onSelect?.(slot), style: {
148
+ flex: 1,
149
+ padding: "7px 10px",
150
+ borderRadius: 8,
151
+ border: "none",
152
+ background: "var(--jg-accent, #7dd3fc)",
153
+ color: "#04121b",
154
+ fontSize: hudType("sm"),
155
+ fontWeight: 800,
156
+ letterSpacing: "0.04em",
157
+ textTransform: "uppercase",
158
+ cursor: "pointer",
159
+ }, children: loadLabel ?? "Load" }), onDelete === undefined ? null : confirming ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-save-slot-confirm-delete": "", onClick: () => {
160
+ setConfirming(false);
161
+ onDelete(slot);
162
+ }, style: {
163
+ padding: "7px 10px",
164
+ borderRadius: 8,
165
+ border: "1px solid var(--jg-danger, #e5484d)",
166
+ background: "rgba(229,72,77,0.2)",
167
+ color: "#ffd7d8",
168
+ fontSize: hudType("sm"),
169
+ fontWeight: 800,
170
+ cursor: "pointer",
171
+ }, children: "Delete" }), _jsx("button", { type: "button", "data-save-slot-cancel-delete": "", onClick: () => setConfirming(false), style: {
172
+ padding: "7px 10px",
173
+ borderRadius: 8,
174
+ border: "1px solid rgba(148,163,184,0.3)",
175
+ background: "transparent",
176
+ color: "rgba(226,235,245,0.8)",
177
+ fontSize: hudType("sm"),
178
+ fontWeight: 700,
179
+ cursor: "pointer",
180
+ }, children: "Keep" })] })) : (_jsx("button", { type: "button", "data-save-slot-delete": "", "aria-label": `Delete ${slot.name ?? slot.id}`, onClick: (event) => {
181
+ event.stopPropagation();
182
+ setConfirming(true);
183
+ }, style: {
184
+ padding: "7px 10px",
185
+ borderRadius: 8,
186
+ border: "1px solid rgba(148,163,184,0.28)",
187
+ background: "transparent",
188
+ color: "rgba(226,235,245,0.72)",
189
+ fontSize: hudType("sm"),
190
+ fontWeight: 700,
191
+ cursor: "pointer",
192
+ }, children: "Delete" }))] })] }));
193
+ }
194
+ /**
195
+ * The slot grid for a {@link GameMenu} detail pane: Continue over the most recent save, then one card
196
+ * per slot. Arrow keys move between cards, Enter loads, and the grid scrolls inside its own pane
197
+ * rather than the page.
198
+ *
199
+ * @capability save-slots-pane slot grid pane for the game menu — Continue newest, per-slot cards, keyboard navigation
200
+ */
201
+ export function SaveSlotsPane({ model, onContinue, onLoad, onNew, onDelete, heading = "Save slots", now = Date.now, columns = 2, className, style, }) {
202
+ const slots = useSaveSlots(model);
203
+ const recent = model.mostRecent();
204
+ const tracks = columns === "auto" ? 3 : columns;
205
+ const nav = useMenuNav({
206
+ ids: slots.map((slot) => slot.id),
207
+ columns: tracks,
208
+ wrap: false,
209
+ onConfirm: (id) => {
210
+ const slot = slots.find((candidate) => candidate.id === id);
211
+ if (slot === undefined)
212
+ return;
213
+ if (slot.empty)
214
+ onNew?.(slot);
215
+ else
216
+ onLoad?.(slot);
217
+ },
218
+ });
219
+ return (_jsxs("div", { "data-save-slots-pane": "", className: className, style: { display: "flex", flexDirection: "column", minHeight: 0, flex: 1, gap: hudSpace("md"), ...style }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [_jsx("div", { style: { fontFamily: hudFont.display, fontSize: hudType("xxl"), fontWeight: 800, letterSpacing: "0.01em" }, children: heading }), onContinue === undefined || recent === null ? null : (_jsxs("button", { type: "button", "data-save-slots-continue": "", onClick: () => onContinue(recent), style: {
220
+ padding: "8px 16px",
221
+ borderRadius: 999,
222
+ border: "none",
223
+ background: "var(--jg-accent, #7dd3fc)",
224
+ color: "#04121b",
225
+ fontSize: hudType("sm"),
226
+ fontWeight: 800,
227
+ letterSpacing: "0.06em",
228
+ textTransform: "uppercase",
229
+ cursor: "pointer",
230
+ boxShadow: "0 10px 24px -12px rgba(125,211,252,0.8)",
231
+ }, children: ["Continue ", recent.name ?? recent.id] }))] }), _jsx("div", { ...nav.containerProps, "data-save-slots-grid": "", style: {
232
+ display: "grid",
233
+ gridTemplateColumns: columns === "auto" ? "repeat(auto-fill, minmax(230px, 1fr))" : `repeat(${columns}, minmax(0, 1fr))`,
234
+ gap: hudSpace("md"),
235
+ minHeight: 0,
236
+ overflowY: "auto",
237
+ overscrollBehavior: "contain",
238
+ scrollbarWidth: "none",
239
+ paddingRight: 2,
240
+ paddingBottom: 4,
241
+ }, children: slots.map((slot) => {
242
+ const active = nav.activeId === slot.id;
243
+ return (_jsx("div", { style: { minWidth: 0, outline: active ? "none" : undefined }, children: _jsx(SaveSlotCard, { slot: slot, now: now, recent: recent !== null && recent.id === slot.id, ...(onLoad === undefined && onNew === undefined
244
+ ? {}
245
+ : {
246
+ onSelect: (chosen) => {
247
+ if (chosen.empty)
248
+ onNew?.(chosen);
249
+ else
250
+ onLoad?.(chosen);
251
+ },
252
+ }), ...(onDelete === undefined ? {} : { onDelete }), style: {
253
+ height: "100%",
254
+ ...(active
255
+ ? {
256
+ boxShadow: "inset 0 0 0 1px var(--jg-accent, #7dd3fc), 0 0 0 1px rgba(125,211,252,0.5), 0 18px 40px -18px rgba(0,0,0,0.95)",
257
+ transform: "translateY(-2px)",
258
+ transition: `transform ${hudDuration("fast")} ${hudEase("spring")}`,
259
+ }
260
+ : {}),
261
+ } }) }, slot.id));
262
+ }) })] }));
263
+ }
264
+ /**
265
+ * The Continue detail pane: the save being continued, read big — captured thumbnail, character, time
266
+ * played, the game's progress summary, when it was saved, and the resume action.
267
+ *
268
+ * @capability continue-pane continue detail pane — the save being resumed with thumbnail, character, playtime and progress
269
+ */
270
+ export function ContinuePane({ slot, onContinue, emptyMessage = "No run saved yet. Start a new game to begin one.", now = Date.now, className, style, }) {
271
+ if (slot === null) {
272
+ return (_jsxs("div", { "data-continue-pane": "", "data-empty": "", className: className, style: {
273
+ display: "flex",
274
+ flexDirection: "column",
275
+ justifyContent: "center",
276
+ gap: 10,
277
+ minHeight: 0,
278
+ color: "rgba(226,235,245,0.62)",
279
+ fontFamily: hudFont.body,
280
+ ...style,
281
+ }, children: [_jsx("div", { style: { fontFamily: hudFont.display, fontSize: hudType("xxl"), fontWeight: 800, color: "rgba(226,235,245,0.8)" }, children: "Nothing to continue" }), _jsx("p", { style: { margin: 0, fontSize: hudType("md"), maxWidth: "42ch" }, children: emptyMessage })] }));
282
+ }
283
+ const thumb = thumbnailOf(slot);
284
+ const playtime = playtimeOf(slot);
285
+ const progress = progressEntries(slot);
286
+ return (_jsxs("div", { "data-continue-pane": "", "data-save-slot-id": slot.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("md"), minHeight: 0, ...style }, children: [_jsxs("div", { style: {
287
+ position: "relative",
288
+ width: "min(100%, 560px)",
289
+ aspectRatio: "16 / 9",
290
+ borderRadius: 14,
291
+ overflow: "hidden",
292
+ boxShadow: "0 30px 70px -30px rgba(0,0,0,0.95), inset 0 0 0 1px rgba(255,255,255,0.12)",
293
+ background: "radial-gradient(120% 120% at 28% 18%, rgba(125,211,252,0.25), rgba(6,10,16,0.92))",
294
+ }, children: [thumb === null ? (_jsx("span", { style: {
295
+ position: "absolute",
296
+ left: 14,
297
+ bottom: 12,
298
+ fontSize: hudType("xs"),
299
+ letterSpacing: "0.16em",
300
+ textTransform: "uppercase",
301
+ color: "rgba(203,213,225,0.36)",
302
+ }, children: "No capture yet" })) : (_jsx("img", { "data-continue-thumb": "", src: thumb, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })), _jsx("span", { style: {
303
+ position: "absolute",
304
+ inset: 0,
305
+ background: "linear-gradient(180deg, transparent 40%, rgba(4,7,11,0.9))",
306
+ } }), _jsxs("div", { style: { position: "absolute", left: 14, bottom: 10, right: 14 }, children: [_jsx("div", { style: { fontSize: hudType("xs"), letterSpacing: "0.2em", textTransform: "uppercase", color: "var(--jg-accent, #7dd3fc)", fontWeight: 700 }, children: "Continue" }), _jsx("div", { "data-continue-name": "", style: { fontFamily: hudFont.display, fontSize: hudType("display"), fontWeight: 800, lineHeight: 1.05 }, children: slot.name ?? slot.id })] })] }), _jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }, children: [playtime === null ? null : (_jsxs("span", { "data-continue-playtime": "", style: {
307
+ padding: "5px 11px",
308
+ borderRadius: 999,
309
+ background: "rgba(148,163,184,0.16)",
310
+ fontSize: hudType("sm"),
311
+ fontWeight: 700,
312
+ }, children: [playtime, " played"] })), progress.map(([key, value]) => (_jsxs("span", { "data-continue-chip": key, style: {
313
+ padding: "5px 11px",
314
+ borderRadius: 999,
315
+ background: "rgba(148,163,184,0.16)",
316
+ fontSize: hudType("sm"),
317
+ fontWeight: 600,
318
+ }, children: [_jsx("span", { style: { opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em", fontSize: 11 }, children: key }), " ", value] }, key))), slot.savedAt === undefined ? null : (_jsxs("span", { "data-continue-saved-at": "", style: { fontSize: hudType("sm"), color: "rgba(203,213,225,0.62)" }, children: ["Saved ", relativeSavedAt(slot.savedAt, now()), " \u00B7 ", new Date(slot.savedAt).toLocaleString()] }))] }), onContinue === undefined ? null : (_jsx("button", { type: "button", "data-continue-action": "", onClick: () => onContinue(slot), style: {
319
+ alignSelf: "flex-start",
320
+ padding: "11px 22px",
321
+ borderRadius: 10,
322
+ border: "none",
323
+ background: "var(--jg-accent, #7dd3fc)",
324
+ color: "#04121b",
325
+ fontFamily: hudFont.display,
326
+ fontSize: hudType("lg"),
327
+ fontWeight: 800,
328
+ letterSpacing: "0.05em",
329
+ textTransform: "uppercase",
330
+ cursor: "pointer",
331
+ boxShadow: "0 18px 34px -18px rgba(125,211,252,0.9)",
332
+ }, children: "Resume" }))] }));
333
+ }
334
+ /**
335
+ * The New Game detail pane: a character name field, an optional palette and avatar choice, and the
336
+ * action that starts the run. The name field is a real form field, so menu key handling leaves typing
337
+ * alone (Enter inside the field starts the run).
338
+ *
339
+ * @capability new-character-pane character creation pane — name field, palette and avatar choice, start action
340
+ */
341
+ export function NewCharacterPane({ name, onNameChange, palettes, paletteId, onPaletteChange, avatars, avatarId, onAvatarChange, onStart, heading = "New run", nameLabel = "Character name", namePlaceholder = "Name your character", maxLength = 24, startLabel = "Begin", className, style, }) {
342
+ const trimmed = name.trim();
343
+ const start = () => {
344
+ if (trimmed.length === 0)
345
+ return;
346
+ onStart({ name: trimmed, paletteId: paletteId ?? null, avatarId: avatarId ?? null });
347
+ };
348
+ return (_jsxs("div", { "data-new-character-pane": "", className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("md"), minHeight: 0, fontFamily: hudFont.body, ...style }, children: [_jsx("div", { style: { fontFamily: hudFont.display, fontSize: hudType("xxl"), fontWeight: 800 }, children: heading }), _jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, maxWidth: 420 }, children: [_jsx("span", { style: { fontSize: hudType("xs"), letterSpacing: "0.16em", textTransform: "uppercase", color: "rgba(203,213,225,0.7)" }, children: nameLabel }), _jsx("input", { "data-new-character-name": "", value: name, maxLength: maxLength, placeholder: namePlaceholder, onChange: (event) => onNameChange(event.target.value), onKeyDown: (event) => {
349
+ if (event.key === "Enter") {
350
+ event.preventDefault();
351
+ start();
352
+ }
353
+ }, style: {
354
+ padding: "10px 12px",
355
+ borderRadius: 10,
356
+ border: "1px solid rgba(148,163,184,0.35)",
357
+ background: "rgba(8,12,18,0.72)",
358
+ color: "var(--jg-text, #f2f6fb)",
359
+ fontFamily: hudFont.display,
360
+ fontSize: hudType("xl"),
361
+ fontWeight: 700,
362
+ outline: "none",
363
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
364
+ } })] }), avatars === undefined || avatars.length === 0 ? null : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx("span", { style: { fontSize: hudType("xs"), letterSpacing: "0.16em", textTransform: "uppercase", color: "rgba(203,213,225,0.7)" }, children: "Avatar" }), _jsx("div", { "data-new-character-avatars": "", style: { display: "flex", gap: 8, flexWrap: "wrap" }, children: avatars.map((avatar) => {
365
+ const picked = avatar.id === avatarId;
366
+ return (_jsx("button", { type: "button", "data-new-character-avatar": avatar.id, "aria-pressed": picked, onClick: () => onAvatarChange?.(avatar.id), style: {
367
+ width: 46,
368
+ height: 46,
369
+ borderRadius: 999,
370
+ border: picked ? "1px solid var(--jg-accent, #7dd3fc)" : "1px solid rgba(148,163,184,0.28)",
371
+ background: picked ? "rgba(125,211,252,0.2)" : "rgba(10,14,20,0.7)",
372
+ color: "var(--jg-text, #f2f6fb)",
373
+ display: "flex",
374
+ alignItems: "center",
375
+ justifyContent: "center",
376
+ fontFamily: hudFont.display,
377
+ fontSize: hudType("lg"),
378
+ fontWeight: 800,
379
+ cursor: "pointer",
380
+ }, children: avatar.glyph ?? avatar.label.slice(0, 1).toUpperCase() }, avatar.id));
381
+ }) })] })), palettes === undefined || palettes.length === 0 ? null : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx("span", { style: { fontSize: hudType("xs"), letterSpacing: "0.16em", textTransform: "uppercase", color: "rgba(203,213,225,0.7)" }, children: "Palette" }), _jsx("div", { "data-new-character-palettes": "", style: { display: "flex", gap: 8, flexWrap: "wrap" }, children: palettes.map((palette) => {
382
+ const picked = palette.id === paletteId;
383
+ return (_jsxs("button", { type: "button", "data-new-character-palette": palette.id, "aria-pressed": picked, onClick: () => onPaletteChange?.(palette.id), style: {
384
+ display: "flex",
385
+ alignItems: "center",
386
+ gap: 7,
387
+ padding: "4px 10px 4px 4px",
388
+ borderRadius: 999,
389
+ border: picked ? "1px solid var(--jg-accent, #7dd3fc)" : "1px solid rgba(148,163,184,0.26)",
390
+ background: picked ? "rgba(125,211,252,0.14)" : "rgba(10,14,20,0.62)",
391
+ color: "rgba(226,235,245,0.86)",
392
+ fontSize: hudType("sm"),
393
+ fontWeight: 600,
394
+ cursor: "pointer",
395
+ }, children: [_jsx("span", { style: { display: "flex" }, children: palette.colors.map((color, index) => (_jsx("span", { style: {
396
+ width: 14,
397
+ height: 14,
398
+ borderRadius: 999,
399
+ background: color,
400
+ marginLeft: index === 0 ? 0 : -4,
401
+ boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
402
+ } }, index))) }), palette.label] }, palette.id));
403
+ }) })] })), _jsx("button", { type: "button", "data-new-character-start": "", disabled: trimmed.length === 0, onClick: start, style: {
404
+ alignSelf: "flex-start",
405
+ padding: "11px 22px",
406
+ borderRadius: 10,
407
+ border: "none",
408
+ background: trimmed.length === 0 ? "rgba(148,163,184,0.24)" : "var(--jg-accent, #7dd3fc)",
409
+ color: trimmed.length === 0 ? "rgba(226,235,245,0.5)" : "#04121b",
410
+ fontFamily: hudFont.display,
411
+ fontSize: hudType("lg"),
412
+ fontWeight: 800,
413
+ letterSpacing: "0.05em",
414
+ textTransform: "uppercase",
415
+ cursor: trimmed.length === 0 ? "not-allowed" : "pointer",
416
+ boxShadow: trimmed.length === 0 ? "none" : "0 18px 34px -18px rgba(125,211,252,0.9)",
417
+ }, children: startLabel })] }));
418
+ }
419
+ /**
420
+ * The Credits detail pane: a scroller that belongs to the pane. Wheel, arrows, PageUp/PageDown, Home
421
+ * and End scroll it, the native scrollbar is hidden, and the page behind never moves.
422
+ *
423
+ * @capability credits-pane credits detail pane — sections scrolled inside the pane
424
+ */
425
+ export function CreditsPane({ sections, heading = "Credits", notice, className, style }) {
426
+ return (_jsxs("div", { "data-credits-pane": "", className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("sm"), minHeight: 0, flex: 1, fontFamily: hudFont.body, ...style }, children: [_jsx("div", { style: { fontFamily: hudFont.display, fontSize: hudType("xxl"), fontWeight: 800 }, children: heading }), _jsxs("div", { "data-credits-scroller": "", tabIndex: 0, onKeyDown: (event) => {
427
+ const target = event.currentTarget;
428
+ const step = target.clientHeight * 0.8;
429
+ const moves = {
430
+ ArrowDown: 48,
431
+ ArrowUp: -48,
432
+ PageDown: step,
433
+ PageUp: -step,
434
+ };
435
+ if (event.key === "Home") {
436
+ event.preventDefault();
437
+ target.scrollTop = 0;
438
+ return;
439
+ }
440
+ if (event.key === "End") {
441
+ event.preventDefault();
442
+ target.scrollTop = target.scrollHeight;
443
+ return;
444
+ }
445
+ const delta = moves[event.key];
446
+ if (delta === undefined)
447
+ return;
448
+ event.preventDefault();
449
+ event.stopPropagation();
450
+ target.scrollTop += delta;
451
+ }, style: {
452
+ minHeight: 0,
453
+ overflowY: "auto",
454
+ overscrollBehavior: "contain",
455
+ scrollbarWidth: "none",
456
+ paddingRight: 6,
457
+ outline: "none",
458
+ maskImage: "linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 22px), transparent 100%)",
459
+ WebkitMaskImage: "linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 22px), transparent 100%)",
460
+ }, children: [sections.map((section) => (_jsxs("section", { "data-credits-section": "", style: { marginBottom: hudSpace("lg") }, children: [_jsx("div", { style: {
461
+ fontSize: hudType("xs"),
462
+ letterSpacing: "0.22em",
463
+ textTransform: "uppercase",
464
+ color: "var(--jg-accent, #7dd3fc)",
465
+ fontWeight: 700,
466
+ marginBottom: 6,
467
+ }, children: section.heading }), section.entries.map((entry) => (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 16, padding: "3px 0", fontSize: hudType("md") }, children: [_jsx("span", { style: { fontWeight: 600 }, children: entry.label }), entry.detail === undefined ? null : (_jsx("span", { style: { color: "rgba(203,213,225,0.62)", textAlign: "right" }, children: entry.detail }))] }, `${section.heading}:${entry.label}`)))] }, section.heading))), notice === undefined ? null : (_jsx("p", { style: { margin: 0, fontSize: hudType("xs"), color: "rgba(203,213,225,0.5)", maxWidth: "60ch" }, children: notice }))] })] }));
468
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The short, quiet click a menu makes when the selection moves or is confirmed. Generated with an
3
+ * oscillator instead of shipped as an audio file, so a game gets menu feedback without authoring a
4
+ * sound and the whole thing unmounts with the screen.
5
+ */
6
+ /** Which feedback sound to play. */
7
+ export type GameMenuSoundKind = "move" | "confirm" | "back";
8
+ /** The sound player's readable state: on/off, gain, and whether audio is actually running. */
9
+ export interface GameMenuSoundState {
10
+ /** Whether the player will make sound. */
11
+ enabled: boolean;
12
+ /** Peak gain per tick. */
13
+ volume: number;
14
+ /** Whether an audio context exists and is running - what a test or a settings preview reads. */
15
+ ready: boolean;
16
+ }
17
+ /** A menu sound player. Give it to `GameMenu`'s `sound` prop, or call its methods from your own menu. */
18
+ export interface GameMenuSound {
19
+ /** Play one tick. A no-op when audio is unavailable or the browser blocked it. */
20
+ play(kind: GameMenuSoundKind): void;
21
+ /** Release the audio context. Safe to call twice. */
22
+ dispose(): void;
23
+ /** Read the player state without starting audio. */
24
+ state(): GameMenuSoundState;
25
+ /** Apply a saved player state - a settings toggle muting the menu without rebuilding it. */
26
+ restore(next: GameMenuSoundState): void;
27
+ }
28
+ /** How {@link createGameMenuSound} is tuned. */
29
+ export interface GameMenuSoundConfig {
30
+ /** Turn sound off entirely. Default true. */
31
+ enabled?: boolean;
32
+ /** Peak gain per tick. Default 0.045 — audible, never a jingle. */
33
+ volume?: number;
34
+ }
35
+ /**
36
+ * A tiny WebAudio click generator for menu feedback: one oscillator burst per tick, created on the
37
+ * first play (so a browser autoplay policy does not log an error at mount) and reused after.
38
+ *
39
+ * @capability menu-sound generated click for menu move/confirm/back, no audio asset
40
+ */
41
+ export declare function createGameMenuSound(config?: GameMenuSoundConfig): GameMenuSound;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The short, quiet click a menu makes when the selection moves or is confirmed. Generated with an
3
+ * oscillator instead of shipped as an audio file, so a game gets menu feedback without authoring a
4
+ * sound and the whole thing unmounts with the screen.
5
+ */
6
+ const TONES = {
7
+ move: { frequency: 880, ms: 26, gain: 1 },
8
+ confirm: { frequency: 1320, ms: 52, gain: 1.25 },
9
+ back: { frequency: 587, ms: 40, gain: 0.9 },
10
+ };
11
+ /**
12
+ * A tiny WebAudio click generator for menu feedback: one oscillator burst per tick, created on the
13
+ * first play (so a browser autoplay policy does not log an error at mount) and reused after.
14
+ *
15
+ * @capability menu-sound generated click for menu move/confirm/back, no audio asset
16
+ */
17
+ export function createGameMenuSound(config = {}) {
18
+ let enabled = config.enabled ?? true;
19
+ let volume = config.volume ?? 0.045;
20
+ let context = null;
21
+ let broken = false;
22
+ function ensure() {
23
+ if (!enabled || broken)
24
+ return null;
25
+ if (context !== null)
26
+ return context;
27
+ const Ctor = typeof globalThis.AudioContext === "function"
28
+ ? globalThis.AudioContext
29
+ : globalThis.webkitAudioContext;
30
+ if (Ctor === undefined) {
31
+ broken = true;
32
+ return null;
33
+ }
34
+ try {
35
+ context = new Ctor();
36
+ return context;
37
+ }
38
+ catch {
39
+ broken = true;
40
+ return null;
41
+ }
42
+ }
43
+ return {
44
+ play(kind) {
45
+ const audio = ensure();
46
+ if (audio === null)
47
+ return;
48
+ if (audio.state === "suspended")
49
+ void audio.resume().catch(() => { });
50
+ const tone = TONES[kind];
51
+ const start = audio.currentTime;
52
+ const end = start + tone.ms / 1000;
53
+ const oscillator = audio.createOscillator();
54
+ const gain = audio.createGain();
55
+ oscillator.type = "triangle";
56
+ oscillator.frequency.setValueAtTime(tone.frequency, start);
57
+ oscillator.frequency.exponentialRampToValueAtTime(tone.frequency * 1.18, end);
58
+ gain.gain.setValueAtTime(0.0001, start);
59
+ gain.gain.exponentialRampToValueAtTime(volume * tone.gain, start + 0.006);
60
+ gain.gain.exponentialRampToValueAtTime(0.0001, end);
61
+ oscillator.connect(gain);
62
+ gain.connect(audio.destination);
63
+ oscillator.start(start);
64
+ oscillator.stop(end + 0.01);
65
+ },
66
+ state() {
67
+ return { enabled, volume, ready: context !== null && context.state === "running" };
68
+ },
69
+ restore(next) {
70
+ enabled = next.enabled;
71
+ volume = next.volume;
72
+ if (!enabled)
73
+ this.dispose();
74
+ },
75
+ dispose() {
76
+ const audio = context;
77
+ context = null;
78
+ if (audio !== null)
79
+ void audio.close().catch(() => { });
80
+ },
81
+ };
82
+ }
package/dist/hudTheme.js CHANGED
@@ -105,6 +105,10 @@ export function hudThemeVars(theme) {
105
105
  "--jg-frame-bg": frame.bg,
106
106
  "--jg-frame-border": frame.border,
107
107
  "--jg-frame-radius": frame.radius,
108
+ // The game menu's two design tokens: a logo sized off the display step and card corners that
109
+ // follow the frame radius, so a menu inherits the theme it is rendered under.
110
+ "--jg-menu-logo-size": "clamp(30px, 4.6vw, 66px)",
111
+ "--jg-menu-card-radius": frame.radius,
108
112
  "--jg-frame-glow": frame.glow,
109
113
  "--jg-slot-bg": slot.bg,
110
114
  "--jg-slot-border": slot.border,
package/dist/index.d.ts CHANGED
@@ -82,3 +82,6 @@ export * from "./saveSlots.js";
82
82
  export * from "./cameraShake.js";
83
83
  export * from "./dialogBehavior.js";
84
84
  export * from "./scrollRail.js";
85
+ export * from "./gameMenu.js";
86
+ export * from "./gameMenuSaves.js";
87
+ export * from "./gameMenuSound.js";
package/dist/index.js CHANGED
@@ -82,3 +82,6 @@ export * from "./saveSlots.js";
82
82
  export * from "./cameraShake.js";
83
83
  export * from "./dialogBehavior.js";
84
84
  export * from "./scrollRail.js";
85
+ export * from "./gameMenu.js";
86
+ export * from "./gameMenuSaves.js";
87
+ export * from "./gameMenuSound.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Turning a rendered frame into the thumbnail a save card shows. Saves keep a small JPEG/WebP data
3
+ * URL (a couple of KB), not a full-resolution PNG, so a slot index still fits in local storage.
4
+ */
5
+ /** How {@link shrinkCapture} sizes and encodes a capture. */
6
+ export interface SaveThumbnailOptions {
7
+ /** Output width in pixels. Default 320. */
8
+ width?: number;
9
+ /** Output height in pixels. Default 180 (16:9). */
10
+ height?: number;
11
+ /** Encoding quality, 0–1. Default 0.6. */
12
+ quality?: number;
13
+ /** Output format. Default `"image/jpeg"` — smallest for a photo-like frame. */
14
+ type?: "image/jpeg" | "image/webp";
15
+ }
16
+ /**
17
+ * Downscale a captured frame (a data URL from `captureCanvas`) to a save-card thumbnail. Resolves to
18
+ * the thumbnail data URL, or `null` when the capture could not be decoded (a blank or tainted frame).
19
+ *
20
+ * @capability shrink-capture downscale a captured frame to a save-card thumbnail data URL
21
+ */
22
+ export declare function shrinkCapture(capture: string | null, options?: SaveThumbnailOptions): Promise<string | null>;