@energy8platform/shell 0.6.6 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/html.cjs.js +677 -109
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +207 -27
  4. package/dist/html.esm.js +670 -110
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +378 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +199 -26
  9. package/dist/index.esm.js +371 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +952 -282
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +202 -28
  14. package/dist/pixi.esm.js +945 -283
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +68 -16
  18. package/src/core/format.ts +8 -4
  19. package/src/core/icon-names.ts +30 -0
  20. package/src/core/index.ts +4 -0
  21. package/src/core/menu.ts +301 -0
  22. package/src/core/popover.ts +81 -0
  23. package/src/core/renderer.ts +13 -10
  24. package/src/core/state.ts +2 -1
  25. package/src/core/types.ts +15 -6
  26. package/src/core/version.ts +1 -1
  27. package/src/ui/html/HtmlRenderer.ts +31 -8
  28. package/src/ui/html/components/GameInfo.ts +1 -1
  29. package/src/ui/html/components/Menu.ts +153 -0
  30. package/src/ui/html/icons.ts +16 -15
  31. package/src/ui/html/primitives.ts +142 -0
  32. package/src/ui/html/shell.css.ts +21 -1
  33. package/src/ui/pixi/PixiRenderer.ts +32 -14
  34. package/src/ui/pixi/components/BottomBar.ts +80 -9
  35. package/src/ui/pixi/components/GameInfo.ts +1 -1
  36. package/src/ui/pixi/components/Menu.ts +167 -0
  37. package/src/ui/pixi/context.ts +3 -2
  38. package/src/ui/pixi/icons.ts +16 -15
  39. package/src/ui/pixi/pixi-icon.ts +15 -3
  40. package/src/ui/pixi/primitives/controls.ts +46 -0
  41. package/src/ui/pixi/primitives/popover.ts +163 -0
  42. package/src/ui/pixi/primitives/scroll.ts +9 -5
  43. package/src/ui/html/components/Settings.ts +0 -68
  44. package/src/ui/pixi/components/Settings.ts +0 -132
package/dist/index.cjs.js CHANGED
@@ -52,6 +52,240 @@ class EventEmitter {
52
52
  }
53
53
  }
54
54
 
