@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/pixi.cjs.js CHANGED
@@ -53,6 +53,240 @@ class EventEmitter {
53
53
  }
54
54
  }
55
55
 
56
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
57
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
58
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
59
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
60
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
61
+ // `node scripts/gen-icons-from-svg.mjs`.
62
+ // The single glyph-name union, shared by core (menu items) and both renderers.
63
+ const ICON_NAMES = [
64
+ 'spin',
65
+ 'turbo1',
66
+ 'autoplay',
67
+ 'stop',
68
+ 'menu',
69
+ 'minus',
70
+ 'plus',
71
+ 'gift',
72
+ 'info',
73
+ 'soundOn',
74
+ 'soundOff',
75
+ 'close',
76
+ 'back',
77
+ 'chevronRight',
78
+ 'ticket',
79
+ 'turbo2',
80
+ 'turboOff',
81
+ 'chevronUp',
82
+ 'chevronDown',
83
+ ];
84
+
85
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
86
+ function isPresetId(id) {
87
+ return PRESET_IDS.includes(id);
88
+ }
89
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
90
+ const DEFAULT_MENU = [
91
+ { id: 'sound' },
92
+ { id: 'music' },
93
+ { id: 'sfx' },
94
+ { type: 'separator' },
95
+ { id: 'gameInfo' },
96
+ ];
97
+ const isSeparator = (i) => i.type === 'separator';
98
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
99
+ function rangeBounds(item) {
100
+ const min = item.min ?? 0;
101
+ const max = item.max ?? 1;
102
+ const derivedStep = (max - min) / 20;
103
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
104
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
105
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
106
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
107
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
108
+ return { min, max, step };
109
+ }
110
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
111
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
112
+ function seedMenuValues(items, prev = {}) {
113
+ const out = {};
114
+ for (const item of items) {
115
+ if (isSeparator(item))
116
+ continue;
117
+ const type = item.type;
118
+ if (!type || isPresetId(item.id))
119
+ continue;
120
+ // A previous value only carries over when its runtime type still matches this item's kind.
121
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
122
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
123
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
124
+ // of warning.
125
+ const prevValue = prev[item.id];
126
+ if (type === 'toggle') {
127
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
128
+ }
129
+ else if (type === 'range') {
130
+ const r = item;
131
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+ const percent = (v) => `${Math.round(v * 100)}%`;
137
+ function safeIcon(name) {
138
+ return name && ICON_NAMES.includes(name) ? name : undefined;
139
+ }
140
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
141
+ function warn(message) {
142
+ console.warn(`[shell] ${message}`);
143
+ }
144
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
145
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
146
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
147
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
148
+ function resolveMenu(host) {
149
+ const rows = [];
150
+ for (const item of host.menu) {
151
+ if (isSeparator(item)) {
152
+ rows.push({ kind: 'separator' });
153
+ continue;
154
+ }
155
+ const type = item.type;
156
+ if (!type) {
157
+ const row = preset(host, item);
158
+ if (row)
159
+ rows.push(row);
160
+ else
161
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
162
+ continue;
163
+ }
164
+ const row = custom(host, item, type);
165
+ if (row)
166
+ rows.push(row);
167
+ }
168
+ return rows;
169
+ }
170
+ function preset(host, item) {
171
+ const disabled = item.disabled ?? false;
172
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
173
+ switch (item.id) {
174
+ case 'sound':
175
+ return {
176
+ kind: 'toggle',
177
+ id: 'sound',
178
+ label,
179
+ disabled,
180
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
181
+ get: () => host.getMenuValue('sound') !== false,
182
+ set: (v) => host.setMenuValue('sound', v),
183
+ };
184
+ case 'music':
185
+ case 'sfx': {
186
+ const id = item.id;
187
+ return {
188
+ kind: 'range',
189
+ id,
190
+ label,
191
+ icon: safeIcon(item.icon),
192
+ disabled,
193
+ min: 0,
194
+ max: 1,
195
+ step: 0.05,
196
+ get: () => Number(host.getMenuValue(id) ?? 1),
197
+ set: (v) => host.setMenuValue(id, v),
198
+ format: percent,
199
+ };
200
+ }
201
+ case 'gameInfo':
202
+ return {
203
+ kind: 'button',
204
+ id: 'gameInfo',
205
+ label,
206
+ icon: safeIcon(item.icon) ?? 'info',
207
+ disabled,
208
+ chevron: true,
209
+ select: () => host.actions.openInfo(),
210
+ };
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+ const DEFAULT_LABELS = {
216
+ sound: 'Sound',
217
+ music: 'Music',
218
+ sfx: 'SFX',
219
+ gameInfo: 'Game info',
220
+ };
221
+ function custom(host, item, type) {
222
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
223
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
224
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
225
+ if (isPresetId(item.id)) {
226
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
227
+ }
228
+ const disabled = item.disabled ?? false;
229
+ const label = host.t(item.label ?? item.id);
230
+ const icon = safeIcon(item.icon);
231
+ if (type === 'toggle') {
232
+ const it = item;
233
+ return {
234
+ kind: 'toggle',
235
+ id: it.id,
236
+ label,
237
+ disabled,
238
+ icon: () => icon,
239
+ get: () => host.getMenuValue(it.id) === true,
240
+ set: (v) => {
241
+ host.setMenuValue(it.id, v);
242
+ it.onChange?.(v);
243
+ },
244
+ };
245
+ }
246
+ if (type === 'range') {
247
+ const it = item;
248
+ const { min, max, step } = rangeBounds(it);
249
+ if (max <= min) {
250
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
251
+ // drop the row instead, exactly like an unknown preset id.
252
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
253
+ return null;
254
+ }
255
+ return {
256
+ kind: 'range',
257
+ id: it.id,
258
+ label,
259
+ icon,
260
+ disabled,
261
+ min,
262
+ max,
263
+ step,
264
+ get: () => Number(host.getMenuValue(it.id) ?? min),
265
+ set: (v) => {
266
+ host.setMenuValue(it.id, v);
267
+ it.onChange?.(v);
268
+ },
269
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
270
+ };
271
+ }
272
+ if (type === 'button') {
273
+ const it = item;
274
+ return {
275
+ kind: 'button',
276
+ id: it.id,
277
+ label,
278
+ icon,
279
+ disabled,
280
+ chevron: it.chevron ?? false,
281
+ select: () => it.onSelect?.(),
282
+ };
283
+ }
284
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
285
+ // button row with no onSelect. Drop it visibly instead.
286
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
287
+ return null;
288
+ }
289
+
56
290
  function createInitialState(config) {
57
291
  return {
58
292
  mode: config.mode,
@@ -69,10 +303,10 @@ function createInitialState(config) {
69
303
  bonus: null,
70
304
  activeFeature: null,
71
305
  volumes: {
72
- master: clampVolume(config.volumes?.master),
73
306
  music: clampVolume(config.volumes?.music),
74
307
  sfx: clampVolume(config.volumes?.sfx),
75
308
  },
309
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
76
310
  };
77
311
  }
78
312
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -170,15 +404,19 @@ function resolveTheme(theme = {}) {
170
404
  * trailing zeros are trimmed down to (but never past) `minDecimals`, so small wins keep their
171
405
  * significant digits. Balance / bet / prices stay fixed at `minDecimals`.
172
406
  *
407
+ * Separators default to the platform convention — `.` decimal, `,` thousands (`€1,234.50`).
408
+ * A comma decimal is NOT safe as a default: players read "2,50" as 250 and believe they were
409
+ * paid far more than they were. Games that need another convention pass `currency.separator`.
410
+ *
173
411
  * Example with `maxDecimals: 4, minDecimals: 2`:
174
- * fixed → 0.0673 → 0,07 0.3 → 0,30
175
- * variable → 0.0673 → 0,0673 0.067 → 0,067 0.3 → 0,30 0 → 0,00
412
+ * fixed → 0.0673 → 0.07 0.3 → 0.30
413
+ * variable → 0.0673 → 0.0673 0.067 → 0.067 0.3 → 0.30 0 → 0.00
176
414
  */
177
415
  function formatCurrency(value, currency, variableDecimals = false) {
178
416
  const maxDecimals = currency.maxDecimals ?? 2;
179
417
  const minDecimals = Math.max(0, Math.min(maxDecimals, currency.minDecimals ?? maxDecimals));
180
- const thousands = currency.separator?.thousands ?? '.';
181
- const decimal = currency.separator?.decimal ?? ',';
418
+ const thousands = currency.separator?.thousands ?? ',';
419
+ const decimal = currency.separator?.decimal ?? '.';
182
420
  const safe = Number.isFinite(value) ? value : 0;
183
421
  // fixed callers round at minDecimals; variable callers round at maxDecimals then trim back down.
184
422
  const places = variableDecimals ? maxDecimals : minDecimals;
@@ -1456,7 +1694,7 @@ class KeyboardController {
1456
1694
 
1457
1695
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1458
1696
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1459
- const PACKAGE_VERSION = '0.6.6';
1697
+ const PACKAGE_VERSION = '0.7.1';
1460
1698
 
1461
1699
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1462
1700
  function resolveConfig(config) {
@@ -1474,6 +1712,7 @@ function resolveConfig(config) {
1474
1712
  theme: config.theme,
1475
1713
  onBonusBuy: config.onBonusBuy,
1476
1714
  volumes: config.volumes,
1715
+ menu: config.menu,
1477
1716
  version: config.version ?? '1.0.0',
1478
1717
  isSocial: config.isSocial ?? false,
1479
1718
  replay: config.replay ?? config.mode === 'replay',
@@ -1494,8 +1733,9 @@ class ShellController extends EventEmitter {
1494
1733
  i18n;
1495
1734
  kbd;
1496
1735
  overlay = null;
1497
- soundRefresh = null;
1498
- volumeRefresh = null;
1736
+ menuItems;
1737
+ menuRefresh = null;
1738
+ overlayKind = null;
1499
1739
  prevBalance;
1500
1740
  prevWin;
1501
1741
  destroyed = false;
@@ -1506,6 +1746,7 @@ class ShellController extends EventEmitter {
1506
1746
  this.config = resolveConfig(config);
1507
1747
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1508
1748
  this.state = createInitialState(this.config);
1749
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1509
1750
  this.tokens = resolveTheme(this.config.theme);
1510
1751
  this.prevBalance = this.state.balance;
1511
1752
  this.prevWin = this.state.win;
@@ -1639,14 +1880,21 @@ class ShellController extends EventEmitter {
1639
1880
  show(req) {
1640
1881
  this.closeModal();
1641
1882
  this.overlay = this.renderer.openOverlay(req) ?? null;
1883
+ this.overlayKind = this.overlay ? req.kind : null;
1642
1884
  }
1885
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1643
1886
  openMenu() {
1887
+ if (this.overlayKind === 'menu') {
1888
+ this.closeModal();
1889
+ return;
1890
+ }
1644
1891
  this.emit('menuOpen');
1645
- this.openSettings();
1892
+ this.show({ kind: 'menu' });
1646
1893
  }
1894
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1647
1895
  openSettings() {
1648
1896
  this.emit('settingsOpen');
1649
- this.show({ kind: 'settings' });
1897
+ this.openMenu();
1650
1898
  }
1651
1899
  openInfo() {
1652
1900
  this.emit('infoOpen');
@@ -1678,35 +1926,72 @@ class ShellController extends EventEmitter {
1678
1926
  if (!this.overlay)
1679
1927
  return;
1680
1928
  this.overlay = null;
1681
- this.soundRefresh = null;
1682
- this.volumeRefresh = null;
1929
+ this.overlayKind = null;
1930
+ this.menuRefresh = null;
1683
1931
  this.renderer.closeOverlay();
1684
1932
  }
1685
1933
  // ── sound ──────────────────────────────────────────────────────────────────
1686
1934
  setSound(on) {
1687
1935
  this.soundOn = on;
1688
1936
  this.emit('settingChange', { key: 'sound', value: on });
1689
- this.soundRefresh?.(on);
1690
- this.renderer.refreshSoundIcon?.(on);
1691
- }
1692
- setSoundRefresh(fn) {
1693
- this.soundRefresh = fn;
1937
+ this.menuRefresh?.('sound', on);
1694
1938
  }
1695
1939
  // ── volume ─────────────────────────────────────────────────────────────────
1696
1940
  getVolume(key) {
1697
1941
  return this.state.volumes[key];
1698
1942
  }
1699
1943
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1700
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1701
- * live-updates the slider if the overlay is currently open. */
1944
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1945
+ * live-updates the slider if the menu is currently open. */
1702
1946
  setVolume(key, value) {
1703
1947
  const v = Math.max(0, Math.min(1, value));
1704
1948
  this.state.volumes[key] = v;
1705
1949
  this.emit('settingChange', { key, value: v });
1706
- this.volumeRefresh?.(key, v);
1707
- }
1708
- setVolumeRefresh(fn) {
1709
- this.volumeRefresh = fn;
1950
+ this.menuRefresh?.(key, v);
1951
+ }
1952
+ // ── menu ───────────────────────────────────────────────────────────────────
1953
+ get menu() {
1954
+ return this.menuItems;
1955
+ }
1956
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1957
+ setMenu(items) {
1958
+ this.menuItems = items;
1959
+ this.state.menu = seedMenuValues(items, this.state.menu);
1960
+ if (this.overlayKind === 'menu')
1961
+ this.show({ kind: 'menu' });
1962
+ }
1963
+ getMenuValue(id) {
1964
+ if (id === 'sound')
1965
+ return this.soundOn;
1966
+ if (id === 'music' || id === 'sfx')
1967
+ return this.state.volumes[id];
1968
+ return this.state.menu[id];
1969
+ }
1970
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1971
+ setMenuValue(id, value) {
1972
+ if (id === 'sound') {
1973
+ this.setSound(value !== false);
1974
+ return;
1975
+ }
1976
+ if (id === 'music' || id === 'sfx') {
1977
+ this.setVolume(id, Number(value));
1978
+ return;
1979
+ }
1980
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1981
+ this.state.menu[id] = next;
1982
+ this.emit('settingChange', { key: id, value: next });
1983
+ this.menuRefresh?.(id, next);
1984
+ }
1985
+ setMenuRefresh(fn) {
1986
+ this.menuRefresh = fn;
1987
+ }
1988
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1989
+ clampRange(id, value) {
1990
+ const item = this.menuItems.find((i) => i.id === id);
1991
+ if (!item || item.type !== 'range')
1992
+ return value;
1993
+ const { min, max } = rangeBounds(item);
1994
+ return Math.max(min, Math.min(max, value));
1710
1995
  }
1711
1996
  // ── features ─────────────────────────────────────────────────────────────────
1712
1997
  activateFeature(bonus) {
@@ -1816,6 +2101,11 @@ class ShellController extends EventEmitter {
1816
2101
  if (this.destroyed)
1817
2102
  return Promise.resolve();
1818
2103
  this.destroyed = true;
2104
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2105
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2106
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2107
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2108
+ this.closeModal();
1819
2109
  if (typeof document !== 'undefined') {
1820
2110
  this.kbd?.detach();
1821
2111
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1825,6 +2115,64 @@ class ShellController extends EventEmitter {
1825
2115
  }
1826
2116
  }
1827
2117
 
2118
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2119
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2120
+ const POPOVER = {
2121
+ /** Keep-out from the surface edges. */
2122
+ margin: 8,
2123
+ /** Space between the anchor and the card. */
2124
+ gap: 8,
2125
+ /** Minimum distance from the arrow tip to either rounded corner. */
2126
+ arrowInset: 14,
2127
+ /** A card shorter than this does not fit — flip to the other side instead. */
2128
+ minH: 120,
2129
+ minW: 220,
2130
+ maxW: 320,
2131
+ };
2132
+ const clamp$1 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2133
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2134
+ function popoverWidth(surfaceW, contentW) {
2135
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2136
+ return Math.max(0, clamp$1(contentW, Math.min(POPOVER.minW, hi), hi));
2137
+ }
2138
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2139
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2140
+ *
2141
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2142
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2143
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2144
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2145
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2146
+ function placePopover(anchor, surface, size, pointer = null) {
2147
+ const { margin, gap, arrowInset, minH } = POPOVER;
2148
+ if (!anchor) {
2149
+ const maxH = Math.max(0, surface.h - margin * 2);
2150
+ const h = Math.min(size.h, maxH);
2151
+ const rawY = (surface.h - h) / 2;
2152
+ const y = clamp$1(rawY, margin, Math.max(margin, surface.h - h - margin));
2153
+ return {
2154
+ x: Math.max(margin, (surface.w - size.w) / 2),
2155
+ y,
2156
+ maxH,
2157
+ arrowX: -1,
2158
+ below: false,
2159
+ };
2160
+ }
2161
+ const spaceAbove = anchor.y - gap - margin;
2162
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2163
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2164
+ // genuinely more room on the other side.
2165
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2166
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2167
+ const h = Math.min(size.h, maxH);
2168
+ const x = clamp$1(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2169
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2170
+ const y = clamp$1(rawY, margin, Math.max(margin, surface.h - h - margin));
2171
+ const arrowAnchor = pointer ?? anchor;
2172
+ const arrowX = clamp$1(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2173
+ return { x, y, maxH, arrowX, below };
2174
+ }
2175
+
1828
2176
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1829
2177
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1830
2178
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2294,25 +2642,25 @@ function roundedPath(g, x, y, w, h, [tl, tr, br, bl]) {
2294
2642
  // GraphicsContext.svg. To change an icon: edit icons.svg, then run
2295
2643
  // `node scripts/gen-icons-from-svg.mjs`.
2296
2644
  const SVGS = {
2297
- 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>`,
2298
- 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>`,
2299
- 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>`,
2645
+ 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>`,
2646
+ 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>`,
2647
+ 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>`,
2300
2648
  stop: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="1.6" width="20.8" height="20.8" rx="2.9"/></g>`,
2301
2649
  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>`,
2302
2650
  minus: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="9.86" width="20.8" height="4.28" rx="2.11"/></g>`,
2303
- 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>`,
2651
+ 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>`,
2304
2652
  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)"/>`,
2305
- 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>`,
2306
- 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>`,
2653
+ 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>`,
2654
+ 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>`,
2307
2655
  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>`,
2308
- 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>`,
2309
- 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>`,
2310
- 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>`,
2656
+ 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>`,
2657
+ 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>`,
2658
+ 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>`,
2311
2659
  ticket: `<path d="M1.72 9.48L19.64 4.67L20.28 7.07C19.19 7.8 18.82 9 19.11 10.09C19.41 11.19 20.33 12.04 21.64 12.13L22.28 14.52L4.36 19.32L3.72 16.93C4.81 16.2 5.18 15 4.89 13.91C4.59 12.81 3.67 11.96 2.36 11.87L1.72 9.48Z" fill="none" stroke="currentColor" stroke-width="1.84" stroke-linejoin="miter"/><path d="M12.57 7.17L9.75 13.26L11.92 12.4L11.84 16.74L14.85 10.06L12.38 11.1Z" fill="currentColor"/>`,
2312
- 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>`,
2313
- 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>`,
2314
- 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>`,
2315
- 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>`,
2660
+ 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>`,
2661
+ 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>`,
2662
+ 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>`,
2663
+ 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>`,
2316
2664
  };
2317
2665
  /** A standalone, parseable SVG document with `currentColor` resolved to `color`.
2318
2666
  * Used by the Pixi renderer to build vector geometry via GraphicsContext.svg(). */
@@ -2348,10 +2696,10 @@ class IconView extends pixi_js.Container {
2348
2696
  gfx;
2349
2697
  _size;
2350
2698
  _color;
2351
- iconName;
2699
+ _iconName;
2352
2700
  constructor(name, size, color = '#ffffff') {
2353
2701
  super();
2354
- this.iconName = name;
2702
+ this._iconName = name;
2355
2703
  this._size = size;
2356
2704
  this._color = color;
2357
2705
  this.gfx = new pixi_js.Graphics(context(name, color));
@@ -2367,11 +2715,22 @@ class IconView extends pixi_js.Container {
2367
2715
  get size() {
2368
2716
  return this._size;
2369
2717
  }
2718
+ get iconName() {
2719
+ return this._iconName;
2720
+ }
2370
2721
  setColor(color) {
2371
2722
  if (color === this._color)
2372
2723
  return;
2373
2724
  this._color = color;
2374
- this.gfx.context = context(this.iconName, color);
2725
+ this.gfx.context = context(this._iconName, color);
2726
+ }
2727
+ /** Swap the glyph in place (e.g. the menu's sound row flips soundOn/soundOff on toggle) — same
2728
+ * cached-context rebuild as setColor, keyed on the new name instead of a new colour. */
2729
+ setIcon(name) {
2730
+ if (name === this._iconName)
2731
+ return;
2732
+ this._iconName = name;
2733
+ this.gfx.context = context(name, this._color);
2375
2734
  }
2376
2735
  setSize(size) {
2377
2736
  if (size === this._size)
@@ -3103,6 +3462,11 @@ class BottomBar extends pixi_js.Container {
3103
3462
  spin;
3104
3463
  autoBtn;
3105
3464
  turboBtn;
3465
+ menuBtn;
3466
+ /** The menu's PLATE, in LOCAL coordinates relative to `inner` — the continuous dark panel (wide)
3467
+ * or the controls row (mobile). Set at the point `applyFit()`/`applyFitMobile()` compute it;
3468
+ * `menuPlate()` converts it to screen space lazily (see that method's doc comment for why). */
3469
+ plateRect = null;
3106
3470
  constructor(host) {
3107
3471
  super();
3108
3472
  this.host = host;
@@ -3112,6 +3476,58 @@ class BottomBar extends pixi_js.Container {
3112
3476
  else
3113
3477
  this.buildWide();
3114
3478
  }
3479
+ /** Screen-space rect of the burger — the menu popover's POINTER (the arrow's target only; see
3480
+ * `menuPlate` for what drives placement). `measureSize()` (the button's own nominal box, e.g.
3481
+ * 36×36) rather than the built-in `getSize()` (the glyph's ink bounds only) so a burger whose icon
3482
+ * doesn't fill its box still reports its true hit-box size; multiplied by `inner.scale` — `getSize`
3483
+ * /`measureSize` only reflect the node's OWN scale (always 1 here), not the ancestor `inner.scale`
3484
+ * the bar's whole fit-scale lives on, so the UNMULTIPLIED width/height would silently overstate the
3485
+ * burger's true on-screen footprint on a scaled-down bar and skew the arrow off-centre. */
3486
+ menuAnchor() {
3487
+ if (!this.menuBtn || this.menuBtn.destroyed)
3488
+ return null;
3489
+ const p = this.menuBtn.getGlobalPosition();
3490
+ const s = this.inner.scale.x;
3491
+ const size = this.menuBtn.measureSize();
3492
+ return { x: p.x, y: p.y, w: size.w * s, h: size.h * s };
3493
+ }
3494
+ /** Screen-space rect of the menu's PLATE — the continuous dark panel (wide) or the controls row
3495
+ * (mobile), NOT the burger itself and NOT (mobile) the info pill below it. Drives the popover's
3496
+ * x/y/maxH/below; the burger (`menuAnchor`) drives only the arrow. Resolved lazily on every call,
3497
+ * like `menuAnchor` — `PixiRenderer.renderBar()` destroys and rebuilds this BottomBar on every
3498
+ * resize and ~20 other state changes, so a value captured once would go stale. `plateRect` is
3499
+ * local to `inner`; `getGlobalPosition` + `inner.scale` convert it to screen space (`outer*` on the
3500
+ * mobile FlexBox and the wide panel's own drawn rect are both LOCAL sizes, unaffected by `inner`'s
3501
+ * ancestor scale, same reasoning as `menuAnchor` above). */
3502
+ menuPlate() {
3503
+ // Pixi v8's Container.destroy() nulls `_position`/`_scale`, so `inner.scale.x` /
3504
+ // `inner.getGlobalPosition()` on a destroyed bar throws — guard for symmetry with menuAnchor()
3505
+ // above, which already returns null once its button is destroyed. Not reachable today
3506
+ // (PixiRenderer.renderBar() destroys and reassigns `this.bar` synchronously, in the same call),
3507
+ // but the asymmetry is a trap for a future caller that holds a reference across a render.
3508
+ if (this.destroyed)
3509
+ return null;
3510
+ if (!this.plateRect)
3511
+ return null;
3512
+ const s = this.inner.scale.x;
3513
+ const origin = this.inner.getGlobalPosition();
3514
+ return {
3515
+ x: origin.x + this.plateRect.x * s,
3516
+ y: origin.y + this.plateRect.y * s,
3517
+ w: this.plateRect.w * s,
3518
+ h: this.plateRect.h * s,
3519
+ };
3520
+ }
3521
+ /** The scale factor the bar currently applies to itself (`inner.scale`) — shared with the menu
3522
+ * popover so its typography/padding/row-heights scale in lockstep with the bar's own chrome,
3523
+ * instead of ignoring the viewport like a fixed-local-size card would. */
3524
+ fitScale() {
3525
+ // Same destroyed guard as menuPlate()/menuAnchor() above, and the same neutral fallback a
3526
+ // scale factor should have (1 = no scaling) rather than throwing.
3527
+ if (this.destroyed)
3528
+ return 1;
3529
+ return this.inner.scale.x;
3530
+ }
3115
3531
  // ── wide / landscape ──────────────────────────────────────────────────────
3116
3532
  buildWide() {
3117
3533
  const { state, tokens } = this.host;
@@ -3122,13 +3538,14 @@ class BottomBar extends pixi_js.Container {
3122
3538
  this.buy = isBase ? (this.buildBuy(BUY_W, 13, 3) ?? undefined) : undefined;
3123
3539
  // LEFT info group: menu · balance · (Total win) · (Win)
3124
3540
  const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
3125
- left.add(new IconButton('menu', {
3541
+ this.menuBtn = new IconButton('menu', {
3126
3542
  size: 36,
3127
3543
  glyph: 30,
3128
3544
  color: '#ffffff',
3129
3545
  hover: tokens.accent,
3130
3546
  onTap: () => this.host.actions.openMenu(),
3131
- }));
3547
+ });
3548
+ left.add(this.menuBtn);
3132
3549
  if (!state.replay) {
3133
3550
  const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
3134
3551
  this.balanceValue = bal.valueText;
@@ -3317,6 +3734,7 @@ class BottomBar extends pixi_js.Container {
3317
3734
  hover: tokens.accent,
3318
3735
  onTap: () => this.host.actions.openMenu(),
3319
3736
  });
3737
+ this.menuBtn = menu;
3320
3738
  // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
3321
3739
  if (isBase && config.features.autoplay) {
3322
3740
  this.autoBtn = new IconButton('autoplay', {
@@ -3552,6 +3970,8 @@ class BottomBar extends pixi_js.Container {
3552
3970
  this.panelBg.clear();
3553
3971
  roundedPath(this.panelBg, panelX, SPIN_POP, panelRight - panelX, BAR_H, [12, 12, 12, 12]);
3554
3972
  this.panelBg.fill(tokens.bar);
3973
+ // The menu's plate — the whole continuous dark panel, local to `inner` (see menuPlate()).
3974
+ this.plateRect = { x: panelX, y: SPIN_POP, w: panelRight - panelX, h: BAR_H };
3555
3975
  if (this.buy)
3556
3976
  this.buy.position.set(OUTER_PAD, panelCenterY - BUY_W / 2);
3557
3977
  left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
@@ -3584,6 +4004,18 @@ class BottomBar extends pixi_js.Container {
3584
4004
  // SPIN hero), inflating the controls row so it overlaps the info row below it.
3585
4005
  controls.setLayoutSize(rowW, M_CTRL_H);
3586
4006
  info.setLayoutSize(rowW, M_INFO_H);
4007
+ // The menu's plate — the controls row (NOT the info pill below it), local to `inner`. Read from
4008
+ // `controls.outerWidth/outerHeight` (its own nominal box), NOT the FlexBox's own bounds (which
4009
+ // would include the SPIN/FS hero popping out both above AND below it — see menuPlate()'s doc
4010
+ // comment) — but the TOP edge is deliberately extended upward by `pop`, the same hero pop-out
4011
+ // amount reserved as `topPad` above, so the plate the popover math sees is the row's true visual
4012
+ // extent on the side that matters: without this, the card's bottom (only `gap` above the plate's
4013
+ // top) can clip the top ~(pop-gap)px of the hero's arc, since 62px M_CTRL_H is 11px shorter than
4014
+ // the 84px hero. The BOTTOM edge is left alone — the hero's symmetric under-pop is a separate,
4015
+ // unrelated concern (the info pill sits right below with its own small gap) and extending it too
4016
+ // would perturb the `below`-placement branch (spaceBelow) for no benefit. A no-op when `pop` is 0
4017
+ // (no hero shown — e.g. replay without free spins).
4018
+ this.plateRect = { x: 0, y: topPad - pop, w: controls.outerWidth, h: controls.outerHeight + pop };
3587
4019
  const s = rowW > 0 ? Math.max(0.4, Math.min(1, avail / rowW)) : 1;
3588
4020
  this.inner.scale.set(s);
3589
4021
  this.inner.position.set((W - rowW * s) / 2, H - localBottom * s - M_PAD_BOTTOM);
@@ -3614,7 +4046,8 @@ class ScrollBox extends pixi_js.Container {
3614
4046
  this.canvas = canvas;
3615
4047
  this.addChild(this.content);
3616
4048
  // maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
3617
- // graphic renders as a white rect, and a masked container blocks pointer events to its children.
4049
+ // graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
4050
+ // what an earlier version of this comment claimed — see the correction in refresh() below.)
3618
4051
  this.eventMode = 'static';
3619
4052
  this.on('pointerdown', this.onDown);
3620
4053
  this.on('globalpointermove', this.onMove);
@@ -3648,10 +4081,13 @@ class ScrollBox extends pixi_js.Container {
3648
4081
  const b = this.content.getLocalBounds();
3649
4082
  const contentH = b.height + b.y; // content laid out from y≈0 downward
3650
4083
  this.maxScroll = Math.max(0, contentH - this.viewH);
3651
- // Only clip + grab pointer/drag when the content actually overflows: a masked container blocks
3652
- // pointer events to its children in Pixi v8, so when it fits we leave it unmasked and passive →
3653
- // interactive controls (settings sliders/buttons) work. Tall scrolling content (game info) that
3654
- // does get masked has no interactive children, so nothing is lost there.
4084
+ // Only clip + grab pointer/drag when the content actually overflows. (An earlier version of this
4085
+ // comment claimed a masked container blocks pointer events to its children in Pixi v8 that's
4086
+ // false: EventBoundary's hitPruneFn only prunes a point OUTSIDE the mask/hitArea, so interactive
4087
+ // children stay reachable anywhere inside the visible viewport; see tests/pixi/bet-picker-fit.test.ts
4088
+ // and tests/pixi/menu.test.ts, which hit-test an overflowing, masked list on purpose to prove it.
4089
+ // We still only mask + grab the pointer when scrollable, since an unmasked, passive box is
4090
+ // simpler and cheaper when there's nothing to clip or drag.)
3655
4091
  const scrollable = this.maxScroll > 0;
3656
4092
  if (scrollable) {
3657
4093
  this.addChild(this.maskG);
@@ -3711,152 +4147,135 @@ class ScrollBox extends pixi_js.Container {
3711
4147
  }
3712
4148
  }
3713
4149
 
3714
- function clamp(min, pref, max) {
3715
- return Math.max(min, Math.min(max, pref));
3716
- }
3717
- /** Small rounded icon nav button — the overlay header back/close and row chevrons.
3718
- * `.ge-ov-nav`: 32×32, radius 9, plaque-dark bg, white icon, hover → glass bg + accent icon. */
3719
- function navButton(host, iconName, onTap, size = 32) {
3720
- const root = new pixi_js.Container();
3721
- const bg = new pixi_js.Graphics();
3722
- const draw = (hover) => {
3723
- bg.clear();
3724
- bg.roundRect(0, 0, size, size, 9);
3725
- bg.fill(hover ? host.tokens.plaqueGlass : host.tokens.plaqueDark);
3726
- };
3727
- draw(false);
3728
- const gs = Math.round(size * 0.5625); // glyph tracks the box (default 32 → 18, unchanged)
3729
- const glyph = makeIcon(iconName, gs, '#ffffff');
3730
- glyph.position.set((size - gs) / 2, (size - gs) / 2);
3731
- root.addChild(bg, glyph);
3732
- root.eventMode = 'static';
3733
- root.cursor = 'pointer';
3734
- root.hitArea = new pixi_js.Rectangle(0, 0, size, size);
3735
- attachHover(root, () => {
3736
- draw(true);
3737
- glyph.setColor(host.tokens.accent);
3738
- }, () => {
3739
- draw(false);
3740
- glyph.setColor('#ffffff');
3741
- });
3742
- attachPress(root, 0.92, onTap);
3743
- return root;
3744
- }
3745
- /** Full-screen overlay: frosted dark veil, fixed header (title + back/close), scrolling body.
3746
- * The body is rebuilt on resize so it reflows like the CSS overlay. */
3747
- class Overlay extends pixi_js.Container {
4150
+ /** Light-dismiss popover: a transparent full-screen hit rect + a rounded card with an arrow.
4151
+ * No veil, no frosted snapshot — the game stays visible and unblurred behind it. */
4152
+ class Popover extends pixi_js.Container {
3748
4153
  tag;
4154
+ dismissLayer = new pixi_js.Graphics();
4155
+ card = new pixi_js.Container();
4156
+ bg = new pixi_js.Graphics();
4157
+ arrow = new pixi_js.Graphics();
4158
+ scroll;
3749
4159
  host;
3750
4160
  opts;
3751
- veil = new pixi_js.Graphics();
3752
- header = new pixi_js.Container();
3753
- scroll;
3754
- titleNode = new pixi_js.Container();
3755
- nav = new pixi_js.Container();
3756
- w = 0;
3757
- h = 0;
3758
- headerH = 44;
3759
- /** Expose the scroll box for keyboard/test access. */
3760
- get scrollContent() { return this.scroll; }
4161
+ _cardX = 0;
4162
+ _cardY = 0;
4163
+ _cardW = 0;
4164
+ _arrowX = -1;
3761
4165
  constructor(host, opts) {
3762
4166
  super();
3763
4167
  this.host = host;
3764
4168
  this.opts = opts;
3765
4169
  this.tag = opts.tag;
3766
4170
  this.scroll = new ScrollBox(host.canvas);
3767
- this.addChild(this.veil, this.scroll, this.header);
3768
- this.veil.eventMode = 'static'; // swallow clicks to the game behind
4171
+ this.card.addChild(this.bg, this.arrow, this.scroll);
4172
+ this.addChild(this.dismissLayer, this.card);
4173
+ this.dismissLayer.eventMode = 'static';
4174
+ this.dismissLayer.on('pointertap', () => this.opts.onClose());
4175
+ // Taps on the card must not fall through to the dismiss layer.
4176
+ this.card.eventMode = 'static';
4177
+ // `e` itself is undefined when a caller (or test) emits the event with no payload, so guard the
4178
+ // property access, not just the call.
4179
+ this.card.on('pointertap', (e) => e?.stopPropagation?.());
3769
4180
  this.resize(host.screenW, host.screenH);
3770
4181
  }
4182
+ get cardX() { return this._cardX; }
4183
+ get cardY() { return this._cardY; }
4184
+ get cardWidth() { return this._cardW; }
4185
+ get arrowX() { return this._arrowX; }
4186
+ get arrowVisible() { return this.arrow.visible; }
3771
4187
  resize(w, h) {
3772
- this.w = w;
3773
- this.h = h;
3774
- const vh = h / 100;
3775
- this.headerH = clamp(40, 6.4 * vh, 52);
3776
- // veil
3777
- this.veil.clear();
3778
- this.veil.rect(0, 0, w, h);
3779
- this.veil.fill(this.host.tokens.backdrop);
3780
- this.veil.hitArea = new pixi_js.Rectangle(0, 0, w, h);
3781
- this.buildHeader();
3782
- this.layoutBody();
3783
- }
3784
- buildHeader() {
3785
- this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
3786
- const pad = 10;
3787
- // back or spacer (left), centred title, close (right)
3788
- const left = this.opts.onBack
3789
- ? navButton(this.host, 'back', () => this.opts.onBack())
3790
- : new pixi_js.Container();
3791
- const close = navButton(this.host, 'close', () => this.opts.onClose());
3792
- const titleSize = clamp(13, 2.6 * (this.h / 100), 16);
3793
- const title = makeText(this.opts.title, {
3794
- size: titleSize,
3795
- weight: '800',
3796
- color: '#ffffff',
3797
- letterSpacing: titleSize * 0.04,
3798
- upper: true,
3799
- align: 'center',
3800
- });
3801
- title.anchor.set(0.5);
3802
- title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
3803
- left.position.set(pad, (this.headerH - 32) / 2 + pad / 2);
3804
- close.position.set(this.w - 32 - pad, (this.headerH - 32) / 2 + pad / 2);
3805
- this.header.addChild(title, left, close);
3806
- }
3807
- layoutBody() {
3808
- const top = this.headerH + 6;
3809
- const sidePad = clamp(16, 4 * (this.w / 100), 24);
3810
- const vPad = clamp(6, 2 * (this.h / 100), 16);
3811
- const bodyW = Math.min(800, this.w - sidePad * 2);
3812
- this.scroll.position.set(0, top);
3813
- this.scroll.setViewport(this.w, this.h - top);
3814
- this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
3815
- const content = this.opts.build(bodyW);
4188
+ this.dismissLayer.clear();
4189
+ this.dismissLayer.rect(0, 0, w, h).fill({ color: 0x000000, alpha: 0 });
4190
+ this.dismissLayer.hitArea = new pixi_js.Rectangle(0, 0, w, h);
4191
+ const s = this.opts.scale?.() ?? 1;
4192
+ const pad = 8;
4193
+ // Width is content-driven (spec: clamped to [220, min(320, surfaceWidth-16)]), so the real
4194
+ // content has to be measured before we know the final width to lay it out at. Probe-build once
4195
+ // at the SMALLEST allowed inner width purely to measure natural size, then throw that copy away
4196
+ // and build the kept one at the resolved final width. Measured in LOCAL (unscaled) units, like
4197
+ // every other Pixi size in this file — `s` only converts to screen units where placement needs
4198
+ // it (the card carries the scale as a single transform, same as the DOM's card).
4199
+ //
4200
+ // The probe deliberately measures at the MINIMUM, not the maximum: a decorative row (the menu's
4201
+ // separator) draws its divider line at exactly the width `build()` is called with it has no
4202
+ // content-driven size of its own, unlike every real row, which ignores that parameter and sizes
4203
+ // from its icon/label/control regardless. Probing at the maximum would make that separator line
4204
+ // alone measure near-maximum and dominate `naturalWidth`, pegging the card at ~maxW for every
4205
+ // menu that has a separator — which the default menu always does. Probing at the minimum cannot
4206
+ // distort the result the other way: content narrower than the probe still clamps to minW either
4207
+ // way (nothing above changes), and content wider than the probe always wins the max() the layout
4208
+ // takes over row widths, so real content still drives growth past minW correctly.
4209
+ const probeW = POPOVER.minW - pad * 2;
4210
+ const probe = this.opts.build(probeW);
4211
+ const measured = probe instanceof FlexBox ? probe.measureSize().w : probe.getSize().width;
4212
+ probe.destroy({ children: true });
4213
+ // Resolve the ON-SCREEN width (screen units, clamped against the surface), then convert back to
4214
+ // LOCAL for the actual content layout — mirrors the DOM's naturalW·s → resolvedW → style.width÷s.
4215
+ const screenW = popoverWidth(w, (measured + pad * 2) * s);
4216
+ const localW = s > 0 ? screenW / s : screenW;
4217
+ const content = this.opts.build(localW - pad * 2);
3816
4218
  if (content instanceof FlexBox)
3817
- content.setLayoutSize(bodyW, undefined);
3818
- content.position.set((this.w - bodyW) / 2, vPad);
4219
+ content.setLayoutSize(localW - pad * 2, undefined);
4220
+ const contentH = content.getSize().height; // local units
4221
+ // The plate falls back to the pointer when it can't be resolved (no distinct plaque), and to the
4222
+ // centred/arrow-less layout when neither resolves — placePopover itself already defaults the
4223
+ // ARROW to `pointer ?? plate`, so passing the pointer through unconditionally is enough there.
4224
+ // A DEGENERATE (fully zero-sized) plate counts as unresolved too, matching the DOM renderer's
4225
+ // rectOf(): `??` only falls through on null/undefined, so a real-but-{w:0,h:0} rect (e.g. read
4226
+ // before the bar's first layout pass) would otherwise silently win over a perfectly good pointer.
4227
+ const pointerRect = this.opts.pointer?.() ?? null;
4228
+ const rawPlate = this.opts.plate();
4229
+ const plateRect = rawPlate && !(rawPlate.w <= 0 && rawPlate.h <= 0) ? rawPlate : pointerRect;
4230
+ const p = placePopover(plateRect, { w, h }, { w: screenW, h: (contentH + pad * 2) * s }, pointerRect);
4231
+ const maxHLocal = s > 0 ? p.maxH / s : p.maxH;
4232
+ const cardH = Math.min(contentH + pad * 2, maxHLocal); // local units
4233
+ this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
4234
+ this.scroll.position.set(pad, pad);
4235
+ this.scroll.setViewport(localW - pad * 2, cardH - pad * 2);
3819
4236
  this.scroll.content.addChild(content);
3820
4237
  this.scroll.refresh();
3821
- }
3822
- /** Handle keyboard navigation while this overlay is the top layer. */
4238
+ this.bg.clear();
4239
+ this.bg.roundRect(0, 0, localW, cardH, 18);
4240
+ this.bg.fill(this.host.tokens.plaqueDark);
4241
+ // Arrow: a 14×7 triangle (local units — it lives inside the scaled card) on the edge that faces
4242
+ // the plate.
4243
+ this.arrow.clear();
4244
+ this.arrow.visible = p.arrowX >= 0;
4245
+ if (this.arrow.visible) {
4246
+ const arrowXLocal = s > 0 ? p.arrowX / s : p.arrowX;
4247
+ const edge = p.below ? 0 : cardH; // the card edge the arrow sits on
4248
+ const tip = p.below ? -7 : cardH + 7; // the tip, pointing at the pointer (or the plate)
4249
+ this.arrow.moveTo(arrowXLocal - 7, edge);
4250
+ this.arrow.lineTo(arrowXLocal + 7, edge);
4251
+ this.arrow.lineTo(arrowXLocal, tip);
4252
+ this.arrow.fill(this.host.tokens.plaqueDark);
4253
+ }
4254
+ // The card carries the scale as ONE transform around its own (0,0) local origin — equivalent to
4255
+ // the DOM's transform-origin:top left — so `p.x`/`p.y` (screen units) remain its visual top-left
4256
+ // regardless of `s`, and every local size above (background rect, scroll viewport, arrow) scales
4257
+ // with it automatically.
4258
+ this.card.scale.set(s);
4259
+ this.card.position.set(p.x, p.y);
4260
+ this._cardX = p.x;
4261
+ this._cardY = p.y;
4262
+ this._cardW = screenW;
4263
+ this._arrowX = p.arrowX;
4264
+ }
4265
+ /** Arrow keys scroll a long list; everything else (Escape included) goes to the controller. */
3823
4266
  onKey(e) {
3824
- const viewH = this.h - (this.headerH + 6); // body viewport height
3825
- const lineStep = 60;
3826
- const pageStep = Math.floor(viewH * 0.9);
3827
- switch (e.code) {
3828
- case 'ArrowDown':
3829
- this.scroll.scrollBy(lineStep);
3830
- return true;
3831
- case 'ArrowUp':
3832
- this.scroll.scrollBy(-lineStep);
3833
- return true;
3834
- case 'PageDown':
3835
- this.scroll.scrollBy(pageStep);
3836
- return true;
3837
- case 'PageUp':
3838
- this.scroll.scrollBy(-pageStep);
3839
- return true;
3840
- case 'Space':
3841
- if (e.shiftKey) {
3842
- this.scroll.scrollBy(-pageStep);
3843
- }
3844
- else {
3845
- this.scroll.scrollBy(pageStep);
3846
- }
3847
- return true;
3848
- case 'Home':
3849
- this.scroll.scrollBy(-999999);
3850
- return true;
3851
- case 'End':
3852
- this.scroll.scrollBy(999999);
3853
- return true;
3854
- default:
3855
- return false;
4267
+ if (e.code === 'ArrowDown') {
4268
+ this.scroll.scrollBy(40);
4269
+ return true;
3856
4270
  }
4271
+ if (e.code === 'ArrowUp') {
4272
+ this.scroll.scrollBy(-40);
4273
+ return true;
4274
+ }
4275
+ return false;
3857
4276
  }
3858
4277
  fit() {
3859
- /* overlays scroll; no card fit needed */
4278
+ this.resize(this.host.screenW, this.host.screenH);
3860
4279
  }
3861
4280
  onRemove() {
3862
4281
  this.scroll.destroy({ children: true });
@@ -3982,6 +4401,49 @@ class Slider extends pixi_js.Container {
3982
4401
  return { w: this.w, h: 24 };
3983
4402
  }
3984
4403
  }
4404
+ /** Pill switch — the Pixi twin of the DOM `.ge-toggle`. 42×24, knob 20, accent when on. */
4405
+ class Toggle extends pixi_js.Container {
4406
+ track = new pixi_js.Graphics();
4407
+ knob = new pixi_js.Graphics();
4408
+ _value;
4409
+ onChange;
4410
+ accent;
4411
+ offFill;
4412
+ constructor(value, onChange, accent = '#8b5cf6', off = 'rgba(255,255,255,.22)') {
4413
+ super();
4414
+ this._value = value;
4415
+ this.onChange = onChange;
4416
+ this.accent = accent;
4417
+ this.offFill = off;
4418
+ this.addChild(this.track, this.knob);
4419
+ this.eventMode = 'static';
4420
+ this.cursor = 'pointer';
4421
+ this.hitArea = new pixi_js.Rectangle(0, 0, 42, 24);
4422
+ this.on('pointertap', () => this.onChange(!this._value));
4423
+ this.paint();
4424
+ }
4425
+ get value() {
4426
+ return this._value;
4427
+ }
4428
+ setValue(v) {
4429
+ this._value = v;
4430
+ this.paint();
4431
+ }
4432
+ paint() {
4433
+ this.track.clear();
4434
+ this.track.roundRect(0, 0, 42, 24, 12);
4435
+ this.track.fill(this._value ? this.accent : this.offFill);
4436
+ this.knob.clear();
4437
+ this.knob.circle(this._value ? 30 : 12, 12, 10);
4438
+ this.knob.fill('#ffffff');
4439
+ }
4440
+ measureSize() {
4441
+ return { w: 42, h: 24 };
4442
+ }
4443
+ setLayoutSize() {
4444
+ /* fixed size */
4445
+ }
4446
+ }
3985
4447
  /** Picker chip — `.ge-chip`. Selected = accent bg + accent border. */
3986
4448
  class Chip extends pixi_js.Container {
3987
4449
  id;
@@ -4030,112 +4492,297 @@ class Chip extends pixi_js.Container {
4030
4492
  }
4031
4493
  }
4032
4494
 
4033
- /** Settings overlay sound on/off, volume sliders, and a Game info link. */
4034
- function openSettings(host) {
4035
- return new Overlay(host, {
4036
- tag: 'settings',
4037
- title: host.t('Settings'),
4495
+ /** The bar menu as a Pixi popover. Same rows, same order as the DOM — both come from resolveMenu.
4496
+ *
4497
+ * `getAnchor` (the burger — the arrow's POINTER), `getPlate` (the bar's plaque — drives placement)
4498
+ * and `getScale` (the bar's own fit-scale) are all FUNCTIONS, resolved lazily on every reposition —
4499
+ * never a captured `BottomBar` instance. `PixiRenderer.renderBar()` destroys and rebuilds the
4500
+ * BottomBar on every resize AND on ~20 other state changes, in the SAME resize handler that then
4501
+ * repositions this popover. A captured instance would already be destroyed by the time `resize()`
4502
+ * next runs, so `menuAnchor()`/`menuPlate()` on it would return `null` and the card would silently
4503
+ * recentre with its arrow hidden — the exact bug the DOM renderer shipped and fixed the same way
4504
+ * (see `html/Menu.ts`'s plate/pointer callbacks). Passing getters means every call reads whichever
4505
+ * bar is CURRENT. `getPlate`/`getScale` are optional so every pre-existing caller (which only ever
4506
+ * passed `getAnchor`) keeps behaving exactly as it did before the plate/pointer/scale split. */
4507
+ function openMenu(host, getAnchor, getPlate, getScale) {
4508
+ const updaters = {};
4509
+ const layer = new Popover(host, {
4510
+ tag: 'menu',
4511
+ plate: () => getPlate?.() ?? null,
4512
+ pointer: () => getAnchor?.() ?? null,
4513
+ scale: () => getScale?.() ?? 1,
4038
4514
  onClose: () => host.closeLayer(),
4039
- build: (w) => buildBody$1(host, w),
4515
+ build: (width) => {
4516
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 6 });
4517
+ for (const row of resolveMenu(host))
4518
+ col.add(buildRow(host, row, width, updaters));
4519
+ return col;
4520
+ },
4040
4521
  });
4522
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
4523
+ return layer;
4524
+ }
4525
+ function label(text) {
4526
+ return makeText(text, { size: 13, weight: '600', color: '#ffffff' });
4041
4527
  }
4042
- function buildBody$1(host, width) {
4043
- const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 10 });
4044
- // Sound on/off backed by the shell's shared `soundOn` state so this toggle and the Shift+M
4045
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
4046
- const soundOn0 = host.soundOn ?? true;
4047
- const speaker = new IconButton(soundOn0 ? 'soundOn' : 'soundOff', {
4048
- size: 36,
4049
- glyph: 24,
4050
- color: host.tokens.plaqueLabel,
4051
- hover: host.tokens.accent,
4052
- activeColor: '#ffffff',
4053
- active: soundOn0,
4054
- onTap: () => host.setSound?.(!(host.soundOn ?? true)),
4528
+ function rowBox(host, name, column = false) {
4529
+ const box = new FlexBox({
4530
+ direction: column ? 'column' : 'row',
4531
+ align: column ? 'stretch' : 'center',
4532
+ gap: column ? 8 : 10,
4533
+ padding: { top: 10, bottom: 10, left: 12, right: 12 },
4534
+ minHeight: column ? undefined : 44,
4535
+ background: { fill: host.tokens.plaqueGlass, radius: 14 },
4055
4536
  });
4056
- // Live-update the speaker when sound changes from here OR via Shift+M (shell clears on close).
4057
- host.setSoundRefresh?.((on) => {
4058
- if (speaker.destroyed)
4059
- return; // overlay torn down but refresher not yet cleared (push-over edge)
4060
- speaker.setIcon(on ? 'soundOn' : 'soundOff');
4061
- speaker.active = on;
4537
+ box.label = name;
4538
+ return box;
4539
+ }
4540
+ function buildRow(host, row, width, updaters) {
4541
+ if (row.kind === 'separator') {
4542
+ const sep = new pixi_js.Container();
4543
+ sep.label = 'menu-sep';
4544
+ const line = new pixi_js.Graphics().rect(4, 6, width - 8, 1).fill(host.tokens.plaqueLine);
4545
+ line.alpha = 0.5;
4546
+ sep.addChild(line);
4547
+ return sep;
4548
+ }
4549
+ if (row.kind === 'button') {
4550
+ const box = rowBox(host, `menu-row-${row.id}`);
4551
+ if (row.icon)
4552
+ box.add(makeIcon(row.icon, 20, '#ffffff'));
4553
+ const text = label(row.label);
4554
+ box.add(text);
4555
+ box.add(new Spacer(), { grow: 1 });
4556
+ if (row.chevron)
4557
+ box.add(makeIcon('chevronRight', 16, host.tokens.muted));
4558
+ if (!row.disabled) {
4559
+ box.setInteractive(true);
4560
+ box.on('pointertap', () => {
4561
+ host.closeLayer();
4562
+ row.select();
4563
+ });
4564
+ attachHover(box, () => { box.setBgFill(host.tokens.plaqueGlassHover); text.style.fill = host.tokens.accent; }, () => { box.setBgFill(host.tokens.plaqueGlass); text.style.fill = '#ffffff'; });
4565
+ }
4566
+ else {
4567
+ box.alpha = 0.5;
4568
+ }
4569
+ return box;
4570
+ }
4571
+ if (row.kind === 'toggle') {
4572
+ const box = rowBox(host, `menu-row-${row.id}`);
4573
+ const glyph = row.icon(row.get());
4574
+ const iconNode = glyph ? makeIcon(glyph, 20, '#ffffff') : null;
4575
+ if (iconNode)
4576
+ box.add(iconNode);
4577
+ box.add(label(row.label));
4578
+ box.add(new Spacer(), { grow: 1 });
4579
+ // Disabled: neutralise the write-through at the source (a no-op onChange) rather than relying
4580
+ // solely on eventMode — Toggle wires its own pointertap listener unconditionally in its
4581
+ // constructor, so a stray direct emit must still be harmless, not just unreachable by real hits.
4582
+ const toggle = new Toggle(row.get(), row.disabled ? () => { } : (v) => row.set(v), host.tokens.accent, host.tokens.plaqueLine);
4583
+ box.add(toggle);
4584
+ updaters[row.id] = (v) => {
4585
+ const on = v === true;
4586
+ toggle.setValue(on);
4587
+ const next = row.icon(on);
4588
+ if (iconNode && next)
4589
+ iconNode.setIcon(next);
4590
+ };
4591
+ if (row.disabled) {
4592
+ box.alpha = 0.5;
4593
+ toggle.eventMode = 'none'; // dimmed + inert — mirrors the button branch's disabled treatment
4594
+ }
4595
+ return box;
4596
+ }
4597
+ const box = rowBox(host, `menu-row-${row.id}`, true);
4598
+ const head = new FlexBox({ direction: 'row', align: 'center' });
4599
+ const value = makeText(row.format(row.get()), { size: 12, weight: '700', color: host.tokens.plaqueLabel });
4600
+ head.add(label(row.label));
4601
+ head.add(new Spacer(), { grow: 1 });
4602
+ head.add(value);
4603
+ // Slider works in 0..1; map to the row's declared bounds, snapping to the MIN-anchored lattice
4604
+ // (min + k·step). Snapping to a bare multiple of step instead (Math.round(raw/step)*step) drifts
4605
+ // off `min` whenever min isn't itself a multiple of step — the common case, since the default step
4606
+ // is (max-min)/20: e.g. min:1,max:10 gives step 0.45, and the bare-multiple formula would emit 0.9
4607
+ // at the far left, BELOW the declared min. Clamp guards any float overshoot past either end.
4608
+ const toUnit = (v) => (row.max === row.min ? 0 : (v - row.min) / (row.max - row.min));
4609
+ const fromUnit = (u) => {
4610
+ const raw = row.min + u * (row.max - row.min);
4611
+ const snapped = row.min + Math.round((raw - row.min) / row.step) * row.step;
4612
+ return Math.max(row.min, Math.min(row.max, snapped));
4613
+ };
4614
+ // Disabled: same reasoning as the toggle branch above — a no-op onInput, not just eventMode.
4615
+ const slider = new Slider(host, toUnit(row.get()), row.disabled ? () => { } : (u) => {
4616
+ const v = fromUnit(u);
4617
+ value.text = row.format(v);
4618
+ head.layout(); // the readout's text just changed width (e.g. "5%" → "100%") — reposition it
4619
+ row.set(v);
4062
4620
  });
4063
- col.add(glassRow(host, [textNode(host, host.t('Sound')), new Spacer(), speaker]));
4064
- // Volume sliders — positions read from the shell's stored volumes (stateful across opens); the
4065
- // registered refreshers let `host.setVolume()` from game code move the thumbs live.
4066
- const updaters = {};
4067
- col.add(sliderRow(host, width, 'master', host.t('Master volume'), updaters));
4068
- col.add(sliderRow(host, width, 'music', host.t('Music'), updaters));
4069
- col.add(sliderRow(host, width, 'sfx', host.t('SFX'), updaters));
4070
- host.setVolumeRefresh?.((key, v) => updaters[key]?.(v));
4071
- // Game info link
4072
- const infoIcon = makeIcon('info', 22, '#ffffff');
4073
- const infoLabel = textNode(host, host.t('Game info'));
4074
- const chevron = makeIcon('chevronRight', 20, host.tokens.muted);
4075
- const infoRow = glassRow(host, [infoIcon, infoLabel, new Spacer(), chevron], { button: true, onTap: () => host.actions.openInfo() });
4076
- col.add(infoRow);
4077
- return col;
4621
+ updaters[row.id] = (v) => {
4622
+ const n = Number(v);
4623
+ value.text = row.format(n);
4624
+ head.layout();
4625
+ slider.setValue(toUnit(n));
4626
+ };
4627
+ box.add(head);
4628
+ box.add(slider);
4629
+ if (row.disabled) {
4630
+ box.alpha = 0.5;
4631
+ slider.eventMode = 'none';
4632
+ }
4633
+ return box;
4078
4634
  }
4079
- function textNode(host, text) {
4080
- return makeText(text, { size: 14, weight: '600', color: '#ffffff' });
4635
+
4636
+ function clamp(min, pref, max) {
4637
+ return Math.max(min, Math.min(max, pref));
4081
4638
  }
4082
- /** A full-width glass row holding the given children (label/controls), align-centred. */
4083
- function glassRow(host, children, opts = {}) {
4084
- const row = new FlexBox({
4085
- direction: 'row',
4086
- align: 'center',
4087
- gap: 12,
4088
- // .ge-ov-row { padding: clamp(11px,2.2vh,15px) 16px } — 15 at desktop; minHeight matches the
4089
- // DOM row's effective height (driven by the tallest child's line box, not just the em box).
4090
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
4091
- minHeight: 60,
4092
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
4639
+ /** Small rounded icon nav button the overlay header back/close and row chevrons.
4640
+ * `.ge-ov-nav`: 32×32, radius 9, plaque-dark bg, white icon, hover → glass bg + accent icon. */
4641
+ function navButton(host, iconName, onTap, size = 32) {
4642
+ const root = new pixi_js.Container();
4643
+ const bg = new pixi_js.Graphics();
4644
+ const draw = (hover) => {
4645
+ bg.clear();
4646
+ bg.roundRect(0, 0, size, size, 9);
4647
+ bg.fill(hover ? host.tokens.plaqueGlass : host.tokens.plaqueDark);
4648
+ };
4649
+ draw(false);
4650
+ const gs = Math.round(size * 0.5625); // glyph tracks the box (default 32 → 18, unchanged)
4651
+ const glyph = makeIcon(iconName, gs, '#ffffff');
4652
+ glyph.position.set((size - gs) / 2, (size - gs) / 2);
4653
+ root.addChild(bg, glyph);
4654
+ root.eventMode = 'static';
4655
+ root.cursor = 'pointer';
4656
+ root.hitArea = new pixi_js.Rectangle(0, 0, size, size);
4657
+ attachHover(root, () => {
4658
+ draw(true);
4659
+ glyph.setColor(host.tokens.accent);
4660
+ }, () => {
4661
+ draw(false);
4662
+ glyph.setColor('#ffffff');
4093
4663
  });
4094
- for (const c of children)
4095
- row.add(c, c instanceof Spacer ? { grow: 1 } : {});
4096
- if (opts.button) {
4097
- row.setInteractive(true); // full-box hit area hover + tap across the whole row, not just the icon/label
4098
- if (opts.onTap)
4099
- row.on('pointertap', opts.onTap);
4100
- // hover: brighter glass + accent text (DOM button.ge-ov-row:hover)
4101
- const texts = children.filter((c) => c instanceof pixi_js.Text);
4102
- const orig = texts.map((t) => t.style.fill);
4103
- attachHover(row, () => {
4104
- row.setBgFill(host.tokens.plaqueGlassHover);
4105
- texts.forEach((t) => (t.style.fill = host.tokens.accent));
4106
- }, () => {
4107
- row.setBgFill(host.tokens.plaqueGlass);
4108
- texts.forEach((t, i) => (t.style.fill = orig[i]));
4664
+ attachPress(root, 0.92, onTap);
4665
+ return root;
4666
+ }
4667
+ /** Full-screen overlay: frosted dark veil, fixed header (title + back/close), scrolling body.
4668
+ * The body is rebuilt on resize so it reflows like the CSS overlay. */
4669
+ class Overlay extends pixi_js.Container {
4670
+ tag;
4671
+ host;
4672
+ opts;
4673
+ veil = new pixi_js.Graphics();
4674
+ header = new pixi_js.Container();
4675
+ scroll;
4676
+ titleNode = new pixi_js.Container();
4677
+ nav = new pixi_js.Container();
4678
+ w = 0;
4679
+ h = 0;
4680
+ headerH = 44;
4681
+ /** Expose the scroll box for keyboard/test access. */
4682
+ get scrollContent() { return this.scroll; }
4683
+ constructor(host, opts) {
4684
+ super();
4685
+ this.host = host;
4686
+ this.opts = opts;
4687
+ this.tag = opts.tag;
4688
+ this.scroll = new ScrollBox(host.canvas);
4689
+ this.addChild(this.veil, this.scroll, this.header);
4690
+ this.veil.eventMode = 'static'; // swallow clicks to the game behind
4691
+ this.resize(host.screenW, host.screenH);
4692
+ }
4693
+ resize(w, h) {
4694
+ this.w = w;
4695
+ this.h = h;
4696
+ const vh = h / 100;
4697
+ this.headerH = clamp(40, 6.4 * vh, 52);
4698
+ // veil
4699
+ this.veil.clear();
4700
+ this.veil.rect(0, 0, w, h);
4701
+ this.veil.fill(this.host.tokens.backdrop);
4702
+ this.veil.hitArea = new pixi_js.Rectangle(0, 0, w, h);
4703
+ this.buildHeader();
4704
+ this.layoutBody();
4705
+ }
4706
+ buildHeader() {
4707
+ this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
4708
+ const pad = 10;
4709
+ // back or spacer (left), centred title, close (right)
4710
+ const left = this.opts.onBack
4711
+ ? navButton(this.host, 'back', () => this.opts.onBack())
4712
+ : new pixi_js.Container();
4713
+ const close = navButton(this.host, 'close', () => this.opts.onClose());
4714
+ const titleSize = clamp(13, 2.6 * (this.h / 100), 16);
4715
+ const title = makeText(this.opts.title, {
4716
+ size: titleSize,
4717
+ weight: '800',
4718
+ color: '#ffffff',
4719
+ letterSpacing: titleSize * 0.04,
4720
+ upper: true,
4721
+ align: 'center',
4109
4722
  });
4723
+ title.anchor.set(0.5);
4724
+ title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
4725
+ left.position.set(pad, (this.headerH - 32) / 2 + pad / 2);
4726
+ close.position.set(this.w - 32 - pad, (this.headerH - 32) / 2 + pad / 2);
4727
+ this.header.addChild(title, left, close);
4728
+ }
4729
+ layoutBody() {
4730
+ const top = this.headerH + 6;
4731
+ const sidePad = clamp(16, 4 * (this.w / 100), 24);
4732
+ const vPad = clamp(6, 2 * (this.h / 100), 16);
4733
+ const bodyW = Math.min(800, this.w - sidePad * 2);
4734
+ this.scroll.position.set(0, top);
4735
+ this.scroll.setViewport(this.w, this.h - top);
4736
+ this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
4737
+ const content = this.opts.build(bodyW);
4738
+ if (content instanceof FlexBox)
4739
+ content.setLayoutSize(bodyW, undefined);
4740
+ content.position.set((this.w - bodyW) / 2, vPad);
4741
+ this.scroll.content.addChild(content);
4742
+ this.scroll.refresh();
4743
+ }
4744
+ /** Handle keyboard navigation while this overlay is the top layer. */
4745
+ onKey(e) {
4746
+ const viewH = this.h - (this.headerH + 6); // body viewport height
4747
+ const lineStep = 60;
4748
+ const pageStep = Math.floor(viewH * 0.9);
4749
+ switch (e.code) {
4750
+ case 'ArrowDown':
4751
+ this.scroll.scrollBy(lineStep);
4752
+ return true;
4753
+ case 'ArrowUp':
4754
+ this.scroll.scrollBy(-lineStep);
4755
+ return true;
4756
+ case 'PageDown':
4757
+ this.scroll.scrollBy(pageStep);
4758
+ return true;
4759
+ case 'PageUp':
4760
+ this.scroll.scrollBy(-pageStep);
4761
+ return true;
4762
+ case 'Space':
4763
+ if (e.shiftKey) {
4764
+ this.scroll.scrollBy(-pageStep);
4765
+ }
4766
+ else {
4767
+ this.scroll.scrollBy(pageStep);
4768
+ }
4769
+ return true;
4770
+ case 'Home':
4771
+ this.scroll.scrollBy(-999999);
4772
+ return true;
4773
+ case 'End':
4774
+ this.scroll.scrollBy(999999);
4775
+ return true;
4776
+ default:
4777
+ return false;
4778
+ }
4779
+ }
4780
+ fit() {
4781
+ /* overlays scroll; no card fit needed */
4782
+ }
4783
+ onRemove() {
4784
+ this.scroll.destroy({ children: true });
4110
4785
  }
4111
- return row;
4112
- }
4113
- /** A column row: head (label + live % value) over a draggable slider. */
4114
- function sliderRow(host, bodyWidth, key, label, updaters) {
4115
- const row = new FlexBox({
4116
- direction: 'column',
4117
- align: 'stretch',
4118
- gap: 10,
4119
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
4120
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
4121
- });
4122
- const head = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
4123
- const initial = host.getVolume(key);
4124
- const valueText = makeText(`${Math.round(initial * 100)}%`, { size: 13, weight: '700', color: host.tokens.plaqueLabel });
4125
- head.add(makeText(label, { size: 14, weight: '600', color: '#ffffff' }));
4126
- head.add(new Spacer(), { grow: 1 });
4127
- head.add(valueText);
4128
- const slider = new Slider(host, initial, (v) => {
4129
- valueText.text = `${Math.round(v * 100)}%`;
4130
- host.setVolume(key, v);
4131
- });
4132
- updaters[key] = (v) => {
4133
- valueText.text = `${Math.round(v * 100)}%`;
4134
- slider.setValue(v);
4135
- };
4136
- row.add(head);
4137
- row.add(slider);
4138
- return row;
4139
4786
  }
4140
4787
 
4141
4788
  /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
@@ -4148,7 +4795,7 @@ function openGameInfo(host) {
4148
4795
  onClose: () => host.closeLayer(),
4149
4796
  onBack: () => {
4150
4797
  host.closeLayer();
4151
- host.actions.openSettings();
4798
+ host.actions.openMenu();
4152
4799
  },
4153
4800
  build: (w) => buildBody(host, w),
4154
4801
  });
@@ -5955,6 +6602,15 @@ class PixiRenderer {
5955
6602
  this.bar = new BottomBar(this.ctx);
5956
6603
  this.barLayer.addChild(this.bar);
5957
6604
  this.bar.applyFit();
6605
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
6606
+ // which can change the bar's own fitScale()/menuPlate() (e.g. a WIN pill appearing mid-autoplay
6607
+ // retriggers the wide layout's overflow-tightening branch). Only onResize used to reposition the
6608
+ // open layer, so a live bar-content change while the menu was open left it at the stale
6609
+ // scale/position until the next resize. Every ShellLayer's resize() only re-reads current
6610
+ // geometry and re-fits/re-centres itself — none of them call back into renderBar() (verified:
6611
+ // Popover, CardModal, Overlay, BuyBonusOverlay) — so this cannot recurse; `currentLayer` is
6612
+ // `null` whenever nothing is open, so this cannot throw either.
6613
+ this.currentLayer?.resize?.(this.screenW, this.screenH);
5958
6614
  }
5959
6615
  setLayout() {
5960
6616
  this.renderBar();
@@ -5988,8 +6644,13 @@ class PixiRenderer {
5988
6644
  openOverlay(req) {
5989
6645
  let layer = null;
5990
6646
  switch (req.kind) {
5991
- case 'settings':
5992
- layer = openSettings(this.ctx);
6647
+ case 'menu':
6648
+ // Getters, not `this.bar` by value: renderBar() destroys/rebuilds the bar on every resize
6649
+ // and ~20 other state changes, in the same resize handler that then repositions this popover
6650
+ // — see Menu.ts's openMenu doc comment for why this must stay lazy. menuAnchor (the burger)
6651
+ // is the arrow's pointer; menuPlate (the plaque) drives placement; fitScale is the same
6652
+ // factor BottomBar applies to its own content via `inner.scale`.
6653
+ layer = openMenu(this.ctx, () => this.bar?.menuAnchor() ?? null, () => this.bar?.menuPlate() ?? null, () => this.bar?.fitScale() ?? 1);
5993
6654
  break;
5994
6655
  case 'gameInfo':
5995
6656
  layer = openGameInfo(this.ctx);
@@ -6012,7 +6673,7 @@ class PixiRenderer {
6012
6673
  }
6013
6674
  if (!layer)
6014
6675
  return;
6015
- this.pushLayer(layer);
6676
+ this.pushLayer(layer, { backdrop: req.kind !== 'menu' });
6016
6677
  const built = layer;
6017
6678
  return {
6018
6679
  onKey: built.onKey ? built.onKey.bind(built) : undefined,
@@ -6022,10 +6683,6 @@ class PixiRenderer {
6022
6683
  closeOverlay() {
6023
6684
  this.closeLayer();
6024
6685
  }
6025
- refreshSoundIcon(_on) {
6026
- // No-op: the open Settings overlay registers its icon updater via host.setSoundRefresh, which
6027
- // the controller's setSound path drives. The renderer has nothing extra to refresh.
6028
- }
6029
6686
  /** Fade out (≈250ms, like GameShell's REMOVE_FADE_MS) then tear down; resolves when removed. */
6030
6687
  destroy() {
6031
6688
  if (this.destroyed)
@@ -6049,9 +6706,12 @@ class PixiRenderer {
6049
6706
  });
6050
6707
  }
6051
6708
  // ── layer stack ────────────────────────────────────────────────────────────
6052
- pushLayer(node) {
6709
+ pushLayer(node, opts) {
6053
6710
  this.clearLayer();
6054
- this.makeBackdrop(); // frosted snapshot of the scene behind (the DOM's backdrop-filter:blur)
6711
+ // Light-dismiss layers (the menu popover) opt out with `{ backdrop: false }` no frosted
6712
+ // snapshot, the game stays visible behind them. Every other caller is unaffected (defaults on).
6713
+ if (opts?.backdrop !== false)
6714
+ this.makeBackdrop(); // frosted snapshot (the DOM's backdrop-filter:blur)
6055
6715
  this.currentLayer = node;
6056
6716
  this.modalLayer.addChild(node);
6057
6717
  this.fitModals();
@@ -6173,6 +6833,7 @@ class PixiRenderer {
6173
6833
  get tokens() { return host.tokens; },
6174
6834
  get layout() { return host.layout; },
6175
6835
  get soundOn() { return host.soundOn; },
6836
+ get menu() { return host.menu; },
6176
6837
  get actions() { return host.actions; },
6177
6838
  openReplay: (opts) => host.openReplay(opts),
6178
6839
  t: (s) => host.t(s),
@@ -6180,17 +6841,18 @@ class PixiRenderer {
6180
6841
  emit: host.emit.bind(host),
6181
6842
  notifyResize: (w, h) => host.notifyResize(w, h),
6182
6843
  setSound: (on) => host.setSound(on),
6183
- setSoundRefresh: (fn) => host.setSoundRefresh(fn),
6184
6844
  getVolume: (key) => host.getVolume(key),
6185
6845
  setVolume: (key, v) => host.setVolume(key, v),
6186
- setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
6846
+ getMenuValue: (id) => host.getMenuValue(id),
6847
+ setMenuValue: (id, v) => host.setMenuValue(id, v),
6848
+ setMenuRefresh: (fn) => host.setMenuRefresh(fn),
6187
6849
  // — Pixi-specific surface —
6188
6850
  get ticker() { return self.app.ticker; },
6189
6851
  get canvas() { return self.app.canvas; },
6190
6852
  get screenW() { return self.app.screen.width; },
6191
6853
  get screenH() { return self.app.screen.height; },
6192
6854
  render: () => self.renderBar(),
6193
- pushLayer: (node) => self.pushLayer(node),
6855
+ pushLayer: (node, opts) => self.pushLayer(node, opts),
6194
6856
  // Route component-initiated closes through the controller (not straight to self.closeLayer) so
6195
6857
  // it clears its OverlayHandle. Otherwise the handle goes stale: hasOpenLayer() stays true and
6196
6858
  // keydowns keep routing to onKey on a destroyed overlay → write to a torn-down ScrollBox.
@@ -6225,16 +6887,24 @@ function removePixiShell() {
6225
6887
  }
6226
6888
 
6227
6889
  exports.DEFAULT_ACCENT = DEFAULT_ACCENT;
6890
+ exports.DEFAULT_MENU = DEFAULT_MENU;
6228
6891
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
6892
+ exports.POPOVER = POPOVER;
6229
6893
  exports.PixiRenderer = PixiRenderer;
6230
6894
  exports.SCHEMES = SCHEMES;
6231
6895
  exports.ShellController = ShellController;
6232
6896
  exports.createI18n = createI18n;
6233
6897
  exports.createPixiShell = createPixiShell;
6234
6898
  exports.createShell = createShell;
6899
+ exports.isPresetId = isPresetId;
6235
6900
  exports.normalizeLang = normalizeLang;
6901
+ exports.placePopover = placePopover;
6902
+ exports.popoverWidth = popoverWidth;
6903
+ exports.rangeBounds = rangeBounds;
6236
6904
  exports.removePixiShell = removePixiShell;
6237
6905
  exports.resolveConfig = resolveConfig;
6906
+ exports.resolveMenu = resolveMenu;
6238
6907
  exports.resolveTheme = resolveTheme;
6908
+ exports.seedMenuValues = seedMenuValues;
6239
6909
  exports.socialize = socialize;
6240
6910
  //# sourceMappingURL=pixi.cjs.js.map