@bongos/core 1.24.85 → 1.24.86

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.
@@ -472,8 +472,11 @@
472
472
  function toolNode(t) { return document.getElementById('craft-tool-' + t.id); }
473
473
  // A docked tool has no button: in its mode the panel is docked at the edge,
474
474
  // out of it the panel goes. Only a tool already loaded needs closing.
475
+ // A screen inside the options menu's frame docks nothing: the menu page around
476
+ // it already docks its own, and a second panel at the frame's edge is a double.
475
477
  function applyDocked(mode) {
476
- var want = toolsFor(mode);
478
+ var framed = !!(window.OTBShellGame && window.OTBShellGame.embedded && window.OTBShellGame.embedded());
479
+ var want = framed ? [] : toolsFor(mode);
477
480
  for (var m in TOOLS) TOOLS[m].forEach(function (t) {
478
481
  if (!t.docked) return;
479
482
  if (want.indexOf(t) !== -1) loadTool(t).then(function (api) { if (api.dock) api.dock(); }, function () { /* the page still works */ });
@@ -363,6 +363,10 @@
363
363
 
364
364
  // Close the menu (game mode's Escape): back where it was opened from.
365
365
  function close() {
366
+ // Opened as the game's options window over a place (shell-game.js): shut the window.
367
+ var holder = null;
368
+ try { holder = window.parent !== window ? window.parent.OTBOptionsOverlay : null; } catch (_) { holder = null; }
369
+ if (holder && holder.isOpen && holder.isOpen()) { holder.close(); return; }
366
370
  var to = state.from || closeTo(window.location.search);
367
371
  if (to) { window.location.assign(to); return; }
368
372
  var ref = '';
@@ -129,6 +129,21 @@ html[data-fullscreen="on"] .opt-title { display: block; }
129
129
  .opt-embed__frame { display: block; width: 100%; height: calc(100vh - 96px - var(--opt-screen-h)); min-height: 480px; border: 0; border-radius: var(--press-radius); background: transparent; }
130
130
  html[data-fullscreen="on"] .opt-embed__frame { height: calc(100vh - 48px - var(--opt-screen-h)); }
131
131
 
132
+ /* Inside the game's options window (shell-game.js): the window is the chrome, so the
133
+ menu sits flush at its top and the open screen fills the rest of it. The page is
134
+ drawn at the embed zoom (style.css), so a window-high length divides by it. */
135
+ html[data-embed="on"] .settings-layout { --opt-vh: calc(100vh / 0.82); padding: var(--ds-space-md) var(--ds-space-lg) 0 0; }
136
+ html[data-embed="on"] .opt-rail { top: 0; max-height: calc(var(--opt-vh) - var(--ds-space-md)); margin-top: calc(-1 * var(--ds-space-md)); padding-top: var(--ds-space-lg); }
137
+ html[data-embed="on"] .opt-title { display: block; font-size: var(--ds-text-xl); }
138
+ /* the window's glass is the ground, so the menu adds none; and window-sized words */
139
+ html[data-embed="on"] .opt-rail { background: none; }
140
+ html[data-embed="on"] .opt-search input { min-height: 34px; font-size: var(--ds-text-sm-plus); }
141
+ html[data-embed="on"] .opt-link { min-height: 30px; font-size: var(--ds-text-md); }
142
+ html[data-embed="on"] .opt-screen { font-size: var(--ds-text-xl); }
143
+ html[data-embed="on"] .settings-groups { --opt-screen-h: calc(var(--ds-space-lg) + var(--ds-space-md)); }
144
+ html[data-embed="on"] .settings-groups .scroll__h { font-size: var(--ds-text-md); }
145
+ html[data-embed="on"] .opt-embed__frame { height: calc(var(--opt-vh) - var(--opt-screen-h) - var(--ds-space-xl)); min-height: 0; }
146
+
132
147
  @media (max-width: 860px) {
133
148
  /* Collapse to one column: the menu sits above the open screen, no longer sticky. */
134
149
  .settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--sp-md); }
@@ -406,11 +406,91 @@
406
406
  try { e.preventDefault(); } catch (_) { /* already finished */ }
407
407
  var host = embedded ? window.parent : window;
408
408
  if (host && host.OTBOptions && typeof host.OTBOptions.escape === 'function') { host.OTBOptions.escape(); return; }
409
+ if (host && host.OTBOptionsOverlay && host.OTBOptionsOverlay.isOpen()) { host.OTBOptionsOverlay.close(); return; }
409
410
  if (embedded) return;
410
- var here = String(window.location.pathname || '') + String(window.location.search || '') + String(window.location.hash || '');
411
- window.location.assign(menuPath() + '?from=' + encodeURIComponent(here));
411
+ openOverlay();
412
412
  }, 0);
413
413
  }
