@energy8platform/platform-core 0.25.3 → 0.26.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.
@@ -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)
@@ -113,10 +158,11 @@ export class GameShell extends EventEmitter<ShellEvents> {
113
158
  host.classList.remove('ge-fit');
114
159
  host.style.transform = '';
115
160
  host.style.transformOrigin = '';
116
- // clear any per-zone height-scale from a prior pass
161
+ // clear any per-zone scale/zoom from a prior pass
117
162
  for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
118
163
  (el as HTMLElement).style.transform = '';
119
164
  (el as HTMLElement).style.transformOrigin = '';
165
+ (el as HTMLElement).style.removeProperty('zoom');
120
166
  }
121
167
  if (this.layout === 'mobile') {
122
168
  // Shrink the whole stack to fit narrow phones (mobile-s, or big balance/win/total-win
@@ -133,74 +179,59 @@ export class GameShell extends EventEmitter<ShellEvents> {
133
179
  }
134
180
  return;
135
181
  }
136
- const availW = this.root.clientWidth - 12;
137
- const availH = this.root.clientHeight * GameShell.BAR_MAX_FRACTION;
138
- // 1) If the inline row overflows the width, lift the WIN pill onto its own line above the bar
139
- // (keeps the controls as large as possible — base's wide row + a big WIN pill hit this).
182
+ // ONE fit-scale, from the SCREEN SIZE, applied identically in EVERY mode — switching base⇄replay
183
+ // must not resize the bar. The factor is the frame WIDTH vs the bar's design width, never the
184
+ // current mode's content width.
185
+ //
186
+ // It's applied with `zoom` (not `transform`): zoom shrinks the LAYOUT, so the zones genuinely
187
+ // take less room and still sit edge-to-edge (menu hard-left, controls hard-right) even when base's
188
+ // wide row would overflow a merely-visually-scaled bar — so there is no per-mode centred cluster
189
+ // and no width/mode branching. A wide WIN pill is still lifted above the row first so it can't
190
+ // shove the controls off-screen. (Mobile, above, keeps its own stacked fit.)
140
191
  if (pill && bar.scrollWidth > bar.clientWidth + 1) { host.insertBefore(pill, bar); pill.classList.add('ge-up'); }
141
- // 2) Still too WIDE (the control row itself doesn't fit) shrink-to-content + uniform scale,
142
- // centred. Only base's wide row reaches here.
143
- if (bar.scrollWidth > bar.clientWidth + 1) {
144
- host.classList.add('ge-fit');
145
- const naturalW = host.offsetWidth, naturalH = host.offsetHeight;
146
- const s = Math.min(1, naturalW > 0 ? availW / naturalW : 1, naturalH > 0 ? availH / naturalH : 1);
147
- if (s < 0.999) host.style.transform = `translateX(-50%) scale(${s.toFixed(4)})`;
148
- else host.classList.remove('ge-fit');
149
- return;
150
- }
151
- // 3) Fits the WIDTH but the stack (control row + any lifted WIN pill) is too TALL for a short
152
- // frame replay/free-spins on Popout S. Shrink each piece toward its OWN edge so the bar
153
- // keeps its full-width space-between layout (menu hard-left, controls hard-right), just
154
- // lower NOT packed into a centred cluster. Scale by frame HEIGHT only.
155
- const naturalH = host.offsetHeight;
156
- if (naturalH > availH && naturalH > 0) {
157
- const s = (availH / naturalH).toFixed(4);
158
- const scaleEdge = (el: Element | null, origin: string): void => {
159
- if (!el) return;
160
- (el as HTMLElement).style.transformOrigin = origin;
161
- (el as HTMLElement).style.transform = `scale(${s})`;
162
- };
163
- scaleEdge(bar.querySelector('.ge-zone-left'), 'left bottom');
164
- scaleEdge(bar.querySelector('.ge-zone-right'), 'right bottom');
165
- scaleEdge(host.querySelector('.ge-winpill'), 'center bottom');
192
+ const zoomBar = (z: number): void => {
193
+ const v = z < 0.999 ? z.toFixed(4) : '';
194
+ for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
195
+ if (v) (el as HTMLElement).style.setProperty('zoom', v);
196
+ else (el as HTMLElement).style.removeProperty('zoom');
197
+ }
198
+ };
199
+ const s = Math.max(GameShell.BAR_MIN_SCALE, Math.min(1, this.root.clientWidth / GameShell.BAR_REF_WIDTH));
200
+ zoomBar(s);
201
+ // Safety: a pathologically long balance/win can still overflow the frame at the screen zoom —
202
+ // nudge the zoom down just enough that the far control (turbo) isn't clipped. Normal content
203
+ // never triggers this, so base and replay keep the SAME zoom (no size change on mode switch).
204
+ if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
205
+ zoomBar(s * (bar.clientWidth / bar.scrollWidth));
166
206
  }
