@energy8platform/shell 0.6.6 → 0.7.1

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 (44) hide show
  1. package/dist/html.cjs.js +677 -109
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +207 -27
  4. package/dist/html.esm.js +670 -110
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +378 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +199 -26
  9. package/dist/index.esm.js +371 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +952 -282
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +202 -28
  14. package/dist/pixi.esm.js +945 -283
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +68 -16
  18. package/src/core/format.ts +8 -4
  19. package/src/core/icon-names.ts +30 -0
  20. package/src/core/index.ts +4 -0
  21. package/src/core/menu.ts +301 -0
  22. package/src/core/popover.ts +81 -0
  23. package/src/core/renderer.ts +13 -10
  24. package/src/core/state.ts +2 -1
  25. package/src/core/types.ts +15 -6
  26. package/src/core/version.ts +1 -1
  27. package/src/ui/html/HtmlRenderer.ts +31 -8
  28. package/src/ui/html/components/GameInfo.ts +1 -1
  29. package/src/ui/html/components/Menu.ts +153 -0
  30. package/src/ui/html/icons.ts +16 -15
  31. package/src/ui/html/primitives.ts +142 -0
  32. package/src/ui/html/shell.css.ts +21 -1
  33. package/src/ui/pixi/PixiRenderer.ts +32 -14
  34. package/src/ui/pixi/components/BottomBar.ts +80 -9
  35. package/src/ui/pixi/components/GameInfo.ts +1 -1
  36. package/src/ui/pixi/components/Menu.ts +167 -0
  37. package/src/ui/pixi/context.ts +3 -2
  38. package/src/ui/pixi/icons.ts +16 -15
  39. package/src/ui/pixi/pixi-icon.ts +15 -3
  40. package/src/ui/pixi/primitives/controls.ts +46 -0
  41. package/src/ui/pixi/primitives/popover.ts +163 -0
  42. package/src/ui/pixi/primitives/scroll.ts +9 -5
  43. package/src/ui/html/components/Settings.ts +0 -68
  44. package/src/ui/pixi/components/Settings.ts +0 -132
package/dist/html.cjs.js CHANGED
@@ -52,6 +52,240 @@ class EventEmitter {
52
52
  }
53
53
  }
54
54
 
