@energy8platform/platform-core 0.25.4 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,8 @@ import type {
11
11
  ShellState,
12
12
  ThemeConfig,
13
13
  } from './types';
14
- import { createInitialState } from './state';
14
+ import { KeyboardController, type KeyboardHost } from './keyboard';
15
+ import { createInitialState, nextTurbo, stepBet } from './state';
15
16
  import { buildThemeVars } from './theme';
16
17
  import { SHELL_CSS, SHELL_ROOT_ID } from './shell.css';
17
18
  import { renderBottomBar } from './components/BottomBar';
@@ -23,7 +24,7 @@ import { buildModal } from './components/Modal';
23
24
  import { buildReplayModal } from './components/ReplayModal';
24
25
  import { countUp } from './motion';
25
26
  import { formatCurrency } from './format';
26
- import { socialize } from './i18n';
27
+ import { createI18n, type I18n } from './i18n';
27
28
 
28
29
  const REMOVE_FADE_MS = 300;
29
30
 
@@ -40,11 +41,20 @@ export class GameShell extends EventEmitter<ShellEvents> {
40
41
  private prevBalance = 0;
41
42
  private prevWin = 0;
42
43
  private moneyAnims: Array<() => void> = [];
43
- private keysBound = false;
44
+ private kbd!: KeyboardController;
45
+ private i18n!: I18n;
46
+ /** onKey handler of the currently open modal/overlay, if any (set in showModal, cleared in closeModal). */
47
+ private modalOnKey: ((e: KeyboardEvent) => boolean) | undefined = undefined;
48
+ /** Shared sound on/off state — Settings speaker toggle and the Shift+M hotkey stay in sync. The
49
+ * game listens to `settingChange({ key: 'sound' })` to (un)mute audio. */
50
+ soundOn = true;
51
+ /** Set by the open Settings modal so Shift+M live-updates its speaker icon; cleared on close. */
52
+ private soundRefresh: ((on: boolean) => void) | null = null;
44
53
 
45
54
  constructor(config: ShellConfig) {
46
55
  super();
47
56
  this.config = config;
57
+ this.i18n = createI18n({ language: config.language, isSocial: config.isSocial });
48
58
  this.state = createInitialState(config);
49
59
 
50
60
  this.styleEl = document.createElement('style');
@@ -63,12 +73,47 @@ export class GameShell extends EventEmitter<ShellEvents> {
63
73
  this.prevWin = this.state.win;
64
74
  this.observeLayout();
65
75
  if (typeof document !== 'undefined') {
66
- document.addEventListener('keydown', this.handleKeyDown);
76
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
77
+ const shell = this;
78
+ const host: KeyboardHost = {
79
+ get state() { return shell.state; },
80
+ get hotkeysEnabled() { return shell.config.features.hotkeys !== false; },
81
+ get spacebarEnabled() { return shell.config.features.spacebar !== false; },
82
+ get turboLevels() { return shell.config.features.turbo; },
83
+ get autoplayEnabled() { return shell.config.features.autoplay != null; },
84
+ get buyBonusEnabled() { return shell.config.features.buyBonus !== false; },
85
+ hasOpenLayer: () => shell.modalHost.childElementCount > 0,
86
+ routeToLayer: (e) => shell.modalOnKey?.(e) ?? false,
87
+ spin: () => shell.emit('spin'),
88
+ stepBet: (dir) => {
89
+ const next = stepBet(shell.state, dir);
90
+ if (next === shell.state.bet) return;
91
+ shell.state.bet = next; shell.emit('betChange', next); shell.render();
92
+ },
93
+ toggleAutoplay: () => {
94
+ if (shell.state.autoplay.active) {
95
+ shell.state.autoplay = { active: false, remaining: 0 };
96
+ shell.emit('autoplayStop'); shell.render();
97
+ } else {
98
+ shell.openAutoplayPicker();
99
+ }
100
+ },
101
+ cycleTurbo: () => {
102
+ const next = nextTurbo(shell.state.turbo, shell.config.features.turbo);
103
+ shell.state.turbo = next; shell.emit('turboChange', next); shell.render();
104
+ },
105
+ openBuyBonus: () => shell.openBuyBonus(),
106
+ openInfo: () => shell.openInfo(),
107
+ openMenu: () => shell.openMenu(),
108
+ toggleMute: () => shell.setSound(!shell.soundOn),
109
+ closeLayer: () => shell.closeModal(),
110
+ };
111
+ this.kbd = new KeyboardController(host);
112
+ this.kbd.attach();
67
113
  // Stake serves the game in an iframe; on first paint focus is on the HOST page, so a `document`
68
114
  // keydown never fires and Space scrolls the parent. Pull window focus into the iframe on the
69
115
  // first pointer interaction so the spacebar shortcut works. Harmless on full-page Energy8.
70
116
  document.addEventListener('pointerdown', this.pullFocus, true);
71
- this.keysBound = true;
72
117
  }
73
118
  this.render();
74
119
  // re-fit once the bundled webfont swaps in (text metrics change → row width changes)
@@ -161,41 +206,32 @@ export class GameShell extends EventEmitter<ShellEvents> {
161
206
  }
162
207
  }
163
208
 
164
- /** Spacebar starts a spin — same path as the spin disc. Ignored when `features.spacebar` is
165
- * false, while a spin is running, while autoplay is active, outside base mode, when an
166
- * overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
167
- * ignored so it can't spam. */
168
209
  /** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
169
210
  * spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
170
211
  private pullFocus = (): void => { try { window.focus(); } catch { /* cross-origin / non-browser */ } };