414
+
415
+ // ---------- the options window (game mode) ----------
416
+ // In game mode the options menu opens as a window OVER the place you are in, not
417
+ // as a page of its own, so the place stays drawn behind it and its colours show
418
+ // through (the owner's call, 2026-11-11: "WoW sizing, an overlay"). The window
419
+ // is a frame of the menu with ?embed=1; the menu page itself (a plain visit to
420
+ // /settings) is unchanged. It is the hall's shared dialog (OTBDialog.bind,
421
+ // hall-dialog.js): Escape, the scrim, the close button, the focus trap and the
422
+ // focus return are its. The page behind is made inert while it is up, because Tab
423
+ // leaving the frame's last control lands in the page, past the dialog's own trap.
424
+ var overlay = null, dialog = null;
425
+ function overlayOpen() { return !!(dialog && dialog.isOpen()); }
426
+ function closeOverlay() { if (overlayOpen()) dialog.close(); }
427
+ function setInert(on) {
428
+ var kids = document.body && document.body.children ? document.body.children : [];
429
+ for (var i = 0; i < kids.length; i += 1) {
430
+ var k = kids[i];
431
+ if (k === overlay || !k.setAttribute) continue;
432
+ if (on) { if (!k.hasAttribute('inert')) { k.setAttribute('inert', ''); k.setAttribute('data-opt-inert', ''); } }
433
+ else if (k.hasAttribute('data-opt-inert')) { k.removeAttribute('inert'); k.removeAttribute('data-opt-inert'); }
434
+ }
435
+ }
436
+ // `rest` is what follows the menu's path: '?open=x#y', '#access', or ''.
437
+ function openOverlay(rest) {
438
+ try { buildOverlay(rest || ''); } catch (_) {
439
+ // No window can be drawn here: the menu's own page, as before the window existed.
440
+ overlay = null; dialog = null;
441
+ var here = String(window.location.pathname || '') + String(window.location.search || '') + String(window.location.hash || '');
442
+ window.location.assign(menuPath() + (rest || '?from=' + encodeURIComponent(here)));
443
+ }
444
+ }
445
+ function buildOverlay(rest) {
446
+ var src = menuPath() + withEmbed(rest);
447
+ if (!overlay) {
448
+ if (!window.OTBDialog || typeof window.OTBDialog.bind !== 'function') throw new Error('no dialog');
449
+ overlay = document.createElement('div');
450
+ overlay.className = 'opt-window';
451
+ overlay.hidden = true;
452
+ overlay.innerHTML = '<div class="opt-window__box" aria-label="Options">' +
453
+ '<button type="button" class="opt-window__x" data-dialog-close aria-label="Close options">×</button>' +
454
+ '<iframe class="opt-window__frame" title="Options"></iframe></div>';
455
+ document.body.appendChild(overlay);
456
+ var frame0 = overlay.querySelector('.opt-window__frame');
457
+ dialog = window.OTBDialog.bind(overlay, {
458
+ panel: overlay.querySelector('.opt-window__box'),
459
+ lockClass: 'opt-window-open',
460
+ initialFocus: function () { return frame0; },
461
+ onOpen: function () { setInert(true); },
462
+ onClose: function () { setInert(false); },
463
+ });
464
+ }
465
+ var frame = overlay.querySelector('.opt-window__frame');
466
+ // A fresh address reloads the menu; the same one keeps it as it was left.
467
+ if (frame.getAttribute('src') !== src) frame.setAttribute('src', src);
468
+ dialog.open();
469
+ }
470
+ // '?open=x#y' → '?embed=1&open=x#y'; '#y' → '?embed=1#y'.
471
+ function withEmbed(rest) {
472
+ var h = rest.indexOf('#'), q = h === -1 ? rest : rest.slice(0, h), frag = h === -1 ? '' : rest.slice(h);
473
+ q = q.replace(/^\?/, '');
474
+ return '?embed=1' + (q ? '&' + q : '') + frag;
475
+ }
476
+ // The menu's own page never opens the window (nor a page in a frame: see the if below).
477
+ function overlayHere() { return on() && !(document.body && document.body.getAttribute('data-page') === 'settings'); }
478
+ if (!embedded) {
479
+ window.OTBOptionsOverlay = { open: openOverlay, close: closeOverlay, isOpen: overlayOpen };
480
+ // A link to the menu (the visor's Options row, the profile chip, the start
481
+ // screen's Options) opens the window in game mode instead of leaving the place.
482
+ if (document.addEventListener) document.addEventListener('click', function (e) {
483
+ if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || !overlayHere()) return;
484
+ var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
485
+ if (!a || a.getAttribute('target')) return;
486
+ var to;
487
+ try { to = new URL(a.getAttribute('href'), window.location.href); } catch (_) { return; }
488
+ if (to.origin !== window.location.origin || to.pathname.replace(/\.html$/, '') !== menuPath()) return;
489
+ e.preventDefault();
490
+ var q = to.search.replace(/([?&])from=[^&#]*&?/, '$1').replace(/[?&]$/, '');
491
+ openOverlay(q + to.hash);
492
+ }, true);
493
+ }
414
494
  if (window.addEventListener) window.addEventListener('keydown', onEscape);
415
495
  // Inside the menu, a link to ANOTHER page leaves the menu for it; a link within the page stays.
416
496
  if (embedded && document.addEventListener) {
@@ -1398,6 +1398,38 @@ html[data-embed="on"] .app-foot,
1398
1398
  html[data-embed="on"] .app-topbar { display: none; }
1399
1399
  html[data-embed="on"] .app-body { margin-left: 0; }
1400
1400
  html[data-embed="on"] .app-content { padding-top: 0; }
1401
+ /* An embedded screen is a sub-window, so it reads at window size, not page size
1402
+ (the owner, 2026-11-11: "WoW sizing"): everything in it is drawn smaller, and it
1403
+ has no ground of its own, so the options window's glass and the place behind
1404
+ show through. */
1405
+ html[data-embed="on"] { zoom: 0.82; }
1406
+ html[data-embed="on"], html[data-embed="on"] body { background: transparent; }
1407
+ /* THE OPTIONS WINDOW (shell-game.js openOverlay): in game mode the menu opens over
1408
+ the place you are in. The scrim only darkens a little, so the place stays legible;
1409
+ the window is night glass, so its colours carry through. */
1410
+ html.opt-window-open { overflow: hidden; }
1411
+ .opt-window { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; }
1412
+ .opt-window[hidden] { display: none; }
1413
+ /* the scrim is the overlay's own ground, so a click on it lands on the dialog root (OTBDialog closes) */
1414
+ .opt-window::before { content: ''; position: absolute; inset: 0; background: var(--scrim); opacity: .33; pointer-events: none; }
1415
+ .opt-window__box {
1416
+ position: relative; box-sizing: border-box;
1417
+ width: min(1040px, calc(100vw - 32px)); height: min(720px, calc(100vh - 48px));
1418
+ background: color-mix(in srgb, var(--bg) 48%, transparent);
1419
+ -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
1420
+ border: var(--line-strong) solid var(--rule);
1421
+ border-radius: var(--press-radius);
1422
+ box-shadow: var(--ds-shadow-pop);
1423
+ }
1424
+ .opt-window__frame { display: block; width: 100%; height: 100%; border: 0; background: transparent; color-scheme: dark; }
1425
+ .opt-window__x {
1426
+ position: absolute; top: var(--ds-space-xs); right: var(--ds-space-xs); z-index: 1;
1427
+ width: 28px; height: 28px; padding: 0; display: grid; place-items: center;
1428
+ font-size: var(--ds-text-2xl); line-height: 1; color: var(--ink-soft);
1429
+ background: transparent; border: var(--line) solid var(--rule-soft); border-radius: var(--press-radius); cursor: pointer;
1430
+ }
1431
+ .opt-window__x:hover { color: var(--ink); background: var(--glass); }
1432
+ .opt-window__x:focus-visible { outline: 0; box-shadow: var(--press-focus); }
1401
1433
  /* Said once on entering, since the choice is remembered and a page can open in it. */
1402
1434
  .fullscreen-hint {
1403
1435
  position: fixed;
@@ -24,3 +24,10 @@ The menu is a Helm screen, written from the hall tier's tokens alone (`settings.
24
24
  - **Widths:** the panel is `min(--helm-panel-w, 32%)` of the content column; below 860px it stacks above the screen as before. A long label wraps inside its row.
25
25
  - **Not built** (the mock shows them on a Controls screen the hall does not have): the key-binding rows, "Reset all keys", and a visible "Back to the city" button. Escape still closes the menu in game mode.
26
26
  - **Tests:** `tests/hall_helm.mjs` ("the options menu is a Helm screen"); the kit states in `settings.states.json` render clean at 1440, 390 and 320.
27
+
28
+ ## The options window (owner, 2026-11-11: "WoW sizing, an overlay")
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.
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
+ - **One review palette.** A framed screen docks no artist palette of its own (`craft-mode.js` `applyDocked`); the page around it has one.
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).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.85",
3
+ "version": "1.24.86",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.85",
9
+ "version": "1.24.86",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.85",
3
+ "version": "1.24.86",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11613,5 +11613,11 @@
11613
11613
  "id": "1005096",
11614
11614
  "text": "Autobongos now runs on a Mac for real: the setup command, start, stop and remove all proven on one, and the Mac-only test failures are gone."
11615
11615
  }
11616
+ ],
11617
+ "1.24.86": [
11618
+ {
11619
+ "id": "1005208",
11620
+ "text": "In game mode, Options now opens as a smaller see-through window over where you are, with smaller text, and the artist palette no longer shows twice."
11621
+ }
11616
11622
  ]
