@bongos/core 1.25.6 → 1.25.8

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.
@@ -103,7 +103,7 @@
103
103
  { label: 'The atlas', href: '/atlas' },
104
104
  ];
105
105
  var TIPS = [
106
- 'Escape opens the options menu, where every setting lives.',
106
+ 'Escape opens the game menu: options, log out and exit.',
107
107
  'Every tower is a goal. It gains a floor each time work ships under it.',
108
108
  'A crane marks a site someone worked on this week.',
109
109
  'A salmon dot on a tower means something there is waiting on you.',
@@ -440,10 +440,103 @@
440
440
  if (host && host.OTBOptions && typeof host.OTBOptions.escape === 'function') { host.OTBOptions.escape(); return; }
441
441
  if (host && host.OTBOptionsOverlay && host.OTBOptionsOverlay.isOpen()) { host.OTBOptionsOverlay.close(); return; }
442
442
  if (embedded) return;
443
- openOverlay();
443
+ if (menuOpen()) { closeMenu(); return; }
444
+ openMenu();
444
445
  }, 0);
445
446
  }
446
447
 
448
+ // ---------- the game menu (task 1005250) ----------
449
+ // Game mode has no sidebar, so it had no way to sign out or to quit. The last Escape (and
450
+ // the visor's Menu hint and the Actions sheet's last row) now opens ONE small menu over the
451
+ // place, as a game does: Options (the options window below), Change character, Log out,
452
+ // Exit game, Return to game. It is the hall's shared dialog (OTBDialog.bind): Escape, the
453
+ // scrim, the focus trap and the focus return are its. A new advanced action is one row in
454
+ // GAME_MENU and one line in onMenuClick. Rules: modules/hall-ui/records/game-menu.md.
455
+ var GAME_MENU = [
456
+ { id: 'options', label: 'Options' },
457
+ { id: 'character', label: 'Change character' },
458
+ // shell.js turns a click on any link to the logout address into the real sign-out
459
+ { id: 'logout', label: 'Log out', href: '/api/bongos/auth/logout', gap: true },
460
+ { id: 'exit', label: 'Exit game' },
461
+ { id: 'return', label: 'Return to game', gap: true },
462
+ ];
463
+ var menuEl = null, menuDlg = null;
464
+ function menuOpen() { return !!(menuDlg && menuDlg.isOpen()); }
465
+ function closeMenu() { if (menuOpen()) menuDlg.close(); }
466
+ // Change character is craft-mode.js's; a page without it shows no such row.
467
+ function menuRows() {
468
+ var C = window.OTBCraftMode;
469
+ return GAME_MENU.filter(function (m) { return m.id !== 'character' || !!(C && typeof C.changeCharacter === 'function'); });
470
+ }
471
+ function menuRowHtml(m) {
472
+ var attrs = ' class="game-menu__item' + (m.id === 'return' ? ' game-menu__item--lit' : '') + '" data-gm="' + m.id + '"';
473
+ return '<li' + (m.gap ? ' class="game-menu__gap"' : '') + '>' +
474
+ (m.href ? '<a' + attrs + ' href="' + esc(m.href) + '">' + esc(m.label) + '</a>' : '<button type="button"' + attrs + '>' + esc(m.label) + '</button>') + '</li>';
475
+ }
476
+ var exitTimer = 0;
477
+ // Any new word in the note (a fresh opening clears it) drops a pending Exit check, so an
478
+ // Exit pressed just before the menu was shut cannot speak into the next opening.
479
+ function menuSay(text) {
480
+ clearTimeout(exitTimer); exitTimer = 0;
481
+ var note = menuEl && menuEl.querySelector('.game-menu__note');
482
+ if (!note) return;
483
+ note.textContent = text;
484
+ note.hidden = !text;
485
+ }
486
+ // Exit closes the tab. A browser only lets a script close a tab a script opened, so when
487
+ // the tab is still here a moment later the menu says so (the start screen's Quit, again).
488
+ function exitGame() {
489
+ try { window.close(); } catch (_) { /* refused */ }
490
+ clearTimeout(exitTimer);
491
+ exitTimer = setTimeout(function () { exitTimer = 0; if (menuOpen()) menuSay('Your browser kept this tab open. Close it to quit.'); }, 300);
492
+ }
493
+ function onMenuClick(e) {
494
+ var el = e.target && e.target.closest ? e.target.closest('[data-gm]') : null;
495
+ var id = el ? el.getAttribute('data-gm') : '';
496
+ if (id === 'return') closeMenu();
497
+ else if (id === 'options') { closeMenu(); openOverlay(); }
498
+ else if (id === 'character') { closeMenu(); window.OTBCraftMode.changeCharacter(); }
499
+ else if (id === 'exit') exitGame();
500
+ else if (id === 'logout') menuSay('Logging out');
501
+ }
502
+ // The arrows walk the rows, as on the start screen (Tab is the dialog's own trap).
503
+ function onMenuKey(e) {
504
+ var step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
505
+ if (!step || !menuEl) return;
506
+ var rows = Array.prototype.slice.call(menuEl.querySelectorAll('[data-gm]'));
507
+ if (!rows.length) return;
508
+ e.preventDefault();
509
+ var at = rows.indexOf(document.activeElement);
510
+ rows[(at + step + rows.length) % rows.length].focus();
511
+ }
512
+ function openMenu() {
513
+ try { buildMenu(); } catch (_) {
514
+ // No menu can be drawn here: the options, as before the game menu existed.
515
+ menuEl = null; menuDlg = null;
516
+ openOverlay();
517
+ }
518
+ }
519
+ function buildMenu() {
520
+ if (!menuEl) {
521
+ if (!window.OTBDialog || typeof window.OTBDialog.bind !== 'function') throw new Error('no dialog');
522
+ menuEl = document.createElement('div');
523
+ menuEl.className = 'game-menu';
524
+ menuEl.hidden = true;
525
+ menuEl.addEventListener('click', onMenuClick);
526
+ menuEl.addEventListener('keydown', onMenuKey);
527
+ menuEl.innerHTML = '<div class="game-menu__box" aria-labelledby="game-menu-title">' +
528
+ '<h2 class="game-menu__title" id="game-menu-title">Game menu</h2>' +
529
+ '<ul class="game-menu__items"></ul>' +
530
+ '<p class="game-menu__note" role="status" hidden></p></div>';
531
+ document.body.appendChild(menuEl);
532
+ menuDlg = window.OTBDialog.bind(menuEl, { panel: menuEl.querySelector('.game-menu__box'), lockClass: 'game-menu-open' });
533
+ }
534
+ // The rows are drawn at each opening: they follow what the page has, and the note starts empty.
535
+ menuEl.querySelector('.game-menu__items').innerHTML = menuRows().map(menuRowHtml).join('');
536
+ menuSay('');
537
+ menuDlg.open();
538
+ }
539
+
447
540
  // ---------- the options window (game mode) ----------
