@energy8platform/shell 0.2.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.
Files changed (67) hide show
  1. package/dist/html.cjs.js +3894 -0
  2. package/dist/html.cjs.js.map +1 -0
  3. package/dist/html.d.ts +616 -0
  4. package/dist/html.esm.js +3879 -0
  5. package/dist/html.esm.js.map +1 -0
  6. package/dist/index.cjs.js +1593 -0
  7. package/dist/index.cjs.js.map +1 -0
  8. package/dist/index.d.ts +554 -0
  9. package/dist/index.esm.js +1582 -0
  10. package/dist/index.esm.js.map +1 -0
  11. package/dist/pixi.cjs.js +5740 -0
  12. package/dist/pixi.cjs.js.map +1 -0
  13. package/dist/pixi.d.ts +682 -0
  14. package/dist/pixi.esm.js +5726 -0
  15. package/dist/pixi.esm.js.map +1 -0
  16. package/package.json +79 -0
  17. package/src/core/EventEmitter.ts +55 -0
  18. package/src/core/ShellController.ts +234 -0
  19. package/src/core/colors.ts +32 -0
  20. package/src/core/fonts-digits.ts +12 -0
  21. package/src/core/fonts.ts +13 -0
  22. package/src/core/format.ts +39 -0
  23. package/src/core/i18n.ts +96 -0
  24. package/src/core/index.ts +35 -0
  25. package/src/core/keyboard.ts +229 -0
  26. package/src/core/locales.ts +864 -0
  27. package/src/core/motion.ts +10 -0
  28. package/src/core/renderer.ts +121 -0
  29. package/src/core/state.ts +31 -0
  30. package/src/core/theme.ts +81 -0
  31. package/src/core/types.ts +269 -0
  32. package/src/core/version.ts +3 -0
  33. package/src/ui/html/HtmlRenderer.ts +292 -0
  34. package/src/ui/html/components/BottomBar.ts +240 -0
  35. package/src/ui/html/components/BuyBonus.ts +326 -0
  36. package/src/ui/html/components/GameInfo.ts +384 -0
  37. package/src/ui/html/components/Modal.ts +36 -0
  38. package/src/ui/html/components/ReplayModal.ts +58 -0
  39. package/src/ui/html/components/Settings.ts +59 -0
  40. package/src/ui/html/components/pickers.ts +146 -0
  41. package/src/ui/html/icons-preview.svg +224 -0
  42. package/src/ui/html/icons.ts +31 -0
  43. package/src/ui/html/index.ts +36 -0
  44. package/src/ui/html/motion-dom.ts +29 -0
  45. package/src/ui/html/primitives.ts +85 -0
  46. package/src/ui/html/shell.css.ts +528 -0
  47. package/src/ui/html/theme-css.ts +21 -0
  48. package/src/ui/pixi/PixiRenderer.ts +350 -0
  49. package/src/ui/pixi/components/BottomBar.ts +477 -0
  50. package/src/ui/pixi/components/BuyBonus.ts +763 -0
  51. package/src/ui/pixi/components/GameInfo.ts +559 -0
  52. package/src/ui/pixi/components/Modal.ts +40 -0
  53. package/src/ui/pixi/components/ReplayModal.ts +80 -0
  54. package/src/ui/pixi/components/Settings.ts +117 -0
  55. package/src/ui/pixi/components/pickers.ts +170 -0
  56. package/src/ui/pixi/context.ts +52 -0
  57. package/src/ui/pixi/icons.ts +45 -0
  58. package/src/ui/pixi/index.ts +56 -0
  59. package/src/ui/pixi/motion-pixi.ts +58 -0
  60. package/src/ui/pixi/pixi-icon.ts +119 -0
  61. package/src/ui/pixi/primitives/card.ts +227 -0
  62. package/src/ui/pixi/primitives/controls.ts +243 -0
  63. package/src/ui/pixi/primitives/flex.ts +335 -0
  64. package/src/ui/pixi/primitives/overlay.ts +181 -0
  65. package/src/ui/pixi/primitives/scroll.ts +117 -0
  66. package/src/ui/pixi/primitives/widgets.ts +680 -0
  67. package/src/ui/pixi/text.ts +131 -0
