@energy8platform/shell 0.10.0 → 0.11.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.
package/dist/html.d.ts CHANGED
@@ -641,7 +641,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
641
641
  tokens: ShellTokens;
642
642
  layout: ShellLayoutMode;
643
643
  soundOn: boolean;
644
- readonly engineVersion = "0.10.0";
644
+ readonly engineVersion = "0.11.1";
645
645
  readonly actions: ShellActions;
646
646
  private renderer;
647
647
  private i18n;
@@ -800,7 +800,59 @@ declare function createI18n(opts: I18nOptions): I18n;
800
800
  declare const DISCLAIMER_LINES: readonly string[];
801
801
 
802
802
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
803
- declare const PACKAGE_VERSION = "0.10.0";
803
+ declare const PACKAGE_VERSION = "0.11.1";
804
+
805
+ /**
806
+ * What a scrollable region should ADVERTISE about itself.
807
+ *
808
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
809
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
810
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
811
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
812
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
813
+ * reads that as content the player can't reach.
814
+ *
815
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
816
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
817
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
818
+ * thumb LOOKS; this decides when there is one and where it sits.
819
+ */
820
+ /**
821
+ * Shortest thumb we will draw, as a fraction of its track.
822
+ *
823
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
824
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
825
+ * and it still travels the whole track, so the position it reports remains truthful even though
826
+ * its length no longer is. That is the right trade: length is decoration, position is information.
827
+ */
828
+ declare const SCROLL_THUMB_MIN = 0.18;
829
+ /** One axis of a scroll region. Named for the Y axis because that is the common case; the
830
+ * buy-bonus strip passes its width metrics through the same fields. */
831
+ interface ScrollMetrics {
832
+ scrollTop: number;
833
+ scrollHeight: number;
834
+ clientHeight: number;
835
+ }
836
+ interface ScrollHint {
837
+ /** There is content past an edge — draw the affordance at all. */
838
+ overflowing: boolean;
839
+ /** Nothing above/left of the viewport: suppress the leading fade. */
840
+ atStart: boolean;
841
+ /** Nothing below/right of the viewport: suppress the trailing fade and the chevron. */
842
+ atEnd: boolean;
843
+ /** Thumb length as a fraction of the track, in `[SCROLL_THUMB_MIN, 1]`. */
844
+ thumbSize: number;
845
+ /** Thumb's leading edge as a fraction of the track, in `[0, 1 - thumbSize]`. */
846
+ thumbOffset: number;
847
+ /** Scrollable distance in pixels — 0 when the content fits. */
848
+ maxScroll: number;
849
+ }
850
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
851
+ declare function scrollHint(m: ScrollMetrics): ScrollHint;
852
+ /** The three-state tag both renderers put on a scroll region, so CSS and tests can name it.
853
+ * `none` when the content fits — the attribute is removed rather than set to it. */
854
+ type ScrollEdge = 'none' | 'start' | 'mid' | 'end';
855
+ declare function scrollEdge(h: ScrollHint): ScrollEdge;
804
856
 
805
857
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
806
858
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
@@ -880,5 +932,5 @@ declare function createGameShell(config: HtmlShellConfig): ShellController;
880
932
  /** Tear down the active shell (no argument — singleton). Resolves immediately when nothing is active. */
881
933
  declare function removeGameShell(): Promise<void>;
882
934
 
883
- export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, POPOVER, SCHEMES, ShellController, createGameShell, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removeGameShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
884
- export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, MenuHost, MenuItem, MenuPresetId, MenuRow, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, PopoverPlacement, Rect as PopoverRect, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, VolumeKey, VolumeLevels, WinSection };
935
+ export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, POPOVER, SCHEMES, SCROLL_THUMB_MIN, ShellController, createGameShell, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removeGameShell, resolveConfig, resolveMenu, resolveTheme, scrollEdge, scrollHint, seedMenuValues, socialize };
936
+ export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, MenuHost, MenuItem, MenuPresetId, MenuRow, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, PopoverPlacement, Rect as PopoverRect, ReplayModalOptions, ResolvedShellConfig, SafeArea, ScrollEdge, ScrollHint, ScrollMetrics, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, VolumeKey, VolumeLevels, WinSection };
package/dist/html.esm.js CHANGED
@@ -322,6 +322,20 @@ function nextTurbo(current, maxLevels) {
322
322
  return 0;
323
323
  return current >= maxLevels ? 0 : current + 1;
324
324
  }