171
212
 
172
- private handleKeyDown = (e: KeyboardEvent): void => {
173
- if (this.destroyed || e.code !== 'Space' || e.repeat) return;
174
- if (this.config.features.spacebar === false) return; // shortcut disabled (e.g. jurisdiction)
175
- const t = e.target as HTMLElement | null;
176
- if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
177
- // Space is ours now — swallow the browser default before any no-op bail. Otherwise the
178
- // native "Space activates the focused button" still fires and re-clicks whichever shell
179
- // <button> (menu/buy/auto) opened the overlay, tearing down + rebuilding the modal: a
180
- // visible flicker. (Also stops the page from scrolling on Space.)
181
- e.preventDefault();
182
- if (this.modalHost.childElementCount > 0) return; // an overlay/modal is open
183
- if (this.state.mode !== 'base' || this.state.busy || this.state.autoplay.active) return;
184
- this.emit('spin');
185
- };
186
-
187
213
  setLayout(layout: 'wide' | 'mobile'): void {
188
214
  if (layout === this.layout) return;
189
215
  this.layout = layout;
190
216
  this.render();
191
217
  }
192
218
 
193
- /** Resolve a built-in shell string. English is the source; with `isSocial` it is run through
194
- * the social-casino word-swap. Game-supplied strings should NOT be passed through this. */
195
- t(text: string): string { return this.config.isSocial ? socialize(text) : text; }
219
+ /** Resolve a built-in shell string through the i18n resolver (translation + optional socialize). */
220
+ t(text: string): string { return this.i18n.t(text); }
196
221
 
197
- /** Toggle the social vocabulary at runtime (re-renders the bar; reopen overlays to refresh them). */
198
- setSocial(isSocial: boolean): void { this.config.isSocial = isSocial; this.render(); }
222
+ /** Toggle the social vocabulary at runtime (rebuilds resolver, re-renders bar). */
223
+ setSocial(isSocial: boolean): void {
224
+ this.config.isSocial = isSocial;
225
+ this.i18n = createI18n({ language: this.config.language, isSocial });
226
+ this.render();
227
+ }
228
+
229
+ /** Swap the active language at runtime (rebuilds resolver, re-renders bar). */
230
+ setLanguage(lang: string): void {
231
+ this.config.language = lang;
232
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
233
+ this.render();
234
+ }
199
235
 
