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