11617
11623
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.85'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.86'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -138,7 +138,7 @@ test('the Settings page IS the menu: one search over the groups, loaded before s
138
138
 
139
139
  // ---- shell-game.js: the embedded page and game mode's Escape --------------------
140
140
 
141
- function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents' } = {}) {
141
+ function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents', dom = false } = {}) {
142
142
  const docListeners = {};
143
143
  const winListeners = {};
144
144
  const timers = [];
@@ -156,13 +156,40 @@ function bootGame({ search = '', framed = true, game = false, path: p = '/my-age
156
156
  win.parent = framed ? parent : win;
157
157
  const document = {
158
158
  documentElement: root,
159
- body: { setAttribute(k, v) { bodyAttrs[k] = v; } },
159
+ body: { setAttribute(k, v) { bodyAttrs[k] = v; }, getAttribute: (k) => (k in bodyAttrs ? bodyAttrs[k] : null) },
160
160
  addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
161
161
  };
162
+ // dom: enough of a page for the game's options window to be drawn (createElement,
163
+ // a sibling the window makes inert, a frame that keeps its src), with OTBDialog.bind
164
+ // standing in for hall-dialog.js (its trap, Escape and focus return are tested there).
165
+ const windows = [];
166
+ const bound = [];
167
+ const sibling = { attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, hasAttribute(k) { return k in this.attrs; }, removeAttribute(k) { delete this.attrs[k]; } };
168
+ if (dom) {
169
+ document.body.children = [sibling];
170
+ document.body.appendChild = (el) => { windows.push(el); document.body.children.push(el); return el; };
171
+ document.createElement = () => {
172
+ const frameAttrs = {};
173
+ const frame = { getAttribute: (k) => (k in frameAttrs ? frameAttrs[k] : null), setAttribute: (k, v) => { frameAttrs[k] = v; }, focus() {} };
174
+ const box = { box: true };
175
+ return { hidden: false, innerHTML: '', querySelector: (sel) => (sel === '.opt-window__box' ? box : frame), frame, box };
176
+ };
177
+ win.OTBDialog = {
178
+ bind(root, opts) {
179
+ let open = false;
180
+ bound.push({ root, opts });
181
+ return {
182
+ open() { if (open) return; open = true; root.hidden = false; root.classes = [opts.lockClass]; opts.onOpen(); },
183
+ close() { if (!open) return; open = false; root.hidden = true; root.classes = []; opts.onClose(); },
184
+ isOpen: () => open,
185
+ };
186
+ },
187
+ };
188
+ }
162
189
  const ctx = vm.createContext({ window: win, document, URLSearchParams, URL });
163
190
  vm.runInContext(read('shell-game.js'), ctx);
164
191
  return {
165
- root, bodyAttrs, parent, assigned, game: win.OTBShellGame,
192
+ root, bodyAttrs, parent, assigned, windows, bound, sibling, win, game: win.OTBShellGame,
166
193
  press(key, target = { tagName: 'DIV' }) {
167
194
  const ev = { key, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
168
195
  for (const fn of winListeners.keydown || []) fn(ev);
@@ -173,7 +200,7 @@ function bootGame({ search = '', framed = true, game = false, path: p = '/my-age
173
200
  const attrs = { href };
174
201
  if (target) attrs.target = target;
175
202
  const a = { getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = v; } };
176
- for (const fn of docListeners.click || []) fn({ target: { closest: () => a } });
203
+ for (const fn of docListeners.click || []) fn({ target: { closest: () => a }, preventDefault() { this.defaultPrevented = true; } });
177
204
  return attrs.target || null;
178
205
  },
179
206
  };
@@ -214,6 +241,38 @@ test('GAME MODE: on the menu itself the last Escape closes it; outside game mode
214
241
  assert.deepEqual(off.assigned, [], 'outside game mode the press stays the shell\'s');
215
242
  });
216
243
 
244
+ test('GAME MODE: away from the menu, Escape opens the options as a window over the place, and Escape again shuts it', () => {
245
+ const city = bootGame({ game: true, framed: false, path: '/city', dom: true });
246
+ city.press('Escape');
247
+ assert.deepEqual(city.assigned, [], 'the place stays: no page change');
248
+ assert.equal(city.windows.length, 1, 'one window drawn over it');
249
+ assert.equal(city.windows[0].frame.getAttribute('src'), '/settings?embed=1', 'the menu, framed and chromeless');
250
+ assert.equal(city.bound.length, 1, 'the window is the shared dialog: OTBDialog.bind owns Escape, the scrim, the focus trap and return');
251
+ assert.equal(city.bound[0].opts.panel, city.windows[0].box, 'the glass box is the dialog panel');
252
+ assert.equal(city.bound[0].opts.lockClass, 'opt-window-open', 'the page behind stops scrolling');
253
+ assert.equal(city.bound[0].opts.initialFocus(), city.windows[0].frame, 'focus goes into the menu');
254
+ assert.equal(city.sibling.hasAttribute('inert'), true, 'the page behind is inert, so Tab out of the frame cannot reach it');
255
+ assert.equal(city.win.OTBOptionsOverlay.isOpen(), true);
256
+ city.press('Escape');
257
+ assert.equal(city.win.OTBOptionsOverlay.isOpen(), false, 'the second Escape shuts it');
258
+ assert.equal(city.sibling.hasAttribute('inert'), false, 'and the page behind is live again');
259
+ city.press('Escape');
260
+ assert.equal(city.windows.length, 1, 'reopening reuses the one window');
261
+ assert.equal(city.bound.length, 1, 'bound once');
262
+ // a link to the menu (the visor's Options row) opens the window at that screen
263
+ city.win.OTBOptionsOverlay.close();
264
+ city.click('/settings?from=%2Fcity#access');
265
+ assert.equal(city.windows[0].frame.getAttribute('src'), '/settings?embed=1#access', 'the screen it named, without ?from=');
266
+ assert.equal(city.win.OTBOptionsOverlay.isOpen(), true);
267
+ // outside game mode the link is a link
268
+ const off = bootGame({ game: false, framed: false, path: '/city', dom: true });
269
+ off.click('/settings');
270
+ assert.equal(off.windows.length, 0);
271
+ // and the menu, closing inside the window, shuts the window rather than leaving the page
272
+ const om = read('options-menu.js');
273
+ assert.ok(om.includes('window.parent.OTBOptionsOverlay') && om.includes('holder.close(); return;'), 'close() shuts the window first');
274
+ });
275
+
217
276
  test('options-menu.js gives the menu its Escape: close in game mode, nothing outside it', () => {
218
277
  const src = read('options-menu.js');
219
278
  assert.match(src, /function escape\(\) \{\s*var game = window\.OTBShellGame;\s*if \(game && game\.on && game\.on\(\)\) close\(\);/);
@@ -41,7 +41,7 @@ function node(tag, id, cls) {
41
41
  }
42
42
  function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
43
43
 
44
- function boot({ session = {}, search = '' } = {}) {
44
+ function boot({ session = {}, search = '', embedded = false } = {}) {
45
45
  const sidebar = node('NAV', 'app-sidebar');
46
46
  const topbar = node('DIV', 'app-topbar');
47
47
  const side = topbar.appendChild(node('DIV', '', 'topbar-side'));
@@ -61,6 +61,7 @@ function boot({ session = {}, search = '' } = {}) {
61
61
  const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
62
62
  const opened = [];
63
63
  const window = { OTB: realOTB(), localStorage: mk({}), sessionStorage: mk(session), matchMedia: () => ({ matches: false }), location: { search } };
64
+ if (embedded) window.OTBShellGame = { embedded: () => true };
64
65
  // "Loading" page-notes.js: record the tag, then define the API it would define.
65
66
  head.onAppend = (tag) => {
66
67
  loaded.push(tag.src);
@@ -94,6 +95,13 @@ test('THE PANEL: docked at the edge in Artist alone, with no top-bar button, and
94
95
  assert.deepEqual(none.loaded, [], 'and nothing loads');
95
96
  });
96
97
 
98
+ test('a screen in the options menu frame docks no second panel: the menu around it has one', async () => {
99
+ const p = boot({ session: { 'hall-craft': 'artist' }, embedded: true });
100
+ await flush();
101
+ assert.deepEqual(p.opened, [], 'no dock inside the frame');
102
+ assert.deepEqual(p.loaded, [], 'and the panel script never loads there');
103
+ });
104
+
97
105
  test('the panel script loads once, at the same version as craft-mode.js', async () => {
98
106
  const p = boot({ session: { 'hall-craft': 'artist' } });
99
107
  await flush();