200
236
  /** Recolour the shell at runtime (e.g. switch dark/light scheme). */
201
237
  setTheme(theme: ThemeConfig): void {
@@ -236,7 +272,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
236
272
  this.state.mode = mode;
237
273
  this.render();
238
274
  }
239
- setBusy(busy: boolean): void { this.state.busy = busy; this.render(); }
275
+ setBusy(busy: boolean): void { this.state.busy = busy; this.render(); this.kbd?.notifyBusyChanged(busy); }
240
276
  setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
241
277
  setTurbo(level: number): void { this.state.turbo = level; this.render(); }
242
278
  /** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
@@ -245,7 +281,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
245
281
  setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
246
282
  setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
247
283
 
248
- private showModal(el: HTMLElement): void {
284
+ private showModal(el: HTMLElement, onKey?: (e: KeyboardEvent) => boolean): void {
249
285
  // The control that opened this overlay (menu/buy/auto) keeps DOM focus. Drop it, or a
250
286
  // stray Space/Enter would natively re-activate that <button> and rebuild the modal — a
251
287
  // visible flicker. Only relinquish focus we own (a shell control), never the host page's.
@@ -253,6 +289,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
253
289
  if (active && this.root.contains(active)) active.blur();
254
290
  this.modalHost.innerHTML = '';
255
291
  this.modalHost.appendChild(el);
292
+ this.modalOnKey = onKey;
256
293
  this.fitModals();
257
294
  }
258
295
 
@@ -307,18 +344,28 @@ export class GameShell extends EventEmitter<ShellEvents> {
307
344
 
308
345
  openMenu(): void { this.emit('menuOpen'); this.openSettings(); }
309
346
  openSettings(): void { this.emit('settingsOpen'); this.showModal(openSettingsModal(this)); }
310
- openInfo(): void { this.emit('infoOpen'); this.showModal(openGameInfoModal(this)); }
347
+ openInfo(): void { this.emit('infoOpen'); const { root, onKey } = openGameInfoModal(this); this.showModal(root, onKey); }
311
348
  openBuyBonus(): void {
312
349
  if (this.config.onBonusBuy) { this.config.onBonusBuy(); return; } // game handles it (own UI)
313
- const overlay = openBuyBonusOverlay(this);
314
- if (overlay) this.showModal(overlay);
350
+ const result = openBuyBonusOverlay(this);
351
+ if (result) this.showModal(result.root, result.onKey);
315
352
  }
316
353
  /** Open a generic, externally-driven modal (title + body + optional action buttons).
317
354
  * Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
318
- openModal(opts: ModalOptions): void { this.showModal(buildModal(opts)); }
355
+ openModal(opts: ModalOptions): void { this.showModal(buildModal(opts), opts.onKey); }
319
356
  /** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
320
357
  * reconnect overlay once the link is restored). No-op when nothing is open. */
321
- closeModal(): void { this.modalHost.innerHTML = ''; }
358
+ closeModal(): void { this.modalOnKey = undefined; this.soundRefresh = null; this.modalHost.innerHTML = ''; }
359
+
360
+ /** Flip the shared sound state, notify the game (`settingChange({ key: 'sound' })`), and live-update
361
+ * the Settings speaker icon if that modal is open. Used by both the Settings toggle and Shift+M. */
362
+ setSound(on: boolean): void {
363
+ this.soundOn = on;
364
+ this.emit('settingChange', { key: 'sound', value: on });
365
+ this.soundRefresh?.(on);
366
+ }
367
+ /** The Settings modal registers an icon-updater while open (cleared on close). */
368
+ setSoundRefresh(fn: ((on: boolean) => void) | null): void { this.soundRefresh = fn; }
322
369
  /** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
323
370
  openReplay(opts: ReplayModalOptions): void {
324
371
  if (this.destroyed) return;
@@ -326,19 +373,18 @@ export class GameShell extends EventEmitter<ShellEvents> {
326
373
  }
327
374
 
328
375
  /** Bet picker — list of available bets with an accent Confirm. */