55
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
56
+ // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
57
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
58
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
59
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
60
+ // `node scripts/gen-icons-from-svg.mjs`.
61
+ // The single glyph-name union, shared by core (menu items) and both renderers.
62
+ const ICON_NAMES = [
63
+ 'spin',
64
+ 'turbo1',
65
+ 'autoplay',
66
+ 'stop',
67
+ 'menu',
68
+ 'minus',
69
+ 'plus',
70
+ 'gift',
71
+ 'info',
72
+ 'soundOn',
73
+ 'soundOff',
74
+ 'close',
75
+ 'back',
76
+ 'chevronRight',
77
+ 'ticket',
78
+ 'turbo2',
79
+ 'turboOff',
80
+ 'chevronUp',
81
+ 'chevronDown',
82
+ ];
83
+
84
+ const PRESET_IDS = ['sound', 'music', 'sfx', 'gameInfo'];
85
+ function isPresetId(id) {
86
+ return PRESET_IDS.includes(id);
87
+ }
88
+ /** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
89
+ const DEFAULT_MENU = [
90
+ { id: 'sound' },
91
+ { id: 'music' },
92
+ { id: 'sfx' },
93
+ { type: 'separator' },
94
+ { id: 'gameInfo' },
95
+ ];
96
+ const isSeparator = (i) => i.type === 'separator';
97
+ /** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
98
+ function rangeBounds(item) {
99
+ const min = item.min ?? 0;
100
+ const max = item.max ?? 1;
101
+ const derivedStep = (max - min) / 20;
102
+ // A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
103
+ // explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
104
+ // and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
105
+ // slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
106
+ const step = item.step != null && item.step > 0 ? item.step : derivedStep;
107
+ return { min, max, step };
108
+ }
109
+ /** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
110
+ * a later `setMenu()` with the same ids does not reset what the player has changed. */
111
+ function seedMenuValues(items, prev = {}) {
112
+ const out = {};
113
+ for (const item of items) {
114
+ if (isSeparator(item))
115
+ continue;
116
+ const type = item.type;
117
+ if (!type || isPresetId(item.id))
118
+ continue;
119
+ // A previous value only carries over when its runtime type still matches this item's kind.
120
+ // Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
121
+ // a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
122
+ // reconfiguration, not a config error, so it falls through to the item's own default instead
123
+ // of warning.
124
+ const prevValue = prev[item.id];
125
+ if (type === 'toggle') {
126
+ out[item.id] = typeof prevValue === 'boolean' ? prevValue : (item.value ?? false);
127
+ }
128
+ else if (type === 'range') {
129
+ const r = item;
130
+ out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ const percent = (v) => `${Math.round(v * 100)}%`;
136
+ function safeIcon(name) {
137
+ return name && ICON_NAMES.includes(name) ? name : undefined;
138
+ }
139
+ /** Every drop/collision warning goes through here so the message style stays uniform. */
140
+ function warn(message) {
141
+ console.warn(`[shell] ${message}`);
142
+ }
143
+ /** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
144
+ * unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
145
+ * silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
146
+ * with a reserved preset id also warns, but keeps its row (see `custom()`). */
147
+ function resolveMenu(host) {
148
+ const rows = [];
149
+ for (const item of host.menu) {
150
+ if (isSeparator(item)) {
151
+ rows.push({ kind: 'separator' });
152
+ continue;
153
+ }
154
+ const type = item.type;
155
+ if (!type) {
156
+ const row = preset(host, item);
157
+ if (row)
158
+ rows.push(row);
159
+ else
160
+ warn(`unknown menu preset id "${item.id}" — item skipped`);
161
+ continue;
162
+ }
163
+ const row = custom(host, item, type);
164
+ if (row)
165
+ rows.push(row);
166
+ }
167
+ return rows;
168
+ }
169
+ function preset(host, item) {
170
+ const disabled = item.disabled ?? false;
171
+ const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
172
+ switch (item.id) {
173
+ case 'sound':
174
+ return {
175
+ kind: 'toggle',
176
+ id: 'sound',
177
+ label,
178
+ disabled,
179
+ icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
180
+ get: () => host.getMenuValue('sound') !== false,
181
+ set: (v) => host.setMenuValue('sound', v),
182
+ };
183
+ case 'music':
184
+ case 'sfx': {
185
+ const id = item.id;
186
+ return {
187
+ kind: 'range',
188
+ id,
189
+ label,
190
+ icon: safeIcon(item.icon),
191
+ disabled,
192
+ min: 0,
193
+ max: 1,
194
+ step: 0.05,
195
+ get: () => Number(host.getMenuValue(id) ?? 1),
196
+ set: (v) => host.setMenuValue(id, v),
197
+ format: percent,
198
+ };
199
+ }
200
+ case 'gameInfo':
201
+ return {
202
+ kind: 'button',
203
+ id: 'gameInfo',
204
+ label,
205
+ icon: safeIcon(item.icon) ?? 'info',
206
+ disabled,
207
+ chevron: true,
208
+ select: () => host.actions.openInfo(),
209
+ };
210
+ default:
211
+ return null;
212
+ }
213
+ }
214
+ const DEFAULT_LABELS = {
215
+ sound: 'Sound',
216
+ music: 'Music',
217
+ sfx: 'SFX',
218
+ gameInfo: 'Game info',
219
+ };
220
+ function custom(host, item, type) {
221
+ // Same store, different semantics: the real preset's get() and a custom row's get() disagree
222
+ // (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
223
+ // ShellHost is what actually owns the value — this warning just makes the clash visible.
224
+ if (isPresetId(item.id)) {
225
+ warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
226
+ }
227
+ const disabled = item.disabled ?? false;
228
+ const label = host.t(item.label ?? item.id);
229
+ const icon = safeIcon(item.icon);
230
+ if (type === 'toggle') {
231
+ const it = item;
232
+ return {
233
+ kind: 'toggle',
234
+ id: it.id,
235
+ label,
236
+ disabled,
237
+ icon: () => icon,
238
+ get: () => host.getMenuValue(it.id) === true,
239
+ set: (v) => {
240
+ host.setMenuValue(it.id, v);
241
+ it.onChange?.(v);
242
+ },
243
+ };
244
+ }
245
+ if (type === 'range') {
246
+ const it = item;
247
+ const { min, max, step } = rangeBounds(it);
248
+ if (max <= min) {
249
+ // A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
250
+ // drop the row instead, exactly like an unknown preset id.
251
+ warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
252
+ return null;
253
+ }
254
+ return {
255
+ kind: 'range',
256
+ id: it.id,
257
+ label,
258
+ icon,
259
+ disabled,
260
+ min,
261
+ max,
262
+ step,
263
+ get: () => Number(host.getMenuValue(it.id) ?? min),
264
+ set: (v) => {
265
+ host.setMenuValue(it.id, v);
266
+ it.onChange?.(v);
267
+ },
268
+ format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
269
+ };
270
+ }
271
+ if (type === 'button') {
272
+ const it = item;
273
+ return {
274
+ kind: 'button',
275
+ id: it.id,
276
+ label,
277
+ icon,
278
+ disabled,
279
+ chevron: it.chevron ?? false,
280
+ select: () => it.onSelect?.(),
281
+ };
282
+ }
283
+ // Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
284
+ // button row with no onSelect. Drop it visibly instead.
285
+ warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
286
+ return null;
287
+ }
288
+
55
289
  function createInitialState(config) {
56
290
  return {
57
291
  mode: config.mode,
@@ -68,10 +302,10 @@ function createInitialState(config) {
68
302
  bonus: null,
69
303
  activeFeature: null,
70
304
  volumes: {
71
- master: clampVolume(config.volumes?.master),
72
305
  music: clampVolume(config.volumes?.music),
73
306
  sfx: clampVolume(config.volumes?.sfx),
74
307
  },
308
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
75
309
  };
76
310
  }
77
311
  /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
@@ -143,15 +377,19 @@ function resolveTheme(theme = {}) {
143
377
  * trailing zeros are trimmed down to (but never past) `minDecimals`, so small wins keep their
144
378
  * significant digits. Balance / bet / prices stay fixed at `minDecimals`.
145
379
  *
380
+ * Separators default to the platform convention — `.` decimal, `,` thousands (`€1,234.50`).
381
+ * A comma decimal is NOT safe as a default: players read "2,50" as 250 and believe they were
382
+ * paid far more than they were. Games that need another convention pass `currency.separator`.
383
+ *
146
384
  * Example with `maxDecimals: 4, minDecimals: 2`:
147
- * fixed → 0.0673 → 0,07 0.3 → 0,30
148
- * variable → 0.0673 → 0,0673 0.067 → 0,067 0.3 → 0,30 0 → 0,00
385
+ * fixed → 0.0673 → 0.07 0.3 → 0.30
386
+ * variable → 0.0673 → 0.0673 0.067 → 0.067 0.3 → 0.30 0 → 0.00
149
387
  */
150
388
  function formatCurrency(value, currency, variableDecimals = false) {
151
389
  const maxDecimals = currency.maxDecimals ?? 2;
152
390
  const minDecimals = Math.max(0, Math.min(maxDecimals, currency.minDecimals ?? maxDecimals));
153
- const thousands = currency.separator?.thousands ?? '.';
154
- const decimal = currency.separator?.decimal ?? ',';
391
+ const thousands = currency.separator?.thousands ?? ',';
392
+ const decimal = currency.separator?.decimal ?? '.';
155
393
  const safe = Number.isFinite(value) ? value : 0;
156
394
  // fixed callers round at minDecimals; variable callers round at maxDecimals then trim back down.
157
395
  const places = variableDecimals ? maxDecimals : minDecimals;
@@ -1429,7 +1667,7 @@ class KeyboardController {
1429
1667
 
1430
1668
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1431
1669
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1432
- const PACKAGE_VERSION = '0.6.6';
1670
+ const PACKAGE_VERSION = '0.7.1';
1433
1671
 
1434
1672
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1435
1673
  function resolveConfig(config) {
@@ -1447,6 +1685,7 @@ function resolveConfig(config) {
1447
1685
  theme: config.theme,
1448
1686
  onBonusBuy: config.onBonusBuy,
1449
1687
  volumes: config.volumes,
1688
+ menu: config.menu,
1450
1689
  version: config.version ?? '1.0.0',
1451
1690
  isSocial: config.isSocial ?? false,
1452
1691
  replay: config.replay ?? config.mode === 'replay',
@@ -1467,8 +1706,9 @@ class ShellController extends EventEmitter {
1467
1706
  i18n;
1468
1707
  kbd;
1469
1708
  overlay = null;
1470
- soundRefresh = null;
1471
- volumeRefresh = null;
1709
+ menuItems;
1710
+ menuRefresh = null;
1711
+ overlayKind = null;
1472
1712
  prevBalance;
1473
1713
  prevWin;
1474
1714
  destroyed = false;
@@ -1479,6 +1719,7 @@ class ShellController extends EventEmitter {
1479
1719
  this.config = resolveConfig(config);
1480
1720
  this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
1481
1721
  this.state = createInitialState(this.config);
1722
+ this.menuItems = this.config.menu ?? DEFAULT_MENU;
1482
1723
  this.tokens = resolveTheme(this.config.theme);
1483
1724
  this.prevBalance = this.state.balance;
1484
1725
  this.prevWin = this.state.win;
@@ -1612,14 +1853,21 @@ class ShellController extends EventEmitter {
1612
1853
  show(req) {
1613
1854
  this.closeModal();
1614
1855
  this.overlay = this.renderer.openOverlay(req) ?? null;
1856
+ this.overlayKind = this.overlay ? req.kind : null;
1615
1857
  }
1858
+ /** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
1616
1859
  openMenu() {
1860
+ if (this.overlayKind === 'menu') {
1861
+ this.closeModal();
1862
+ return;
1863
+ }
1617
1864
  this.emit('menuOpen');
1618
- this.openSettings();
1865
+ this.show({ kind: 'menu' });
1619
1866
  }
1867
+ /** @deprecated The Settings overlay is gone — this opens the bar menu. */
1620
1868
  openSettings() {
1621
1869
  this.emit('settingsOpen');
1622
- this.show({ kind: 'settings' });
1870
+ this.openMenu();
1623
1871
  }
1624
1872
  openInfo() {
1625
1873
  this.emit('infoOpen');
@@ -1651,35 +1899,72 @@ class ShellController extends EventEmitter {
1651
1899
  if (!this.overlay)
1652
1900
  return;
1653
1901
  this.overlay = null;
1654
- this.soundRefresh = null;
1655
- this.volumeRefresh = null;
1902
+ this.overlayKind = null;
1903
+ this.menuRefresh = null;
1656
1904
  this.renderer.closeOverlay();
1657
1905
  }
1658
1906
  // ── sound ──────────────────────────────────────────────────────────────────
1659
1907
  setSound(on) {
1660
1908
  this.soundOn = on;
1661
1909
  this.emit('settingChange', { key: 'sound', value: on });
1662
- this.soundRefresh?.(on);
1663
- this.renderer.refreshSoundIcon?.(on);
1664
- }
1665
- setSoundRefresh(fn) {
1666
- this.soundRefresh = fn;
1910
+ this.menuRefresh?.('sound', on);
1667
1911
  }
1668
1912
  // ── volume ─────────────────────────────────────────────────────────────────
1669
1913
  getVolume(key) {
1670
1914
  return this.state.volumes[key];
1671
1915
  }
1672
1916
  /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1673
- * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1674
- * live-updates the slider if the overlay is currently open. */
1917
+ * clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
1918
+ * live-updates the slider if the menu is currently open. */
1675
1919
  setVolume(key, value) {
1676
1920
  const v = Math.max(0, Math.min(1, value));
1677
1921
  this.state.volumes[key] = v;
1678
1922
  this.emit('settingChange', { key, value: v });
1679
- this.volumeRefresh?.(key, v);
1923
+ this.menuRefresh?.(key, v);
1924
+ }
1925
+ // ── menu ───────────────────────────────────────────────────────────────────
1926
+ get menu() {
1927
+ return this.menuItems;
1928
+ }
1929
+ /** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
1930
+ setMenu(items) {
1931
+ this.menuItems = items;
1932
+ this.state.menu = seedMenuValues(items, this.state.menu);
1933
+ if (this.overlayKind === 'menu')
1934
+ this.show({ kind: 'menu' });
1680
1935
  }
1681
- setVolumeRefresh(fn) {
1682
- this.volumeRefresh = fn;
1936
+ getMenuValue(id) {
1937
+ if (id === 'sound')
1938
+ return this.soundOn;
1939
+ if (id === 'music' || id === 'sfx')
1940
+ return this.state.volumes[id];
1941
+ return this.state.menu[id];
1942
+ }
1943
+ /** Set a menu value. Presets route to their own homes so there is never a second copy. */
1944
+ setMenuValue(id, value) {
1945
+ if (id === 'sound') {
1946
+ this.setSound(value !== false);
1947
+ return;
1948
+ }
1949
+ if (id === 'music' || id === 'sfx') {
1950
+ this.setVolume(id, Number(value));
1951
+ return;
1952
+ }
1953
+ const next = typeof value === 'number' ? this.clampRange(id, value) : value;
1954
+ this.state.menu[id] = next;
1955
+ this.emit('settingChange', { key: id, value: next });
1956
+ this.menuRefresh?.(id, next);
1957
+ }
1958
+ setMenuRefresh(fn) {
1959
+ this.menuRefresh = fn;
1960
+ }
1961
+ /** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
1962
+ clampRange(id, value) {
1963
+ const item = this.menuItems.find((i) => i.id === id);
1964
+ if (!item || item.type !== 'range')
1965
+ return value;
1966
+ const { min, max } = rangeBounds(item);
1967
+ return Math.max(min, Math.min(max, value));
1683
1968
  }
1684
1969
  // ── features ─────────────────────────────────────────────────────────────────
1685
1970
  activateFeature(bonus) {
@@ -1789,6 +2074,11 @@ class ShellController extends EventEmitter {
1789
2074
  if (this.destroyed)
1790
2075
  return Promise.resolve();
1791
2076
  this.destroyed = true;
2077
+ // With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
2078
+ // would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
2079
+ // invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
2080
+ // and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
2081
+ this.closeModal();
1792
2082
  if (typeof document !== 'undefined') {
1793
2083
  this.kbd?.detach();
1794
2084
  document.removeEventListener('pointerdown', this.pullFocus, true);
@@ -1798,6 +2088,64 @@ class ShellController extends EventEmitter {
1798
2088
  }
1799
2089
  }
1800
2090
 
2091
+ /** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
2092
+ * place it identically — the renderers only supply measured sizes and apply the result. */
2093
+ const POPOVER = {
2094
+ /** Keep-out from the surface edges. */
2095
+ margin: 8,
2096
+ /** Space between the anchor and the card. */
2097
+ gap: 8,
2098
+ /** Minimum distance from the arrow tip to either rounded corner. */
2099
+ arrowInset: 14,
2100
+ /** A card shorter than this does not fit — flip to the other side instead. */
2101
+ minH: 120,
2102
+ minW: 220,
2103
+ maxW: 320,
2104
+ };
2105
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2106
+ /** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
2107
+ function popoverWidth(surfaceW, contentW) {
2108
+ const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
2109
+ return Math.max(0, clamp(contentW, Math.min(POPOVER.minW, hi), hi));
2110
+ }
2111
+ /** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
2112
+ * inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
2113
+ *
2114
+ * `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
2115
+ * card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
2116
+ * the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
2117
+ * inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
2118
+ * (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
2119
+ function placePopover(anchor, surface, size, pointer = null) {
2120
+ const { margin, gap, arrowInset, minH } = POPOVER;
2121
+ if (!anchor) {
2122
+ const maxH = Math.max(0, surface.h - margin * 2);
2123
+ const h = Math.min(size.h, maxH);
2124
+ const rawY = (surface.h - h) / 2;
2125
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2126
+ return {
2127
+ x: Math.max(margin, (surface.w - size.w) / 2),
2128
+ y,
2129
+ maxH,
2130
+ arrowX: -1,
2131
+ below: false,
2132
+ };
2133
+ }
2134
+ const spaceAbove = anchor.y - gap - margin;
2135
+ const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
2136
+ // Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
2137
+ // genuinely more room on the other side.
2138
+ const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
2139
+ const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
2140
+ const h = Math.min(size.h, maxH);
2141
+ const x = clamp(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
2142
+ const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
2143
+ const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
2144
+ const arrowAnchor = pointer ?? anchor;
2145
+ const arrowX = clamp(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
2146
+ return { x, y, maxH, arrowX, below };
2147
+ }
2148
+
1801
2149
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
1802
2150
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
1803
2151
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -1817,13 +2165,21 @@ function createShell(opts) {
1817
2165
  }
1818
2166
 
1819
2167
  exports.DEFAULT_ACCENT = DEFAULT_ACCENT;
2168
+ exports.DEFAULT_MENU = DEFAULT_MENU;
1820
2169
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
2170
+ exports.POPOVER = POPOVER;
1821
2171
  exports.SCHEMES = SCHEMES;
1822
2172
  exports.ShellController = ShellController;
1823
2173
  exports.createI18n = createI18n;
1824
2174
  exports.createShell = createShell;
2175
+ exports.isPresetId = isPresetId;
1825
2176
  exports.normalizeLang = normalizeLang;
2177
+ exports.placePopover = placePopover;
2178
+ exports.popoverWidth = popoverWidth;
2179
+ exports.rangeBounds = rangeBounds;
1826
2180
  exports.resolveConfig = resolveConfig;
2181
+ exports.resolveMenu = resolveMenu;
1827
2182
  exports.resolveTheme = resolveTheme;
2183
+ exports.seedMenuValues = seedMenuValues;
1828
2184
  exports.socialize = socialize;
1829
2185
  //# sourceMappingURL=index.cjs.js.map