@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/pixi.d.ts CHANGED
@@ -643,7 +643,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
643
643
  tokens: ShellTokens;
644
644
  layout: ShellLayoutMode;
645
645
  soundOn: boolean;
646
- readonly engineVersion = "0.10.0";
646
+ readonly engineVersion = "0.11.1";
647
647
  readonly actions: ShellActions;
648
648
  private renderer;
649
649
  private i18n;
@@ -802,7 +802,59 @@ declare function createI18n(opts: I18nOptions): I18n;
802
802
  declare const DISCLAIMER_LINES: readonly string[];
803
803
 
804
804
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
805
- declare const PACKAGE_VERSION = "0.10.0";
805
+ declare const PACKAGE_VERSION = "0.11.1";
806
+
807
+ /**
808
+ * What a scrollable region should ADVERTISE about itself.
809
+ *
810
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
811
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
812
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
813
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
814
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
815
+ * reads that as content the player can't reach.
816
+ *
817
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
818
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
819
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
820
+ * thumb LOOKS; this decides when there is one and where it sits.
821
+ */
822
+ /**
823
+ * Shortest thumb we will draw, as a fraction of its track.
824
+ *
825
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
826
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
827
+ * and it still travels the whole track, so the position it reports remains truthful even though
828
+ * its length no longer is. That is the right trade: length is decoration, position is information.
829
+ */
830
+ declare const SCROLL_THUMB_MIN = 0.18;
831
+ /** One axis of a scroll region. Named for the Y axis because that is the common case; the
832
+ * buy-bonus strip passes its width metrics through the same fields. */
833
+ interface ScrollMetrics {
834
+ scrollTop: number;
835
+ scrollHeight: number;
836
+ clientHeight: number;
837
+ }
838
+ interface ScrollHint {
839
+ /** There is content past an edge — draw the affordance at all. */
840
+ overflowing: boolean;
841
+ /** Nothing above/left of the viewport: suppress the leading fade. */
842
+ atStart: boolean;
843
+ /** Nothing below/right of the viewport: suppress the trailing fade and the chevron. */
844
+ atEnd: boolean;
845
+ /** Thumb length as a fraction of the track, in `[SCROLL_THUMB_MIN, 1]`. */
846
+ thumbSize: number;
847
+ /** Thumb's leading edge as a fraction of the track, in `[0, 1 - thumbSize]`. */
848
+ thumbOffset: number;
849
+ /** Scrollable distance in pixels — 0 when the content fits. */
850
+ maxScroll: number;
851
+ }
852
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
853
+ declare function scrollHint(m: ScrollMetrics): ScrollHint;
854
+ /** The three-state tag both renderers put on a scroll region, so CSS and tests can name it.
855
+ * `none` when the content fits — the attribute is removed rather than set to it. */
856
+ type ScrollEdge = 'none' | 'start' | 'mid' | 'end';
857
+ declare function scrollEdge(h: ScrollHint): ScrollEdge;
806
858
 
807
859
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
808
860
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
@@ -938,5 +990,5 @@ declare function createPixiShell(config: PixiShellConfig): PixiGameShell;
938
990
  * Resolves when removed — mirrors `removePixiShell` in the legacy package. */
939
991
  declare function removePixiShell(): Promise<void>;
940
992
 
941
- export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, PACKAGE_VERSION, POPOVER, PixiRenderer, SCHEMES, ShellController, createI18n, createPixiShell, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removePixiShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
942
- export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, I18n, I18nOptions, Lang, MenuHost, MenuItem, MenuPresetId, MenuRow, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, PixiGameShell, PixiShellConfig, PixiShellSurface, PopoverPlacement, Rect as PopoverRect, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, VolumeKey, VolumeLevels, WinSection };
993
+ export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, PACKAGE_VERSION, POPOVER, PixiRenderer, SCHEMES, SCROLL_THUMB_MIN, ShellController, createI18n, createPixiShell, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removePixiShell, resolveConfig, resolveMenu, resolveTheme, scrollEdge, scrollHint, seedMenuValues, socialize };
994
+ export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, I18n, I18nOptions, Lang, MenuHost, MenuItem, MenuPresetId, MenuRow, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, PixiGameShell, PixiShellConfig, PixiShellSurface, PopoverPlacement, Rect as PopoverRect, ReplayModalOptions, ResolvedShellConfig, SafeArea, ScrollEdge, ScrollHint, ScrollMetrics, ShapeDef, Shell, ShellActions, ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, VolumeKey, VolumeLevels, WinSection };
package/dist/pixi.esm.js CHANGED
@@ -323,6 +323,20 @@ function nextTurbo(current, maxLevels) {
323
323
  return 0;
324
324
  return current >= maxLevels ? 0 : current + 1;
325
325
  }