329
- openBetPicker(): void { this.showModal(openBetModal(this)); }
376
+ openBetPicker(): void { const { root, onKey } = openBetModal(this); this.showModal(root, onKey); }
330
377
  /** Autoplay picker — spin-count list; Confirm starts autoplay. */
331
- openAutoplayPicker(): void { this.showModal(openAutoplayModal(this)); }
378
+ openAutoplayPicker(): void { const { root, onKey } = openAutoplayModal(this); this.showModal(root, onKey); }
332
379
 
333
380
  destroy(): Promise<void> {
334
381
  if (this.destroyed) return Promise.resolve();
335
382
  this.destroyed = true;
336
383
  this.ro?.disconnect();
337
384
  this.ro = null;
338
- if (this.keysBound) {
339
- document.removeEventListener('keydown', this.handleKeyDown);
385
+ if (typeof document !== 'undefined') {
386
+ this.kbd?.detach();
340
387
  document.removeEventListener('pointerdown', this.pullFocus, true);
341
- this.keysBound = false;
342
388
  }
343
389
  this.cancelMoneyAnims();
344
390
  this.removeAllListeners();
@@ -2,27 +2,175 @@ import type { GameShell } from '../GameShell';
2
2
  import type { BonusOption } from '../types';
3
3
  import { formatCurrency } from '../format';
4
4
  import { stepBet } from '../state';
5
+ import { betDir } from '../keyboard';
5
6
  import { effectiveAccent, contrastText } from '../colors';
6
7
  import { createOverlay, createCardModal } from './primitives';
7
8
  import { icon, type IconName } from './icons';
8
9
 
9
- /** Buy-bonus overlay a grid of art-forward cards, one per option. */
10
- export function openBuyBonusOverlay(shell: GameShell): HTMLElement | null {
10
+ /** Mutable state shared between the overlay DOM and the onKey handler. */
11
+ interface OverlayState {
12
+ /** Index into the affordable-card subset; -1 = none (no affordable cards). */
13
+ focusIndex: number;
14
+ /** The bonus whose confirm dialog is currently open, or undefined. */
15
+ confirmBonus: BonusOption | undefined;
16
+ }
17
+
18
+ /** Buy-bonus overlay — a grid of art-forward cards, one per option.
19
+ * Returns the overlay element + a keyboard handler for the shell's `showModal`. */
20
+ export function openBuyBonusOverlay(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } | null {
11
21
  const bonuses = shell.config.features.buyBonus;
12
22
  if (bonuses === false || bonuses.length === 0) return null;
13
23
 
14
- const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => root.remove() });
24
+ const st: OverlayState = { focusIndex: -1, confirmBonus: undefined };
25
+
26
+ const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => shell.closeModal() });
15
27
  root.dataset.ge = 'buybonus-overlay';
28
+
16
29
  // Re-render the grid whenever the bet changes so every card's price stays live.
