@energy8platform/shell 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/html.cjs.js +679 -113
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +213 -31
  4. package/dist/html.esm.js +672 -114
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +376 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +204 -29
  9. package/dist/index.esm.js +369 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +957 -285
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +208 -32
  14. package/dist/pixi.esm.js +950 -286
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +75 -21
  18. package/src/core/icon-names.ts +30 -0
  19. package/src/core/index.ts +4 -0
  20. package/src/core/menu.ts +301 -0
  21. package/src/core/popover.ts +81 -0
  22. package/src/core/renderer.ts +17 -12
  23. package/src/core/state.ts +2 -1
  24. package/src/core/types.ts +12 -6
  25. package/src/core/version.ts +1 -1
  26. package/src/ui/html/HtmlRenderer.ts +35 -12
  27. package/src/ui/html/components/GameInfo.ts +1 -1
  28. package/src/ui/html/components/Menu.ts +153 -0
  29. package/src/ui/html/icons.ts +16 -15
  30. package/src/ui/html/primitives.ts +142 -0
  31. package/src/ui/html/shell.css.ts +21 -1
  32. package/src/ui/pixi/PixiRenderer.ts +41 -21
  33. package/src/ui/pixi/components/BottomBar.ts +80 -9
  34. package/src/ui/pixi/components/GameInfo.ts +1 -1
  35. package/src/ui/pixi/components/Menu.ts +167 -0
  36. package/src/ui/pixi/context.ts +3 -2
  37. package/src/ui/pixi/icons.ts +16 -15
  38. package/src/ui/pixi/pixi-icon.ts +15 -3
  39. package/src/ui/pixi/primitives/controls.ts +46 -0
  40. package/src/ui/pixi/primitives/popover.ts +163 -0
  41. package/src/ui/pixi/primitives/scroll.ts +9 -5
  42. package/src/ui/html/components/Settings.ts +0 -68
  43. package/src/ui/pixi/components/Settings.ts +0 -132
package/dist/pixi.esm.js CHANGED
@@ -51,6 +51,240 @@ class EventEmitter {
51
51
  }
52
52
  }
53
53
 