55
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
56
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
57
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
58
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
59
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
60
+ // `node scripts/gen-icons-from-svg.mjs`.
61
+ // The single glyph-name union, shared by core (menu items) and both renderers.
62
+ const ICON_NAMES = [
63
+ 'spin',
64
+ 'turbo1',
65
+ 'autoplay',
66
+ 'stop',
67
+ 'menu',
68
+ 'minus',
69
+ 'plus',
70
+ 'gift',
71
+ 'info',
72
+ 'soundOn',
73
+ 'soundOff',
74
+ 'close',
75
+ 'back',
76
+ 'chevronRight',
77
+ 'ticket',
78
+ 'turbo2',
79
+ 'turboOff',
80
+ 'chevronUp',
81
+ 'chevronDown',
82
+ ];
83
+
84
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
85
+ function isPresetId(id) {
86
+ return PRESET_IDS.includes(id);
87
+ }
88
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
89
+ const DEFAULT_MENU = [
90
+ { id: 'sound' },
91
+ { id: 'music' },
92
+ { id: 'sfx' },
93
+ { type: 'separator' },
94
+ { id: 'gameInfo' },
95
+ ];
96
+ const isSeparator = (i) => i.type === 'separator';
97
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
98
+ function rangeBounds(item) {
99
+ const min = item.min ?? 0;
100
+ const max = item.max ?? 1;
101
+ const derivedStep = (max - min) / 20;
102
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
103
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
104
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
105
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
106
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
107
+ return { min, max, step };
108
+ }
109
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
110
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
111
+ function seedMenuValues(items, prev = {}) {
112
+ const out = {};
113
+ for (const item of items) {
114
+ if (isSeparator(item))
115
+ continue;
116
+ const type = item.type;
117
+ if (!type || isPresetId(item.id))
118
+ continue;
119
+ // A previous value only carries over when its runtime type still matches this item's kind.
120
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
121
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
122
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
123
+ // of warning.
124
+ const prevValue = prev[item.id];
125
+ if (type === 'toggle') {
126
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
127
+ }
128
+ else if (type === 'range') {
129
+ const r = item;
130
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ const percent = (v) => `${Math.round(v * 100)}%`;
136
+ function safeIcon(name) {
137
+ return name && ICON_NAMES.includes(name) ? name : undefined;
138
+ }
139
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
140
+ function warn(message) {
141
+ console.warn(`[shell] ${message}`);
142
+ }
143
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
144
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
145
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
146
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
147
+ function resolveMenu(host) {
148
+ const rows = [];
149
+ for (const item of host.menu) {
150
+ if (isSeparator(item)) {
151
+ rows.push({ kind: 'separator' });
152
+ continue;
153
+ }
154
+ const type = item.type;
155
+ if (!type) {
156
+ const row = preset(host, item);
157
+ if (row)
158
+ rows.push(row);
159
+ else
160
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
161
+ continue;
162
+ }
163
+ const row = custom(host, item, type);
164
+ if (row)
165
+ rows.push(row);
166
+ }
167
+ return rows;
168
+ }
169
+ function preset(host, item) {
170
+ const disabled = item.disabled ?? false;
171
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
172
+ switch (item.id) {
173
+ case 'sound':
174
+ return {
175
+ kind: 'toggle',
176
+ id: 'sound',
177
+ label,
178
+ disabled,
179
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
180
+ get: () => host.getMenuValue('sound') !== false,
181
+ set: (v) => host.setMenuValue('sound', v),
182
+ };
183
+ case 'music':
184
+ case 'sfx': {
185
+ const id = item.id;
186
+ return {
187
+ kind: 'range',
188
+ id,
189
+ label,
190
+ icon: safeIcon(item.icon),
191
+ disabled,
192
+ min: 0,
193
+ max: 1,
194
+ step: 0.05,
195
+ get: () => Number(host.getMenuValue(id) ?? 1),
196
+ set: (v) => host.setMenuValue(id, v),
197
+ format: percent,
198
+ };
199
+ }
200
+ case 'gameInfo':
201
+ return {
202
+ kind: 'button',
203
+ id: 'gameInfo',
204
+ label,
205
+ icon: safeIcon(item.icon) ?? 'info',
206
+ disabled,
207
+ chevron: true,
208
+ select: () => host.actions.openInfo(),
209
+ };
210
+ default:
211
+ return null;
212
+ }
213
+ }
214
+ const DEFAULT_LABELS = {
215
+ sound: 'Sound',
216
+ music: 'Music',
217
+ sfx: 'SFX',
218
+ gameInfo: 'Game info',
219
+ };
220
+ function custom(host, item, type) {
221
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
222
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
223
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
224
+ if (isPresetId(item.id)) {
225
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
226
+ }
227
+ const disabled = item.disabled ?? false;
228
+ const label = host.t(item.label ?? item.id);
229
+ const icon = safeIcon(item.icon);
230
+ if (type === 'toggle') {
231
+ const it = item;
232
+ return {
233
+ kind: 'toggle',
234
+ id: it.id,
235
+ label,
236
+ disabled,
237
+ icon: () => icon,
238
+ get: () => host.getMenuValue(it.id) === true,
239
+ set: (v) => {
240
+ host.setMenuValue(it.id, v);
241
+ it.onChange?.(v);
242
+ },
243
+ };
244
+ }
245
+ if (type === 'range') {
246
+ const it = item;
247
+ const { min, max, step } = rangeBounds(it);
248
+ if (max <= min) {
249
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
250
+ // drop the row instead, exactly like an unknown preset id.
251
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
252
+ return null;
253
+ }
254
+ return {
255
+ kind: 'range',
256
+ id: it.id,
257
+ label,
258
+ icon,
259
+ disabled,
260
+ min,
261
+ max,
262
+ step,
263
+ get: () => Number(host.getMenuValue(it.id) ?? min),
264
+ set: (v) => {
265
+ host.setMenuValue(it.id, v);
266
+ it.onChange?.(v);
267
+ },
268
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
269
+ };
270
+ }
271
+ if (type === 'button') {
272
+ const it = item;
273
+ return {
274
+ kind: 'button',
275
+ id: it.id,
276
+ label,
277
+ icon,
278
+ disabled,
279
+ chevron: it.chevron ?? false,
280
+ select: () => it.onSelect?.(),
281
+ };
282
+ }
283
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
284
+ // button row with no onSelect. Drop it visibly instead.
285
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
286
+ return null;
287
+ }
288
+
55
289
  function createInitialState(config) {
56
290
  return {
57
291
  mode: config.mode,
@@ -68,10 +302,10 @@ function createInitialState(config) {
68
302
  bonus: null,
69
303
  activeFeature: null,
70
304
  volumes: {
71
- master: clampVolume(config.volumes?.master),
72
305
  music: clampVolume(config.volumes?.music),
73
306
  sfx: clampVolume(config.volumes?.sfx),
74
307
  },
308
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
75
309
  };
76
310
  }
77
311
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -169,15 +403,19 @@ function resolveTheme(theme = {}) {
169
403
  * trailing zeros are trimmed down to (but never past) `minDecimals`, so small wins keep their
170
404
  * significant digits. Balance / bet / prices stay fixed at `minDecimals`.
171
405
  *
406
+ * Separators default to the platform convention — `.` decimal, `,` thousands (`€1,234.50`).
407
+ * A comma decimal is NOT safe as a default: players read "2,50" as 250 and believe they were
408
+ * paid far more than they were. Games that need another convention pass `currency.separator`.
409
+ *
172
410
  * Example with `maxDecimals: 4, minDecimals: 2`:
173
- * fixed → 0.0673 → 0,07 0.3 → 0,30
174
- * variable → 0.0673 → 0,0673 0.067 → 0,067 0.3 → 0,30 0 → 0,00
411
+ * fixed → 0.0673 → 0.07 0.3 → 0.30
412
+ * variable → 0.0673 → 0.0673 0.067 → 0.067 0.3 → 0.30 0 → 0.00
175
413
  */
176
414
  function formatCurrency(value, currency, variableDecimals = false) {
177
415
  const maxDecimals = currency.maxDecimals ?? 2;
178
416
  const minDecimals = Math.max(0, Math.min(maxDecimals, currency.minDecimals ?? maxDecimals));
179
- const thousands = currency.separator?.thousands ?? '.';
180
- const decimal = currency.separator?.decimal ?? ',';
417
+ const thousands = currency.separator?.thousands ?? ',';
418
+ const decimal = currency.separator?.decimal ?? '.';
181
419
  const safe = Number.isFinite(value) ? value : 0;
182
420
  // fixed callers round at minDecimals; variable callers round at maxDecimals then trim back down.
183
421
  const places = variableDecimals ? maxDecimals : minDecimals;
@@ -1455,7 +1693,7 @@ class KeyboardController {
1455
1693
 
1456
1694
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1457
1695
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1458
- const PACKAGE_VERSION = '0.6.6';
1696
+ const PACKAGE_VERSION = '0.7.1';
1459
1697
 
1460
1698
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1461
1699
  function resolveConfig(config) {
@@ -1473,6 +1711,7 @@ function resolveConfig(config) {
1473
1711
  theme: config.theme,
1474
1712
  onBonusBuy: config.onBonusBuy,
1475
1713
  volumes: config.volumes,
1714
+ menu: config.menu,
1476
1715
  version: config.version ?? '1.0.0',
1477
1716
  isSocial: config.isSocial ?? false,
1478
1717
  replay: config.replay ?? config.mode === 'replay',
@@ -1493,8 +1732,9 @@ class ShellController extends EventEmitter {
1493
1732
  i18n;
1494
1733
  kbd;
1495
1734
  overlay = null;
1496
- soundRefresh = null;
1497
- volumeRefresh = null;
1735
+ menuItems;
1736
+ menuRefresh = null;
1737
+ overlayKind = null;
1498
1738
  prevBalance;
1499
1739
  prevWin;
1500
1740
  destroyed = false;
@@ -1505,6 +1745,7 @@ class ShellController extends EventEmitter {
1505
1745
  this.config = resolveConfig(config);
1506
1746
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1507
1747
  this.state = createInitialState(this.config);
1748
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1508
1749
  this.tokens = resolveTheme(this.config.theme);
1509
1750
  this.prevBalance = this.state.balance;
1510
1751
  this.prevWin = this.state.win;
@@ -1638,14 +1879,21 @@ class ShellController extends EventEmitter {
1638
1879
  show(req) {
1639
1880
  this.closeModal();
1640
1881
  this.overlay = this.renderer.openOverlay(req) ?? null;
1882
+ this.overlayKind = this.overlay ? req.kind : null;
1641
1883
  }
1884
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1642
1885
  openMenu() {
1886
+ if (this.overlayKind === 'menu') {
1887
+ this.closeModal();
1888
+ return;
1889
+ }
1643
1890
  this.emit('menuOpen');
1644
- this.openSettings();
1891
+ this.show({ kind: 'menu' });
1645
1892
  }
1893
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1646
1894
  openSettings() {
1647
1895
  this.emit('settingsOpen');
1648
- this.show({ kind: 'settings' });
1896
+ this.openMenu();
1649
1897
  }
1650
1898
  openInfo() {
1651
1899
  this.emit('infoOpen');
@@ -1677,35 +1925,72 @@ class ShellController extends EventEmitter {
1677
1925
  if (!this.overlay)
1678
1926
  return;
1679
1927
  this.overlay = null;
1680
- this.soundRefresh = null;
1681
- this.volumeRefresh = null;
1928
+ this.overlayKind = null;
1929
+ this.menuRefresh = null;
1682
1930
  this.renderer.closeOverlay();
1683
1931
  }
1684
1932
  // ── sound ──────────────────────────────────────────────────────────────────
1685
1933
  setSound(on) {
1686
1934
  this.soundOn = on;
1687
1935
  this.emit('settingChange', { key: 'sound', value: on });
1688
- this.soundRefresh?.(on);
1689
- this.renderer.refreshSoundIcon?.(on);
1690
- }
1691
- setSoundRefresh(fn) {
1692
- this.soundRefresh = fn;
1936
+ this.menuRefresh?.('sound', on);
1693
1937
  }
1694
1938
  // ── volume ─────────────────────────────────────────────────────────────────
1695
1939
  getVolume(key) {
1696
1940
  return this.state.volumes[key];
1697
1941
  }
1698
1942
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1699
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1700
- * live-updates the slider if the overlay is currently open. */
1943
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1944
+ * live-updates the slider if the menu is currently open. */
1701
1945
  setVolume(key, value) {
1702
1946
  const v = Math.max(0, Math.min(1, value));
1703
1947
  this.state.volumes[key] = v;
1704
1948
  this.emit('settingChange', { key, value: v });
1705
- this.volumeRefresh?.(key, v);
1949
+ this.menuRefresh?.(key, v);
1950
+ }
1951
+ // ── menu ───────────────────────────────────────────────────────────────────
1952
+ get menu() {
1953
+ return this.menuItems;
1954
+ }
1955
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1956
+ setMenu(items) {
1957
+ this.menuItems = items;
1958
+ this.state.menu = seedMenuValues(items, this.state.menu);
1959
+ if (this.overlayKind === 'menu')
1960
+ this.show({ kind: 'menu' });
1706
1961
  }
1707
- setVolumeRefresh(fn) {
1708
- this.volumeRefresh = fn;
1962
+ getMenuValue(id) {
1963
+ if (id === 'sound')
1964
+ return this.soundOn;
1965
+ if (id === 'music' || id === 'sfx')
1966
+ return this.state.volumes[id];
1967
+ return this.state.menu[id];
1968
+ }
1969
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1970
+ setMenuValue(id, value) {
1971
+ if (id === 'sound') {
1972
+ this.setSound(value !== false);
1973
+ return;
1974
+ }
1975
+ if (id === 'music' || id === 'sfx') {
1976
+ this.setVolume(id, Number(value));
1977
+ return;
1978
+ }
1979
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1980
+ this.state.menu[id] = next;
1981
+ this.emit('settingChange', { key: id, value: next });
1982
+ this.menuRefresh?.(id, next);
1983
+ }
1984
+ setMenuRefresh(fn) {
1985
+ this.menuRefresh = fn;
1986
+ }
1987
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1988
+ clampRange(id, value) {
1989
+ const item = this.menuItems.find((i) => i.id === id);
1990
+ if (!item || item.type !== 'range')
1991
+ return value;
1992
+ const { min, max } = rangeBounds(item);
1993
+ return Math.max(min, Math.min(max, value));
1709
1994
  }
1710
1995
  // ── features ─────────────────────────────────────────────────────────────────
1711
1996
  activateFeature(bonus) {
@@ -1815,6 +2100,11 @@ class ShellController extends EventEmitter {
1815
2100
  if (this.destroyed)
1816
2101
  return Promise.resolve();
1817
2102
  this.destroyed = true;
2103
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2104
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2105
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2106
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2107
+ this.closeModal();
1818
2108
  if (typeof document !== 'undefined') {
1819
2109
  this.kbd?.detach();
1820
2110
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1824,6 +2114,64 @@ class ShellController extends EventEmitter {
1824
2114
  }
1825
2115
  }
1826
2116
 
2117
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2118
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2119
+ const POPOVER = {
2120
+ /** Keep-out from the surface edges. */
2121
+ margin: 8,
2122
+ /** Space between the anchor and the card. */
2123
+ gap: 8,
2124
+ /** Minimum distance from the arrow tip to either rounded corner. */
2125
+ arrowInset: 14,
2126
+ /** A card shorter than this does not fit — flip to the other side instead. */
2127
+ minH: 120,
2128
+ minW: 220,
2129
+ maxW: 320,
2130
+ };
2131
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2132
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2133
+ function popoverWidth(surfaceW, contentW) {
2134
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2135
+ return Math.max(0, clamp(contentW, Math.min(POPOVER.minW, hi), hi));
2136
+ }
2137
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2138
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2139
+ *
2140
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2141
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2142
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2143
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2144
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2145
+ function placePopover(anchor, surface, size, pointer = null) {
2146
+ const { margin, gap, arrowInset, minH } = POPOVER;
2147
+ if (!anchor) {
2148
+ const maxH = Math.max(0, surface.h - margin * 2);
2149
+ const h = Math.min(size.h, maxH);
2150
+ const rawY = (surface.h - h) / 2;
2151
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2152
+ return {
2153
+ x: Math.max(margin, (surface.w - size.w) / 2),
2154
+ y,
2155
+ maxH,
2156
+ arrowX: -1,
2157
+ below: false,
2158
+ };
2159
+ }
2160
+ const spaceAbove = anchor.y - gap - margin;
2161
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2162
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2163
+ // genuinely more room on the other side.
2164
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2165
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2166
+ const h = Math.min(size.h, maxH);
2167
+ const x = clamp(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2168
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2169
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2170
+ const arrowAnchor = pointer ?? anchor;
2171
+ const arrowX = clamp(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2172
+ return { x, y, maxH, arrowX, below };
2173
+ }
2174
+
1827
2175
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1828
2176
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1829
2177
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2031,7 +2379,10 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2031
2379
  border-radius:16px; background:var(--shell-plaque-glass); color:#fff; font-size:14px; font-weight:600; }
2032
2380
  #${SHELL_ROOT_ID} .ge-ov-row .ge-grow { flex:1; text-align:left; }
2033
2381
  #${SHELL_ROOT_ID} button.ge-ov-row { cursor:pointer; font-family:inherit; transition:background .12s ease, color .12s ease; }
2034
- #${SHELL_ROOT_ID} button.ge-ov-row:hover { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
2382
+ #${SHELL_ROOT_ID} button.ge-ov-row:hover:not([disabled]) { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
2383
+ /* disabled rows — mirrors Pixi's box.alpha = 0.5 for all three row kinds. A <button> row carries
2384
+ the native attribute directly; a toggle/range row is a <div> wrapper, so it gets .ge-disabled. */
2385
+ #${SHELL_ROOT_ID} .ge-ov-row[disabled], #${SHELL_ROOT_ID} .ge-ov-row.ge-disabled { opacity:.5; cursor:default; }
2035
2386
  #${SHELL_ROOT_ID} .ge-ov-row.ge-col { flex-direction:column; align-items:stretch; gap:10px; }
2036
2387
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head { display:flex; justify-content:space-between; align-items:center; }
2037
2388
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head .ge-val { color:var(--shell-plaque-label); font-variant-numeric:tabular-nums; font-weight:700; }
@@ -2042,6 +2393,7 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2042
2393
  #${SHELL_ROOT_ID} .ge-toggle i { position:absolute; top:2px; left:2px; width:20px; height:20px; border-radius:50%;
2043
2394
  background:#fff; transition:left .12s ease; }
2044
2395
  #${SHELL_ROOT_ID} .ge-toggle.ge-on i { left:20px; }
2396
+ #${SHELL_ROOT_ID} .ge-toggle:disabled { cursor:default; }
2045
2397
  /* sound on/off — speaker icon button (replaces the toggle) */
2046
2398
  #${SHELL_ROOT_ID} .ge-snd { pointer-events:auto; cursor:pointer; border:none; background:none; padding:0;
2047
2399
  width:36px; height:36px; display:flex; align-items:center; justify-content:center; font-size:24px;
@@ -2050,6 +2402,22 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2050
2402
  #${SHELL_ROOT_ID} .ge-snd:hover { color:var(--shell-accent); }
2051
2403
  #${SHELL_ROOT_ID} .ge-snd:active { transform:scale(.92); }
2052
2404
 
2405
+ /* bar menu popover — light dismiss (no dim, no blur), card anchored to the burger */
2406
+ #${SHELL_ROOT_ID} .ge-pop-layer { position:absolute; inset:0; z-index:55; pointer-events:auto; }
2407
+ #${SHELL_ROOT_ID} .ge-pop { position:absolute; box-sizing:border-box; display:flex; flex-direction:column;
2408
+ padding:8px; border-radius:18px; background:var(--shell-plaque-dark);
2409
+ box-shadow:0 14px 38px rgba(0,0,0,.5); backdrop-filter:blur(12px) saturate(120%);
2410
+ -webkit-backdrop-filter:blur(12px) saturate(120%); animation:ge-ov-in .12s ease-out; }
2411
+ #${SHELL_ROOT_ID} .ge-pop-body { overflow-y:auto; overflow-x:hidden; min-height:0; }
2412
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row { padding:10px 12px; margin-bottom:6px; font-size:13px; }
2413
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row:last-child { margin-bottom:0; }
2414
+ #${SHELL_ROOT_ID} .ge-pop-sep { height:1px; margin:6px 4px; background:var(--shell-plaque-line); opacity:.5; }
2415
+ #${SHELL_ROOT_ID} .ge-pop-arrow { position:absolute; bottom:-7px; width:14px; height:14px; margin-left:-7px;
2416
+ background:var(--shell-plaque-dark); transform:rotate(45deg); border-radius:3px; }
2417
+ #${SHELL_ROOT_ID} .ge-pop.ge-pop-below .ge-pop-arrow { bottom:auto; top:-7px; }
2418
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-icon { flex:0 0 auto; width:20px; font-size:20px; display:flex; }
2419
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-chev { flex:0 0 auto; width:16px; font-size:16px; color:var(--shell-muted); display:flex; }
2420
+
2053
2421
  /* game info — each section is its own glass plaque; body text sized for comfortable reading */
2054
2422
  #${SHELL_ROOT_ID} .ge-gi-sec { margin-bottom:12px; background:var(--shell-plaque-glass);
2055
2423
  border-radius:16px; padding:16px 18px; }
@@ -2480,25 +2848,25 @@ function countUp(el, from, to, fmt, durationMs = 450) {
2480
2848
  // GraphicsContext.svg. To change an icon: edit icons.svg, then run
2481
2849
  // `node scripts/gen-icons-from-svg.mjs`.
2482
2850
  const SVGS = {
2483
- 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>`,
2484
- 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>`,
2485
- 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>`,
2851
+ 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>`,
2852
+ 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>`,
2853
+ 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>`,
2486
2854
  stop: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="1.6" width="20.8" height="20.8" rx="2.9"/></g>`,
2487
2855
  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>`,
2488
2856
  minus: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="9.86" width="20.8" height="4.28" rx="2.11"/></g>`,
2489
- 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>`,
2857
+ 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>`,
2490
2858
  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)"/>`,
2491
- 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>`,
2492
- 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>`,
2859
+ 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>`,
2860
+ 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>`,
2493
2861
  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>`,
2494
- 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>`,
2495
- 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>`,
2496
- 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>`,
2862
+ 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>`,
2863
+ 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>`,
2864
+ 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>`,
2497
2865
  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>`,
2498
- 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>`,
2499
- 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>`,
2500
- 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>`,
2501
- 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>`,
2866
+ 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>`,
2867
+ 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>`,
2868
+ 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>`,
2869
+ 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>`,
2502
2870
  };
2503
2871
  /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
2504
2872
  function icon(name) {
@@ -2857,77 +3225,246 @@ function createOverlay(opts) {
2857
3225
  root.append(head, scroll);
2858
3226
  return { root, body, scroll };
2859
3227
  }
3228
+ /** A light-dismiss popover: a transparent full-surface layer (closes on pointerdown) holding a
3229
+ * card with an arrow that points at `pointer`. Append rows to `body`; call `position()` after the
3230
+ * card is in the DOM and again on resize. */
3231
+ function createPopover(opts) {
3232
+ const root = document.createElement('div');
3233
+ root.className = 'ge-pop-layer';
3234
+ root.dataset.ge = opts.ge;
3235
+ const card = document.createElement('div');
3236
+ card.className = 'ge-pop';
3237
+ card.dataset.ge = 'menu-card';
3238
+ const body = document.createElement('div');
3239
+ body.className = 'ge-pop-body';
3240
+ const arrow = document.createElement('span');
3241
+ arrow.className = 'ge-pop-arrow';
3242
+ card.append(body, arrow);
3243
+ root.appendChild(card);
3244
+ // Clicks inside the card must not reach the dismiss layer.
3245
+ card.addEventListener('pointerdown', (e) => e.stopPropagation());
3246
+ root.addEventListener('pointerdown', opts.onClose);
3247
+ const resolveEl = (v) => typeof v === 'function' ? v() : (v ?? null);
3248
+ /** A rect in surface coordinates, or null when unresolved/fully zero-sized (a zero-HEIGHT rect —
3249
+ * e.g. a not-yet-laid-out anchor — is still considered valid, matching placePopover's own rule). */
3250
+ const rectOf = (el, surfaceRect) => {
3251
+ if (!el)
3252
+ return null;
3253
+ const r = el.getBoundingClientRect();
3254
+ if (r.width <= 0 && r.height <= 0)
3255
+ return null;
3256
+ return { x: r.left - surfaceRect.left, y: r.top - surfaceRect.top, w: r.width, h: r.height };
3257
+ };
3258
+ const position = () => {
3259
+ const surfaceRect = opts.surface.getBoundingClientRect();
3260
+ const surface = { w: surfaceRect.width || opts.surface.clientWidth, h: surfaceRect.height || opts.surface.clientHeight };
3261
+ if (surface.w <= 0 || surface.h <= 0)
3262
+ return;
3263
+ const s = opts.scale?.() ?? 1;
3264
+ // 1. Clear a prior run's constraints (transform + width + max-height + left) before measuring —
3265
+ // otherwise scrollWidth/offsetHeight report the already-scaled/clamped box (not the natural
3266
+ // content size) on every call after the first, and the card can shrink to fit a
3267
+ // narrower/shorter surface but never grow back when the surface widens/grows again. An uncleared
3268
+ // max-height is the worse of the two: placePopover positions a card sized for the STALE clamped
3269
+ // height, then the un-clamped natural height (restored below) springs back afterward — so the
3270
+ // rendered card can overlap the plate or run off the surface edge until reopened. The transform
3271
+ // doesn't affect layout either way, but clearing it keeps every pass measuring the same way.
3272
+ // `left` matters too: `.ge-pop` is absolutely positioned inside an `inset:0` layer, so with the
3273
+ // previous pass's `left` still applied, its shrink-to-fit width is bounded by (layerWidth − left)
3274
+ // instead of the card's true natural width. Invisible at scale 1; a scale below 1 lays the card
3275
+ // out at up to 1/s its on-screen width, so a stale left — only ever a few hundred px — can clip it.
3276
+ card.style.transform = '';
3277
+ card.style.width = '';
3278
+ card.style.maxHeight = '';
3279
+ card.style.left = '0px';
3280
+ const naturalW = card.scrollWidth || POPOVER.minW;
3281
+ // 2. Resolve the ON-SCREEN width from the natural (unscaled) width scaled up to screen units,
3282
+ // then set the LOCAL style.width so the card renders at that resolved width once scaled — and
3283
+ // re-measure the height at that width (wrapping may have changed it).
3284
+ const resolvedW = popoverWidth(surface.w, naturalW * s);
3285
+ card.style.width = `${resolvedW / s}px`;
3286
+ const naturalH = card.offsetHeight || POPOVER.minH;
3287
+ // 3. Resolve plate/pointer in surface coordinates and place using SCREEN-space size —
3288
+ // placePopover works in surface pixels throughout, so both the anchor rects and `size` here are
3289
+ // screen units even though the card's own layout (width/height above) is still LOCAL/unscaled.
3290
+ const plateEl = resolveEl(opts.plate);
3291
+ const pointerEl = resolveEl(opts.pointer);
3292
+ let plate = rectOf(plateEl, surfaceRect) ?? rectOf(pointerEl, surfaceRect);
3293
+ // Extend the plate's TOP edge upward to a popped-out hero's true top (e.g. the mobile SPIN/FS
3294
+ // control, taller than its row) — bottom edge untouched. Both rects are already in the SAME
3295
+ // surface-coordinate space (post any bar-scale transform), so this is a plain coordinate compare,
3296
+ // no separate unit conversion needed.
3297
+ const overflowEl = resolveEl(opts.plateOverflowTop);
3298
+ if (plate) {
3299
+ const overflowRect = rectOf(overflowEl, surfaceRect);
3300
+ if (overflowRect && overflowRect.y < plate.y) {
3301
+ plate = { ...plate, h: plate.h + (plate.y - overflowRect.y), y: overflowRect.y };
3302
+ }
3303
+ }
3304
+ const pointer = rectOf(pointerEl, surfaceRect);
3305
+ const p = placePopover(plate, surface, { w: resolvedW, h: naturalH * s }, pointer);
3306
+ // 4. Apply. maxHeight and the arrow's offset are LOCAL (unscaled) too, since both live inside the
3307
+ // scaled card; transform-origin:top left keeps left/top (screen units) as the card's visual
3308
+ // top-left regardless of `s`, and — since a transform doesn't affect layout — the next
3309
+ // measurement pass stays clean without needing any extra bookkeeping.
3310
+ card.style.left = `${p.x}px`;
3311
+ card.style.top = `${p.y}px`;
3312
+ card.style.maxHeight = `${p.maxH / s}px`;
3313
+ card.style.transformOrigin = 'top left';
3314
+ card.style.transform = Math.abs(s - 1) > 0.001 ? `scale(${s})` : '';
3315
+ card.classList.toggle('ge-pop-below', p.below);
3316
+ if (p.arrowX < 0)
3317
+ arrow.style.display = 'none';
3318
+ else {
3319
+ arrow.style.display = '';
3320
+ arrow.style.left = `${p.arrowX / s}px`;
3321
+ }
3322
+ };
3323
+ return { root, card, body, position };
3324
+ }
2860
3325
 
2861
- function openSettingsModal(host) {
2862
- const { root, body } = createOverlay({ title: host.t('Settings'), onClose: () => host.actions.closeOverlay() });
2863
- root.dataset.ge = 'settings-modal';
2864
- // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
2865
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
2866
- const sound = (() => {
3326
+ /** The bar menu, as a light-dismiss popover anchored to the burger. Rows come from the core model,
3327
+ * so DOM and Pixi always show the same list in the same order. */
3328
+ function openMenuPopover(host, surface, getScale) {
3329
+ const pop = createPopover({
3330
+ ge: 'menu-popover',
3331
+ surface,
3332
+ // Resolved lazily on every position() call, not captured once: HtmlRenderer's renderBar() (run
3333
+ // on every resize, and on ~20 other state changes) rebuilds the bottom bar from scratch, so the
3334
+ // plate/burger are brand-new elements after it — a captured reference would already be detached
3335
+ // by the next position() call and the card would silently recentre with its arrow hidden.
3336
+ // The plate is the bar's own plaque — the continuous dark panel wide, the controls row mobile
3337
+ // (NOT the info pill below it) — so the card sits flush with the WHOLE bar, not just the burger.
3338
+ plate: () => surface.querySelector(host.layout === 'mobile' ? '.ge-m-controls' : '.ge-bar-panel'),
3339
+ pointer: () => surface.querySelector('[data-ge="menu"]'),
3340
+ // Mobile only: the SPIN disc / FS counter is taller than `.ge-m-controls` and centred inside it
3341
+ // (`align-items:center` over a shorter fixed-height row), so it pops above the row's own top edge
3342
+ // — see createPopover's `plateOverflowTop` doc comment. Exactly one of the two (or neither, e.g. a
3343
+ // replay with no free spins) is ever rendered, so a combined selector resolves to whichever is
3344
+ // current. Wide's plate already contains its content, so this deliberately resolves to null there.
3345
+ plateOverflowTop: () => host.layout === 'mobile'
3346
+ ? surface.querySelector('[data-ge="spin"], [data-ge="fs-counter"]')
3347
+ : null,
3348
+ scale: getScale,
3349
+ onClose: () => host.actions.closeOverlay(),
3350
+ });
3351
+ const updaters = {};
3352
+ for (const row of resolveMenu(host)) {
3353
+ pop.body.appendChild(buildRow(host, row, updaters, () => pop.position()));
3354
+ }
3355
+ // Live updates while open (host.setMenuValue / setSound / setVolume); cleared by the controller.
3356
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
3357
+ return { root: pop.root, position: pop.position };
3358
+ }
3359
+ function glyph(name, cls) {
3360
+ if (!name)
3361
+ return null;
3362
+ const el = document.createElement('span');
3363
+ el.className = cls;
3364
+ el.innerHTML = icon(name);
3365
+ return el;
3366
+ }
3367
+ function buildRow(host, row, updaters, reposition) {
3368
+ if (row.kind === 'separator') {
3369
+ const sep = document.createElement('div');
3370
+ sep.className = 'ge-pop-sep';
3371
+ sep.dataset.ge = 'menu-sep';
3372
+ return sep;
3373
+ }
3374
+ if (row.kind === 'button') {
3375
+ // The row hook goes on a wrapper so both `menu-row-<id>` (order assertions) and
3376
+ // `menu-item-<id>` (the clickable control) exist, as they do for toggle/range rows.
3377
+ const wrap = document.createElement('div');
3378
+ wrap.dataset.ge = `menu-row-${row.id}`;
3379
+ const btn = document.createElement('button');
3380
+ btn.className = 'ge-ov-row';
3381
+ btn.dataset.ge = `menu-item-${row.id}`;
3382
+ btn.disabled = row.disabled;
3383
+ const label = document.createElement('span');
3384
+ label.className = 'ge-grow';
3385
+ label.textContent = row.label;
3386
+ const ico = glyph(row.icon, 'ge-mi-icon');
3387
+ const chev = row.chevron ? glyph('chevronRight', 'ge-mi-chev') : null;
3388
+ for (const el of [ico, label, chev])
3389
+ if (el)
3390
+ btn.appendChild(el);
3391
+ btn.addEventListener('click', () => {
3392
+ if (btn.disabled)
3393
+ return;
3394
+ host.actions.closeOverlay();
3395
+ row.select();
3396
+ });
3397
+ wrap.appendChild(btn);
3398
+ return wrap;
3399
+ }
3400
+ if (row.kind === 'toggle') {
3401
+ const el = document.createElement('div');
3402
+ el.className = 'ge-ov-row';
3403
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
3404
+ el.dataset.ge = `menu-row-${row.id}`;
3405
+ const ico = glyph(row.icon(row.get()), 'ge-mi-icon');
3406
+ const label = document.createElement('span');
3407
+ label.className = 'ge-grow';
3408
+ label.textContent = row.label;
2867
3409
  const btn = document.createElement('button');
2868
- btn.className = 'ge-snd';
2869
- btn.dataset.ge = 'setting-sound';
2870
- btn.setAttribute('aria-label', host.t('Sound'));
2871
- const paint = (on) => {
2872
- btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
2873
- btn.classList.toggle('ge-active', on);
2874
- btn.setAttribute('aria-pressed', String(on));
3410
+ btn.className = 'ge-toggle';
3411
+ btn.dataset.ge = `menu-item-${row.id}`;
3412
+ btn.disabled = row.disabled;
3413
+ btn.innerHTML = '<i></i>';
3414
+ const paint = (v) => {
3415
+ btn.classList.toggle('ge-on', v);
3416
+ btn.setAttribute('aria-pressed', String(v));
3417
+ const next = row.icon(v);
3418
+ if (ico && next)
3419
+ ico.innerHTML = icon(next);
2875
3420
  };
2876
- paint(host.soundOn);
2877
- btn.addEventListener('click', () => host.setSound(!host.soundOn));
2878
- // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
2879
- host.setSoundRefresh(paint);
2880
- const row = document.createElement('div');
2881
- row.className = 'ge-ov-row';
2882
- row.innerHTML = `<span class="ge-grow">${host.t('Sound')}</span>`;
2883
- row.appendChild(btn);
2884
- return row;
2885
- })();
2886
- body.appendChild(sound);
2887
- // Volume sliders — full-width column rows with a live value readout. Positions are read from the
2888
- // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
2889
- // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
2890
- const updaters = {};
2891
- const slider = (key, label) => {
2892
- const row = document.createElement('div');
2893
- row.className = 'ge-ov-row ge-col';
2894
- const head = document.createElement('div');
2895
- head.className = 'ge-row-head';
2896
- const val = document.createElement('span');
2897
- val.className = 'ge-val';
2898
- head.innerHTML = `<span>${label}</span>`;
2899
- head.appendChild(val);
2900
- const input = document.createElement('input');
2901
- input.type = 'range';
2902
- input.min = '0';
2903
- input.max = '1';
2904
- input.step = '0.05';
2905
- input.className = 'ge-slider';
2906
- input.dataset.ge = `setting-${key}`;
2907
- const paint = (v) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
2908
- paint(host.getVolume(key));
2909
- input.addEventListener('input', () => {
2910
- val.textContent = `${Math.round(Number(input.value) * 100)}%`;
2911
- host.setVolume(key, Number(input.value));
3421
+ paint(row.get());
3422
+ btn.addEventListener('click', () => {
3423
+ if (!btn.disabled)
3424
+ row.set(!row.get());
2912
3425
  });
2913
- updaters[key] = paint;
2914
- row.append(head, input);
2915
- return row;
3426
+ updaters[row.id] = (v) => paint(v === true);
3427
+ if (ico)
3428
+ el.appendChild(ico);
3429
+ el.append(label, btn);
3430
+ return el;
3431
+ }
3432
+ // range
3433
+ const el = document.createElement('div');
3434
+ el.className = 'ge-ov-row ge-col';
3435
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
3436
+ el.dataset.ge = `menu-row-${row.id}`;
3437
+ const head = document.createElement('div');
3438
+ head.className = 'ge-row-head';
3439
+ const name = document.createElement('span');
3440
+ name.textContent = row.label;
3441
+ const val = document.createElement('span');
3442
+ val.className = 'ge-val';
3443
+ head.append(name, val);
3444
+ const input = document.createElement('input');
3445
+ input.type = 'range';
3446
+ input.className = 'ge-slider';
3447
+ input.dataset.ge = `menu-item-${row.id}`;
3448
+ input.min = String(row.min);
3449
+ input.max = String(row.max);
3450
+ input.step = String(row.step);
3451
+ input.disabled = row.disabled;
3452
+ const paint = (v) => {
3453
+ input.value = String(v);
3454
+ val.textContent = row.format(v);
3455
+ };
3456
+ paint(row.get());
3457
+ input.addEventListener('input', () => {
3458
+ const v = Number(input.value);
3459
+ val.textContent = row.format(v);
3460
+ row.set(v);
3461
+ });
3462
+ updaters[row.id] = (v) => {
3463
+ paint(Number(v));
3464
+ reposition();
2916
3465
  };
2917
- body.appendChild(slider('master', host.t('Master volume')));
2918
- body.appendChild(slider('music', host.t('Music')));
2919
- body.appendChild(slider('sfx', host.t('SFX')));
2920
- // Live-update sliders when volume changes via host.setVolume (shell clears on close).
2921
- host.setVolumeRefresh((key, v) => updaters[key]?.(v));
2922
- // Game info — full-width row button that opens its own overlay
2923
- const gameInfo = document.createElement('button');
2924
- gameInfo.className = 'ge-ov-row';
2925
- gameInfo.dataset.ge = 'game-info-btn';
2926
- gameInfo.style.marginTop = '6px';
2927
- 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>`;
2928
- gameInfo.addEventListener('click', () => { root.remove(); host.actions.openInfo(); });
2929
- body.appendChild(gameInfo);
2930
- return root;
3466
+ el.append(head, input);
3467
+ return el;
2931
3468
  }
2932
3469
 
2933
3470
  /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
@@ -2937,7 +3474,7 @@ function openGameInfoModal(host) {
2937
3474
  const { root, body, scroll } = createOverlay({
2938
3475
  title: host.t('Game info'),
2939
3476
  onClose: () => host.actions.closeOverlay(),
2940
- onBack: () => { root.remove(); host.actions.openSettings(); },
3477
+ onBack: () => { root.remove(); host.actions.openMenu(); },
2941
3478
  });
2942
3479
  root.dataset.ge = 'info-modal';
2943
3480
  const rawSections = host.config.gameInfo.sections ?? [];
@@ -3910,7 +4447,11 @@ class HtmlRenderer {
3910
4447
  ro = null;
3911
4448
  moneyAnims = [];
3912
4449
  modalOnKey;
4450
+ popoverPosition = null;
3913
4451
  destroyed = false;
4452
+ /** The scale factor last applied to the bar by applyFitScale() — exposed so the menu popover can
4453
+ * match it exactly (see getBarScale) rather than re-derive its own, possibly-disagreeing, value. */
4454
+ barScale = 1;
3914
4455
  /** MutationObserver for buy-bonus confirm fit — fires fitModals() when nodes are added
3915
4456
  * inside the modalHost (e.g. the confirm dialog appended after the grid is open). */
3916
4457
  mutObs = null;
@@ -3953,6 +4494,14 @@ class HtmlRenderer {
3953
4494
  this.barHost.innerHTML = '';
3954
4495
  this.barHost.appendChild(renderBottomBar(this.host));
3955
4496
  this.applyFitScale();
4497
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
4498
+ // which can change barScale and/or the plate rect (e.g. a WIN pill appearing mid-autoplay
4499
+ // retriggers the overflow-tightening branch). Only the resize hook used to reposition an open
4500
+ // popover, so a live bar-content change while the menu was open left the card at the stale
4501
+ // scale/position until the next resize. `popoverPosition` only reads plate/pointer/scale getters
4502
+ // and writes card styles — no path back into renderBar() — so this cannot recurse; it's `null`
4503
+ // whenever no popover is open (or a different overlay kind is), so this cannot throw either.
4504
+ this.popoverPosition?.();
3956
4505
  }
3957
4506
  setLayout() {
3958
4507
  this.renderBar();
@@ -3972,11 +4521,9 @@ class HtmlRenderer {
3972
4521
  }
3973
4522
  closeOverlay() {
3974
4523
  this.modalOnKey = undefined;
4524
+ this.popoverPosition = null;
3975
4525
  this.modalHost.innerHTML = '';
3976
4526
  }
3977
- refreshSoundIcon(_on) {
3978
- // Settings registers via host.setSoundRefresh; nothing extra here
3979
- }
3980
4527
  destroy() {
3981
4528
  if (this.destroyed)
3982
4529
  return Promise.resolve();
@@ -3997,6 +4544,10 @@ class HtmlRenderer {
3997
4544
  }
3998
4545
  /** Trigger a bar fit-scale pass (used by tests that stub geometry after the initial render). */
3999
4546
  fitBar() { this.applyFitScale(); }
4547
+ /** The scale factor applyFitScale() last applied to the bar — the menu popover multiplies its own
4548
+ * card by this SAME number so its typography/padding/row-heights carry the same visual weight
4549
+ * relationship the bar has, instead of ignoring the viewport like a fixed-px card would. */
4550
+ getBarScale() { return this.barScale; }
4000
4551
  // ── private ────────────────────────────────────────────────────────────────
4001
4552
  cancelMoneyAnims() {
4002
4553
  for (const cancel of this.moneyAnims)
@@ -4005,8 +4556,9 @@ class HtmlRenderer {
4005
4556
  }
4006
4557
  buildOverlay(req) {
4007
4558
  switch (req.kind) {
4008
- case 'settings': {
4009
- const root = openSettingsModal(this.host);
4559
+ case 'menu': {
4560
+ const { root, position } = openMenuPopover(this.host, this.root, () => this.getBarScale());
4561
+ this.popoverPosition = position;
4010
4562
  return { root };
4011
4563
  }
4012
4564
  case 'gameInfo': {
@@ -4050,6 +4602,7 @@ class HtmlRenderer {
4050
4602
  this.modalHost.appendChild(el);
4051
4603
  this.modalOnKey = onKey;
4052
4604
  this.fitModals();
4605
+ this.popoverPosition?.(); // measure + place now that the card is in the DOM
4053
4606
  }
4054
4607
  /** Uniformly scale every open centred card modal (.ge-sheet) down so it fits a short/narrow
4055
4608
  * popout. Covers pickers, generic + replay modals, AND the buy-bonus confirm (which is hosted
@@ -4100,13 +4653,18 @@ class HtmlRenderer {
4100
4653
  for (const row of Array.from(bar.children))
4101
4654
  need = Math.max(need, row.scrollWidth);
4102
4655
  const avail = bar.clientWidth;
4656
+ let s = 1;
4103
4657
  if (need > avail + 1 && avail > 0) {
4658
+ s = Math.max(0.4, avail / need);
4104
4659
  host.style.transformOrigin = 'bottom left';
4105
- host.style.transform = `scale(${Math.max(0.4, avail / need).toFixed(4)})`;
4660
+ host.style.transform = `scale(${s.toFixed(4)})`;
4106
4661
  }
4662
+ this.barScale = s;
4107
4663
  return;
4108
4664
  }
4665
+ let sApplied = 1;
4109
4666
  const zoomBar = (z) => {
4667
+ sApplied = z;
4110
4668
  const v = z < 0.999 ? z.toFixed(4) : '';
4111
4669
  const set = (el) => {
4112
4670
  if (!el)
@@ -4126,6 +4684,7 @@ class HtmlRenderer {
4126
4684
  if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
4127
4685
  zoomBar(s * (bar.clientWidth / bar.scrollWidth));
4128
4686
  }
4687
+ this.barScale = sApplied;
4129
4688
  this.fitReadouts();
4130
4689
  }
4131
4690
  /** Unified number-fit: shrink EVERY readout's value span (`.ge-rd-val`) with a transform-scale so
@@ -4155,6 +4714,7 @@ class HtmlRenderer {
4155
4714
  this.host.notifyResize(w, h);
4156
4715
  this.applyFitScale();
4157
4716
  this.fitModals();
4717
+ this.popoverPosition?.(); // re-place the card if the surface itself resized
4158
4718
  });
4159
4719
  this.ro.observe(this.root);
4160
4720
  }
@@ -4179,17 +4739,25 @@ function removeGameShell() {
4179
4739
  }
4180
4740
 
4181
4741
  exports.DEFAULT_ACCENT = DEFAULT_ACCENT;
4742
+ exports.DEFAULT_MENU = DEFAULT_MENU;
4182
4743
  exports.GameShell = ShellController;
4183
4744
  exports.HtmlRenderer = HtmlRenderer;
4184
4745
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
4746
+ exports.POPOVER = POPOVER;
4185
4747
  exports.SCHEMES = SCHEMES;
4186
4748
  exports.ShellController = ShellController;
4187
4749
  exports.createGameShell = createGameShell;
4188
4750
  exports.createI18n = createI18n;
4189
4751
  exports.createShell = createShell;
4752
+ exports.isPresetId = isPresetId;
4190
4753
  exports.normalizeLang = normalizeLang;
4754
+ exports.placePopover = placePopover;
4755
+ exports.popoverWidth = popoverWidth;
4756
+ exports.rangeBounds = rangeBounds;
4191
4757
  exports.removeGameShell = removeGameShell;
4192
4758
  exports.resolveConfig = resolveConfig;
4759
+ exports.resolveMenu = resolveMenu;
4193
4760
  exports.resolveTheme = resolveTheme;
4761
+ exports.seedMenuValues = seedMenuValues;
4194
4762
  exports.socialize = socialize;
4195
4763
  //# sourceMappingURL=html.cjs.js.map