@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/dist/pixi.d.ts CHANGED
@@ -473,7 +473,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
473
473
  tokens: ShellTokens;
474
474
  layout: ShellLayoutMode;
475
475
  soundOn: boolean;
476
- readonly engineVersion = "0.2.1";
476
+ readonly engineVersion = "0.3.0";
477
477
  readonly actions: ShellActions;
478
478
  private renderer;
479
479
  private i18n;
@@ -539,7 +539,7 @@ interface I18n {
539
539
  declare function createI18n(opts: I18nOptions): I18n;
540
540
 
541
541
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
542
- declare const PACKAGE_VERSION = "0.2.1";
542
+ declare const PACKAGE_VERSION = "0.3.0";
543
543
 
544
544
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
545
545
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
package/dist/pixi.esm.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { applySocialReplacements } from '@energy8platform/game-sdk/social';
1
2
  import { TextStyle, Text, CanvasTextMetrics, Container, Graphics, Rectangle, GraphicsContext, BlurFilter, Color, Assets, Sprite, RenderTexture, ColorMatrixFilter } from 'pixi.js';
2
3
 
3
4
  /**
@@ -189,7 +190,7 @@ function formatCurrency(value, currency, variableDecimals = false) {
189
190
  // Keys are the English source strings exactly as they appear at t('…') call sites.
190
191
  // Entries within each language block are sorted alphabetically by key for diff stability.
191
192
  // 'en' is omitted: the resolver returns the source string unchanged for English.
192
- const LOCALES = {
193
+ const BASE_LOCALES = {
193
194
  da: {
194
195
  DISCLAIMER: 'Ansvarsfraskrivelse',
195
196
  Activate: 'Aktivér',
@@ -1046,78 +1047,137 @@ const LOCALES = {
1046
1047
  'Winning shapes': '赢利图形',
1047
1048
  },
1048
1049
  };
1050
+ // ── Legal disclaimer body (Stake canonical lines) ──────────────────────────────────────────────
1051
+ // Kept in a separate block (grouped per language, not interleaved) because these are long legal
1052
+ // sentences, MACHINE-TRANSLATED and pending native QA. The keys are the exact English source lines
1053
+ // the Stake bridge emits (see @energy8platform/stake-bridge `buildDisclaimer`). The copyright/brand
1054
+ // line ("TM and © {year} Stake Engine.") is deliberately omitted — it renders verbatim.
1055
+ const D = {
1056
+ L1: 'Malfunction voids all wins and plays.',
1057
+ L2: 'A consistent internet connection is required. In the event of a disconnection, reload the game to finish any uncompleted rounds.',
1058
+ L3: 'The expected return is calculated over many plays.',
1059
+ L4: 'The game display is not representative of any physical device and is for illustrative purposes only.',
1060
+ L5: 'Winnings are settled according to the amount received from the Remote Game Server and not from events within the web browser.',
1061
+ };
1062
+ const DISCLAIMER_LOCALES = {
1063
+ da: {
1064
+ [D.L1]: 'Fejlfunktion annullerer alle gevinster og spil.',
1065
+ [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.',
1066
+ [D.L3]: 'Det forventede afkast beregnes over mange spil.',
1067
+ [D.L4]: 'Spillets visning svarer ikke til nogen fysisk enhed og er kun til illustrative formål.',
1068
+ [D.L5]: 'Gevinster afregnes i henhold til det beløb, der modtages fra Remote Game Server, og ikke ud fra hændelser i webbrowseren.',
1069
+ },
1070
+ de: {
1071
+ [D.L1]: 'Eine Fehlfunktion macht alle Gewinne und Spiele ungültig.',
1072
+ [D.L2]: 'Eine stabile Internetverbindung ist erforderlich. Im Falle einer Unterbrechung lade das Spiel neu, um nicht abgeschlossene Runden zu beenden.',
1073
+ [D.L3]: 'Der erwartete Ertrag wird über viele Spiele berechnet.',
1074
+ [D.L4]: 'Die Spieldarstellung entspricht keinem physischen Gerät und dient nur zur Veranschaulichung.',
1075
+ [D.L5]: 'Gewinne werden gemäß dem vom Remote Game Server erhaltenen Betrag abgerechnet und nicht anhand von Ereignissen im Webbrowser.',
1076
+ },
1077
+ es: {
1078
+ [D.L1]: 'Un fallo anula todas las ganancias y jugadas.',
1079
+ [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.',
1080
+ [D.L3]: 'El retorno esperado se calcula a lo largo de muchas jugadas.',
1081
+ [D.L4]: 'La visualización del juego no representa ningún dispositivo físico y tiene fines meramente ilustrativos.',
1082
+ [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.',
1083
+ },
1084
+ fi: {
1085
+ [D.L1]: 'Toimintahäiriö mitätöi kaikki voitot ja pelit.',
1086
+ [D.L2]: 'Vakaa internetyhteys vaaditaan. Jos yhteys katkeaa, lataa peli uudelleen suorittaaksesi keskeneräiset kierrokset loppuun.',
1087
+ [D.L3]: 'Odotettu palautus lasketaan monen pelin perusteella.',
1088
+ [D.L4]: 'Pelin näkymä ei vastaa mitään fyysistä laitetta ja on tarkoitettu vain havainnollistamiseen.',
1089
+ [D.L5]: 'Voitot maksetaan Remote Game Serverin ilmoittaman summan mukaan, ei verkkoselaimen tapahtumien perusteella.',
1090
+ },
1091
+ fr: {
1092
+ [D.L1]: 'Un dysfonctionnement annule tous les gains et les parties.',
1093
+ [D.L2]: 'Une connexion internet stable est requise. En cas de déconnexion, rechargez le jeu pour terminer les tours non achevés.',
1094
+ [D.L3]: 'Le retour attendu est calculé sur de nombreuses parties.',
1095
+ [D.L4]: "L'affichage du jeu ne représente aucun appareil physique et sert uniquement à des fins d'illustration.",
1096
+ [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.',
1097
+ },
1098
+ hi: {
1099
+ [D.L1]: 'खराबी सभी जीत और खेलों को रद्द कर देती है।',
1100
+ [D.L2]: 'एक स्थिर इंटरनेट कनेक्शन आवश्यक है। कनेक्शन टूटने की स्थिति में, अधूरे राउंड पूरे करने के लिए गेम को फिर से लोड करें।',
1101
+ [D.L3]: 'अपेक्षित रिटर्न की गणना कई खेलों पर की जाती है।',
1102
+ [D.L4]: 'गेम का प्रदर्शन किसी भौतिक उपकरण का प्रतिनिधित्व नहीं करता और केवल उदाहरण के उद्देश्य से है।',
1103
+ [D.L5]: 'जीत का निपटान Remote Game Server से प्राप्त राशि के अनुसार किया जाता है, न कि वेब ब्राउज़र के भीतर की घटनाओं के आधार पर।',
1104
+ },
1105
+ id: {
1106
+ [D.L1]: 'Malfungsi membatalkan semua kemenangan dan permainan.',
1107
+ [D.L2]: 'Koneksi internet yang stabil diperlukan. Jika terjadi pemutusan koneksi, muat ulang permainan untuk menyelesaikan ronde yang belum selesai.',
1108
+ [D.L3]: 'Perkiraan pengembalian dihitung dari banyak permainan.',
1109
+ [D.L4]: 'Tampilan permainan tidak mewakili perangkat fisik mana pun dan hanya untuk tujuan ilustrasi.',
1110
+ [D.L5]: 'Kemenangan diselesaikan sesuai dengan jumlah yang diterima dari Remote Game Server dan bukan dari peristiwa di dalam peramban web.',
1111
+ },
1112
+ ja: {
1113
+ [D.L1]: '誤作動が発生した場合、すべての勝利とプレイは無効となります。',
1114
+ [D.L2]: '安定したインターネット接続が必要です。接続が切断された場合は、ゲームを再読み込みして未完了のラウンドを終了してください。',
1115
+ [D.L3]: '期待される還元率は多数のプレイにわたって計算されます。',
1116
+ [D.L4]: 'ゲームの表示は物理的な機器を表すものではなく、あくまで説明のためのものです。',
1117
+ [D.L5]: '賞金はウェブブラウザ内のイベントではなく、Remote Game Server から受信した金額に基づいて確定されます。',
1118
+ },
1119
+ ko: {
1120
+ [D.L1]: '오작동이 발생하면 모든 당첨과 플레이가 무효가 됩니다.',
1121
+ [D.L2]: '안정적인 인터넷 연결이 필요합니다. 연결이 끊긴 경우 게임을 새로 고침하여 완료되지 않은 라운드를 마치십시오.',
1122
+ [D.L3]: '예상 환급률은 여러 번의 플레이에 걸쳐 계산됩니다.',
1123
+ [D.L4]: '게임 화면은 실제 기기를 나타내지 않으며 설명을 위한 용도로만 제공됩니다.',
1124
+ [D.L5]: '당첨금은 웹 브라우저 내의 이벤트가 아니라 Remote Game Server에서 수신한 금액에 따라 정산됩니다.',
1125
+ },
1126
+ pl: {
1127
+ [D.L1]: 'Awaria unieważnia wszystkie wygrane i gry.',
1128
+ [D.L2]: 'Wymagane jest stabilne połączenie internetowe. W przypadku rozłączenia załaduj grę ponownie, aby dokończyć niezakończone rundy.',
1129
+ [D.L3]: 'Oczekiwany zwrot jest obliczany na przestrzeni wielu gier.',
1130
+ [D.L4]: 'Wyświetlanie gry nie odzwierciedla żadnego fizycznego urządzenia i służy wyłącznie celom ilustracyjnym.',
1131
+ [D.L5]: 'Wygrane są rozliczane zgodnie z kwotą otrzymaną z Remote Game Server, a nie na podstawie zdarzeń w przeglądarce internetowej.',
1132
+ },
1133
+ pt: {
1134
+ [D.L1]: 'Uma falha anula todos os ganhos e jogadas.',
1135
+ [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.',
1136
+ [D.L3]: 'O retorno esperado é calculado ao longo de muitas jogadas.',
1137
+ [D.L4]: 'A apresentação do jogo não representa nenhum dispositivo físico e destina-se apenas a fins ilustrativos.',
1138
+ [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.',
1139
+ },
1140
+ ru: {
1141
+ [D.L1]: 'Сбой аннулирует все выигрыши и игры.',
1142
+ [D.L2]: 'Требуется стабильное интернет-соединение. В случае разрыва соединения перезагрузите игру, чтобы завершить незаконченные раунды.',
1143
+ [D.L3]: 'Ожидаемый возврат рассчитывается на основе множества игр.',
1144
+ [D.L4]: 'Отображение игры не соответствует какому-либо физическому устройству и приводится исключительно в иллюстративных целях.',
1145
+ [D.L5]: 'Выигрыши начисляются в соответствии с суммой, полученной от Remote Game Server, а не на основе событий в веб-браузере.',
1146
+ },
1147
+ tr: {
1148
+ [D.L1]: 'Arıza, tüm kazançları ve oyunları geçersiz kılar.',
1149
+ [D.L2]: 'Kararlı bir internet bağlantısı gereklidir. Bağlantının kesilmesi durumunda, tamamlanmamış turları bitirmek için oyunu yeniden yükleyin.',
1150
+ [D.L3]: 'Beklenen getiri, çok sayıda oyun üzerinden hesaplanır.',
1151
+ [D.L4]: 'Oyun görüntüsü herhangi bir fiziksel cihazı temsil etmez ve yalnızca açıklama amaçlıdır.',
1152
+ [D.L5]: "Kazançlar, web tarayıcısındaki olaylara göre değil, Remote Game Server'dan alınan tutara göre ödenir.",
1153
+ },
1154
+ vi: {
1155
+ [D.L1]: 'Sự cố sẽ hủy bỏ mọi phần thắng và lượt chơi.',
1156
+ [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.',
1157
+ [D.L3]: 'Lợi nhuận kỳ vọng được tính trên nhiều lượt chơi.',
1158
+ [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.',
1159
+ [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.',
1160
+ },
1161
+ zh: {
1162
+ [D.L1]: '故障将使所有奖金和游戏无效。',
1163
+ [D.L2]: '需要稳定的网络连接。如果连接中断,请重新加载游戏以完成未结束的回合。',
1164
+ [D.L3]: '预期回报是根据多次游戏计算得出的。',
1165
+ [D.L4]: '游戏显示并不代表任何实体设备,仅供说明之用。',
1166
+ [D.L5]: '奖金根据从 Remote Game Server 收到的金额结算,而非依据网页浏览器内的事件。',
1167
+ },
1168
+ };
1169
+ /** Merge the base UI catalog with the separately-authored disclaimer lines, per language. */
1170
+ const LOCALES = Object.fromEntries(Object.keys(BASE_LOCALES).map((l) => [l, { ...BASE_LOCALES[l], ...DISCLAIMER_LOCALES[l] }]));
1049
1171
 
1050
1172
  // Social-casino language. English is the source (and, for now, the only) language; `socialize`
1051
1173
  // rewrites the restricted gambling vocabulary into social-safe phrasing while preserving case.
1052
1174
  //
1053
- // Ordering matters: the longest / most specific phrases are listed first so they win over their
1054
- // constituent words (e.g. "buy bonus" before "buy", "pay out" before "pay"). The JS alternation
1055
- // tries entries left-to-right at each position, so a phrase earlier in this list takes priority.
1056
- //
1057
- // Conflicting duplicates in the source table are resolved to a single replacement here
1058
- // (betting→playing, total bet→total play, paid out→won, pays out→win).
1059
- const RULES = [
1060
- ['be awarded to player’s accounts', 'appear in player’s accounts'],
1061
- ["be awarded to player's accounts", "appear in player's accounts"],
1062
- ['place your bets', 'come and play / join in the game'],
1063
- ['at the cost of', 'for'],
1064
- ['cost of', 'can be played for'],
1065
- ['win feature', 'play feature'],
1066
- ['total bet', 'total play'],
1067
- ['buy bonus', 'get bonus'],
1068
- ['bonus buy', 'bonus / feature'],
1069
- ['pay out', 'win / won'],
1070
- ['paid out', 'won'],
1071
- ['pays out', 'win'],
1072
- ['payout', 'win'], // single word; "pay out" (spaced) is handled above
1073
- ['paytable', 'win table'],
1074
- ['paylines', 'winlines'],
1075
- ['payline', 'winline'],
1076
- ['bet/s', 'play/s'],
1077
- ['betting', 'playing'],
1078
- ['rebet', 'respin'],
1079
- ['stake', 'play amount'],
1080
- ['payer', 'winner'],
1081
- ['bets', 'plays'],
1082
- ['pays', 'wins'],
1083
- ['paid', 'won'],
1084
- ['bought', 'instantly triggered'],
1085
- ['purchase', 'play'],
1086
- ['price', 'play'],
1087
- ['cost', 'play'], // standalone; the "cost of" / "at the cost of" phrases above win first
1088
- ['deposit', 'get coins'],
1089
- ['withdraw', 'redeem'],
1090
- ['currency', 'token'],
1091
- ['gamble', 'play'],
1092
- ['wager', 'play'],
1093
- ['credit', 'balance'],
1094
- ['money', 'coins'],
1095
- ['cash', 'coins'],
1096
- ['fund', 'balance'],
1097
- ['bet', 'play'],
1098
- ['pay', 'win'],
1099
- ['buy', 'play'],
1100
- ];
1101
- const MAP = new Map(RULES.map(([k, v]) => [k.toLowerCase(), v]));
1102
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
1103
- // Letter-bounded so we only swap whole words/phrases (e.g. "pay" inside "Autoplay" is left alone).
1104
- const PATTERN = new RegExp(`(?<![A-Za-z])(?:${RULES.map(([k]) => escapeRe(k)).join('|')})(?![A-Za-z])`, 'gi');
1105
- /** Carry the matched text's capitalisation onto the replacement: ALL CAPS → upper, Capitalised
1106
- * (first letter) → capitalise the replacement's first letter, otherwise lower-case as written. */
1107
- function applyCase(match, repl) {
1108
- const letters = match.replace(/[^A-Za-z]/g, '');
1109
- if (letters && letters === letters.toUpperCase())
1110
- return repl.toUpperCase();
1111
- if (/^[^A-Za-z]*[A-Z]/.test(match))
1112
- return repl.charAt(0).toUpperCase() + repl.slice(1);
1113
- return repl;
1114
- }
1175
+ // The replacement dictionary is NOT kept here it lives in the platform-wide canonical module
1176
+ // `@energy8platform/game-sdk/social`, the same table the game canvas and the Stake bridge use, so
1177
+ // the chrome never drifts from the games. Modelled as the `en-social` pseudo-locale in createI18n.
1115
1178
  /** Rewrite restricted gambling terms in `text` to social-safe phrasing, preserving case. */
1116
1179
  function socialize(text) {
1117
- return text.replace(PATTERN, (m) => {
1118
- const repl = MAP.get(m.toLowerCase());
1119
- return repl == null ? m : applyCase(m, repl);
1120
- });
1180
+ return applySocialReplacements(text);
1121
1181
  }
1122
1182
  const LANGS = ['de', 'en', 'es', 'fi', 'fr', 'hi', 'id', 'ja', 'ko', 'pl', 'pt', 'ru', 'tr', 'vi', 'zh', 'da'];
1123
1183
  const LANG_SET = new Set(LANGS);
@@ -1126,6 +1186,8 @@ function normalizeLang(code) {
1126
1186
  return (LANG_SET.has(base) ? base : 'en');
1127
1187
  }
1128
1188
  function createI18n(opts) {
1189
+ // Social is the `en-social` pseudo-locale: it rewrites the English source. Non-English locales are
1190
+ // authored social-safe already, so `socialize` only runs on the English source string.
1129
1191
  const lang = normalizeLang(opts.language);
1130
1192
  const t = (src) => {
1131
1193
  if (lang === 'en')
@@ -1352,7 +1414,7 @@ class KeyboardController {
1352
1414
 
1353
1415
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1354
1416
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1355
- const PACKAGE_VERSION = '0.2.1';
1417
+ const PACKAGE_VERSION = '0.3.0';
1356
1418
 
1357
1419
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1358
1420
  function resolveConfig(config) {
@@ -4273,6 +4335,10 @@ function imageBox(url, w, h) {
4273
4335
  return c;
4274
4336
  }
4275
4337
 
4338
+ /** Below this frame height (px), a wide/landscape popout (e.g. Popout S 400×225) stacks its cards
4339
+ * vertically and scrolls — like mobile — so descriptions stay readable instead of shrinking to a
4340
+ * ~4px floor. Wide + taller frames (Popout L and up) keep the centred horizontal row. */
4341
+ const SHORT_STACK_H = 340;
4276
4342
  /** Buy-bonus overlay — art-forward cards (one per option), a live bet footer, and a confirm modal.
4277
4343
  * Returns null when there are no bonus options. */
4278
4344
  function openBuyBonus(host) {
@@ -4302,6 +4368,8 @@ class BuyBonusOverlay extends Container {
4302
4368
  cardEntries = [];
4303
4369
  /** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
4304
4370
  focusIndex = -1;
4371
+ /** true when cards are stacked vertically (mobile, or a short landscape popout). */
4372
+ stack = false;
4305
4373
  // Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
4306
4374
  // prunes pointer events outside its band, stalling globalpointermove mid-drag so later cards never
4307
4375
  // scroll into reach. The overlay sees the whole screen, so the scroll runs the full range.
@@ -4411,7 +4479,10 @@ class BuyBonusOverlay extends Container {
4411
4479
  }
4412
4480
  buildCards() {
4413
4481
  this.strip.removeChildren().forEach((c) => c.destroy({ children: true }));
4414
- const mobile = this.host.layout === 'mobile';
4482
+ // Stack (readable vertical list + scroll) on mobile OR on a short landscape popout; a shrink-to-fit
4483
+ // horizontal row on wide + tall frames. See SHORT_STACK_H.
4484
+ const stack = this.host.layout === 'mobile' || this.h <= SHORT_STACK_H;
4485
+ this.stack = stack;
4415
4486
  const top = this.headerH + 6;
4416
4487
  const areaH = this.h - top - this.footerH - 6;
4417
4488
  const gap = 14;
@@ -4422,12 +4493,12 @@ class BuyBonusOverlay extends Container {
4422
4493
  // N cards (+ gaps + 24px side margins) fit the frame width. min() of the two = the binding fit.
4423
4494
  // Floor 4 is a last-resort so a 400×225 popout still shows the CTA (then X-drag scrolls the slack).
4424
4495
  const emH = 3.4 * (areaH / 100);
4425
- const emW = mobile
4496
+ const emW = stack
4426
4497
  ? (this.w - 48) / 18 // vertical stack: a single card spans the width
4427
4498
  : (this.w - 48 - (n - 1) * gap) / (18 * n); // row: N cards + gaps fit the frame width
4428
- const em = mobile ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
4499
+ const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
4429
4500
  const cardW = Math.min(18 * em, this.w - 48);
4430
- const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, mobile, areaH));
4501
+ const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, stack, areaH));
4431
4502
  const cardH = Math.max(...cards.map((c) => c.height));
4432
4503
  for (const c of cards)
4433
4504
  c.setHeight(cardH);
@@ -4450,7 +4521,7 @@ class BuyBonusOverlay extends Container {
4450
4521
  this.focusIndex = -1;
4451
4522
  }
4452
4523
  this.stripMask.clear();
4453
- if (mobile) {
4524
+ if (stack) {
4454
4525
  // vertical stack — scroll vertically if it overflows (simple drag)
4455
4526
  let y = 0;
4456
4527
  const colX = (this.w - cardW) / 2;
@@ -4477,7 +4548,7 @@ class BuyBonusOverlay extends Container {
4477
4548
  }
4478
4549
  }
4479
4550
  // ── one card ──────────────────────────────────────────────────────────────
4480
- buildCard(bonus, cardW, em, mobile, areaH) {
4551
+ buildCard(bonus, cardW, em, stack, areaH) {
4481
4552
  const accent = effectiveAccent(bonus);
4482
4553
  const ink = contrastText(accent);
4483
4554
  const price = bonus.priceMultiplier * this.host.state.bet;
@@ -4679,7 +4750,8 @@ class BuyBonusOverlay extends Container {
4679
4750
  * Browse phase: arrows move focus; +/- step bet; Enter/Space opens confirm; Escape closes.
4680
4751
  * Confirm phase: Enter/Space buys/activates; Escape returns to browse. */
4681
4752
  onKey(e) {
4682
- const mobile = this.host.layout === 'mobile';
4753
+ // Vertical stack (mobile / short popout) → Up/Down navigate; horizontal row → Left/Right.
4754
+ const stack = this.stack;
4683
4755
  if (this.confirm && this.confirmBonus) {
4684
4756
  // ── Confirm phase ──
4685
4757
  switch (e.code) {
@@ -4713,8 +4785,8 @@ class BuyBonusOverlay extends Container {
4713
4785
  return true;
4714
4786
  }
4715
4787
  // Determine navigation direction from key code + layout
4716
- const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
4717
- const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
4788
+ const fwdKey = e.code === 'ArrowRight' || (stack && e.code === 'ArrowDown');
4789
+ const bwdKey = e.code === 'ArrowLeft' || (stack && e.code === 'ArrowUp');
4718
4790
  if (fwdKey) {
4719
4791
  if (last < 0)
4720
4792
  return true;