325
+ /**
326
+ * Is a bonus buy unavailable right now?
327
+ *
328
+ * The three RUNTIME locks, in one place because they used to be in three: both bottom bars spelled
329
+ * them out for the coin, and the Shift+B hotkey spelled out a different, shorter set — so the
330
+ * keyboard opened the buy-bonus overlay mid-round and let a player stake a second bet on top of a
331
+ * round already in flight. A predicate the bars and the hotkey share cannot drift apart again.
332
+ *
333
+ * Runtime only. Whether the feature EXISTS at all is a config question (`features.buyBonus`), and
334
+ * whether this particular surface should offer it (mode, replay) belongs to the caller.
335
+ */
336
+ function bonusBuyLocked(s) {
337
+ return s.busy || s.autoplay.active || !s.buyBonusEnabled;
338
+ }
325
339
 
326
340
  /** The single brand accent (purple). The bar accent default (theme.ts) and the buy-a-bonus
327
341
  * card default both derive from this, so a rebrand only touches one constant. */
@@ -1624,7 +1638,9 @@ class KeyboardController {
1624
1638
  }
1625
1639
  break;
1626
1640
  case 'KeyB':
1627
- if (h.buyBonusEnabled && s.mode === 'base' && !s.replay) {
1641
+ // `bonusBuyLocked` is the same predicate the bar's coin uses. Without it this hotkey
1642
+ // reached past a disabled coin and opened the overlay mid-round.
1643
+ if (h.buyBonusEnabled && s.mode === 'base' && !s.replay && !bonusBuyLocked(s)) {
1628
1644
  h.openBuyBonus();
1629
1645
  return;
1630
1646
  }
@@ -1736,7 +1752,7 @@ function keyboardCapable(win = typeof window === 'undefined' ? undefined : windo
1736
1752
 
1737
1753
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1738
1754
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1739
- const PACKAGE_VERSION = '0.10.0';
1755
+ const PACKAGE_VERSION = '0.11.1';
1740
1756
 
1741
1757
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1742
1758
  function resolveConfig(config) {
@@ -2222,6 +2238,66 @@ function placePopover(anchor, surface, size, pointer = null) {
2222
2238
  return { x, y, maxH, arrowX, below };
2223
2239
  }
2224
2240
 
2241
+ /**
2242
+ * What a scrollable region should ADVERTISE about itself.
2243
+ *
2244
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
2245
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
2246
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
2247
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
2248
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
2249
+ * reads that as content the player can't reach.
2250
+ *
2251
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
2252
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
2253
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
2254
+ * thumb LOOKS; this decides when there is one and where it sits.
2255
+ */
2256
+ /** Fractions of a pixel are layout rounding, not reachable content. */
2257
+ const EPSILON = 1;
2258
+ /** Landing within half a pixel of an edge counts as arriving: a browser settles a flung scroll on
2259
+ * 199.5 of 200, and a fade left glowing over content the player has already reached reads as a
2260
+ * bug. */
2261
+ const EDGE_EPSILON = 0.5;
2262
+ /**
2263
+ * Shortest thumb we will draw, as a fraction of its track.
2264
+ *
2265
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
2266
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
2267
+ * and it still travels the whole track, so the position it reports remains truthful even though
2268
+ * its length no longer is. That is the right trade: length is decoration, position is information.
2269
+ */
2270
+ const SCROLL_THUMB_MIN = 0.18;
2271
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
2272
+ function scrollHint(m) {
2273
+ const view = Math.max(0, m.clientHeight);
2274
+ const content = Math.max(0, m.scrollHeight);
2275
+ const maxScroll = Math.max(0, content - view);
2276
+ if (maxScroll <= EPSILON || view <= 0) {
2277
+ // Both edges are "the edge" when there is nowhere to go — callers gate every fade on the
2278
+ // matching flag, so a region that fits draws nothing without needing to check `overflowing`.
2279
+ return { overflowing: false, atStart: true, atEnd: true, thumbSize: 1, thumbOffset: 0, maxScroll: 0 };
2280
+ }
2281
+ const at = Math.max(0, Math.min(maxScroll, m.scrollTop));
2282
+ const thumbSize = Math.min(1, Math.max(SCROLL_THUMB_MIN, view / content));
2283
+ const progress = at / maxScroll;
2284
+ return {
2285
+ overflowing: true,
2286
+ atStart: at <= EDGE_EPSILON,
2287
+ atEnd: maxScroll - at <= EDGE_EPSILON,
2288
+ thumbSize,
2289
+ thumbOffset: progress * (1 - thumbSize),
2290
+ maxScroll,
2291
+ };
2292
+ }
2293
+ function scrollEdge(h) {
2294
+ if (!h.overflowing)
2295
+ return 'none';
2296
+ if (h.atStart)
2297
+ return 'start';
2298
+ return h.atEnd ? 'end' : 'mid';
2299
+ }
2300
+
2225
2301
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
2226
2302
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
2227
2303
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2425,6 +2501,68 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2425
2501
  transition:background .12s ease, color .12s ease; }
2426
2502
  #${SHELL_ROOT_ID} .ge-ov-nav:hover { background:var(--shell-plaque-glass); color:var(--shell-accent); }
2427
2503
  #${SHELL_ROOT_ID} .ge-ov-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden; }
2504
+
2505
+ /* ═══ scroll affordance — "there is more, and you can reach it" ═══════════════════════════════
2506
+ A Stake popout is 400×225. Game info holds twelve screens there, buy-bonus stacks its cards, and
2507
+ the menu popover hides its last row. All of them scrolled already; none of them said so, and
2508
+ Stake rejected the build for it. macOS is the aggravating factor — its overlay scrollbars stay
2509
+ invisible until something is ALREADY scrolling, so the one moment a player needs the hint is the
2510
+ one moment the OS withholds it. Styling the scrollbar at all opts out of that behaviour.
2511
+
2512
+ Three layers, all keyed off data-scroll (written by attachScrollAffordance):
2513
+ 1. a persistent thumb — the standing "this scrolls" mark;
2514
+ 2. a mask fade at whichever edge hides content — the mask applies to the element's own box, so
2515
+ it does NOT travel with the content the way a child gradient would;
2516
+ 3. a chevron, shown only in the start state and retired for good on the first scroll.
2517
+ The end state deliberately gets no trailing fade: there is nothing left below to hint at. */
2518
+ /* Order matters here, and not for cascade reasons. Chromium honours the STANDARD scrollbar
2519
+ properties when they are present and then ignores ::-webkit-scrollbar entirely — and on macOS the
2520
+ standard properties leave the scrollbar in overlay mode, i.e. invisible until it is already
2521
+ moving. Defining ::-webkit-scrollbar with a width is what switches that scroller to a classic,
2522
+ always-painted, still-draggable scrollbar. So the standard properties are quarantined behind an
2523
+ @supports that only Firefox (which has no ::-webkit-scrollbar) satisfies. */
2524
+ @supports not selector(::-webkit-scrollbar) {
2525
+ #${SHELL_ROOT_ID} [data-scroll] { scrollbar-width:thin;
2526
+ scrollbar-color:var(--shell-scrollbar,rgba(255,255,255,.34)) transparent; } }
2527
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar { width:6px; height:6px; }
2528
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-track { background:transparent; }
2529
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb { border-radius:999px;
2530
+ background:var(--shell-scrollbar,rgba(255,255,255,.34)); }
2531
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb:hover { background:var(--shell-accent); }
2532
+ /* Vertical fade. The stop pair is the same on both edges; only which edges are opaque changes.
2533
+ mask-composite-free on purpose — a single multi-stop gradient handles the two-edge case. */
2534
+ #${SHELL_ROOT_ID} [data-scroll="start"]:not([data-scroll-axis="x"]) {
2535
+ -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%);
2536
+ mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%); }
2537
+ #${SHELL_ROOT_ID} [data-scroll="mid"]:not([data-scroll-axis="x"]) {
2538
+ -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%);
2539
+ mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%); }
2540
+ #${SHELL_ROOT_ID} [data-scroll="end"]:not([data-scroll-axis="x"]) {
2541
+ -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px);
2542
+ mask-image:linear-gradient(to bottom, transparent 0, #000 22px); }
2543
+ /* Horizontal fade — the buy-bonus card strip. */
2544
+ #${SHELL_ROOT_ID} [data-scroll="start"][data-scroll-axis="x"] {
2545
+ -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%);
2546
+ mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%); }
2547
+ #${SHELL_ROOT_ID} [data-scroll="mid"][data-scroll-axis="x"] {
2548
+ -webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%);
2549
+ mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%); }
2550
+ #${SHELL_ROOT_ID} [data-scroll="end"][data-scroll-axis="x"] {
2551
+ -webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px);
2552
+ mask-image:linear-gradient(to right, transparent 0, #000 20px); }
2553
+ /* The chevron. Its host is whatever box CONTAINS the scroller (overlay root / popover card), so it
2554
+ holds still while the content moves under it. */
2555
+ #${SHELL_ROOT_ID} .ge-scroll-cue { position:absolute; left:0; top:0; z-index:2;
2556
+ width:22px; height:22px; padding:3px; box-sizing:border-box;
2557
+ display:flex; align-items:center; justify-content:center; pointer-events:none;
2558
+ border-radius:50%; color:#fff; background:var(--shell-plaque-dark);
2559
+ box-shadow:0 2px 10px rgba(0,0,0,.45); animation:ge-scroll-cue 1.6s ease-in-out infinite; }
2560
+ @keyframes ge-scroll-cue {
2561
+ 0%,100% { transform:translateY(0); opacity:.85; }
2562
+ 50% { transform:translateY(4px); opacity:1; } }
2563
+ /* A player who has asked for stillness still needs the hint — keep the chevron, drop the bob. */
2564
+ @media (prefers-reduced-motion: reduce) {
2565
+ #${SHELL_ROOT_ID} .ge-scroll-cue { animation:none; opacity:.95; } }
2428
2566
  #${SHELL_ROOT_ID} .ge-ov-body { max-width:800px; margin:0 auto; box-sizing:border-box;
2429
2567
  padding:clamp(6px,2vh,16px) clamp(16px,4vw,24px) clamp(16px,4vh,28px); }
