@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/index.esm.js CHANGED
@@ -50,6 +50,240 @@ class EventEmitter {
50
50
  }
51
51
  }
52
52
 
53
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
54
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
55
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
56
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
57
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
58
+ // `node scripts/gen-icons-from-svg.mjs`.
59
+ // The single glyph-name union, shared by core (menu items) and both renderers.
60
+ const ICON_NAMES = [
61
+ 'spin',
62
+ 'turbo1',
63
+ 'autoplay',
64
+ 'stop',
65
+ 'menu',
66
+ 'minus',
67
+ 'plus',
68
+ 'gift',
69
+ 'info',
70
+ 'soundOn',
71
+ 'soundOff',
72
+ 'close',
73
+ 'back',
74
+ 'chevronRight',
75
+ 'ticket',
76
+ 'turbo2',
77
+ 'turboOff',
78
+ 'chevronUp',
79
+ 'chevronDown',
80
+ ];
81
+
82
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
83
+ function isPresetId(id) {
84
+ return PRESET_IDS.includes(id);
85
+ }
86
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
87
+ const DEFAULT_MENU = [
88
+ { id: 'sound' },
89
+ { id: 'music' },
90
+ { id: 'sfx' },
91
+ { type: 'separator' },
92
+ { id: 'gameInfo' },
93
+ ];
94
+ const isSeparator = (i) => i.type === 'separator';
95
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
96
+ function rangeBounds(item) {
97
+ const min = item.min ?? 0;
98
+ const max = item.max ?? 1;
99
+ const derivedStep = (max - min) / 20;
100
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
101
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
102
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
103
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
104
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
105
+ return { min, max, step };
106
+ }
107
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
108
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
109
+ function seedMenuValues(items, prev = {}) {
110
+ const out = {};
111
+ for (const item of items) {
112
+ if (isSeparator(item))
113
+ continue;
114
+ const type = item.type;
115
+ if (!type || isPresetId(item.id))
116
+ continue;
117
+ // A previous value only carries over when its runtime type still matches this item's kind.
118
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
119
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
120
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
121
+ // of warning.
122
+ const prevValue = prev[item.id];
123
+ if (type === 'toggle') {
124
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
125
+ }
126
+ else if (type === 'range') {
127
+ const r = item;
128
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+ const percent = (v) => `${Math.round(v * 100)}%`;
134
+ function safeIcon(name) {
135
+ return name && ICON_NAMES.includes(name) ? name : undefined;
136
+ }
137
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
138
+ function warn(message) {
139
+ console.warn(`[shell] ${message}`);
140
+ }
141
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
142
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
143
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
144
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
145
+ function resolveMenu(host) {
146
+ const rows = [];
147
+ for (const item of host.menu) {
148
+ if (isSeparator(item)) {
149
+ rows.push({ kind: 'separator' });
150
+ continue;
151
+ }
152
+ const type = item.type;
153
+ if (!type) {
154
+ const row = preset(host, item);
155
+ if (row)
156
+ rows.push(row);
157
+ else
158
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
159
+ continue;
160
+ }
161
+ const row = custom(host, item, type);
162
+ if (row)
163
+ rows.push(row);
164
+ }
165
+ return rows;
166
+ }
167
+ function preset(host, item) {
168
+ const disabled = item.disabled ?? false;
169
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
170
+ switch (item.id) {
171
+ case 'sound':
172
+ return {
173
+ kind: 'toggle',
174
+ id: 'sound',
175
+ label,
176
+ disabled,
177
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
178
+ get: () => host.getMenuValue('sound') !== false,
179
+ set: (v) => host.setMenuValue('sound', v),
180
+ };
181
+ case 'music':
182
+ case 'sfx': {
183
+ const id = item.id;
184
+ return {
185
+ kind: 'range',
186
+ id,
187
+ label,
188
+ icon: safeIcon(item.icon),
189
+ disabled,
190
+ min: 0,
191
+ max: 1,
192
+ step: 0.05,
193
+ get: () => Number(host.getMenuValue(id) ?? 1),
194
+ set: (v) => host.setMenuValue(id, v),
195
+ format: percent,
196
+ };
197
+ }
198
+ case 'gameInfo':
199
+ return {
200
+ kind: 'button',
201
+ id: 'gameInfo',
202
+ label,
203
+ icon: safeIcon(item.icon) ?? 'info',
204
+ disabled,
205
+ chevron: true,
206
+ select: () => host.actions.openInfo(),
207
+ };
208
+ default:
209
+ return null;
210
+ }
211
+ }
212
+ const DEFAULT_LABELS = {
213
+ sound: 'Sound',
214
+ music: 'Music',
215
+ sfx: 'SFX',
216
+ gameInfo: 'Game info',
217
+ };
218
+ function custom(host, item, type) {
219
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
220
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
221
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
222
+ if (isPresetId(item.id)) {
223
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
224
+ }
225
+ const disabled = item.disabled ?? false;
226
+ const label = host.t(item.label ?? item.id);
227
+ const icon = safeIcon(item.icon);
228
+ if (type === 'toggle') {
229
+ const it = item;
230
+ return {
231
+ kind: 'toggle',
232
+ id: it.id,
233
+ label,
234
+ disabled,
235
+ icon: () => icon,
236
+ get: () => host.getMenuValue(it.id) === true,
237
+ set: (v) => {
238
+ host.setMenuValue(it.id, v);
239
+ it.onChange?.(v);
240
+ },
241
+ };
242
+ }
243
+ if (type === 'range') {
244
+ const it = item;
245
+ const { min, max, step } = rangeBounds(it);
246
+ if (max <= min) {
247
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
248
+ // drop the row instead, exactly like an unknown preset id.
249
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
250
+ return null;
251
+ }
252
+ return {
253
+ kind: 'range',
254
+ id: it.id,
255
+ label,
256
+ icon,
257
+ disabled,
258
+ min,
259
+ max,
260
+ step,
261
+ get: () => Number(host.getMenuValue(it.id) ?? min),
262
+ set: (v) => {
263
+ host.setMenuValue(it.id, v);
264
+ it.onChange?.(v);
265
+ },
266
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
267
+ };
268
+ }
269
+ if (type === 'button') {
270
+ const it = item;
271
+ return {
272
+ kind: 'button',
273
+ id: it.id,
274
+ label,
275
+ icon,
276
+ disabled,
277
+ chevron: it.chevron ?? false,
278
+ select: () => it.onSelect?.(),
279
+ };
280
+ }
281
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
282
+ // button row with no onSelect. Drop it visibly instead.
283
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
284
+ return null;
285
+ }
286
+
53
287
  function createInitialState(config) {
54
288
  return {
55
289
  mode: config.mode,
@@ -66,10 +300,10 @@ function createInitialState(config) {
66
300
  bonus: null,
67
301
  activeFeature: null,
68
302
  volumes: {
69
- master: clampVolume(config.volumes?.master),
70
303
  music: clampVolume(config.volumes?.music),
71
304
  sfx: clampVolume(config.volumes?.sfx),
72
305
  },
306
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
73
307
  };
74
308
  }
75
309
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -1427,7 +1661,7 @@ class KeyboardController {
1427
1661
 
1428
1662
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1429
1663
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1430
- const PACKAGE_VERSION = '0.6.5';
1664
+ const PACKAGE_VERSION = '0.7.0';
1431
1665
 
1432
1666
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1433
1667
  function resolveConfig(config) {
@@ -1445,6 +1679,7 @@ function resolveConfig(config) {
1445
1679
  theme: config.theme,
1446
1680
  onBonusBuy: config.onBonusBuy,
1447
1681
  volumes: config.volumes,
1682
+ menu: config.menu,
1448
1683
  version: config.version ?? '1.0.0',
1449
1684
  isSocial: config.isSocial ?? false,
1450
1685
  replay: config.replay ?? config.mode === 'replay',
@@ -1465,8 +1700,9 @@ class ShellController extends EventEmitter {
1465
1700
  i18n;
1466
1701
  kbd;
1467
1702
  overlay = null;
1468
- soundRefresh = null;
1469
- volumeRefresh = null;
1703
+ menuItems;
1704
+ menuRefresh = null;
1705
+ overlayKind = null;
1470
1706
  prevBalance;
1471
1707
  prevWin;
1472
1708
  destroyed = false;
@@ -1477,6 +1713,7 @@ class ShellController extends EventEmitter {
1477
1713
  this.config = resolveConfig(config);
1478
1714
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1479
1715
  this.state = createInitialState(this.config);
1716
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1480
1717
  this.tokens = resolveTheme(this.config.theme);
1481
1718
  this.prevBalance = this.state.balance;
1482
1719
  this.prevWin = this.state.win;
@@ -1610,14 +1847,21 @@ class ShellController extends EventEmitter {
1610
1847
  show(req) {
1611
1848
  this.closeModal();
1612
1849
  this.overlay = this.renderer.openOverlay(req) ?? null;
1850
+ this.overlayKind = this.overlay ? req.kind : null;
1613
1851
  }
1852
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1614
1853
  openMenu() {
1854
+ if (this.overlayKind === 'menu') {
1855
+ this.closeModal();
1856
+ return;
1857
+ }
1615
1858
  this.emit('menuOpen');
1616
- this.openSettings();
1859
+ this.show({ kind: 'menu' });
1617
1860
  }
1861
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1618
1862
  openSettings() {
1619
1863
  this.emit('settingsOpen');
1620
- this.show({ kind: 'settings' });
1864
+ this.openMenu();
1621
1865
  }
1622
1866
  openInfo() {
1623
1867
  this.emit('infoOpen');
@@ -1649,35 +1893,72 @@ class ShellController extends EventEmitter {
1649
1893
  if (!this.overlay)
1650
1894
  return;
1651
1895
  this.overlay = null;
1652
- this.soundRefresh = null;
1653
- this.volumeRefresh = null;
1896
+ this.overlayKind = null;
1897
+ this.menuRefresh = null;
1654
1898
  this.renderer.closeOverlay();
1655
1899
  }
1656
1900
  // ── sound ──────────────────────────────────────────────────────────────────
1657
1901
  setSound(on) {
1658
1902
  this.soundOn = on;
1659
1903
  this.emit('settingChange', { key: 'sound', value: on });
1660
- this.soundRefresh?.(on);
1661
- this.renderer.refreshSoundIcon?.(on);
1662
- }
1663
- setSoundRefresh(fn) {
1664
- this.soundRefresh = fn;
1904
+ this.menuRefresh?.('sound', on);
1665
1905
  }
1666
1906
  // ── volume ─────────────────────────────────────────────────────────────────
1667
1907
  getVolume(key) {
1668
1908
  return this.state.volumes[key];
1669
1909
  }
1670
1910
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1671
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1672
- * live-updates the slider if the overlay is currently open. */
1911
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1912
+ * live-updates the slider if the menu is currently open. */
1673
1913
  setVolume(key, value) {
1674
1914
  const v = Math.max(0, Math.min(1, value));
1675
1915
  this.state.volumes[key] = v;
1676
1916
  this.emit('settingChange', { key, value: v });
1677
- this.volumeRefresh?.(key, v);
1917
+ this.menuRefresh?.(key, v);
1918
+ }
1919
+ // ── menu ───────────────────────────────────────────────────────────────────
1920
+ get menu() {
1921
+ return this.menuItems;
1922
+ }
1923
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1924
+ setMenu(items) {
1925
+ this.menuItems = items;
1926
+ this.state.menu = seedMenuValues(items, this.state.menu);
1927
+ if (this.overlayKind === 'menu')
1928
+ this.show({ kind: 'menu' });
1678
1929
  }
1679
- setVolumeRefresh(fn) {
1680
- this.volumeRefresh = fn;
1930
+ getMenuValue(id) {
1931
+ if (id === 'sound')
1932
+ return this.soundOn;
1933
+ if (id === 'music' || id === 'sfx')
1934
+ return this.state.volumes[id];
1935
+ return this.state.menu[id];
1936
+ }
1937
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1938
+ setMenuValue(id, value) {
1939
+ if (id === 'sound') {
1940
+ this.setSound(value !== false);
1941
+ return;
1942
+ }
1943
+ if (id === 'music' || id === 'sfx') {
1944
+ this.setVolume(id, Number(value));
1945
+ return;
1946
+ }
1947
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1948
+ this.state.menu[id] = next;
1949
+ this.emit('settingChange', { key: id, value: next });
1950
+ this.menuRefresh?.(id, next);
1951
+ }
1952
+ setMenuRefresh(fn) {
1953
+ this.menuRefresh = fn;
1954
+ }
1955
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1956
+ clampRange(id, value) {
1957
+ const item = this.menuItems.find((i) => i.id === id);
1958
+ if (!item || item.type !== 'range')
1959
+ return value;
1960
+ const { min, max } = rangeBounds(item);
1961
+ return Math.max(min, Math.min(max, value));
1681
1962
  }
1682
1963
  // ── features ─────────────────────────────────────────────────────────────────
1683
1964
  activateFeature(bonus) {
@@ -1694,10 +1975,10 @@ class ShellController extends EventEmitter {
1694
1975
  this.renderer.renderBar();
1695
1976
  }
1696
1977
  // ── game-facing public API (mirrors GameShell/PixiGameShell) ───────────────────
1697
- money(field, from, to) {
1978
+ money(field, from, to, durationMs) {
1698
1979
  this.renderer.renderBar();
1699
1980
  if (to !== from)
1700
- this.renderer.animateMoney(field, from, to);
1981
+ this.renderer.animateMoney(field, from, to, durationMs);
1701
1982
  }
1702
1983
  setBalance(n) {
1703
1984
  const from = this.prevBalance;
@@ -1707,7 +1988,9 @@ class ShellController extends EventEmitter {
1707
1988
  }
1708
1989
  /** Set the WIN readout. Counts up/down from the previous value by default. Pass
1709
1990
  * `{ animate: false }` to SNAP instantly (renderBar cancels any in-flight count-up) — used by the
1710
- * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong. */
1991
+ * host to clear WIN to 0 at spin start, where an animated count-DOWN would look wrong.
1992
+ * `{ durationMs }` shortens/lengthens the count-up (default 450ms) — a scene reporting a win per
1993
+ * cascade step passes its step length so each count-up finishes before the next step lands. */
1711
1994
  setWin(n, opts) {
1712
1995
  const from = this.prevWin;
1713
1996
  this.state.win = n;
@@ -1716,7 +1999,7 @@ class ShellController extends EventEmitter {
1716
1999
  this.renderer.renderBar(); // instant repaint from state; cancels running money anims
1717
2000
  return;
1718
2001
  }
1719
- this.money('win', from, n);
2002
+ this.money('win', from, n, opts?.durationMs);
1720
2003
  }
1721
2004
  setBet(n) {
1722
2005
  this.state.bet = n;
@@ -1785,6 +2068,11 @@ class ShellController extends EventEmitter {
1785
2068
  if (this.destroyed)
1786
2069
  return Promise.resolve();
1787
2070
  this.destroyed = true;
2071
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2072
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2073
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2074
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2075
+ this.closeModal();
1788
2076
  if (typeof document !== 'undefined') {
1789
2077
  this.kbd?.detach();
1790
2078
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1794,6 +2082,64 @@ class ShellController extends EventEmitter {
1794
2082
  }
1795
2083
  }
1796
2084
 
2085
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2086
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2087
+ const POPOVER = {
2088
+ /** Keep-out from the surface edges. */
2089
+ margin: 8,
2090
+ /** Space between the anchor and the card. */
2091
+ gap: 8,
2092
+ /** Minimum distance from the arrow tip to either rounded corner. */
2093
+ arrowInset: 14,
2094
+ /** A card shorter than this does not fit — flip to the other side instead. */
2095
+ minH: 120,
2096
+ minW: 220,
2097
+ maxW: 320,
2098
+ };
2099
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2100
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2101
+ function popoverWidth(surfaceW, contentW) {
2102
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2103
+ return Math.max(0, clamp(contentW, Math.min(POPOVER.minW, hi), hi));
2104
+ }
2105
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2106
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2107
+ *
2108
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2109
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2110
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2111
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2112
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2113
+ function placePopover(anchor, surface, size, pointer = null) {
2114
+ const { margin, gap, arrowInset, minH } = POPOVER;
2115
+ if (!anchor) {
2116
+ const maxH = Math.max(0, surface.h - margin * 2);
2117
+ const h = Math.min(size.h, maxH);
2118
+ const rawY = (surface.h - h) / 2;
2119
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2120
+ return {
2121
+ x: Math.max(margin, (surface.w - size.w) / 2),
2122
+ y,
2123
+ maxH,
2124
+ arrowX: -1,
2125
+ below: false,
2126
+ };
2127
+ }
2128
+ const spaceAbove = anchor.y - gap - margin;
2129
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2130
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2131
+ // genuinely more room on the other side.
2132
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2133
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2134
+ const h = Math.min(size.h, maxH);
2135
+ const x = clamp(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2136
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2137
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2138
+ const arrowAnchor = pointer ?? anchor;
2139
+ const arrowX = clamp(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2140
+ return { x, y, maxH, arrowX, below };
2141
+ }
2142
+
1797
2143
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1798
2144
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1799
2145
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -1812,5 +2158,5 @@ function createShell(opts) {
1812
2158
  return controller;
1813
2159
  }
1814
2160
 
1815
- export { DEFAULT_ACCENT, PACKAGE_VERSION, SCHEMES, ShellController, createI18n, createShell, normalizeLang, resolveConfig, resolveTheme, socialize };
2161
+ export { DEFAULT_ACCENT, DEFAULT_MENU, PACKAGE_VERSION, POPOVER, SCHEMES, ShellController, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
1816
2162
  //# sourceMappingURL=index.esm.js.map