448
541
  // In game mode the options menu opens as a window OVER the place you are in, not
449
542
  // as a page of its own, so the place stays drawn behind it and its colours show
@@ -527,6 +620,7 @@
527
620
  function overlayHere() { return on() && !(document.body && document.body.getAttribute('data-page') === 'settings'); }
528
621
  if (!embedded) {
529
622
  window.OTBOptionsOverlay = { open: openOverlay, openProfile: openProfile, close: closeOverlay, isOpen: overlayOpen };
623
+ window.OTBGameMenu = { open: openMenu, close: closeMenu, isOpen: menuOpen };
530
624
  // A link to the menu (the visor's Options row, the profile chip, the start
531
625
  // screen's Options) opens the window in game mode instead of leaving the place.
532
626
  if (document.addEventListener) document.addEventListener('click', function (e) {
@@ -537,6 +631,8 @@
537
631
  try { to = new URL(a.getAttribute('href'), window.location.href); } catch (_) { return; }
538
632
  if (to.origin !== window.location.origin || to.pathname.replace(/\.html$/, '') !== menuPath()) return;
539
633
  e.preventDefault();
634
+ // the visor's Menu hint and the Actions sheet's last row: the game menu, not the options
635
+ if (a.getAttribute('data-game-menu') !== null) { openMenu(); return; }
540
636
  var q = to.search.replace(/([?&])from=[^&#]*&?/, '$1').replace(/[?&]$/, '');
541
637
  openOverlay(q + to.hash);
542
638
  }, true);
@@ -313,6 +313,16 @@
313
313
  /* THE PLATE ON THE SKY (task 1005196): words and controls that sit on the hour's sky stand
314
314
  on a small plate of the night, sized to what it holds, never on a darkened sky. */
315
315
  --night-plate: color-mix(in srgb, var(--bg) 88%, transparent);
316
+ /* THE SMOKED PANE (task 1005252, the owner's pick from the glass lab): a plate on the visor
317
+ blurs what is behind it, so a scene's own words and marks (a screenshot of the hall under
318
+ review carries a visor of its own) go soft instead of reading as a second visor. Lighter
319
+ than the plate, but the backdrop is dimmed too, so ice on it still holds AA over a white
320
+ sky; a lit top edge makes it a pane. Where blur is missing or less transparency is asked
321
+ for, the plate stays the night plate. */
322
+ --smoke: color-mix(in srgb, var(--bg) 62%, transparent);
323
+ --smoke-blur: blur(14px) saturate(.8) brightness(.85);
324
+ --smoke-sheen: linear-gradient(to bottom, color-mix(in srgb, var(--ink-soft) 16%, transparent), transparent 40%);
325
+ --smoke-edge: inset 0 1px 0 color-mix(in srgb, var(--ink-soft) 35%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--bg) 60%, transparent);
316
326
  --line-strong: 1.5px;
317
327
  --helm-panel-w: 400px;
318
328
  --helm-row: 60px;
@@ -504,6 +514,14 @@ html[data-faces="helm"] {
504
514
  --font-body: var(--helm-body);
505
515
  }
506
516
 
517
+ /* Less transparency asked for (task 1005252): the visor's smoked panes go back to plates of the
518
+ night. `html` lifts it over visor.css, which loads after this sheet. */
519
+ @media (prefers-reduced-transparency: reduce) {
520
+ html .visor__place, html .visor__who, html .visor__hint, html .visor__progress-words {
521
+ background: var(--night-plate); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none;
522
+ }
523
+ }
524
+
507
525
  /* The Kill Switch Rule (DESIGN.md). Nothing escapes it, and no motion is
508
526
  authored in JavaScript, because JavaScript motion escapes it. */
509
527
  @media (prefers-reduced-motion: reduce) {
@@ -891,6 +909,9 @@ html[data-theme="dark"] .brand-mark__mark { --mark-ink: #e2e1e4; }
891
909
  html[data-game="on"] .visor__hint:not([data-visor-sheet]) { display: none; }
892
910
  html[data-game="on"] .visor__hint--sheet { min-width: 44px; justify-content: center; }
893
911
  html[data-game="on"] .visor__hint--sheet .visor__dots { display: block; }
912
+ /* task 1005252: a phone keeps the night plate, not the smoked pane — a live blur under a HUD
913
+ that the scene pans beneath is the costly kind of drawing, and a phone has the weakest GPU */
914
+ html[data-game="on"] .visor__who, html[data-game="on"] .visor__hint { background: var(--night-plate); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
894
915
  html[data-game="on"] .visor__hint--sheet > :not(.visor__dots) { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
895
916
  html[data-game="on"] .visor__main .visor__key { display: none; }
896
917
  html[data-game="on"] .visor__sheet { bottom: calc(var(--visor-inset-bottom) + 112px); max-height: 60vh; }
@@ -1516,6 +1537,44 @@ html.opt-window-open { overflow: hidden; }
1516
1537
  }
1517
1538
  .opt-window__x:hover { color: var(--ink); background: var(--glass); }
1518
1539
  .opt-window__x:focus-visible { outline: 0; box-shadow: var(--press-focus); }
1540
+ /* THE GAME MENU (shell-game.js openMenu, task 1005250): game mode's Escape. One small
1541
+ night panel over the place, its rows stacked as buttons in three runs (what you can
1542
+ open, leaving, going back); Return to game is the one lit row. */
1543
+ html.game-menu-open { overflow: hidden; }
1544
+ .game-menu { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; }
1545
+ .game-menu[hidden] { display: none; }
1546
+ .game-menu::before { content: ''; position: absolute; inset: 0; background: var(--scrim); opacity: .33; pointer-events: none; }
1547
+ .game-menu__box {
1548
+ position: relative; box-sizing: border-box;
1549
+ width: min(300px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow-y: auto;
1550
+ padding: var(--ds-space-md);
1551
+ background: var(--night-glass);
1552
+ border: var(--line-strong) solid var(--rule);
1553
+ border-radius: var(--press-radius);
1554
+ box-shadow: var(--ds-shadow-pop);
1555
+ }
1556
+ .game-menu__title {
1557
+ margin: 0 0 var(--ds-space-md); text-align: center;
1558
+ font-family: var(--name-face); font-size: var(--ds-text-xl); font-weight: var(--name-weight);
1559
+ letter-spacing: var(--name-space); text-transform: var(--name-case); color: var(--ink);
1560
+ }
1561
+ .game-menu__items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ds-space-xs); }
1562
+ .game-menu__gap { margin-top: var(--ds-space-sm); }
1563
+ .game-menu__item {
1564
+ display: flex; align-items: center; justify-content: center; box-sizing: border-box;
1565
+ width: 100%; min-height: 40px; padding: var(--ds-space-hair) var(--ds-space-sm);
1566
+ background: var(--glass); color: var(--ink);
1567
+ border: var(--line) solid var(--rule); border-radius: var(--press-radius);
1568
+ font-family: var(--name-face); font-size: var(--ds-text-xl); font-weight: 400;
1569
+ letter-spacing: var(--name-space); text-transform: var(--name-case); text-align: center;
1570
+ text-decoration: none; cursor: pointer;
1571
+ }
1572
+ .game-menu__item:hover { color: var(--ink); border-color: var(--ink-soft); text-decoration: none; }
1573
+ .game-menu__item:focus-visible { outline: 0; box-shadow: var(--press-focus); }
1574
+ .game-menu__item--lit { background: var(--accent); color: var(--accent-on); border-color: transparent; font-weight: var(--name-weight-lit); }
1575
+ .game-menu__item--lit:hover { background: var(--accent-lift); color: var(--accent-on); border-color: transparent; }
1576
+ .game-menu__note { margin: var(--ds-space-sm) 0 0; font-size: var(--ds-text-md); line-height: 1.5; text-align: center; text-wrap: pretty; color: var(--ink); }
1577
+ .game-menu__note[hidden] { display: none; }
1519
1578
  /* Said once on entering, since the choice is remembered and a page can open in it. */
1520
1579
  .fullscreen-hint {
1521
1580
  position: fixed;
@@ -79,6 +79,17 @@ html[data-game="on"] .visor:not([hidden]) {
79
79
  font-size: var(--ds-text-xs); letter-spacing: var(--name-space); line-height: 1; color: var(--ink-soft);
80
80
  padding: var(--ds-space-hair) var(--ds-space-xs); background: var(--night-plate); clip-path: var(--cut-path-sm);
81
81
  }
82
+ /* smoked glass (task 1005252): where the browser can blur, each plate is a pane of smoked glass
83
+ that softens the scene behind it, so a scene that carries marks of its own (the review of a
84
+ hall screenshot) cannot line up with ours. Still sized to its words: the sky outside the
85
+ plates is untouched. Asking for less transparency puts the night plate back (style.css,
86
+ beside the Kill Switch, since the visor adds no media query of its own). */
87
+ @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
88
+ .visor__place, .visor__who, .visor__hint, .visor__progress-words {
89
+ background: var(--smoke-sheen), var(--smoke); box-shadow: var(--smoke-edge);
90
+ -webkit-backdrop-filter: var(--smoke-blur); backdrop-filter: var(--smoke-blur);
91
+ }
92
+ }
82
93
 
83
94
  /* the foot row: you on the left, the one thing to press in the middle, the key hints on the
84
95
  right. The two side groups lean in a little, as the glass curves. */
@@ -16,8 +16,9 @@
16
16
  // went with it).
17
17
  // Centre: your character's main action, the one accent button, with its key. Right: the
18
18
  // quiet key hints, no boxes: your other actions and "Actions" (key A), which opens the
19
- // sheet (your character's actions, then the keys of the place you are in, then Options on
20
- // a key of its own; Escape closes it). Options is only in the sheet (task 1005248).
19
+ // sheet (your character's actions, then the keys of the place you are in, then the game
20
+ // menu on a key of its own; Escape closes it). It is only in the sheet (task 1005248; the
21
+ // game menu since task 1005250).
21
22
  // - every key comes from resolveKeys: one owner per key, never two (task 1005248).
22
23
  //
23
24
  // The rules it keeps (ADR 0399):
@@ -65,7 +66,9 @@
65
66
  };
66
67
  var SHEET_KEY = 'a';
67
68
  var CLOSE_KEY = 'esc'; // shown once: the sheet's Close (Escape)
68
- var OPTIONS = { id: 'options', label: 'Options', key: 'o', href: '/settings' };
69
+ // The sheet's last row is the game menu (options, log out, exit; task 1005250): shell-game.js
70
+ // opens it for a data-game-menu link, and the address (the options) is the fallback.
71
+ var OPTIONS = { id: 'options', label: 'Game menu', key: 'm', href: '/settings' };
69
72
  // Keys a scene binds on its own page (the sky's L 0 / ?, the gallery's F, the palette's K).
70
73
  // A moved key never lands on one of them; a scene that declares one keeps it.
71
74
  var AVOID = ['l', '0', '/', '?', 'f', 'k', '+', '-', '='];
@@ -377,7 +380,7 @@
377
380
  '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml(CLOSE_KEY) + '</button></div>' +
378
381
  '<p class="visor__cap">' + esc(NAMES[id]) + '</p><ul class="visor__rows">' + rows + '</ul>' +
379
382
  (place ? '<p class="visor__cap">This place</p><ul class="visor__rows">' + place + '</ul>' : '') +
380
- '<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
383
+ '<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" data-game-menu href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
381
384
  }
382
385
  function shown() { return !!(root && !root.hidden); }
383
386
  function render() {
@@ -460,6 +463,7 @@
460
463
  return;
461
464
  }
462
465
  // Options opens the game's options window over the place when it can (shell-game.js)
466
+ if (a.id === OPTIONS.id && window.OTBGameMenu && typeof window.OTBGameMenu.open === 'function') { window.OTBGameMenu.open(); return; }
463
467
  if (a.id === OPTIONS.id && window.OTBOptionsOverlay && typeof window.OTBOptionsOverlay.open === 'function') { window.OTBOptionsOverlay.open(); return; }
464
468
  if (a.href) window.location.assign(hallHref(a.href));
465
469
  }
@@ -475,6 +479,7 @@
475
479
  if (t.closest('[data-visor-unfocus]')) { dropFocus(); return; }
476
480
  if (t.closest('[data-visor-lead]')) { if (focus && focus.lead && !focus.lead.busy) { closeSheet(false); focus.lead.run(); } return; }
477
481
  if (t.closest('[data-visor-sheet]')) { openSheet(); return; }
482
+ if (t.closest('[data-game-menu]')) { closeSheet(false); return; } // shell-game.js opens the menu
478
483
  // You: your profile in the game's window, the place still drawn behind it. With no
479
484
  // window (shell-game.js absent), the link opens the profile page.
480
485
  if (t.closest('[data-visor-me]')) {
@@ -502,7 +507,11 @@
502
507
  function busy() {
503
508
  var html = document.documentElement;
504
509
  if (html.classList && (html.classList.contains('craft-picker-open') || html.classList.contains('gs-open'))) return true;
505
- return !!document.querySelector('dialog[open], [aria-modal="true"]');
510
+ // a dialog that is up: one bound once keeps aria-modal while it is hidden (the options
511
+ // window, the game menu), and a hidden one must not hold the keys (task 1005250)
512
+ var up = document.querySelectorAll('dialog[open], [aria-modal="true"]');
513
+ for (var i = 0; i < up.length; i++) if (!up[i].closest('[hidden]')) return true;
514
+ return false;
506
515
  }
507
516
  // Which action (or the sheet) a key press means, or null. Pure, for the tests.
508
517
  function keyFor(id, e) {
@@ -0,0 +1,22 @@
1
+ # The game menu — game mode's Escape: options, log out, exit (task 1005250)
2
+
3
+ Covers: every game place (`shell-game.js` `openMenu`, `style.css` `.game-menu`), and the visor's two ways in (`visor.js`). Game mode itself is in `game-mode.md`; the options window it opens is in `options-menu.md`.
4
+
5
+ ## What it settled (owner, 2026-11-12: "a game menu like in WoW")
6
+
7
+ - **Why.** Game mode has no sidebar, so it had no Sign out and no way to quit. Escape opened the options straight away, and the options held neither.
8
+ - **One small menu over the place.** In game mode the last Escape (one no dialog, sheet, picker or scene used) opens it; Escape again, the scrim or Return to game shuts it. It is the hall's shared dialog (`OTBDialog.bind`): Escape, the scrim, the focus trap and the focus return are the dialog's. The arrows walk the rows.
9
+ - **The rows** (`GAME_MENU`), in three runs: **Options** (shuts the menu and opens the options window), **Change character** (craft-mode's own `changeCharacter`, with its own confirm; no row on a page without it); **Log out**, **Exit game**; **Return to game**, the one lit row.
10
+ - **Log out is a link to the sign-out address.** `shell.js` already turns a click on any such link into the real sign-out (the POST, the cookies, the bounce to the front page), so the menu writes no sign-out of its own. Signing in again passes the view chooser and the start screen, as every sign-in does.
11
+ - **Exit game closes the tab**, and does not sign out (the start screen's Quit, the same way). A browser only closes a tab a script opened; when the tab is still there 300ms later the menu says "Your browser kept this tab open. Close it to quit."
12
+ - **The visor.** The Actions sheet's last row is "Game menu" on M (it was "Options" on O), and M on the glass opens it too. Escape is on the glass nowhere: one owner per key (task 1005248), and the shell's last Escape is not a visor key. The row is a link to `/settings` carrying `data-game-menu`; `shell-game.js` opens the menu for such a link, and the address is the fallback. A plain link to `/settings` (the profile chip, the start screen's Options) still opens the options window directly.
13
+ - **Where it does not open.** On the options menu's own page and inside its frame, Escape is the options menu's, as before. Outside game mode there is no game menu: classic has the sidebar's Sign out. A page that cannot draw it (no `OTBDialog`) opens the options, as before the menu existed.
14
+ - **A visor fix that came with it.** `visor.js` `busy()` counted a hidden dialog as up: a dialog bound once keeps `aria-modal` while hidden, so after the options window (or this menu) had been opened and shut once, the visor's letter keys stayed dead until a reload. It now skips a dialog inside `[hidden]`.
15
+ - **A new advanced action** is one row in `GAME_MENU` and one line in `onMenuClick`.
16
+
17
+ ## Not done
18
+
19
+ - The start screen is not shown again on Log out without a sign-in in between; "back to the start screen, still signed in" would be its own row.
20
+ - Exit game does not sign out. If it should, it is one line in `exitGame`.
21
+
22
+ Tests: `tests/hall_game_menu.mjs` (the real `shell-game.js`, `hall-dialog.js` and `visor.js` in a real browser: Escape, each row, the visor's two ways in, a phone width, classic mode); `tests/hall_visor.mjs` (the hint and the sheet row); `tests/hall_options_menu.mjs` (the tip, and the fallback to the options window).
@@ -8,7 +8,7 @@ Covers: every shelled hall page (`shell-game.js`, `shell.js`). Spec: `docs/specs
8
8
  - **Game mode is the existing full-screen switch, made hall-wide. There is no second full-screen mode.** The switch was already on every page (task 1004430) but remembered per page, so a reader had to turn it on 33 times. Game mode is one `localStorage` key, `hall-game`; while it is `on`, `shell.js` `storedFullscreen()` answers yes for every page, whatever that page remembers. What hides, the corner button and the Escape hand-off are the shell's, unchanged.
9
9
  - **`shell-game.js` holds the mode and nothing else**: `window.OTBShellGame` = `on()`, `set(bool)`, `hint()`. It stamps `html[data-game="on"]` for a sheet or script that wants to read the mode. `shell.js` reads it through a fallback object, so a page without the file is never in game mode. It is its own file because `shell.js` sits on the 1,500-line ratchet; the start screen and options menu (R03, R04) are expected to grow here, not in `shell.js`.
10
10
  - **Load order**: `shell-game.js` before `shell-trail.js` / `craft-mode.js` / `shell.js`, loaded the same way (deferred or not) as the shell on that page. Test-pinned.
11
- - **Game or classic, never both (task 1005200, owner 2026-11-11).** Inside game mode there is no way back to the bars: the corner button is hidden (`style.css`) and does nothing if pressed, Escape opens the options menu, and the "how to leave" line is never shown. The only switch is Options → Preferences → View (`settings-view.js`), plus the chooser at every sign-in (`records/start-screen.md`). A page's own `hall-fullscreen:<page>` memory is still never overwritten, so switching to classic hands each page back what it remembered.
11
+ - **Game or classic, never both (task 1005200, owner 2026-11-11).** Inside game mode there is no way back to the bars: the corner button is hidden (`style.css`) and does nothing if pressed, Escape opens the game menu (options, log out, exit; task 1005250, `records/game-menu.md`), and the "how to leave" line is never shown. The only switch is Options → Preferences → View (`settings-view.js`), plus the chooser at every sign-in (`records/start-screen.md`). A page's own `hall-fullscreen:<page>` memory is still never overwritten, so switching to classic hands each page back what it remembered.
12
12
  - **Game mode reaches only the game's places** (`shell-game.js` `PLACES`: home, city, goal-map, thinking, atlas, settings, profile). Any other hall page `location.replace`s to the city on load, and so does switching to game on one. A screen embedded in the options menu is exempt. The visor drops an action whose `href` is not a place (`visor.js` `inGame`). A page joins `PLACES` when it has a game version.
13
13
  - **The choice is kept on the account**: `GET/PATCH /api/bongos/me/hall-view` (`modules/builder-settings/hall-view-prefs.js`, key `hall_view` in `behavior_prefs`, null = never chosen). `hall-game` in localStorage is only the copy a page paints from; `sync()` reads the account on every page load and switches if another browser changed it.
14
14
  - **No page may opt out.** `<body data-fullscreen-switch="off">` would keep that page's sidebar in game mode; the test fails on one. A new hall page needs `#app-topbar` and the `shell-game.js` tag, like the rest.
@@ -17,6 +17,6 @@ Covers: every shelled hall page (`shell-game.js`, `shell.js`). Spec: `docs/specs
17
17
  ## For the tasks after this one
18
18
 
19
19
  - A HUD or options menu that wants Escape must call `preventDefault()` on the press. `shell.js` `onFullscreenKey` waits one tick before leaving so a listener added after it still wins.
20
- - Since R04 (task 1005144, ADR 0401) the last Escape in game mode opens the options menu (`shell-game.js` `onEscape`), and on the menu it closes it again. Since task 1005200 there is no way back to the bars inside game mode.
20
+ - Since R04 (task 1005144, ADR 0401) the last Escape in game mode was the options menu's (`shell-game.js` `onEscape`); since task 1005250 it opens the game menu, whose Options row opens the options (`records/game-menu.md`). On the options menu's own page it still closes it. Since task 1005200 there is no way back to the bars inside game mode.
21
21
 
22
22
  Tests: `tests/hall_fullscreen.mjs` (the eight `game mode:` cases run the real `shell-game.js` + `shell.js`), and the script lists in `tests/hall_city.mjs`, `tests/hall_street.mjs`, `tests/hall_my_agents.mjs`, `tests/goal_map_page.mjs`.
@@ -27,7 +27,7 @@ The menu is a Helm screen, written from the hall tier's tokens alone (`settings.
27
27
 
28
28
  ## The options window (owner, 2026-11-11: "WoW sizing, an overlay")
29
29
 
30
- - **In game mode the menu opens over the place, not as a page.** Escape away from the menu, or a click on any link to `/settings` (the visor's Options row, the profile chip), draws one window (`shell-game.js` `openOverlay`, `window.OTBOptionsOverlay`), built on the shared dialog (`OTBDialog.bind`: Escape, scrim, close button, focus trap and return), with the page behind made `inert` while it is up, holding the menu framed with `?embed=1`; the place stays drawn behind it. Escape, the scrim or the × shuts it; `options-menu.js` `close()` shuts the window when it is in one. A page that cannot draw it falls back to the menu page. A plain visit to `/settings` is the page, as before.
30
+ - **In game mode the menu opens over the place, not as a page.** The game menu's Options row (task 1005250, `records/game-menu.md`; until then Escape itself), or a click on any link to `/settings` (the profile chip), draws one window (`shell-game.js` `openOverlay`, `window.OTBOptionsOverlay`), built on the shared dialog (`OTBDialog.bind`: Escape, scrim, close button, focus trap and return), with the page behind made `inert` while it is up, holding the menu framed with `?embed=1`; the place stays drawn behind it. Escape, the scrim or the × shuts it; `options-menu.js` `close()` shuts the window when it is in one. A page that cannot draw it falls back to the menu page. A plain visit to `/settings` is the page, as before.
31
31
  - **Window sized, see-through.** The window is `min(1040px, …) × min(720px, …)` of glass at 48% of the ground with a light blur, over the shared `--scrim` at a third. Every embedded screen is drawn at `zoom: 0.82` with no ground of its own (`style.css`), and the menu inside it takes smaller words (`settings.css`, `html[data-embed="on"]`); a window-high length divides by the zoom (`--opt-vh`).
32
32
  - **One review palette.** A framed screen docks no artist palette of its own (`craft-mode.js` `applyDocked`); the page around it has one.
33
33
  - **Tests:** `tests/hall_options_menu.mjs` ("away from the menu, Escape opens the options as a window…"), `tests/page_notes_panel.mjs` (the frame docks no panel).
@@ -8,9 +8,9 @@ Covers: Home (`shell-game.js` `start()`, `shell-game.css`, the hand-off in `welc
8
8
  - **Five choices, one column: Start, Options, Achievements, Extras, Quit.** Start is the one filled pill. Options is a link to `/settings`, which IS the one options menu since task 1005144 (`records/options-menu.md`); Achievements is `/profile`. Extras opens three links under the menu (the thinking sky, the atlas, the docs). Quit calls `window.close()`; a browser only closes a tab a script opened, so when the tab is still there 300ms later the screen says "Close it to quit" instead of doing nothing.
9
9
  - **Start turns game mode on** (`set(true)`), then shows the load screen. Nothing else in the hall's interface sets it.
10
10
  - **The load screen is the still, a tip, and the city building up.** The still is the welcome's painting for the time of day, handed in by `welcome-boot.js` (this file names no picture). In a card of the hall's own tokens, the project city is drawn by `home-city-draw.js` `citySvg` from the one bounded read Home already uses (`GET /home/city?weeks=10`), at 31 stops from the oldest week to now, about three seconds, with "The city, N weeks ago" under it. Each stop is a whole drawing set as state; nothing animates in JavaScript. With reduced motion it is the finished city at once. With no city to draw (the read failed, the scripts are missing) the still and the tip stand for 1.4s. Either way the world opens.
11
- - **Tips rotate** (`localStorage 'hall-game-tip'` counts them), so each load shows the next one. A tip must be true of the hall today; the first one is the Escape rule from `game-mode.md` (rewritten by task 1005144: Escape opens the options menu).
11
+ - **Tips rotate** (`localStorage 'hall-game-tip'` counts them), so each load shows the next one. A tip must be true of the hall today; the first one is the Escape rule from `game-mode.md` (rewritten by task 1005144, and again by task 1005250: Escape opens the game menu).
12
12
  - **The craft picker waits for the world.** While the start screen is up the shell asks no picker (`onAccess` returned true). `done` runs once the screen is gone and asks `OTBCraftMode.onAccess` then.
13
- - **Keys.** While either screen is up it keeps every key: the arrows and Tab walk the menu, Escape folds Extras away and does nothing else, so the shell's Escape cannot flip the bars underneath. Once the world shows, the last Escape opens the options menu (task 1005144); the screen's capture listener calls `preventDefault`, so the menu never opens from under it.
13
+ - **Keys.** While either screen is up it keeps every key: the arrows and Tab walk the menu, Escape folds Extras away and does nothing else, so the shell's Escape cannot flip the bars underneath. Once the world shows, the last Escape opens the game menu (task 1005250; the options menu until then); the screen's capture listener calls `preventDefault`, so the menu never opens from under it.
14
14
  - **The look is Helm (task 1005192, [ADR 0402](../../../docs/adr/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md)).** The start screen is one full-height night panel over the painting (`--night-glass`, an ice hairline left and right at `--line-strong`), `--helm-panel-w` wide. On a desk it stands off the left edge by `--helm-inset`; that token is a `clamp`, so on a phone the panel is the whole width and the sheet still has no `@media`. In it: the mark (the project's logo when it has one) over the name in the name voice; Start as a full-width accent bar under `--accent-on` that names its key ("Enter", drawn by CSS so the choice's own text stays "Start"); the other four as quiet rows on `--rule-soft` hairlines; and at the foot, over the strong hairline, the tip the load screen will show next (`#gs-line`), with the Quit note under it when the browser keeps the tab. The rows are list rows in a panel, so they are bars, not cut buttons. A row's focus is the inset two-tone ring (`--press-focus`), which is what makes focus visible on the accent bar.
15
15
  - **The load screen is the same panel, centred** over the painting under the hall's `--scrim`: the week in the name voice (`#gs-when`), one segment per week of the history (`#gs-segs`, the shared `.segs` / `.seg`; filled up to the week being shown, counted the way the sentence words it, and hidden from a screen reader because the sentence says the same), the city, and the tip under a hairline. With no city there are no segments.
16
16
  - **The literal floor is zero.** Every colour, size, corner, shadow, face and measure in `shell-game.css` is a hall-tier token (`style.css`); `tests/hall_start_screen.mjs` fails on a typed one. The fade into the world is one CSS opacity transition, stopped by the hall's reduced-motion rule.
@@ -12,6 +12,9 @@ The visor is the control and the scene is the view: every main call to action in
12
12
  - **The rule.** A key in `ACTIONS` or a scene's registration is a recommendation. `resolveKeys(id)` in `visor.js` hands every key out once, and the glass, the sheet and `keyFor` all read it; nothing else names a key. Who keeps a key, in order: the visor's own (A Actions, Escape Close); the keys a scene binds itself (an action without `bind`, the `keys` lines), because the visor cannot move a key it does not bind, so a second claim on one shows no key; the character's actions; the scene's `bind` actions; Options. A later claimant moves to the next free letter of its own name, then of the alphabet, never onto a key a scene binds on its page (`AVOID`: L 0 / ? F K + - =); with none free it has no key.
13
13
  - **Options is only in the sheet** (the owner likes it there), on O, or its next free letter (the Patron's O is the Board room, so P). Pressing it opens the options window over the place, like a click. Escape on the glass is the sheet's Close only. The shell's "last Escape opens the options menu" (`shell-game.js`, ADR 0399 §2.4) is not a visor key and is unchanged.
14
14
  - **Tests:** `tests/hall_visor.mjs` proves no shared key for every character over the sky, the city and Home, and over a hostile scene that claims A, Escape, a character key and one key twice.
15
+ ## The game menu on the glass (task 1005250)
16
+
17
+ The Actions sheet's last row is "Game menu" on M, where it was "Options" on O: it opens the game menu (options, log out, exit; `records/game-menu.md`), and the options are a row inside that. The key rule above is unchanged: Escape is on the glass nowhere, and the shell's last Escape now opens the game menu. `busy()` no longer counts a hidden dialog as up. Where an older bullet below says "Options (Esc)", this is current.
15
18
 
16
19
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
17
20
 
@@ -55,6 +58,7 @@ The glass was redrawn to the owner's mock-up "Cloud city: in the visor, minimal"
55
58
  - **The four progress segments (task 1005203).** Under the place: the version the project is building, in quarters, then the same reading in words (`BONGOS-V2 · 89%`; the screen reader hears the version's name too). The owner chose the measure (2026-11-11): the version's `percent_complete` from the public `GET /versions/progress`, the number /status reports. The version is the starred focus (`GET /versions/focus`) while it is still open, else the first one `building`; with neither, or a failed read, no segments. A quarter wholly built is the soft ink, the quarter being built is the accent, the rest are outlines; each wears an outline of the night, and the words stand on a night plate (`--night-plate`, the rule task 1005196 set), for a midday sky. The arc band is `--visor-arc` (64px) and the scene inset grew to 88px (`--visor-inset-top`), so the sky's top offsets in `thinking.css` grew by 24px. Hidden on a phone, with the place. Harness fixture: `versions__progress.json`.
56
59
  - **In the mock-up, not built, because the visor has no data for it:** the marker and name on the building in view (the visor owns no camera, ADR 0399 §2.2), and the party bars with a speaker's name and a Voice key. Each is a scene or a later task handing the visor real data; none is drawn empty.
57
60
  - **It reads over any sky, with no per-hour style, and it darkens none of it** (task 1005196, owner 2026-11-11: "I don't like how dark the edges of the view are"). The glass paints nothing of its own: the bands of `--night-glass` at its top and bottom edge are gone, and so is the dark halo round its type. The marks carry themselves. The place, each key hint and your name and credits stand on a small cut plate of the night (`--night-plate`, the ground at 88%), sized to the words; every line is drawn twice (a wider solid stroke of the night under the ice hairline, `.visor__line--under`), so an arc is ice on night and a dark line on cream cloud; a key sits on a chip of the night. One new token, `--night-plate`. Tried first and dropped: a 4px night stroke round each letter with no plate, which filled the condensed capitals in.
61
+ - **The plates are smoked glass** (task 1005252, the owner's pick from a lab of seven treatments, 2026-11-12). Reviewing a screenshot of the hall put a second visor under ours, and solid plates stacked on its plates. Where the browser can blur, the place, each hint, your name and credits and the progress words are panes: the scene behind them blurred, slightly dimmed and greyed (`--smoke-blur`), a lighter tint of the night (`--smoke`, 62%), a faint sheen (`--smoke-sheen`) and a lit top edge (`--smoke-edge`). Still sized to the words, so the sky outside them is untouched. Asking for less transparency, or a browser without backdrop blur, gets the night plate (the fallback is in style.css, because visor.css carries no media query). Tried and not picked: glass bands top and foot (bring back the band task 1005196 removed), a blurred lens rim, and halos only (too weak over a white sky). On a phone (the shared 640px block in style.css) the hints and your credits keep the night plate: a live blur under a HUD the scene pans beneath costs every frame, and a phone has the weakest GPU. Every colour in the four smoke tokens derives from `--bg` or `--ink-soft`. **Seen and approved (2026-11-12):** the builder who asked for it picked smoked glass in the live lab (all seven treatments over four backdrops), then saw before/after renders of the real visor sheets (a midday sky, and a screenshot of the visor under the visor) and said "Ship it". They then saw it staged: the real hall served by the local preview harness (Home in game mode, the artisan held, the city pannable under the glass), and answered "Looks good, land it". Not yet looked at: the night hour.
58
62
  - **A phone.** The shared phone block in `style.css` (no breakpoint of the visor's own): the top arc, its notch and the place step aside (the top of a phone is the scene's and the shell's); the foot row stays one line with nothing leaning (the sprite, the count over its word, the accent button whose name may wrap and whose key is left to the sheet, and of the hints only Actions, as three dots on a night chip). Between a phone and a wide desk the hints wrap onto a second line, right-aligned, rather than run into the button.
59
63
  - **Scenes.** The top arc, the notch and the place sit inside the top 64px (`--visor-arc`; the segments under them take the inset to 88px, `--visor-inset-top`), so no scene's top row is struck through. Home no longer shifts its greeting row left (there is no pill top-right to clear). The city's door rises above the foot row at every width, because the accent button is centred where the door stood. The sky's `visor-on` offsets are unchanged.
60
64
  - **Looking at it.** The stand-in scene in `tests/hall_visor.mjs` over the hall's real sheets; put a bright gradient behind it to check midday.
@@ -134,6 +134,9 @@ function githubChecklist(status, appSlug, facts = {}) {
134
134
  // what "is alive" waits for — device flow is optional by design
135
135
  connected: approve === 'done' && install === 'done' && signIn === 'done',
136
136
  wired_elsewhere: elsewhere,
137
+ // GitHub sent the founder back from the install page (or they asked for a check) and
138
+ // the runner has not confirmed it yet: the page shows the step as being checked
139
+ install_checking: install !== 'done' && !!(facts.installReportedAt || facts.checkRequestedAt),
137
140
  };
138
141
  }
139
142
 
@@ -533,7 +533,10 @@ body:has(#view-new:not([hidden])) footer.foot{position:relative;z-index:1;backgr
533
533
  .setFact .optional{margin-left:8px;font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);}
534
534
  .setFact.done .d{background:color-mix(in srgb,var(--ok) 12%,transparent);color:var(--ok);border-color:var(--ok);}
535
535
  .setFact.done .t,.setFact.now .t,.setFact.you .t{color:var(--ink);}
536
- .setFact.now .d{border:2px solid var(--accent);}
536
+ .setFact.now .d{border:2px solid var(--accent);border-top-color:transparent;animation:factSpin 1s linear infinite;}
537
+ /* a loading wheel is essential, so it turns even with reduced motion (slower, no pulse) */
538
+ @media (prefers-reduced-motion:reduce){.setFact.now .d{animation-duration:2s;}}
539
+ @keyframes factSpin{to{transform:rotate(360deg)}}
537
540
  .setFact.you .d{background:color-mix(in srgb,var(--accent) 16%,transparent);border-color:color-mix(in srgb,var(--accent) 60%,transparent);color:var(--ink);}
538
541
  .setFact.bad .d{background:color-mix(in srgb,var(--err) 10%,transparent);color:var(--err);border-color:var(--err);}
539
542
  .setFact.bad .t{color:var(--err);}
@@ -548,8 +551,7 @@ body:has(#view-new:not([hidden])) footer.foot{position:relative;z-index:1;backgr
548
551
  .setFact .acctPick .acctTyped{margin-top:10px;}
549
552
  .setFact .acctPick .hint{min-height:0;}
550
553
  @media (prefers-reduced-motion:no-preference){
551
- .setFact.now .d{animation:factBreathe 1.4s ease-in-out infinite,factSpin 1s linear infinite;border-top-color:transparent;}
552
- @keyframes factSpin{to{transform:rotate(360deg)}}
554
+ .setFact.now .d{animation:factBreathe 1.4s ease-in-out infinite,factSpin 1s linear infinite;}
553
555
  .setNow.working::after{content:"";display:inline-block;width:1.2em;text-align:left;animation:workDots 1.4s steps(4,end) infinite;}
554
556
  @keyframes workDots{0%{content:""}25%{content:"."}50%{content:".."}75%{content:"..."}}
555
557
  @keyframes factBreathe{0%,100%{border-color:var(--accent)}50%{border-color:color-mix(in srgb,var(--accent) 35%,transparent)}}
@@ -6933,6 +6935,7 @@ summary{min-height:24px;padding:3px 0;}
6933
6935
  sentence and carries the action that fixes it, with the app GitHub already made, never a
6934
6936
  second one. live.pickUp is the server's word for what that action does (github-setup.js
6935
6937
  pickUpFor); live.verifying is whether the runner is still checking. */
6938
+ var installOpened = false; /* this page sent the founder to GitHub's install page */
6936
6939
  var ghTabTrouble = null; /* 'behind' | 'closed': what this page saw of the GitHub tab it opened */
6937
6940
  var NO_SECOND_APP = 'Nothing is lost, and no second app will be made.';
6938
6941
  function projectName() { return state.name || state.slug || 'your project'; }
@@ -7067,6 +7070,10 @@ summary{min-height:24px;padding:3px 0;}
7067
7070
  /* the runner stopped checking and GitHub never confirmed the install: the link opens the
7068
7071
  SAME app's install page, and the click asks the runner to look again */
7069
7072
  else if (live.pickUp === 'check') { install.state = 'bad'; install.sub = 'GitHub stopped before the app was on ' + (repo || 'your repo') + '.'; install.right = checked; install.act = 'pick-up-link'; install.href = installUrl; }
7073
+ /* the founder came back from GitHub's install page (GitHub reported it, or they pressed
7074
+ the link here): the runner is checking with GitHub, so the step spins until it ticks,
7075
+ and the link stays in case they never finished */
7076
+ else if ((live.setup && live.setup.install_checking) || installOpened) { install.state = 'now'; install.sub = (repo ? repo + '. ' : '') + 'Checking with GitHub. This ticks by itself.'; install.right = checked; install.act = 'install-link'; install.href = installUrl; }
7070
7077
  else { install.state = 'you'; install.right = 'not yet seen'; install.act = 'install-link'; install.href = installUrl; }
7071
7078
 
7072
7079
  var signIn = { key: 'sign_in', title: 'Builders can sign in' };
@@ -7313,6 +7320,7 @@ summary{min-height:24px;padding:3px 0;}
7313
7320
  var ib = $('installBtn'), instRow = gh[1];
7314
7321
  var instHref = instRow && instRow.state !== 'done' && homeUp && (instRow.act === 'install-link' || instRow.act === 'pick-up-link') ? installHrefOf(instRow) : '';
7315
7322
  if (instHref) ib.setAttribute('href', instHref); else ib.removeAttribute('href');
7323
+ if (!ib.getAttribute('data-wired')) { ib.setAttribute('data-wired', '1'); ib.addEventListener('click', function () { installOpened = true; paintFacts(); }); }
7316
7324
  ib.hidden = !instHref;
7317
7325
  paintSetDone(ready, home, gh);
7318
7326
  tipsRun(!ready);