2430
2568
 
@@ -2786,7 +2924,9 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2786
2924
  card's font-size is the one knob (clamped for readability); everything inside is em-relative so
2787
2925
  the whole card scales as a unit. GameShell.fitModal() still transform-scales it down as a
2788
2926
  backstop for very short popouts. */
2789
- #${SHELL_ROOT_ID} .ge-modal-card { font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
2927
+ /* position:relative is here for the scroll cue: the card is the cue's containing block, so a
2928
+ capped, scrolling chip grid hints at its bottom edge rather than the screen's. */
2929
+ #${SHELL_ROOT_ID} .ge-modal-card { position:relative; font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
2790
2930
  overflow:hidden; transform-origin:center center; background:var(--shell-plaque-solid); border-radius:1.3em;
2791
2931
  display:flex; flex-direction:column; }
2792
2932
  /* ✕ pinned to the overlay corner (the screen), not the card */
@@ -3219,7 +3359,114 @@ function applyBusy(host, bar) {
3219
3359
  const buy = bar.querySelector('[data-ge="buybonus"]');
3220
3360
  // disabled for the whole autoplay run (not just per-spin busy) so it doesn't flicker/pulse
3221
3361
  if (buy)
3222
- buy.disabled = busy || auto || !host.state.buyBonusEnabled;
3362
+ buy.disabled = bonusBuyLocked(host.state);
3363
+ }
3364
+
3365
+ /** Cue diameter, mirrored in the stylesheet. Kept here because the cue is positioned in JS. */
3366
+ const CUE_SIZE = 22;
3367
+ const CUE_GAP = 6;
3368
+ /** The chevron glyph, inline so it needs no icon-set entry and no font. */
3369
+ const CUE_SVG = '<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" ' +
3370
+ 'stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9l7 7 7-7"/></svg>';
3371
+ function attachScrollAffordance(el, opts = {}) {
3372
+ const axis = opts.axis ?? 'y';
3373
+ let cue = null;
3374
+ // Once the player scrolls they have discovered the gesture; re-offering it every time they
3375
+ // return to the top would nag rather than inform.
3376
+ let cueRetired = false;
3377
+ let destroyed = false;
3378
+ if (axis === 'x')
3379
+ el.dataset.scrollAxis = 'x';
3380
+ const removeCue = () => {
3381
+ cue?.remove();
3382
+ cue = null;
3383
+ };
3384
+ const showCue = () => {
3385
+ if (cue || cueRetired || opts.cue === false)
3386
+ return;
3387
+ const host = opts.cueHost ?? el.parentElement;
3388
+ if (!host)
3389
+ return;
3390
+ cue = document.createElement('div');
3391
+ cue.className = 'ge-scroll-cue';
3392
+ cue.setAttribute('aria-hidden', 'true');
3393
+ cue.innerHTML = CUE_SVG;
3394
+ host.appendChild(cue);
3395
+ positionCue();
3396
+ };
3397
+ /** Pin the cue to the bottom of the SCROLLER, not of its host. The buy-bonus overlay hangs a bet
3398
+ * bar below its scroll region, and a cue pinned to the host's bottom edge lands on top of it. */
3399
+ const positionCue = () => {
3400
+ if (!cue)
3401
+ return;
3402
+ const size = cue.offsetWidth || CUE_SIZE;
3403
+ if (axis === 'x') {
3404
+ cue.style.left = `${el.offsetLeft + el.offsetWidth - size - CUE_GAP}px`;
3405
+ cue.style.top = `${el.offsetTop + (el.offsetHeight - size) / 2}px`;
3406
+ }
3407
+ else {
3408
+ cue.style.left = `${el.offsetLeft + (el.offsetWidth - size) / 2}px`;
3409
+ cue.style.top = `${el.offsetTop + el.offsetHeight - size - CUE_GAP}px`;
3410
+ }
3411
+ };
3412
+ const sync = () => {
3413
+ if (destroyed)
3414
+ return;
3415
+ const h = axis === 'x'
3416
+ ? scrollHint({ scrollTop: el.scrollLeft, scrollHeight: el.scrollWidth, clientHeight: el.clientWidth })
3417
+ : scrollHint({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
3418
+ const edge = scrollEdge(h);
3419
+ if (edge === 'none') {
3420
+ delete el.dataset.scroll;
3421
+ removeCue();
3422
+ return;
3423
+ }
3424
+ el.dataset.scroll = edge;
3425
+ if (edge === 'start') {
3426
+ showCue();
3427
+ positionCue(); // the scroller can move/resize under a cue that is already up
3428
+ }
3429
+ else {
3430
+ removeCue();
3431
+ }
3432
+ };
3433
+ // Retirement is keyed off actual MOVEMENT, not off the event. A browser also fires `scroll` when
3434
+ // content reflows under a pinned offset, and a hint dismissed by a reflow the player never caused
3435
+ // is a hint they never saw.
3436
+ let lastPos = axis === 'x' ? el.scrollLeft : el.scrollTop;
3437
+ const onScroll = () => {
3438
+ const pos = axis === 'x' ? el.scrollLeft : el.scrollTop;
3439
+ if (pos !== lastPos) {
3440
+ lastPos = pos;
3441
+ cueRetired = true;
3442
+ removeCue();
3443
+ }
3444
+ sync();
3445
+ };
3446
+ el.addEventListener('scroll', onScroll, { passive: true });
3447
+ // Content in these regions is built asynchronously (fonts, images, a rebuilt body on resize), so
3448
+ // a single sync at mount would measure the wrong thing. ResizeObserver is absent in jsdom.
3449
+ const RO = globalThis.ResizeObserver;
3450
+ const ro = typeof RO === 'function' ? new RO(() => sync()) : null;
3451
+ if (ro) {
3452
+ ro.observe(el);
3453
+ if (el.firstElementChild)
3454
+ ro.observe(el.firstElementChild);
3455
+ }
3456
+ sync();
3457
+ return {
3458
+ sync,
3459
+ destroy() {
3460
+ if (destroyed)
3461
+ return;
3462
+ destroyed = true;
3463
+ el.removeEventListener('scroll', onScroll);
3464
+ ro?.disconnect();
3465
+ delete el.dataset.scroll;
3466
+ delete el.dataset.scrollAxis;
3467
+ removeCue();
3468
+ },
3469
+ };
3223
3470
  }
3224
3471
 
3225
3472
  /** A centred CARD modal — frosted backdrop + opaque card with an accent title heading and an
@@ -3256,8 +3503,9 @@ function createCardModal(opts) {
3256
3503
  }
3257
3504
  return { root, card, body };
3258
3505
  }
3259
- /** Full-screen overlay. Returns { root, body, scroll }; append content to body.
3260
- * The `scroll` element is the scrollable container (overflow-y: auto). */
3506
+ /** Full-screen overlay. Returns { root, body, scroll, affordance }; append content to body.
3507
+ * The `scroll` element is the scrollable container (overflow-y: auto); `affordance` marks it as
3508
+ * scrollable once it overflows — call `affordance.sync()` after filling or resizing the body. */
3261
3509
  function createOverlay(opts) {
3262
3510
  const root = document.createElement('div');
3263
3511
  root.className = 'ge-shell-overlay';
@@ -3295,7 +3543,9 @@ function createOverlay(opts) {
3295
3543
  body.className = 'ge-ov-body';
3296
3544
  scroll.appendChild(body);
3297
3545
  root.append(head, scroll);
3298
- return { root, body, scroll };
3546
+ // The cue is hosted on `root`, not on `scroll`: `scroll` is the element that moves.
3547
+ const affordance = attachScrollAffordance(scroll, { cueHost: root });
3548
+ return { root, body, scroll, affordance };
3299
3549
  }
3300
3550
  /** A light-dismiss popover: a transparent full-surface layer (closes on pointerdown) holding a
3301
3551
  * card with an arrow that points at `pointer`. Append rows to `body`; call `position()` after the
@@ -3316,6 +3566,9 @@ function createPopover(opts) {
3316
3566
  // Clicks inside the card must not reach the dismiss layer.
3317
3567
  card.addEventListener('pointerdown', (e) => e.stopPropagation());
3318
3568
  root.addEventListener('pointerdown', opts.onClose);
3569
+ // The card clamps itself to `maxHeight` in position(), so the body's overflow is only knowable
3570
+ // after that runs — hence the sync at the end of position().
3571
+ const affordance = attachScrollAffordance(body, { cueHost: card });
3319
3572
  const resolveEl = (v) => typeof v === 'function' ? v() : (v ?? null);
3320
3573
  /** A rect in surface coordinates, or null when unresolved/fully zero-sized (a zero-HEIGHT rect —
3321
3574
  * e.g. a not-yet-laid-out anchor — is still considered valid, matching placePopover's own rule). */
@@ -3391,8 +3644,9 @@ function createPopover(opts) {
3391
3644
  arrow.style.display = '';
3392
3645
  arrow.style.left = `${p.arrowX / s}px`;
3393
3646
  }
3647
+ affordance.sync();
3394
3648
  };
3395
- return { root, card, body, position };
3649
+ return { root, card, body, affordance, position };
3396
3650
  }
3397
3651
 
3398
3652
  /** The bar menu, as a light-dismiss popover anchored to the burger. Rows come from the core model,
@@ -3543,7 +3797,7 @@ function buildRow(host, row, updaters, reposition) {
3543
3797
  const HOTKEYS_DEFAULT_ORDER = -0.5;
3544
3798
  const SVG_NS = 'http://www.w3.org/2000/svg';
3545
3799
  function openGameInfoModal(host) {
3546
- const { root, body, scroll } = createOverlay({
3800
+ const { root, body, scroll, affordance } = createOverlay({
3547
3801
  title: host.t('Game info'),
3548
3802
  onClose: () => host.actions.closeOverlay(),
3549
3803
  onBack: () => { root.remove(); host.actions.openMenu(); },
@@ -3569,6 +3823,8 @@ function openGameInfoModal(host) {
3569
3823
  .sort((a, b) => a.k - b.k || a.i - b.i)
3570
3824
  .forEach(({ s }) => body.appendChild(renderSection(host, s)));
3571
3825
  body.appendChild(versionFooter(host));
3826
+ // The body is filled after createOverlay returned, so its first honest measurement is here.
3827
+ affordance.sync();
3572
3828
  const LINE = 60;
3573
3829
  const PAGE = () => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
3574
3830
  const onKey = (e) => {
@@ -3961,10 +4217,13 @@ function openBuyBonusOverlay(host) {
3961
4217
  if (bonuses === false || bonuses.length === 0)
3962
4218
  return null;
3963
4219
  const st = { focusIndex: -1, confirmBonus: undefined };
3964
- const { root, body } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
4220
+ const { root, body, affordance } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
3965
4221
  root.dataset.ge = 'buybonus-overlay';
4222
+ // The strip's own X-scroll affordance, rebuilt with the grid it describes.
4223
+ let gridAffordance = null;
3966
4224
  // Re-render the grid whenever the bet changes so every card's price stays live.
3967
4225
  const renderGrid = () => {
4226
+ gridAffordance?.destroy();
3968
4227
  body.innerHTML = '';
3969
4228
  const grid = document.createElement('div');
3970
4229
  grid.className = 'ge-bb-grid';
@@ -3990,6 +4249,11 @@ function openBuyBonusOverlay(host) {
3990
4249
  else {
3991
4250
  st.focusIndex = -1;
3992
4251
  }
4252
+ // Two axes, two affordances. Below a ~340px frame the CSS stacks the cards and the OVERLAY
4253
+ // scrolls vertically (see the ge-bb-frame container query); above it the STRIP scrolls
4254
+ // horizontally. Each is attached unconditionally and stays silent on the axis that fits.
4255
+ gridAffordance = attachScrollAffordance(grid, { axis: 'x', cue: false });
4256
+ affordance.sync();
3993
4257
  };
3994
4258
  renderGrid();
3995
4259
  root.appendChild(buildBetBar(host, renderGrid)); // thin bottom footer, only as tall as the pill
@@ -4320,6 +4584,9 @@ function buildSheet(opts) {
4320
4584
  grid.appendChild(chip);
4321
4585
  }
4322
4586
  ui.body.appendChild(grid);
4587
+ // A long bet ladder is capped at 50vh and scrolls; in a 225px popout that cap bites after two
4588
+ // rows. The cue hangs off the card, which holds still while the grid moves.
4589
+ attachScrollAffordance(grid, { cueHost: ui.card });
4323
4590
  function doConfirm() {
4324
4591
  opts.onConfirm(selected);
4325
4592
  opts.onClose();
@@ -4815,5 +5082,5 @@ function removeGameShell() {
4815
5082
  return shell.destroy();
4816
5083
  }
4817
5084
 
4818
- export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, POPOVER, SCHEMES, ShellController, createGameShell, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removeGameShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
5085
+ export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, POPOVER, SCHEMES, SCROLL_THUMB_MIN, ShellController, createGameShell, createI18n, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removeGameShell, resolveConfig, resolveMenu, resolveTheme, scrollEdge, scrollHint, seedMenuValues, socialize };
4819
5086
  //# sourceMappingURL=html.esm.js.map