17
- const renderGrid = () => {
30
+ const renderGrid = (): void => {
18
31
  body.innerHTML = '';
19
32
  const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
20
- for (const bonus of bonuses) grid.appendChild(buildCard(shell, bonus, root));
33
+ // Card count drives the width-fit clamp in CSS (each card is 18em; N cards must fit the frame
34
+ // width), so the row scales to the available width instead of overflowing into an X-scroll.
35
+ grid.style.setProperty('--ge-bb-n', String(bonuses.length));
36
+ const affordable: BonusOption[] = [];
37
+ for (const bonus of bonuses) {
38
+ const card = buildCard(shell, bonus, root, st);
39
+ grid.appendChild(card);
40
+ if (isAffordable(shell, bonus)) affordable.push(bonus);
41
+ }
21
42
  body.appendChild(grid);
43
+ // Initialize or restore focus index
44
+ if (affordable.length > 0) {
45
+ if (st.focusIndex < 0) st.focusIndex = 0;
46
+ else st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
47
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
48
+ } else {
49
+ st.focusIndex = -1;
50
+ }
22
51
  };
52
+
23
53
  renderGrid();
24
54
  root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
25
- return root;
55
+
56
+ /** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
57
+ * Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
58
+ const stepBetBy = (dir: 1 | -1): void => {
59
+ const next = stepBet(shell.state, dir);
60
+ if (next === shell.state.bet) return;
61
+ shell.state.bet = next; shell.emit('betChange', next); shell.render();
62
+ renderGrid();
63
+ };
64
+
65
+ /** Keyboard handler for both browse and confirm phases. */
66
+ const onKey = (e: KeyboardEvent): boolean => {
67
+ const affordable = bonuses.filter((b) => isAffordable(shell, b));
68
+
69
+ // ── Confirm phase ──
70
+ if (st.confirmBonus) {
71
+ switch (e.code) {
72
+ case 'Enter':
73
+ case 'Space': {
74
+ const bonus = st.confirmBonus;
75
+ if (!isAffordable(shell, bonus)) return true;
76
+ if (bonus.type === 'feature') shell.activateFeature(bonus);
77
+ else shell.emit('buyBonusSelect', { id: bonus.id });
78
+ shell.closeModal();
79
+ return true;
80
+ }
81
+ case 'Escape':
82
+ // Remove the confirm dialog, return to browse
83
+ closeConfirm(root, st);
84
+ return true;
85
+ default:
86
+ return false;
87
+ }
88
+ }
89
+
90
+ // ── Browse phase ──
91
+ const last = affordable.length - 1;
92
+ const mobile = shell.layout === 'mobile';
93
+
94
+ // Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
95
+ // navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
96
+ const bet = betDir(e);
97
+ if (bet !== null) { stepBetBy(bet); return true; }
98
+
99
+ // Determine navigation direction from key code + layout (mobile uses vertical arrows)
100
+ const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
101
+ const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
102
+
103
+ if (fwdKey) {
104
+ if (last < 0) return true;
105
+ if (st.focusIndex < last) {
106
+ st.focusIndex++;
107
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
108
+ }
109
+ return true;
110
+ }
111
+ if (bwdKey) {
112
+ if (last < 0) return true;
113
+ if (st.focusIndex > 0) {
114
+ st.focusIndex--;
115
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
116
+ }
117
+ return true;
118
+ }
119
+
120
+ switch (e.code) {
121
+ case 'Enter':
122
+ case 'Space':
123
+ if (last < 0 || st.focusIndex < 0) return true;
124
+ {
125
+ const bonus = affordable[st.focusIndex];
126
+ openConfirm(shell, bonus, root, st);
127
+ }
128
+ return true;
129
+ // Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
130
+ case 'Equal':
131
+ stepBetBy(1);
132
+ return true;
133
+ case 'Minus':
134
+ stepBetBy(-1);
135
+ return true;
136
+ case 'Escape':
137
+ shell.closeModal();
138
+ return true;
139
+ default:
140
+ return false;
141
+ }
142
+ };
143
+
144
+ return { root, onKey };
145
+ }
146
+
147
+ /** Apply a CSS keyboard-focus class to the currently focused affordable card. */
148
+ function applyFocusClass(overlay: HTMLElement, bonuses: BonusOption[], affordable: BonusOption[], focusIndex: number): void {
149
+ for (const b of bonuses) {
150
+ const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`) as HTMLElement | null;
151
+ if (!card) continue;
152
+ card.classList.remove('ge-bonus-card--kbd-focus');
153
+ }
154
+ const focused = affordable[focusIndex];
155
+ if (!focused) return;
156
+ const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`) as HTMLElement | null;
157
+ if (card) card.classList.add('ge-bonus-card--kbd-focus');
158
+ }
159
+
160
+ /** Open the confirm dialog for the given bonus and track it in overlay state. */
161
+ function openConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): void {
162
+ closeConfirm(overlay, st); // remove any existing confirm
163
+ st.confirmBonus = bonus;
164
+ overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
165
+ shell.fitModals();
166
+ }
167
+
168
+ /** Remove the confirm dialog and clear the overlay state. */
169
+ function closeConfirm(overlay: HTMLElement, st: OverlayState): void {
170
+ // The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
171
+ const sheet = overlay.querySelector('[data-ge="bonus-confirm"]') as HTMLElement | null;
172
+ if (sheet) sheet.remove();
173
+ st.confirmBonus = undefined;
26
174
  }
