@energy8platform/shell 0.9.0 → 0.11.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.
package/dist/html.cjs.js CHANGED
@@ -1308,6 +1308,19 @@ const D = {
1308
1308
  L4: 'The game display is not representative of any physical device and is for illustrative purposes only.',
1309
1309
  L5: 'Winnings are settled according to the amount received from the Remote Game Server and not from events within the web browser.',
1310
1310
  };
1311
+ /**
1312
+ * The disclaimer body every launch shows, in source English — and the SAME strings that key the
1313
+ * translations below. One constant for both on purpose: a host that kept its own copy would drift
1314
+ * from these keys by one character and the lookup would miss in silence, leaving a player on any
1315
+ * language with English legal text. Certification asks for the opposite ("if multiple languages are
1316
+ * supported, the disclaimer is translated and displayed in each language").
1317
+ *
1318
+ * Brand-free by construction. Stake's own template ends with a seventh line — "TM and © {year}
1319
+ * Stake Engine." — and that one belongs to a Stake launch alone: it arrives with the Stake bridge's
1320
+ * `INIT.config.disclaimerLines`, which outrank this default, and renders verbatim (see the host's
1321
+ * `isBrandLine`). Every other platform gets the same mandated wording without someone else's mark.
1322
+ */
1323
+ const DISCLAIMER_LINES = [D.L1, D.L2, D.L3, D.L4, D.L5];
1311
1324
  const DISCLAIMER_LOCALES = {
1312
1325
  da: {
1313
1326
  [D.L1]: 'Fejlfunktion annullerer alle gevinster og spil.',
@@ -1725,7 +1738,7 @@ function keyboardCapable(win = typeof window === 'undefined' ? undefined : windo
1725
1738
 
1726
1739
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1727
1740
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1728
- const PACKAGE_VERSION = '0.9.0';
1741
+ const PACKAGE_VERSION = '0.11.0';
1729
1742
 
1730
1743
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1731
1744
  function resolveConfig(config) {
@@ -2211,6 +2224,66 @@ function placePopover(anchor, surface, size, pointer = null) {
2211
2224
  return { x, y, maxH, arrowX, below };
2212
2225
  }
2213
2226
 
2227
+ /**
2228
+ * What a scrollable region should ADVERTISE about itself.
2229
+ *
2230
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
2231
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
2232
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
2233
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
2234
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
2235
+ * reads that as content the player can't reach.
2236
+ *
2237
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
2238
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
2239
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
2240
+ * thumb LOOKS; this decides when there is one and where it sits.
2241
+ */
2242
+ /** Fractions of a pixel are layout rounding, not reachable content. */
2243
+ const EPSILON = 1;
2244
+ /** Landing within half a pixel of an edge counts as arriving: a browser settles a flung scroll on
2245
+ * 199.5 of 200, and a fade left glowing over content the player has already reached reads as a
2246
+ * bug. */
2247
+ const EDGE_EPSILON = 0.5;
2248
+ /**
2249
+ * Shortest thumb we will draw, as a fraction of its track.
2250
+ *
2251
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
2252
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
2253
+ * and it still travels the whole track, so the position it reports remains truthful even though
2254
+ * its length no longer is. That is the right trade: length is decoration, position is information.
2255
+ */
2256
+ const SCROLL_THUMB_MIN = 0.18;
2257
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
2258
+ function scrollHint(m) {
2259
+ const view = Math.max(0, m.clientHeight);
2260
+ const content = Math.max(0, m.scrollHeight);
2261
+ const maxScroll = Math.max(0, content - view);
2262
+ if (maxScroll <= EPSILON || view <= 0) {
2263
+ // Both edges are "the edge" when there is nowhere to go — callers gate every fade on the
2264
+ // matching flag, so a region that fits draws nothing without needing to check `overflowing`.
2265
+ return { overflowing: false, atStart: true, atEnd: true, thumbSize: 1, thumbOffset: 0, maxScroll: 0 };
2266
+ }
2267
+ const at = Math.max(0, Math.min(maxScroll, m.scrollTop));
2268
+ const thumbSize = Math.min(1, Math.max(SCROLL_THUMB_MIN, view / content));
2269
+ const progress = at / maxScroll;
2270
+ return {
2271
+ overflowing: true,
2272
+ atStart: at <= EDGE_EPSILON,
2273
+ atEnd: maxScroll - at <= EDGE_EPSILON,
2274
+ thumbSize,
2275
+ thumbOffset: progress * (1 - thumbSize),
2276
+ maxScroll,
2277
+ };
2278
+ }
2279
+ function scrollEdge(h) {
2280
+ if (!h.overflowing)
2281
+ return 'none';
2282
+ if (h.atStart)
2283
+ return 'start';
2284
+ return h.atEnd ? 'end' : 'mid';
2285
+ }
2286
+
2214
2287
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
2215
2288
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
2216
2289
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -2414,6 +2487,68 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2414
2487
  transition:background .12s ease, color .12s ease; }
2415
2488
  #${SHELL_ROOT_ID} .ge-ov-nav:hover { background:var(--shell-plaque-glass); color:var(--shell-accent); }
2416
2489
  #${SHELL_ROOT_ID} .ge-ov-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden; }
2490
+
2491
+ /* ═══ scroll affordance — "there is more, and you can reach it" ═══════════════════════════════
2492
+ A Stake popout is 400×225. Game info holds twelve screens there, buy-bonus stacks its cards, and
2493
+ the menu popover hides its last row. All of them scrolled already; none of them said so, and
2494
+ Stake rejected the build for it. macOS is the aggravating factor — its overlay scrollbars stay
2495
+ invisible until something is ALREADY scrolling, so the one moment a player needs the hint is the
2496
+ one moment the OS withholds it. Styling the scrollbar at all opts out of that behaviour.
2497
+
2498
+ Three layers, all keyed off data-scroll (written by attachScrollAffordance):
2499
+ 1. a persistent thumb — the standing "this scrolls" mark;
2500
+ 2. a mask fade at whichever edge hides content — the mask applies to the element's own box, so
2501
+ it does NOT travel with the content the way a child gradient would;
2502
+ 3. a chevron, shown only in the start state and retired for good on the first scroll.
2503
+ The end state deliberately gets no trailing fade: there is nothing left below to hint at. */
2504
+ /* Order matters here, and not for cascade reasons. Chromium honours the STANDARD scrollbar
2505
+ properties when they are present and then ignores ::-webkit-scrollbar entirely — and on macOS the
2506
+ standard properties leave the scrollbar in overlay mode, i.e. invisible until it is already
2507
+ moving. Defining ::-webkit-scrollbar with a width is what switches that scroller to a classic,
2508
+ always-painted, still-draggable scrollbar. So the standard properties are quarantined behind an
2509
+ @supports that only Firefox (which has no ::-webkit-scrollbar) satisfies. */
2510
+ @supports not selector(::-webkit-scrollbar) {
2511
+ #${SHELL_ROOT_ID} [data-scroll] { scrollbar-width:thin;
2512
+ scrollbar-color:var(--shell-scrollbar,rgba(255,255,255,.34)) transparent; } }
2513
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar { width:6px; height:6px; }
2514
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-track { background:transparent; }
2515
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb { border-radius:999px;
2516
+ background:var(--shell-scrollbar,rgba(255,255,255,.34)); }
2517
+ #${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb:hover { background:var(--shell-accent); }
2518
+ /* Vertical fade. The stop pair is the same on both edges; only which edges are opaque changes.
2519
+ mask-composite-free on purpose — a single multi-stop gradient handles the two-edge case. */
2520
+ #${SHELL_ROOT_ID} [data-scroll="start"]:not([data-scroll-axis="x"]) {
2521
+ -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%);
2522
+ mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%); }
2523
+ #${SHELL_ROOT_ID} [data-scroll="mid"]:not([data-scroll-axis="x"]) {
2524
+ -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%);
2525
+ mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%); }
2526
+ #${SHELL_ROOT_ID} [data-scroll="end"]:not([data-scroll-axis="x"]) {
2527
+ -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px);
2528
+ mask-image:linear-gradient(to bottom, transparent 0, #000 22px); }
2529
+ /* Horizontal fade — the buy-bonus card strip. */
2530
+ #${SHELL_ROOT_ID} [data-scroll="start"][data-scroll-axis="x"] {
2531
+ -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%);
2532
+ mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%); }
2533
+ #${SHELL_ROOT_ID} [data-scroll="mid"][data-scroll-axis="x"] {
2534
+ -webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%);
2535
+ mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%); }
2536
+ #${SHELL_ROOT_ID} [data-scroll="end"][data-scroll-axis="x"] {
2537
+ -webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px);
2538
+ mask-image:linear-gradient(to right, transparent 0, #000 20px); }
2539
+ /* The chevron. Its host is whatever box CONTAINS the scroller (overlay root / popover card), so it
2540
+ holds still while the content moves under it. */
2541
+ #${SHELL_ROOT_ID} .ge-scroll-cue { position:absolute; left:0; top:0; z-index:2;
2542
+ width:22px; height:22px; padding:3px; box-sizing:border-box;
2543
+ display:flex; align-items:center; justify-content:center; pointer-events:none;
2544
+ border-radius:50%; color:#fff; background:var(--shell-plaque-dark);
2545
+ box-shadow:0 2px 10px rgba(0,0,0,.45); animation:ge-scroll-cue 1.6s ease-in-out infinite; }
2546
+ @keyframes ge-scroll-cue {
2547
+ 0%,100% { transform:translateY(0); opacity:.85; }
2548
+ 50% { transform:translateY(4px); opacity:1; } }
2549
+ /* A player who has asked for stillness still needs the hint — keep the chevron, drop the bob. */
2550
+ @media (prefers-reduced-motion: reduce) {
2551
+ #${SHELL_ROOT_ID} .ge-scroll-cue { animation:none; opacity:.95; } }
2417
2552
  #${SHELL_ROOT_ID} .ge-ov-body { max-width:800px; margin:0 auto; box-sizing:border-box;
2418
2553
  padding:clamp(6px,2vh,16px) clamp(16px,4vw,24px) clamp(16px,4vh,28px); }