326
+ /**
327
+ * Is a bonus buy unavailable right now?
328
+ *
329
+ * The three RUNTIME locks, in one place because they used to be in three: both bottom bars spelled
330
+ * them out for the coin, and the Shift+B hotkey spelled out a different, shorter set — so the
331
+ * keyboard opened the buy-bonus overlay mid-round and let a player stake a second bet on top of a
332
+ * round already in flight. A predicate the bars and the hotkey share cannot drift apart again.
333
+ *
334
+ * Runtime only. Whether the feature EXISTS at all is a config question (`features.buyBonus`), and
335
+ * whether this particular surface should offer it (mode, replay) belongs to the caller.
336
+ */
337
+ function bonusBuyLocked(s) {
338
+ return s.busy || s.autoplay.active || !s.buyBonusEnabled;
339
+ }
326
340
 
327
341
  /** The single brand accent (purple). The bar accent default (theme.ts) and the buy-a-bonus
328
342
  * card default both derive from this, so a rebrand only touches one constant. */
@@ -1625,7 +1639,9 @@ class KeyboardController {
1625
1639
  }
1626
1640
  break;
1627
1641
  case 'KeyB':
1628
- if (h.buyBonusEnabled && s.mode === 'base' && !s.replay) {
1642
+ // `bonusBuyLocked` is the same predicate the bar's coin uses. Without it this hotkey
1643
+ // reached past a disabled coin and opened the overlay mid-round.
1644
+ if (h.buyBonusEnabled && s.mode === 'base' && !s.replay && !bonusBuyLocked(s)) {
1629
1645
  h.openBuyBonus();
1630
1646
  return;
1631
1647
  }
@@ -1737,7 +1753,7 @@ function keyboardCapable(win = typeof window === 'undefined' ? undefined : windo
1737
1753
 
1738
1754
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1739
1755
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1740
- const PACKAGE_VERSION = '0.10.0';
1756
+ const PACKAGE_VERSION = '0.11.1';
1741
1757
 
1742
1758
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1743
1759
  function resolveConfig(config) {
@@ -2223,6 +2239,66 @@ function placePopover(anchor, surface, size, pointer = null) {
2223
2239
  return { x, y, maxH, arrowX, below };
2224
2240
  }
2225
2241
 
2242
+ /**
2243
+ * What a scrollable region should ADVERTISE about itself.
2244
+ *
2245
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
2246
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
2247
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
2248
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
2249
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
2250
+ * reads that as content the player can't reach.
2251
+ *
2252
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
2253
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
2254
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
2255
+ * thumb LOOKS; this decides when there is one and where it sits.
2256
+ */
2257
+ /** Fractions of a pixel are layout rounding, not reachable content. */
2258
+ const EPSILON = 1;
2259
+ /** Landing within half a pixel of an edge counts as arriving: a browser settles a flung scroll on
2260
+ * 199.5 of 200, and a fade left glowing over content the player has already reached reads as a
2261
+ * bug. */
2262
+ const EDGE_EPSILON = 0.5;
2263
+ /**
2264
+ * Shortest thumb we will draw, as a fraction of its track.
2265
+ *
2266
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
2267
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
2268
+ * and it still travels the whole track, so the position it reports remains truthful even though
2269
+ * its length no longer is. That is the right trade: length is decoration, position is information.
2270
+ */
2271
+ const SCROLL_THUMB_MIN = 0.18;
2272
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
2273
+ function scrollHint(m) {
2274
+ const view = Math.max(0, m.clientHeight);
2275
+ const content = Math.max(0, m.scrollHeight);
2276
+ const maxScroll = Math.max(0, content - view);
2277
+ if (maxScroll <= EPSILON || view <= 0) {
2278
+ // Both edges are "the edge" when there is nowhere to go — callers gate every fade on the
2279
+ // matching flag, so a region that fits draws nothing without needing to check `overflowing`.
2280
+ return { overflowing: false, atStart: true, atEnd: true, thumbSize: 1, thumbOffset: 0, maxScroll: 0 };
2281
+ }
2282
+ const at = Math.max(0, Math.min(maxScroll, m.scrollTop));
2283
+ const thumbSize = Math.min(1, Math.max(SCROLL_THUMB_MIN, view / content));
2284
+ const progress = at / maxScroll;
2285
+ return {
2286
+ overflowing: true,
2287
+ atStart: at <= EDGE_EPSILON,
2288
+ atEnd: maxScroll - at <= EDGE_EPSILON,
2289
+ thumbSize,
2290
+ thumbOffset: progress * (1 - thumbSize),
2291
+ maxScroll,
2292
+ };
2293
+ }
2294
+ function scrollEdge(h) {
2295
+ if (!h.overflowing)
2296
+ return 'none';
2297
+ if (h.atStart)
2298
+ return 'start';
2299
+ return h.atEnd ? 'end' : 'mid';
2300
+ }
2301
+
2226
2302
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
2227
2303
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
2228
2304
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -3867,7 +3943,12 @@ class BottomBar extends Container {
3867
3943
  if (config.features.turbo > 0) {
3868
3944
  this.turboBtn = makeTurboButton(this.host, state.turbo, () => this.onTurbo(), 40, 22);
3869
3945
  }
3870
- const buy = isBase ? (this.buildBuy(M_BUY, 9, 2) ?? undefined) : undefined;
3946
+ // On `this`, not a local: applyBusy() guards every line with `if (this.<control>)`, so a coin
3947
+ // kept in a local is drawn, laid out, tappable — and unreachable by every lock there is.
3948
+ // That is exactly how a phone player could tap SPIN and open buy-bonus on the round in
3949
+ // flight. (Safe for layout: applyFit() hands mobile to applyFitMobile() before it touches
3950
+ // `this.buy` for the wide bar's positioning.)
3951
+ this.buy = isBase ? (this.buildBuy(M_BUY, 9, 2) ?? undefined) : undefined;
3871
3952
  // level 1 — controls bar (dark)
3872
3953
  const controls = new FlexBox({
3873
3954
  direction: 'row',
@@ -3888,8 +3969,8 @@ class BottomBar extends Container {
3888
3969
  const rz = new FlexBox({ direction: 'row', align: 'center', gap: SIDE, justify: 'end' });
3889
3970
  if (this.turboBtn)
3890
3971
  rz.add(this.turboBtn);
3891
- if (buy)
3892
- rz.add(buy);
3972
+ if (this.buy)
3973
+ rz.add(this.buy);
3893
3974
  const zw = Math.max(lz.measureSize().w, rz.measureSize().w);
3894
3975
  lz.setLayoutSize(zw, undefined);
3895
3976
  rz.setLayoutSize(zw, undefined);
@@ -4055,7 +4136,7 @@ class BottomBar extends Container {
4055
4136
  if (this.autoBtn)
4056
4137
  this.autoBtn.disabled = state.busy && !auto;
4057
4138
  if (this.buy)
4058
- this.buy.disabled = state.busy || auto || !state.buyBonusEnabled;
4139
+ this.buy.disabled = bonusBuyLocked(state);
4059
4140
  if (this.betReadout && lockBet) {
4060
4141
  this.betReadout.eventMode = 'none';
4061
4142
  this.betReadout.cursor = 'default';
@@ -4148,11 +4229,207 @@ function SPIN_POP_MOBILE() {
4148
4229
  return (SPIN - M_CTRL_H) / 2; // 11
4149
4230
  }
4150
4231
 
4232
+ /** Thickness of the thumb, and how far it is inset from the edge it rides. */
4233
+ const THUMB_THICK = 4;
4234
+ const THUMB_INSET = 3;
4235
+ /** Shortest thumb we will draw in pixels — the fractional floor still degenerates on a tiny frame. */
4236
+ const THUMB_MIN_PX = 22;
4237
+ /** Depth of the edge scrim. */
4238
+ const SCRIM = 34;
4239
+ const SCRIM_ALPHA = 0.72;
4240
+ /** The overlay chrome — plaques, bar, backdrop — is scheme-independent in this shell (see
4241
+ * resolveTheme: those tokens are fixed, only the palette behind them flips). The affordance is
4242
+ * chrome, so it is a fixed neutral too: white marks over a tint of the same hue as the veil, which
4243
+ * makes the fade read as content dissolving INTO the overlay rather than as a grey band on top. */
4244
+ const SCRIM_TINT = 0x0c111c;
4245
+ const CUE_R = 11;
4246
+ const CUE_BOB = 4;
4247
+ const CUE_PERIOD = 1600; // ms
4248
+ /**
4249
+ * The scrim's alpha ramp, densest AT the edge and clear by `depth` inward.
4250
+ *
4251
+ * Exported because the direction is the whole point and is easy to get backwards: an inverted ramp
4252
+ * draws a hard-edged dark band floating over the content instead of a fade into the frame, and it
4253
+ * looks deliberate enough that nobody questions it. The squared falloff keeps the outer half of the
4254
+ * ramp faint, so the scrim reads as content thinning out rather than as a panel laid on top.
4255
+ */
4256
+ function scrimRamp(depth) {
4257
+ const slices = Math.max(1, Math.round(depth));
4258
+ const thickness = depth / slices;
4259
+ const out = [];
4260
+ for (let i = 0; i < slices; i++) {
4261
+ const offset = i * thickness;
4262
+ const t = 1 - (offset + thickness / 2) / depth; // 1 at the edge → 0 at `depth`
4263
+ out.push({ offset, thickness, alpha: SCRIM_ALPHA * t * t });
4264
+ }
4265
+ return out;
4266
+ }
4267
+ class ScrollAffordanceView extends Container {
4268
+ thumb = new Graphics();
4269
+ scrim = new Graphics();
4270
+ cue = new Container();
4271
+ ticker;
4272
+ bounds = null;
4273
+ cueOn = false;
4274
+ // Once the region has moved the player knows the gesture; re-offering it on every return to the
4275
+ // top would nag rather than inform.
4276
+ cueRetired = false;
4277
+ cueBaseY = 0;
4278
+ elapsed = 0;
4279
+ tick;
4280
+ constructor(ticker) {
4281
+ super();
4282
+ this.ticker = ticker;
4283
+ this.eventMode = 'none'; // decoration: never intercept the drag it is advertising
4284
+ this.addChild(this.scrim, this.thumb, this.cue);
4285
+ this.buildCue();
4286
+ this.visible = false;
4287
+ }
4288
+ /** Where the thumb is drawn, in the parent's space — `null` when nothing is drawn. */
4289
+ get thumbBounds() {
4290
+ return this.visible ? this.bounds : null;
4291
+ }
4292
+ get cueVisible() {
4293
+ return this.visible && this.cueOn;
4294
+ }
4295
+ /** Re-draw for a viewport and a hint. Cheap enough to call on every scroll frame. */
4296
+ update(vp, hint) {
4297
+ if (this.destroyed)
4298
+ return;
4299
+ if (!hint.overflowing) {
4300
+ this.visible = false;
4301
+ this.bounds = null;
4302
+ this.showCue(false);
4303
+ return;
4304
+ }
4305
+ this.visible = true;
4306
+ // Any movement off the start retires the cue permanently.
4307
+ if (!hint.atStart)
4308
+ this.cueRetired = true;
4309
+ this.drawThumb(vp, hint);
4310
+ this.drawScrim(vp, hint);
4311
+ this.placeCue(vp);
4312
+ this.showCue(hint.atStart && !this.cueRetired);
4313
+ }
4314
+ drawThumb(vp, hint) {
4315
+ const along = vp.axis === 'y' ? vp.h : vp.w;
4316
+ const track = Math.max(0, along - THUMB_INSET * 2);
4317
+ const len = Math.max(Math.min(track, THUMB_MIN_PX), track * hint.thumbSize);
4318
+ // Recover the travel from the (possibly floored) length so the thumb still reaches both ends.
4319
+ const travel = Math.max(0, track - len);
4320
+ const progress = hint.thumbSize < 1 ? hint.thumbOffset / (1 - hint.thumbSize) : 0;
4321
+ const at = THUMB_INSET + travel * progress;
4322
+ this.bounds =
4323
+ vp.axis === 'y'
4324
+ ? { x: vp.x + vp.w - THUMB_INSET - THUMB_THICK, y: vp.y + at, w: THUMB_THICK, h: len }
4325
+ : { x: vp.x + at, y: vp.y + vp.h - THUMB_INSET - THUMB_THICK, w: len, h: THUMB_THICK };
4326
+ const b = this.bounds;
4327
+ this.thumb.clear();
4328
+ // A faint track behind the thumb: on a dark overlay a lone thumb can read as a stray highlight;
4329
+ // with the groove behind it, it reads as a scrollbar.
4330
+ if (vp.axis === 'y') {
4331
+ this.thumb
4332
+ .roundRect(b.x, vp.y + THUMB_INSET, THUMB_THICK, track, THUMB_THICK / 2)
4333
+ .fill({ color: 0xffffff, alpha: 0.1 });
4334
+ }
4335
+ else {
4336
+ this.thumb
4337
+ .roundRect(vp.x + THUMB_INSET, b.y, track, THUMB_THICK, THUMB_THICK / 2)
4338
+ .fill({ color: 0xffffff, alpha: 0.1 });
4339
+ }
4340
+ this.thumb.roundRect(b.x, b.y, b.w, b.h, THUMB_THICK / 2).fill({ color: 0xffffff, alpha: 0.55 });
4341
+ }
4342
+ /** Stacked 1px slices rather than a gradient fill: no texture, no backend-specific paths, and at
4343
+ * one device pixel per slice there is nothing left to band. */
4344
+ drawScrim(vp, hint) {
4345
+ this.scrim.clear();
4346
+ const depth = Math.min(SCRIM, (vp.axis === 'y' ? vp.h : vp.w) / 3);
4347
+ if (depth <= 0)
4348
+ return;
4349
+ for (const { offset, thickness, alpha } of scrimRamp(depth)) {
4350
+ // `offset` is measured INWARD from whichever edge is being faded.
4351
+ if (!hint.atEnd) {
4352
+ if (vp.axis === 'y')
4353
+ this.scrim.rect(vp.x, vp.y + vp.h - offset - thickness, vp.w, thickness);
4354
+ else
4355
+ this.scrim.rect(vp.x + vp.w - offset - thickness, vp.y, thickness, vp.h);
4356
+ this.scrim.fill({ color: SCRIM_TINT, alpha });
4357
+ }
4358
+ if (!hint.atStart) {
4359
+ if (vp.axis === 'y')
4360
+ this.scrim.rect(vp.x, vp.y + offset, vp.w, thickness);
4361
+ else
4362
+ this.scrim.rect(vp.x + offset, vp.y, thickness, vp.h);
4363
+ this.scrim.fill({ color: SCRIM_TINT, alpha });
4364
+ }
4365
+ }
4366
+ }
4367
+ buildCue() {
4368
+ const disc = new Graphics();
4369
+ disc.circle(0, 0, CUE_R).fill({ color: 0x0b0f18, alpha: 0.9 });
4370
+ disc.circle(0, 0, CUE_R).stroke({ color: 0xffffff, alpha: 0.22, width: 1 });
4371
+ const size = Math.round(CUE_R * 1.3);
4372
+ const glyph = makeIcon('chevronDown', size, '#ffffff');
4373
+ glyph.position.set(-size / 2, -size / 2);
4374
+ this.cue.addChild(disc, glyph);
4375
+ this.cue.visible = false;
4376
+ }
4377
+ placeCue(vp) {
4378
+ // Bottom-centre for a vertical region; hugging the trailing edge for a horizontal one.
4379
+ if (vp.axis === 'y') {
4380
+ this.cue.x = vp.x + vp.w / 2;
4381
+ this.cueBaseY = vp.y + vp.h - CUE_R - 6;
4382
+ }
4383
+ else {
4384
+ this.cue.x = vp.x + vp.w - CUE_R - 6;
4385
+ this.cueBaseY = vp.y + vp.h / 2;
4386
+ this.cue.rotation = -Math.PI / 2; // chevron points the way the strip moves
4387
+ }
4388
+ this.cue.y = this.cueBaseY;
4389
+ }
4390
+ showCue(on) {
4391
+ if (on === this.cueOn)
4392
+ return;
4393
+ this.cueOn = on;
4394
+ this.cue.visible = on;
4395
+ if (on)
4396
+ this.startBob();
4397
+ else
4398
+ this.stopBob();
4399
+ }
4400
+ startBob() {
4401
+ // A player who asked for stillness still needs the hint; they just do not need it moving.
4402
+ if (this.tick || !this.ticker || prefersReducedMotion())
4403
+ return;
4404
+ this.elapsed = 0;
4405
+ this.tick = (t) => {
4406
+ this.elapsed += t.deltaMS;
4407
+ const phase = (this.elapsed % CUE_PERIOD) / CUE_PERIOD;
4408
+ this.cue.y = this.cueBaseY + Math.sin(phase * Math.PI * 2) * (CUE_BOB / 2) + CUE_BOB / 2;
4409
+ };
4410
+ this.ticker.add(this.tick);
4411
+ }
4412
+ stopBob() {
4413
+ if (!this.tick)
4414
+ return;
4415
+ this.ticker?.remove(this.tick);
4416
+ this.tick = undefined;
4417
+ this.cue.y = this.cueBaseY;
4418
+ }
4419
+ destroy(options) {
4420
+ this.stopBob();
4421
+ super.destroy(options);
4422
+ }
4423
+ }
4424
+
4151
4425
  /** A vertically-scrolling viewport — mask + drag + mouse-wheel, the Pixi analogue of the
4152
4426
  * overlay's `overflow-y:auto` scroll region. Add content to `.content`; call `refresh()`
4153
4427
  * after content changes or a resize. */
4154
4428
  class ScrollBox extends Container {
4155
4429
  content = new Container();
4430
+ /** The drawn "this scrolls" marks — thumb, edge scrim, chevron. Public so the overlay that owns
4431
+ * the box (and the tests) can read what a player would see. */
4432
+ affordance;
4156
4433
  maskG = new Graphics();
4157
4434
  viewW = 0;
4158
4435
  viewH = 0;
@@ -4163,10 +4440,13 @@ class ScrollBox extends Container {
4163
4440
  moved = false;
4164
4441
  canvas;
4165
4442
  wheelHandler;
4166
- constructor(canvas) {
4443
+ constructor(canvas, ticker) {
4167
4444
  super();
4168
4445
  this.canvas = canvas;
4169
- this.addChild(this.content);
4446
+ this.affordance = new ScrollAffordanceView(ticker);
4447
+ // Added AFTER content, and never masked: the marks describe the viewport, so clipping them to
4448
+ // the content they describe would hide them exactly when the content overflows.
4449
+ this.addChild(this.content, this.affordance);
4170
4450
  // maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
4171
4451
  // graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
4172
4452
  // what an earlier version of this comment claimed — see the correction in refresh() below.)
@@ -4221,6 +4501,7 @@ class ScrollBox extends Container {
4221
4501
  }
4222
4502
  this.eventMode = scrollable ? 'static' : 'passive';
4223
4503
  this.setScroll(this.scrollY);
4504
+ this.syncAffordance();
4224
4505
  }
4225
4506
  /** Max scrollable distance (0 when content fits) — exposed for tests. */
4226
4507
  get maxScrollY() {
@@ -4237,6 +4518,12 @@ class ScrollBox extends Container {
4237
4518
  return;
4238
4519
  this.scrollY = Math.max(0, Math.min(this.maxScroll, y)) || 0; // || 0 converts -0 to 0
4239
4520
  this.content.y = this.scrollY === 0 ? 0 : -this.scrollY;
4521
+ this.syncAffordance();
4522
+ }
4523
+ syncAffordance() {
4524
+ if (this.destroyed || this.affordance.destroyed)
4525
+ return;
4526
+ this.affordance.update({ x: 0, y: 0, w: this.viewW, h: this.viewH, axis: 'y' }, scrollHint({ scrollTop: this.scrollY, scrollHeight: this.viewH + this.maxScroll, clientHeight: this.viewH }));
4240
4527
  }
4241
4528
  onDown = (e) => {
4242
4529
  this.dragging = true;
@@ -4807,7 +5094,7 @@ class Overlay extends Container {
4807
5094
  this.host = host;
4808
5095
  this.opts = opts;
4809
5096
  this.tag = opts.tag;
4810
- this.scroll = new ScrollBox(host.canvas);
5097
+ this.scroll = new ScrollBox(host.canvas, host.ticker);
4811
5098
  this.addChild(this.veil, this.scroll, this.header);
4812
5099
  this.veil.eventMode = 'static'; // swallow clicks to the game behind
4813
5100
  this.resize(host.screenW, host.screenH);
@@ -5530,6 +5817,10 @@ class BuyBonusOverlay extends Container {
5530
5817
  header = new Container();
5531
5818
  strip = new Container(); // cards live here (drag-scrollable when wide)
5532
5819
  stripMask = new Graphics();
5820
+ /** "the strip moves" — the drawn marks over the card band. Unmasked and outside `strip`, so a
5821
+ * drag moves the cards under it while the marks hold still. Public for the same reason
5822
+ * ScrollBox.affordance is: it is the only readable account of what a player sees here. */
5823
+ scrollCue;
5533
5824
  footer = new Container();
5534
5825
  confirm;
5535
5826
  /** The bonus shown in the current confirm dialog (set by openConfirm, cleared by removeConfirm). */
@@ -5566,7 +5857,8 @@ class BuyBonusOverlay extends Container {
5566
5857
  super();
5567
5858
  this.host = host;
5568
5859
  this.bonuses = bonuses;
5569
- this.addChild(this.veil, this.strip, this.header, this.footer);
5860
+ this.scrollCue = new ScrollAffordanceView(host.ticker);
5861
+ this.addChild(this.veil, this.strip, this.scrollCue, this.header, this.footer);
5570
5862
  this.veil.eventMode = 'static';
5571
5863
  this.strip.mask = this.stripMask;
5572
5864
  this.addChild(this.stripMask);
@@ -5599,6 +5891,7 @@ class BuyBonusOverlay extends Container {
5599
5891
  this.strip.position.x = this.dragBaseX + this.dragX;
5600
5892
  else
5601
5893
  this.strip.position.y = this.dragBaseY + this.dragX;
5894
+ this.syncScrollCue();
5602
5895
  };
5603
5896
  onDragUp = () => {
5604
5897
  this.dragging = false;
@@ -5616,6 +5909,14 @@ class BuyBonusOverlay extends Container {
5616
5909
  this.dragging = false;
5617
5910
  this.strip.eventMode = 'auto'; // visual-only; its children (cards) are still hit-tested in-band
5618
5911
  this.strip.position.set(baseX, baseY);
5912
+ this.syncScrollCue();
5913
+ }
5914
+ /** Re-draw the band's scroll marks from the current drag state. `dragX` runs [-dragMax, 0] as the
5915
+ * strip is pulled, so the scroll position is its negation. */
5916
+ syncScrollCue() {
5917
+ if (this.destroyed || this.scrollCue.destroyed)
5918
+ return;
5919
+ this.scrollCue.update({ x: 0, y: this.bandTop, w: this.w, h: this.bandH, axis: this.dragAxis }, scrollHint({ scrollTop: -this.dragX, scrollHeight: this.bandH + this.dragMax, clientHeight: this.bandH }));
5619
5920
  }
5620
5921
  resize(w, h) {
5621
5922
  this.w = w;
@@ -7196,5 +7497,5 @@ function removePixiShell() {
7196
7497
  return shell.destroy();
7197
7498
  }
7198
7499
 
7199
- export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, PACKAGE_VERSION, POPOVER, PixiRenderer, SCHEMES, ShellController, createI18n, createPixiShell, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removePixiShell, resolveConfig, resolveMenu, resolveTheme, seedMenuValues, socialize };
7500
+ export { DEFAULT_ACCENT, DEFAULT_MENU, DISCLAIMER_LINES, PACKAGE_VERSION, POPOVER, PixiRenderer, SCHEMES, SCROLL_THUMB_MIN, ShellController, createI18n, createPixiShell, createShell, isPresetId, normalizeLang, placePopover, popoverWidth, rangeBounds, removePixiShell, resolveConfig, resolveMenu, resolveTheme, scrollEdge, scrollHint, seedMenuValues, socialize };
7200
7501
  //# sourceMappingURL=pixi.esm.js.map