27
175
 
28
176
  /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
@@ -63,7 +211,7 @@ function stepButton(ge: string, name: IconName): HTMLButtonElement {
63
211
 
64
212
  /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
65
213
  * Clicking (when affordable) opens the confirmation modal. */
66
- function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
214
+ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
67
215
  const accent = effectiveAccent(bonus);
68
216
  const card = document.createElement('div');
69
217
  card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
@@ -75,8 +223,7 @@ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement):
75
223
  // affordability at click time, so it's a safe no-op when the option can't be bought.
76
224
  const select = (): void => {
77
225
  if (!isAffordable(shell, bonus)) return;
78
- overlay.appendChild(buildConfirm(shell, bonus, overlay));
79
- shell.fitModals();
226
+ openConfirm(shell, bonus, overlay, st);
80
227
  };
81
228
 
82
229
  // Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
@@ -123,9 +270,9 @@ function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
123
270
 
124
271
  /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
125
272
  * preview body and a full-bleed Cancel + action footer. */
126
- function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
273
+ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
127
274
  const accent = effectiveAccent(bonus);
128
- const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => ui.root.remove() });
275
+ const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
129
276
 
130
277
  const price = bonus.priceMultiplier * shell.state.bet;
131
278
  const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
@@ -140,7 +287,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
140
287
  const cancel = document.createElement('button');
141
288
  cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
142
289
  cancel.textContent = shell.t('Cancel');
143
- cancel.addEventListener('click', () => ui.root.remove());
290
+ cancel.addEventListener('click', () => closeConfirm(overlay, st));
144
291
  const buy = document.createElement('button');
145
292
  buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
146
293
  buy.textContent = shell.t(actionLabel(bonus));
@@ -151,8 +298,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
151
298
  if (!isAffordable(shell, bonus)) return;
152
299
  if (bonus.type === 'feature') shell.activateFeature(bonus);
153
300
  else shell.emit('buyBonusSelect', { id: bonus.id });
154
- ui.root.remove();
155
- overlay.remove();
301
+ shell.closeModal();
156
302
  });
157
303
  actions.append(cancel, buy);
158
304
  ui.card.appendChild(actions);
@@ -4,17 +4,32 @@ import { createOverlay, twoLine } from './primitives';
4
4
  import { icon } from './icons';
5
5
  import { PACKAGE_VERSION } from '../version';
6
6
 
7
+ /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
8
+ const HOTKEYS_DEFAULT_ORDER = -0.5;
9
+
7
10
  const SVG_NS = 'http://www.w3.org/2000/svg';
8
11
 
