@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.d.ts CHANGED
@@ -16,7 +16,11 @@ declare class EventEmitter<TEvents extends {}> {
16
16
  removeAllListeners(event?: keyof TEvents): this;
17
17
  }
18
18
 
19
- type ShellMode = 'base' | 'freeSpins' | 'replay';
19
+ /** `freeSpins` and `bonus` are the SAME bar layout (host-driven hero + Total Win); `freeSpins` is
20
+ * kept as a back-compat alias for the common case (its readout is derived current/total), while
21
+ * `bonus` pairs with `setBonus()` to show a game-supplied label + value (adventure, hold-and-spin,
22
+ * respins — anything that isn't a plain free-spins counter). */
23
+ type ShellMode = 'base' | 'bonus' | 'freeSpins' | 'replay';
20
24
  interface CurrencyConfig {
21
25
  symbol: string;
22
26
  position: 'left' | 'right';
@@ -205,6 +209,19 @@ interface FreeSpinsState {
205
209
  total: number;
206
210
  totalWin: number;
207
211
  }
212
+ /** A game-supplied bonus readout for the bar hero, set via `setBonus()`. Generalizes the
213
+ * free-spins counter: the shell renders `label` (localized via the shell translator, so a game
214
+ * i18n entry for it is honoured) + `value` (a pre-formatted string, shown verbatim — "2 / 10",
215
+ * "3", "×5", "7 coins") + the Total Win accumulator. The shell stays free of any per-game bonus
216
+ * concept — the label/value semantics live in the game/host. */
217
+ interface BonusReadout {
218
+ /** Bar label, e.g. 'Free spins' | 'Adventure' | 'Hold & Spin'. Run through the shell translator. */
219
+ label: string;
220
+ /** Pre-formatted counter value shown verbatim (NOT translated). */
221
+ value: string;
222
+ /** Cumulative bonus win for the Total Win readout. */
223
+ totalWin: number;
224
+ }
208
225
  /** One footer button of a generic modal. Clicking it runs `on` (if any), then closes the modal. */
209
226
  interface ModalAction {
210
227
  title: string;
@@ -283,6 +300,12 @@ interface ShellState {
283
300
  turbo: number;
284
301
  buyBonusEnabled: boolean;
285
302
  freeSpins: FreeSpinsState;
303
+ /** Game-supplied bonus label + value override (from `setBonus()`). When null the bar derives the
304
+ * readout from `freeSpins` (label 'Free spins', value current/total) — the back-compat default. */
305
+ bonus: {
306
+ label: string;
307
+ value: string;
308
+ } | null;
286
309
  /** The currently activated `feature` option (e.g. Ante), or null. Drives the
287
310
  * effective-bet readout tint and the BUY BONUS → DISABLE toggle on the bar. */
288
311
  activeFeature: BonusOption | null;
@@ -471,7 +494,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
471
494
  tokens: ShellTokens;
472
495
  layout: ShellLayoutMode;
473
496
  soundOn: boolean;
474
- readonly engineVersion = "0.4.0";
497
+ readonly engineVersion = "0.5.0";
475
498
  readonly actions: ShellActions;
476
499
  private renderer;
477
500
  private i18n;
@@ -514,6 +537,10 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
514
537
  setTurbo(level: number): void;
515
538
  setBuyBonusEnabled(enabled: boolean): void;
516
539
  setFreeSpins(fs: FreeSpinsState): void;
540
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
541
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
542
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
543
+ setBonus(b: BonusReadout): void;
517
544
  setTheme(theme: ThemeConfig): void;
518
545
  setLanguage(lang: string): void;
519
546
  setSocial(isSocial: boolean): void;
@@ -537,7 +564,7 @@ interface I18n {
537
564
  declare function createI18n(opts: I18nOptions): I18n;
538
565
 
539
566
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
540
- declare const PACKAGE_VERSION = "0.4.0";
567
+ declare const PACKAGE_VERSION = "0.5.0";
541
568
 
542
569
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
543
570
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
@@ -613,4 +640,4 @@ declare function createGameShell(config: HtmlShellConfig): ShellController;
613
640
  declare function removeGameShell(): Promise<void>;
614
641
 
615
642
  export { DEFAULT_ACCENT, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, SCHEMES, ShellController, createGameShell, createI18n, createShell, normalizeLang, removeGameShell, resolveConfig, resolveTheme, socialize };
616
- export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, WinSection };
643
+ export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, WinSection };
package/dist/html.esm.js CHANGED
@@ -63,6 +63,7 @@ function createInitialState(config) {
63
63
  turbo: 0,
64
64
  buyBonusEnabled: true,
65
65
  freeSpins: { current: 0, total: 0, totalWin: 0 },
66
+ bonus: null,
66
67
  activeFeature: null,
67
68
  };
68
69
  }
@@ -1436,7 +1437,7 @@ class KeyboardController {
1436
1437
 
1437
1438
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1438
1439
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1439
- const PACKAGE_VERSION = '0.4.0';
1440
+ const PACKAGE_VERSION = '0.5.0';
1440
1441
 
1441
1442
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1442
1443
  function resolveConfig(config) {
@@ -1497,9 +1498,15 @@ class ShellController extends EventEmitter {
1497
1498
  this.renderer.renderBar();
1498
1499
  }
1499
1500
  // ── ShellHost ──────────────────────────────────────────────────────────────
1500
- t(text) { return this.i18n.t(text); }
1501
- formatCurrency(n, win = false) { return formatCurrency(n, this.config.currency, win); }
1502
- formatWin(value) { return this.formatCurrency(value, true); }
1501
+ t(text) {
1502
+ return this.i18n.t(text);
1503
+ }
1504
+ formatCurrency(n, win = false) {
1505
+ return formatCurrency(n, this.config.currency, win);
1506
+ }
1507
+ formatWin(value) {
1508
+ return this.formatCurrency(value, true);
1509
+ }
1503
1510
  notifyResize(w, h) {
1504
1511
  const layout = w !== 0 && h > w ? 'mobile' : 'wide';
1505
1512
  if (layout !== this.layout) {
@@ -1566,12 +1573,24 @@ class ShellController extends EventEmitter {
1566
1573
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1567
1574
  const self = this;
1568
1575
  const host = {
1569
- get state() { return self.state; },
1570
- get hotkeysEnabled() { return self.config.features.hotkeys !== false; },
1571
- get spacebarEnabled() { return self.config.features.spacebar !== false; },
1572
- get turboLevels() { return self.config.features.turbo; },
1573
- get autoplayEnabled() { return self.config.features.autoplay != null; },
1574
- get buyBonusEnabled() { return self.config.features.buyBonus !== false; },
1576
+ get state() {
1577
+ return self.state;
1578
+ },
1579
+ get hotkeysEnabled() {
1580
+ return self.config.features.hotkeys !== false;
1581
+ },
1582
+ get spacebarEnabled() {
1583
+ return self.config.features.spacebar !== false;
1584
+ },
1585
+ get turboLevels() {
1586
+ return self.config.features.turbo;
1587
+ },
1588
+ get autoplayEnabled() {
1589
+ return self.config.features.autoplay != null;
1590
+ },
1591
+ get buyBonusEnabled() {
1592
+ return self.config.features.buyBonus !== false;
1593
+ },
1575
1594
  hasOpenLayer: () => self.overlay !== null,
1576
1595
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
1577
1596
  spin: () => self.actions.spin(),
@@ -1587,27 +1606,52 @@ class ShellController extends EventEmitter {
1587
1606
  this.kbd = new KeyboardController(host);
1588
1607
  this.kbd.attach();
1589
1608
  }
1590
- pullFocus = () => { try {
1591
- globalThis.focus?.();
1592
- }
1593
- catch { /* cross-origin */ } };
1609
+ pullFocus = () => {
1610
+ try {
1611
+ globalThis.focus?.();
1612
+ }
1613
+ catch {
1614
+ /* cross-origin */
1615
+ }
1616
+ };
1594
1617
  // ── overlay flow ─────────────────────────────────────────────────────────────
1595
1618
  show(req) {
1596
1619
  this.closeModal();
1597
1620
  this.overlay = this.renderer.openOverlay(req) ?? null;
1598
1621
  }
1599
- openMenu() { this.emit('menuOpen'); this.openSettings(); }
1600
- openSettings() { this.emit('settingsOpen'); this.show({ kind: 'settings' }); }
1601
- openInfo() { this.emit('infoOpen'); this.show({ kind: 'gameInfo' }); }
1602
- openBuyBonus() { if (this.config.onBonusBuy) {
1603
- this.config.onBonusBuy();
1604
- return;
1605
- } this.show({ kind: 'buyBonus' }); }
1606
- openBetPicker() { this.show({ kind: 'betPicker' }); }
1607
- openAutoplayPicker() { this.show({ kind: 'autoplayPicker' }); }
1608
- openReplay(opts) { if (this.destroyed)
1609
- return; this.show({ kind: 'replay', opts }); }
1610
- openModal(opts) { this.show({ kind: 'modal', opts }); }
1622
+ openMenu() {
1623
+ this.emit('menuOpen');
1624
+ this.openSettings();
1625
+ }
1626
+ openSettings() {
1627
+ this.emit('settingsOpen');
1628
+ this.show({ kind: 'settings' });
1629
+ }
1630
+ openInfo() {
1631
+ this.emit('infoOpen');
1632
+ this.show({ kind: 'gameInfo' });
1633
+ }
1634
+ openBuyBonus() {
1635
+ if (this.config.onBonusBuy) {
1636
+ this.config.onBonusBuy();
1637
+ return;
1638
+ }
1639
+ this.show({ kind: 'buyBonus' });
1640
+ }
1641
+ openBetPicker() {
1642
+ this.show({ kind: 'betPicker' });
1643
+ }
1644
+ openAutoplayPicker() {
1645
+ this.show({ kind: 'autoplayPicker' });
1646
+ }
1647
+ openReplay(opts) {
1648
+ if (this.destroyed)
1649
+ return;
1650
+ this.show({ kind: 'replay', opts });
1651
+ }
1652
+ openModal(opts) {
1653
+ this.show({ kind: 'modal', opts });
1654
+ }
1611
1655
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
1612
1656
  closeModal() {
1613
1657
  if (!this.overlay)
@@ -1623,7 +1667,9 @@ class ShellController extends EventEmitter {
1623
1667
  this.soundRefresh?.(on);
1624
1668
  this.renderer.refreshSoundIcon?.(on);
1625
1669
  }
1626
- setSoundRefresh(fn) { this.soundRefresh = fn; }
1670
+ setSoundRefresh(fn) {
1671
+ this.soundRefresh = fn;
1672
+ }
1627
1673
  // ── features ─────────────────────────────────────────────────────────────────
1628
1674
  activateFeature(bonus) {
1629
1675
  this.state.activeFeature = bonus;
@@ -1644,21 +1690,81 @@ class ShellController extends EventEmitter {
1644
1690
  if (to !== from)
1645
1691
  this.renderer.animateMoney(field, from, to);
1646
1692
  }
1647
- setBalance(n) { const from = this.prevBalance; this.state.balance = n; this.prevBalance = n; this.money('balance', from, n); }
1648
- setWin(n) { const from = this.prevWin; this.state.win = n; this.prevWin = n; this.money('win', from, n); }
1649
- setBet(n) { this.state.bet = n; this.renderer.renderBar(); }
1650
- setMode(mode) { if (mode === 'replay')
1651
- this.state.replay = true; this.state.mode = mode; this.renderer.renderBar(); }
1652
- setBusy(busy) { this.state.busy = busy; this.renderer.renderBar(); this.kbd?.notifyBusyChanged(busy); }
1653
- setAutoplay(a) { this.state.autoplay = a; this.renderer.renderBar(); }
1654
- setTurbo(level) { this.state.turbo = level; this.renderer.renderBar(); }
1655
- setBuyBonusEnabled(enabled) { this.state.buyBonusEnabled = enabled; this.renderer.renderBar(); }
1656
- setFreeSpins(fs) { this.state.freeSpins = fs; this.renderer.renderBar(); }
1657
- setTheme(theme) { this.config.theme = theme; this.tokens = resolveTheme(theme); this.renderer.applyTheme(this.tokens); this.renderer.renderBar(); }
1658
- setLanguage(lang) { this.config.language = lang; this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial }); this.renderer.renderBar(); }
1659
- setSocial(isSocial) { this.config.isSocial = isSocial; this.i18n = createI18n({ language: this.config.language, isSocial }); this.renderer.renderBar(); }
1660
- setLayout(layout) { if (layout === this.layout)
1661
- return; this.layout = layout; this.renderer.setLayout(layout); this.renderer.renderBar(); }
1693
+ setBalance(n) {
1694
+ const from = this.prevBalance;
1695
+ this.state.balance = n;
1696
+ this.prevBalance = n;
1697
+ this.money('balance', from, n);
1698
+ }
1699
+ setWin(n) {
1700
+ const from = this.prevWin;
1701
+ this.state.win = n;
1702
+ this.prevWin = n;
1703
+ this.money('win', from, n);
1704
+ }
1705
+ setBet(n) {
1706
+ this.state.bet = n;
1707
+ this.renderer.renderBar();
1708
+ }
1709
+ setMode(mode) {
1710
+ if (mode === 'replay')
1711
+ this.state.replay = true;
1712
+ this.state.mode = mode;
1713
+ this.renderer.renderBar();
1714
+ }
1715
+ setBusy(busy) {
1716
+ this.state.busy = busy;
1717
+ this.renderer.renderBar();
1718
+ this.kbd?.notifyBusyChanged(busy);
1719
+ }
1720
+ setAutoplay(a) {
1721
+ this.state.autoplay = a;
1722
+ this.renderer.renderBar();
1723
+ }
1724
+ setTurbo(level) {
1725
+ this.state.turbo = level;
1726
+ this.renderer.renderBar();
1727
+ }
1728
+ setBuyBonusEnabled(enabled) {
1729
+ this.state.buyBonusEnabled = enabled;
1730
+ this.renderer.renderBar();
1731
+ }
1732
+ setFreeSpins(fs) {
1733
+ this.state.freeSpins = fs;
1734
+ this.state.bonus = null;
1735
+ this.renderer.renderBar();
1736
+ }
1737
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
1738
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
1739
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
1740
+ setBonus(b) {
1741
+ this.state.bonus = { label: b.label, value: b.value };
1742
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
1743
+ this.renderer.renderBar();
1744
+ }
1745
+ setTheme(theme) {
1746
+ this.config.theme = theme;
1747
+ this.tokens = resolveTheme(theme);
1748
+ this.renderer.applyTheme(this.tokens);
1749
+ this.renderer.renderBar();
1750
+ }
1751
+ setLanguage(lang) {
1752
+ this.config.language = lang;
1753
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
1754
+ this.renderer.renderBar();
1755
+ }
1756
+ setSocial(isSocial) {
1757
+ this.config.isSocial = isSocial;
1758
+ this.i18n = createI18n({ language: this.config.language, isSocial });
1759
+ this.renderer.renderBar();
1760
+ }
1761
+ setLayout(layout) {
1762
+ if (layout === this.layout)
1763
+ return;
1764
+ this.layout = layout;
1765
+ this.renderer.setLayout(layout);
1766
+ this.renderer.renderBar();
1767
+ }
1662
1768
  destroy() {
1663
1769
  if (this.destroyed)
1664
1770
  return Promise.resolve();
@@ -1910,7 +2016,12 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1910
2016
  #${SHELL_ROOT_ID} .ge-gi-hk-block { display:flex; flex-direction:column; }
1911
2017
  #${SHELL_ROOT_ID} .ge-gi-hk { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; }
1912
2018
  #${SHELL_ROOT_ID} .ge-gi-hk + .ge-gi-hk { border-top:1px solid var(--shell-plaque-line); }
1913
- #${SHELL_ROOT_ID} .ge-gi-hk-chips { display:flex; align-items:center; flex-wrap:wrap; gap:4px; flex:0 0 auto; }
2019
+ /* Let the keycaps SHRINK instead of hogging the row: flex:0 1 auto lets the chips block give up
2020
+ width down to its widest single combo, and flex-wrap reflows the extra combos onto another line.
2021
+ On narrow/mobile widths the many-chip rows (Raise/Lower bet) otherwise squeeze the label off the
2022
+ right edge and clip it. (No min-width:0 here — the min-content floor is what keeps a combo intact
2023
+ and wrapping rather than overflowing.) */
2024
+ #${SHELL_ROOT_ID} .ge-gi-hk-chips { display:flex; align-items:center; flex-wrap:wrap; gap:4px; flex:0 1 auto; }
1914
2025
  #${SHELL_ROOT_ID} .ge-gi-hk-combo { display:inline-flex; align-items:center; gap:4px; }
1915
2026
  #${SHELL_ROOT_ID} .ge-gi-hk-chip { display:inline-flex; align-items:center; justify-content:center;
1916
2027
  padding:2px 7px; border-radius:6px; border:1px solid var(--shell-plaque-line);
@@ -1919,7 +2030,10 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1919
2030
  font-weight:600; line-height:1.5; white-space:nowrap; min-width:1.6em; text-align:center; }
1920
2031
  #${SHELL_ROOT_ID} .ge-gi-hk-sep { color:var(--shell-plaque-label); font-size:11px; padding:0 1px; }
1921
2032
  #${SHELL_ROOT_ID} .ge-gi-hk-sep2 { color:var(--shell-plaque-label); font-size:11px; padding:0 4px; }
1922
- #${SHELL_ROOT_ID} .ge-gi-hk-tx { color:rgba(255,255,255,.88); font-size:14px; font-weight:600; text-align:right; flex:1; }
2033
+ /* flex:1 1 auto (content-based basis, NOT flex:1's 0 basis) so the label keeps demanding its word
2034
+ width and shrinks in PROPORTION to the chips — both give ground and wrap, instead of the label
2035
+ collapsing to nothing. No min-width:0: the min-content floor stops a word being clipped. */
2036
+ #${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; }
1923
2037
 
1924
2038
  /* controls — two blocks (gameplay / menu & info), icon/name/description per control */
1925
2039
  #${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); }
@@ -1933,7 +2047,7 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
1933
2047
  #${SHELL_ROOT_ID} .ge-gi-ctl-ic--bet { flex-direction:column; font-size:18px; gap:0; }
1934
2048
  /* buy bonus draws the real control-bar badge, scaled down & non-interactive */
1935
2049
  #${SHELL_ROOT_ID} .ge-gi-ctl-ic .ge-shell-buybonus { width:46px; height:46px; font-size:8px; border-width:2px; pointer-events:none; }
1936
- #${SHELL_ROOT_ID} .ge-gi-ctl-tx { display:flex; flex-direction:column; gap:2px; }
2050
+ #${SHELL_ROOT_ID} .ge-gi-ctl-tx { display:flex; flex-direction:column; gap:2px; min-width:0; }
1937
2051
  #${SHELL_ROOT_ID} .ge-gi-ctl-tx b { color:#fff; font-size:15px; font-weight:700; }
1938
2052
  #${SHELL_ROOT_ID} .ge-gi-ctl-tx span { color:rgba(255,255,255,.7); font-size:13px; line-height:1.4; }
1939
2053
 
@@ -2220,6 +2334,14 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2220
2334
  #${SHELL_ROOT_ID}.ge-mobile .ge-sheet-grid { grid-template-columns:repeat(var(--cols-m,var(--cols,3)),1fr); }
2221
2335
  /* the bet picker packs 6 chips/row → give its card room (autoplay & co. stay at the 28em default) */
2222
2336
  #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-modal-card { max-width:44em; }
2337
+ /* Bet chips carry variable-width currency (a wide currency makes labels grow). Reflow the columns
2338
+ to the widest label (--chip-min, computed in JS) so no chip is ever clipped, and cap the grid
2339
+ height so a long bet ladder scrolls instead of overflowing the card's clipped edge. */
2340
+ #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-sheet-grid,
2341
+ #${SHELL_ROOT_ID}.ge-mobile .ge-sheet[data-ge="bet-modal"] .ge-sheet-grid {
2342
+ grid-template-columns:repeat(auto-fill, minmax(min(100%, var(--chip-min, 6em)), 1fr));
2343
+ max-height:min(50vh, 28em); overflow-y:auto; overflow-x:hidden; }
2344
+ #${SHELL_ROOT_ID} .ge-sheet[data-ge="bet-modal"] .ge-chip { min-width:0; }
2223
2345
  #${SHELL_ROOT_ID} .ge-chip { pointer-events:auto; cursor:pointer; border:1px solid var(--shell-plaque-line);
2224
2346
  border-radius:.8em; background:rgba(255,255,255,.04); color:#fff; font-size:1em; font-weight:700;
2225
2347
  font-variant-numeric:tabular-nums; padding:.8em .55em; transition:background .12s ease, border-color .12s ease; }
@@ -2364,8 +2486,10 @@ function iconBtn(ge, name, onClick, active = false) {
2364
2486
  b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
2365
2487
  b.dataset.ge = ge;
2366
2488
  b.innerHTML = icon(name);
2367
- b.addEventListener('click', () => { if (!b.disabled)
2368
- onClick(); });
2489
+ b.addEventListener('click', () => {
2490
+ if (!b.disabled)
2491
+ onClick();
2492
+ });
2369
2493
  return b;
2370
2494
  }
2371
2495
  function renderBottomBar(host) {
@@ -2381,15 +2505,13 @@ function renderBottomBar(host) {
2381
2505
  // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
2382
2506
  // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
2383
2507
  const isBase = state.mode === 'base';
2384
- const isFS = state.mode === 'freeSpins';
2508
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
2385
2509
  // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
2386
2510
  // only when it's a free-spins replay (freeSpins.total > 0).
2387
2511
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
2388
2512
  // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
2389
2513
  // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
2390
- const balance = state.replay
2391
- ? null
2392
- : readout('balance', host.t('Balance'), fmt(state.balance));
2514
+ const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
2393
2515
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
2394
2516
  // the feature accent; the base state.bet is unchanged and returns once the feature is off.
2395
2517
  const feature = state.activeFeature;
@@ -2412,21 +2534,25 @@ function renderBottomBar(host) {
2412
2534
  betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
2413
2535
  betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
2414
2536
  betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
2415
- betValue.addEventListener('click', () => { if (!betLocked(host))
2416
- host.actions.openBetPicker(); });
2537
+ betValue.addEventListener('click', () => {
2538
+ if (!betLocked(host))
2539
+ host.actions.openBetPicker();
2540
+ });
2417
2541
  spin = spinButton(host);
2418
2542
  auto = config.features.autoplay ? autoButton(host) : null;
2419
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
2543
+ buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
2420
2544
  }
2421
2545
  const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
2422
2546
  // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
2423
2547
  // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
2424
2548
  const fs = state.freeSpins;
2425
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
2549
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
2426
2550
  // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
2427
2551
  // style as the SPIN disc); Total Win stays an inline readout.
2428
- const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
2429
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
2552
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
2553
+ const fsTotalWin = showFsBlocks
2554
+ ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
2555
+ : null;
2430
2556
  // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
2431
2557
  const hero = isBase ? spin : fsHero;
2432
2558
  if (mobile) {
@@ -2469,13 +2595,13 @@ function renderBottomBar(host) {
2469
2595
  }
2470
2596
  /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
2471
2597
  * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
2472
- function fsHeroPlaque(host, text) {
2598
+ function fsHeroPlaque(host, text, label) {
2473
2599
  const el = document.createElement('div');
2474
2600
  el.className = 'ge-fs-hero';
2475
2601
  el.dataset.ge = 'fs-counter';
2476
2602
  const lbl = document.createElement('span');
2477
2603
  lbl.className = 'ge-fs-lbl';
2478
- lbl.textContent = host.t('Free spins');
2604
+ lbl.textContent = host.t(label ?? 'Free spins');
2479
2605
  const num = document.createElement('span');
2480
2606
  num.className = 'ge-fs-num';
2481
2607
  num.textContent = text;
@@ -2495,7 +2621,9 @@ function plaque(cls, children) {
2495
2621
  d.append(...children);
2496
2622
  return d;
2497
2623
  }
2498
- function compact(items) { return items.filter((x) => x !== null); }
2624
+ function compact(items) {
2625
+ return items.filter((x) => x !== null);
2626
+ }
2499
2627
  function buyBtn(host) {
2500
2628
  const buy = document.createElement('button');
2501
2629
  buy.className = 'ge-shell-buybonus';
@@ -2508,15 +2636,19 @@ function buyBtn(host) {
2508
2636
  buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
2509
2637
  buy.style.background = accent;
2510
2638
  buy.style.color = contrastText(accent);
2511
- buy.addEventListener('click', () => { if (!buy.disabled)
2512
- host.actions.deactivateFeature(); });
2639
+ buy.addEventListener('click', () => {
2640
+ if (!buy.disabled)
2641
+ host.actions.deactivateFeature();
2642
+ });
2513
2643
  }
2514
2644
  else {
2515
2645
  // Ticket icon (no text); keep the label for screen readers.
2516
2646
  buy.setAttribute('aria-label', host.t('BUY BONUS'));
2517
2647
  buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
2518
- buy.addEventListener('click', () => { if (!buy.disabled)
2519
- host.actions.openBuyBonus(); });
2648
+ buy.addEventListener('click', () => {
2649
+ if (!buy.disabled)
2650
+ host.actions.openBuyBonus();
2651
+ });
2520
2652
  }
2521
2653
  return buy;
2522
2654
  }
@@ -2534,15 +2666,19 @@ function spinButton(host) {
2534
2666
  const rem = state.autoplay.remaining;
2535
2667
  const label = Number.isFinite(rem) ? String(rem) : '∞';
2536
2668
  sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
2537
- sp.addEventListener('click', () => { if (!sp.disabled)
2538
- host.actions.stopAutoplay(); });
2669
+ sp.addEventListener('click', () => {
2670
+ if (!sp.disabled)
2671
+ host.actions.stopAutoplay();
2672
+ });
2539
2673
  }
2540
2674
  else {
2541
2675
  sp.innerHTML = icon('spin');
2542
2676
  if (state.busy)
2543
2677
  sp.classList.add('ge-spinning');
2544
- sp.addEventListener('click', () => { if (!sp.disabled)
2545
- host.actions.spin(); });
2678
+ sp.addEventListener('click', () => {
2679
+ if (!sp.disabled)
2680
+ host.actions.spin();
2681
+ });
2546
2682
  }
2547
2683
  return sp;
2548
2684
  }
@@ -3470,6 +3606,15 @@ function buildSheet(opts) {
3470
3606
  const cols = typeof opts.columns === 'number' ? { wide: opts.columns, mobile: opts.columns } : opts.columns;
3471
3607
  grid.style.setProperty('--cols', String(cols.wide));
3472
3608
  grid.style.setProperty('--cols-m', String(cols.mobile));
3609
+ if (opts.autoFit) {
3610
+ // Widest label in ch (tabular digits ≈ 1ch each; symbols/separators are narrower, so this
3611
+ // slightly over-estimates → safe) + chip padding/border. Floored so normal-width currencies
3612
+ // keep the compact ~6-per-row look and only wide currencies drop to fewer columns.
3613
+ const maxLen = opts.choices.reduce((m, c) => Math.max(m, c.label.length), 0);
3614
+ // Floor at 6em ≈ the natural width of a chip in the 6-wide layout (44em card): short labels
3615
+ // clamp here so a normal currency keeps 6 columns; wider labels raise it and drop to fewer.
3616
+ grid.style.setProperty('--chip-min', `max(6em, calc(${maxLen}ch + 1.4em))`);
3617
+ }
3473
3618
  let selected = opts.selected;
3474
3619
  let focusIndex = opts.choices.findIndex((c) => c.id === selected);
3475
3620
  if (focusIndex < 0)
@@ -3541,7 +3686,7 @@ function buildSheet(opts) {
3541
3686
  /** Bet picker — all available bets as chips (6 per row, 3 on mobile), accent Confirm applies it. */
3542
3687
  function openBetModal(host) {
3543
3688
  return buildSheet({
3544
- ge: 'bet-modal', title: host.t('Bet'), columns: { wide: 6, mobile: 3 }, confirmLabel: host.t('Confirm'),
3689
+ ge: 'bet-modal', title: host.t('Bet'), columns: { wide: 6, mobile: 3 }, autoFit: true, confirmLabel: host.t('Confirm'),
3545
3690
  choices: host.state.availableBets.map((b) => ({ id: String(b), label: host.formatCurrency(b) })),
3546
3691
  selected: String(host.state.bet),
3547
3692
  onClose: () => host.actions.closeOverlay(),