@energy8platform/shell 0.4.1 → 0.6.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,9 +65,19 @@ 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,
70
+ volumes: {
71
+ master: clampVolume(config.volumes?.master),
72
+ music: clampVolume(config.volumes?.music),
73
+ sfx: clampVolume(config.volumes?.sfx),
74
+ },
69
75
  };
70
76
  }
77
+ /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
78
+ function clampVolume(v) {
79
+ return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
80
+ }
71
81
  /** Step bet up/down within availableBets, clamped at the ends. */
72
82
  function stepBet(state, direction) {
73
83
  const idx = state.availableBets.indexOf(state.bet);
@@ -1438,7 +1448,7 @@ class KeyboardController {
1438
1448
 
1439
1449
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1440
1450
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1441
- const PACKAGE_VERSION = '0.4.1';
1451
+ const PACKAGE_VERSION = '0.6.0';
1442
1452
 
1443
1453
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1444
1454
  function resolveConfig(config) {
@@ -1455,6 +1465,7 @@ function resolveConfig(config) {
1455
1465
  features: config.features,
1456
1466
  theme: config.theme,
1457
1467
  onBonusBuy: config.onBonusBuy,
1468
+ volumes: config.volumes,
1458
1469
  version: config.version ?? '1.0.0',
1459
1470
  isSocial: config.isSocial ?? false,
1460
1471
  replay: config.replay ?? config.mode === 'replay',
@@ -1476,6 +1487,7 @@ class ShellController extends EventEmitter {
1476
1487
  kbd;
1477
1488
  overlay = null;
1478
1489
  soundRefresh = null;
1490
+ volumeRefresh = null;
1479
1491
  prevBalance;
1480
1492
  prevWin;
1481
1493
  destroyed = false;
@@ -1499,9 +1511,15 @@ class ShellController extends EventEmitter {
1499
1511
  this.renderer.renderBar();
1500
1512
  }
1501
1513
  // ── 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); }
1514
+ t(text) {
1515
+ return this.i18n.t(text);
1516
+ }
1517
+ formatCurrency(n, win = false) {
1518
+ return formatCurrency(n, this.config.currency, win);
1519
+ }
1520
+ formatWin(value) {
1521
+ return this.formatCurrency(value, true);
1522
+ }
1505
1523
  notifyResize(w, h) {
1506
1524
  const layout = w !== 0 && h > w ? 'mobile' : 'wide';
1507
1525
  if (layout !== this.layout) {
@@ -1568,12 +1586,24 @@ class ShellController extends EventEmitter {
1568
1586
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1569
1587
  const self = this;
1570
1588
  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; },
1589
+ get state() {
1590
+ return self.state;
1591
+ },
1592
+ get hotkeysEnabled() {
1593
+ return self.config.features.hotkeys !== false;
1594
+ },
1595
+ get spacebarEnabled() {
1596
+ return self.config.features.spacebar !== false;
1597
+ },
1598
+ get turboLevels() {
1599
+ return self.config.features.turbo;
1600
+ },
1601
+ get autoplayEnabled() {
1602
+ return self.config.features.autoplay != null;
1603
+ },
1604
+ get buyBonusEnabled() {
1605
+ return self.config.features.buyBonus !== false;
1606
+ },
1577
1607
  hasOpenLayer: () => self.overlay !== null,
1578
1608
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
1579
1609
  spin: () => self.actions.spin(),
@@ -1589,33 +1619,59 @@ class ShellController extends EventEmitter {
1589
1619
  this.kbd = new KeyboardController(host);
1590
1620
  this.kbd.attach();
1591
1621
  }
1592
- pullFocus = () => { try {
1593
- globalThis.focus?.();
1594
- }
1595
- catch { /* cross-origin */ } };
1622
+ pullFocus = () => {
1623
+ try {
1624
+ globalThis.focus?.();
1625
+ }
1626
+ catch {
1627
+ /* cross-origin */
1628
+ }
1629
+ };
1596
1630
  // ── overlay flow ─────────────────────────────────────────────────────────────
1597
1631
  show(req) {
1598
1632
  this.closeModal();
1599
1633
  this.overlay = this.renderer.openOverlay(req) ?? null;
1600
1634
  }
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 }); }
1635
+ openMenu() {
1636
+ this.emit('menuOpen');
1637
+ this.openSettings();
1638
+ }
1639
+ openSettings() {
1640
+ this.emit('settingsOpen');
1641
+ this.show({ kind: 'settings' });
1642
+ }
1643
+ openInfo() {
1644
+ this.emit('infoOpen');
1645
+ this.show({ kind: 'gameInfo' });
1646
+ }
1647
+ openBuyBonus() {
1648
+ if (this.config.onBonusBuy) {
1649
+ this.config.onBonusBuy();
1650
+ return;
1651
+ }
1652
+ this.show({ kind: 'buyBonus' });
1653
+ }
1654
+ openBetPicker() {
1655
+ this.show({ kind: 'betPicker' });
1656
+ }
1657
+ openAutoplayPicker() {
1658
+ this.show({ kind: 'autoplayPicker' });
1659
+ }
1660
+ openReplay(opts) {
1661
+ if (this.destroyed)
1662
+ return;
1663
+ this.show({ kind: 'replay', opts });
1664
+ }
1665
+ openModal(opts) {
1666
+ this.show({ kind: 'modal', opts });
1667
+ }
1613
1668
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
1614
1669
  closeModal() {
1615
1670
  if (!this.overlay)
1616
1671
  return;
1617
1672
  this.overlay = null;
1618
1673
  this.soundRefresh = null;
1674
+ this.volumeRefresh = null;
1619
1675
  this.renderer.closeOverlay();
1620
1676
  }
1621
1677
  // ── sound ──────────────────────────────────────────────────────────────────
@@ -1625,7 +1681,25 @@ class ShellController extends EventEmitter {
1625
1681
  this.soundRefresh?.(on);
1626
1682
  this.renderer.refreshSoundIcon?.(on);
1627
1683
  }
1628
- setSoundRefresh(fn) { this.soundRefresh = fn; }
1684
+ setSoundRefresh(fn) {
1685
+ this.soundRefresh = fn;
1686
+ }
1687
+ // ── volume ─────────────────────────────────────────────────────────────────
1688
+ getVolume(key) {
1689
+ return this.state.volumes[key];
1690
+ }
1691
+ /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1692
+ * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1693
+ * live-updates the slider if the overlay is currently open. */
1694
+ setVolume(key, value) {
1695
+ const v = Math.max(0, Math.min(1, value));
1696
+ this.state.volumes[key] = v;
1697
+ this.emit('settingChange', { key, value: v });
1698
+ this.volumeRefresh?.(key, v);
1699
+ }
1700
+ setVolumeRefresh(fn) {
1701
+ this.volumeRefresh = fn;
1702
+ }
1629
1703
  // ── features ─────────────────────────────────────────────────────────────────
1630
1704
  activateFeature(bonus) {
1631
1705
  this.state.activeFeature = bonus;
@@ -1646,21 +1720,81 @@ class ShellController extends EventEmitter {
1646
1720
  if (to !== from)
1647
1721
  this.renderer.animateMoney(field, from, to);
1648
1722
  }
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(); }
1723
+ setBalance(n) {
1724
+ const from = this.prevBalance;
1725
+ this.state.balance = n;
1726
+ this.prevBalance = n;
1727
+ this.money('balance', from, n);
1728
+ }
1729
+ setWin(n) {
1730
+ const from = this.prevWin;
1731
+ this.state.win = n;
1732
+ this.prevWin = n;
1733
+ this.money('win', from, n);
1734
+ }
1735
+ setBet(n) {
1736
+ this.state.bet = n;
1737
+ this.renderer.renderBar();
1738
+ }
1739
+ setMode(mode) {
1740
+ if (mode === 'replay')
1741
+ this.state.replay = true;
1742
+ this.state.mode = mode;
1743
+ this.renderer.renderBar();
1744
+ }
1745
+ setBusy(busy) {
1746
+ this.state.busy = busy;
1747
+ this.renderer.renderBar();
1748
+ this.kbd?.notifyBusyChanged(busy);
1749
+ }
1750
+ setAutoplay(a) {
1751
+ this.state.autoplay = a;
1752
+ this.renderer.renderBar();
1753
+ }
1754
+ setTurbo(level) {
1755
+ this.state.turbo = level;
1756
+ this.renderer.renderBar();
1757
+ }
1758
+ setBuyBonusEnabled(enabled) {
1759
+ this.state.buyBonusEnabled = enabled;
1760
+ this.renderer.renderBar();
1761
+ }
1762
+ setFreeSpins(fs) {
1763
+ this.state.freeSpins = fs;
1764
+ this.state.bonus = null;
1765
+ this.renderer.renderBar();
1766
+ }
1767
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
1768
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
1769
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
1770
+ setBonus(b) {
1771
+ this.state.bonus = { label: b.label, value: b.value };
1772
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
1773
+ this.renderer.renderBar();
1774
+ }
1775
+ setTheme(theme) {
1776
+ this.config.theme = theme;
1777
+ this.tokens = resolveTheme(theme);
1778
+ this.renderer.applyTheme(this.tokens);
1779
+ this.renderer.renderBar();
1780
+ }
1781
+ setLanguage(lang) {
1782
+ this.config.language = lang;
1783
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
1784
+ this.renderer.renderBar();
1785
+ }
1786
+ setSocial(isSocial) {
1787
+ this.config.isSocial = isSocial;
1788
+ this.i18n = createI18n({ language: this.config.language, isSocial });
1789
+ this.renderer.renderBar();
1790
+ }
1791
+ setLayout(layout) {
1792
+ if (layout === this.layout)
1793
+ return;
1794
+ this.layout = layout;
1795
+ this.renderer.setLayout(layout);
1796
+ this.renderer.renderBar();
1797
+ }
1664
1798
  destroy() {
1665
1799
  if (this.destroyed)
1666
1800
  return Promise.resolve();
@@ -2382,8 +2516,10 @@ function iconBtn(ge, name, onClick, active = false) {
2382
2516
  b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
2383
2517
  b.dataset.ge = ge;
2384
2518
  b.innerHTML = icon(name);
2385
- b.addEventListener('click', () => { if (!b.disabled)
2386
- onClick(); });
2519
+ b.addEventListener('click', () => {
2520
+ if (!b.disabled)
2521
+ onClick();
2522
+ });
2387
2523
  return b;
2388
2524
  }
2389
2525
  function renderBottomBar(host) {
@@ -2399,15 +2535,13 @@ function renderBottomBar(host) {
2399
2535
  // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
2400
2536
  // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
2401
2537
  const isBase = state.mode === 'base';
2402
- const isFS = state.mode === 'freeSpins';
2538
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
2403
2539
  // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
2404
2540
  // only when it's a free-spins replay (freeSpins.total > 0).
2405
2541
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
2406
2542
  // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
2407
2543
  // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
2408
- const balance = state.replay
2409
- ? null
2410
- : readout('balance', host.t('Balance'), fmt(state.balance));
2544
+ const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
2411
2545
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
2412
2546
  // the feature accent; the base state.bet is unchanged and returns once the feature is off.
2413
2547
  const feature = state.activeFeature;
@@ -2430,21 +2564,25 @@ function renderBottomBar(host) {
2430
2564
  betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
2431
2565
  betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
2432
2566
  betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
2433
- betValue.addEventListener('click', () => { if (!betLocked(host))
2434
- host.actions.openBetPicker(); });
2567
+ betValue.addEventListener('click', () => {
2568
+ if (!betLocked(host))
2569
+ host.actions.openBetPicker();
2570
+ });
2435
2571
  spin = spinButton(host);
2436
2572
  auto = config.features.autoplay ? autoButton(host) : null;
2437
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
2573
+ buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
2438
2574
  }
2439
2575
  const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
2440
2576
  // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
2441
2577
  // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
2442
2578
  const fs = state.freeSpins;
2443
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
2579
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
2444
2580
  // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
2445
2581
  // style as the SPIN disc); Total Win stays an inline readout.
2446
- const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
2447
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
2582
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
2583
+ const fsTotalWin = showFsBlocks
2584
+ ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
2585
+ : null;
2448
2586
  // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
2449
2587
  const hero = isBase ? spin : fsHero;
2450
2588
  if (mobile) {
@@ -2487,13 +2625,13 @@ function renderBottomBar(host) {
2487
2625
  }
2488
2626
  /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
2489
2627
  * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
2490
- function fsHeroPlaque(host, text) {
2628
+ function fsHeroPlaque(host, text, label) {
2491
2629
  const el = document.createElement('div');
2492
2630
  el.className = 'ge-fs-hero';
2493
2631
  el.dataset.ge = 'fs-counter';
2494
2632
  const lbl = document.createElement('span');
2495
2633
  lbl.className = 'ge-fs-lbl';
2496
- lbl.textContent = host.t('Free spins');
2634
+ lbl.textContent = host.t(label ?? 'Free spins');
2497
2635
  const num = document.createElement('span');
2498
2636
  num.className = 'ge-fs-num';
2499
2637
  num.textContent = text;
@@ -2513,7 +2651,9 @@ function plaque(cls, children) {
2513
2651
  d.append(...children);
2514
2652
  return d;
2515
2653
  }
2516
- function compact(items) { return items.filter((x) => x !== null); }
2654
+ function compact(items) {
2655
+ return items.filter((x) => x !== null);
2656
+ }
2517
2657
  function buyBtn(host) {
2518
2658
  const buy = document.createElement('button');
2519
2659
  buy.className = 'ge-shell-buybonus';
@@ -2526,15 +2666,19 @@ function buyBtn(host) {
2526
2666
  buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
2527
2667
  buy.style.background = accent;
2528
2668
  buy.style.color = contrastText(accent);
2529
- buy.addEventListener('click', () => { if (!buy.disabled)
2530
- host.actions.deactivateFeature(); });
2669
+ buy.addEventListener('click', () => {
2670
+ if (!buy.disabled)
2671
+ host.actions.deactivateFeature();
2672
+ });
2531
2673
  }
2532
2674
  else {
2533
2675
  // Ticket icon (no text); keep the label for screen readers.
2534
2676
  buy.setAttribute('aria-label', host.t('BUY BONUS'));
2535
2677
  buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
2536
- buy.addEventListener('click', () => { if (!buy.disabled)
2537
- host.actions.openBuyBonus(); });
2678
+ buy.addEventListener('click', () => {
2679
+ if (!buy.disabled)
2680
+ host.actions.openBuyBonus();
2681
+ });
2538
2682
  }
2539
2683
  return buy;
2540
2684
  }
@@ -2552,15 +2696,19 @@ function spinButton(host) {
2552
2696
  const rem = state.autoplay.remaining;
2553
2697
  const label = Number.isFinite(rem) ? String(rem) : '∞';
2554
2698
  sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
2555
- sp.addEventListener('click', () => { if (!sp.disabled)
2556
- host.actions.stopAutoplay(); });
2699
+ sp.addEventListener('click', () => {
2700
+ if (!sp.disabled)
2701
+ host.actions.stopAutoplay();
2702
+ });
2557
2703
  }
2558
2704
  else {
2559
2705
  sp.innerHTML = icon('spin');
2560
2706
  if (state.busy)
2561
2707
  sp.classList.add('ge-spinning');
2562
- sp.addEventListener('click', () => { if (!sp.disabled)
2563
- host.actions.spin(); });
2708
+ sp.addEventListener('click', () => {
2709
+ if (!sp.disabled)
2710
+ host.actions.spin();
2711
+ });
2564
2712
  }
2565
2713
  return sp;
2566
2714
  }
@@ -2709,7 +2857,10 @@ function openSettingsModal(host) {
2709
2857
  return row;
2710
2858
  })();
2711
2859
  body.appendChild(sound);
2712
- // Volume sliders — full-width column rows with a live value readout
2860
+ // Volume sliders — full-width column rows with a live value readout. Positions are read from the
2861
+ // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
2862
+ // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
2863
+ const updaters = {};
2713
2864
  const slider = (key, label) => {
2714
2865
  const row = document.createElement('div');
2715
2866
  row.className = 'ge-ov-row ge-col';
@@ -2717,7 +2868,6 @@ function openSettingsModal(host) {
2717
2868
  head.className = 'ge-row-head';
2718
2869
  const val = document.createElement('span');
2719
2870
  val.className = 'ge-val';
2720
- val.textContent = '100%';
2721
2871
  head.innerHTML = `<span>${label}</span>`;
2722
2872
  head.appendChild(val);
2723
2873
  const input = document.createElement('input');
@@ -2725,19 +2875,23 @@ function openSettingsModal(host) {
2725
2875
  input.min = '0';
2726
2876
  input.max = '1';
2727
2877
  input.step = '0.05';
2728
- input.value = '1';
2729
2878
  input.className = 'ge-slider';
2730
2879
  input.dataset.ge = `setting-${key}`;
2880
+ const paint = (v) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
2881
+ paint(host.getVolume(key));
2731
2882
  input.addEventListener('input', () => {
2732
2883
  val.textContent = `${Math.round(Number(input.value) * 100)}%`;
2733
- host.emit('settingChange', { key, value: Number(input.value) });
2884
+ host.setVolume(key, Number(input.value));
2734
2885
  });
2886
+ updaters[key] = paint;
2735
2887
  row.append(head, input);
2736
2888
  return row;
2737
2889
  };
2738
2890
  body.appendChild(slider('master', host.t('Master volume')));
2739
2891
  body.appendChild(slider('music', host.t('Music')));
2740
2892
  body.appendChild(slider('sfx', host.t('SFX')));
2893
+ // Live-update sliders when volume changes via host.setVolume (shell clears on close).
2894
+ host.setVolumeRefresh((key, v) => updaters[key]?.(v));
2741
2895
  // Game info — full-width row button that opens its own overlay
2742
2896
  const gameInfo = document.createElement('button');
2743
2897
  gameInfo.className = 'ge-ov-row';