167
207
  }
168
208
 
169
- /** Spacebar starts a spin — same path as the spin disc. Ignored when `features.spacebar` is
170
- * false, while a spin is running, while autoplay is active, outside base mode, when an
171
- * overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
172
- * ignored so it can't spam. */
173
209
  /** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
174
210
  * spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
175
211
  private pullFocus = (): void => { try { window.focus(); } catch { /* cross-origin / non-browser */ } };
176
212
 
177
- private handleKeyDown = (e: KeyboardEvent): void => {
178
- if (this.destroyed || e.code !== 'Space' || e.repeat) return;
179
- if (this.config.features.spacebar === false) return; // shortcut disabled (e.g. jurisdiction)
180
- const t = e.target as HTMLElement | null;
181
- if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
182
- // Space is ours now — swallow the browser default before any no-op bail. Otherwise the
183
- // native "Space activates the focused button" still fires and re-clicks whichever shell
184
- // <button> (menu/buy/auto) opened the overlay, tearing down + rebuilding the modal: a
185
- // visible flicker. (Also stops the page from scrolling on Space.)
186
- e.preventDefault();
187
- if (this.modalHost.childElementCount > 0) return; // an overlay/modal is open
188
- if (this.state.mode !== 'base' || this.state.busy || this.state.autoplay.active) return;
189
- this.emit('spin');
190
- };
191
-
192
213
  setLayout(layout: 'wide' | 'mobile'): void {
193
214
  if (layout === this.layout) return;
194
215
  this.layout = layout;
195
216
  this.render();
196
217
  }
197
218
 
198
- /** Resolve a built-in shell string. English is the source; with `isSocial` it is run through
199
- * the social-casino word-swap. Game-supplied strings should NOT be passed through this. */
200
- 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); }
221
+
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
+ }
201
228
 
202
- /** Toggle the social vocabulary at runtime (re-renders the bar; reopen overlays to refresh them). */
203
- setSocial(isSocial: boolean): void { this.config.isSocial = isSocial; this.render(); }
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
+ }
204
235
 
205
236
  /** Recolour the shell at runtime (e.g. switch dark/light scheme). */
206
237
  setTheme(theme: ThemeConfig): void {
@@ -241,7 +272,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
241
272
  this.state.mode = mode;
242
273
  this.render();
243
274
  }
244
- 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); }
245
276
  setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
246
277
  setTurbo(level: number): void { this.state.turbo = level; this.render(); }
247
278
  /** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
@@ -250,7 +281,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
250
281
  setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
251
282
  setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
252
283
 
253
- private showModal(el: HTMLElement): void {
284
+ private showModal(el: HTMLElement, onKey?: (e: KeyboardEvent) => boolean): void {
254
285
  // The control that opened this overlay (menu/buy/auto) keeps DOM focus. Drop it, or a
255
286
  // stray Space/Enter would natively re-activate that <button> and rebuild the modal — a
256
287
  // visible flicker. Only relinquish focus we own (a shell control), never the host page's.
@@ -258,6 +289,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
258
289
  if (active && this.root.contains(active)) active.blur();
259
290
  this.modalHost.innerHTML = '';
260
291
  this.modalHost.appendChild(el);
292
+ this.modalOnKey = onKey;
261
293
  this.fitModals();
262
294
  }
263
295
 
@@ -274,9 +306,12 @@ export class GameShell extends EventEmitter<ShellEvents> {
274
306
  * modals from filling a small popout edge-to-edge (so even short pickers scale down there). */
275
307
  private static readonly MODAL_FIT = 0.86;
276
308
 
277
- /** Max fraction of the frame HEIGHT the bottom bar may occupy before it fit-scales down. Keeps the
278
- * bar a consistent, small slice on short popouts in EVERY mode (base this already via width). */
279
- private static readonly BAR_MAX_FRACTION = 0.27;
309
+ /** The bar's design width (px). When the frame is narrower, the bar fit-scales DOWN with the
310
+ * screen the SAME factor in every mode, so replay/free-spins shrink like base instead of
311
+ * staying full-size on a popout. */
312
+ private static readonly BAR_REF_WIDTH = 840;
313
+ /** Lower bound on the bar fit-scale (guards a degenerate near-zero frame). */
314
+ private static readonly BAR_MIN_SCALE = 0.5;
280
315
 
