@energy8platform/shell 0.6.5 → 0.7.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.
Files changed (43) hide show
  1. package/dist/html.cjs.js +679 -113
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +213 -31
  4. package/dist/html.esm.js +672 -114
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +376 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +204 -29
  9. package/dist/index.esm.js +369 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +957 -285
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +208 -32
  14. package/dist/pixi.esm.js +950 -286
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +75 -21
  18. package/src/core/icon-names.ts +30 -0
  19. package/src/core/index.ts +4 -0
  20. package/src/core/menu.ts +301 -0
  21. package/src/core/popover.ts +81 -0
  22. package/src/core/renderer.ts +17 -12
  23. package/src/core/state.ts +2 -1
  24. package/src/core/types.ts +12 -6
  25. package/src/core/version.ts +1 -1
  26. package/src/ui/html/HtmlRenderer.ts +35 -12
  27. package/src/ui/html/components/GameInfo.ts +1 -1
  28. package/src/ui/html/components/Menu.ts +153 -0
  29. package/src/ui/html/icons.ts +16 -15
  30. package/src/ui/html/primitives.ts +142 -0
  31. package/src/ui/html/shell.css.ts +21 -1
  32. package/src/ui/pixi/PixiRenderer.ts +41 -21
  33. package/src/ui/pixi/components/BottomBar.ts +80 -9
  34. package/src/ui/pixi/components/GameInfo.ts +1 -1
  35. package/src/ui/pixi/components/Menu.ts +167 -0
  36. package/src/ui/pixi/context.ts +3 -2
  37. package/src/ui/pixi/icons.ts +16 -15
  38. package/src/ui/pixi/pixi-icon.ts +15 -3
  39. package/src/ui/pixi/primitives/controls.ts +46 -0
  40. package/src/ui/pixi/primitives/popover.ts +163 -0
  41. package/src/ui/pixi/primitives/scroll.ts +9 -5
  42. package/src/ui/html/components/Settings.ts +0 -68
  43. package/src/ui/pixi/components/Settings.ts +0 -132
package/dist/html.esm.js CHANGED
@@ -50,6 +50,240 @@ class EventEmitter {
50
50
  }
51
51
  }
52
52
 
