@energy8platform/shell 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/shell",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Energy8 branded game shell — one logic core, pluggable html/pixi renderers behind a stable contract.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -40,6 +40,9 @@
40
40
  "test:watch": "vitest",
41
41
  "prepublishOnly": "npm run build"
42
42
  },
43
+ "dependencies": {
44
+ "@energy8platform/game-sdk": "^2.9.0"
45
+ },
43
46
  "peerDependencies": {
44
47
  "pixi.js": "^8.16.0"
45
48
  },
package/src/core/i18n.ts CHANGED
@@ -1,77 +1,16 @@
1
+ import { applySocialReplacements } from '@energy8platform/game-sdk/social';
1
2
  import { LOCALES } from './locales';
2
3
 
3
4
  // Social-casino language. English is the source (and, for now, the only) language; `socialize`
4
5
  // rewrites the restricted gambling vocabulary into social-safe phrasing while preserving case.
5
6
  //
6
- // Ordering matters: the longest / most specific phrases are listed first so they win over their
7
- // constituent words (e.g. "buy bonus" before "buy", "pay out" before "pay"). The JS alternation
8
- // tries entries left-to-right at each position, so a phrase earlier in this list takes priority.
9
- //
10
- // Conflicting duplicates in the source table are resolved to a single replacement here
11
- // (betting→playing, total bet→total play, paid out→won, pays out→win).
12
- const RULES: ReadonlyArray<readonly [string, string]> = [
13
- ['be awarded to player’s accounts', 'appear in player’s accounts'],
14
- ["be awarded to player's accounts", "appear in player's accounts"],
15
- ['place your bets', 'come and play / join in the game'],
16
- ['at the cost of', 'for'],
17
- ['cost of', 'can be played for'],
18
- ['win feature', 'play feature'],
19
- ['total bet', 'total play'],
20
- ['buy bonus', 'get bonus'],
21
- ['bonus buy', 'bonus / feature'],
22
- ['pay out', 'win / won'],
23
- ['paid out', 'won'],
24
- ['pays out', 'win'],
25
- ['payout', 'win'], // single word; "pay out" (spaced) is handled above
26
- ['paytable', 'win table'],
27
- ['paylines', 'winlines'],
28
- ['payline', 'winline'],
29
- ['bet/s', 'play/s'],
30
- ['betting', 'playing'],
31
- ['rebet', 'respin'],
32
- ['stake', 'play amount'],
33
- ['payer', 'winner'],
34
- ['bets', 'plays'],
35
- ['pays', 'wins'],
36
- ['paid', 'won'],
37
- ['bought', 'instantly triggered'],
38
- ['purchase', 'play'],
39
- ['price', 'play'],
40
- ['cost', 'play'], // standalone; the "cost of" / "at the cost of" phrases above win first
41
- ['deposit', 'get coins'],
42
- ['withdraw', 'redeem'],
43
- ['currency', 'token'],
44
- ['gamble', 'play'],
45
- ['wager', 'play'],
46
- ['credit', 'balance'],
47
- ['money', 'coins'],
48
- ['cash', 'coins'],
49
- ['fund', 'balance'],
50
- ['bet', 'play'],
51
- ['pay', 'win'],
52
- ['buy', 'play'],
53
- ];
54
-
55
- const MAP = new Map(RULES.map(([k, v]) => [k.toLowerCase(), v] as const));
56
- const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
57
- // Letter-bounded so we only swap whole words/phrases (e.g. "pay" inside "Autoplay" is left alone).
58
- const PATTERN = new RegExp(`(?<![A-Za-z])(?:${RULES.map(([k]) => escapeRe(k)).join('|')})(?![A-Za-z])`, 'gi');
59
-
60
- /** Carry the matched text's capitalisation onto the replacement: ALL CAPS → upper, Capitalised
61
- * (first letter) → capitalise the replacement's first letter, otherwise lower-case as written. */
62
- function applyCase(match: string, repl: string): string {
63
- const letters = match.replace(/[^A-Za-z]/g, '');
64
- if (letters && letters === letters.toUpperCase()) return repl.toUpperCase();
65
- if (/^[^A-Za-z]*[A-Z]/.test(match)) return repl.charAt(0).toUpperCase() + repl.slice(1);
66
- return repl;
67
- }
7
+ // The replacement dictionary is NOT kept here it lives in the platform-wide canonical module
8
+ // `@energy8platform/game-sdk/social`, the same table the game canvas and the Stake bridge use, so
9
+ // the chrome never drifts from the games. Modelled as the `en-social` pseudo-locale in createI18n.
68
10
 