281
316
  private fitSheet(root: HTMLElement): void {
282
317
  const card = root.querySelector('.ge-modal-card') as HTMLElement | null;
@@ -309,18 +344,28 @@ export class GameShell extends EventEmitter<ShellEvents> {
309
344
 
310
345
  openMenu(): void { this.emit('menuOpen'); this.openSettings(); }
311
346
  openSettings(): void { this.emit('settingsOpen'); this.showModal(openSettingsModal(this)); }
312
- openInfo(): void { this.emit('infoOpen'); this.showModal(openGameInfoModal(this)); }
347
+ openInfo(): void { this.emit('infoOpen'); const { root, onKey } = openGameInfoModal(this); this.showModal(root, onKey); }
313
348
  openBuyBonus(): void {
314
349
  if (this.config.onBonusBuy) { this.config.onBonusBuy(); return; } // game handles it (own UI)
315
- const overlay = openBuyBonusOverlay(this);
316
- if (overlay) this.showModal(overlay);
350
+ const result = openBuyBonusOverlay(this);
351
+ if (result) this.showModal(result.root, result.onKey);
317
352
  }
318
353
  /** Open a generic, externally-driven modal (title + body + optional action buttons).
319
354
  * Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
320
- openModal(opts: ModalOptions): void { this.showModal(buildModal(opts)); }
355
+ openModal(opts: ModalOptions): void { this.showModal(buildModal(opts), opts.onKey); }
321
356
  /** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
322
357
  * reconnect overlay once the link is restored). No-op when nothing is open. */
323
- 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; }
324
369
  /** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
325
370
  openReplay(opts: ReplayModalOptions): void {
326
371
  if (this.destroyed) return;
@@ -328,19 +373,18 @@ export class GameShell extends EventEmitter<ShellEvents> {
328
373
  }
329
374
 
330
375
  /** Bet picker — list of available bets with an accent Confirm. */
331
- openBetPicker(): void { this.showModal(openBetModal(this)); }
376
+ openBetPicker(): void { const { root, onKey } = openBetModal(this); this.showModal(root, onKey); }
332
377
  /** Autoplay picker — spin-count list; Confirm starts autoplay. */
333
- openAutoplayPicker(): void { this.showModal(openAutoplayModal(this)); }
378
+ openAutoplayPicker(): void { const { root, onKey } = openAutoplayModal(this); this.showModal(root, onKey); }
334
379
 
335
380
  destroy(): Promise<void> {
336
381
  if (this.destroyed) return Promise.resolve();
337
382
  this.destroyed = true;
338
383
  this.ro?.disconnect();
339
384
  this.ro = null;
340
- if (this.keysBound) {
341
- document.removeEventListener('keydown', this.handleKeyDown);
385
+ if (typeof document !== 'undefined') {
386
+ this.kbd?.detach();
342
387
  document.removeEventListener('pointerdown', this.pullFocus, true);
343
- this.keysBound = false;
344
388
  }
345
389
  this.cancelMoneyAnims();
346
390
  this.removeAllListeners();
@@ -2,27 +2,172 @@ 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
+ const affordable: BonusOption[] = [];
34
+ for (const bonus of bonuses) {
35
+ const card = buildCard(shell, bonus, root, st);
36
+ grid.appendChild(card);
37
+ if (isAffordable(shell, bonus)) affordable.push(bonus);
38
+ }
21
39
  body.appendChild(grid);
40
+ // Initialize or restore focus index
41
+ if (affordable.length > 0) {
42
+ if (st.focusIndex < 0) st.focusIndex = 0;
43
+ else st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
44
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
45
+ } else {
46
+ st.focusIndex = -1;
47
+ }
22
48
  };
49
+
23
50
  renderGrid();
24
51
  root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
25
- return root;
52
+
53
+ /** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
54
+ * Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
55
+ const stepBetBy = (dir: 1 | -1): void => {
56
+ const next = stepBet(shell.state, dir);
57
+ if (next === shell.state.bet) return;
58
+ shell.state.bet = next; shell.emit('betChange', next); shell.render();
59
+ renderGrid();
60
+ };
61
+
62
+ /** Keyboard handler for both browse and confirm phases. */
63
+ const onKey = (e: KeyboardEvent): boolean => {
64
+ const affordable = bonuses.filter((b) => isAffordable(shell, b));
65
+
66
+ // ── Confirm phase ──
67
+ if (st.confirmBonus) {
68
+ switch (e.code) {
69
+ case 'Enter':
70
+ case 'Space': {
71
+ const bonus = st.confirmBonus;
72
+ if (!isAffordable(shell, bonus)) return true;
73
+ if (bonus.type === 'feature') shell.activateFeature(bonus);
74
+ else shell.emit('buyBonusSelect', { id: bonus.id });
75
+ shell.closeModal();
76
+ return true;
77
+ }
78
+ case 'Escape':
79
+ // Remove the confirm dialog, return to browse
80
+ closeConfirm(root, st);
81
+ return true;
82
+ default:
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // ── Browse phase ──
88
+ const last = affordable.length - 1;
89
+ const mobile = shell.layout === 'mobile';
90
+
91
+ // Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
92
+ // navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
93
+ const bet = betDir(e);
94
+ if (bet !== null) { stepBetBy(bet); return true; }
95
+
96
+ // Determine navigation direction from key code + layout (mobile uses vertical arrows)
97
+ const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
98
+ const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
99
+
100
+ if (fwdKey) {
101
+ if (last < 0) return true;
102
+ if (st.focusIndex < last) {
103
+ st.focusIndex++;
104
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
105
+ }
106
+ return true;
107
+ }
108
+ if (bwdKey) {
109
+ if (last < 0) return true;
110
+ if (st.focusIndex > 0) {
111
+ st.focusIndex--;
112
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
113
+ }
114
+ return true;
115
+ }
116
+
117
+ switch (e.code) {
118
+ case 'Enter':
119
+ case 'Space':
120
+ if (last < 0 || st.focusIndex < 0) return true;
121
+ {
122
+ const bonus = affordable[st.focusIndex];
123
+ openConfirm(shell, bonus, root, st);
124
+ }
125
+ return true;
126
+ // Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
127
+ case 'Equal':
128
+ stepBetBy(1);
129
+ return true;
130
+ case 'Minus':
131
+ stepBetBy(-1);
132
+ return true;
133
+ case 'Escape':
134
+ shell.closeModal();
135
+ return true;
136
+ default:
137
+ return false;
138
+ }
139
+ };
140
+
141
+ return { root, onKey };
142
+ }
143
+
144
+ /** Apply a CSS keyboard-focus class to the currently focused affordable card. */
145
+ function applyFocusClass(overlay: HTMLElement, bonuses: BonusOption[], affordable: BonusOption[], focusIndex: number): void {
146
+ for (const b of bonuses) {
147
+ const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`) as HTMLElement | null;
148
+ if (!card) continue;
149
+ card.classList.remove('ge-bonus-card--kbd-focus');
150
+ }
151
+ const focused = affordable[focusIndex];
152
+ if (!focused) return;
153
+ const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`) as HTMLElement | null;
154
+ if (card) card.classList.add('ge-bonus-card--kbd-focus');
155
+ }
156
+
157
+ /** Open the confirm dialog for the given bonus and track it in overlay state. */
158
+ function openConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): void {
159
+ closeConfirm(overlay, st); // remove any existing confirm
160
+ st.confirmBonus = bonus;
161
+ overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
162
+ shell.fitModals();
163
+ }
164
+
165
+ /** Remove the confirm dialog and clear the overlay state. */
166
+ function closeConfirm(overlay: HTMLElement, st: OverlayState): void {
167
+ // The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
168
+ const sheet = overlay.querySelector('[data-ge="bonus-confirm"]') as HTMLElement | null;
169
+ if (sheet) sheet.remove();
170
+ st.confirmBonus = undefined;
26
171
  }
27
172
 
28
173
  /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
@@ -63,7 +208,7 @@ function stepButton(ge: string, name: IconName): HTMLButtonElement {
63
208
 
64
209
  /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
65
210
  * Clicking (when affordable) opens the confirmation modal. */
66
- function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
211
+ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
67
212
  const accent = effectiveAccent(bonus);
68
213
  const card = document.createElement('div');
69
214
  card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
@@ -75,8 +220,7 @@ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement):
75
220
  // affordability at click time, so it's a safe no-op when the option can't be bought.
76
221
  const select = (): void => {
77
222
  if (!isAffordable(shell, bonus)) return;
78
- overlay.appendChild(buildConfirm(shell, bonus, overlay));
79
- shell.fitModals();
223
+ openConfirm(shell, bonus, overlay, st);
80
224
  };
81
225
 
82
226
  // Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
@@ -123,9 +267,9 @@ function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
123
267
 
124
268
  /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
125
269
  * preview body and a full-bleed Cancel + action footer. */
126
- function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
270
+ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
127
271
  const accent = effectiveAccent(bonus);
128
- const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => ui.root.remove() });
272
+ const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
129
273
 
130
274
  const price = bonus.priceMultiplier * shell.state.bet;
131
275
  const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
@@ -140,7 +284,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
140
284
  const cancel = document.createElement('button');
141
285
  cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
142
286
  cancel.textContent = shell.t('Cancel');
143
- cancel.addEventListener('click', () => ui.root.remove());
287
+ cancel.addEventListener('click', () => closeConfirm(overlay, st));
144
288
  const buy = document.createElement('button');
145
289
  buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
146
290
  buy.textContent = shell.t(actionLabel(bonus));
@@ -151,8 +295,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
151
295
  if (!isAffordable(shell, bonus)) return;
152
296
  if (bonus.type === 'feature') shell.activateFeature(bonus);
153
297
  else shell.emit('buyBonusSelect', { id: bonus.id });
154
- ui.root.remove();
155
- overlay.remove();
298
+ shell.closeModal();
156
299
  });
157
300
  actions.append(cancel, buy);
158
301
  ui.card.appendChild(actions);