2419
2554
 
@@ -2775,7 +2910,9 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2775
2910
  card's font-size is the one knob (clamped for readability); everything inside is em-relative so
2776
2911
  the whole card scales as a unit. GameShell.fitModal() still transform-scales it down as a
2777
2912
  backstop for very short popouts. */
2778
- #${SHELL_ROOT_ID} .ge-modal-card { font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
2913
+ /* position:relative is here for the scroll cue: the card is the cue's containing block, so a
2914
+ capped, scrolling chip grid hints at its bottom edge rather than the screen's. */
2915
+ #${SHELL_ROOT_ID} .ge-modal-card { position:relative; font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
2779
2916
  overflow:hidden; transform-origin:center center; background:var(--shell-plaque-solid); border-radius:1.3em;
2780
2917
  display:flex; flex-direction:column; }
2781
2918
  /* ✕ pinned to the overlay corner (the screen), not the card */
@@ -3211,6 +3348,113 @@ function applyBusy(host, bar) {
3211
3348
  buy.disabled = busy || auto || !host.state.buyBonusEnabled;
3212
3349
  }
3213
3350
 
3351
+ /** Cue diameter, mirrored in the stylesheet. Kept here because the cue is positioned in JS. */
3352
+ const CUE_SIZE = 22;
3353
+ const CUE_GAP = 6;
3354
+ /** The chevron glyph, inline so it needs no icon-set entry and no font. */
3355
+ const CUE_SVG = '<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" ' +
3356
+ 'stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9l7 7 7-7"/></svg>';
3357
+ function attachScrollAffordance(el, opts = {}) {
3358
+ const axis = opts.axis ?? 'y';
3359
+ let cue = null;
3360
+ // Once the player scrolls they have discovered the gesture; re-offering it every time they
3361
+ // return to the top would nag rather than inform.
3362
+ let cueRetired = false;
3363
+ let destroyed = false;
3364
+ if (axis === 'x')
3365
+ el.dataset.scrollAxis = 'x';
3366
+ const removeCue = () => {
3367
+ cue?.remove();
3368
+ cue = null;
3369
+ };
3370
+ const showCue = () => {
3371
+ if (cue || cueRetired || opts.cue === false)
3372
+ return;
3373
+ const host = opts.cueHost ?? el.parentElement;
3374
+ if (!host)
3375
+ return;
3376
+ cue = document.createElement('div');
3377
+ cue.className = 'ge-scroll-cue';
3378
+ cue.setAttribute('aria-hidden', 'true');
3379
+ cue.innerHTML = CUE_SVG;
3380
+ host.appendChild(cue);
3381
+ positionCue();
3382
+ };
3383
+ /** Pin the cue to the bottom of the SCROLLER, not of its host. The buy-bonus overlay hangs a bet
3384
+ * bar below its scroll region, and a cue pinned to the host's bottom edge lands on top of it. */
3385
+ const positionCue = () => {
3386
+ if (!cue)
3387
+ return;
3388
+ const size = cue.offsetWidth || CUE_SIZE;
3389
+ if (axis === 'x') {
3390
+ cue.style.left = `${el.offsetLeft + el.offsetWidth - size - CUE_GAP}px`;
3391
+ cue.style.top = `${el.offsetTop + (el.offsetHeight - size) / 2}px`;
3392
+ }
3393
+ else {
3394
+ cue.style.left = `${el.offsetLeft + (el.offsetWidth - size) / 2}px`;
3395
+ cue.style.top = `${el.offsetTop + el.offsetHeight - size - CUE_GAP}px`;
3396
+ }
3397
+ };
3398
+ const sync = () => {
3399
+ if (destroyed)
3400
+ return;
3401
+ const h = axis === 'x'
3402
+ ? scrollHint({ scrollTop: el.scrollLeft, scrollHeight: el.scrollWidth, clientHeight: el.clientWidth })
3403
+ : scrollHint({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
3404
+ const edge = scrollEdge(h);
3405
+ if (edge === 'none') {
3406
+ delete el.dataset.scroll;
3407
+ removeCue();
3408
+ return;
3409
+ }
3410
+ el.dataset.scroll = edge;
3411
+ if (edge === 'start') {
3412
+ showCue();
3413
+ positionCue(); // the scroller can move/resize under a cue that is already up
3414
+ }
3415
+ else {
3416
+ removeCue();
3417
+ }
3418
+ };
3419
+ // Retirement is keyed off actual MOVEMENT, not off the event. A browser also fires `scroll` when
3420
+ // content reflows under a pinned offset, and a hint dismissed by a reflow the player never caused
3421
+ // is a hint they never saw.
3422
+ let lastPos = axis === 'x' ? el.scrollLeft : el.scrollTop;
3423
+ const onScroll = () => {
3424
+ const pos = axis === 'x' ? el.scrollLeft : el.scrollTop;
3425
+ if (pos !== lastPos) {
3426
+ lastPos = pos;
3427
+ cueRetired = true;
3428
+ removeCue();
3429
+ }
3430
+ sync();
3431
+ };
3432
+ el.addEventListener('scroll', onScroll, { passive: true });
3433
+ // Content in these regions is built asynchronously (fonts, images, a rebuilt body on resize), so
3434
+ // a single sync at mount would measure the wrong thing. ResizeObserver is absent in jsdom.
3435
+ const RO = globalThis.ResizeObserver;
3436
+ const ro = typeof RO === 'function' ? new RO(() => sync()) : null;
3437
+ if (ro) {
3438
+ ro.observe(el);
3439
+ if (el.firstElementChild)
3440
+ ro.observe(el.firstElementChild);
3441
+ }
3442
+ sync();
3443
+ return {
3444
+ sync,
3445
+ destroy() {
3446
+ if (destroyed)
3447
+ return;
3448
+ destroyed = true;
3449
+ el.removeEventListener('scroll', onScroll);
3450
+ ro?.disconnect();
3451
+ delete el.dataset.scroll;
3452
+ delete el.dataset.scrollAxis;
3453
+ removeCue();
3454
+ },
3455
+ };
3456
+ }
3457
+
3214
3458
  /** A centred CARD modal — frosted backdrop + opaque card with an accent title heading and an
3215
3459
  * overlay ✕ in the top-right. The shared chrome for every centred modal (buy-bonus confirm,
3216
3460
  * bet, autoplay, generic openModal). Append content to `body`; append full-bleed footer
@@ -3245,8 +3489,9 @@ function createCardModal(opts) {
3245
3489
  }
3246
3490
  return { root, card, body };
3247
3491
  }
3248
- /** Full-screen overlay. Returns { root, body, scroll }; append content to body.
3249
- * The `scroll` element is the scrollable container (overflow-y: auto). */
3492
+ /** Full-screen overlay. Returns { root, body, scroll, affordance }; append content to body.
3493
+ * The `scroll` element is the scrollable container (overflow-y: auto); `affordance` marks it as
3494
+ * scrollable once it overflows — call `affordance.sync()` after filling or resizing the body. */
3250
3495
  function createOverlay(opts) {
3251
3496
  const root = document.createElement('div');
3252
3497
  root.className = 'ge-shell-overlay';
@@ -3284,7 +3529,9 @@ function createOverlay(opts) {
3284
3529
  body.className = 'ge-ov-body';
3285
3530
  scroll.appendChild(body);
3286
3531
  root.append(head, scroll);
3287
- return { root, body, scroll };
3532
+ // The cue is hosted on `root`, not on `scroll`: `scroll` is the element that moves.
3533
+ const affordance = attachScrollAffordance(scroll, { cueHost: root });
3534
+ return { root, body, scroll, affordance };
3288
3535
  }
3289
3536
  /** A light-dismiss popover: a transparent full-surface layer (closes on pointerdown) holding a
3290
3537
  * card with an arrow that points at `pointer`. Append rows to `body`; call `position()` after the
@@ -3305,6 +3552,9 @@ function createPopover(opts) {
3305
3552
  // Clicks inside the card must not reach the dismiss layer.
3306
3553
  card.addEventListener('pointerdown', (e) => e.stopPropagation());
3307
3554
  root.addEventListener('pointerdown', opts.onClose);
3555
+ // The card clamps itself to `maxHeight` in position(), so the body's overflow is only knowable
3556
+ // after that runs — hence the sync at the end of position().
3557
+ const affordance = attachScrollAffordance(body, { cueHost: card });
3308
3558
  const resolveEl = (v) => typeof v === 'function' ? v() : (v ?? null);
3309
3559
  /** A rect in surface coordinates, or null when unresolved/fully zero-sized (a zero-HEIGHT rect —
3310
3560
  * e.g. a not-yet-laid-out anchor — is still considered valid, matching placePopover's own rule). */
@@ -3380,8 +3630,9 @@ function createPopover(opts) {
3380
3630
  arrow.style.display = '';
3381
3631
  arrow.style.left = `${p.arrowX / s}px`;
3382
3632
  }
3633
+ affordance.sync();
3383
3634
  };
3384
- return { root, card, body, position };
3635
+ return { root, card, body, affordance, position };
3385
3636
  }
3386
3637
 
3387
3638
  /** The bar menu, as a light-dismiss popover anchored to the burger. Rows come from the core model,
@@ -3532,7 +3783,7 @@ function buildRow(host, row, updaters, reposition) {
3532
3783
  const HOTKEYS_DEFAULT_ORDER = -0.5;
3533
3784
  const SVG_NS = 'http://www.w3.org/2000/svg';
3534
3785
  function openGameInfoModal(host) {
3535
- const { root, body, scroll } = createOverlay({
3786
+ const { root, body, scroll, affordance } = createOverlay({
3536
3787
  title: host.t('Game info'),
3537
3788
  onClose: () => host.actions.closeOverlay(),
3538
3789
  onBack: () => { root.remove(); host.actions.openMenu(); },
@@ -3558,6 +3809,8 @@ function openGameInfoModal(host) {
3558
3809
  .sort((a, b) => a.k - b.k || a.i - b.i)
3559
3810
  .forEach(({ s }) => body.appendChild(renderSection(host, s)));
3560
3811
  body.appendChild(versionFooter(host));
3812
+ // The body is filled after createOverlay returned, so its first honest measurement is here.
3813
+ affordance.sync();
3561
3814
  const LINE = 60;
3562
3815
  const PAGE = () => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
3563
3816
  const onKey = (e) => {
@@ -3950,10 +4203,13 @@ function openBuyBonusOverlay(host) {
3950
4203
  if (bonuses === false || bonuses.length === 0)
3951
4204
  return null;
3952
4205
  const st = { focusIndex: -1, confirmBonus: undefined };
3953
- const { root, body } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
4206
+ const { root, body, affordance } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
3954
4207
  root.dataset.ge = 'buybonus-overlay';
4208
+ // The strip's own X-scroll affordance, rebuilt with the grid it describes.
4209
+ let gridAffordance = null;
3955
4210
  // Re-render the grid whenever the bet changes so every card's price stays live.
3956
4211
  const renderGrid = () => {
4212
+ gridAffordance?.destroy();
3957
4213
  body.innerHTML = '';
3958
4214
  const grid = document.createElement('div');
3959
4215
  grid.className = 'ge-bb-grid';
@@ -3979,6 +4235,11 @@ function openBuyBonusOverlay(host) {
3979
4235
  else {
3980
4236
  st.focusIndex = -1;
3981
4237
  }
4238
+ // Two axes, two affordances. Below a ~340px frame the CSS stacks the cards and the OVERLAY
4239
+ // scrolls vertically (see the ge-bb-frame container query); above it the STRIP scrolls
4240
+ // horizontally. Each is attached unconditionally and stays silent on the axis that fits.
4241
+ gridAffordance = attachScrollAffordance(grid, { axis: 'x', cue: false });
4242
+ affordance.sync();
3982
4243
  };
3983
4244
  renderGrid();
3984
4245
  root.appendChild(buildBetBar(host, renderGrid)); // thin bottom footer, only as tall as the pill
@@ -4309,6 +4570,9 @@ function buildSheet(opts) {
4309
4570
  grid.appendChild(chip);
4310
4571
  }
4311
4572
  ui.body.appendChild(grid);
4573
+ // A long bet ladder is capped at 50vh and scrolls; in a 225px popout that cap bites after two
4574
+ // rows. The cue hangs off the card, which holds still while the grid moves.
4575
+ attachScrollAffordance(grid, { cueHost: ui.card });
4312
4576
  function doConfirm() {
4313
4577
  opts.onConfirm(selected);
4314
4578
  opts.onClose();
@@ -4806,11 +5070,13 @@ function removeGameShell() {
4806
5070
 
4807
5071
  exports.DEFAULT_ACCENT = DEFAULT_ACCENT;
4808
5072
  exports.DEFAULT_MENU = DEFAULT_MENU;
5073
+ exports.DISCLAIMER_LINES = DISCLAIMER_LINES;
4809
5074
  exports.GameShell = ShellController;
4810
5075
  exports.HtmlRenderer = HtmlRenderer;
4811
5076
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
4812
5077
  exports.POPOVER = POPOVER;
4813
5078
  exports.SCHEMES = SCHEMES;
5079
+ exports.SCROLL_THUMB_MIN = SCROLL_THUMB_MIN;
4814
5080
  exports.ShellController = ShellController;
4815
5081
  exports.createGameShell = createGameShell;
4816
5082
  exports.createI18n = createI18n;
@@ -4824,6 +5090,8 @@ exports.removeGameShell = removeGameShell;
4824
5090
  exports.resolveConfig = resolveConfig;
4825
5091
  exports.resolveMenu = resolveMenu;
4826
5092
  exports.resolveTheme = resolveTheme;
5093
+ exports.scrollEdge = scrollEdge;
5094
+ exports.scrollHint = scrollHint;
4827
5095
  exports.seedMenuValues = seedMenuValues;
4828
5096
  exports.socialize = socialize;
4829
5097
  //# sourceMappingURL=html.cjs.js.map