69
11
  /** Rewrite restricted gambling terms in `text` to social-safe phrasing, preserving case. */
70
12
  export function socialize(text: string): string {
71
- return text.replace(PATTERN, (m) => {
72
- const repl = MAP.get(m.toLowerCase());
73
- return repl == null ? m : applyCase(m, repl);
74
- });
13
+ return applySocialReplacements(text);
75
14
  }
76
15
 
77
16
  export type Lang = 'de'|'en'|'es'|'fi'|'fr'|'hi'|'id'|'ja'|'ko'|'pl'|'pt'|'ru'|'tr'|'vi'|'zh'|'da';
@@ -87,6 +26,8 @@ export interface I18nOptions { language: string; isSocial?: boolean; messages?:
87
26
  export interface I18n { readonly lang: Lang; t(src: string): string; }
88
27
 
89
28
  export function createI18n(opts: I18nOptions): I18n {
29
+ // Social is the `en-social` pseudo-locale: it rewrites the English source. Non-English locales are
30
+ // authored social-safe already, so `socialize` only runs on the English source string.
90
31
  const lang = normalizeLang(opts.language);
91
32
  const t = (src: string): string => {
92
33
  if (lang === 'en') return opts.isSocial ? socialize(src) : src;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { Lang } from './i18n';
7
7
 
8
- export const LOCALES: Partial<Record<Lang, Record<string, string>>> = {
8
+ const BASE_LOCALES: Partial<Record<Lang, Record<string, string>>> = {
9
9
  da: {
10
10
  DISCLAIMER: 'Ansvarsfraskrivelse',
11
11
  Activate: 'Aktivér',
@@ -862,3 +862,129 @@ export const LOCALES: Partial<Record<Lang, Record<string, string>>> = {
862
862
  'Winning shapes': '赢利图形',
863
863
  },
864
864
  };
865
+
866
+ // ── Legal disclaimer body (Stake canonical lines) ──────────────────────────────────────────────
867
+ // Kept in a separate block (grouped per language, not interleaved) because these are long legal
868
+ // sentences, MACHINE-TRANSLATED and pending native QA. The keys are the exact English source lines
869
+ // the Stake bridge emits (see @energy8platform/stake-bridge `buildDisclaimer`). The copyright/brand
870
+ // line ("TM and © {year} Stake Engine.") is deliberately omitted — it renders verbatim.
871
+ const D = {
872
+ L1: 'Malfunction voids all wins and plays.',
873
+ L2: 'A consistent internet connection is required. In the event of a disconnection, reload the game to finish any uncompleted rounds.',
874
+ L3: 'The expected return is calculated over many plays.',
875
+ L4: 'The game display is not representative of any physical device and is for illustrative purposes only.',
876
+ L5: 'Winnings are settled according to the amount received from the Remote Game Server and not from events within the web browser.',
877
+ } as const;
878
+
879
+ const DISCLAIMER_LOCALES: Partial<Record<Lang, Record<string, string>>> = {
880
+ da: {
881
+ [D.L1]: 'Fejlfunktion annullerer alle gevinster og spil.',
882
+ [D.L2]: 'En stabil internetforbindelse er påkrævet. I tilfælde af en afbrydelse skal du genindlæse spillet for at afslutte eventuelle uafsluttede runder.',
883
+ [D.L3]: 'Det forventede afkast beregnes over mange spil.',
884
+ [D.L4]: 'Spillets visning svarer ikke til nogen fysisk enhed og er kun til illustrative formål.',
885
+ [D.L5]: 'Gevinster afregnes i henhold til det beløb, der modtages fra Remote Game Server, og ikke ud fra hændelser i webbrowseren.',
886
+ },
887
+ de: {
888
+ [D.L1]: 'Eine Fehlfunktion macht alle Gewinne und Spiele ungültig.',
889
+ [D.L2]: 'Eine stabile Internetverbindung ist erforderlich. Im Falle einer Unterbrechung lade das Spiel neu, um nicht abgeschlossene Runden zu beenden.',
890
+ [D.L3]: 'Der erwartete Ertrag wird über viele Spiele berechnet.',
891
+ [D.L4]: 'Die Spieldarstellung entspricht keinem physischen Gerät und dient nur zur Veranschaulichung.',
892
+ [D.L5]: 'Gewinne werden gemäß dem vom Remote Game Server erhaltenen Betrag abgerechnet und nicht anhand von Ereignissen im Webbrowser.',
893
+ },
894
+ es: {
895
+ [D.L1]: 'Un fallo anula todas las ganancias y jugadas.',
896
+ [D.L2]: 'Se requiere una conexión a internet estable. En caso de desconexión, vuelve a cargar el juego para terminar las rondas no completadas.',
897
+ [D.L3]: 'El retorno esperado se calcula a lo largo de muchas jugadas.',
898
+ [D.L4]: 'La visualización del juego no representa ningún dispositivo físico y tiene fines meramente ilustrativos.',
899
+ [D.L5]: 'Las ganancias se liquidan según el importe recibido del Remote Game Server y no según los eventos ocurridos en el navegador web.',
900
+ },
901
+ fi: {
902
+ [D.L1]: 'Toimintahäiriö mitätöi kaikki voitot ja pelit.',
903
+ [D.L2]: 'Vakaa internetyhteys vaaditaan. Jos yhteys katkeaa, lataa peli uudelleen suorittaaksesi keskeneräiset kierrokset loppuun.',
904
+ [D.L3]: 'Odotettu palautus lasketaan monen pelin perusteella.',
905
+ [D.L4]: 'Pelin näkymä ei vastaa mitään fyysistä laitetta ja on tarkoitettu vain havainnollistamiseen.',
906
+ [D.L5]: 'Voitot maksetaan Remote Game Serverin ilmoittaman summan mukaan, ei verkkoselaimen tapahtumien perusteella.',
907
+ },
908
+ fr: {
909
+ [D.L1]: 'Un dysfonctionnement annule tous les gains et les parties.',
910
+ [D.L2]: 'Une connexion internet stable est requise. En cas de déconnexion, rechargez le jeu pour terminer les tours non achevés.',
911
+ [D.L3]: 'Le retour attendu est calculé sur de nombreuses parties.',
912
+ [D.L4]: "L'affichage du jeu ne représente aucun appareil physique et sert uniquement à des fins d'illustration.",
913
+ [D.L5]: 'Les gains sont réglés en fonction du montant reçu du Remote Game Server et non des événements survenus dans le navigateur web.',
914
+ },
915
+ hi: {
916
+ [D.L1]: 'खराबी सभी जीत और खेलों को रद्द कर देती है।',
917
+ [D.L2]: 'एक स्थिर इंटरनेट कनेक्शन आवश्यक है। कनेक्शन टूटने की स्थिति में, अधूरे राउंड पूरे करने के लिए गेम को फिर से लोड करें।',
918
+ [D.L3]: 'अपेक्षित रिटर्न की गणना कई खेलों पर की जाती है।',
919
+ [D.L4]: 'गेम का प्रदर्शन किसी भौतिक उपकरण का प्रतिनिधित्व नहीं करता और केवल उदाहरण के उद्देश्य से है।',
920
+ [D.L5]: 'जीत का निपटान Remote Game Server से प्राप्त राशि के अनुसार किया जाता है, न कि वेब ब्राउज़र के भीतर की घटनाओं के आधार पर।',
921
+ },
922
+ id: {
923
+ [D.L1]: 'Malfungsi membatalkan semua kemenangan dan permainan.',
924
+ [D.L2]: 'Koneksi internet yang stabil diperlukan. Jika terjadi pemutusan koneksi, muat ulang permainan untuk menyelesaikan ronde yang belum selesai.',
925
+ [D.L3]: 'Perkiraan pengembalian dihitung dari banyak permainan.',
926
+ [D.L4]: 'Tampilan permainan tidak mewakili perangkat fisik mana pun dan hanya untuk tujuan ilustrasi.',
927
+ [D.L5]: 'Kemenangan diselesaikan sesuai dengan jumlah yang diterima dari Remote Game Server dan bukan dari peristiwa di dalam peramban web.',
928
+ },
929
+ ja: {
930
+ [D.L1]: '誤作動が発生した場合、すべての勝利とプレイは無効となります。',
931
+ [D.L2]: '安定したインターネット接続が必要です。接続が切断された場合は、ゲームを再読み込みして未完了のラウンドを終了してください。',
932
+ [D.L3]: '期待される還元率は多数のプレイにわたって計算されます。',
933
+ [D.L4]: 'ゲームの表示は物理的な機器を表すものではなく、あくまで説明のためのものです。',
934
+ [D.L5]: '賞金はウェブブラウザ内のイベントではなく、Remote Game Server から受信した金額に基づいて確定されます。',
935
+ },
936
+ ko: {
937
+ [D.L1]: '오작동이 발생하면 모든 당첨과 플레이가 무효가 됩니다.',
938
+ [D.L2]: '안정적인 인터넷 연결이 필요합니다. 연결이 끊긴 경우 게임을 새로 고침하여 완료되지 않은 라운드를 마치십시오.',
939
+ [D.L3]: '예상 환급률은 여러 번의 플레이에 걸쳐 계산됩니다.',
940
+ [D.L4]: '게임 화면은 실제 기기를 나타내지 않으며 설명을 위한 용도로만 제공됩니다.',
941
+ [D.L5]: '당첨금은 웹 브라우저 내의 이벤트가 아니라 Remote Game Server에서 수신한 금액에 따라 정산됩니다.',
942
+ },
943
+ pl: {
944
+ [D.L1]: 'Awaria unieważnia wszystkie wygrane i gry.',
945
+ [D.L2]: 'Wymagane jest stabilne połączenie internetowe. W przypadku rozłączenia załaduj grę ponownie, aby dokończyć niezakończone rundy.',
946
+ [D.L3]: 'Oczekiwany zwrot jest obliczany na przestrzeni wielu gier.',
947
+ [D.L4]: 'Wyświetlanie gry nie odzwierciedla żadnego fizycznego urządzenia i służy wyłącznie celom ilustracyjnym.',
948
+ [D.L5]: 'Wygrane są rozliczane zgodnie z kwotą otrzymaną z Remote Game Server, a nie na podstawie zdarzeń w przeglądarce internetowej.',
949
+ },
950
+ pt: {
951
+ [D.L1]: 'Uma falha anula todos os ganhos e jogadas.',
952
+ [D.L2]: 'É necessária uma ligação à internet estável. Em caso de desconexão, recarregue o jogo para concluir as rondas não terminadas.',
953
+ [D.L3]: 'O retorno esperado é calculado ao longo de muitas jogadas.',
954
+ [D.L4]: 'A apresentação do jogo não representa nenhum dispositivo físico e destina-se apenas a fins ilustrativos.',
955
+ [D.L5]: 'Os ganhos são liquidados de acordo com o valor recebido do Remote Game Server e não com base em eventos ocorridos no navegador web.',
956
+ },
957
+ ru: {
958
+ [D.L1]: 'Сбой аннулирует все выигрыши и игры.',
959
+ [D.L2]: 'Требуется стабильное интернет-соединение. В случае разрыва соединения перезагрузите игру, чтобы завершить незаконченные раунды.',
960
+ [D.L3]: 'Ожидаемый возврат рассчитывается на основе множества игр.',
961
+ [D.L4]: 'Отображение игры не соответствует какому-либо физическому устройству и приводится исключительно в иллюстративных целях.',
962
+ [D.L5]: 'Выигрыши начисляются в соответствии с суммой, полученной от Remote Game Server, а не на основе событий в веб-браузере.',
963
+ },
964
+ tr: {
965
+ [D.L1]: 'Arıza, tüm kazançları ve oyunları geçersiz kılar.',
966
+ [D.L2]: 'Kararlı bir internet bağlantısı gereklidir. Bağlantının kesilmesi durumunda, tamamlanmamış turları bitirmek için oyunu yeniden yükleyin.',
967
+ [D.L3]: 'Beklenen getiri, çok sayıda oyun üzerinden hesaplanır.',
968
+ [D.L4]: 'Oyun görüntüsü herhangi bir fiziksel cihazı temsil etmez ve yalnızca açıklama amaçlıdır.',
969
+ [D.L5]: "Kazançlar, web tarayıcısındaki olaylara göre değil, Remote Game Server'dan alınan tutara göre ödenir.",
970
+ },
971
+ vi: {
972
+ [D.L1]: 'Sự cố sẽ hủy bỏ mọi phần thắng và lượt chơi.',
973
+ [D.L2]: 'Cần có kết nối internet ổn định. Trong trường hợp mất kết nối, hãy tải lại trò chơi để hoàn tất các vòng chưa hoàn thành.',
974
+ [D.L3]: 'Lợi nhuận kỳ vọng được tính trên nhiều lượt chơi.',
975
+ [D.L4]: 'Hiển thị của trò chơi không đại diện cho bất kỳ thiết bị vật lý nào và chỉ nhằm mục đích minh họa.',
976
+ [D.L5]: 'Phần thắng được thanh toán theo số tiền nhận được từ Remote Game Server chứ không dựa trên các sự kiện trong trình duyệt web.',
977
+ },
978
+ zh: {
979
+ [D.L1]: '故障将使所有奖金和游戏无效。',
980
+ [D.L2]: '需要稳定的网络连接。如果连接中断,请重新加载游戏以完成未结束的回合。',
981
+ [D.L3]: '预期回报是根据多次游戏计算得出的。',
982
+ [D.L4]: '游戏显示并不代表任何实体设备,仅供说明之用。',
983
+ [D.L5]: '奖金根据从 Remote Game Server 收到的金额结算,而非依据网页浏览器内的事件。',
984
+ },
985
+ };
986
+
987
+ /** Merge the base UI catalog with the separately-authored disclaimer lines, per language. */
988
+ export const LOCALES: Partial<Record<Lang, Record<string, string>>> = Object.fromEntries(
989
+ (Object.keys(BASE_LOCALES) as Lang[]).map((l) => [l, { ...BASE_LOCALES[l], ...DISCLAIMER_LOCALES[l] }]),
990
+ );
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
2
2
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
3
- export const PACKAGE_VERSION = '0.2.1';
3
+ export const PACKAGE_VERSION = '0.3.0';
@@ -397,6 +397,17 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
397
397
  #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-bb-betval { min-width:clamp(50px,14cqh,66px); }
398
398
  #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-bb-betval b { font-size:clamp(9px,2.5cqh,11px); }
399
399
  #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-bb-betval span { font-size:clamp(5px,1.25cqh,6px); }
400
+ /* Popout S (very short landscape, e.g. 400×225): the height-fit shrank the cards' descriptions to an
401
+ unreadable ~4px floor with nothing to scroll. Below a ~340px frame height, drop the shrink-to-fit and
402
+ switch the (non-mobile) buy-bonus to a READABLE vertical stack that scrolls vertically — like mobile.
403
+ Query the overlay's ge-bb-frame size container so it tracks the popout frame, not the browser window. */
404
+ @container ge-bb-frame (max-height: 340px) {
405
+ #${SHELL_ROOT_ID}:not(.ge-mobile) [data-ge="buybonus-overlay"] .ge-ov-scroll { overflow-y:auto; }
406
+ #${SHELL_ROOT_ID}:not(.ge-mobile) [data-ge="buybonus-overlay"] .ge-ov-body { justify-content:flex-start; }
407
+ #${SHELL_ROOT_ID}:not(.ge-mobile) .ge-bb-grid { flex-direction:column; align-items:center; overflow:visible; }
408
+ #${SHELL_ROOT_ID}:not(.ge-mobile) .ge-bb-grid .ge-bonus-card {
409
+ flex:0 0 auto; width:18em; font-size:min(12px, calc(84cqw / 18)); }
410
+ }
400
411
 
401
412
  /* ═══ desktop control bar — one continuous dark panel; white-disc buttons; BUY BONUS outside-left ═══ */
402
413
  /* the dark surface fills the row width (BUY BONUS sits outside, to its left) */
@@ -10,6 +10,11 @@ import { clamp } from '../primitives/overlay';
10
10
  import { attachHover } from '../primitives/widgets';
11
11
  import { roundedPath } from '../primitives/flex';
12
12
 
13
+ /** Below this frame height (px), a wide/landscape popout (e.g. Popout S 400×225) stacks its cards
14
+ * vertically and scrolls — like mobile — so descriptions stay readable instead of shrinking to a
15
+ * ~4px floor. Wide + taller frames (Popout L and up) keep the centred horizontal row. */
16
+ const SHORT_STACK_H = 340;
17
+
13
18
  /** Buy-bonus overlay — art-forward cards (one per option), a live bet footer, and a confirm modal.
14
19
  * Returns null when there are no bonus options. */
15
20
  export function openBuyBonus(host: PixiComponentContext): ShellLayer | null {
@@ -46,6 +51,8 @@ class BuyBonusOverlay extends Container implements ShellLayer {
46
51
  private cardEntries: CardEntry[] = [];
47
52
  /** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
48
53
  private focusIndex = -1;
54
+ /** true when cards are stacked vertically (mobile, or a short landscape popout). */
55
+ private stack = false;
49
56
  // Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
50
57
  // prunes pointer events outside its band, stalling globalpointermove mid-drag so later cards never
51
58
  // scroll into reach. The overlay sees the whole screen, so the scroll runs the full range.
@@ -155,7 +162,10 @@ class BuyBonusOverlay extends Container implements ShellLayer {
155
162
 
156
163
  private buildCards(): void {
157
164
  this.strip.removeChildren().forEach((c) => c.destroy({ children: true }));
158
- const mobile = this.host.layout === 'mobile';
165
+ // Stack (readable vertical list + scroll) on mobile OR on a short landscape popout; a shrink-to-fit
166
+ // horizontal row on wide + tall frames. See SHORT_STACK_H.
167
+ const stack = this.host.layout === 'mobile' || this.h <= SHORT_STACK_H;
168
+ this.stack = stack;
159
169
  const top = this.headerH + 6;
160
170
  const areaH = this.h - top - this.footerH - 6;
161
171
  const gap = 14;
@@ -166,13 +176,13 @@ class BuyBonusOverlay extends Container implements ShellLayer {
166
176
  // N cards (+ gaps + 24px side margins) fit the frame width. min() of the two = the binding fit.
167
177
  // Floor 4 is a last-resort so a 400×225 popout still shows the CTA (then X-drag scrolls the slack).
168
178
  const emH = 3.4 * (areaH / 100);
169
- const emW = mobile
179
+ const emW = stack
170
180
  ? (this.w - 48) / 18 // vertical stack: a single card spans the width
171
181
  : (this.w - 48 - (n - 1) * gap) / (18 * n); // row: N cards + gaps fit the frame width
172
- const em = mobile ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
182
+ const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
173
183
  const cardW = Math.min(18 * em, this.w - 48);
174
184
 
175
- const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, mobile, areaH));
185
+ const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, stack, areaH));
176
186
  const cardH = Math.max(...cards.map((c) => c.height));
177
187
  for (const c of cards) c.setHeight(cardH);
178
188
 
@@ -193,7 +203,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
193
203
  }
194
204
 
195
205
  this.stripMask.clear();
196
- if (mobile) {
206
+ if (stack) {
197
207
  // vertical stack — scroll vertically if it overflows (simple drag)
198
208
  let y = 0;
199
209
  const colX = (this.w - cardW) / 2;
@@ -220,7 +230,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
220
230
  }
221
231
 
222
232
  // ── one card ──────────────────────────────────────────────────────────────
223
- private buildCard(bonus: BonusOption, cardW: number, em: number, mobile: boolean, areaH: number): CardView {
233
+ private buildCard(bonus: BonusOption, cardW: number, em: number, stack: boolean, areaH: number): CardView {
224
234
  const accent = effectiveAccent(bonus);
225
235
  const ink = contrastText(accent);
226
236
  const price = bonus.priceMultiplier * this.host.state.bet;
@@ -255,7 +265,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
255
265
  ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
256
266
  onSelect: select,
257
267
  });
258
- void mobile;
268
+ void stack;
259
269
  void areaH;
260
270
  return card;
261
271
  }
@@ -435,7 +445,8 @@ class BuyBonusOverlay extends Container implements ShellLayer {
435
445
  * Browse phase: arrows move focus; +/- step bet; Enter/Space opens confirm; Escape closes.
436
446
  * Confirm phase: Enter/Space buys/activates; Escape returns to browse. */
437
447
  onKey(e: KeyboardEvent): boolean {
438
- const mobile = this.host.layout === 'mobile';
448
+ // Vertical stack (mobile / short popout) → Up/Down navigate; horizontal row → Left/Right.
449
+ const stack = this.stack;
439
450
 
440
451
  if (this.confirm && this.confirmBonus) {
441
452
  // ── Confirm phase ──
@@ -467,8 +478,8 @@ class BuyBonusOverlay extends Container implements ShellLayer {
467
478
  if (bet !== null) { this.stepBetBy(bet); return true; }
468
479
 
469
480
  // Determine navigation direction from key code + layout
470
- const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
471
- const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
481
+ const fwdKey = e.code === 'ArrowRight' || (stack && e.code === 'ArrowDown');
482
+ const bwdKey = e.code === 'ArrowLeft' || (stack && e.code === 'ArrowUp');
472
483
 
473
484
  if (fwdKey) {
474
485
  if (last < 0) return true;