53
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
54
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
55
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
56
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
57
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
58
+ // `node scripts/gen-icons-from-svg.mjs`.
59
+ // The single glyph-name union, shared by core (menu items) and both renderers.
60
+ const ICON_NAMES = [
61
+ 'spin',
62
+ 'turbo1',
63
+ 'autoplay',
64
+ 'stop',
65
+ 'menu',
66
+ 'minus',
67
+ 'plus',
68
+ 'gift',
69
+ 'info',
70
+ 'soundOn',
71
+ 'soundOff',
72
+ 'close',
73
+ 'back',
74
+ 'chevronRight',
75
+ 'ticket',
76
+ 'turbo2',
77
+ 'turboOff',
78
+ 'chevronUp',
79
+ 'chevronDown',
80
+ ];
81
+
82
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
83
+ function isPresetId(id) {
84
+ return PRESET_IDS.includes(id);
85
+ }
86
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
87
+ const DEFAULT_MENU = [
88
+ { id: 'sound' },
89
+ { id: 'music' },
90
+ { id: 'sfx' },
91
+ { type: 'separator' },
92
+ { id: 'gameInfo' },
93
+ ];
94
+ const isSeparator = (i) => i.type === 'separator';
95
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
96
+ function rangeBounds(item) {
97
+ const min = item.min ?? 0;
98
+ const max = item.max ?? 1;
99
+ const derivedStep = (max - min) / 20;
100
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
101
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
102
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
103
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
104
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
105
+ return { min, max, step };
106
+ }
107
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
108
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
109
+ function seedMenuValues(items, prev = {}) {
110
+ const out = {};
111
+ for (const item of items) {
112
+ if (isSeparator(item))
113
+ continue;
114
+ const type = item.type;
115
+ if (!type || isPresetId(item.id))
116
+ continue;
117
+ // A previous value only carries over when its runtime type still matches this item's kind.
118
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
119
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
120
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
121
+ // of warning.
122
+ const prevValue = prev[item.id];
123
+ if (type === 'toggle') {
124
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
125
+ }
126
+ else if (type === 'range') {
127
+ const r = item;
128
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+ const percent = (v) => `${Math.round(v * 100)}%`;
134
+ function safeIcon(name) {
135
+ return name && ICON_NAMES.includes(name) ? name : undefined;
136
+ }
137
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
138
+ function warn(message) {
139
+ console.warn(`[shell] ${message}`);
140
+ }
141
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
142
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
143
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
144
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
145
+ function resolveMenu(host) {
146
+ const rows = [];
147
+ for (const item of host.menu) {
148
+ if (isSeparator(item)) {
149
+ rows.push({ kind: 'separator' });
150
+ continue;
151
+ }
152
+ const type = item.type;
153
+ if (!type) {
154
+ const row = preset(host, item);
155
+ if (row)
156
+ rows.push(row);
157
+ else
158
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
159
+ continue;
160
+ }
161
+ const row = custom(host, item, type);
162
+ if (row)
163
+ rows.push(row);
164
+ }
165
+ return rows;
166
+ }
167
+ function preset(host, item) {
168
+ const disabled = item.disabled ?? false;
169
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
170
+ switch (item.id) {
171
+ case 'sound':
172
+ return {
173
+ kind: 'toggle',
174
+ id: 'sound',
175
+ label,
176
+ disabled,
177
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
178
+ get: () => host.getMenuValue('sound') !== false,
179
+ set: (v) => host.setMenuValue('sound', v),
180
+ };
181
+ case 'music':
182
+ case 'sfx': {
183
+ const id = item.id;
184
+ return {
185
+ kind: 'range',
186
+ id,
187
+ label,
188
+ icon: safeIcon(item.icon),
189
+ disabled,
190
+ min: 0,
191
+ max: 1,
192
+ step: 0.05,
193
+ get: () => Number(host.getMenuValue(id) ?? 1),
194
+ set: (v) => host.setMenuValue(id, v),
195
+ format: percent,
196
+ };
197
+ }
198
+ case 'gameInfo':
199
+ return {
200
+ kind: 'button',
201
+ id: 'gameInfo',
202
+ label,
203
+ icon: safeIcon(item.icon) ?? 'info',
204
+ disabled,
205
+ chevron: true,
206
+ select: () => host.actions.openInfo(),
207
+ };
208
+ default:
209
+ return null;
210
+ }
211
+ }
212
+ const DEFAULT_LABELS = {
213
+ sound: 'Sound',
214
+ music: 'Music',
215
+ sfx: 'SFX',
216
+ gameInfo: 'Game info',
217
+ };
218
+ function custom(host, item, type) {
219
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
220
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
221
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
222
+ if (isPresetId(item.id)) {
223
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
224
+ }
225
+ const disabled = item.disabled ?? false;
226
+ const label = host.t(item.label ?? item.id);
227
+ const icon = safeIcon(item.icon);
228
+ if (type === 'toggle') {
229
+ const it = item;
230
+ return {
231
+ kind: 'toggle',
232
+ id: it.id,
233
+ label,
234
+ disabled,
235
+ icon: () => icon,
236
+ get: () => host.getMenuValue(it.id) === true,
237
+ set: (v) => {
238
+ host.setMenuValue(it.id, v);
239
+ it.onChange?.(v);
240
+ },
241
+ };
242
+ }
243
+ if (type === 'range') {
244
+ const it = item;
245
+ const { min, max, step } = rangeBounds(it);
246
+ if (max <= min) {
247
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
248
+ // drop the row instead, exactly like an unknown preset id.
249
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
250
+ return null;
251
+ }
252
+ return {
253
+ kind: 'range',
254
+ id: it.id,
255
+ label,
256
+ icon,
257
+ disabled,
258
+ min,
259
+ max,
260
+ step,
261
+ get: () => Number(host.getMenuValue(it.id) ?? min),
262
+ set: (v) => {
263
+ host.setMenuValue(it.id, v);
264
+ it.onChange?.(v);
265
+ },
266
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
267
+ };
268
+ }
269
+ if (type === 'button') {
270
+ const it = item;
271
+ return {
272
+ kind: 'button',
273
+ id: it.id,
274
+ label,
275
+ icon,
276
+ disabled,
277
+ chevron: it.chevron ?? false,
278
+ select: () => it.onSelect?.(),
279
+ };
280
+ }
281
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
282
+ // button row with no onSelect. Drop it visibly instead.
283
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
284
+ return null;
285
+ }
286
+
53
287
  function createInitialState(config) {
54
288
  return {
55
289
  mode: config.mode,
@@ -66,10 +300,10 @@ function createInitialState(config) {
66
300
  bonus: null,
67
301
  activeFeature: null,
68
302
  volumes: {
69
- master: clampVolume(config.volumes?.master),
70
303
  music: clampVolume(config.volumes?.music),
71
304
  sfx: clampVolume(config.volumes?.sfx),
72
305
  },
306
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
73
307
  };
74
308
  }
75
309
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -1453,7 +1687,7 @@ class KeyboardController {
1453
1687
 
1454
1688
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1455
1689
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1456
- const PACKAGE_VERSION = '0.6.5';
1690
+ const PACKAGE_VERSION = '0.7.0';
1457
1691
 
1458
1692
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1459
1693
  function resolveConfig(config) {
@@ -1471,6 +1705,7 @@ function resolveConfig(config) {
1471
1705
  theme: config.theme,
1472
1706
  onBonusBuy: config.onBonusBuy,
1473
1707
  volumes: config.volumes,
1708
+ menu: config.menu,
1474
1709
  version: config.version ?? '1.0.0',
1475
1710
  isSocial: config.isSocial ?? false,
1476
1711
  replay: config.replay ?? config.mode === 'replay',
@@ -1491,8 +1726,9 @@ class ShellController extends EventEmitter {
1491
1726
  i18n;
1492
1727
  kbd;
1493
1728
  overlay = null;
1494
- soundRefresh = null;
1495
- volumeRefresh = null;
1729
+ menuItems;
1730
+ menuRefresh = null;
1731
+ overlayKind = null;
1496
1732
  prevBalance;
1497
1733
  prevWin;
1498
1734
  destroyed = false;
@@ -1503,6 +1739,7 @@ class ShellController extends EventEmitter {
1503
1739
  this.config = resolveConfig(config);
1504
1740
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1505
1741
  this.state = createInitialState(this.config);
1742
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1506
1743
  this.tokens = resolveTheme(this.config.theme);
1507
1744
  this.prevBalance = this.state.balance;
1508
1745
  this.prevWin = this.state.win;
@@ -1636,14 +1873,21 @@ class ShellController extends EventEmitter {
1636
1873
  show(req) {
1637
1874
  this.closeModal();
1638
1875
  this.overlay = this.renderer.openOverlay(req) ?? null;
1876
+ this.overlayKind = this.overlay ? req.kind : null;
1639
1877
  }
1878
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1640
1879
  openMenu() {
1880
+ if (this.overlayKind === 'menu') {
1881
+ this.closeModal();
1882
+ return;
1883
+ }
1641
1884
  this.emit('menuOpen');
1642
- this.openSettings();
1885
+ this.show({ kind: 'menu' });
1643
1886
  }
1887
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1644
1888
  openSettings() {
1645
1889
  this.emit('settingsOpen');
1646
- this.show({ kind: 'settings' });
1890
+ this.openMenu();
1647
1891
  }
1648
1892
  openInfo() {
1649
1893
  this.emit('infoOpen');
@@ -1675,35 +1919,72 @@ class ShellController extends EventEmitter {
1675
1919
  if (!this.overlay)
1676
1920
  return;
1677
1921
  this.overlay = null;
1678
- this.soundRefresh = null;
1679
- this.volumeRefresh = null;
1922
+ this.overlayKind = null;
1923
+ this.menuRefresh = null;
1680
1924
  this.renderer.closeOverlay();
1681
1925
  }
1682
1926
  // ── sound ──────────────────────────────────────────────────────────────────
1683
1927
  setSound(on) {
1684
1928
  this.soundOn = on;
1685
1929
  this.emit('settingChange', { key: 'sound', value: on });
1686
- this.soundRefresh?.(on);
1687
- this.renderer.refreshSoundIcon?.(on);
1688
- }
1689
- setSoundRefresh(fn) {
1690
- this.soundRefresh = fn;
1930
+ this.menuRefresh?.('sound', on);
1691
1931
  }
1692
1932
  // ── volume ─────────────────────────────────────────────────────────────────
1693
1933
  getVolume(key) {
1694
1934
  return this.state.volumes[key];
1695
1935
  }
1696
1936
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1697
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1698
- * live-updates the slider if the overlay is currently open. */
1937
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1938
+ * live-updates the slider if the menu is currently open. */
1699
1939
  setVolume(key, value) {
1700
1940
  const v = Math.max(0, Math.min(1, value));
1701
1941
  this.state.volumes[key] = v;
1702
1942
  this.emit('settingChange', { key, value: v });
1703
- this.volumeRefresh?.(key, v);
1943
+ this.menuRefresh?.(key, v);
1944
+ }
1945
+ // ── menu ───────────────────────────────────────────────────────────────────
1946
+ get menu() {
1947
+ return this.menuItems;
1948
+ }
1949
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1950
+ setMenu(items) {
1951
+ this.menuItems = items;
1952
+ this.state.menu = seedMenuValues(items, this.state.menu);
1953
+ if (this.overlayKind === 'menu')
1954
+ this.show({ kind: 'menu' });
1955
+ }
1956
+ getMenuValue(id) {
1957
+ if (id === 'sound')
1958
+ return this.soundOn;
1959
+ if (id === 'music' || id === 'sfx')
1960
+ return this.state.volumes[id];
1961
+ return this.state.menu[id];
1962
+ }
1963
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1964
+ setMenuValue(id, value) {
1965
+ if (id === 'sound') {
1966
+ this.setSound(value !== false);
1967
+ return;
1968
+ }
1969
+ if (id === 'music' || id === 'sfx') {
1970
+ this.setVolume(id, Number(value));
1971
+ return;
1972
+ }
1973
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1974
+ this.state.menu[id] = next;
1975
+ this.emit('settingChange', { key: id, value: next });
1976
+ this.menuRefresh?.(id, next);
1977
+ }
1978
+ setMenuRefresh(fn) {
1979
+ this.menuRefresh = fn;
1704
1980
  }
1705
- setVolumeRefresh(fn) {
1706
- this.volumeRefresh = fn;
1981
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1982
+ clampRange(id, value) {
1983
+ const item = this.menuItems.find((i) => i.id === id);
1984
+ if (!item || item.type !== 'range')
1985
+ return value;
1986
+ const { min, max } = rangeBounds(item);
1987
+ return Math.max(min, Math.min(max, value));
1707
1988
  }
1708
1989
  // ── features ─────────────────────────────────────────────────────────────────
1709
1990
  activateFeature(bonus) {
@@ -1720,10 +2001,10 @@ class ShellController extends EventEmitter {
1720
2001
  this.renderer.renderBar();
1721
2002
  }
1722
2003
  // ── game-facing public API (mirrors GameShell/PixiGameShell) ───────────────────
1723
- money(field, from, to) {
2004
+ money(field, from, to, durationMs) {
1724
2005
  this.renderer.renderBar();
1725
2006
  if (to !== from)
1726
- this.renderer.animateMoney(field, from, to);
2007
+ this.renderer.animateMoney(field, from, to, durationMs);
1727
2008
  }
1728
2009
  setBalance(n) {
1729
2010
  const from = this.prevBalance;
@@ -1733,7 +2014,9 @@ class ShellController extends EventEmitter {
1733
2014
  }
1734
2015
  /** Set the WIN readout. Counts up/down from the previous value by default. Pass
1735
2016
  * `{ animate: false }` to SNAP instantly (renderBar cancels any in-flight count-up) — used by the
1736
- * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong. */
2017
+ * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong.
2018
+ * `{ durationMs }` shortens/lengthens the count-up (default 450ms) — a scene reporting a win per
2019
+ * cascade step passes its step length so each count-up finishes before the next step lands. */
1737
2020
  setWin(n, opts) {
1738
2021
  const from = this.prevWin;
1739
2022
  this.state.win = n;
@@ -1742,7 +2025,7 @@ class ShellController extends EventEmitter {
1742
2025
  this.renderer.renderBar(); // instant repaint from state; cancels running money anims
1743
2026
  return;
1744
2027
  }
1745
- this.money('win', from, n);
2028
+ this.money('win', from, n, opts?.durationMs);
1746
2029
  }
1747
2030
  setBet(n) {
1748
2031
  this.state.bet = n;
@@ -1811,6 +2094,11 @@ class ShellController extends EventEmitter {
1811
2094
  if (this.destroyed)
1812
2095
  return Promise.resolve();
1813
2096
  this.destroyed = true;
2097
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2098
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2099
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2100
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2101
+ this.closeModal();
1814
2102
  if (typeof document !== 'undefined') {
1815
2103
  this.kbd?.detach();
1816
2104
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1820,6 +2108,64 @@ class ShellController extends EventEmitter {
1820
2108
  }
1821
2109
  }
1822
2110
 
2111
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2112
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2113
+ const POPOVER = {
2114
+ /** Keep-out from the surface edges. */
2115
+ margin: 8,
2116
+ /** Space between the anchor and the card. */
2117
+ gap: 8,
2118
+ /** Minimum distance from the arrow tip to either rounded corner. */
2119
+ arrowInset: 14,
2120
+ /** A card shorter than this does not fit — flip to the other side instead. */
2121
+ minH: 120,
2122
+ minW: 220,
2123
+ maxW: 320,
2124
+ };
2125
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2126
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2127
+ function popoverWidth(surfaceW, contentW) {
2128
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2129
+ return Math.max(0, clamp(contentW, Math.min(POPOVER.minW, hi), hi));
2130
+ }
2131
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2132
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2133
+ *
2134
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2135
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2136
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2137
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2138
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2139
+ function placePopover(anchor, surface, size, pointer = null) {
2140
+ const { margin, gap, arrowInset, minH } = POPOVER;
2141
+ if (!anchor) {
2142
+ const maxH = Math.max(0, surface.h - margin * 2);
2143
+ const h = Math.min(size.h, maxH);
2144
+ const rawY = (surface.h - h) / 2;
2145
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2146
+ return {
2147
+ x: Math.max(margin, (surface.w - size.w) / 2),
2148
+ y,
2149
+ maxH,
2150
+ arrowX: -1,
2151
+ below: false,
2152
+ };
2153
+ }
2154
+ const spaceAbove = anchor.y - gap - margin;
2155
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2156
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2157
+ // genuinely more room on the other side.
2158
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2159
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2160
+ const h = Math.min(size.h, maxH);
2161
+ const x = clamp(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2162
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2163
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2164
+ const arrowAnchor = pointer ?? anchor;
2165
+ const arrowX = clamp(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2166
+ return { x, y, maxH, arrowX, below };
2167
+ }
2168
+
1823
2169
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1824
2170
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1825
2171
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2027,7 +2373,10 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2027
2373
  border-radius:16px; background:var(--shell-plaque-glass); color:#fff; font-size:14px; font-weight:600; }
2028
2374
  #${SHELL_ROOT_ID} .ge-ov-row .ge-grow { flex:1; text-align:left; }
2029
2375
  #${SHELL_ROOT_ID} button.ge-ov-row { cursor:pointer; font-family:inherit; transition:background .12s ease, color .12s ease; }
2030
- #${SHELL_ROOT_ID} button.ge-ov-row:hover { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
2376
+ #${SHELL_ROOT_ID} button.ge-ov-row:hover:not([disabled]) { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
2377
+ /* disabled rows — mirrors Pixi's box.alpha = 0.5 for all three row kinds. A <button> row carries
2378
+ the native attribute directly; a toggle/range row is a <div> wrapper, so it gets .ge-disabled. */
2379
+ #${SHELL_ROOT_ID} .ge-ov-row[disabled], #${SHELL_ROOT_ID} .ge-ov-row.ge-disabled { opacity:.5; cursor:default; }
2031
2380
  #${SHELL_ROOT_ID} .ge-ov-row.ge-col { flex-direction:column; align-items:stretch; gap:10px; }
2032
2381
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head { display:flex; justify-content:space-between; align-items:center; }
2033
2382
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head .ge-val { color:var(--shell-plaque-label); font-variant-numeric:tabular-nums; font-weight:700; }
@@ -2038,6 +2387,7 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2038
2387
  #${SHELL_ROOT_ID} .ge-toggle i { position:absolute; top:2px; left:2px; width:20px; height:20px; border-radius:50%;
2039
2388
  background:#fff; transition:left .12s ease; }
2040
2389
  #${SHELL_ROOT_ID} .ge-toggle.ge-on i { left:20px; }
2390
+ #${SHELL_ROOT_ID} .ge-toggle:disabled { cursor:default; }
2041
2391
  /* sound on/off — speaker icon button (replaces the toggle) */
2042
2392
  #${SHELL_ROOT_ID} .ge-snd { pointer-events:auto; cursor:pointer; border:none; background:none; padding:0;
2043
2393
  width:36px; height:36px; display:flex; align-items:center; justify-content:center; font-size:24px;
@@ -2046,6 +2396,22 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2046
2396
  #${SHELL_ROOT_ID} .ge-snd:hover { color:var(--shell-accent); }
2047
2397
  #${SHELL_ROOT_ID} .ge-snd:active { transform:scale(.92); }
2048
2398
 
2399
+ /* bar menu popover — light dismiss (no dim, no blur), card anchored to the burger */
2400
+ #${SHELL_ROOT_ID} .ge-pop-layer { position:absolute; inset:0; z-index:55; pointer-events:auto; }
2401
+ #${SHELL_ROOT_ID} .ge-pop { position:absolute; box-sizing:border-box; display:flex; flex-direction:column;
2402
+ padding:8px; border-radius:18px; background:var(--shell-plaque-dark);
2403
+ box-shadow:0 14px 38px rgba(0,0,0,.5); backdrop-filter:blur(12px) saturate(120%);
2404
+ -webkit-backdrop-filter:blur(12px) saturate(120%); animation:ge-ov-in .12s ease-out; }
2405
+ #${SHELL_ROOT_ID} .ge-pop-body { overflow-y:auto; overflow-x:hidden; min-height:0; }
2406
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row { padding:10px 12px; margin-bottom:6px; font-size:13px; }
2407
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row:last-child { margin-bottom:0; }
2408
+ #${SHELL_ROOT_ID} .ge-pop-sep { height:1px; margin:6px 4px; background:var(--shell-plaque-line); opacity:.5; }
2409
+ #${SHELL_ROOT_ID} .ge-pop-arrow { position:absolute; bottom:-7px; width:14px; height:14px; margin-left:-7px;
2410
+ background:var(--shell-plaque-dark); transform:rotate(45deg); border-radius:3px; }
2411
+ #${SHELL_ROOT_ID} .ge-pop.ge-pop-below .ge-pop-arrow { bottom:auto; top:-7px; }
2412
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-icon { flex:0 0 auto; width:20px; font-size:20px; display:flex; }
2413
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-chev { flex:0 0 auto; width:16px; font-size:16px; color:var(--shell-muted); display:flex; }
2414
+
2049
2415
  /* game info — each section is its own glass plaque; body text sized for comfortable reading */
2050
2416
  #${SHELL_ROOT_ID} .ge-gi-sec { margin-bottom:12px; background:var(--shell-plaque-glass);
2051
2417
  border-radius:16px; padding:16px 18px; }
@@ -2476,25 +2842,25 @@ function countUp(el, from, to, fmt, durationMs = 450) {
2476
2842
  // GraphicsContext.svg. To change an icon: edit icons.svg, then run
2477
2843
  // `node scripts/gen-icons-from-svg.mjs`.
2478
2844
  const SVGS = {
2479
- spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.57 8.41C13.3 8.31 13.51 7.09 13.59 6.65C13.61 6.56 13.55 6.48 13.46 6.48C12.32 6.45 5.74 6.61 4.16 14.85C4.15 14.9 4 15.16 3.8 14.91C3.27 14.23 2.43 12.08 3.46 9.16C6.01 1.92 13 3.07 13.79 3.23C13.83 3.23 13.86 3.2 13.86 3.17C13.85 2.86 13.82 1.68 14.17 1.64C14.51 1.6 19.04 5.61 18.96 5.95C18.86 6.36 13.83 8.51 13.57 8.41Z"/><path d="M10.43 15.59C10.7 15.69 10.49 16.91 10.41 17.35C10.39 17.44 10.45 17.52 10.54 17.52C11.68 17.55 18.26 17.39 19.84 9.15C19.85 9.1 20 8.84 20.2 9.09C20.73 9.77 21.57 11.92 20.54 14.85C17.99 22.08 11 20.93 10.21 20.77C10.17 20.77 10.13 20.8 10.14 20.83C10.15 21.14 10.18 22.32 9.83 22.36C9.49 22.4 4.96 18.39 5.04 18.05C5.14 17.64 10.17 15.49 10.43 15.59Z"/></g>`,
2480
- turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62Z"/></g>`,
2481
- autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.97 12.41C20.22 11.94 20.95 11.88 20.97 12.47C21.14 16.4 18.71 19.92 14.44 20.97C8.59 22.4 5.29 18.55 5.28 18.52C5.2 18.33 4.41 19.77 4.19 19.59C3.81 19.28 3.78 14.9 3.9 14.78C4.01 14.67 8.39 14.95 8.48 15.31C8.58 15.64 7.2 16.1 7.4 16.38C9.14 18.67 15.82 20.29 19.97 12.41Z"/><path d="M9.3 15.36L9.3 8.67C9.3 8.32 9.97 7.74 10.58 8.14C15.83 11.56 16 11.46 16.01 12.04C16.02 12.61 15.12 12.98 10.63 15.91C10.32 16.11 9.29 15.72 9.29 15.36Z"/><path d="M4.03 11.59C3.78 12.06 3.05 12.12 3.03 11.53C2.86 7.6 5.29 4.08 9.56 3.03C15.41 1.6 18.71 5.45 18.72 5.48C18.8 5.67 19.59 4.23 19.81 4.41C20.19 4.72 20.22 9.1 20.1 9.22C19.99 9.33 15.61 9.05 15.52 8.69C15.42 8.36 16.8 7.9 16.6 7.62C14.86 5.33 8.18 3.71 4.03 11.59Z"/></g>`,
2845
+ spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.57 8.41C13.3 8.31 13.51 7.09 13.59 6.65C13.61 6.56 13.55 6.48 13.46 6.48C12.32 6.45 5.74 6.61 4.16 14.85C4.15 14.9 4 15.16 3.8 14.91C3.27 14.23 2.43 12.08 3.46 9.16C6.01 1.92 13 3.07 13.79 3.23C13.83 3.23 13.86 3.2 13.86 3.17C13.85 2.86 13.82 1.68 14.17 1.64C14.51 1.6 19.04 5.61 18.96 5.95C18.86 6.36 13.83 8.51 13.57 8.41L13.57 8.41Z"/><path d="M10.43 15.59C10.7 15.69 10.49 16.91 10.41 17.35C10.39 17.44 10.45 17.52 10.54 17.52C11.68 17.55 18.26 17.39 19.84 9.15C19.85 9.1 20 8.84 20.2 9.09C20.73 9.77 21.57 11.92 20.54 14.85C17.99 22.08 11 20.93 10.21 20.77C10.17 20.77 10.13 20.8 10.14 20.83C10.15 21.14 10.18 22.32 9.83 22.36C9.49 22.4 4.96 18.39 5.04 18.05C5.14 17.64 10.17 15.49 10.43 15.59L10.43 15.59L10.43 15.59Z"/></g>`,
2846
+ turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62L17.81 2.62Z"/></g>`,
2847
+ autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.97 12.41C20.22 11.94 20.95 11.88 20.97 12.47C21.14 16.4 18.71 19.92 14.44 20.97C8.59 22.4 5.29 18.55 5.28 18.52C5.2 18.33 4.41 19.77 4.19 19.59C3.81 19.28 3.78 14.9 3.9 14.78C4.01 14.67 8.39 14.95 8.48 15.31C8.58 15.64 7.2 16.1 7.4 16.38C9.14 18.67 15.82 20.29 19.97 12.41L19.97 12.41Z"/><path d="M9.3 15.36L9.3 8.67C9.3 8.32 9.97 7.74 10.58 8.14C15.83 11.56 16 11.46 16.01 12.04C16.02 12.61 15.12 12.98 10.63 15.91C10.32 16.11 9.29 15.72 9.29 15.36L9.3 15.36Z"/><path d="M4.03 11.59C3.78 12.06 3.05 12.12 3.03 11.53C2.86 7.6 5.29 4.08 9.56 3.03C15.41 1.6 18.71 5.45 18.72 5.48C18.8 5.67 19.59 4.23 19.81 4.41C20.19 4.72 20.22 9.1 20.1 9.22C19.99 9.33 15.61 9.05 15.52 8.69C15.42 8.36 16.8 7.9 16.6 7.62C14.86 5.33 8.18 3.71 4.03 11.59L4.03 11.59Z"/></g>`,
2482
2848
  stop: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="1.6" width="20.8" height="20.8" rx="2.9"/></g>`,
2483
2849
  menu: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.61" y="3.81" width="20.79" height="4.47" rx="2.23"/><rect x="1.6" y="9.77" width="20.79" height="4.46" rx="2.23"/><rect x="1.6" y="15.72" width="20.79" height="4.47" rx="2.23"/></g>`,
2484
2850
  minus: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="9.86" width="20.8" height="4.28" rx="2.11"/></g>`,
2485
- plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.72 14.37L13.84 14.37L13.84 20.33C13.84 21.48 12.89 22.4 11.74 22.36C10.61 22.36 9.7 21.45 9.7 20.33L9.7 14.37L3.67 14.37C2.52 14.37 1.6 13.42 1.63 12.27C1.63 11.15 2.55 10.23 3.67 10.23L9.7 10.23L9.7 4.28C9.71 1.61 13.83 1.6 13.84 4.28L13.84 10.23L19.72 10.23C22.39 10.24 22.4 14.36 19.72 14.37Z"/></g>`,
2851
+ plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.72 14.37L13.84 14.37L13.84 20.33C13.84 21.48 12.89 22.4 11.74 22.36C10.61 22.36 9.7 21.45 9.7 20.33L9.7 14.37L3.67 14.37C2.52 14.37 1.6 13.42 1.63 12.27C1.63 11.15 2.55 10.23 3.67 10.23L9.7 10.23L9.7 4.28C9.71 1.61 13.83 1.6 13.84 4.28L13.84 10.23L19.72 10.23C22.39 10.24 22.4 14.36 19.72 14.37L19.72 14.37Z"/></g>`,
2486
2852
  gift: `<rect x="4" y="9" width="16" height="11" rx="2" fill="currentColor"/><path d="M9 9c-1.35 -0.31 -2.18 -1.65 -1.87 -3s1.65 -2.18 3 -1.87c0.93 0.22 1.65 0.94 1.87 1.87c0.31 -1.35 1.65 -2.18 3 -1.87c1.35 0.31 2.18 1.65 1.87 3c-0.22 0.93 -0.94 1.65 -1.87 1.87h-6Z" fill="currentColor"/><rect x="11" y="9" width="2" height="11" fill="rgba(0,0,0,.35)"/>`,
2487
- info: `<g fill="currentColor" fill-rule="evenodd"><circle cx="11.98" cy="4.27" r="2.67"/><path d="M16.05 21.09L16.05 21.54C16.05 22.02 15.67 22.4 15.19 22.4L8.89 22.4C8.42 22.4 8.04 22.02 8.04 21.54L8.04 20.98C8.04 20.5 8.42 20.12 8.89 20.12L8.89 20.12C9.13 20.12 9.32 19.93 9.32 19.7L9.32 11.59C9.32 11.29 9.08 11.05 8.8 11.05C8.33 11.05 7.95 10.67 7.95 10.19L7.95 9.18C7.95 8.7 8.33 8.32 8.8 8.32L9.32 8.32C9.32 8.32 13.8 8.32 13.8 8.32C14.27 8.32 14.65 8.71 14.65 9.18L14.65 19.7C14.65 19.93 14.84 20.12 15.07 20.12L15.07 20.12C15.61 20.12 16.05 20.56 16.05 21.09Z"/></g>`,
2488
- soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 15.04L1.6 9.18C1.6 8.75 1.95 8.4 2.38 8.4L5.03 8.4C5.21 8.4 5.39 8.36 5.56 8.28L11.6 5.26C12.12 5 12.73 5.38 12.73 5.96L12.73 18.27C12.73 18.85 12.12 19.22 11.6 18.96L5.56 15.94C5.39 15.86 5.22 15.82 5.03 15.82L2.38 15.82C1.95 15.82 1.6 15.47 1.6 15.04Z"/><path d="M16.01 12.11C16.04 12.36 15.67 14.7 14.7 15.23C13.92 15.42 13.54 14.77 13.86 14.1C14.89 12.56 14.88 11.66 13.86 10.13C13.54 9.46 13.92 8.8 14.7 8.99C15.67 9.53 16.06 11.81 16.01 12.11Z"/><path d="M19.15 12.12C19.2 12.51 18.6 16.4 17.3 17.05C16.32 17.54 15.99 16.87 16.42 15.81C17.8 13.4 17.59 9.93 16.42 8.41C15.73 7.5 16.34 6.58 17.26 7.18C18.74 8.15 19.21 11.64 19.15 12.12Z"/><path d="M22.4 12.16C22.16 15.18 21 17.95 19.71 18.82C18.73 19.48 18.22 18.42 18.93 17.32C20.96 14.17 21.92 9.95 18.9 6.29C18.13 5.35 19.35 4.52 20.11 5.48C21.43 7.14 22.33 9.14 22.4 12.16Z"/></g>`,
2853
+ info: `<g fill="currentColor" fill-rule="evenodd"><circle cx="11.98" cy="4.27" r="2.67"/><path d="M16.05 21.09L16.05 21.54C16.05 22.02 15.67 22.4 15.19 22.4L8.89 22.4C8.42 22.4 8.04 22.02 8.04 21.54L8.04 20.98C8.04 20.5 8.42 20.12 8.89 20.12L8.89 20.12C9.13 20.12 9.32 19.93 9.32 19.7L9.32 11.59C9.32 11.29 9.08 11.05 8.8 11.05C8.33 11.05 7.95 10.67 7.95 10.19L7.95 9.18C7.95 8.7 8.33 8.32 8.8 8.32L13.8 8.32C14.27 8.32 14.65 8.71 14.65 9.18L14.65 19.7C14.65 19.93 14.84 20.12 15.07 20.12L15.07 20.12C15.61 20.12 16.05 20.56 16.05 21.09L16.05 21.09Z"/></g>`,
2854
+ soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 15.04L1.6 9.18C1.6 8.75 1.95 8.4 2.38 8.4L5.03 8.4C5.21 8.4 5.39 8.36 5.56 8.28L11.6 5.26C12.12 5 12.73 5.38 12.73 5.96L12.73 18.27C12.73 18.85 12.12 19.22 11.6 18.96L5.56 15.94C5.39 15.86 5.22 15.82 5.03 15.82L2.38 15.82C1.95 15.82 1.6 15.47 1.6 15.04L1.6 15.04Z"/><path d="M16.01 12.11C16.04 12.36 15.67 14.7 14.7 15.23C13.92 15.42 13.54 14.77 13.86 14.1C14.89 12.56 14.88 11.66 13.86 10.13C13.54 9.46 13.92 8.8 14.7 8.99C15.67 9.53 16.06 11.81 16.01 12.11Z"/><path d="M19.15 12.12C19.2 12.51 18.6 16.4 17.3 17.05C16.32 17.54 15.99 16.87 16.42 15.81C17.8 13.4 17.59 9.93 16.42 8.41C15.73 7.5 16.34 6.58 17.26 7.18C18.74 8.15 19.21 11.64 19.15 12.12L19.15 12.12Z"/><path d="M22.4 12.16C22.16 15.18 21 17.95 19.71 18.82C18.73 19.48 18.22 18.42 18.93 17.32C20.96 14.17 21.92 9.95 18.9 6.29C18.13 5.35 19.35 4.52 20.11 5.48C21.43 7.14 22.33 9.14 22.4 12.16L22.4 12.16Z"/></g>`,
2489
2855
  soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 14.77L1.6 9.23C1.6 8.82 1.93 8.49 2.34 8.49L4.85 8.49C5.02 8.49 5.19 8.45 5.34 8.38L11.06 5.52C11.55 5.27 12.12 5.63 12.12 6.18L12.12 17.82C12.12 18.37 11.55 18.73 11.06 18.48L5.34 15.62C5.19 15.55 5.02 15.51 4.85 15.51L2.34 15.51C1.93 15.51 1.6 15.18 1.6 14.77Z"/><path d="M21.95 16.53C21.5 16.98 20.78 16.98 20.33 16.53L17.79 13.99L15.25 16.54C14.8 16.99 14.07 16.99 13.62 16.54C13.17 16.09 13.17 15.36 13.62 14.92L16.17 12.37L13.63 9.83C13.18 9.38 13.18 8.65 13.63 8.21C13.85 7.98 14.14 7.87 14.44 7.87C14.73 7.87 15.02 7.98 15.25 8.21L17.79 10.75L20.33 8.21C20.77 7.76 21.5 7.76 21.95 8.21C22.17 8.44 22.28 8.73 22.28 9.02C22.28 9.32 22.17 9.61 21.95 9.83L19.41 12.37L21.95 14.91C22.4 15.36 22.4 16.08 21.95 16.53Z"/></g>`,
2490
- close: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.39 21.37C20.38 22.38 18.74 22.38 17.73 21.37L12.01 15.64L6.27 21.38C5.26 22.39 3.62 22.39 2.61 21.38C1.6 20.38 1.6 18.74 2.61 17.73L8.35 11.99L2.62 6.25C1.61 5.24 1.61 3.61 2.62 2.6C3.12 2.09 3.79 1.84 4.45 1.84C5.11 1.84 5.77 2.09 6.28 2.6L12.01 8.33L17.72 2.62C18.73 1.61 20.37 1.61 21.38 2.62C21.89 3.12 22.14 3.78 22.14 4.44C22.14 5.11 21.89 5.77 21.38 6.27L15.67 11.99L21.39 17.71C22.4 18.72 22.4 20.36 21.39 21.37Z"/></g>`,
2491
- back: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.49 2.32L17.49 2.32C18.2 3.03 18.2 4.19 17.49 4.9L11.68 10.71C10.97 11.42 10.97 12.58 11.68 13.29L17.49 19.1C18.21 19.81 18.21 20.97 17.49 21.69L17.49 21.69C16.78 22.4 15.62 22.4 14.91 21.69L7.8 14.58L7.8 14.58C7.8 14.58 6.51 13.29 6.51 13.29C5.79 12.58 5.79 11.42 6.51 10.71L14.9 2.31C15.62 1.6 16.77 1.6 17.49 2.31Z"/></g>`,
2492
- chevronRight: `<g fill="currentColor" fill-rule="evenodd"><path d="M6.51 21.68L6.51 21.68C5.8 20.97 5.8 19.81 6.51 19.1L12.32 13.29C13.03 12.58 13.03 11.42 12.32 10.71L6.51 4.9C5.79 4.19 5.79 3.03 6.51 2.31L6.51 2.31C7.22 1.6 8.38 1.6 9.09 2.31L16.2 9.42L16.2 9.42C16.2 9.42 17.49 10.71 17.49 10.71C18.21 11.42 18.21 12.58 17.49 13.29L9.1 21.69C8.38 22.4 7.23 22.4 6.51 21.69Z"/></g>`,
2856
+ close: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.39 21.37C20.38 22.38 18.74 22.38 17.73 21.37L12.01 15.64L6.27 21.38C5.26 22.39 3.62 22.39 2.61 21.38C1.6 20.38 1.6 18.74 2.61 17.73L8.35 11.99L2.62 6.25C1.61 5.24 1.61 3.61 2.62 2.6C3.12 2.09 3.79 1.84 4.45 1.84C5.11 1.84 5.77 2.09 6.28 2.6L12.01 8.33L17.72 2.62C18.73 1.61 20.37 1.61 21.38 2.62C21.89 3.12 22.14 3.78 22.14 4.44C22.14 5.11 21.89 5.77 21.38 6.27L15.67 11.99L21.39 17.71C22.4 18.72 22.4 20.36 21.39 21.37L21.39 21.37Z"/></g>`,
2857
+ back: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.49 2.32L17.49 2.32C18.2 3.03 18.2 4.19 17.49 4.9L11.68 10.71C10.97 11.42 10.97 12.58 11.68 13.29L17.49 19.1C18.21 19.81 18.21 20.97 17.49 21.69L17.49 21.69C16.78 22.4 15.62 22.4 14.91 21.69L7.8 14.58L7.8 14.58L6.51 13.29C5.79 12.58 5.79 11.42 6.51 10.71L14.9 2.31C15.62 1.6 16.77 1.6 17.49 2.31L17.49 2.31Z"/></g>`,
2858
+ chevronRight: `<g fill="currentColor" fill-rule="evenodd"><path d="M6.51 21.68L6.51 21.68C5.8 20.97 5.8 19.81 6.51 19.1L12.32 13.29C13.03 12.58 13.03 11.42 12.32 10.71L6.51 4.9C5.79 4.19 5.79 3.03 6.51 2.31L6.51 2.31C7.22 1.6 8.38 1.6 9.09 2.31L16.2 9.42L16.2 9.42L17.49 10.71C18.21 11.42 18.21 12.58 17.49 13.29L9.1 21.69C8.38 22.4 7.23 22.4 6.51 21.69L6.51 21.69Z"/></g>`,
2493
2859
  ticket: `<g transform="translate(12 12) scale(0.0354) translate(-518.2 -535.2)"><g transform="rotate(-15 512 512)"><path d="M250 392H774L774 462C739 474 720 504 720 536C720 568 739 598 774 610V680H250V610C285 598 304 568 304 536C304 504 285 474 250 462V392Z" fill="none" stroke="currentColor" stroke-width="52" stroke-linejoin="miter" stroke-linecap="square"/><path d="M562.9 408.3L441.5 553.9L506.9 546.3L472.9 664.1L604 504L528.9 514.3Z" fill="currentColor"/></g></g>`,
2494
- turbo2: `<g fill="currentColor" fill-rule="evenodd"><path d="M8.24 8.81L3.92 7.68C3.88 7.67 3.88 7.61 3.92 7.61L9.66 6.45C9.8 6.43 9.89 6.56 9.83 6.68L8.86 8.35C8.67 8.73 8.33 8.83 8.24 8.81Z"/><path d="M20.58 3.04L17.34 8.38C17.09 8.8 17.39 9.33 17.87 9.33L21.57 9.33C22.12 9.33 22.4 9.99 22.02 10.39L11.24 21.73C11.12 21.86 10.95 21.93 10.77 21.92L10.77 21.92C10.34 21.92 10.04 21.47 10.2 21.07L13.04 13.82C13.21 13.41 12.9 12.96 12.45 12.97L8.46 13.01C7.98 13.01 7.67 12.5 7.9 12.08L13.2 2.4C13.31 2.2 13.52 2.07 13.75 2.07L20.05 2.09C20.54 2.1 20.84 2.63 20.58 3.04Z"/><path d="M5.96 12.68L1.64 11.54C1.6 11.54 1.6 11.47 1.64 11.47L7.38 10.31C7.52 10.3 7.61 10.42 7.55 10.55L6.58 12.22C6.39 12.59 6.05 12.69 5.96 12.68Z"/><path d="M9.67 16.66L5.35 15.53C5.31 15.52 5.31 15.46 5.35 15.46L11.09 14.3C11.22 14.28 11.32 14.41 11.26 14.53L10.29 16.2C10.1 16.58 9.76 16.68 9.67 16.66Z"/></g>`,
2495
- turboOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62Z"/></g>`,
2496
- chevronUp: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.68 17.49L21.68 17.49C20.97 18.2 19.81 18.2 19.1 17.49L13.29 11.68C12.58 10.97 11.42 10.97 10.71 11.68L4.9 17.49C4.19 18.21 3.03 18.21 2.31 17.49L2.31 17.49C1.6 16.78 1.6 15.62 2.31 14.91L9.42 7.8L9.42 7.8C9.42 7.8 10.71 6.51 10.71 6.51C11.42 5.79 12.58 5.79 13.29 6.51L21.69 14.9C22.4 15.62 22.4 16.77 21.69 17.49Z"/></g>`,
2497
- chevronDown: `<g fill="currentColor" fill-rule="evenodd"><path d="M2.32 6.51L2.32 6.51C3.03 5.8 4.19 5.8 4.9 6.51L10.71 12.32C11.42 13.03 12.58 13.03 13.29 12.32L19.1 6.51C19.82 5.79 20.97 5.79 21.69 6.51L21.69 6.51C22.4 7.22 22.4 8.38 21.69 9.09L14.58 16.19L14.58 16.2C14.58 16.2 13.29 17.49 13.29 17.49C12.58 18.21 11.42 18.21 10.71 17.49L2.31 9.1C1.6 8.38 1.6 7.23 2.31 6.51Z"/></g>`,
2860
+ turbo2: `<g fill="currentColor" fill-rule="evenodd"><path d="M8.24 8.81L3.92 7.68C3.88 7.67 3.88 7.61 3.92 7.61L9.66 6.45C9.8 6.43 9.89 6.56 9.83 6.68L8.86 8.35C8.67 8.73 8.33 8.83 8.24 8.81L8.24 8.81Z"/><path d="M20.58 3.04L17.34 8.38C17.09 8.8 17.39 9.33 17.87 9.33L21.57 9.33C22.12 9.33 22.4 9.99 22.02 10.39L11.24 21.73C11.12 21.86 10.95 21.93 10.77 21.92L10.77 21.92C10.34 21.92 10.04 21.47 10.2 21.07L13.04 13.82C13.21 13.41 12.9 12.96 12.45 12.97L8.46 13.01C7.98 13.01 7.67 12.5 7.9 12.08L13.2 2.4C13.31 2.2 13.52 2.07 13.75 2.07L20.05 2.09C20.54 2.1 20.84 2.63 20.58 3.04L20.58 3.04Z"/><path d="M5.96 12.68L1.64 11.54C1.6 11.54 1.6 11.47 1.64 11.47L7.38 10.31C7.51 10.3 7.61 10.42 7.55 10.55L6.58 12.22C6.39 12.59 6.05 12.69 5.96 12.68L5.96 12.68Z"/><path d="M9.67 16.66L5.35 15.53C5.31 15.52 5.31 15.46 5.35 15.46L11.09 14.3C11.22 14.28 11.32 14.41 11.26 14.53L10.29 16.2C10.1 16.58 9.76 16.68 9.67 16.66L9.67 16.66Z"/></g>`,
2861
+ turboOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62L17.81 2.62Z"/></g>`,
2862
+ chevronUp: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.68 17.49L21.68 17.49C20.97 18.2 19.81 18.2 19.1 17.49L13.29 11.68C12.58 10.97 11.42 10.97 10.71 11.68L4.9 17.49C4.19 18.21 3.03 18.21 2.31 17.49L2.31 17.49C1.6 16.78 1.6 15.62 2.31 14.91L9.42 7.8L9.42 7.8L10.71 6.51C11.42 5.79 12.58 5.79 13.29 6.51L21.69 14.9C22.4 15.62 22.4 16.77 21.69 17.49L21.69 17.49Z"/></g>`,
2863
+ chevronDown: `<g fill="currentColor" fill-rule="evenodd"><path d="M2.32 6.51L2.32 6.51C3.03 5.8 4.19 5.8 4.9 6.51L10.71 12.32C11.42 13.03 12.58 13.03 13.29 12.32L19.1 6.51C19.82 5.79 20.97 5.79 21.69 6.51L21.69 6.51C22.4 7.22 22.4 8.38 21.69 9.09L14.58 16.19L14.58 16.2L13.29 17.49C12.58 18.21 11.42 18.21 10.71 17.49L2.31 9.1C1.6 8.38 1.6 7.23 2.31 6.51L2.32 6.51L2.32 6.51Z"/></g>`,
2498
2864
  };
2499
2865
  /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
2500
2866
  function icon(name) {
@@ -2853,77 +3219,246 @@ function createOverlay(opts) {
2853
3219
  root.append(head, scroll);
2854
3220
  return { root, body, scroll };
2855
3221
  }
3222
+ /** A light-dismiss popover: a transparent full-surface layer (closes on pointerdown) holding a
3223
+ * card with an arrow that points at `pointer`. Append rows to `body`; call `position()` after the
3224
+ * card is in the DOM and again on resize. */
3225
+ function createPopover(opts) {
3226
+ const root = document.createElement('div');
3227
+ root.className = 'ge-pop-layer';
3228
+ root.dataset.ge = opts.ge;
3229
+ const card = document.createElement('div');
3230
+ card.className = 'ge-pop';
3231
+ card.dataset.ge = 'menu-card';
3232
+ const body = document.createElement('div');
3233
+ body.className = 'ge-pop-body';
3234
+ const arrow = document.createElement('span');
3235
+ arrow.className = 'ge-pop-arrow';
3236
+ card.append(body, arrow);
3237
+ root.appendChild(card);
3238
+ // Clicks inside the card must not reach the dismiss layer.
3239
+ card.addEventListener('pointerdown', (e) => e.stopPropagation());
3240
+ root.addEventListener('pointerdown', opts.onClose);
3241
+ const resolveEl = (v) => typeof v === 'function' ? v() : (v ?? null);
3242
+ /** A rect in surface coordinates, or null when unresolved/fully zero-sized (a zero-HEIGHT rect —
3243
+ * e.g. a not-yet-laid-out anchor — is still considered valid, matching placePopover's own rule). */
3244
+ const rectOf = (el, surfaceRect) => {
3245
+ if (!el)
3246
+ return null;
3247
+ const r = el.getBoundingClientRect();
3248
+ if (r.width <= 0 && r.height <= 0)
3249
+ return null;
3250
+ return { x: r.left - surfaceRect.left, y: r.top - surfaceRect.top, w: r.width, h: r.height };
3251
+ };
3252
+ const position = () => {
3253
+ const surfaceRect = opts.surface.getBoundingClientRect();
3254
+ const surface = { w: surfaceRect.width || opts.surface.clientWidth, h: surfaceRect.height || opts.surface.clientHeight };
3255
+ if (surface.w <= 0 || surface.h <= 0)
3256
+ return;
3257
+ const s = opts.scale?.() ?? 1;
3258
+ // 1. Clear a prior run's constraints (transform + width + max-height + left) before measuring —
3259
+ // otherwise scrollWidth/offsetHeight report the already-scaled/clamped box (not the natural
3260
+ // content size) on every call after the first, and the card can shrink to fit a
3261
+ // narrower/shorter surface but never grow back when the surface widens/grows again. An uncleared
3262
+ // max-height is the worse of the two: placePopover positions a card sized for the STALE clamped
3263
+ // height, then the un-clamped natural height (restored below) springs back afterward — so the
3264
+ // rendered card can overlap the plate or run off the surface edge until reopened. The transform
3265
+ // doesn't affect layout either way, but clearing it keeps every pass measuring the same way.
3266
+ // `left` matters too: `.ge-pop` is absolutely positioned inside an `inset:0` layer, so with the
3267
+ // previous pass's `left` still applied, its shrink-to-fit width is bounded by (layerWidth − left)
3268
+ // instead of the card's true natural width. Invisible at scale 1; a scale below 1 lays the card
3269
+ // out at up to 1/s its on-screen width, so a stale left — only ever a few hundred px — can clip it.
3270
+ card.style.transform = '';
3271
+ card.style.width = '';
3272
+ card.style.maxHeight = '';
3273
+ card.style.left = '0px';
3274
+ const naturalW = card.scrollWidth || POPOVER.minW;
3275
+ // 2. Resolve the ON-SCREEN width from the natural (unscaled) width scaled up to screen units,
3276
+ // then set the LOCAL style.width so the card renders at that resolved width once scaled — and
3277
+ // re-measure the height at that width (wrapping may have changed it).
3278
+ const resolvedW = popoverWidth(surface.w, naturalW * s);
3279
+ card.style.width = `${resolvedW / s}px`;
3280
+ const naturalH = card.offsetHeight || POPOVER.minH;
3281
+ // 3. Resolve plate/pointer in surface coordinates and place using SCREEN-space size —
3282
+ // placePopover works in surface pixels throughout, so both the anchor rects and `size` here are
3283
+ // screen units even though the card's own layout (width/height above) is still LOCAL/unscaled.
3284
+ const plateEl = resolveEl(opts.plate);
3285
+ const pointerEl = resolveEl(opts.pointer);
3286
+ let plate = rectOf(plateEl, surfaceRect) ?? rectOf(pointerEl, surfaceRect);
3287
+ // Extend the plate's TOP edge upward to a popped-out hero's true top (e.g. the mobile SPIN/FS
3288
+ // control, taller than its row) — bottom edge untouched. Both rects are already in the SAME
3289
+ // surface-coordinate space (post any bar-scale transform), so this is a plain coordinate compare,
3290
+ // no separate unit conversion needed.
3291
+ const overflowEl = resolveEl(opts.plateOverflowTop);
3292
+ if (plate) {
3293
+ const overflowRect = rectOf(overflowEl, surfaceRect);
3294
+ if (overflowRect && overflowRect.y < plate.y) {
3295
+ plate = { ...plate, h: plate.h + (plate.y - overflowRect.y), y: overflowRect.y };
3296
+ }
3297
+ }
3298
+ const pointer = rectOf(pointerEl, surfaceRect);
3299
+ const p = placePopover(plate, surface, { w: resolvedW, h: naturalH * s }, pointer);
3300
+ // 4. Apply. maxHeight and the arrow's offset are LOCAL (unscaled) too, since both live inside the
3301
+ // scaled card; transform-origin:top left keeps left/top (screen units) as the card's visual
3302
+ // top-left regardless of `s`, and — since a transform doesn't affect layout — the next
3303
+ // measurement pass stays clean without needing any extra bookkeeping.
3304
+ card.style.left = `${p.x}px`;
3305
+ card.style.top = `${p.y}px`;
3306
+ card.style.maxHeight = `${p.maxH / s}px`;
3307
+ card.style.transformOrigin = 'top left';
3308
+ card.style.transform = Math.abs(s - 1) > 0.001 ? `scale(${s})` : '';
3309
+ card.classList.toggle('ge-pop-below', p.below);
3310
+ if (p.arrowX < 0)
3311
+ arrow.style.display = 'none';
3312
+ else {
3313
+ arrow.style.display = '';
3314
+ arrow.style.left = `${p.arrowX / s}px`;
3315
+ }
3316
+ };
3317
+ return { root, card, body, position };
3318
+ }
2856
3319
 
2857
- function openSettingsModal(host) {
2858
- const { root, body } = createOverlay({ title: host.t('Settings'), onClose: () => host.actions.closeOverlay() });
2859
- root.dataset.ge = 'settings-modal';
2860
- // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
2861
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
2862
- const sound = (() => {
3320
+ /** The bar menu, as a light-dismiss popover anchored to the burger. Rows come from the core model,
3321
+ * so DOM and Pixi always show the same list in the same order. */
3322
+ function openMenuPopover(host, surface, getScale) {
3323
+ const pop = createPopover({
3324
+ ge: 'menu-popover',
3325
+ surface,
3326
+ // Resolved lazily on every position() call, not captured once: HtmlRenderer's renderBar() (run
3327
+ // on every resize, and on ~20 other state changes) rebuilds the bottom bar from scratch, so the
3328
+ // plate/burger are brand-new elements after it — a captured reference would already be detached
3329
+ // by the next position() call and the card would silently recentre with its arrow hidden.
3330
+ // The plate is the bar's own plaque — the continuous dark panel wide, the controls row mobile
3331
+ // (NOT the info pill below it) — so the card sits flush with the WHOLE bar, not just the burger.
3332
+ plate: () => surface.querySelector(host.layout === 'mobile' ? '.ge-m-controls' : '.ge-bar-panel'),
3333
+ pointer: () => surface.querySelector('[data-ge="menu"]'),
3334
+ // Mobile only: the SPIN disc / FS counter is taller than `.ge-m-controls` and centred inside it
3335
+ // (`align-items:center` over a shorter fixed-height row), so it pops above the row's own top edge
3336
+ // — see createPopover's `plateOverflowTop` doc comment. Exactly one of the two (or neither, e.g. a
3337
+ // replay with no free spins) is ever rendered, so a combined selector resolves to whichever is
3338
+ // current. Wide's plate already contains its content, so this deliberately resolves to null there.
3339
+ plateOverflowTop: () => host.layout === 'mobile'
3340
+ ? surface.querySelector('[data-ge="spin"], [data-ge="fs-counter"]')
3341
+ : null,
3342
+ scale: getScale,
3343
+ onClose: () => host.actions.closeOverlay(),
3344
+ });
3345
+ const updaters = {};
3346
+ for (const row of resolveMenu(host)) {
3347
+ pop.body.appendChild(buildRow(host, row, updaters, () => pop.position()));
3348
+ }
3349
+ // Live updates while open (host.setMenuValue / setSound / setVolume); cleared by the controller.
3350
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
3351
+ return { root: pop.root, position: pop.position };
3352
+ }
3353
+ function glyph(name, cls) {
3354
+ if (!name)
3355
+ return null;
3356
+ const el = document.createElement('span');
3357
+ el.className = cls;
3358
+ el.innerHTML = icon(name);
3359
+ return el;
3360
+ }
3361
+ function buildRow(host, row, updaters, reposition) {
3362
+ if (row.kind === 'separator') {
3363
+ const sep = document.createElement('div');
3364
+ sep.className = 'ge-pop-sep';
3365
+ sep.dataset.ge = 'menu-sep';
3366
+ return sep;
3367
+ }
3368
+ if (row.kind === 'button') {
3369
+ // The row hook goes on a wrapper so both `menu-row-<id>` (order assertions) and
3370
+ // `menu-item-<id>` (the clickable control) exist, as they do for toggle/range rows.
3371
+ const wrap = document.createElement('div');
3372
+ wrap.dataset.ge = `menu-row-${row.id}`;
3373
+ const btn = document.createElement('button');
3374
+ btn.className = 'ge-ov-row';
3375
+ btn.dataset.ge = `menu-item-${row.id}`;
3376
+ btn.disabled = row.disabled;
3377
+ const label = document.createElement('span');
3378
+ label.className = 'ge-grow';
3379
+ label.textContent = row.label;
3380
+ const ico = glyph(row.icon, 'ge-mi-icon');
3381
+ const chev = row.chevron ? glyph('chevronRight', 'ge-mi-chev') : null;
3382
+ for (const el of [ico, label, chev])
3383
+ if (el)
3384
+ btn.appendChild(el);
3385
+ btn.addEventListener('click', () => {
3386
+ if (btn.disabled)
3387
+ return;
3388
+ host.actions.closeOverlay();
3389
+ row.select();
3390
+ });
3391
+ wrap.appendChild(btn);
3392
+ return wrap;
3393
+ }
3394
+ if (row.kind === 'toggle') {
3395
+ const el = document.createElement('div');
3396
+ el.className = 'ge-ov-row';
3397
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
3398
+ el.dataset.ge = `menu-row-${row.id}`;
3399
+ const ico = glyph(row.icon(row.get()), 'ge-mi-icon');
3400
+ const label = document.createElement('span');
3401
+ label.className = 'ge-grow';
3402
+ label.textContent = row.label;
2863
3403
  const btn = document.createElement('button');
2864
- btn.className = 'ge-snd';
2865
- btn.dataset.ge = 'setting-sound';
2866
- btn.setAttribute('aria-label', host.t('Sound'));
2867
- const paint = (on) => {
2868
- btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
2869
- btn.classList.toggle('ge-active', on);
2870
- btn.setAttribute('aria-pressed', String(on));
3404
+ btn.className = 'ge-toggle';
3405
+ btn.dataset.ge = `menu-item-${row.id}`;
3406
+ btn.disabled = row.disabled;
3407
+ btn.innerHTML = '<i></i>';
3408
+ const paint = (v) => {
3409
+ btn.classList.toggle('ge-on', v);
3410
+ btn.setAttribute('aria-pressed', String(v));
3411
+ const next = row.icon(v);
3412
+ if (ico && next)
3413
+ ico.innerHTML = icon(next);
2871
3414
  };
2872
- paint(host.soundOn);
2873
- btn.addEventListener('click', () => host.setSound(!host.soundOn));
2874
- // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
2875
- host.setSoundRefresh(paint);
2876
- const row = document.createElement('div');
2877
- row.className = 'ge-ov-row';
2878
- row.innerHTML = `<span class="ge-grow">${host.t('Sound')}</span>`;
2879
- row.appendChild(btn);
2880
- return row;
2881
- })();
2882
- body.appendChild(sound);
2883
- // Volume sliders — full-width column rows with a live value readout. Positions are read from the
2884
- // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
2885
- // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
2886
- const updaters = {};
2887
- const slider = (key, label) => {
2888
- const row = document.createElement('div');
2889
- row.className = 'ge-ov-row ge-col';
2890
- const head = document.createElement('div');
2891
- head.className = 'ge-row-head';
2892
- const val = document.createElement('span');
2893
- val.className = 'ge-val';
2894
- head.innerHTML = `<span>${label}</span>`;
2895
- head.appendChild(val);
2896
- const input = document.createElement('input');
2897
- input.type = 'range';
2898
- input.min = '0';
2899
- input.max = '1';
2900
- input.step = '0.05';
2901
- input.className = 'ge-slider';
2902
- input.dataset.ge = `setting-${key}`;
2903
- const paint = (v) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
2904
- paint(host.getVolume(key));
2905
- input.addEventListener('input', () => {
2906
- val.textContent = `${Math.round(Number(input.value) * 100)}%`;
2907
- host.setVolume(key, Number(input.value));
3415
+ paint(row.get());
3416
+ btn.addEventListener('click', () => {
3417
+ if (!btn.disabled)
3418
+ row.set(!row.get());
2908
3419
  });
2909
- updaters[key] = paint;
2910
- row.append(head, input);
2911
- return row;
3420
+ updaters[row.id] = (v) => paint(v === true);
3421
+ if (ico)
3422
+ el.appendChild(ico);
3423
+ el.append(label, btn);
3424
+ return el;
3425
+ }
3426
+ // range
3427
+ const el = document.createElement('div');
3428
+ el.className = 'ge-ov-row ge-col';
3429
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
3430
+ el.dataset.ge = `menu-row-${row.id}`;
3431
+ const head = document.createElement('div');
3432
+ head.className = 'ge-row-head';
3433
+ const name = document.createElement('span');
3434
+ name.textContent = row.label;
3435
+ const val = document.createElement('span');
3436
+ val.className = 'ge-val';
3437
+ head.append(name, val);
3438
+ const input = document.createElement('input');
3439
+ input.type = 'range';
3440
+ input.className = 'ge-slider';
3441
+ input.dataset.ge = `menu-item-${row.id}`;
3442
+ input.min = String(row.min);
3443
+ input.max = String(row.max);
3444
+ input.step = String(row.step);
3445
+ input.disabled = row.disabled;
3446
+ const paint = (v) => {
3447
+ input.value = String(v);
3448
+ val.textContent = row.format(v);
3449
+ };
3450
+ paint(row.get());
3451
+ input.addEventListener('input', () => {
3452
+ const v = Number(input.value);
3453
+ val.textContent = row.format(v);
3454
+ row.set(v);
3455
+ });
3456
+ updaters[row.id] = (v) => {
3457
+ paint(Number(v));
3458
+ reposition();
2912
3459
  };
2913
- body.appendChild(slider('master', host.t('Master volume')));
2914
- body.appendChild(slider('music', host.t('Music')));
2915
- body.appendChild(slider('sfx', host.t('SFX')));
2916
- // Live-update sliders when volume changes via host.setVolume (shell clears on close).
2917
- host.setVolumeRefresh((key, v) => updaters[key]?.(v));
2918
- // Game info — full-width row button that opens its own overlay
2919
- const gameInfo = document.createElement('button');
2920
- gameInfo.className = 'ge-ov-row';
2921
- gameInfo.dataset.ge = 'game-info-btn';
2922
- gameInfo.style.marginTop = '6px';
2923
- gameInfo.innerHTML = `<span style="width:22px;font-size:22px">${icon('info')}</span><span class="ge-grow">${host.t('Game info')}</span><span style="width:20px;font-size:20px;color:var(--shell-muted)">${icon('chevronRight')}</span>`;
2924
- gameInfo.addEventListener('click', () => { root.remove(); host.actions.openInfo(); });
2925
- body.appendChild(gameInfo);
2926
- return root;
3460
+ el.append(head, input);
3461
+ return el;
2927
3462
  }
2928
3463
 
2929
3464
  /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
@@ -2933,7 +3468,7 @@ function openGameInfoModal(host) {
2933
3468
  const { root, body, scroll } = createOverlay({
2934
3469
  title: host.t('Game info'),
2935
3470
  onClose: () => host.actions.closeOverlay(),
2936
- onBack: () => { root.remove(); host.actions.openSettings(); },
3471
+ onBack: () => { root.remove(); host.actions.openMenu(); },
2937
3472
  });
2938
3473
  root.dataset.ge = 'info-modal';
2939
3474
  const rawSections = host.config.gameInfo.sections ?? [];
@@ -3888,13 +4423,13 @@ function buildReplayModal(host, opts, reopen) {
3888
4423
  const REMOVE_FADE_MS = 300;
3889
4424
  /** Count-up the value span (.ge-rd-val) of a readout, leaving its label untouched.
3890
4425
  * Returns the count-up canceler so the renderer can stop it before the node is replaced. */
3891
- function animateReadout(el, from, to, fmt) {
4426
+ function animateReadout(el, from, to, fmt, durationMs) {
3892
4427
  const val = el.querySelector('.ge-rd-val');
3893
4428
  if (!val) {
3894
4429
  el.textContent = fmt(to);
3895
4430
  return () => { };
3896
4431
  }
3897
- return countUp(val, from, to, fmt);
4432
+ return durationMs == null ? countUp(val, from, to, fmt) : countUp(val, from, to, fmt, durationMs);
3898
4433
  }
3899
4434
  class HtmlRenderer {
3900
4435
  host;
@@ -3906,7 +4441,11 @@ class HtmlRenderer {
3906
4441
  ro = null;
3907
4442
  moneyAnims = [];
3908
4443
  modalOnKey;
4444
+ popoverPosition = null;
3909
4445
  destroyed = false;
4446
+ /** The scale factor last applied to the bar by applyFitScale() — exposed so the menu popover can
4447
+ * match it exactly (see getBarScale) rather than re-derive its own, possibly-disagreeing, value. */
4448
+ barScale = 1;
3910
4449
  /** MutationObserver for buy-bonus confirm fit — fires fitModals() when nodes are added
3911
4450
  * inside the modalHost (e.g. the confirm dialog appended after the grid is open). */
3912
4451
  mutObs = null;
@@ -3949,15 +4488,23 @@ class HtmlRenderer {
3949
4488
  this.barHost.innerHTML = '';
3950
4489
  this.barHost.appendChild(renderBottomBar(this.host));
3951
4490
  this.applyFitScale();
4491
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
4492
+ // which can change barScale and/or the plate rect (e.g. a WIN pill appearing mid-autoplay
4493
+ // retriggers the overflow-tightening branch). Only the resize hook used to reposition an open
4494
+ // popover, so a live bar-content change while the menu was open left the card at the stale
4495
+ // scale/position until the next resize. `popoverPosition` only reads plate/pointer/scale getters
4496
+ // and writes card styles — no path back into renderBar() — so this cannot recurse; it's `null`
4497
+ // whenever no popover is open (or a different overlay kind is), so this cannot throw either.
4498
+ this.popoverPosition?.();
3952
4499
  }
3953
4500
  setLayout() {
3954
4501
  this.renderBar();
3955
4502
  }
3956
- animateMoney(field, from, to) {
4503
+ animateMoney(field, from, to, durationMs) {
3957
4504
  const fmt = (n) => this.host.formatCurrency(n, field === 'win');
3958
4505
  const el = this.barHost.querySelector(`[data-ge="${field}"]`);
3959
4506
  if (el)
3960
- this.moneyAnims.push(animateReadout(el, from, to, fmt));
4507
+ this.moneyAnims.push(animateReadout(el, from, to, fmt, durationMs));
3961
4508
  }
3962
4509
  openOverlay(req) {
3963
4510
  const built = this.buildOverlay(req);
@@ -3968,11 +4515,9 @@ class HtmlRenderer {
3968
4515
  }
3969
4516
  closeOverlay() {
3970
4517
  this.modalOnKey = undefined;
4518
+ this.popoverPosition = null;
3971
4519
  this.modalHost.innerHTML = '';
3972
4520
  }
3973
- refreshSoundIcon(_on) {
3974
- // Settings registers via host.setSoundRefresh; nothing extra here
3975
- }
3976
4521
  destroy() {
3977
4522
  if (this.destroyed)
3978
4523
  return Promise.resolve();
@@ -3993,6 +4538,10 @@ class HtmlRenderer {
3993
4538
  }
3994
4539
  /** Trigger a bar fit-scale pass (used by tests that stub geometry after the initial render). */
3995
4540
  fitBar() { this.applyFitScale(); }
4541
+ /** The scale factor applyFitScale() last applied to the bar — the menu popover multiplies its own
4542
+ * card by this SAME number so its typography/padding/row-heights carry the same visual weight
4543
+ * relationship the bar has, instead of ignoring the viewport like a fixed-px card would. */
4544
+ getBarScale() { return this.barScale; }
3996
4545
  // ── private ────────────────────────────────────────────────────────────────
3997
4546
  cancelMoneyAnims() {
3998
4547
  for (const cancel of this.moneyAnims)
@@ -4001,8 +4550,9 @@ class HtmlRenderer {
4001
4550
  }
4002
4551
  buildOverlay(req) {
4003
4552
  switch (req.kind) {
4004
- case 'settings': {
4005
- const root = openSettingsModal(this.host);
4553
+ case 'menu': {
4554
+ const { root, position } = openMenuPopover(this.host, this.root, () => this.getBarScale());
4555
+ this.popoverPosition = position;
4006
4556
  return { root };
4007
4557
  }
4008
4558
  case 'gameInfo': {
@@ -4046,6 +4596,7 @@ class HtmlRenderer {
4046
4596
  this.modalHost.appendChild(el);
4047
4597
  this.modalOnKey = onKey;
4048
4598
  this.fitModals();
4599
+ this.popoverPosition?.(); // measure + place now that the card is in the DOM
4049
4600
  }
4050
4601
  /** Uniformly scale every open centred card modal (.ge-sheet) down so it fits a short/narrow
4051
4602
  * popout. Covers pickers, generic + replay modals, AND the buy-bonus confirm (which is hosted
@@ -4096,13 +4647,18 @@ class HtmlRenderer {
4096
4647
  for (const row of Array.from(bar.children))
4097
4648
  need = Math.max(need, row.scrollWidth);
4098
4649
  const avail = bar.clientWidth;
4650
+ let s = 1;
4099
4651
  if (need > avail + 1 && avail > 0) {
4652
+ s = Math.max(0.4, avail / need);
4100
4653
  host.style.transformOrigin = 'bottom left';
4101
- host.style.transform = `scale(${Math.max(0.4, avail / need).toFixed(4)})`;
4654
+ host.style.transform = `scale(${s.toFixed(4)})`;
4102
4655
  }
4656
+ this.barScale = s;
4103
4657
  return;
4104
4658
  }
4659
+ let sApplied = 1;
4105
4660
  const zoomBar = (z) => {
4661
+ sApplied = z;
4106
4662
  const v = z < 0.999 ? z.toFixed(4) : '';
4107
4663
  const set = (el) => {
4108
4664
  if (!el)
@@ -4122,6 +4678,7 @@ class HtmlRenderer {
4122
4678
  if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
4123
4679
  zoomBar(s * (bar.clientWidth / bar.scrollWidth));
4124
4680
  }
4681
+ this.barScale = sApplied;
4125
4682
  this.fitReadouts();
4126
4683
  }
4127
4684
  /** Unified number-fit: shrink EVERY readout's value span (`.ge-rd-val`) with a transform-scale so
@@ -4151,6 +4708,7 @@ class HtmlRenderer {
4151
4708
  this.host.notifyResize(w, h);
4152
4709
  this.applyFitScale();
4153
4710
  this.fitModals();
4711
+ this.popoverPosition?.(); // re-place the card if the surface itself resized
4154
4712
  });
4155
4713
  this.ro.observe(this.root);
4156
4714
  }
@@ -4174,5 +4732,5 @@ function removeGameShell() {
4174
4732
  return shell.destroy();
4175
4733
  }
4176
4734
 
4177
- export { DEFAULT_ACCENT, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, SCHEMES, ShellController, createGameShell, createI18n, createShell, normalizeLang, removeGameShell, resolveConfig, resolveTheme, socialize };
4735
+ export { DEFAULT_ACCENT, DEFAULT_MENU, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, POPOVER, SCHEMES, ShellController, createGameShell, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removeGameShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
4178
4736
  //# sourceMappingURL=html.esm.js.map