9
- export function openGameInfoModal(shell: GameShell): HTMLElement {
10
- const { root, body } = createOverlay({
12
+ /** Result of openGameInfoModal the overlay root element plus a keyboard handler. */
13
+ export interface GameInfoModal {
14
+ root: HTMLElement;
15
+ onKey: (e: KeyboardEvent) => boolean;
16
+ }
17
+
18
+ export function openGameInfoModal(shell: GameShell): GameInfoModal {
19
+ const { root, body, scroll } = createOverlay({
11
20
  title: shell.t('Game info'),
12
21
  onClose: () => root.remove(),
13
22
  onBack: () => { root.remove(); shell.openSettings(); },
14
23
  });
15
24
  root.dataset.ge = 'info-modal';
16
25
 
17
- const sections = shell.config.gameInfo.sections ?? [];
26
+ const rawSections = shell.config.gameInfo.sections ?? [];
27
+ // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
28
+ const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
29
+ if (shell.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
30
+ sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
31
+ }
32
+ const sections = sectionsWithHotkeys;
18
33
  // Default placement: modes first, controls second, the rest in declaration order.
19
34
  // An explicit `order` overrides; ties keep declaration order (stable).
20
35
  const base = (s: GameInfoSection, i: number): number =>
@@ -25,7 +40,26 @@ export function openGameInfoModal(shell: GameShell): HTMLElement {
25
40
  .forEach(({ s }) => body.appendChild(renderSection(shell, s)));
26
41
 
27
42
  body.appendChild(versionFooter(shell));
28
- return root;
43
+
44
+ const LINE = 60;
45
+ const PAGE = (): number => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
46
+ const onKey = (e: KeyboardEvent): boolean => {
47
+ switch (e.code) {
48
+ case 'ArrowDown': scroll.scrollTop += LINE; return true;
49
+ case 'ArrowUp': scroll.scrollTop = Math.max(0, scroll.scrollTop - LINE); return true;
50
+ case 'PageDown': scroll.scrollTop += PAGE(); return true;
51
+ case 'PageUp': scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE()); return true;
52
+ case 'Space':
53
+ if (e.shiftKey) { scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE()); }
54
+ else { scroll.scrollTop += PAGE(); }
55
+ return true;
56
+ case 'Home': scroll.scrollTop = 0; return true;
57
+ case 'End': scroll.scrollTop = scroll.scrollHeight - scroll.clientHeight; return true;
58
+ default: return false;
59
+ }
60
+ };
61
+
62
+ return { root, onKey };
29
63
  }
30
64
 
31
65
  /** A muted version stamp pinned to the bottom of the game-info modal:
@@ -43,9 +77,11 @@ function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
43
77
  switch (s.type) {
44
78
  case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
45
79
  case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
80
+ case 'hotkeys': return sectionHotkeys(shell, sec('info-hotkeys', s.title, shell.t('Hotkeys')));
46
81
  case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
47
82
  case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
48
- case 'custom': return sectionCustom(s, sec('info-custom', s.title, ''));
83
+ // Translate the heading (e.g. the host-built DISCLAIMER title); the body stays verbatim.
84
+ case 'custom': return sectionCustom(s, sec('info-custom', s.title != null ? shell.t(s.title) : undefined, ''));
49
85
  }
50
86
  }
51
87
 
@@ -123,6 +159,69 @@ function ctlBlock(shell: GameShell, label: string, rows: CtlRow[]): HTMLElement
123
159
  return block;
124
160
  }
125
161
 
162
+ // ── hotkeys (keycap chips → localized action name) ────────────────────────────
163
+ type HkRow = { chips: string[]; name: string; on: boolean };
164
+
165
+ function sectionHotkeys(shell: GameShell, el: HTMLElement): HTMLElement {
166
+ const { features } = shell.config;
167
+
168
+ /** Render one or more key names as keycap chips joined by " / ". */
169
+ const chips = (...keys: string[]): string =>
170
+ keys.map((k) => `<span class="ge-gi-hk-chip">${k}</span>`).join('<span class="ge-gi-hk-sep"> / </span>');
171
+
172
+ const rows: HkRow[] = [
173
+ { chips: ['Space'], name: 'Spin', on: true },
174
+ { chips: ['Shift', '↑', 'Shift', '='], name: 'Raise bet', on: true },
175
+ { chips: ['Shift', '↓', 'Shift', '-'], name: 'Lower bet', on: true },
176
+ { chips: ['Shift', 'A'], name: 'Autoplay', on: features.autoplay != null },
177
+ { chips: ['Shift', 'T'], name: 'Turbo', on: features.turbo > 0 },
178
+ { chips: ['Shift', 'B'], name: 'Buy bonus', on: features.buyBonus !== false },
179
+ { chips: ['Shift', 'I'], name: 'Game info', on: true },
180
+ { chips: ['Shift', 'S'], name: 'Menu', on: true },
181
+ { chips: ['Shift', 'M'], name: 'Mute', on: true },
182
+ { chips: ['←', '→'], name: 'Navigate', on: true },
183
+ { chips: ['Enter'], name: 'Confirm', on: true },
184
+ { chips: ['Esc'], name: 'Close', on: true },
185
+ ];
186
+
187
+ const block = document.createElement('div');
188
+ block.className = 'ge-gi-hk-block';
189
+
190
+ for (const r of rows.filter((x) => x.on)) {
191
+ const row = document.createElement('div');
192
+ row.className = 'ge-gi-hk';
193
+
194
+ // Build the chips column
195
+ const chipsEl = document.createElement('div');
196
+ chipsEl.className = 'ge-gi-hk-chips';
197
+
198
+ if (r.name === 'Raise bet' || r.name === 'Lower bet') {
199
+ // Two combos separated by " / ": Shift+↑ / Shift+= and Shift+↓ / Shift+-
200
+ const [k1, k2, k3, k4] = r.chips;
201
+ chipsEl.innerHTML =
202
+ `<span class="ge-gi-hk-combo">${chips(k1, k2)}</span>` +
203
+ `<span class="ge-gi-hk-sep2"> / </span>` +
204
+ `<span class="ge-gi-hk-combo">${chips(k3, k4)}</span>`;
205
+ } else if (r.chips.length > 1) {
206
+ // Chord: Shift + X
207
+ chipsEl.innerHTML = `<span class="ge-gi-hk-combo">${chips(...r.chips)}</span>`;
208
+ } else {
209
+ chipsEl.innerHTML = chips(...r.chips);
210
+ }
211
+
212
+ const tx = document.createElement('div');
213
+ tx.className = 'ge-gi-hk-tx';
214
+ tx.textContent = shell.t(r.name);
215
+
216
+ row.appendChild(chipsEl);
217
+ row.appendChild(tx);
218
+ block.appendChild(row);
219
+ }
220
+
221
+ el.appendChild(block);
222
+ return el;
223
+ }
224
+
126
225
  // ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
127
226
  function sectionPaytable(rows: PaytableRow[], el: HTMLElement): HTMLElement {
128
227
  const grid = document.createElement('div'); grid.className = 'ge-gi-pt-grid';
@@ -6,22 +6,21 @@ export function openSettingsModal(shell: GameShell): HTMLElement {
6
6
  const { root, body } = createOverlay({ title: shell.t('Settings'), onClose: () => root.remove() });
7
7
  root.dataset.ge = 'settings-modal';
8
8
 
9
- // Sound on/off (starts on) full-width row with a speaker icon button
9
+ // Sound on/off backed by the shell's shared `soundOn` state so this toggle and the Shift+M
10
+ // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
10
11
  const sound = (() => {
11
- let on = true;
12
12
  const btn = document.createElement('button');
13
- btn.className = 'ge-snd ge-active'; btn.dataset.ge = 'setting-sound';
14
- btn.setAttribute('aria-label', 'Sound');
15
- const paint = () => {
13
+ btn.className = 'ge-snd'; btn.dataset.ge = 'setting-sound';
14
+ btn.setAttribute('aria-label', shell.t('Sound'));
15
+ const paint = (on: boolean) => {
16
16
  btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
17
17
  btn.classList.toggle('ge-active', on);
18
18
  btn.setAttribute('aria-pressed', String(on));
19
19
  };
20
- paint();
21
- btn.addEventListener('click', () => {
22
- on = !on; paint();
23
- shell.emit('settingChange', { key: 'sound', value: on });
24
- });
20
+ paint(shell.soundOn);
21
+ btn.addEventListener('click', () => shell.setSound(!shell.soundOn));
22
+ // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
23
+ shell.setSoundRefresh(paint);
25
24
  const row = document.createElement('div'); row.className = 'ge-ov-row';
26
25
  row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`; row.appendChild(btn);
27
26
  return row;