@jgengine/react 0.37.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,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;
@@ -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
+ }