@energy8platform/shell 0.4.0 → 0.5.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.
package/dist/html.cjs.js CHANGED
@@ -65,6 +65,7 @@ function createInitialState(config) {
65
65
  turbo: 0,
66
66
  buyBonusEnabled: true,
67
67
  freeSpins: { current: 0, total: 0, totalWin: 0 },
68
+ bonus: null,
68
69
  activeFeature: null,
69
70
  };
70
71
  }
@@ -1438,7 +1439,7 @@ class KeyboardController {
1438
1439
 
1439
1440
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1440
1441
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1441
- const PACKAGE_VERSION = '0.4.0';
1442
+ const PACKAGE_VERSION = '0.5.0';
1442
1443
 
1443
1444
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1444
1445
  function resolveConfig(config) {
@@ -1499,9 +1500,15 @@ class ShellController extends EventEmitter {
1499
1500
  this.renderer.renderBar();
1500
1501
  }
1501
1502
  // ── ShellHost ──────────────────────────────────────────────────────────────
1502
- t(text) { return this.i18n.t(text); }
1503
- formatCurrency(n, win = false) { return formatCurrency(n, this.config.currency, win); }
1504
- formatWin(value) { return this.formatCurrency(value, true); }
1503
+ t(text) {
1504
+ return this.i18n.t(text);
1505
+ }
1506
+ formatCurrency(n, win = false) {
1507
+ return formatCurrency(n, this.config.currency, win);
1508
+ }
1509
+ formatWin(value) {
1510
+ return this.formatCurrency(value, true);
1511
+ }
1505
1512
  notifyResize(w, h) {
1506
1513
  const layout = w !== 0 && h > w ? 'mobile' : 'wide';
1507
1514
  if (layout !== this.layout) {
@@ -1568,12 +1575,24 @@ class ShellController extends EventEmitter {
1568
1575
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1569
1576
  const self = this;
1570
1577
  const host = {
1571
- get state() { return self.state; },
1572
- get hotkeysEnabled() { return self.config.features.hotkeys !== false; },
1573
- get spacebarEnabled() { return self.config.features.spacebar !== false; },
1574
- get turboLevels() { return self.config.features.turbo; },
1575
- get autoplayEnabled() { return self.config.features.autoplay != null; },
1576
- get buyBonusEnabled() { return self.config.features.buyBonus !== false; },
1578
+ get state() {
1579
+ return self.state;
1580
+ },
1581
+ get hotkeysEnabled() {
1582
+ return self.config.features.hotkeys !== false;
1583
+ },
1584
+ get spacebarEnabled() {
1585
+ return self.config.features.spacebar !== false;
1586
+ },
1587
+ get turboLevels() {
1588
+ return self.config.features.turbo;
1589
+ },
1590
+ get autoplayEnabled() {
1591
+ return self.config.features.autoplay != null;
1592
+ },
1593
+ get buyBonusEnabled() {
1594
+ return self.config.features.buyBonus !== false;
1595
+ },
1577
1596
  hasOpenLayer: () => self.overlay !== null,
1578
1597
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
1579
1598
  spin: () => self.actions.spin(),
@@ -1589,27 +1608,52 @@ class ShellController extends EventEmitter {
1589
1608
  this.kbd = new KeyboardController(host);
1590
1609
  this.kbd.attach();
1591
1610
  }
1592
- pullFocus = () => { try {
1593
- globalThis.focus?.();
1594
- }
1595
- catch { /* cross-origin */ } };
1611
+ pullFocus = () => {
1612
+ try {
1613
+ globalThis.focus?.();
1614
+ }
1615
+ catch {
1616
+ /* cross-origin */
1617
+ }
1618
+ };
1596
1619
  // ── overlay flow ─────────────────────────────────────────────────────────────
1597
1620
  show(req) {
1598
1621
  this.closeModal();
1599
1622
  this.overlay = this.renderer.openOverlay(req) ?? null;
1600
1623
  }
1601
- openMenu() { this.emit('menuOpen'); this.openSettings(); }
1602
- openSettings() { this.emit('settingsOpen'); this.show({ kind: 'settings' }); }
1603
- openInfo() { this.emit('infoOpen'); this.show({ kind: 'gameInfo' }); }
1604
- openBuyBonus() { if (this.config.onBonusBuy) {
1605
- this.config.onBonusBuy();
1606
- return;
1607
- } this.show({ kind: 'buyBonus' }); }
1608
- openBetPicker() { this.show({ kind: 'betPicker' }); }
1609
- openAutoplayPicker() { this.show({ kind: 'autoplayPicker' }); }
1610
- openReplay(opts) { if (this.destroyed)
1611
- return; this.show({ kind: 'replay', opts }); }
1612
- openModal(opts) { this.show({ kind: 'modal', opts }); }
1624
+ openMenu() {
1625
+ this.emit('menuOpen');
1626
+ this.openSettings();
1627
+ }
1628
+ openSettings() {
1629
+ this.emit('settingsOpen');
1630
+ this.show({ kind: 'settings' });
1631
+ }
1632
+ openInfo() {
1633
+ this.emit('infoOpen');
1634
+ this.show({ kind: 'gameInfo' });
1635
+ }
1636
+ openBuyBonus() {
1637
+ if (this.config.onBonusBuy) {
1638
+ this.config.onBonusBuy();
1639
+ return;
1640
+ }
1641
+ this.show({ kind: 'buyBonus' });
1642
+ }
1643
+ openBetPicker() {
1644
+ this.show({ kind: 'betPicker' });
1645
+ }
1646
+ openAutoplayPicker() {
1647
+ this.show({ kind: 'autoplayPicker' });
1648
+ }
1649
+ openReplay(opts) {
1650
+ if (this.destroyed)
1651
+ return;
1652
+ this.show({ kind: 'replay', opts });
1653
+ }
1654
+ openModal(opts) {
1655
+ this.show({ kind: 'modal', opts });
1656
+ }
1613
1657
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
1614
1658
  closeModal() {
1615
1659
  if (!this.overlay)
@@ -1625,7 +1669,9 @@ class ShellController extends EventEmitter {
1625
1669
  this.soundRefresh?.(on);
1626
1670
  this.renderer.refreshSoundIcon?.(on);
1627
1671
  }
1628
- setSoundRefresh(fn) { this.soundRefresh = fn; }
1672
+ setSoundRefresh(fn) {
1673
+ this.soundRefresh = fn;
1674
+ }
1629
1675
  // ── features ─────────────────────────────────────────────────────────────────
1630
1676
  activateFeature(bonus) {
1631
1677
  this.state.activeFeature = bonus;
@@ -1646,21 +1692,81 @@ class ShellController extends EventEmitter {
1646
1692
  if (to !== from)
1647
1693
  this.renderer.animateMoney(field, from, to);
1648
1694
  }
1649
- setBalance(n) { const from = this.prevBalance; this.state.balance = n; this.prevBalance = n; this.money('balance', from, n); }
1650
- setWin(n) { const from = this.prevWin; this.state.win = n; this.prevWin = n; this.money('win', from, n); }
1651
- setBet(n) { this.state.bet = n; this.renderer.renderBar(); }
1652
- setMode(mode) { if (mode === 'replay')
1653
- this.state.replay = true; this.state.mode = mode; this.renderer.renderBar(); }
1654
- setBusy(busy) { this.state.busy = busy; this.renderer.renderBar(); this.kbd?.notifyBusyChanged(busy); }
1655
- setAutoplay(a) { this.state.autoplay = a; this.renderer.renderBar(); }
1656
- setTurbo(level) { this.state.turbo = level; this.renderer.renderBar(); }
1657
- setBuyBonusEnabled(enabled) { this.state.buyBonusEnabled = enabled; this.renderer.renderBar(); }
1658
- setFreeSpins(fs) { this.state.freeSpins = fs; this.renderer.renderBar(); }
1659
- setTheme(theme) { this.config.theme = theme; this.tokens = resolveTheme(theme); this.renderer.applyTheme(this.tokens); this.renderer.renderBar(); }
1660
- setLanguage(lang) { this.config.language = lang; this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial }); this.renderer.renderBar(); }
1661
- setSocial(isSocial) { this.config.isSocial = isSocial; this.i18n = createI18n({ language: this.config.language, isSocial }); this.renderer.renderBar(); }
1662
- setLayout(layout) { if (layout === this.layout)
1663
- return; this.layout = layout; this.renderer.setLayout(layout); this.renderer.renderBar(); }
1695
+ setBalance(n) {
1696
+ const from = this.prevBalance;
1697
+ this.state.balance = n;
1698
+ this.prevBalance = n;
1699
+ this.money('balance', from, n);
1700
+ }
1701
+ setWin(n) {
1702
+ const from = this.prevWin;
1703
+ this.state.win = n;
1704
+ this.prevWin = n;
1705
+ this.money('win', from, n);
1706
+ }
1707
+ setBet(n) {
1708
+ this.state.bet = n;
1709
+ this.renderer.renderBar();
1710
+ }
1711
+ setMode(mode) {
1712
+ if (mode === 'replay')
1713
+ this.state.replay = true;
1714
+ this.state.mode = mode;
1715
+ this.renderer.renderBar();
1716
+ }
1717
+ setBusy(busy) {
1718
+ this.state.busy = busy;
1719
+ this.renderer.renderBar();
1720
+ this.kbd?.notifyBusyChanged(busy);
1721
+ }
1722
+ setAutoplay(a) {
1723
+ this.state.autoplay = a;
1724
+ this.renderer.renderBar();
1725
+ }
1726
+ setTurbo(level) {
1727
+ this.state.turbo = level;
1728
+ this.renderer.renderBar();
1729
+ }
1730
+ setBuyBonusEnabled(enabled) {
1731
+ this.state.buyBonusEnabled = enabled;
1732
+ this.renderer.renderBar();
1733
+ }
1734
+ setFreeSpins(fs) {
1735
+ this.state.freeSpins = fs;
1736
+ this.state.bonus = null;
1737
+ this.renderer.renderBar();
1738
+ }
1739
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
1740
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
1741
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
1742
+ setBonus(b) {
1743
+ this.state.bonus = { label: b.label, value: b.value };
1744
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
1745
+ this.renderer.renderBar();
1746
+ }
1747
+ setTheme(theme) {
1748
+ this.config.theme = theme;
1749
+ this.tokens = resolveTheme(theme);
1750
+ this.renderer.applyTheme(this.tokens);
1751
+ this.renderer.renderBar();
1752
+ }
1753
+ setLanguage(lang) {
1754
+ this.config.language = lang;
1755
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
1756
+ this.renderer.renderBar();
1757
+ }
1758
+ setSocial(isSocial) {
1759
+ this.config.isSocial = isSocial;
1760
+ this.i18n = createI18n({ language: this.config.language, isSocial });
1761
+ this.renderer.renderBar();
1762
+ }
1763
+ setLayout(layout) {
1764
+ if (layout === this.layout)
1765
+ return;
1766
+ this.layout = layout;
1767
+ this.renderer.setLayout(layout);
1768
+ this.renderer.renderBar();
1769
+ }
1664
1770
  destroy() {
1665
1771
  if (this.destroyed)
1666
1772
  return Promise.resolve();
@@ -1912,7 +2018,12 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1912
2018
  #${SHELL_ROOT_ID} .ge-gi-hk-block { display:flex; flex-direction:column; }
1913
2019
  #${SHELL_ROOT_ID} .ge-gi-hk { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; }
1914
2020
  #${SHELL_ROOT_ID} .ge-gi-hk + .ge-gi-hk { border-top:1px solid var(--shell-plaque-line); }
1915
- #${SHELL_ROOT_ID} .ge-gi-hk-chips { display:flex; align-items:center; flex-wrap:wrap; gap:4px; flex:0 0 auto; }
2021
+ /* Let the keycaps SHRINK instead of hogging the row: flex:0 1 auto lets the chips block give up
2022
+ width down to its widest single combo, and flex-wrap reflows the extra combos onto another line.
2023
+ On narrow/mobile widths the many-chip rows (Raise/Lower bet) otherwise squeeze the label off the
2024
+ right edge and clip it. (No min-width:0 here — the min-content floor is what keeps a combo intact
2025
+ and wrapping rather than overflowing.) */
2026
+ #${SHELL_ROOT_ID} .ge-gi-hk-chips { display:flex; align-items:center; flex-wrap:wrap; gap:4px; flex:0 1 auto; }
1916
2027
  #${SHELL_ROOT_ID} .ge-gi-hk-combo { display:inline-flex; align-items:center; gap:4px; }
1917
2028
  #${SHELL_ROOT_ID} .ge-gi-hk-chip { display:inline-flex; align-items:center; justify-content:center;
1918
2029
  padding:2px 7px; border-radius:6px; border:1px solid var(--shell-plaque-line);
@@ -1921,7 +2032,10 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1921
2032
  font-weight:600; line-height:1.5; white-space:nowrap; min-width:1.6em; text-align:center; }
1922
2033
  #${SHELL_ROOT_ID} .ge-gi-hk-sep { color:var(--shell-plaque-label); font-size:11px; padding:0 1px; }
1923
2034
  #${SHELL_ROOT_ID} .ge-gi-hk-sep2 { color:var(--shell-plaque-label); font-size:11px; padding:0 4px; }
1924
- #${SHELL_ROOT_ID} .ge-gi-hk-tx { color:rgba(255,255,255,.88); font-size:14px; font-weight:600; text-align:right; flex:1; }
2035
+ /* flex:1 1 auto (content-based basis, NOT flex:1's 0 basis) so the label keeps demanding its word
2036
+ width and shrinks in PROPORTION to the chips — both give ground and wrap, instead of the label
2037
+ collapsing to nothing. No min-width:0: the min-content floor stops a word being clipped. */
2038
+ #${SHELL_ROOT_ID} .ge-gi-hk-tx { color:rgba(255,255,255,.88); font-size:14px; font-weight:600; text-align:right; flex:1 1 auto; }
1925
2039
 
1926
2040
  /* controls — two blocks (gameplay / menu & info), icon/name/description per control */
1927
2041
  #${SHELL_ROOT_ID} .ge-gi-ctl-block + .ge-gi-ctl-block { margin-top:16px; padding-top:4px; border-top:1px solid var(--shell-plaque-line); }
@@ -1935,7 +2049,7 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1935
2049
  #${SHELL_ROOT_ID} .ge-gi-ctl-ic--bet { flex-direction:column; font-size:18px; gap:0; }
1936
2050
  /* buy bonus draws the real control-bar badge, scaled down & non-interactive */
1937
2051
  #${SHELL_ROOT_ID} .ge-gi-ctl-ic .ge-shell-buybonus { width:46px; height:46px; font-size:8px; border-width:2px; pointer-events:none; }
1938
- #${SHELL_ROOT_ID} .ge-gi-ctl-tx { display:flex; flex-direction:column; gap:2px; }
2052
+ #${SHELL_ROOT_ID} .ge-gi-ctl-tx { display:flex; flex-direction:column; gap:2px; min-width:0; }
1939
2053
  #${SHELL_ROOT_ID} .ge-gi-ctl-tx b { color:#fff; font-size:15px; font-weight:700; }
1940
2054
  #${SHELL_ROOT_ID} .ge-gi-ctl-tx span { color:rgba(255,255,255,.7); font-size:13px; line-height:1.4; }
1941
2055
 
@@ -2222,6 +2336,14 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2222
2336
  #${SHELL_ROOT_ID}.ge-mobile .ge-sheet-grid { grid-template-columns:repeat(var(--cols-m,var(--cols,3)),1fr); }
2223
2337
  /* the bet picker packs 6 chips/row → give its card room (autoplay & co. stay at the 28em default) */
2224
2338
  #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-modal-card { max-width:44em; }
2339
+ /* Bet chips carry variable-width currency (a wide currency makes labels grow). Reflow the columns
2340
+ to the widest label (--chip-min, computed in JS) so no chip is ever clipped, and cap the grid
2341
+ height so a long bet ladder scrolls instead of overflowing the card's clipped edge. */
2342
+ #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-sheet-grid,
2343
+ #${SHELL_ROOT_ID}.ge-mobile .ge-sheet[data-ge="bet-modal"] .ge-sheet-grid {
2344
+ grid-template-columns:repeat(auto-fill, minmax(min(100%, var(--chip-min, 6em)), 1fr));
2345
+ max-height:min(50vh, 28em); overflow-y:auto; overflow-x:hidden; }
2346
+ #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-chip { min-width:0; }
2225
2347
  #${SHELL_ROOT_ID} .ge-chip { pointer-events:auto; cursor:pointer; border:1px solid var(--shell-plaque-line);
2226
2348
  border-radius:.8em; background:rgba(255,255,255,.04); color:#fff; font-size:1em; font-weight:700;
2227
2349
  font-variant-numeric:tabular-nums; padding:.8em .55em; transition:background .12s ease, border-color .12s ease; }
@@ -2366,8 +2488,10 @@ function iconBtn(ge, name, onClick, active = false) {
2366
2488
  b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
2367
2489
  b.dataset.ge = ge;
2368
2490
  b.innerHTML = icon(name);
2369
- b.addEventListener('click', () => { if (!b.disabled)
2370
- onClick(); });
2491
+ b.addEventListener('click', () => {
2492
+ if (!b.disabled)
2493
+ onClick();
2494
+ });
2371
2495
  return b;
2372
2496
  }
2373
2497
  function renderBottomBar(host) {
@@ -2383,15 +2507,13 @@ function renderBottomBar(host) {
2383
2507
  // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
2384
2508
  // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
2385
2509
  const isBase = state.mode === 'base';
2386
- const isFS = state.mode === 'freeSpins';
2510
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
2387
2511
  // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
2388
2512
  // only when it's a free-spins replay (freeSpins.total > 0).
2389
2513
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
2390
2514
  // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
2391
2515
  // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
2392
- const balance = state.replay
2393
- ? null
2394
- : readout('balance', host.t('Balance'), fmt(state.balance));
2516
+ const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
2395
2517
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
2396
2518
  // the feature accent; the base state.bet is unchanged and returns once the feature is off.
2397
2519
  const feature = state.activeFeature;
@@ -2414,21 +2536,25 @@ function renderBottomBar(host) {
2414
2536
  betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
2415
2537
  betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
2416
2538
  betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
2417
- betValue.addEventListener('click', () => { if (!betLocked(host))
2418
- host.actions.openBetPicker(); });
2539
+ betValue.addEventListener('click', () => {
2540
+ if (!betLocked(host))
2541
+ host.actions.openBetPicker();
2542
+ });
2419
2543
  spin = spinButton(host);
2420
2544
  auto = config.features.autoplay ? autoButton(host) : null;
2421
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
2545
+ buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
2422
2546
  }
2423
2547
  const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
2424
2548
  // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
2425
2549
  // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
2426
2550
  const fs = state.freeSpins;
2427
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
2551
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
2428
2552
  // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
2429
2553
  // style as the SPIN disc); Total Win stays an inline readout.
2430
- const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
2431
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
2554
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
2555
+ const fsTotalWin = showFsBlocks
2556
+ ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
2557
+ : null;
2432
2558
  // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
2433
2559
  const hero = isBase ? spin : fsHero;
2434
2560
  if (mobile) {
@@ -2471,13 +2597,13 @@ function renderBottomBar(host) {
2471
2597
  }
2472
2598
  /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
2473
2599
  * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
2474
- function fsHeroPlaque(host, text) {
2600
+ function fsHeroPlaque(host, text, label) {
2475
2601
  const el = document.createElement('div');
2476
2602
  el.className = 'ge-fs-hero';
2477
2603
  el.dataset.ge = 'fs-counter';
2478
2604
  const lbl = document.createElement('span');
2479
2605
  lbl.className = 'ge-fs-lbl';
2480
- lbl.textContent = host.t('Free spins');
2606
+ lbl.textContent = host.t(label ?? 'Free spins');
2481
2607
  const num = document.createElement('span');
2482
2608
  num.className = 'ge-fs-num';
2483
2609
  num.textContent = text;
@@ -2497,7 +2623,9 @@ function plaque(cls, children) {
2497
2623
  d.append(...children);
2498
2624
  return d;
2499
2625
  }
2500
- function compact(items) { return items.filter((x) => x !== null); }
2626
+ function compact(items) {
2627
+ return items.filter((x) => x !== null);
2628
+ }
2501
2629
  function buyBtn(host) {
2502
2630
  const buy = document.createElement('button');
2503
2631
  buy.className = 'ge-shell-buybonus';
@@ -2510,15 +2638,19 @@ function buyBtn(host) {
2510
2638
  buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
2511
2639
  buy.style.background = accent;
2512
2640
  buy.style.color = contrastText(accent);
2513
- buy.addEventListener('click', () => { if (!buy.disabled)
2514
- host.actions.deactivateFeature(); });
2641
+ buy.addEventListener('click', () => {
2642
+ if (!buy.disabled)
2643
+ host.actions.deactivateFeature();
2644
+ });
2515
2645
  }
2516
2646
  else {
2517
2647
  // Ticket icon (no text); keep the label for screen readers.
2518
2648
  buy.setAttribute('aria-label', host.t('BUY BONUS'));
2519
2649
  buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
2520
- buy.addEventListener('click', () => { if (!buy.disabled)
2521
- host.actions.openBuyBonus(); });
2650
+ buy.addEventListener('click', () => {
2651
+ if (!buy.disabled)
2652
+ host.actions.openBuyBonus();
2653
+ });
2522
2654
  }
2523
2655
  return buy;
2524
2656
  }
@@ -2536,15 +2668,19 @@ function spinButton(host) {
2536
2668
  const rem = state.autoplay.remaining;
2537
2669
  const label = Number.isFinite(rem) ? String(rem) : '∞';
2538
2670
  sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
2539
- sp.addEventListener('click', () => { if (!sp.disabled)
2540
- host.actions.stopAutoplay(); });
2671
+ sp.addEventListener('click', () => {
2672
+ if (!sp.disabled)
2673
+ host.actions.stopAutoplay();
2674
+ });
2541
2675
  }
2542
2676
  else {
2543
2677
  sp.innerHTML = icon('spin');
2544
2678
  if (state.busy)
2545
2679
  sp.classList.add('ge-spinning');
2546
- sp.addEventListener('click', () => { if (!sp.disabled)
2547
- host.actions.spin(); });
2680
+ sp.addEventListener('click', () => {
2681
+ if (!sp.disabled)
2682
+ host.actions.spin();
2683
+ });
2548
2684
  }
2549
2685
  return sp;
2550
2686
  }
@@ -3472,6 +3608,15 @@ function buildSheet(opts) {
3472
3608
  const cols = typeof opts.columns === 'number' ? { wide: opts.columns, mobile: opts.columns } : opts.columns;
3473
3609
  grid.style.setProperty('--cols', String(cols.wide));
3474
3610
  grid.style.setProperty('--cols-m', String(cols.mobile));
3611
+ if (opts.autoFit) {
3612
+ // Widest label in ch (tabular digits ≈ 1ch each; symbols/separators are narrower, so this
3613
+ // slightly over-estimates → safe) + chip padding/border. Floored so normal-width currencies
3614
+ // keep the compact ~6-per-row look and only wide currencies drop to fewer columns.
3615
+ const maxLen = opts.choices.reduce((m, c) => Math.max(m, c.label.length), 0);
3616
+ // Floor at 6em ≈ the natural width of a chip in the 6-wide layout (44em card): short labels
3617
+ // clamp here so a normal currency keeps 6 columns; wider labels raise it and drop to fewer.
3618
+ grid.style.setProperty('--chip-min', `max(6em, calc(${maxLen}ch + 1.4em))`);
3619
+ }
3475
3620
  let selected = opts.selected;
3476
3621
  let focusIndex = opts.choices.findIndex((c) => c.id === selected);
3477
3622
  if (focusIndex < 0)
@@ -3543,7 +3688,7 @@ function buildSheet(opts) {
3543
3688
  /** Bet picker — all available bets as chips (6 per row, 3 on mobile), accent Confirm applies it. */
3544
3689
  function openBetModal(host) {
3545
3690
  return buildSheet({
3546
- ge: 'bet-modal', title: host.t('Bet'), columns: { wide: 6, mobile: 3 }, confirmLabel: host.t('Confirm'),
3691
+ ge: 'bet-modal', title: host.t('Bet'), columns: { wide: 6, mobile: 3 }, autoFit: true, confirmLabel: host.t('Confirm'),
3547
3692
  choices: host.state.availableBets.map((b) => ({ id: String(b), label: host.formatCurrency(b) })),
3548
3693
  selected: String(host.state.bet),
3549
3694
  onClose: () => host.actions.closeOverlay(),