@@ -0,0 +1,292 @@
1
+ import type { ShellRenderer, ShellHost, OverlayRequest, OverlayHandle } from '@/core/renderer';
2
+ import type { ShellTokens } from '@/core/theme';
3
+ import { SHELL_CSS, SHELL_ROOT_ID } from './shell.css';
4
+ import { buildThemeVars } from './theme-css';
5
+ import { countUp } from './motion-dom';
6
+ import { renderBottomBar } from './components/BottomBar';
7
+ import { openSettingsModal } from './components/Settings';
8
+ import { openGameInfoModal } from './components/GameInfo';
9
+ import { openBuyBonusOverlay } from './components/BuyBonus';
10
+ import { openBetModal, openAutoplayModal } from './components/pickers';
11
+ import { buildModal } from './components/Modal';
12
+ import { buildReplayModal } from './components/ReplayModal';
13
+
14
+ export interface HtmlRendererOptions {
15
+ mount: HTMLElement;
16
+ }
17
+
18
+ const REMOVE_FADE_MS = 300;
19
+
20
+ /** Count-up the value span (.ge-rd-val) of a readout, leaving its label untouched.
21
+ * Returns the count-up canceler so the renderer can stop it before the node is replaced. */
22
+ function animateReadout(el: HTMLElement, from: number, to: number, fmt: (n: number) => string): () => void {
23
+ const val = el.querySelector('.ge-rd-val') as HTMLElement | null;
24
+ if (!val) { el.textContent = fmt(to); return () => {}; }
25
+ return countUp(val, from, to, fmt);
26
+ }
27
+
28
+ export class HtmlRenderer implements ShellRenderer {
29
+ private host!: ShellHost;
30
+ private mountEl: HTMLElement;
31
+ private root!: HTMLElement;
32
+ private styleEl!: HTMLStyleElement;
33
+ private barHost = document.createElement('div');
34
+ private modalHost = document.createElement('div');
35
+ private ro: ResizeObserver | null = null;
36
+ private moneyAnims: Array<() => void> = [];
37
+ private modalOnKey: ((e: KeyboardEvent) => boolean) | undefined;
38
+ private destroyed = false;
39
+
40
+ /** MutationObserver for buy-bonus confirm fit — fires fitModals() when nodes are added
41
+ * inside the modalHost (e.g. the confirm dialog appended after the grid is open). */
42
+ private mutObs: MutationObserver | null = null;
43
+
44
+ static readonly BAR_REF_WIDTH = 840;
45
+ static readonly BAR_MIN_SCALE = 0.5;
46
+ static readonly MODAL_FIT = 0.86;
47
+
48
+ constructor(opts: HtmlRendererOptions) {
49
+ this.mountEl = opts.mount;
50
+ }
51
+
52
+ mount(host: ShellHost): void {
53
+ this.host = host;
54
+ this.styleEl = document.createElement('style');
55
+ this.styleEl.textContent = SHELL_CSS;
56
+ this.root = document.createElement('div');
57
+ this.root.id = SHELL_ROOT_ID;
58
+ this.mountEl.append(this.styleEl, this.root);
59
+ this.barHost.className = 'ge-shell-barhost';
60
+ this.modalHost.className = 'ge-shell-modalhost';
61
+ this.root.append(this.barHost, this.modalHost);
62
+ this.observeLayout();
63
+ // Install a MutationObserver on modalHost (childList only, not attributes) so that when
64
+ // a buy-bonus confirm dialog is appended inside an already-open overlay, fitModals() fires
65
+ // and scales it correctly. Watching attributes would cause a transform→mutation loop.
66
+ this.mutObs = new MutationObserver(() => { if (!this.destroyed) this.fitModals(); });
67
+ this.mutObs.observe(this.modalHost, { childList: true, subtree: true });
68
+ if (typeof document !== 'undefined' && document.fonts) {
69
+ document.fonts.ready.then(() => { if (!this.destroyed) this.applyFitScale(); });
70
+ }
71
+ }
72
+
73
+ applyTheme(tokens: ShellTokens): void {
74
+ this.root.setAttribute('style', buildThemeVars(tokens));
75
+ }
76
+
77
+ renderBar(): void {
78
+ if (this.destroyed) return;
79
+ this.cancelMoneyAnims();
80
+ this.root.classList.toggle('ge-mobile', this.host.layout === 'mobile');
81
+ this.barHost.innerHTML = '';
82
+ this.barHost.appendChild(renderBottomBar(this.host));
83
+ this.applyFitScale();
84
+ }
85
+
86
+ setLayout(): void {
87
+ this.renderBar();
88
+ }
89
+
90
+ animateMoney(field: 'balance' | 'win', from: number, to: number): void {
91
+ const fmt = (n: number) => this.host.formatCurrency(n, field === 'win');
92
+ const el = this.barHost.querySelector(`[data-ge="${field}"]`) as HTMLElement | null;
93
+ if (el) this.moneyAnims.push(animateReadout(el, from, to, fmt));
94
+ }
95
+
96
+ openOverlay(req: OverlayRequest): OverlayHandle | void {
97
+ const built = this.buildOverlay(req);
98
+ if (!built) return;
99
+ this.showModal(built.root, built.onKey);
100
+ return { onKey: built.onKey, close: () => this.closeOverlay() };
101
+ }
102
+
103
+ closeOverlay(): void {
104
+ this.modalOnKey = undefined;
105
+ this.modalHost.innerHTML = '';
106
+ }
107
+
108
+ refreshSoundIcon?(_on: boolean): void {
109
+ // Settings registers via host.setSoundRefresh; nothing extra here
110
+ }
111
+
112
+ destroy(): Promise<void> {
113
+ if (this.destroyed) return Promise.resolve();
114
+ this.destroyed = true;
115
+ this.ro?.disconnect();
116
+ this.ro = null;
117
+ this.mutObs?.disconnect();
118
+ this.mutObs = null;
119
+ this.cancelMoneyAnims();
120
+ this.root.classList.add('ge-shell-hidden');
121
+ return new Promise<void>((resolve) => {
122
+ setTimeout(() => {
123
+ this.root.remove();
124
+ this.styleEl.remove();
125
+ resolve();
126
+ }, REMOVE_FADE_MS);
127
+ });
128
+ }
129
+
130
+ /** Trigger a bar fit-scale pass (used by tests that stub geometry after the initial render). */
131
+ fitBar(): void { this.applyFitScale(); }
132
+
133
+ // ── private ────────────────────────────────────────────────────────────────
134
+
135
+ private cancelMoneyAnims(): void {
136
+ for (const cancel of this.moneyAnims) cancel();
137
+ this.moneyAnims = [];
138
+ }
139
+
140
+ private buildOverlay(req: OverlayRequest): { root: HTMLElement; onKey?: (e: KeyboardEvent) => boolean } | null {
141
+ switch (req.kind) {
142
+ case 'settings': {
143
+ const root = openSettingsModal(this.host);
144
+ return { root };
145
+ }
146
+ case 'gameInfo': {
147
+ const { root, onKey } = openGameInfoModal(this.host);
148
+ return { root, onKey };
149
+ }
150
+ case 'buyBonus': {
151
+ const result = openBuyBonusOverlay(this.host);
152
+ if (!result) return null;
153
+ return { root: result.root, onKey: result.onKey };
154
+ }
155
+ case 'betPicker': {
156
+ const { root, onKey } = openBetModal(this.host);
157
+ return { root, onKey };
158
+ }
159
+ case 'autoplayPicker': {
160
+ const { root, onKey } = openAutoplayModal(this.host);
161
+ return { root, onKey };
162
+ }
163
+ case 'replay': {
164
+ const opts = req.opts;
165
+ const reopen = (): void => { this.openReplayInternal(opts); };
166
+ const root = buildReplayModal(this.host, opts, reopen);
167
+ return { root };
168
+ }
169
+ case 'modal': {
170
+ const root = buildModal(req.opts);
171
+ return { root, onKey: req.opts.onKey };
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Opens a replay modal and shows it — used as the reopen callback after START REPLAY. */
177
+ private openReplayInternal(opts: import('@/core/types').ReplayModalOptions): void {
178
+ if (this.destroyed) return;
179
+ const reopen = (): void => { this.openReplayInternal(opts); };
180
+ const root = buildReplayModal(this.host, opts, reopen);
181
+ this.showModal(root);
182
+ }
183
+
184
+ private showModal(el: HTMLElement, onKey?: (e: KeyboardEvent) => boolean): void {
185
+ // Drop focus from any open shell control so a stray Space/Enter doesn't re-activate it
186
+ const active = document.activeElement as HTMLElement | null;
187
+ if (active && this.root.contains(active)) active.blur();
188
+ this.modalHost.innerHTML = '';
189
+ this.modalHost.appendChild(el);
190
+ this.modalOnKey = onKey;
191
+ this.fitModals();
192
+ }
193
+
194
+ /** Uniformly scale every open centred card modal (.ge-sheet) down so it fits a short/narrow
195
+ * popout. Covers pickers, generic + replay modals, AND the buy-bonus confirm (which is hosted
196
+ * inside the overlay, not directly in modalHost). */
197
+ fitModals(): void {
198
+ if (this.destroyed) return;
199
+ this.modalHost.querySelectorAll('.ge-sheet').forEach((el) => this.fitSheet(el as HTMLElement));
200
+ }
201
+
202
+ private fitSheet(root: HTMLElement): void {
203
+ const card = root.querySelector('.ge-modal-card') as HTMLElement | null;
204
+ if (!card) return;
205
+ card.style.transform = '';
206
+ const availW = root.clientWidth, availH = root.clientHeight;
207
+ const w = card.offsetWidth, h = card.offsetHeight;
208
+ if (w <= 0 || h <= 0 || availW <= 0 || availH <= 0) return;
209
+ const fit = HtmlRenderer.MODAL_FIT;
210
+ const s = Math.min(1, (availW * fit) / w, (availH * fit) / h);
211
+ if (s < 0.999) card.style.transform = `scale(${s.toFixed(4)})`;
212
+ }
213
+
214
+ private applyFitScale(): void {
215
+ if (this.destroyed) return;
216
+ const host = this.barHost;
217
+ const bar = host.querySelector('.ge-shell-bottom') as HTMLElement | null;
218
+ if (!bar) return;
219
+ // reset to baseline (idempotent)
220
+ host.classList.remove('ge-fit');
221
+ host.style.transform = '';
222
+ host.style.transformOrigin = '';
223
+ // clear any zoom from a prior pass. We zoom the whole dark PANEL (so the bar surface shrinks with
224
+ // its content on a narrow popout), plus BUY BONUS + a lifted WIN pill, which live outside it.
225
+ for (const el of host.querySelectorAll('.ge-bar-panel, .ge-shell-buybonus')) {
226
+ (el as HTMLElement).style.transform = '';
227
+ (el as HTMLElement).style.transformOrigin = '';
228
+ (el as HTMLElement).style.removeProperty('zoom');
229
+ }
230
+ if (this.host.layout === 'mobile') {
231
+ // First shrink long numbers per-readout (info pill balance/win, the total-win slot) so the
232
+ // buttons row stays full-size; then, only if a row STILL overflows (tiny phones), scale the
233
+ // whole stack as a last resort.
234
+ this.fitReadouts();
235
+ let need = 0;
236
+ for (const row of Array.from(bar.children) as HTMLElement[]) need = Math.max(need, row.scrollWidth);
237
+ const avail = bar.clientWidth;
238
+ if (need > avail + 1 && avail > 0) {
239
+ host.style.transformOrigin = 'bottom left';
240
+ host.style.transform = `scale(${Math.max(0.4, avail / need).toFixed(4)})`;
241
+ }
242
+ return;
243
+ }
244
+ const zoomBar = (z: number): void => {
245
+ const v = z < 0.999 ? z.toFixed(4) : '';
246
+ const set = (el: Element | null): void => {
247
+ if (!el) return;
248
+ if (v) (el as HTMLElement).style.setProperty('zoom', v);
249
+ else (el as HTMLElement).style.removeProperty('zoom');
250
+ };
251
+ // zoom the whole panel (surface + content, incl. the inline WIN pill, shrink together);
252
+ // BUY BONUS sits outside the panel, so zoom it too.
253
+ set(host.querySelector('.ge-bar-panel'));
254
+ set(bar.querySelector('.ge-shell-buybonus'));
255
+ };
256
+ const s = Math.max(HtmlRenderer.BAR_MIN_SCALE, Math.min(1, this.root.clientWidth / HtmlRenderer.BAR_REF_WIDTH));
257
+ zoomBar(s);
258
+ if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
259
+ zoomBar(s * (bar.clientWidth / bar.scrollWidth));
260
+ }
261
+ this.fitReadouts();
262
+ }
263
+
264
+ /** Unified number-fit: shrink EVERY readout's value span (`.ge-rd-val`) with a transform-scale so
265
+ * it fits its readout box — the single rule for all money displays (BET, balance, win, total win),
266
+ * so a large number never grows the layout. It only ever kicks in on a BOUNDED box (a fixed width,
267
+ * a flex slot, or a shrinkable `min-width:0` slot); a content-sized readout has box == value, so
268
+ * the pass is a no-op there. The value span is inline-block, so its true width is measurable even
269
+ * inside an `overflow:hidden` slot; the label is left untouched. */
270
+ private fitReadouts(): void {
271
+ for (const rd of Array.from(this.barHost.querySelectorAll('.ge-rd')) as HTMLElement[]) {
272
+ const val = rd.querySelector(':scope > .ge-rd-val') as HTMLElement | null;
273
+ if (!val) continue;
274
+ val.style.transform = ''; // measure at full size
275
+ const avail = rd.clientWidth, need = val.scrollWidth;
276
+ if (need > avail + 0.5 && need > 0) val.style.transform = `scale(${(avail / need).toFixed(3)})`;
277
+ }
278
+ }
279
+
280
+ private observeLayout(): void {
281
+ const RO = (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
282
+ if (typeof RO !== 'function') return;
283
+ this.ro = new RO((entries) => {
284
+ const rect = entries[0]?.contentRect;
285
+ const w = rect?.width ?? 0, h = rect?.height ?? 0;
286
+ this.host.notifyResize(w, h);
287
+ this.applyFitScale();
288
+ this.fitModals();
289
+ });
290
+ this.ro.observe(this.root);
291
+ }
292
+ }
@@ -0,0 +1,240 @@
1
+ import type { ShellHost } from '@/core/renderer';
2
+ import { effectiveAccent, contrastText } from '@/core/colors';
3
+ import { icon, type IconName } from '../icons';
4
+
5
+ /** A floating labelled money readout (balance/win/bet). */
6
+ function readout(ge: string, label: string, value: string): HTMLElement {
7
+ const el = document.createElement('div');
8
+ el.dataset.ge = ge;
9
+ el.className = `ge-rd ge-${ge}`;
10
+ // The value lives in its own inline-block span (.ge-rd-val) so it can be measured & shrunk to fit
11
+ // (see fitReadouts) independently of the label, and so the count-up animates just the number.
12
+ const lbl = document.createElement('span'); lbl.className = 'ge-lbl'; lbl.textContent = label;
13
+ const val = document.createElement('span'); val.className = 'ge-rd-val'; val.textContent = value;
14
+ el.append(lbl, val);
15
+ return el;
16
+ }
17
+
18
+ // Turbo is ONE bolt glyph (turbo1) whose state is shown by colour, not by swapping glyphs:
19
+ // off (0) → muted bolt · L1 → white bolt + 2px accent outline · L2 → accent-filled bolt + outline.
20
+ // The level class (ge-turbo-0/1/2) drives the styling in shell.css; ≥2 caps at the L2 look.
21
+ function turboBtn(host: ShellHost, level: number): HTMLButtonElement {
22
+ const b = iconBtn('turbo', 'turbo1', () => host.actions.cycleTurbo(), level > 0);
23
+ b.classList.add(`ge-turbo-${Math.min(2, level)}`);
24
+ return b;
25
+ }
26
+
27
+ /** A borderless icon button. */
28
+ function iconBtn(ge: string, name: IconName, onClick: () => void, active = false): HTMLButtonElement {
29
+ const b = document.createElement('button');
30
+ b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
31
+ b.dataset.ge = ge;
32
+ b.innerHTML = icon(name);
33
+ b.addEventListener('click', () => { if (!b.disabled) onClick(); });
34
+ return b;
35
+ }
36
+
37
+ export function renderBottomBar(host: ShellHost): HTMLElement {
38
+ const { state, config } = host;
39
+ const fmt = (n: number) => host.formatCurrency(n);
40
+ const fmtWin = (n: number) => host.formatCurrency(n, true); // win / total-win: variable decimals
41
+ const mobile = host.layout === 'mobile';
42
+ const bar = document.createElement('div');
43
+ bar.className = 'ge-shell-bottom';
44
+ bar.dataset.geMode = state.mode;
45
+
46
+ // menu icon button (always)
47
+ const menu = iconBtn('menu', 'menu', () => host.actions.openMenu());
48
+
49
+ // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
50
+ // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
51
+ const isBase = state.mode === 'base';
52
+ const isFS = state.mode === 'freeSpins';
53
+ // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
54
+ // only when it's a free-spins replay (freeSpins.total > 0).
55
+ const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
56
+
57
+ // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
58
+ // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
59
+ const balance = state.replay
60
+ ? null
61
+ : readout('balance', host.t('Balance'), fmt(state.balance));
62
+ // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
63
+ // the feature accent; the base state.bet is unchanged and returns once the feature is off.
64
+ const feature = state.activeFeature;
65
+ const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
66
+ const betValue = readout('bet-value', host.t('Bet'), fmt(betShown));
67
+ if (feature) {
68
+ const accent = effectiveAccent(feature);
69
+ betValue.classList.add('ge-bet-feature');
70
+ betValue.style.color = accent;
71
+ // tint the "BET" label too (its .ge-lbl colour is set in CSS, so override inline)
72
+ const lbl = betValue.querySelector('.ge-lbl') as HTMLElement | null;
73
+ if (lbl) lbl.style.color = accent;
74
+ }
75
+ const turbo = config.features.turbo > 0 ? turboBtn(host, state.turbo) : null;
76
+
77
+ // interactive controls — base mode only
78
+ let betDown: HTMLElement | null = null, betUp: HTMLElement | null = null;
79
+ let spin: HTMLElement | null = null, auto: HTMLElement | null = null, buy: HTMLElement | null = null;
80
+ if (isBase) {
81
+ betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
82
+ betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
83
+ betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
84
+ betValue.addEventListener('click', () => { if (!betLocked(host)) host.actions.openBetPicker(); });
85
+ spin = spinButton(host);
86
+ auto = config.features.autoplay ? autoButton(host) : null;
87
+ buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
88
+ }
89
+
90
+ const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
91
+ // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
92
+ // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
93
+ const fs = state.freeSpins;
94
+ const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
95
+ // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
96
+ // style as the SPIN disc); Total Win stays an inline readout.
97
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
98
+ const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
99
+ // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
100
+ const hero = isBase ? spin : fsHero;
101
+
102
+ if (mobile) {
103
+ // Two levels:
104
+ // 1) controls bar — [menu · auto · SPIN-or-FS · Total Win · turbo · buy]
105
+ // 2) a small info pill below — [balance · − bet + · win]
106
+ bar.appendChild(plaque('ge-m-controls', compact([menu, auto, hero, fsTotalWin, turbo, buy])));
107
+ const betGroup = plaque('ge-m-betgroup', compact([betDown, betValue, betUp]));
108
+ // WIN always occupies its slot (shows €0 between wins) so the pill never reflows on win↔0.
109
+ const mWin = readout('win', host.t('Win'), fmtWin(state.win));
110
+ bar.appendChild(plaque('ge-m-info', compact([balance, betGroup, mWin])));
111
+ } else {
112
+ // DESKTOP: BUY BONUS floats OUTSIDE, to the left of one continuous dark bar panel.
113
+ // LEFT (all the info): [menu] · [balance] · [Total Win] · [WIN]
114
+ // (Total Win only in FS / a fs replay; WIN only when there's a win this spin)
115
+ const left = zone('ge-zone-left', ...compact([menu, balance, fsTotalWin, winEl]));
116
+
117
+ // RIGHT (the controls): [bet (+ step)] · |divider| · [auto · SPIN-or-FS · turbo]
118
+ const betKids: HTMLElement[] = [betValue];
119
+ if (betUp && betDown) {
120
+ const step = document.createElement('div'); step.className = 'ge-betstep'; step.append(betUp, betDown);
121
+ betKids.push(step);
122
+ }
123
+ const betGroup = plaque('ge-betgroup', betKids);
124
+ const divider = document.createElement('div'); divider.className = 'ge-pl-divider';
125
+ const spinWrap = document.createElement('div'); spinWrap.className = 'ge-spinwrap';
126
+ spinWrap.append(...compact([auto, hero, turbo]));
127
+ const right = zone('ge-zone-right', betGroup, divider, spinWrap);
128
+
129
+ // One continuous dark panel: info group hard-left, controls hard-right (space-between).
130
+ // BUY BONUS sits to its left, outside the panel.
131
+ const panel = plaque('ge-bar-panel', [left, right]);
132
+ bar.append(...compact([buy, panel]));
133
+ }
134
+
135
+ applyBusy(host, bar);
136
+ return bar;
137
+ }
138
+
139
+ /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
140
+ * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
141
+ function fsHeroPlaque(host: ShellHost, text: string): HTMLElement {
142
+ const el = document.createElement('div');
143
+ el.className = 'ge-fs-hero'; el.dataset.ge = 'fs-counter';
144
+ const lbl = document.createElement('span'); lbl.className = 'ge-fs-lbl'; lbl.textContent = host.t('Free spins');
145
+ const num = document.createElement('span'); num.className = 'ge-fs-num'; num.textContent = text;
146
+ el.append(lbl, num);
147
+ return el;
148
+ }
149
+
150
+ function zone(cls: string, ...children: HTMLElement[]): HTMLElement {
151
+ const z = document.createElement('div');
152
+ z.className = `ge-zone ${cls}`;
153
+ z.append(...children);
154
+ return z;
155
+ }
156
+ /** A rounded background panel ("plaque") grouping a set of controls. */
157
+ function plaque(cls: string, children: HTMLElement[]): HTMLElement {
158
+ const d = document.createElement('div');
159
+ d.className = cls;
160
+ d.append(...children);
161
+ return d;
162
+ }
163
+ function compact(items: (HTMLElement | null)[]): HTMLElement[] { return items.filter((x): x is HTMLElement => x !== null); }
164
+
165
+ function buyBtn(host: ShellHost): HTMLButtonElement {
166
+ const buy = document.createElement('button');
167
+ buy.className = 'ge-shell-buybonus'; buy.dataset.ge = 'buybonus';
168
+ const feature = host.state.activeFeature;
169
+ if (feature) {
170
+ // A feature is active → this button turns into DISABLE (tinted with the feature accent).
171
+ const accent = effectiveAccent(feature);
172
+ buy.classList.add('ge-disable');
173
+ buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
174
+ buy.style.background = accent; buy.style.color = contrastText(accent);
175
+ buy.addEventListener('click', () => { if (!buy.disabled) host.actions.deactivateFeature(); });
176
+ } else {
177
+ // Ticket icon (no text); keep the label for screen readers.
178
+ buy.setAttribute('aria-label', host.t('BUY BONUS'));
179
+ buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
180
+ buy.addEventListener('click', () => { if (!buy.disabled) host.actions.openBuyBonus(); });
181
+ }
182
+ return buy;
183
+ }
184
+
185
+ function betLocked(host: ShellHost): boolean {
186
+ return host.state.busy || host.state.autoplay.active;
187
+ }
188
+
189
+ /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
190
+ function spinButton(host: ShellHost): HTMLButtonElement {
191
+ const { state } = host;
192
+ const sp = document.createElement('button');
193
+ sp.className = 'ge-shell-spin'; sp.dataset.ge = 'spin';
194
+ if (state.autoplay.active) {
195
+ sp.classList.add('ge-stop');
196
+ const rem = state.autoplay.remaining;
197
+ const label = Number.isFinite(rem) ? String(rem) : '∞';
198
+ sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
199
+ sp.addEventListener('click', () => { if (!sp.disabled) host.actions.stopAutoplay(); });
200
+ } else {
201
+ sp.innerHTML = icon('spin');
202
+ if (state.busy) sp.classList.add('ge-spinning');
203
+ sp.addEventListener('click', () => { if (!sp.disabled) host.actions.spin(); });
204
+ }
205
+ return sp;
206
+ }
207
+
208
+ /** Autoplay icon button — opens the count picker; glows accent while running. */
209
+ function autoButton(host: ShellHost): HTMLButtonElement {
210
+ const active = host.state.autoplay.active;
211
+ const b = iconBtn('autoplay', 'autoplay', () => onAutoplay(host), active);
212
+ if (active) b.classList.add('ge-glow');
213
+ return b;
214
+ }
215
+
216
+ function onAutoplay(host: ShellHost): void {
217
+ if (host.state.autoplay.active) host.actions.stopAutoplay();
218
+ else host.actions.openAutoplayPicker();
219
+ }
220
+
221
+ function applyBusy(host: ShellHost, bar: HTMLElement): void {
222
+ const { busy } = host.state;
223
+ const auto = host.state.autoplay.active;
224
+ const lockBet = busy || auto;
225
+ const disable = (ge: string, off: boolean) => {
226
+ const el = bar.querySelector(`[data-ge="${ge}"]`) as HTMLButtonElement | null;
227
+ if (el) el.disabled = off;
228
+ };
229
+ // also disable the stepper that's already at the end of the bet range
230
+ const i = host.state.availableBets.indexOf(host.state.bet);
231
+ disable('bet-up', lockBet || i >= host.state.availableBets.length - 1);
232
+ disable('bet-down', lockBet || i <= 0);
233
+ disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
234
+ disable('autoplay', busy && !auto); // keep autoplay (stop) clickable through autoplay
235
+ const betVal = bar.querySelector('[data-ge="bet-value"]') as HTMLElement | null;
236
+ if (betVal) betVal.classList.toggle('ge-disabled', lockBet);
237
+ const buy = bar.querySelector('[data-ge="buybonus"]') as HTMLButtonElement | null;
238
+ // disabled for the whole autoplay run (not just per-spin busy) so it doesn't flicker/pulse
239
+ if (buy) buy.disabled = busy || auto || !host.state.buyBonusEnabled;
240
+ }