@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.cjs.js +280 -10
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +56 -4
- package/dist/html.esm.js +278 -11
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +81 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +56 -4
- package/dist/index.esm.js +79 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +314 -10
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +56 -4
- package/dist/pixi.esm.js +312 -11
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/index.ts +3 -0
- package/src/core/keyboard.ts +7 -1
- package/src/core/scrollHint.ts +91 -0
- package/src/core/state.ts +15 -0
- package/src/core/version.ts +1 -1
- package/src/ui/html/components/BottomBar.ts +2 -1
- package/src/ui/html/components/BuyBonus.ts +11 -1
- package/src/ui/html/components/GameInfo.ts +3 -1
- package/src/ui/html/components/pickers.ts +4 -0
- package/src/ui/html/primitives.ts +16 -5
- package/src/ui/html/scroll-affordance.ts +144 -0
- package/src/ui/html/shell.css.ts +65 -1
- package/src/ui/pixi/components/BottomBar.ts +9 -3
- package/src/ui/pixi/components/BuyBonus.ts +20 -1
- package/src/ui/pixi/primitives/overlay.ts +1 -1
- package/src/ui/pixi/primitives/scroll-affordance.ts +244 -0
- package/src/ui/pixi/primitives/scroll.ts +21 -3
package/package.json
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -38,3 +38,6 @@ export { createI18n, socialize, normalizeLang } from './i18n';
|
|
|
38
38
|
export { DISCLAIMER_LINES } from './locales';
|
|
39
39
|
export type { Lang, I18n, I18nOptions } from './i18n';
|
|
40
40
|
export { PACKAGE_VERSION } from './version';
|
|
41
|
+
|
|
42
|
+
export { scrollHint, scrollEdge, SCROLL_THUMB_MIN } from './scrollHint';
|
|
43
|
+
export type { ScrollHint, ScrollMetrics, ScrollEdge } from './scrollHint';
|
package/src/core/keyboard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShellState } from './types';
|
|
2
|
+
import { bonusBuyLocked } from './state';
|
|
2
3
|
|
|
3
4
|
export interface KeyboardHost {
|
|
4
5
|
readonly state: ShellState;
|
|
@@ -153,7 +154,12 @@ export class KeyboardController {
|
|
|
153
154
|
if (h.turboLevels > 0 && !s.replay) { h.cycleTurbo(); return; }
|
|
154
155
|
break;
|
|
155
156
|
case 'KeyB':
|
|
156
|
-
|
|
157
|
+
// `bonusBuyLocked` is the same predicate the bar's coin uses. Without it this hotkey
|
|
158
|
+
// reached past a disabled coin and opened the overlay mid-round.
|
|
159
|
+
if (h.buyBonusEnabled && s.mode === 'base' && !s.replay && !bonusBuyLocked(s)) {
|
|
160
|
+
h.openBuyBonus();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
157
163
|
break;
|
|
158
164
|
case 'KeyI':
|
|
159
165
|
h.openInfo(); return;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a scrollable region should ADVERTISE about itself.
|
|
3
|
+
*
|
|
4
|
+
* A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
|
|
5
|
+
* content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
|
|
6
|
+
* row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
|
|
7
|
+
* overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
|
|
8
|
+
* player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
|
|
9
|
+
* reads that as content the player can't reach.
|
|
10
|
+
*
|
|
11
|
+
* The maths lives here, apart from both renderers, for one reason: the DOM shell reads
|
|
12
|
+
* `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
|
|
13
|
+
* two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
|
|
14
|
+
* thumb LOOKS; this decides when there is one and where it sits.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Fractions of a pixel are layout rounding, not reachable content. */
|
|
18
|
+
const EPSILON = 1;
|
|
19
|
+
|
|
20
|
+
/** Landing within half a pixel of an edge counts as arriving: a browser settles a flung scroll on
|
|
21
|
+
* 199.5 of 200, and a fade left glowing over content the player has already reached reads as a
|
|
22
|
+
* bug. */
|
|
23
|
+
const EDGE_EPSILON = 0.5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shortest thumb we will draw, as a fraction of its track.
|
|
27
|
+
*
|
|
28
|
+
* The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
|
|
29
|
+
* as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
|
|
30
|
+
* and it still travels the whole track, so the position it reports remains truthful even though
|
|
31
|
+
* its length no longer is. That is the right trade: length is decoration, position is information.
|
|
32
|
+
*/
|
|
33
|
+
export const SCROLL_THUMB_MIN = 0.18;
|
|
34
|
+
|
|
35
|
+
/** One axis of a scroll region. Named for the Y axis because that is the common case; the
|
|
36
|
+
* buy-bonus strip passes its width metrics through the same fields. */
|
|
37
|
+
export interface ScrollMetrics {
|
|
38
|
+
scrollTop: number;
|
|
39
|
+
scrollHeight: number;
|
|
40
|
+
clientHeight: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ScrollHint {
|
|
44
|
+
/** There is content past an edge — draw the affordance at all. */
|
|
45
|
+
overflowing: boolean;
|
|
46
|
+
/** Nothing above/left of the viewport: suppress the leading fade. */
|
|
47
|
+
atStart: boolean;
|
|
48
|
+
/** Nothing below/right of the viewport: suppress the trailing fade and the chevron. */
|
|
49
|
+
atEnd: boolean;
|
|
50
|
+
/** Thumb length as a fraction of the track, in `[SCROLL_THUMB_MIN, 1]`. */
|
|
51
|
+
thumbSize: number;
|
|
52
|
+
/** Thumb's leading edge as a fraction of the track, in `[0, 1 - thumbSize]`. */
|
|
53
|
+
thumbOffset: number;
|
|
54
|
+
/** Scrollable distance in pixels — 0 when the content fits. */
|
|
55
|
+
maxScroll: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
|
|
59
|
+
export function scrollHint(m: ScrollMetrics): ScrollHint {
|
|
60
|
+
const view = Math.max(0, m.clientHeight);
|
|
61
|
+
const content = Math.max(0, m.scrollHeight);
|
|
62
|
+
const maxScroll = Math.max(0, content - view);
|
|
63
|
+
|
|
64
|
+
if (maxScroll <= EPSILON || view <= 0) {
|
|
65
|
+
// Both edges are "the edge" when there is nowhere to go — callers gate every fade on the
|
|
66
|
+
// matching flag, so a region that fits draws nothing without needing to check `overflowing`.
|
|
67
|
+
return { overflowing: false, atStart: true, atEnd: true, thumbSize: 1, thumbOffset: 0, maxScroll: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const at = Math.max(0, Math.min(maxScroll, m.scrollTop));
|
|
71
|
+
const thumbSize = Math.min(1, Math.max(SCROLL_THUMB_MIN, view / content));
|
|
72
|
+
const progress = at / maxScroll;
|
|
73
|
+
return {
|
|
74
|
+
overflowing: true,
|
|
75
|
+
atStart: at <= EDGE_EPSILON,
|
|
76
|
+
atEnd: maxScroll - at <= EDGE_EPSILON,
|
|
77
|
+
thumbSize,
|
|
78
|
+
thumbOffset: progress * (1 - thumbSize),
|
|
79
|
+
maxScroll,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The three-state tag both renderers put on a scroll region, so CSS and tests can name it.
|
|
84
|
+
* `none` when the content fits — the attribute is removed rather than set to it. */
|
|
85
|
+
export type ScrollEdge = 'none' | 'start' | 'mid' | 'end';
|
|
86
|
+
|
|
87
|
+
export function scrollEdge(h: ScrollHint): ScrollEdge {
|
|
88
|
+
if (!h.overflowing) return 'none';
|
|
89
|
+
if (h.atStart) return 'start';
|
|
90
|
+
return h.atEnd ? 'end' : 'mid';
|
|
91
|
+
}
|
package/src/core/state.ts
CHANGED
|
@@ -41,3 +41,18 @@ export function nextTurbo(current: number, maxLevels: number): number {
|
|
|
41
41
|
if (maxLevels <= 0) return 0;
|
|
42
42
|
return current >= maxLevels ? 0 : current + 1;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Is a bonus buy unavailable right now?
|
|
47
|
+
*
|
|
48
|
+
* The three RUNTIME locks, in one place because they used to be in three: both bottom bars spelled
|
|
49
|
+
* them out for the coin, and the Shift+B hotkey spelled out a different, shorter set — so the
|
|
50
|
+
* keyboard opened the buy-bonus overlay mid-round and let a player stake a second bet on top of a
|
|
51
|
+
* round already in flight. A predicate the bars and the hotkey share cannot drift apart again.
|
|
52
|
+
*
|
|
53
|
+
* Runtime only. Whether the feature EXISTS at all is a config question (`features.buyBonus`), and
|
|
54
|
+
* whether this particular surface should offer it (mode, replay) belongs to the caller.
|
|
55
|
+
*/
|
|
56
|
+
export function bonusBuyLocked(s: ShellState): boolean {
|
|
57
|
+
return s.busy || s.autoplay.active || !s.buyBonusEnabled;
|
|
58
|
+
}
|
package/src/core/version.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ShellHost } from '@/core/renderer';
|
|
|
2
2
|
import { effectiveAccent } from '@/core/colors';
|
|
3
3
|
import { icon, type IconName } from '../icons';
|
|
4
4
|
import { BUY_BONUS_ART, BUY_BONUS_SOCIAL_ART, BUY_BONUS_DISABLED_ART } from '../../buy-bonus-art';
|
|
5
|
+
import { bonusBuyLocked } from '@/core/state';
|
|
5
6
|
|
|
6
7
|
/** A floating labelled money readout (balance/win/bet). */
|
|
7
8
|
function readout(ge: string, label: string, value: string): HTMLElement {
|
|
@@ -295,5 +296,5 @@ function applyBusy(host: ShellHost, bar: HTMLElement): void {
|
|
|
295
296
|
if (betVal) betVal.classList.toggle('ge-disabled', lockBet);
|
|
296
297
|
const buy = bar.querySelector('[data-ge="buybonus"]') as HTMLButtonElement | null;
|
|
297
298
|
// disabled for the whole autoplay run (not just per-spin busy) so it doesn't flicker/pulse
|
|
298
|
-
if (buy) buy.disabled =
|
|
299
|
+
if (buy) buy.disabled = bonusBuyLocked(host.state);
|
|
299
300
|
}
|
|
@@ -3,6 +3,7 @@ import type { BonusOption } from '@/core/types';
|
|
|
3
3
|
import { betDir } from '@/core/keyboard';
|
|
4
4
|
import { effectiveAccent, contrastText } from '@/core/colors';
|
|
5
5
|
import { createOverlay, createCardModal } from '../primitives';
|
|
6
|
+
import { attachScrollAffordance, type ScrollAffordance } from '../scroll-affordance';
|
|
6
7
|
import { icon, type IconName } from '../icons';
|
|
7
8
|
|
|
8
9
|
/** Mutable state shared between the overlay DOM and the onKey handler. */
|
|
@@ -21,11 +22,15 @@ export function openBuyBonusOverlay(host: ShellHost): { root: HTMLElement; onKey
|
|
|
21
22
|
|
|
22
23
|
const st: OverlayState = { focusIndex: -1, confirmBonus: undefined };
|
|
23
24
|
|
|
24
|
-
const { root, body } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
|
|
25
|
+
const { root, body, affordance } = createOverlay({ title: host.t('Buy bonus'), onClose: () => host.actions.closeOverlay() });
|
|
25
26
|
root.dataset.ge = 'buybonus-overlay';
|
|
26
27
|
|
|
28
|
+
// The strip's own X-scroll affordance, rebuilt with the grid it describes.
|
|
29
|
+
let gridAffordance: ScrollAffordance | null = null;
|
|
30
|
+
|
|
27
31
|
// Re-render the grid whenever the bet changes so every card's price stays live.
|
|
28
32
|
const renderGrid = (): void => {
|
|
33
|
+
gridAffordance?.destroy();
|
|
29
34
|
body.innerHTML = '';
|
|
30
35
|
const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
|
|
31
36
|
// Card count drives the width-fit clamp in CSS (each card is 18em; N cards must fit the frame
|
|
@@ -46,6 +51,11 @@ export function openBuyBonusOverlay(host: ShellHost): { root: HTMLElement; onKey
|
|
|
46
51
|
} else {
|
|
47
52
|
st.focusIndex = -1;
|
|
48
53
|
}
|
|
54
|
+
// Two axes, two affordances. Below a ~340px frame the CSS stacks the cards and the OVERLAY
|
|
55
|
+
// scrolls vertically (see the ge-bb-frame container query); above it the STRIP scrolls
|
|
56
|
+
// horizontally. Each is attached unconditionally and stays silent on the axis that fits.
|
|
57
|
+
gridAffordance = attachScrollAffordance(grid, { axis: 'x', cue: false });
|
|
58
|
+
affordance.sync();
|
|
49
59
|
};
|
|
50
60
|
|
|
51
61
|
renderGrid();
|
|
@@ -17,7 +17,7 @@ export interface GameInfoModal {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function openGameInfoModal(host: ShellHost): GameInfoModal {
|
|
20
|
-
const { root, body, scroll } = createOverlay({
|
|
20
|
+
const { root, body, scroll, affordance } = createOverlay({
|
|
21
21
|
title: host.t('Game info'),
|
|
22
22
|
onClose: () => host.actions.closeOverlay(),
|
|
23
23
|
onBack: () => { root.remove(); host.actions.openMenu(); },
|
|
@@ -46,6 +46,8 @@ export function openGameInfoModal(host: ShellHost): GameInfoModal {
|
|
|
46
46
|
.forEach(({ s }) => body.appendChild(renderSection(host, s)));
|
|
47
47
|
|
|
48
48
|
body.appendChild(versionFooter(host));
|
|
49
|
+
// The body is filled after createOverlay returned, so its first honest measurement is here.
|
|
50
|
+
affordance.sync();
|
|
49
51
|
|
|
50
52
|
const LINE = 60;
|
|
51
53
|
const PAGE = (): number => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ShellHost } from '@/core/renderer';
|
|
2
2
|
import { createCardModal } from '../primitives';
|
|
3
|
+
import { attachScrollAffordance } from '../scroll-affordance';
|
|
3
4
|
|
|
4
5
|
interface Choice { id: string; label: string }
|
|
5
6
|
|
|
@@ -71,6 +72,9 @@ function buildSheet(opts: SheetOpts): Sheet {
|
|
|
71
72
|
chips.push(chip); grid.appendChild(chip);
|
|
72
73
|
}
|
|
73
74
|
ui.body.appendChild(grid);
|
|
75
|
+
// A long bet ladder is capped at 50vh and scrolls; in a 225px popout that cap bites after two
|
|
76
|
+
// rows. The cue hangs off the card, which holds still while the grid moves.
|
|
77
|
+
attachScrollAffordance(grid, { cueHost: ui.card });
|
|
74
78
|
|
|
75
79
|
function doConfirm(): void {
|
|
76
80
|
opts.onConfirm(selected);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { icon } from './icons';
|
|
2
2
|
import { placePopover, popoverWidth, POPOVER, type Rect } from '@/core/popover';
|
|
3
|
+
import { attachScrollAffordance, type ScrollAffordance } from './scroll-affordance';
|
|
3
4
|
|
|
4
5
|
/** Render a (possibly socialised) two-word label across two lines — the BUY BONUS badge.
|
|
5
6
|
* Shared so the bottom-bar button and the Game-info control legend break identically. */
|
|
@@ -53,9 +54,12 @@ export interface OverlayOpts {
|
|
|
53
54
|
onBack?: () => void;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
/** Full-screen overlay. Returns { root, body, scroll }; append content to body.
|
|
57
|
-
* The `scroll` element is the scrollable container (overflow-y: auto)
|
|
58
|
-
|
|
57
|
+
/** Full-screen overlay. Returns { root, body, scroll, affordance }; append content to body.
|
|
58
|
+
* The `scroll` element is the scrollable container (overflow-y: auto); `affordance` marks it as
|
|
59
|
+
* scrollable once it overflows — call `affordance.sync()` after filling or resizing the body. */
|
|
60
|
+
export function createOverlay(opts: OverlayOpts): {
|
|
61
|
+
root: HTMLDivElement; body: HTMLDivElement; scroll: HTMLDivElement; affordance: ScrollAffordance;
|
|
62
|
+
} {
|
|
59
63
|
const root = document.createElement('div');
|
|
60
64
|
root.className = 'ge-shell-overlay';
|
|
61
65
|
const head = document.createElement('div');
|
|
@@ -82,7 +86,9 @@ export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body:
|
|
|
82
86
|
const body = document.createElement('div'); body.className = 'ge-ov-body';
|
|
83
87
|
scroll.appendChild(body);
|
|
84
88
|
root.append(head, scroll);
|
|
85
|
-
|
|
89
|
+
// The cue is hosted on `root`, not on `scroll`: `scroll` is the element that moves.
|
|
90
|
+
const affordance = attachScrollAffordance(scroll, { cueHost: root });
|
|
91
|
+
return { root, body, scroll, affordance };
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
export interface PopoverOpts {
|
|
@@ -126,6 +132,7 @@ export function createPopover(opts: PopoverOpts): {
|
|
|
126
132
|
root: HTMLDivElement;
|
|
127
133
|
card: HTMLDivElement;
|
|
128
134
|
body: HTMLDivElement;
|
|
135
|
+
affordance: ScrollAffordance;
|
|
129
136
|
position(): void;
|
|
130
137
|
} {
|
|
131
138
|
const root = document.createElement('div');
|
|
@@ -143,6 +150,9 @@ export function createPopover(opts: PopoverOpts): {
|
|
|
143
150
|
// Clicks inside the card must not reach the dismiss layer.
|
|
144
151
|
card.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
145
152
|
root.addEventListener('pointerdown', opts.onClose);
|
|
153
|
+
// The card clamps itself to `maxHeight` in position(), so the body's overflow is only knowable
|
|
154
|
+
// after that runs — hence the sync at the end of position().
|
|
155
|
+
const affordance = attachScrollAffordance(body, { cueHost: card });
|
|
146
156
|
|
|
147
157
|
const resolveEl = (v: HTMLElement | null | (() => HTMLElement | null) | undefined): HTMLElement | null =>
|
|
148
158
|
typeof v === 'function' ? v() : (v ?? null);
|
|
@@ -222,6 +232,7 @@ export function createPopover(opts: PopoverOpts): {
|
|
|
222
232
|
arrow.style.display = '';
|
|
223
233
|
arrow.style.left = `${p.arrowX / s}px`;
|
|
224
234
|
}
|
|
235
|
+
affordance.sync();
|
|
225
236
|
};
|
|
226
|
-
return { root, card, body, position };
|
|
237
|
+
return { root, card, body, affordance, position };
|
|
227
238
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { scrollHint, scrollEdge } from '@/core/scrollHint';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make a scrollable region say so.
|
|
5
|
+
*
|
|
6
|
+
* The DOM half of the affordance. It writes two dataset marks the stylesheet reads —
|
|
7
|
+
* `data-scroll="start|mid|end"` and `data-scroll-axis` — and owns the one part CSS alone cannot do:
|
|
8
|
+
* a chevron that appears on an overflowing region and retires once the player has scrolled.
|
|
9
|
+
*
|
|
10
|
+
* The cue is a SIBLING of the scroller, not a child. A child would scroll away with the content on
|
|
11
|
+
* the very first drag, which is precisely when it still has something to say.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ScrollAxis = 'y' | 'x';
|
|
15
|
+
|
|
16
|
+
export interface ScrollAffordanceOpts {
|
|
17
|
+
/** Which axis overflows. `x` is the buy-bonus card strip; everything else is `y`. */
|
|
18
|
+
axis?: ScrollAxis;
|
|
19
|
+
/** Where the chevron is appended. Defaults to the scroller's parent, which is what every shell
|
|
20
|
+
* surface wants — pass one explicitly when the parent is itself clipped. */
|
|
21
|
+
cueHost?: HTMLElement | null;
|
|
22
|
+
/** Draw the chevron at all (default true). The buy-bonus card strip opts out: it already carries
|
|
23
|
+
* its own ‹ › arrows, and a second, differently-shaped hint for the same gesture reads as two
|
|
24
|
+
* controls rather than one. The thumb and the edge fade still apply. */
|
|
25
|
+
cue?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScrollAffordance {
|
|
29
|
+
/** Re-measure and repaint the marks. Call after content or size changes; `scroll` is automatic. */
|
|
30
|
+
sync(): void;
|
|
31
|
+
/** Detach listeners and restore the element. */
|
|
32
|
+
destroy(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cue diameter, mirrored in the stylesheet. Kept here because the cue is positioned in JS. */
|
|
36
|
+
const CUE_SIZE = 22;
|
|
37
|
+
const CUE_GAP = 6;
|
|
38
|
+
|
|
39
|
+
/** The chevron glyph, inline so it needs no icon-set entry and no font. */
|
|
40
|
+
const CUE_SVG =
|
|
41
|
+
'<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" ' +
|
|
42
|
+
'stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9l7 7 7-7"/></svg>';
|
|
43
|
+
|
|
44
|
+
export function attachScrollAffordance(el: HTMLElement, opts: ScrollAffordanceOpts = {}): ScrollAffordance {
|
|
45
|
+
const axis: ScrollAxis = opts.axis ?? 'y';
|
|
46
|
+
let cue: HTMLElement | null = null;
|
|
47
|
+
// Once the player scrolls they have discovered the gesture; re-offering it every time they
|
|
48
|
+
// return to the top would nag rather than inform.
|
|
49
|
+
let cueRetired = false;
|
|
50
|
+
let destroyed = false;
|
|
51
|
+
|
|
52
|
+
if (axis === 'x') el.dataset.scrollAxis = 'x';
|
|
53
|
+
|
|
54
|
+
const removeCue = (): void => {
|
|
55
|
+
cue?.remove();
|
|
56
|
+
cue = null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const showCue = (): void => {
|
|
60
|
+
if (cue || cueRetired || opts.cue === false) return;
|
|
61
|
+
const host = opts.cueHost ?? el.parentElement;
|
|
62
|
+
if (!host) return;
|
|
63
|
+
cue = document.createElement('div');
|
|
64
|
+
cue.className = 'ge-scroll-cue';
|
|
65
|
+
cue.setAttribute('aria-hidden', 'true');
|
|
66
|
+
cue.innerHTML = CUE_SVG;
|
|
67
|
+
host.appendChild(cue);
|
|
68
|
+
positionCue();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Pin the cue to the bottom of the SCROLLER, not of its host. The buy-bonus overlay hangs a bet
|
|
72
|
+
* bar below its scroll region, and a cue pinned to the host's bottom edge lands on top of it. */
|
|
73
|
+
const positionCue = (): void => {
|
|
74
|
+
if (!cue) return;
|
|
75
|
+
const size = cue.offsetWidth || CUE_SIZE;
|
|
76
|
+
if (axis === 'x') {
|
|
77
|
+
cue.style.left = `${el.offsetLeft + el.offsetWidth - size - CUE_GAP}px`;
|
|
78
|
+
cue.style.top = `${el.offsetTop + (el.offsetHeight - size) / 2}px`;
|
|
79
|
+
} else {
|
|
80
|
+
cue.style.left = `${el.offsetLeft + (el.offsetWidth - size) / 2}px`;
|
|
81
|
+
cue.style.top = `${el.offsetTop + el.offsetHeight - size - CUE_GAP}px`;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const sync = (): void => {
|
|
86
|
+
if (destroyed) return;
|
|
87
|
+
const h =
|
|
88
|
+
axis === 'x'
|
|
89
|
+
? scrollHint({ scrollTop: el.scrollLeft, scrollHeight: el.scrollWidth, clientHeight: el.clientWidth })
|
|
90
|
+
: scrollHint({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
|
91
|
+
const edge = scrollEdge(h);
|
|
92
|
+
if (edge === 'none') {
|
|
93
|
+
delete el.dataset.scroll;
|
|
94
|
+
removeCue();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
el.dataset.scroll = edge;
|
|
98
|
+
if (edge === 'start') {
|
|
99
|
+
showCue();
|
|
100
|
+
positionCue(); // the scroller can move/resize under a cue that is already up
|
|
101
|
+
} else {
|
|
102
|
+
removeCue();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Retirement is keyed off actual MOVEMENT, not off the event. A browser also fires `scroll` when
|
|
107
|
+
// content reflows under a pinned offset, and a hint dismissed by a reflow the player never caused
|
|
108
|
+
// is a hint they never saw.
|
|
109
|
+
let lastPos = axis === 'x' ? el.scrollLeft : el.scrollTop;
|
|
110
|
+
const onScroll = (): void => {
|
|
111
|
+
const pos = axis === 'x' ? el.scrollLeft : el.scrollTop;
|
|
112
|
+
if (pos !== lastPos) {
|
|
113
|
+
lastPos = pos;
|
|
114
|
+
cueRetired = true;
|
|
115
|
+
removeCue();
|
|
116
|
+
}
|
|
117
|
+
sync();
|
|
118
|
+
};
|
|
119
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
120
|
+
|
|
121
|
+
// Content in these regions is built asynchronously (fonts, images, a rebuilt body on resize), so
|
|
122
|
+
// a single sync at mount would measure the wrong thing. ResizeObserver is absent in jsdom.
|
|
123
|
+
const RO = (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
|
124
|
+
const ro = typeof RO === 'function' ? new RO(() => sync()) : null;
|
|
125
|
+
if (ro) {
|
|
126
|
+
ro.observe(el);
|
|
127
|
+
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
sync();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
sync,
|
|
134
|
+
destroy(): void {
|
|
135
|
+
if (destroyed) return;
|
|
136
|
+
destroyed = true;
|
|
137
|
+
el.removeEventListener('scroll', onScroll);
|
|
138
|
+
ro?.disconnect();
|
|
139
|
+
delete el.dataset.scroll;
|
|
140
|
+
delete el.dataset.scrollAxis;
|
|
141
|
+
removeCue();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/ui/html/shell.css.ts
CHANGED
|
@@ -168,6 +168,68 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
|
|
|
168
168
|
transition:background .12s ease, color .12s ease; }
|
|
169
169
|
#${SHELL_ROOT_ID} .ge-ov-nav:hover { background:var(--shell-plaque-glass); color:var(--shell-accent); }
|
|
170
170
|
#${SHELL_ROOT_ID} .ge-ov-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden; }
|
|
171
|
+
|
|
172
|
+
/* ═══ scroll affordance — "there is more, and you can reach it" ═══════════════════════════════
|
|
173
|
+
A Stake popout is 400×225. Game info holds twelve screens there, buy-bonus stacks its cards, and
|
|
174
|
+
the menu popover hides its last row. All of them scrolled already; none of them said so, and
|
|
175
|
+
Stake rejected the build for it. macOS is the aggravating factor — its overlay scrollbars stay
|
|
176
|
+
invisible until something is ALREADY scrolling, so the one moment a player needs the hint is the
|
|
177
|
+
one moment the OS withholds it. Styling the scrollbar at all opts out of that behaviour.
|
|
178
|
+
|
|
179
|
+
Three layers, all keyed off data-scroll (written by attachScrollAffordance):
|
|
180
|
+
1. a persistent thumb — the standing "this scrolls" mark;
|
|
181
|
+
2. a mask fade at whichever edge hides content — the mask applies to the element's own box, so
|
|
182
|
+
it does NOT travel with the content the way a child gradient would;
|
|
183
|
+
3. a chevron, shown only in the start state and retired for good on the first scroll.
|
|
184
|
+
The end state deliberately gets no trailing fade: there is nothing left below to hint at. */
|
|
185
|
+
/* Order matters here, and not for cascade reasons. Chromium honours the STANDARD scrollbar
|
|
186
|
+
properties when they are present and then ignores ::-webkit-scrollbar entirely — and on macOS the
|
|
187
|
+
standard properties leave the scrollbar in overlay mode, i.e. invisible until it is already
|
|
188
|
+
moving. Defining ::-webkit-scrollbar with a width is what switches that scroller to a classic,
|
|
189
|
+
always-painted, still-draggable scrollbar. So the standard properties are quarantined behind an
|
|
190
|
+
@supports that only Firefox (which has no ::-webkit-scrollbar) satisfies. */
|
|
191
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
192
|
+
#${SHELL_ROOT_ID} [data-scroll] { scrollbar-width:thin;
|
|
193
|
+
scrollbar-color:var(--shell-scrollbar,rgba(255,255,255,.34)) transparent; } }
|
|
194
|
+
#${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar { width:6px; height:6px; }
|
|
195
|
+
#${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-track { background:transparent; }
|
|
196
|
+
#${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb { border-radius:999px;
|
|
197
|
+
background:var(--shell-scrollbar,rgba(255,255,255,.34)); }
|
|
198
|
+
#${SHELL_ROOT_ID} [data-scroll]::-webkit-scrollbar-thumb:hover { background:var(--shell-accent); }
|
|
199
|
+
/* Vertical fade. The stop pair is the same on both edges; only which edges are opaque changes.
|
|
200
|
+
mask-composite-free on purpose — a single multi-stop gradient handles the two-edge case. */
|
|
201
|
+
#${SHELL_ROOT_ID} [data-scroll="start"]:not([data-scroll-axis="x"]) {
|
|
202
|
+
-webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%);
|
|
203
|
+
mask-image:linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%); }
|
|
204
|
+
#${SHELL_ROOT_ID} [data-scroll="mid"]:not([data-scroll-axis="x"]) {
|
|
205
|
+
-webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%);
|
|
206
|
+
mask-image:linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 34px), transparent 100%); }
|
|
207
|
+
#${SHELL_ROOT_ID} [data-scroll="end"]:not([data-scroll-axis="x"]) {
|
|
208
|
+
-webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 22px);
|
|
209
|
+
mask-image:linear-gradient(to bottom, transparent 0, #000 22px); }
|
|
210
|
+
/* Horizontal fade — the buy-bonus card strip. */
|
|
211
|
+
#${SHELL_ROOT_ID} [data-scroll="start"][data-scroll-axis="x"] {
|
|
212
|
+
-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%);
|
|
213
|
+
mask-image:linear-gradient(to right, #000 calc(100% - 30px), transparent 100%); }
|
|
214
|
+
#${SHELL_ROOT_ID} [data-scroll="mid"][data-scroll-axis="x"] {
|
|
215
|
+
-webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%);
|
|
216
|
+
mask-image:linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 30px), transparent 100%); }
|
|
217
|
+
#${SHELL_ROOT_ID} [data-scroll="end"][data-scroll-axis="x"] {
|
|
218
|
+
-webkit-mask-image:linear-gradient(to right, transparent 0, #000 20px);
|
|
219
|
+
mask-image:linear-gradient(to right, transparent 0, #000 20px); }
|
|
220
|
+
/* The chevron. Its host is whatever box CONTAINS the scroller (overlay root / popover card), so it
|
|
221
|
+
holds still while the content moves under it. */
|
|
222
|
+
#${SHELL_ROOT_ID} .ge-scroll-cue { position:absolute; left:0; top:0; z-index:2;
|
|
223
|
+
width:22px; height:22px; padding:3px; box-sizing:border-box;
|
|
224
|
+
display:flex; align-items:center; justify-content:center; pointer-events:none;
|
|
225
|
+
border-radius:50%; color:#fff; background:var(--shell-plaque-dark);
|
|
226
|
+
box-shadow:0 2px 10px rgba(0,0,0,.45); animation:ge-scroll-cue 1.6s ease-in-out infinite; }
|
|
227
|
+
@keyframes ge-scroll-cue {
|
|
228
|
+
0%,100% { transform:translateY(0); opacity:.85; }
|
|
229
|
+
50% { transform:translateY(4px); opacity:1; } }
|
|
230
|
+
/* A player who has asked for stillness still needs the hint — keep the chevron, drop the bob. */
|
|
231
|
+
@media (prefers-reduced-motion: reduce) {
|
|
232
|
+
#${SHELL_ROOT_ID} .ge-scroll-cue { animation:none; opacity:.95; } }
|
|
171
233
|
#${SHELL_ROOT_ID} .ge-ov-body { max-width:800px; margin:0 auto; box-sizing:border-box;
|
|
172
234
|
padding:clamp(6px,2vh,16px) clamp(16px,4vw,24px) clamp(16px,4vh,28px); }
|
|
173
235
|
|
|
@@ -529,7 +591,9 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
|
|
|
529
591
|
card's font-size is the one knob (clamped for readability); everything inside is em-relative so
|
|
530
592
|
the whole card scales as a unit. GameShell.fitModal() still transform-scales it down as a
|
|
531
593
|
backstop for very short popouts. */
|
|
532
|
-
|
|
594
|
+
/* position:relative is here for the scroll cue: the card is the cue's containing block, so a
|
|
595
|
+
capped, scrolling chip grid hints at its bottom edge rather than the screen's. */
|
|
596
|
+
#${SHELL_ROOT_ID} .ge-modal-card { position:relative; font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
|
|
533
597
|
overflow:hidden; transform-origin:center center; background:var(--shell-plaque-solid); border-radius:1.3em;
|
|
534
598
|
display:flex; flex-direction:column; }
|
|
535
599
|
/* ✕ pinned to the overlay corner (the screen), not the card */
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../primitives/widgets';
|
|
16
16
|
import type { SpinAutoplayMode } from '../primitives/widgets';
|
|
17
17
|
import type { IconName } from '../icons';
|
|
18
|
+
import { bonusBuyLocked } from '@/core/state';
|
|
18
19
|
|
|
19
20
|
// ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
|
|
20
21
|
/** Which of the disc's three faces the autoplay state calls for — see `SpinAutoplayMode`. A run
|
|
@@ -425,7 +426,12 @@ export class BottomBar extends Container {
|
|
|
425
426
|
if (config.features.turbo > 0) {
|
|
426
427
|
this.turboBtn = makeTurboButton(this.host, state.turbo, () => this.onTurbo(), 40, 22);
|
|
427
428
|
}
|
|
428
|
-
|
|
429
|
+
// On `this`, not a local: applyBusy() guards every line with `if (this.<control>)`, so a coin
|
|
430
|
+
// kept in a local is drawn, laid out, tappable — and unreachable by every lock there is.
|
|
431
|
+
// That is exactly how a phone player could tap SPIN and open buy-bonus on the round in
|
|
432
|
+
// flight. (Safe for layout: applyFit() hands mobile to applyFitMobile() before it touches
|
|
433
|
+
// `this.buy` for the wide bar's positioning.)
|
|
434
|
+
this.buy = isBase ? (this.buildBuy(M_BUY, 9, 2) ?? undefined) : undefined;
|
|
429
435
|
|
|
430
436
|
// level 1 — controls bar (dark)
|
|
431
437
|
const controls = new FlexBox({
|
|
@@ -445,7 +451,7 @@ export class BottomBar extends Container {
|
|
|
445
451
|
if (this.autoBtn) lz.add(this.autoBtn);
|
|
446
452
|
const rz = new FlexBox({ direction: 'row', align: 'center', gap: SIDE, justify: 'end' });
|
|
447
453
|
if (this.turboBtn) rz.add(this.turboBtn);
|
|
448
|
-
if (buy) rz.add(buy);
|
|
454
|
+
if (this.buy) rz.add(this.buy);
|
|
449
455
|
const zw = Math.max(lz.measureSize().w, rz.measureSize().w);
|
|
450
456
|
lz.setLayoutSize(zw, undefined);
|
|
451
457
|
rz.setLayoutSize(zw, undefined);
|
|
@@ -608,7 +614,7 @@ export class BottomBar extends Container {
|
|
|
608
614
|
if (this.betDown) this.betDown.disabled = lockBet || i <= 0;
|
|
609
615
|
if (this.spin) this.spin.disabled = state.busy && !auto;
|
|
610
616
|
if (this.autoBtn) this.autoBtn.disabled = state.busy && !auto;
|
|
611
|
-
if (this.buy) this.buy.disabled = state
|
|
617
|
+
if (this.buy) this.buy.disabled = bonusBuyLocked(state);
|
|
612
618
|
if (this.betReadout && lockBet) {
|
|
613
619
|
this.betReadout.eventMode = 'none';
|
|
614
620
|
this.betReadout.cursor = 'default';
|
|
@@ -10,6 +10,8 @@ import { navButton } from '../primitives/overlay';
|
|
|
10
10
|
import { clamp } from '../primitives/overlay';
|
|
11
11
|
import { attachHover } from '../primitives/widgets';
|
|
12
12
|
import { roundedPath } from '../primitives/flex';
|
|
13
|
+
import { ScrollAffordanceView } from '../primitives/scroll-affordance';
|
|
14
|
+
import { scrollHint } from '@/core/scrollHint';
|
|
13
15
|
|
|
14
16
|
/** Below this frame height (px), a wide/landscape popout (e.g. Popout S 400×225) stacks its cards
|
|
15
17
|
* vertically and scrolls — like mobile — so descriptions stay readable instead of shrinking to a
|
|
@@ -43,6 +45,10 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
43
45
|
private header = new Container();
|
|
44
46
|
private strip = new Container(); // cards live here (drag-scrollable when wide)
|
|
45
47
|
private stripMask = new Graphics();
|
|
48
|
+
/** "the strip moves" — the drawn marks over the card band. Unmasked and outside `strip`, so a
|
|
49
|
+
* drag moves the cards under it while the marks hold still. Public for the same reason
|
|
50
|
+
* ScrollBox.affordance is: it is the only readable account of what a player sees here. */
|
|
51
|
+
readonly scrollCue: ScrollAffordanceView;
|
|
46
52
|
private footer = new Container();
|
|
47
53
|
private confirm?: Container;
|
|
48
54
|
/** The bonus shown in the current confirm dialog (set by openConfirm, cleared by removeConfirm). */
|
|
@@ -80,7 +86,8 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
80
86
|
super();
|
|
81
87
|
this.host = host;
|
|
82
88
|
this.bonuses = bonuses;
|
|
83
|
-
this.
|
|
89
|
+
this.scrollCue = new ScrollAffordanceView(host.ticker);
|
|
90
|
+
this.addChild(this.veil, this.strip, this.scrollCue, this.header, this.footer);
|
|
84
91
|
this.veil.eventMode = 'static';
|
|
85
92
|
this.strip.mask = this.stripMask;
|
|
86
93
|
this.addChild(this.stripMask);
|
|
@@ -108,6 +115,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
108
115
|
this.dragX = Math.max(-this.dragMax, Math.min(0, this.dragBase + (cur - this.dragFrom)));
|
|
109
116
|
if (this.dragAxis === 'x') this.strip.position.x = this.dragBaseX + this.dragX;
|
|
110
117
|
else this.strip.position.y = this.dragBaseY + this.dragX;
|
|
118
|
+
this.syncScrollCue();
|
|
111
119
|
};
|
|
112
120
|
private onDragUp = (): void => {
|
|
113
121
|
this.dragging = false;
|
|
@@ -126,6 +134,17 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
126
134
|
this.dragging = false;
|
|
127
135
|
this.strip.eventMode = 'auto'; // visual-only; its children (cards) are still hit-tested in-band
|
|
128
136
|
this.strip.position.set(baseX, baseY);
|
|
137
|
+
this.syncScrollCue();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Re-draw the band's scroll marks from the current drag state. `dragX` runs [-dragMax, 0] as the
|
|
141
|
+
* strip is pulled, so the scroll position is its negation. */
|
|
142
|
+
private syncScrollCue(): void {
|
|
143
|
+
if (this.destroyed || this.scrollCue.destroyed) return;
|
|
144
|
+
this.scrollCue.update(
|
|
145
|
+
{ x: 0, y: this.bandTop, w: this.w, h: this.bandH, axis: this.dragAxis },
|
|
146
|
+
scrollHint({ scrollTop: -this.dragX, scrollHeight: this.bandH + this.dragMax, clientHeight: this.bandH }),
|
|
147
|
+
);
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
resize(w: number, h: number): void {
|
|
@@ -72,7 +72,7 @@ export class Overlay extends Container implements ShellLayer {
|
|
|
72
72
|
this.host = host;
|
|
73
73
|
this.opts = opts;
|
|
74
74
|
this.tag = opts.tag;
|
|
75
|
-
this.scroll = new ScrollBox(host.canvas);
|
|
75
|
+
this.scroll = new ScrollBox(host.canvas, host.ticker);
|
|
76
76
|
this.addChild(this.veil, this.scroll, this.header);
|
|
77
77
|
this.veil.eventMode = 'static'; // swallow clicks to the game behind
|
|
78
78
|
this.resize(host.screenW, host.screenH);
|