54
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
55
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
56
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
57
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
58
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
59
+ // `node scripts/gen-icons-from-svg.mjs`.
60
+ // The single glyph-name union, shared by core (menu items) and both renderers.
61
+ const ICON_NAMES = [
62
+ 'spin',
63
+ 'turbo1',
64
+ 'autoplay',
65
+ 'stop',
66
+ 'menu',
67
+ 'minus',
68
+ 'plus',
69
+ 'gift',
70
+ 'info',
71
+ 'soundOn',
72
+ 'soundOff',
73
+ 'close',
74
+ 'back',
75
+ 'chevronRight',
76
+ 'ticket',
77
+ 'turbo2',
78
+ 'turboOff',
79
+ 'chevronUp',
80
+ 'chevronDown',
81
+ ];
82
+
83
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
84
+ function isPresetId(id) {
85
+ return PRESET_IDS.includes(id);
86
+ }
87
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
88
+ const DEFAULT_MENU = [
89
+ { id: 'sound' },
90
+ { id: 'music' },
91
+ { id: 'sfx' },
92
+ { type: 'separator' },
93
+ { id: 'gameInfo' },
94
+ ];
95
+ const isSeparator = (i) => i.type === 'separator';
96
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
97
+ function rangeBounds(item) {
98
+ const min = item.min ?? 0;
99
+ const max = item.max ?? 1;
100
+ const derivedStep = (max - min) / 20;
101
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
102
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
103
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
104
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
105
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
106
+ return { min, max, step };
107
+ }
108
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
109
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
110
+ function seedMenuValues(items, prev = {}) {
111
+ const out = {};
112
+ for (const item of items) {
113
+ if (isSeparator(item))
114
+ continue;
115
+ const type = item.type;
116
+ if (!type || isPresetId(item.id))
117
+ continue;
118
+ // A previous value only carries over when its runtime type still matches this item's kind.
119
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
120
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
121
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
122
+ // of warning.
123
+ const prevValue = prev[item.id];
124
+ if (type === 'toggle') {
125
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
126
+ }
127
+ else if (type === 'range') {
128
+ const r = item;
129
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ const percent = (v) => `${Math.round(v * 100)}%`;
135
+ function safeIcon(name) {
136
+ return name && ICON_NAMES.includes(name) ? name : undefined;
137
+ }
138
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
139
+ function warn(message) {
140
+ console.warn(`[shell] ${message}`);
141
+ }
142
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
143
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
144
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
145
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
146
+ function resolveMenu(host) {
147
+ const rows = [];
148
+ for (const item of host.menu) {
149
+ if (isSeparator(item)) {
150
+ rows.push({ kind: 'separator' });
151
+ continue;
152
+ }
153
+ const type = item.type;
154
+ if (!type) {
155
+ const row = preset(host, item);
156
+ if (row)
157
+ rows.push(row);
158
+ else
159
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
160
+ continue;
161
+ }
162
+ const row = custom(host, item, type);
163
+ if (row)
164
+ rows.push(row);
165
+ }
166
+ return rows;
167
+ }
168
+ function preset(host, item) {
169
+ const disabled = item.disabled ?? false;
170
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
171
+ switch (item.id) {
172
+ case 'sound':
173
+ return {
174
+ kind: 'toggle',
175
+ id: 'sound',
176
+ label,
177
+ disabled,
178
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
179
+ get: () => host.getMenuValue('sound') !== false,
180
+ set: (v) => host.setMenuValue('sound', v),
181
+ };
182
+ case 'music':
183
+ case 'sfx': {
184
+ const id = item.id;
185
+ return {
186
+ kind: 'range',
187
+ id,
188
+ label,
189
+ icon: safeIcon(item.icon),
190
+ disabled,
191
+ min: 0,
192
+ max: 1,
193
+ step: 0.05,
194
+ get: () => Number(host.getMenuValue(id) ?? 1),
195
+ set: (v) => host.setMenuValue(id, v),
196
+ format: percent,
197
+ };
198
+ }
199
+ case 'gameInfo':
200
+ return {
201
+ kind: 'button',
202
+ id: 'gameInfo',
203
+ label,
204
+ icon: safeIcon(item.icon) ?? 'info',
205
+ disabled,
206
+ chevron: true,
207
+ select: () => host.actions.openInfo(),
208
+ };
209
+ default:
210
+ return null;
211
+ }
212
+ }
213
+ const DEFAULT_LABELS = {
214
+ sound: 'Sound',
215
+ music: 'Music',
216
+ sfx: 'SFX',
217
+ gameInfo: 'Game info',
218
+ };
219
+ function custom(host, item, type) {
220
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
221
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
222
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
223
+ if (isPresetId(item.id)) {
224
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
225
+ }
226
+ const disabled = item.disabled ?? false;
227
+ const label = host.t(item.label ?? item.id);
228
+ const icon = safeIcon(item.icon);
229
+ if (type === 'toggle') {
230
+ const it = item;
231
+ return {
232
+ kind: 'toggle',
233
+ id: it.id,
234
+ label,
235
+ disabled,
236
+ icon: () => icon,
237
+ get: () => host.getMenuValue(it.id) === true,
238
+ set: (v) => {
239
+ host.setMenuValue(it.id, v);
240
+ it.onChange?.(v);
241
+ },
242
+ };
243
+ }
244
+ if (type === 'range') {
245
+ const it = item;
246
+ const { min, max, step } = rangeBounds(it);
247
+ if (max <= min) {
248
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
249
+ // drop the row instead, exactly like an unknown preset id.
250
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
251
+ return null;
252
+ }
253
+ return {
254
+ kind: 'range',
255
+ id: it.id,
256
+ label,
257
+ icon,
258
+ disabled,
259
+ min,
260
+ max,
261
+ step,
262
+ get: () => Number(host.getMenuValue(it.id) ?? min),
263
+ set: (v) => {
264
+ host.setMenuValue(it.id, v);
265
+ it.onChange?.(v);
266
+ },
267
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
268
+ };
269
+ }
270
+ if (type === 'button') {
271
+ const it = item;
272
+ return {
273
+ kind: 'button',
274
+ id: it.id,
275
+ label,
276
+ icon,
277
+ disabled,
278
+ chevron: it.chevron ?? false,
279
+ select: () => it.onSelect?.(),
280
+ };
281
+ }
282
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
283
+ // button row with no onSelect. Drop it visibly instead.
284
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
285
+ return null;
286
+ }
287
+
54
288
  function createInitialState(config) {
55
289
  return {
56
290
  mode: config.mode,
@@ -67,10 +301,10 @@ function createInitialState(config) {
67
301
  bonus: null,
68
302
  activeFeature: null,
69
303
  volumes: {
70
- master: clampVolume(config.volumes?.master),
71
304
  music: clampVolume(config.volumes?.music),
72
305
  sfx: clampVolume(config.volumes?.sfx),
73
306
  },
307
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
74
308
  };
75
309
  }
76
310
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -1454,7 +1688,7 @@ class KeyboardController {
1454
1688
 
1455
1689
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1456
1690
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1457
- const PACKAGE_VERSION = '0.6.5';
1691
+ const PACKAGE_VERSION = '0.7.0';
1458
1692
 
1459
1693
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1460
1694
  function resolveConfig(config) {
@@ -1472,6 +1706,7 @@ function resolveConfig(config) {
1472
1706
  theme: config.theme,
1473
1707
  onBonusBuy: config.onBonusBuy,
1474
1708
  volumes: config.volumes,
1709
+ menu: config.menu,
1475
1710
  version: config.version ?? '1.0.0',
1476
1711
  isSocial: config.isSocial ?? false,
1477
1712
  replay: config.replay ?? config.mode === 'replay',
@@ -1492,8 +1727,9 @@ class ShellController extends EventEmitter {
1492
1727
  i18n;
1493
1728
  kbd;
1494
1729
  overlay = null;
1495
- soundRefresh = null;
1496
- volumeRefresh = null;
1730
+ menuItems;
1731
+ menuRefresh = null;
1732
+ overlayKind = null;
1497
1733
  prevBalance;
1498
1734
  prevWin;
1499
1735
  destroyed = false;
@@ -1504,6 +1740,7 @@ class ShellController extends EventEmitter {
1504
1740
  this.config = resolveConfig(config);
1505
1741
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1506
1742
  this.state = createInitialState(this.config);
1743
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1507
1744
  this.tokens = resolveTheme(this.config.theme);
1508
1745
  this.prevBalance = this.state.balance;
1509
1746
  this.prevWin = this.state.win;
@@ -1637,14 +1874,21 @@ class ShellController extends EventEmitter {
1637
1874
  show(req) {
1638
1875
  this.closeModal();
1639
1876
  this.overlay = this.renderer.openOverlay(req) ?? null;
1877
+ this.overlayKind = this.overlay ? req.kind : null;
1640
1878
  }
1879
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1641
1880
  openMenu() {
1881
+ if (this.overlayKind === 'menu') {
1882
+ this.closeModal();
1883
+ return;
1884
+ }
1642
1885
  this.emit('menuOpen');
1643
- this.openSettings();
1886
+ this.show({ kind: 'menu' });
1644
1887
  }
1888
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1645
1889
  openSettings() {
1646
1890
  this.emit('settingsOpen');
1647
- this.show({ kind: 'settings' });
1891
+ this.openMenu();
1648
1892
  }
1649
1893
  openInfo() {
1650
1894
  this.emit('infoOpen');
@@ -1676,35 +1920,72 @@ class ShellController extends EventEmitter {
1676
1920
  if (!this.overlay)
1677
1921
  return;
1678
1922
  this.overlay = null;
1679
- this.soundRefresh = null;
1680
- this.volumeRefresh = null;
1923
+ this.overlayKind = null;
1924
+ this.menuRefresh = null;
1681
1925
  this.renderer.closeOverlay();
1682
1926
  }
1683
1927
  // ── sound ──────────────────────────────────────────────────────────────────
1684
1928
  setSound(on) {
1685
1929
  this.soundOn = on;
1686
1930
  this.emit('settingChange', { key: 'sound', value: on });
1687
- this.soundRefresh?.(on);
1688
- this.renderer.refreshSoundIcon?.(on);
1689
- }
1690
- setSoundRefresh(fn) {
1691
- this.soundRefresh = fn;
1931
+ this.menuRefresh?.('sound', on);
1692
1932
  }
1693
1933
  // ── volume ─────────────────────────────────────────────────────────────────
1694
1934
  getVolume(key) {
1695
1935
  return this.state.volumes[key];
1696
1936
  }
1697
1937
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1698
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1699
- * live-updates the slider if the overlay is currently open. */
1938
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1939
+ * live-updates the slider if the menu is currently open. */
1700
1940
  setVolume(key, value) {
1701
1941
  const v = Math.max(0, Math.min(1, value));
1702
1942
  this.state.volumes[key] = v;
1703
1943
  this.emit('settingChange', { key, value: v });
1704
- this.volumeRefresh?.(key, v);
1705
- }
1706
- setVolumeRefresh(fn) {
1707
- this.volumeRefresh = fn;
1944
+ this.menuRefresh?.(key, v);
1945
+ }
1946
+ // ── menu ───────────────────────────────────────────────────────────────────
1947
+ get menu() {
1948
+ return this.menuItems;
1949
+ }
1950
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1951
+ setMenu(items) {
1952
+ this.menuItems = items;
1953
+ this.state.menu = seedMenuValues(items, this.state.menu);
1954
+ if (this.overlayKind === 'menu')
1955
+ this.show({ kind: 'menu' });
1956
+ }
1957
+ getMenuValue(id) {
1958
+ if (id === 'sound')
1959
+ return this.soundOn;
1960
+ if (id === 'music' || id === 'sfx')
1961
+ return this.state.volumes[id];
1962
+ return this.state.menu[id];
1963
+ }
1964
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1965
+ setMenuValue(id, value) {
1966
+ if (id === 'sound') {
1967
+ this.setSound(value !== false);
1968
+ return;
1969
+ }
1970
+ if (id === 'music' || id === 'sfx') {
1971
+ this.setVolume(id, Number(value));
1972
+ return;
1973
+ }
1974
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1975
+ this.state.menu[id] = next;
1976
+ this.emit('settingChange', { key: id, value: next });
1977
+ this.menuRefresh?.(id, next);
1978
+ }
1979
+ setMenuRefresh(fn) {
1980
+ this.menuRefresh = fn;
1981
+ }
1982
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1983
+ clampRange(id, value) {
1984
+ const item = this.menuItems.find((i) => i.id === id);
1985
+ if (!item || item.type !== 'range')
1986
+ return value;
1987
+ const { min, max } = rangeBounds(item);
1988
+ return Math.max(min, Math.min(max, value));
1708
1989
  }
1709
1990
  // ── features ─────────────────────────────────────────────────────────────────
1710
1991
  activateFeature(bonus) {
@@ -1721,10 +2002,10 @@ class ShellController extends EventEmitter {
1721
2002
  this.renderer.renderBar();
1722
2003
  }
1723
2004
  // ── game-facing public API (mirrors GameShell/PixiGameShell) ───────────────────
1724
- money(field, from, to) {
2005
+ money(field, from, to, durationMs) {
1725
2006
  this.renderer.renderBar();
1726
2007
  if (to !== from)
1727
- this.renderer.animateMoney(field, from, to);
2008
+ this.renderer.animateMoney(field, from, to, durationMs);
1728
2009
  }
1729
2010
  setBalance(n) {
1730
2011
  const from = this.prevBalance;
@@ -1734,7 +2015,9 @@ class ShellController extends EventEmitter {
1734
2015
  }
1735
2016
  /** Set the WIN readout. Counts up/down from the previous value by default. Pass
1736
2017
  * `{ animate: false }` to SNAP instantly (renderBar cancels any in-flight count-up) — used by the
1737
- * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong. */
2018
+ * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong.
2019
+ * `{ durationMs }` shortens/lengthens the count-up (default 450ms) — a scene reporting a win per
2020
+ * cascade step passes its step length so each count-up finishes before the next step lands. */
1738
2021
  setWin(n, opts) {
1739
2022
  const from = this.prevWin;
1740
2023
  this.state.win = n;
@@ -1743,7 +2026,7 @@ class ShellController extends EventEmitter {
1743
2026
  this.renderer.renderBar(); // instant repaint from state; cancels running money anims
1744
2027
  return;
1745
2028
  }
1746
- this.money('win', from, n);
2029
+ this.money('win', from, n, opts?.durationMs);
1747
2030
  }
1748
2031
  setBet(n) {
1749
2032
  this.state.bet = n;
@@ -1812,6 +2095,11 @@ class ShellController extends EventEmitter {
1812
2095
  if (this.destroyed)
1813
2096
  return Promise.resolve();
1814
2097
  this.destroyed = true;
2098
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2099
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2100
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2101
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2102
+ this.closeModal();
1815
2103
  if (typeof document !== 'undefined') {
1816
2104
  this.kbd?.detach();
1817
2105
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1821,6 +2109,64 @@ class ShellController extends EventEmitter {
1821
2109
  }
1822
2110
  }
1823
2111
 
2112
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2113
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2114
+ const POPOVER = {
2115
+ /** Keep-out from the surface edges. */
2116
+ margin: 8,
2117
+ /** Space between the anchor and the card. */
2118
+ gap: 8,
2119
+ /** Minimum distance from the arrow tip to either rounded corner. */
2120
+ arrowInset: 14,
2121
+ /** A card shorter than this does not fit — flip to the other side instead. */
2122
+ minH: 120,
2123
+ minW: 220,
2124
+ maxW: 320,
2125
+ };
2126
+ const clamp$1 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2127
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2128
+ function popoverWidth(surfaceW, contentW) {
2129
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2130
+ return Math.max(0, clamp$1(contentW, Math.min(POPOVER.minW, hi), hi));
2131
+ }
2132
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2133
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2134
+ *
2135
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2136
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2137
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2138
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2139
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2140
+ function placePopover(anchor, surface, size, pointer = null) {
2141
+ const { margin, gap, arrowInset, minH } = POPOVER;
2142
+ if (!anchor) {
2143
+ const maxH = Math.max(0, surface.h - margin * 2);
2144
+ const h = Math.min(size.h, maxH);
2145
+ const rawY = (surface.h - h) / 2;
2146
+ const y = clamp$1(rawY, margin, Math.max(margin, surface.h - h - margin));
2147
+ return {
2148
+ x: Math.max(margin, (surface.w - size.w) / 2),
2149
+ y,
2150
+ maxH,
2151
+ arrowX: -1,
2152
+ below: false,
2153
+ };
2154
+ }
2155
+ const spaceAbove = anchor.y - gap - margin;
2156
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2157
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2158
+ // genuinely more room on the other side.
2159
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2160
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2161
+ const h = Math.min(size.h, maxH);
2162
+ const x = clamp$1(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2163
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2164
+ const y = clamp$1(rawY, margin, Math.max(margin, surface.h - h - margin));
2165
+ const arrowAnchor = pointer ?? anchor;
2166
+ const arrowX = clamp$1(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2167
+ return { x, y, maxH, arrowX, below };
2168
+ }
2169
+
1824
2170
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1825
2171
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1826
2172
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2290,25 +2636,25 @@ function roundedPath(g, x, y, w, h, [tl, tr, br, bl]) {
2290
2636
  // GraphicsContext.svg. To change an icon: edit icons.svg, then run
2291
2637
  // `node scripts/gen-icons-from-svg.mjs`.
2292
2638
  const SVGS = {
2293
- 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>`,
2294
- 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>`,
2295
- 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>`,
2639
+ 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>`,
2640
+ 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>`,
2641
+ 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>`,
2296
2642
  stop: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="1.6" width="20.8" height="20.8" rx="2.9"/></g>`,
2297
2643
  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>`,
2298
2644
  minus: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="9.86" width="20.8" height="4.28" rx="2.11"/></g>`,
2299
- 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>`,
2645
+ 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>`,
2300
2646
  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)"/>`,
2301
- 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>`,
2302
- 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>`,
2647
+ 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>`,
2648
+ 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>`,
2303
2649
  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>`,
2304
- 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>`,
2305
- 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>`,
2306
- 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>`,
2650
+ 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>`,
2651
+ 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>`,
2652
+ 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>`,
2307
2653
  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"/>`,
2308
- 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>`,
2309
- 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>`,
2310
- 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>`,
2311
- 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>`,
2654
+ 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>`,
2655
+ 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>`,
2656
+ 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>`,
2657
+ 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>`,
2312
2658
  };
2313
2659
  /** A standalone, parseable SVG document with `currentColor` resolved to `color`.
2314
2660
  * Used by the Pixi renderer to build vector geometry via GraphicsContext.svg(). */
@@ -2344,10 +2690,10 @@ class IconView extends Container {
2344
2690
  gfx;
2345
2691
  _size;
2346
2692
  _color;
2347
- iconName;
2693
+ _iconName;
2348
2694
  constructor(name, size, color = '#ffffff') {
2349
2695
  super();
2350
- this.iconName = name;
2696
+ this._iconName = name;
2351
2697
  this._size = size;
2352
2698
  this._color = color;
2353
2699
  this.gfx = new Graphics(context(name, color));
@@ -2363,11 +2709,22 @@ class IconView extends Container {
2363
2709
  get size() {
2364
2710
  return this._size;
2365
2711
  }
2712
+ get iconName() {
2713
+ return this._iconName;
2714
+ }
2366
2715
  setColor(color) {
2367
2716
  if (color === this._color)
2368
2717
  return;
2369
2718
  this._color = color;
2370
- this.gfx.context = context(this.iconName, color);
2719
+ this.gfx.context = context(this._iconName, color);
2720
+ }
2721
+ /** Swap the glyph in place (e.g. the menu's sound row flips soundOn/soundOff on toggle) — same
2722
+ * cached-context rebuild as setColor, keyed on the new name instead of a new colour. */
2723
+ setIcon(name) {
2724
+ if (name === this._iconName)
2725
+ return;
2726
+ this._iconName = name;
2727
+ this.gfx.context = context(name, this._color);
2371
2728
  }
2372
2729
  setSize(size) {
2373
2730
  if (size === this._size)
@@ -3099,6 +3456,11 @@ class BottomBar extends Container {
3099
3456
  spin;
3100
3457
  autoBtn;
3101
3458
  turboBtn;
3459
+ menuBtn;
3460
+ /** The menu's PLATE, in LOCAL coordinates relative to `inner` — the continuous dark panel (wide)
3461
+ * or the controls row (mobile). Set at the point `applyFit()`/`applyFitMobile()` compute it;
3462
+ * `menuPlate()` converts it to screen space lazily (see that method's doc comment for why). */
3463
+ plateRect = null;
3102
3464
  constructor(host) {
3103
3465
  super();
3104
3466
  this.host = host;
@@ -3108,6 +3470,58 @@ class BottomBar extends Container {
3108
3470
  else
3109
3471
  this.buildWide();
3110
3472
  }
3473
+ /** Screen-space rect of the burger — the menu popover's POINTER (the arrow's target only; see
3474
+ * `menuPlate` for what drives placement). `measureSize()` (the button's own nominal box, e.g.
3475
+ * 36×36) rather than the built-in `getSize()` (the glyph's ink bounds only) so a burger whose icon
3476
+ * doesn't fill its box still reports its true hit-box size; multiplied by `inner.scale` — `getSize`
3477
+ * /`measureSize` only reflect the node's OWN scale (always 1 here), not the ancestor `inner.scale`
3478
+ * the bar's whole fit-scale lives on, so the UNMULTIPLIED width/height would silently overstate the
3479
+ * burger's true on-screen footprint on a scaled-down bar and skew the arrow off-centre. */
3480
+ menuAnchor() {
3481
+ if (!this.menuBtn || this.menuBtn.destroyed)
3482
+ return null;
3483
+ const p = this.menuBtn.getGlobalPosition();
3484
+ const s = this.inner.scale.x;
3485
+ const size = this.menuBtn.measureSize();
3486
+ return { x: p.x, y: p.y, w: size.w * s, h: size.h * s };
3487
+ }
3488
+ /** Screen-space rect of the menu's PLATE — the continuous dark panel (wide) or the controls row
3489
+ * (mobile), NOT the burger itself and NOT (mobile) the info pill below it. Drives the popover's
3490
+ * x/y/maxH/below; the burger (`menuAnchor`) drives only the arrow. Resolved lazily on every call,
3491
+ * like `menuAnchor` — `PixiRenderer.renderBar()` destroys and rebuilds this BottomBar on every
3492
+ * resize and ~20 other state changes, so a value captured once would go stale. `plateRect` is
3493
+ * local to `inner`; `getGlobalPosition` + `inner.scale` convert it to screen space (`outer*` on the
3494
+ * mobile FlexBox and the wide panel's own drawn rect are both LOCAL sizes, unaffected by `inner`'s
3495
+ * ancestor scale, same reasoning as `menuAnchor` above). */
3496
+ menuPlate() {
3497
+ // Pixi v8's Container.destroy() nulls `_position`/`_scale`, so `inner.scale.x` /
3498
+ // `inner.getGlobalPosition()` on a destroyed bar throws — guard for symmetry with menuAnchor()
3499
+ // above, which already returns null once its button is destroyed. Not reachable today
3500
+ // (PixiRenderer.renderBar() destroys and reassigns `this.bar` synchronously, in the same call),
3501
+ // but the asymmetry is a trap for a future caller that holds a reference across a render.
3502
+ if (this.destroyed)
3503
+ return null;
3504
+ if (!this.plateRect)
3505
+ return null;
3506
+ const s = this.inner.scale.x;
3507
+ const origin = this.inner.getGlobalPosition();
3508
+ return {
3509
+ x: origin.x + this.plateRect.x * s,
3510
+ y: origin.y + this.plateRect.y * s,
3511
+ w: this.plateRect.w * s,
3512
+ h: this.plateRect.h * s,
3513
+ };
3514
+ }
3515
+ /** The scale factor the bar currently applies to itself (`inner.scale`) — shared with the menu
3516
+ * popover so its typography/padding/row-heights scale in lockstep with the bar's own chrome,
3517
+ * instead of ignoring the viewport like a fixed-local-size card would. */
3518
+ fitScale() {
3519
+ // Same destroyed guard as menuPlate()/menuAnchor() above, and the same neutral fallback a
3520
+ // scale factor should have (1 = no scaling) rather than throwing.
3521
+ if (this.destroyed)
3522
+ return 1;
3523
+ return this.inner.scale.x;
3524
+ }
3111
3525
  // ── wide / landscape ──────────────────────────────────────────────────────
3112
3526
  buildWide() {
3113
3527
  const { state, tokens } = this.host;
@@ -3118,13 +3532,14 @@ class BottomBar extends Container {
3118
3532
  this.buy = isBase ? (this.buildBuy(BUY_W, 13, 3) ?? undefined) : undefined;
3119
3533
  // LEFT info group: menu · balance · (Total win) · (Win)
3120
3534
  const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
3121
- left.add(new IconButton('menu', {
3535
+ this.menuBtn = new IconButton('menu', {
3122
3536
  size: 36,
3123
3537
  glyph: 30,
3124
3538
  color: '#ffffff',
3125
3539
  hover: tokens.accent,
3126
3540
  onTap: () => this.host.actions.openMenu(),
3127
- }));
3541
+ });
3542
+ left.add(this.menuBtn);
3128
3543
  if (!state.replay) {
3129
3544
  const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
3130
3545
  this.balanceValue = bal.valueText;
@@ -3313,6 +3728,7 @@ class BottomBar extends Container {
3313
3728
  hover: tokens.accent,
3314
3729
  onTap: () => this.host.actions.openMenu(),
3315
3730
  });
3731
+ this.menuBtn = menu;
3316
3732
  // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
3317
3733
  if (isBase && config.features.autoplay) {
3318
3734
  this.autoBtn = new IconButton('autoplay', {
@@ -3548,6 +3964,8 @@ class BottomBar extends Container {
3548
3964
  this.panelBg.clear();
3549
3965
  roundedPath(this.panelBg, panelX, SPIN_POP, panelRight - panelX, BAR_H, [12, 12, 12, 12]);
3550
3966
  this.panelBg.fill(tokens.bar);
3967
+ // The menu's plate — the whole continuous dark panel, local to `inner` (see menuPlate()).
3968
+ this.plateRect = { x: panelX, y: SPIN_POP, w: panelRight - panelX, h: BAR_H };
3551
3969
  if (this.buy)
3552
3970
  this.buy.position.set(OUTER_PAD, panelCenterY - BUY_W / 2);
3553
3971
  left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
@@ -3580,6 +3998,18 @@ class BottomBar extends Container {
3580
3998
  // SPIN hero), inflating the controls row so it overlaps the info row below it.
3581
3999
  controls.setLayoutSize(rowW, M_CTRL_H);
3582
4000
  info.setLayoutSize(rowW, M_INFO_H);
4001
+ // The menu's plate — the controls row (NOT the info pill below it), local to `inner`. Read from
4002
+ // `controls.outerWidth/outerHeight` (its own nominal box), NOT the FlexBox's own bounds (which
4003
+ // would include the SPIN/FS hero popping out both above AND below it — see menuPlate()'s doc
4004
+ // comment) — but the TOP edge is deliberately extended upward by `pop`, the same hero pop-out
4005
+ // amount reserved as `topPad` above, so the plate the popover math sees is the row's true visual
4006
+ // extent on the side that matters: without this, the card's bottom (only `gap` above the plate's
4007
+ // top) can clip the top ~(pop-gap)px of the hero's arc, since 62px M_CTRL_H is 11px shorter than
4008
+ // the 84px hero. The BOTTOM edge is left alone — the hero's symmetric under-pop is a separate,
4009
+ // unrelated concern (the info pill sits right below with its own small gap) and extending it too
4010
+ // would perturb the `below`-placement branch (spaceBelow) for no benefit. A no-op when `pop` is 0
4011
+ // (no hero shown — e.g. replay without free spins).
4012
+ this.plateRect = { x: 0, y: topPad - pop, w: controls.outerWidth, h: controls.outerHeight + pop };
3583
4013
  const s = rowW > 0 ? Math.max(0.4, Math.min(1, avail / rowW)) : 1;
3584
4014
  this.inner.scale.set(s);
3585
4015
  this.inner.position.set((W - rowW * s) / 2, H - localBottom * s - M_PAD_BOTTOM);
@@ -3610,7 +4040,8 @@ class ScrollBox extends Container {
3610
4040
  this.canvas = canvas;
3611
4041
  this.addChild(this.content);
3612
4042
  // maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
3613
- // graphic renders as a white rect, and a masked container blocks pointer events to its children.
4043
+ // graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
4044
+ // what an earlier version of this comment claimed — see the correction in refresh() below.)
3614
4045
  this.eventMode = 'static';
3615
4046
  this.on('pointerdown', this.onDown);
3616
4047
  this.on('globalpointermove', this.onMove);
@@ -3644,10 +4075,13 @@ class ScrollBox extends Container {
3644
4075
  const b = this.content.getLocalBounds();
3645
4076
  const contentH = b.height + b.y; // content laid out from y≈0 downward
3646
4077
  this.maxScroll = Math.max(0, contentH - this.viewH);
3647
- // Only clip + grab pointer/drag when the content actually overflows: a masked container blocks
3648
- // pointer events to its children in Pixi v8, so when it fits we leave it unmasked and passive →
3649
- // interactive controls (settings sliders/buttons) work. Tall scrolling content (game info) that
3650
- // does get masked has no interactive children, so nothing is lost there.
4078
+ // Only clip + grab pointer/drag when the content actually overflows. (An earlier version of this
4079
+ // comment claimed a masked container blocks pointer events to its children in Pixi v8 that's
4080
+ // false: EventBoundary's hitPruneFn only prunes a point OUTSIDE the mask/hitArea, so interactive
4081
+ // children stay reachable anywhere inside the visible viewport; see tests/pixi/bet-picker-fit.test.ts
4082
+ // and tests/pixi/menu.test.ts, which hit-test an overflowing, masked list on purpose to prove it.
4083
+ // We still only mask + grab the pointer when scrollable, since an unmasked, passive box is
4084
+ // simpler and cheaper when there's nothing to clip or drag.)
3651
4085
  const scrollable = this.maxScroll > 0;
3652
4086
  if (scrollable) {
3653
4087
  this.addChild(this.maskG);
@@ -3707,152 +4141,135 @@ class ScrollBox extends Container {
3707
4141
  }
3708
4142
  }
3709
4143
 
3710
- function clamp(min, pref, max) {
3711
- return Math.max(min, Math.min(max, pref));
3712
- }
3713
- /** Small rounded icon nav button — the overlay header back/close and row chevrons.
3714
- * `.ge-ov-nav`: 32×32, radius 9, plaque-dark bg, white icon, hover → glass bg + accent icon. */
3715
- function navButton(host, iconName, onTap, size = 32) {
3716
- const root = new Container();
3717
- const bg = new Graphics();
3718
- const draw = (hover) => {
3719
- bg.clear();
3720
- bg.roundRect(0, 0, size, size, 9);
3721
- bg.fill(hover ? host.tokens.plaqueGlass : host.tokens.plaqueDark);
3722
- };
3723
- draw(false);
3724
- const gs = Math.round(size * 0.5625); // glyph tracks the box (default 32 → 18, unchanged)
3725
- const glyph = makeIcon(iconName, gs, '#ffffff');
3726
- glyph.position.set((size - gs) / 2, (size - gs) / 2);
3727
- root.addChild(bg, glyph);
3728
- root.eventMode = 'static';
3729
- root.cursor = 'pointer';
3730
- root.hitArea = new Rectangle(0, 0, size, size);
3731
- attachHover(root, () => {
3732
- draw(true);
3733
- glyph.setColor(host.tokens.accent);
3734
- }, () => {
3735
- draw(false);
3736
- glyph.setColor('#ffffff');
3737
- });
3738
- attachPress(root, 0.92, onTap);
3739
- return root;
3740
- }
3741
- /** Full-screen overlay: frosted dark veil, fixed header (title + back/close), scrolling body.
3742
- * The body is rebuilt on resize so it reflows like the CSS overlay. */
3743
- class Overlay extends Container {
4144
+ /** Light-dismiss popover: a transparent full-screen hit rect + a rounded card with an arrow.
4145
+ * No veil, no frosted snapshot — the game stays visible and unblurred behind it. */
4146
+ class Popover extends Container {
3744
4147
  tag;
4148
+ dismissLayer = new Graphics();
4149
+ card = new Container();
4150
+ bg = new Graphics();
4151
+ arrow = new Graphics();
4152
+ scroll;
3745
4153
  host;
3746
4154
  opts;
3747
- veil = new Graphics();
3748
- header = new Container();
3749
- scroll;
3750
- titleNode = new Container();
3751
- nav = new Container();
3752
- w = 0;
3753
- h = 0;
3754
- headerH = 44;
3755
- /** Expose the scroll box for keyboard/test access. */
3756
- get scrollContent() { return this.scroll; }
4155
+ _cardX = 0;
4156
+ _cardY = 0;
4157
+ _cardW = 0;
4158
+ _arrowX = -1;
3757
4159
  constructor(host, opts) {
3758
4160
  super();
3759
4161
  this.host = host;
3760
4162
  this.opts = opts;
3761
4163
  this.tag = opts.tag;
3762
4164
  this.scroll = new ScrollBox(host.canvas);
3763
- this.addChild(this.veil, this.scroll, this.header);
3764
- this.veil.eventMode = 'static'; // swallow clicks to the game behind
4165
+ this.card.addChild(this.bg, this.arrow, this.scroll);
4166
+ this.addChild(this.dismissLayer, this.card);
4167
+ this.dismissLayer.eventMode = 'static';
4168
+ this.dismissLayer.on('pointertap', () => this.opts.onClose());
4169
+ // Taps on the card must not fall through to the dismiss layer.
4170
+ this.card.eventMode = 'static';
4171
+ // `e` itself is undefined when a caller (or test) emits the event with no payload, so guard the
4172
+ // property access, not just the call.
4173
+ this.card.on('pointertap', (e) => e?.stopPropagation?.());
3765
4174
  this.resize(host.screenW, host.screenH);
3766
4175
  }
4176
+ get cardX() { return this._cardX; }
4177
+ get cardY() { return this._cardY; }
4178
+ get cardWidth() { return this._cardW; }
4179
+ get arrowX() { return this._arrowX; }
4180
+ get arrowVisible() { return this.arrow.visible; }
3767
4181
  resize(w, h) {
3768
- this.w = w;
3769
- this.h = h;
3770
- const vh = h / 100;
3771
- this.headerH = clamp(40, 6.4 * vh, 52);
3772
- // veil
3773
- this.veil.clear();
3774
- this.veil.rect(0, 0, w, h);
3775
- this.veil.fill(this.host.tokens.backdrop);
3776
- this.veil.hitArea = new Rectangle(0, 0, w, h);
3777
- this.buildHeader();
3778
- this.layoutBody();
3779
- }
3780
- buildHeader() {
3781
- this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
3782
- const pad = 10;
3783
- // back or spacer (left), centred title, close (right)
3784
- const left = this.opts.onBack
3785
- ? navButton(this.host, 'back', () => this.opts.onBack())
3786
- : new Container();
3787
- const close = navButton(this.host, 'close', () => this.opts.onClose());
3788
- const titleSize = clamp(13, 2.6 * (this.h / 100), 16);
3789
- const title = makeText(this.opts.title, {
3790
- size: titleSize,
3791
- weight: '800',
3792
- color: '#ffffff',
3793
- letterSpacing: titleSize * 0.04,
3794
- upper: true,
3795
- align: 'center',
3796
- });
3797
- title.anchor.set(0.5);
3798
- title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
3799
- left.position.set(pad, (this.headerH - 32) / 2 + pad / 2);
3800
- close.position.set(this.w - 32 - pad, (this.headerH - 32) / 2 + pad / 2);
3801
- this.header.addChild(title, left, close);
3802
- }
3803
- layoutBody() {
3804
- const top = this.headerH + 6;
3805
- const sidePad = clamp(16, 4 * (this.w / 100), 24);
3806
- const vPad = clamp(6, 2 * (this.h / 100), 16);
3807
- const bodyW = Math.min(800, this.w - sidePad * 2);
3808
- this.scroll.position.set(0, top);
3809
- this.scroll.setViewport(this.w, this.h - top);
3810
- this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
3811
- const content = this.opts.build(bodyW);
4182
+ this.dismissLayer.clear();
4183
+ this.dismissLayer.rect(0, 0, w, h).fill({ color: 0x000000, alpha: 0 });
4184
+ this.dismissLayer.hitArea = new Rectangle(0, 0, w, h);
4185
+ const s = this.opts.scale?.() ?? 1;
4186
+ const pad = 8;
4187
+ // Width is content-driven (spec: clamped to [220, min(320, surfaceWidth-16)]), so the real
4188
+ // content has to be measured before we know the final width to lay it out at. Probe-build once
4189
+ // at the SMALLEST allowed inner width purely to measure natural size, then throw that copy away
4190
+ // and build the kept one at the resolved final width. Measured in LOCAL (unscaled) units, like
4191
+ // every other Pixi size in this file — `s` only converts to screen units where placement needs
4192
+ // it (the card carries the scale as a single transform, same as the DOM's card).
4193
+ //
4194
+ // The probe deliberately measures at the MINIMUM, not the maximum: a decorative row (the menu's
4195
+ // separator) draws its divider line at exactly the width `build()` is called with it has no
4196
+ // content-driven size of its own, unlike every real row, which ignores that parameter and sizes
4197
+ // from its icon/label/control regardless. Probing at the maximum would make that separator line
4198
+ // alone measure near-maximum and dominate `naturalWidth`, pegging the card at ~maxW for every
4199
+ // menu that has a separator — which the default menu always does. Probing at the minimum cannot
4200
+ // distort the result the other way: content narrower than the probe still clamps to minW either
4201
+ // way (nothing above changes), and content wider than the probe always wins the max() the layout
4202
+ // takes over row widths, so real content still drives growth past minW correctly.
4203
+ const probeW = POPOVER.minW - pad * 2;
4204
+ const probe = this.opts.build(probeW);
4205
+ const measured = probe instanceof FlexBox ? probe.measureSize().w : probe.getSize().width;
4206
+ probe.destroy({ children: true });
4207
+ // Resolve the ON-SCREEN width (screen units, clamped against the surface), then convert back to
4208
+ // LOCAL for the actual content layout — mirrors the DOM's naturalW·s → resolvedW → style.width÷s.
4209
+ const screenW = popoverWidth(w, (measured + pad * 2) * s);
4210
+ const localW = s > 0 ? screenW / s : screenW;
4211
+ const content = this.opts.build(localW - pad * 2);
3812
4212
  if (content instanceof FlexBox)
3813
- content.setLayoutSize(bodyW, undefined);
3814
- content.position.set((this.w - bodyW) / 2, vPad);
4213
+ content.setLayoutSize(localW - pad * 2, undefined);
4214
+ const contentH = content.getSize().height; // local units
4215
+ // The plate falls back to the pointer when it can't be resolved (no distinct plaque), and to the
4216
+ // centred/arrow-less layout when neither resolves — placePopover itself already defaults the
4217
+ // ARROW to `pointer ?? plate`, so passing the pointer through unconditionally is enough there.
4218
+ // A DEGENERATE (fully zero-sized) plate counts as unresolved too, matching the DOM renderer's
4219
+ // rectOf(): `??` only falls through on null/undefined, so a real-but-{w:0,h:0} rect (e.g. read
4220
+ // before the bar's first layout pass) would otherwise silently win over a perfectly good pointer.
4221
+ const pointerRect = this.opts.pointer?.() ?? null;
4222
+ const rawPlate = this.opts.plate();
4223
+ const plateRect = rawPlate && !(rawPlate.w <= 0 && rawPlate.h <= 0) ? rawPlate : pointerRect;
4224
+ const p = placePopover(plateRect, { w, h }, { w: screenW, h: (contentH + pad * 2) * s }, pointerRect);
4225
+ const maxHLocal = s > 0 ? p.maxH / s : p.maxH;
4226
+ const cardH = Math.min(contentH + pad * 2, maxHLocal); // local units
4227
+ this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
4228
+ this.scroll.position.set(pad, pad);
4229
+ this.scroll.setViewport(localW - pad * 2, cardH - pad * 2);
3815
4230
  this.scroll.content.addChild(content);
3816
4231
  this.scroll.refresh();
3817
- }
3818
- /** Handle keyboard navigation while this overlay is the top layer. */
4232
+ this.bg.clear();
4233
+ this.bg.roundRect(0, 0, localW, cardH, 18);
4234
+ this.bg.fill(this.host.tokens.plaqueDark);
4235
+ // Arrow: a 14×7 triangle (local units — it lives inside the scaled card) on the edge that faces
4236
+ // the plate.
4237
+ this.arrow.clear();
4238
+ this.arrow.visible = p.arrowX >= 0;
4239
+ if (this.arrow.visible) {
4240
+ const arrowXLocal = s > 0 ? p.arrowX / s : p.arrowX;
4241
+ const edge = p.below ? 0 : cardH; // the card edge the arrow sits on
4242
+ const tip = p.below ? -7 : cardH + 7; // the tip, pointing at the pointer (or the plate)
4243
+ this.arrow.moveTo(arrowXLocal - 7, edge);
4244
+ this.arrow.lineTo(arrowXLocal + 7, edge);
4245
+ this.arrow.lineTo(arrowXLocal, tip);
4246
+ this.arrow.fill(this.host.tokens.plaqueDark);
4247
+ }
4248
+ // The card carries the scale as ONE transform around its own (0,0) local origin — equivalent to
4249
+ // the DOM's transform-origin:top left — so `p.x`/`p.y` (screen units) remain its visual top-left
4250
+ // regardless of `s`, and every local size above (background rect, scroll viewport, arrow) scales
4251
+ // with it automatically.
4252
+ this.card.scale.set(s);
4253
+ this.card.position.set(p.x, p.y);
4254
+ this._cardX = p.x;
4255
+ this._cardY = p.y;
4256
+ this._cardW = screenW;
4257
+ this._arrowX = p.arrowX;
4258
+ }
4259
+ /** Arrow keys scroll a long list; everything else (Escape included) goes to the controller. */
3819
4260
  onKey(e) {
3820
- const viewH = this.h - (this.headerH + 6); // body viewport height
3821
- const lineStep = 60;
3822
- const pageStep = Math.floor(viewH * 0.9);
3823
- switch (e.code) {
3824
- case 'ArrowDown':
3825
- this.scroll.scrollBy(lineStep);
3826
- return true;
3827
- case 'ArrowUp':
3828
- this.scroll.scrollBy(-lineStep);
3829
- return true;
3830
- case 'PageDown':
3831
- this.scroll.scrollBy(pageStep);
3832
- return true;
3833
- case 'PageUp':
3834
- this.scroll.scrollBy(-pageStep);
3835
- return true;
3836
- case 'Space':
3837
- if (e.shiftKey) {
3838
- this.scroll.scrollBy(-pageStep);
3839
- }
3840
- else {
3841
- this.scroll.scrollBy(pageStep);
3842
- }
3843
- return true;
3844
- case 'Home':
3845
- this.scroll.scrollBy(-999999);
3846
- return true;
3847
- case 'End':
3848
- this.scroll.scrollBy(999999);
3849
- return true;
3850
- default:
3851
- return false;
4261
+ if (e.code === 'ArrowDown') {
4262
+ this.scroll.scrollBy(40);
4263
+ return true;
4264
+ }
4265
+ if (e.code === 'ArrowUp') {
4266
+ this.scroll.scrollBy(-40);
4267
+ return true;
3852
4268
  }
4269
+ return false;
3853
4270
  }
3854
4271
  fit() {
3855
- /* overlays scroll; no card fit needed */
4272
+ this.resize(this.host.screenW, this.host.screenH);
3856
4273
  }
3857
4274
  onRemove() {
3858
4275
  this.scroll.destroy({ children: true });
@@ -3978,6 +4395,49 @@ class Slider extends Container {
3978
4395
  return { w: this.w, h: 24 };
3979
4396
  }
3980
4397
  }
4398
+ /** Pill switch — the Pixi twin of the DOM `.ge-toggle`. 42×24, knob 20, accent when on. */
4399
+ class Toggle extends Container {
4400
+ track = new Graphics();
4401
+ knob = new Graphics();
4402
+ _value;
4403
+ onChange;
4404
+ accent;
4405
+ offFill;
4406
+ constructor(value, onChange, accent = '#8b5cf6', off = 'rgba(255,255,255,.22)') {
4407
+ super();
4408
+ this._value = value;
4409
+ this.onChange = onChange;
4410
+ this.accent = accent;
4411
+ this.offFill = off;
4412
+ this.addChild(this.track, this.knob);
4413
+ this.eventMode = 'static';
4414
+ this.cursor = 'pointer';
4415
+ this.hitArea = new Rectangle(0, 0, 42, 24);
4416
+ this.on('pointertap', () => this.onChange(!this._value));
4417
+ this.paint();
4418
+ }
4419
+ get value() {
4420
+ return this._value;
4421
+ }
4422
+ setValue(v) {
4423
+ this._value = v;
4424
+ this.paint();
4425
+ }
4426
+ paint() {
4427
+ this.track.clear();
4428
+ this.track.roundRect(0, 0, 42, 24, 12);
4429
+ this.track.fill(this._value ? this.accent : this.offFill);
4430
+ this.knob.clear();
4431
+ this.knob.circle(this._value ? 30 : 12, 12, 10);
4432
+ this.knob.fill('#ffffff');
4433
+ }
4434
+ measureSize() {
4435
+ return { w: 42, h: 24 };
4436
+ }
4437
+ setLayoutSize() {
4438
+ /* fixed size */
4439
+ }
4440
+ }
3981
4441
  /** Picker chip — `.ge-chip`. Selected = accent bg + accent border. */
3982
4442
  class Chip extends Container {
3983
4443
  id;
@@ -4026,112 +4486,297 @@ class Chip extends Container {
4026
4486
  }
4027
4487
  }
4028
4488
 
4029
- /** Settings overlay sound on/off, volume sliders, and a Game info link. */
4030
- function openSettings(host) {
4031
- return new Overlay(host, {
4032
- tag: 'settings',
4033
- title: host.t('Settings'),
4489
+ /** The bar menu as a Pixi popover. Same rows, same order as the DOM — both come from resolveMenu.
4490
+ *
4491
+ * `getAnchor` (the burger — the arrow's POINTER), `getPlate` (the bar's plaque — drives placement)
4492
+ * and `getScale` (the bar's own fit-scale) are all FUNCTIONS, resolved lazily on every reposition —
4493
+ * never a captured `BottomBar` instance. `PixiRenderer.renderBar()` destroys and rebuilds the
4494
+ * BottomBar on every resize AND on ~20 other state changes, in the SAME resize handler that then
4495
+ * repositions this popover. A captured instance would already be destroyed by the time `resize()`
4496
+ * next runs, so `menuAnchor()`/`menuPlate()` on it would return `null` and the card would silently
4497
+ * recentre with its arrow hidden — the exact bug the DOM renderer shipped and fixed the same way
4498
+ * (see `html/Menu.ts`'s plate/pointer callbacks). Passing getters means every call reads whichever
4499
+ * bar is CURRENT. `getPlate`/`getScale` are optional so every pre-existing caller (which only ever
4500
+ * passed `getAnchor`) keeps behaving exactly as it did before the plate/pointer/scale split. */
4501
+ function openMenu(host, getAnchor, getPlate, getScale) {
4502
+ const updaters = {};
4503
+ const layer = new Popover(host, {
4504
+ tag: 'menu',
4505
+ plate: () => getPlate?.() ?? null,
4506
+ pointer: () => getAnchor?.() ?? null,
4507
+ scale: () => getScale?.() ?? 1,
4034
4508
  onClose: () => host.closeLayer(),
4035
- build: (w) => buildBody$1(host, w),
4509
+ build: (width) => {
4510
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 6 });
4511
+ for (const row of resolveMenu(host))
4512
+ col.add(buildRow(host, row, width, updaters));
4513
+ return col;
4514
+ },
4036
4515
  });
4516
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
4517
+ return layer;
4518
+ }
4519
+ function label(text) {
4520
+ return makeText(text, { size: 13, weight: '600', color: '#ffffff' });
4037
4521
  }
4038
- function buildBody$1(host, width) {
4039
- const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 10 });
4040
- // Sound on/off backed by the shell's shared `soundOn` state so this toggle and the Shift+M
4041
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
4042
- const soundOn0 = host.soundOn ?? true;
4043
- const speaker = new IconButton(soundOn0 ? 'soundOn' : 'soundOff', {
4044
- size: 36,
4045
- glyph: 24,
4046
- color: host.tokens.plaqueLabel,
4047
- hover: host.tokens.accent,
4048
- activeColor: '#ffffff',
4049
- active: soundOn0,
4050
- onTap: () => host.setSound?.(!(host.soundOn ?? true)),
4522
+ function rowBox(host, name, column = false) {
4523
+ const box = new FlexBox({
4524
+ direction: column ? 'column' : 'row',
4525
+ align: column ? 'stretch' : 'center',
4526
+ gap: column ? 8 : 10,
4527
+ padding: { top: 10, bottom: 10, left: 12, right: 12 },
4528
+ minHeight: column ? undefined : 44,
4529
+ background: { fill: host.tokens.plaqueGlass, radius: 14 },
4051
4530
  });
4052
- // Live-update the speaker when sound changes from here OR via Shift+M (shell clears on close).
4053
- host.setSoundRefresh?.((on) => {
4054
- if (speaker.destroyed)
4055
- return; // overlay torn down but refresher not yet cleared (push-over edge)
4056
- speaker.setIcon(on ? 'soundOn' : 'soundOff');
4057
- speaker.active = on;
4531
+ box.label = name;
4532
+ return box;
4533
+ }
4534
+ function buildRow(host, row, width, updaters) {
4535
+ if (row.kind === 'separator') {
4536
+ const sep = new Container();
4537
+ sep.label = 'menu-sep';
4538
+ const line = new Graphics().rect(4, 6, width - 8, 1).fill(host.tokens.plaqueLine);
4539
+ line.alpha = 0.5;
4540
+ sep.addChild(line);
4541
+ return sep;
4542
+ }
4543
+ if (row.kind === 'button') {
4544
+ const box = rowBox(host, `menu-row-${row.id}`);
4545
+ if (row.icon)
4546
+ box.add(makeIcon(row.icon, 20, '#ffffff'));
4547
+ const text = label(row.label);
4548
+ box.add(text);
4549
+ box.add(new Spacer(), { grow: 1 });
4550
+ if (row.chevron)
4551
+ box.add(makeIcon('chevronRight', 16, host.tokens.muted));
4552
+ if (!row.disabled) {
4553
+ box.setInteractive(true);
4554
+ box.on('pointertap', () => {
4555
+ host.closeLayer();
4556
+ row.select();
4557
+ });
4558
+ attachHover(box, () => { box.setBgFill(host.tokens.plaqueGlassHover); text.style.fill = host.tokens.accent; }, () => { box.setBgFill(host.tokens.plaqueGlass); text.style.fill = '#ffffff'; });
4559
+ }
4560
+ else {
4561
+ box.alpha = 0.5;
4562
+ }
4563
+ return box;
4564
+ }
4565
+ if (row.kind === 'toggle') {
4566
+ const box = rowBox(host, `menu-row-${row.id}`);
4567
+ const glyph = row.icon(row.get());
4568
+ const iconNode = glyph ? makeIcon(glyph, 20, '#ffffff') : null;
4569
+ if (iconNode)
4570
+ box.add(iconNode);
4571
+ box.add(label(row.label));
4572
+ box.add(new Spacer(), { grow: 1 });
4573
+ // Disabled: neutralise the write-through at the source (a no-op onChange) rather than relying
4574
+ // solely on eventMode — Toggle wires its own pointertap listener unconditionally in its
4575
+ // constructor, so a stray direct emit must still be harmless, not just unreachable by real hits.
4576
+ const toggle = new Toggle(row.get(), row.disabled ? () => { } : (v) => row.set(v), host.tokens.accent, host.tokens.plaqueLine);
4577
+ box.add(toggle);
4578
+ updaters[row.id] = (v) => {
4579
+ const on = v === true;
4580
+ toggle.setValue(on);
4581
+ const next = row.icon(on);
4582
+ if (iconNode && next)
4583
+ iconNode.setIcon(next);
4584
+ };
4585
+ if (row.disabled) {
4586
+ box.alpha = 0.5;
4587
+ toggle.eventMode = 'none'; // dimmed + inert — mirrors the button branch's disabled treatment
4588
+ }
4589
+ return box;
4590
+ }
4591
+ const box = rowBox(host, `menu-row-${row.id}`, true);
4592
+ const head = new FlexBox({ direction: 'row', align: 'center' });
4593
+ const value = makeText(row.format(row.get()), { size: 12, weight: '700', color: host.tokens.plaqueLabel });
4594
+ head.add(label(row.label));
4595
+ head.add(new Spacer(), { grow: 1 });
4596
+ head.add(value);
4597
+ // Slider works in 0..1; map to the row's declared bounds, snapping to the MIN-anchored lattice
4598
+ // (min + k·step). Snapping to a bare multiple of step instead (Math.round(raw/step)*step) drifts
4599
+ // off `min` whenever min isn't itself a multiple of step — the common case, since the default step
4600
+ // is (max-min)/20: e.g. min:1,max:10 gives step 0.45, and the bare-multiple formula would emit 0.9
4601
+ // at the far left, BELOW the declared min. Clamp guards any float overshoot past either end.
4602
+ const toUnit = (v) => (row.max === row.min ? 0 : (v - row.min) / (row.max - row.min));
4603
+ const fromUnit = (u) => {
4604
+ const raw = row.min + u * (row.max - row.min);
4605
+ const snapped = row.min + Math.round((raw - row.min) / row.step) * row.step;
4606
+ return Math.max(row.min, Math.min(row.max, snapped));
4607
+ };
4608
+ // Disabled: same reasoning as the toggle branch above — a no-op onInput, not just eventMode.
4609
+ const slider = new Slider(host, toUnit(row.get()), row.disabled ? () => { } : (u) => {
4610
+ const v = fromUnit(u);
4611
+ value.text = row.format(v);
4612
+ head.layout(); // the readout's text just changed width (e.g. "5%" → "100%") — reposition it
4613
+ row.set(v);
4058
4614
  });
4059
- col.add(glassRow(host, [textNode(host, host.t('Sound')), new Spacer(), speaker]));
4060
- // Volume sliders — positions read from the shell's stored volumes (stateful across opens); the
4061
- // registered refreshers let `host.setVolume()` from game code move the thumbs live.
4062
- const updaters = {};
4063
- col.add(sliderRow(host, width, 'master', host.t('Master volume'), updaters));
4064
- col.add(sliderRow(host, width, 'music', host.t('Music'), updaters));
4065
- col.add(sliderRow(host, width, 'sfx', host.t('SFX'), updaters));
4066
- host.setVolumeRefresh?.((key, v) => updaters[key]?.(v));
4067
- // Game info link
4068
- const infoIcon = makeIcon('info', 22, '#ffffff');
4069
- const infoLabel = textNode(host, host.t('Game info'));
4070
- const chevron = makeIcon('chevronRight', 20, host.tokens.muted);
4071
- const infoRow = glassRow(host, [infoIcon, infoLabel, new Spacer(), chevron], { button: true, onTap: () => host.actions.openInfo() });
4072
- col.add(infoRow);
4073
- return col;
4615
+ updaters[row.id] = (v) => {
4616
+ const n = Number(v);
4617
+ value.text = row.format(n);
4618
+ head.layout();
4619
+ slider.setValue(toUnit(n));
4620
+ };
4621
+ box.add(head);
4622
+ box.add(slider);
4623
+ if (row.disabled) {
4624
+ box.alpha = 0.5;
4625
+ slider.eventMode = 'none';
4626
+ }
4627
+ return box;
4074
4628
  }
4075
- function textNode(host, text) {
4076
- return makeText(text, { size: 14, weight: '600', color: '#ffffff' });
4629
+
4630
+ function clamp(min, pref, max) {
4631
+ return Math.max(min, Math.min(max, pref));
4077
4632
  }
4078
- /** A full-width glass row holding the given children (label/controls), align-centred. */
4079
- function glassRow(host, children, opts = {}) {
4080
- const row = new FlexBox({
4081
- direction: 'row',
4082
- align: 'center',
4083
- gap: 12,
4084
- // .ge-ov-row { padding: clamp(11px,2.2vh,15px) 16px } — 15 at desktop; minHeight matches the
4085
- // DOM row's effective height (driven by the tallest child's line box, not just the em box).
4086
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
4087
- minHeight: 60,
4088
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
4633
+ /** Small rounded icon nav button the overlay header back/close and row chevrons.
4634
+ * `.ge-ov-nav`: 32×32, radius 9, plaque-dark bg, white icon, hover → glass bg + accent icon. */
4635
+ function navButton(host, iconName, onTap, size = 32) {
4636
+ const root = new Container();
4637
+ const bg = new Graphics();
4638
+ const draw = (hover) => {
4639
+ bg.clear();
4640
+ bg.roundRect(0, 0, size, size, 9);
4641
+ bg.fill(hover ? host.tokens.plaqueGlass : host.tokens.plaqueDark);
4642
+ };
4643
+ draw(false);
4644
+ const gs = Math.round(size * 0.5625); // glyph tracks the box (default 32 → 18, unchanged)
4645
+ const glyph = makeIcon(iconName, gs, '#ffffff');
4646
+ glyph.position.set((size - gs) / 2, (size - gs) / 2);
4647
+ root.addChild(bg, glyph);
4648
+ root.eventMode = 'static';
4649
+ root.cursor = 'pointer';
4650
+ root.hitArea = new Rectangle(0, 0, size, size);
4651
+ attachHover(root, () => {
4652
+ draw(true);
4653
+ glyph.setColor(host.tokens.accent);
4654
+ }, () => {
4655
+ draw(false);
4656
+ glyph.setColor('#ffffff');
4089
4657
  });
4090
- for (const c of children)
4091
- row.add(c, c instanceof Spacer ? { grow: 1 } : {});
4092
- if (opts.button) {
4093
- row.setInteractive(true); // full-box hit area hover + tap across the whole row, not just the icon/label
4094
- if (opts.onTap)
4095
- row.on('pointertap', opts.onTap);
4096
- // hover: brighter glass + accent text (DOM button.ge-ov-row:hover)
4097
- const texts = children.filter((c) => c instanceof Text);
4098
- const orig = texts.map((t) => t.style.fill);
4099
- attachHover(row, () => {
4100
- row.setBgFill(host.tokens.plaqueGlassHover);
4101
- texts.forEach((t) => (t.style.fill = host.tokens.accent));
4102
- }, () => {
4103
- row.setBgFill(host.tokens.plaqueGlass);
4104
- texts.forEach((t, i) => (t.style.fill = orig[i]));
4658
+ attachPress(root, 0.92, onTap);
4659
+ return root;
4660
+ }
4661
+ /** Full-screen overlay: frosted dark veil, fixed header (title + back/close), scrolling body.
4662
+ * The body is rebuilt on resize so it reflows like the CSS overlay. */
4663
+ class Overlay extends Container {
4664
+ tag;
4665
+ host;
4666
+ opts;
4667
+ veil = new Graphics();
4668
+ header = new Container();
4669
+ scroll;
4670
+ titleNode = new Container();
4671
+ nav = new Container();
4672
+ w = 0;
4673
+ h = 0;
4674
+ headerH = 44;
4675
+ /** Expose the scroll box for keyboard/test access. */
4676
+ get scrollContent() { return this.scroll; }
4677
+ constructor(host, opts) {
4678
+ super();
4679
+ this.host = host;
4680
+ this.opts = opts;
4681
+ this.tag = opts.tag;
4682
+ this.scroll = new ScrollBox(host.canvas);
4683
+ this.addChild(this.veil, this.scroll, this.header);
4684
+ this.veil.eventMode = 'static'; // swallow clicks to the game behind
4685
+ this.resize(host.screenW, host.screenH);
4686
+ }
4687
+ resize(w, h) {
4688
+ this.w = w;
4689
+ this.h = h;
4690
+ const vh = h / 100;
4691
+ this.headerH = clamp(40, 6.4 * vh, 52);
4692
+ // veil
4693
+ this.veil.clear();
4694
+ this.veil.rect(0, 0, w, h);
4695
+ this.veil.fill(this.host.tokens.backdrop);
4696
+ this.veil.hitArea = new Rectangle(0, 0, w, h);
4697
+ this.buildHeader();
4698
+ this.layoutBody();
4699
+ }
4700
+ buildHeader() {
4701
+ this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
4702
+ const pad = 10;
4703
+ // back or spacer (left), centred title, close (right)
4704
+ const left = this.opts.onBack
4705
+ ? navButton(this.host, 'back', () => this.opts.onBack())
4706
+ : new Container();
4707
+ const close = navButton(this.host, 'close', () => this.opts.onClose());
4708
+ const titleSize = clamp(13, 2.6 * (this.h / 100), 16);
4709
+ const title = makeText(this.opts.title, {
4710
+ size: titleSize,
4711
+ weight: '800',
4712
+ color: '#ffffff',
4713
+ letterSpacing: titleSize * 0.04,
4714
+ upper: true,
4715
+ align: 'center',
4105
4716
  });
4717
+ title.anchor.set(0.5);
4718
+ title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
4719
+ left.position.set(pad, (this.headerH - 32) / 2 + pad / 2);
4720
+ close.position.set(this.w - 32 - pad, (this.headerH - 32) / 2 + pad / 2);
4721
+ this.header.addChild(title, left, close);
4722
+ }
4723
+ layoutBody() {
4724
+ const top = this.headerH + 6;
4725
+ const sidePad = clamp(16, 4 * (this.w / 100), 24);
4726
+ const vPad = clamp(6, 2 * (this.h / 100), 16);
4727
+ const bodyW = Math.min(800, this.w - sidePad * 2);
4728
+ this.scroll.position.set(0, top);
4729
+ this.scroll.setViewport(this.w, this.h - top);
4730
+ this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
4731
+ const content = this.opts.build(bodyW);
4732
+ if (content instanceof FlexBox)
4733
+ content.setLayoutSize(bodyW, undefined);
4734
+ content.position.set((this.w - bodyW) / 2, vPad);
4735
+ this.scroll.content.addChild(content);
4736
+ this.scroll.refresh();
4737
+ }
4738
+ /** Handle keyboard navigation while this overlay is the top layer. */
4739
+ onKey(e) {
4740
+ const viewH = this.h - (this.headerH + 6); // body viewport height
4741
+ const lineStep = 60;
4742
+ const pageStep = Math.floor(viewH * 0.9);
4743
+ switch (e.code) {
4744
+ case 'ArrowDown':
4745
+ this.scroll.scrollBy(lineStep);
4746
+ return true;
4747
+ case 'ArrowUp':
4748
+ this.scroll.scrollBy(-lineStep);
4749
+ return true;
4750
+ case 'PageDown':
4751
+ this.scroll.scrollBy(pageStep);
4752
+ return true;
4753
+ case 'PageUp':
4754
+ this.scroll.scrollBy(-pageStep);
4755
+ return true;
4756
+ case 'Space':
4757
+ if (e.shiftKey) {
4758
+ this.scroll.scrollBy(-pageStep);
4759
+ }
4760
+ else {
4761
+ this.scroll.scrollBy(pageStep);
4762
+ }
4763
+ return true;
4764
+ case 'Home':
4765
+ this.scroll.scrollBy(-999999);
4766
+ return true;
4767
+ case 'End':
4768
+ this.scroll.scrollBy(999999);
4769
+ return true;
4770
+ default:
4771
+ return false;
4772
+ }
4773
+ }
4774
+ fit() {
4775
+ /* overlays scroll; no card fit needed */
4776
+ }
4777
+ onRemove() {
4778
+ this.scroll.destroy({ children: true });
4106
4779
  }
4107
- return row;
4108
- }
4109
- /** A column row: head (label + live % value) over a draggable slider. */
4110
- function sliderRow(host, bodyWidth, key, label, updaters) {
4111
- const row = new FlexBox({
4112
- direction: 'column',
4113
- align: 'stretch',
4114
- gap: 10,
4115
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
4116
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
4117
- });
4118
- const head = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
4119
- const initial = host.getVolume(key);
4120
- const valueText = makeText(`${Math.round(initial * 100)}%`, { size: 13, weight: '700', color: host.tokens.plaqueLabel });
4121
- head.add(makeText(label, { size: 14, weight: '600', color: '#ffffff' }));
4122
- head.add(new Spacer(), { grow: 1 });
4123
- head.add(valueText);
4124
- const slider = new Slider(host, initial, (v) => {
4125
- valueText.text = `${Math.round(v * 100)}%`;
4126
- host.setVolume(key, v);
4127
- });
4128
- updaters[key] = (v) => {
4129
- valueText.text = `${Math.round(v * 100)}%`;
4130
- slider.setValue(v);
4131
- };
4132
- row.add(head);
4133
- row.add(slider);
4134
- return row;
4135
4780
  }
4136
4781
 
4137
4782
  /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
@@ -4144,7 +4789,7 @@ function openGameInfo(host) {
4144
4789
  onClose: () => host.closeLayer(),
4145
4790
  onBack: () => {
4146
4791
  host.closeLayer();
4147
- host.actions.openSettings();
4792
+ host.actions.openMenu();
4148
4793
  },
4149
4794
  build: (w) => buildBody(host, w),
4150
4795
  });
@@ -5951,6 +6596,15 @@ class PixiRenderer {
5951
6596
  this.bar = new BottomBar(this.ctx);
5952
6597
  this.barLayer.addChild(this.bar);
5953
6598
  this.bar.applyFit();
6599
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
6600
+ // which can change the bar's own fitScale()/menuPlate() (e.g. a WIN pill appearing mid-autoplay
6601
+ // retriggers the wide layout's overflow-tightening branch). Only onResize used to reposition the
6602
+ // open layer, so a live bar-content change while the menu was open left it at the stale
6603
+ // scale/position until the next resize. Every ShellLayer's resize() only re-reads current
6604
+ // geometry and re-fits/re-centres itself — none of them call back into renderBar() (verified:
6605
+ // Popover, CardModal, Overlay, BuyBonusOverlay) — so this cannot recurse; `currentLayer` is
6606
+ // `null` whenever nothing is open, so this cannot throw either.
6607
+ this.currentLayer?.resize?.(this.screenW, this.screenH);
5954
6608
  }
5955
6609
  setLayout() {
5956
6610
  this.renderBar();
@@ -5962,14 +6616,18 @@ class PixiRenderer {
5962
6616
  }
5963
6617
  /** Count a money readout from→to on the freshly-rendered bar's value Text node. Mirrors
5964
6618
  * PixiGameShell.animateMoney (which counted on the just-built bar's value Texts). */
5965
- animateMoney(field, from, to) {
6619
+ animateMoney(field, from, to, durationMs) {
5966
6620
  if (!this.bar)
5967
6621
  return;
6622
+ // countUpText defaults durationMs to 450 — pass it only when the caller overrode it.
6623
+ const count = (text, fmt) => durationMs == null
6624
+ ? countUpText(this.ticker, text, from, to, fmt)
6625
+ : countUpText(this.ticker, text, from, to, fmt, durationMs);
5968
6626
  if (field === 'balance' && this.bar.balanceValue) {
5969
- this.moneyAnims.push(countUpText(this.ticker, this.bar.balanceValue, from, to, (n) => this.ctx.fmt(n)));
6627
+ this.moneyAnims.push(count(this.bar.balanceValue, (n) => this.ctx.fmt(n)));
5970
6628
  }
5971
6629
  else if (field === 'win' && this.bar.winValue) {
5972
- this.moneyAnims.push(countUpText(this.ticker, this.bar.winValue, from, to, (n) => this.ctx.fmtWin(n)));
6630
+ this.moneyAnims.push(count(this.bar.winValue, (n) => this.ctx.fmtWin(n)));
5973
6631
  }
5974
6632
  }
5975
6633
  cancelMoneyAnims() {
@@ -5980,8 +6638,13 @@ class PixiRenderer {
5980
6638
  openOverlay(req) {
5981
6639
  let layer = null;
5982
6640
  switch (req.kind) {
5983
- case 'settings':
5984
- layer = openSettings(this.ctx);
6641
+ case 'menu':
6642
+ // Getters, not `this.bar` by value: renderBar() destroys/rebuilds the bar on every resize
6643
+ // and ~20 other state changes, in the same resize handler that then repositions this popover
6644
+ // — see Menu.ts's openMenu doc comment for why this must stay lazy. menuAnchor (the burger)
6645
+ // is the arrow's pointer; menuPlate (the plaque) drives placement; fitScale is the same
6646
+ // factor BottomBar applies to its own content via `inner.scale`.
6647
+ layer = openMenu(this.ctx, () => this.bar?.menuAnchor() ?? null, () => this.bar?.menuPlate() ?? null, () => this.bar?.fitScale() ?? 1);
5985
6648
  break;
5986
6649
  case 'gameInfo':
5987
6650
  layer = openGameInfo(this.ctx);
@@ -6004,7 +6667,7 @@ class PixiRenderer {
6004
6667
  }
6005
6668
  if (!layer)
6006
6669
  return;
6007
- this.pushLayer(layer);
6670
+ this.pushLayer(layer, { backdrop: req.kind !== 'menu' });
6008
6671
  const built = layer;
6009
6672
  return {
6010
6673
  onKey: built.onKey ? built.onKey.bind(built) : undefined,
@@ -6014,10 +6677,6 @@ class PixiRenderer {
6014
6677
  closeOverlay() {
6015
6678
  this.closeLayer();
6016
6679
  }
6017
- refreshSoundIcon(_on) {
6018
- // No-op: the open Settings overlay registers its icon updater via host.setSoundRefresh, which
6019
- // the controller's setSound path drives. The renderer has nothing extra to refresh.
6020
- }
6021
6680
  /** Fade out (≈250ms, like GameShell's REMOVE_FADE_MS) then tear down; resolves when removed. */
6022
6681
  destroy() {
6023
6682
  if (this.destroyed)
@@ -6041,9 +6700,12 @@ class PixiRenderer {
6041
6700
  });
6042
6701
  }
6043
6702
  // ── layer stack ────────────────────────────────────────────────────────────
6044
- pushLayer(node) {
6703
+ pushLayer(node, opts) {
6045
6704
  this.clearLayer();
6046
- this.makeBackdrop(); // frosted snapshot of the scene behind (the DOM's backdrop-filter:blur)
6705
+ // Light-dismiss layers (the menu popover) opt out with `{ backdrop: false }` no frosted
6706
+ // snapshot, the game stays visible behind them. Every other caller is unaffected (defaults on).
6707
+ if (opts?.backdrop !== false)
6708
+ this.makeBackdrop(); // frosted snapshot (the DOM's backdrop-filter:blur)
6047
6709
  this.currentLayer = node;
6048
6710
  this.modalLayer.addChild(node);
6049
6711
  this.fitModals();
@@ -6165,6 +6827,7 @@ class PixiRenderer {
6165
6827
  get tokens() { return host.tokens; },
6166
6828
  get layout() { return host.layout; },
6167
6829
  get soundOn() { return host.soundOn; },
6830
+ get menu() { return host.menu; },
6168
6831
  get actions() { return host.actions; },
6169
6832
  openReplay: (opts) => host.openReplay(opts),
6170
6833
  t: (s) => host.t(s),
@@ -6172,17 +6835,18 @@ class PixiRenderer {
6172
6835
  emit: host.emit.bind(host),
6173
6836
  notifyResize: (w, h) => host.notifyResize(w, h),
6174
6837
  setSound: (on) => host.setSound(on),
6175
- setSoundRefresh: (fn) => host.setSoundRefresh(fn),
6176
6838
  getVolume: (key) => host.getVolume(key),
6177
6839
  setVolume: (key, v) => host.setVolume(key, v),
6178
- setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
6840
+ getMenuValue: (id) => host.getMenuValue(id),
6841
+ setMenuValue: (id, v) => host.setMenuValue(id, v),
6842
+ setMenuRefresh: (fn) => host.setMenuRefresh(fn),
6179
6843
  // — Pixi-specific surface —
6180
6844
  get ticker() { return self.app.ticker; },
6181
6845
  get canvas() { return self.app.canvas; },
6182
6846
  get screenW() { return self.app.screen.width; },
6183
6847
  get screenH() { return self.app.screen.height; },
6184
6848
  render: () => self.renderBar(),
6185
- pushLayer: (node) => self.pushLayer(node),
6849
+ pushLayer: (node, opts) => self.pushLayer(node, opts),
6186
6850
  // Route component-initiated closes through the controller (not straight to self.closeLayer) so
6187
6851
  // it clears its OverlayHandle. Otherwise the handle goes stale: hasOpenLayer() stays true and
6188
6852
  // keydowns keep routing to onKey on a destroyed overlay → write to a torn-down ScrollBox.
@@ -6216,5 +6880,5 @@ function removePixiShell() {
6216
6880
  return shell.destroy();
6217
6881
  }
6218
6882
 
6219
- export { DEFAULT_ACCENT, PACKAGE_VERSION, PixiRenderer, SCHEMES, ShellController, createI18n, createPixiShell, createShell, normalizeLang, removePixiShell, resolveConfig, resolveTheme, socialize };
6883
+ export { DEFAULT_ACCENT, DEFAULT_MENU, PACKAGE_VERSION, POPOVER, PixiRenderer, SCHEMES, ShellController, createI18n, createPixiShell, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removePixiShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
6220
6884
  //# sourceMappingURL=pixi.esm.js.map