@bongos/core 1.25.1 → 1.25.2

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/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/bongos/package-core.js",
5
- "core_version": "1.25.1",
6
- "core_contract": "1.25.1",
7
- "source_commit": "ab6188463865682dcca46f3680eb50a3227322d9",
5
+ "core_version": "1.25.2",
6
+ "core_contract": "1.25.2",
7
+ "source_commit": "dc57adbfa99c5fe357e8cf6722c5706199587d8a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-11-12T21:45:21.690Z",
9
+ "built_at": "2026-11-12T23:01:13.126Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 654,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 4676,
20
- "tree_sha256": "bdaeb36c9c1b8bff0272eae34c82d2a800abec3856f9eeebe36a61d0f35d6b3f",
20
+ "tree_sha256": "80c07d7b3023e83808ed7dc00f57b85a1c44550f7fd2b51df0d44c924dd5f2ad",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2527,12 +2527,12 @@
2527
2527
  {
2528
2528
  "path": "docs/copy-inventory.md",
2529
2529
  "mode": "0000644",
2530
- "sha256": "94fedf8bca9d202551ac01582ae236b9fc3d4dd38805f079023b8c61dab8c34a"
2530
+ "sha256": "0466e87434c29e6c17fc74c146a7ecf5c2d795e6817c5a8870df0cb704373c52"
2531
2531
  },
2532
2532
  {
2533
2533
  "path": "docs/copy-registry.json",
2534
2534
  "mode": "0000644",
2535
- "sha256": "3b9b2e2d1549748d240c2de55c53e8a8440241581a3cc848554993303dda3c5f"
2535
+ "sha256": "ad843392d336c5e2f459411b80e8426e9d8c7d0ffb1bd7cd60dc076c5a3cfc4e"
2536
2536
  },
2537
2537
  {
2538
2538
  "path": "docs/design/apex-pass-2-direction.md",
@@ -3087,7 +3087,7 @@
3087
3087
  {
3088
3088
  "path": "docs/module-api-changelog.md",
3089
3089
  "mode": "0000644",
3090
- "sha256": "5cbde19ec7bd628215e624bd4e1d5396d42b25d9db77c511e93884a79a00d062"
3090
+ "sha256": "1d1d128fddc2d52abe04ec70862a0f5cfda31ceb6e2e266630c140402e30830a"
3091
3091
  },
3092
3092
  {
3093
3093
  "path": "docs/modules-contract.md",
@@ -3172,7 +3172,7 @@
3172
3172
  {
3173
3173
  "path": "docs/page-readings.json",
3174
3174
  "mode": "0000644",
3175
- "sha256": "e45541ce7f58c185af5290f210c3e6e1fec64ae19a4e2b9336ab9d1c2021b3d9"
3175
+ "sha256": "8d1710ac1c1a2e954e990604fa84ca6d8473b775affce45227159d992566017e"
3176
3176
  },
3177
3177
  {
3178
3178
  "path": "docs/project-context.template.md",
@@ -7547,7 +7547,7 @@
7547
7547
  {
7548
7548
  "path": "modules/hall-ui/public/visor.js",
7549
7549
  "mode": "0000644",
7550
- "sha256": "4bf7299fd961c12f15e355b9898d779c0c4a3f258817fd712ec12d1a2d70fb6a"
7550
+ "sha256": "b5e10f04166881ad0301873d165da1a6f6500b37685a0ac565c2c500705f0470"
7551
7551
  },
7552
7552
  {
7553
7553
  "path": "modules/hall-ui/public/vote-control.js",
@@ -7867,7 +7867,7 @@
7867
7867
  {
7868
7868
  "path": "modules/hall-ui/records/visor.md",
7869
7869
  "mode": "0000644",
7870
- "sha256": "e8392fa5d8ec56cffd336101ed8a247b377be8172cea7d54e40c887548114607"
7870
+ "sha256": "19c6509e448da3b561a78aaa18bc2b301ed2443ef154ce1626f98fb7fd9287a8"
7871
7871
  },
7872
7872
  {
7873
7873
  "path": "modules/hall-ui/records/welcome-tower.md",
@@ -12217,12 +12217,12 @@
12217
12217
  {
12218
12218
  "path": "package-lock.json",
12219
12219
  "mode": "0000644",
12220
- "sha256": "91918d6ff38e731f24ee145f1ac45ad79aeae24a1f748d538fd229dc129d517e"
12220
+ "sha256": "5d2513f948e7a8850de02637e4484ede6f5179c86b04865397d95182d8d276b2"
12221
12221
  },
12222
12222
  {
12223
12223
  "path": "package.json",
12224
12224
  "mode": "0000644",
12225
- "sha256": "6d0090ccb1cf713ca96875e4acc6f4af8ab030650a847404825d78c8642c0abd"
12225
+ "sha256": "427c5862690dc27e7d377994e6168ac30cc09bca716565934fc62f8c305e4b0e"
12226
12226
  },
12227
12227
  {
12228
12228
  "path": "public-docs/index.html",
@@ -12242,7 +12242,7 @@
12242
12242
  {
12243
12243
  "path": "release-notes.json",
12244
12244
  "mode": "0000644",
12245
- "sha256": "9a01a7a0d535adb2c9804def7bf85a89ef6343277e4d71d5891168d18fac0f2c"
12245
+ "sha256": "6903717372407e77f5a906d0dcd4b06d8aa21dd10255a19a5602228c714a3457"
12246
12246
  },
12247
12247
  {
12248
12248
  "path": "scripts/bongos/CLAUDE.md",
@@ -16252,7 +16252,7 @@
16252
16252
  {
16253
16253
  "path": "src/module-api.js",
16254
16254
  "mode": "0000644",
16255
- "sha256": "39c65d85a20b2b575503327d65f70039e139827031595d85ded7b9228477ff8b"
16255
+ "sha256": "83df2597f55dcfbaf89dde346ad6e2a3b697b254a1cb00e985cc3d94df7adece"
16256
16256
  },
16257
16257
  {
16258
16258
  "path": "src/module-loader/catalog.js",
@@ -19522,7 +19522,7 @@
19522
19522
  {
19523
19523
  "path": "tests/hall_visor.mjs",
19524
19524
  "mode": "0000644",
19525
- "sha256": "48de1d859b2e3dacb7f8aa693e55b405923c65621bd995278af4227861099290"
19525
+ "sha256": "da366a1781b96c82152232b8e63a594609609e309336e36bf4d69cf80090b24f"
19526
19526
  },
19527
19527
  {
19528
19528
  "path": "tests/hall_work_history_id.mjs",
@@ -316,7 +316,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
316
316
  | `4df46e2be815` | certain | Clear the key? | `modules/hall-ui/public/market-watch-settings.js:218` |
317
317
  | `593dda3178eb` | certain | Click to copy | `modules/hall-ui/public/copy-on-click.js:137` |
318
318
  | `34624d39ff29` | certain | Clone the project to your machine and run | `modules/hall-ui/public/setup-gate.js:34` |
319
- | `3a426bad2c49` | certain | Close actions | `modules/hall-ui/public/visor.js:293` |
319
+ | `3a426bad2c49` | certain | Close actions | `modules/hall-ui/public/visor.js:336` |
320
320
  | `70f6a704d504` | certain | Close it | `modules/hall-ui/public/copy-desk.html:140` |
321
321
  | `053e576ba9a3` | certain | Close options | `modules/hall-ui/public/shell-game.js:454` |
322
322
  | `d12a2fb5730d` | certain | Close this flag | `modules/hall-ui/public/copy-desk.html:116` |
@@ -1486,7 +1486,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1486
1486
  | `9a685b01e2c6` | certain | This page opens at a higher rank. | `modules/hall-ui/public/watch.html:237` |
1487
1487
  | `e7a289134dc3` | certain | This page shows no words the reader could find. | `modules/hall-ui/public/tweak-editor.js:246` |
1488
1488
  | `d4b3b5556986` | certain | This page's reference docs | `modules/hall-ui/public/page-notes.js:166` |
1489
- | `79e4a934b434` | certain | This place | `modules/hall-ui/public/visor.js:295` |
1489
+ | `79e4a934b434` | certain | This place | `modules/hall-ui/public/visor.js:338` |
1490
1490
  | `5bdcbec770dd` | certain | This project has no slash commands yet. | `modules/hall-ui/public/commands.js:37` |
1491
1491
  | `f53b83bfbe54` | certain | This project has no watch rules yet. | `modules/hall-ui/public/market-watch-rules.js:62` |
1492
1492
  | `e97b2d549f10` | certain | This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this pr | `modules/hall-ui/public/project-settings.js:412` |
@@ -5208,7 +5208,7 @@
5208
5208
  "surface": "builders-hall",
5209
5209
  "text": "Close actions",
5210
5210
  "file": "modules/hall-ui/public/visor.js",
5211
- "line": 293,
5211
+ "line": 336,
5212
5212
  "origin": "js-markup",
5213
5213
  "confidence": "certain"
5214
5214
  },
@@ -15738,7 +15738,7 @@
15738
15738
  "surface": "builders-hall",
15739
15739
  "text": "This place",
15740
15740
  "file": "modules/hall-ui/public/visor.js",
15741
- "line": 295,
15741
+ "line": 338,
15742
15742
  "origin": "js-markup",
15743
15743
  "confidence": "certain"
15744
15744
  },
@@ -3444,5 +3444,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3444
3444
  after its permission gate (task 1004747, ADR 0382). Additive, so MINOR.
3445
3445
  1.25.1 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3446
3446
  landed since 1.25.0 with no explicit bump. run 43313162829. (task 1002620)
3447
+ 1.25.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3448
+ landed since 1.25.1 with no explicit bump. run 43325008692. (task 1002620)
3447
3449
  ---------------------------------------------------------------------------
3448
3450
  ```
@@ -283,7 +283,7 @@
283
283
  "surface": "builders",
284
284
  "title": "The city",
285
285
  "states_read": ["city","fullscreen","focus","pins","living","card","card-ask"],
286
- "files_hash": "0367a0e57f2fcfb1",
286
+ "files_hash": "573bb0fbcdcafa9a",
287
287
  "reading_hash": "277096d903879a57",
288
288
  "counts": {"lines":26,"placed":9,"shared":6,"unplaced":11},
289
289
  "omitted": {"data":7},
@@ -613,7 +613,7 @@
613
613
  "surface": "builders",
614
614
  "title": "The sky",
615
615
  "states_read": ["tour","sky","wells","ledger","full","full-back","out"],
616
- "files_hash": "9de2854d844d990f",
616
+ "files_hash": "fe5e504a7e12bca9",
617
617
  "reading_hash": "4a879397a84631e3",
618
618
  "counts": {"lines":50,"placed":25,"shared":6,"unplaced":19},
619
619
  "omitted": {"data":10},
@@ -954,8 +954,8 @@
954
954
  "surface": "builders",
955
955
  "title": "Builders' Hall",
956
956
  "states_read": ["home","out","founding","genesis","decide","demo"],
957
- "files_hash": "36c7410d9c8aeae0",
958
- "reading_hash": "45468116976bf674",
957
+ "files_hash": "b3b116e114d183f4",
958
+ "reading_hash": "59797028e6124c9c",
959
959
  "counts": {"lines":32,"placed":12,"shared":8,"unplaced":12},
960
960
  "omitted": {"data":5},
961
961
  "notes": ["founding: an action failed (page.focus: Timeout 6000ms exceeded.); read as it stood"],
@@ -977,7 +977,7 @@
977
977
  {"key":"L0015","section":"Needs your attention13","text":"Ideas awaiting triage","placement":"placed","file":"modules/hall-ui/public/builders.js","line":320,"string_id":"e181637c5a5e"},
978
978
  {"key":"L0016","section":"Needs your attention13","text":"Requires project {…} fix","placement":"unplaced"},
979
979
  {"key":"L0017","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":514,"string_id":"1883ad7f07fc"},
980
- {"key":"L0018","section":"Needs your attention13","text":"core 1.24.94","placement":"unplaced"},
980
+ {"key":"L0018","section":"Needs your attention13","text":"core 1.25.1","placement":"unplaced"},
981
981
  {"key":"L0019","section":"Needs your attention13","text":"Skip","placement":"unplaced"},
982
982
  {"key":"L0020","section":"Needs your attention13","text":"Built on","placement":"placed","file":"modules/hall-ui/public/welcome-boot.js","line":183,"string_id":"fef59346ffee"},
983
983
  {"key":"L0021","section":"Needs your attention13","text":"cloudbongos.com","placement":"unplaced"},
@@ -3159,7 +3159,7 @@
3159
3159
  "surface": "builders",
3160
3160
  "title": "Your thinking",
3161
3161
  "states_read": ["tour","sky","list","simple","full-idea","full","full-list","full-back","out"],
3162
- "files_hash": "96c460b71d47125f",
3162
+ "files_hash": "cd0f2d8f522becc8",
3163
3163
  "reading_hash": "b73f848b46b0f9b8",
3164
3164
  "counts": {"lines":50,"placed":25,"shared":6,"unplaced":19},
3165
3165
  "omitted": {"data":10},
@@ -11,9 +11,10 @@
11
11
  // the character's colour; task 1005218), your character's name and your credits
12
12
  // (builders.total_credits — credits only, never a payout; the spec's "does not exist").
13
13
  // Centre: your character's main action, the one accent button, with its key. Right: the
14
- // quiet key hints, no boxes: your other actions, "Actions" (key A), which opens the
15
- // sheet (your character's actions, then the keys of the place you are in, then Options
16
- // on Escape), and Options itself.
14
+ // quiet key hints, no boxes: your other actions and "Actions" (key A), which opens the
15
+ // sheet (your character's actions, then the keys of the place you are in, then Options on
16
+ // a key of its own; Escape closes it). Options is only in the sheet (task 1005248).
17
+ // - every key comes from resolveKeys: one owner per key, never two (task 1005248).
17
18
  //
18
19
  // The rules it keeps (ADR 0399):
19
20
  // - one overlay, #visor, with pointer-events: none; only its controls take a pointer, so a
@@ -59,6 +60,11 @@
59
60
  ],
60
61
  };
61
62
  var SHEET_KEY = 'a';
63
+ var CLOSE_KEY = 'esc'; // shown once: the sheet's Close (Escape)
64
+ var OPTIONS = { id: 'options', label: 'Options', key: 'o', href: '/settings' };
65
+ // Keys a scene binds on its own page (the sky's L 0 / ?, the gallery's F, the palette's K).
66
+ // A moved key never lands on one of them; a scene that declares one keeps it.
67
+ var AVOID = ['l', '0', '/', '?', 'f', 'k', '+', '-', '='];
62
68
  var NAMES = { artisan: 'Artisan', artist: 'Artist', patron: 'Patron' };
63
69
 
64
70
  function esc(v) { return window.OTB && window.OTB.escapeHtml ? window.OTB.escapeHtml(v) : String(v == null ? '' : v).replace(/[^\w .,:'-]/g, ''); }
@@ -151,6 +157,42 @@
151
157
  render();
152
158
  }
153
159
 
160
+ // ---------- the keys: one resolver, so no key ever means two things (task 1005248) ----------
161
+ // Every key the visor shows or binds comes from here, and here hands each key out once.
162
+ // A key in ACTIONS or a scene's registration is a RECOMMENDATION: when it is already taken
163
+ // the action moves to the next free letter of its own name, then of the alphabet, and with
164
+ // none free it shows and binds no key at all. Never two owners. In order of who keeps a key:
165
+ // 1. the visor's own: A (Actions) and Escape (Close, while the sheet is open);
166
+ // 2. the keys a scene binds itself (an action without `bind`, and the `keys` lines): the
167
+ // visor cannot move a key it does not bind, so a second claim on one shows no key;
168
+ // 3. the character's actions, in order; 4. the scene's `bind` actions; 5. Options.
169
+ function norm(k) { return k ? String(k).toLowerCase() : ''; }
170
+ function resolveKeys(id) {
171
+ var taken = {};
172
+ function claim(k, who) { if (!k || taken[k]) return ''; taken[k] = who; return k; }
173
+ function free(pref, label, who) {
174
+ var got = claim(norm(pref), who);
175
+ if (got || !pref) return got;
176
+ var pool = String(label || '').toLowerCase().replace(/[^a-z]/g, '') + 'abcdefghijklmnopqrstuvwxyz';
177
+ for (var i = 0; i < pool.length; i++) if (AVOID.indexOf(pool[i]) < 0 && claim(pool[i], who)) return pool[i];
178
+ return '';
179
+ }
180
+ claim(SHEET_KEY, 'sheet');
181
+ claim(CLOSE_KEY, 'close');
182
+ var sa = scene ? scene.actions : [], sk = scene ? scene.keys : [];
183
+ var place = sa.map(function () { return ''; });
184
+ sa.forEach(function (a, i) { if (!a.bind) place[i] = claim(norm(a.key), 'place:' + i); });
185
+ var lines = sk.map(function (k, i) { return claim(norm(k.key), 'line:' + i); });
186
+ var acts = actionsFor(id).map(function (a) {
187
+ var c = {}; for (var p in a) c[p] = a[p];
188
+ c.key = free(a.key, a.label, a.id);
189
+ return c;
190
+ });
191
+ sa.forEach(function (a, i) { if (a.bind) place[i] = free(a.key, a.label, 'place:' + i); });
192
+ var opts = { id: OPTIONS.id, label: OPTIONS.label, href: OPTIONS.href, key: free(OPTIONS.key, OPTIONS.label, OPTIONS.id) };
193
+ return { actions: acts, place: place, lines: lines, options: opts, taken: taken };
194
+ }
195
+
154
196
  // ---------- what the scene has picked (task 1005243) ----------
155
197
  // A scene that picks something (Home's tower) hands the visor a small card about it:
156
198
  // a title, one status, a few lines, and at most one action. The action becomes the
@@ -227,7 +269,7 @@
227
269
  svgOpen('visor__notch', '0 0 12 9') + twice('<path class="visor__line" d="M0 0l6 9l6 -9"/>') + '</svg>' +
228
270
  svgOpen('visor__sight', '0 0 24 24') + twice('<circle class="visor__line" cx="12" cy="12" r="11"/>') + '<circle class="visor__dot" cx="12" cy="12" r="2"/></svg>';
229
271
  }
230
- function keyHtml(k) { k = String(k); return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
272
+ function keyHtml(k) { k = String(k || ''); if (!k) return ''; if (k === CLOSE_KEY) k = 'Esc'; return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
231
273
  // The count and its unit are two spans, so the number can be white and the word ice; the
232
274
  // text between them is still creditsText's, word for word.
233
275
  function creditsHtml(n) {
@@ -240,14 +282,14 @@
240
282
  return '<a class="visor__hint" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '>' + keyHtml(a.key) + '<span>' + esc(a.label) + '</span></a>';
241
283
  }
242
284
  // The scene's lead action, as the first quiet hint (task 1005209).
243
- function placeHintHtml(a, i) {
244
- return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + (a.key ? keyHtml(a.key) : '') + '<span>' + esc(a.label) + '</span></button>';
285
+ function placeHintHtml(a, i, key) {
286
+ return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + keyHtml(key) + '<span>' + esc(a.label) + '</span></button>';
245
287
  }
246
288
  function actionAttrs(a) {
247
289
  return a.href ? 'href="' + esc(hallHref(a.href)) + '"' : 'href="#" role="button"';
248
290
  }
249
291
  function glassHtml(id) {
250
- var acts = actionsFor(id), main = acts[0], C = craft();
292
+ var keys = resolveKeys(id), acts = keys.actions, main = acts[0], C = craft();
251
293
  // Your picture on the glass is you wearing the visor (task 1005218); an older craft-mode
252
294
  // without that picture still hands over the whole figure.
253
295
  var sprite = C && typeof C.portraitSrc === 'function' ? C.portraitSrc(id) : (C && typeof C.spriteSrc === 'function' ? C.spriteSrc(id) : '');
@@ -263,10 +305,9 @@
263
305
  '<span class="visor__credits" data-visor-credits>' + creditsHtml(n) + '</span></span>' +
264
306
  '</div>' +
265
307
  mainHtml(main) +
266
- '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
308
+ '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i, keys.place[i]) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
267
309
  '<button class="visor__hint visor__hint--sheet" type="button" data-visor-sheet aria-expanded="false" aria-controls="visor-sheet">' +
268
310
  '<span class="visor__dots" aria-hidden="true"></span>' + keyHtml(SHEET_KEY) + '<span>Actions</span></button>' +
269
- '<a class="visor__hint visor__hint--options" href="' + esc(hallHref('/settings')) + '">' + keyHtml('Esc') + '<span>Options</span></a>' +
270
311
  '</div>' +
271
312
  '</div>';
272
313
  }
@@ -280,20 +321,22 @@
280
321
  '<p class="visor__progress-words"><span class="sr-only">' + esc(said) + '</span>' +
281
322
  '<span aria-hidden="true">' + esc(progress.id) + ' · ' + progress.pct + '%</span></p></div>';
282
323
  }
324
+ // Options lives only in here (the owner, 2026-11-12), on a key of its own: Escape is Close.
283
325
  function sheetHtml(id) {
284
- var rows = actionsFor(id).map(function (a) {
326
+ var keys = resolveKeys(id);
327
+ var rows = keys.actions.map(function (a) {
285
328
  return '<li><a class="visor__row" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '><span>' + esc(a.label) + '</span>' + keyHtml(a.key) + '</a></li>';
286
329
  }).join('');
287
330
  var place = scene.actions.map(function (a, i) {
288
- return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + (a.key ? keyHtml(a.key) : '') + '</button></li>';
289
- }).concat(scene.keys.map(function (k) {
290
- return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(k.key) + '</span></li>';
331
+ return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + keyHtml(keys.place[i]) + '</button></li>';
332
+ }).concat(scene.keys.map(function (k, i) {
333
+ return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(keys.lines[i]) + '</span></li>';
291
334
  })).join('');
292
335
  return '<div class="visor__sheet-head"><h2 class="visor__sheet-title" id="visor-sheet-title">Actions</h2>' +
293
- '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml('Esc') + '</button></div>' +
336
+ '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml(CLOSE_KEY) + '</button></div>' +
294
337
  '<p class="visor__cap">' + esc(NAMES[id]) + '</p><ul class="visor__rows">' + rows + '</ul>' +
295
338
  (place ? '<p class="visor__cap">This place</p><ul class="visor__rows">' + place + '</ul>' : '') +
296
- '<ul class="visor__rows"><li><a class="visor__row" href="' + esc(hallHref('/settings')) + '"><span>Options</span>' + keyHtml('Esc') + '</a></li></ul>';
339
+ '<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>';
297
340
  }
298
341
  function shown() { return !!(root && !root.hidden); }
299
342
  function render() {
@@ -375,10 +418,12 @@
375
418
  if (C && typeof C.openTool === 'function') C.openTool(a.tool);
376
419
  return;
377
420
  }
421
+ // Options opens the game's options window over the place when it can (shell-game.js)
422
+ if (a.id === OPTIONS.id && window.OTBOptionsOverlay && typeof window.OTBOptionsOverlay.open === 'function') { window.OTBOptionsOverlay.open(); return; }
378
423
  if (a.href) window.location.assign(hallHref(a.href));
379
424
  }
380
425
  function find(id) {
381
- var acts = actionsFor(character());
426
+ var acts = resolveKeys(character()).actions;
382
427
  for (var i = 0; i < acts.length; i++) if (acts[i].id === id) return acts[i];
383
428
  return null;
384
429
  }
@@ -415,11 +460,12 @@
415
460
  if (!e || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || typeof e.key !== 'string' || e.key.length !== 1) return null;
416
461
  var k = e.key.toLowerCase();
417
462
  if (k === SHEET_KEY) return 'sheet';
418
- var acts = actionsFor(id);
419
- for (var i = 0; i < acts.length; i++) if (acts[i].key === k) return acts[i];
420
- // then the keys the visor binds for the place (task 1005209); the character's win a clash
463
+ // the resolver gave each key one owner, so the first match is the only one
464
+ var keys = resolveKeys(id);
465
+ for (var i = 0; i < keys.actions.length; i++) if (keys.actions[i].key === k) return keys.actions[i];
421
466
  var own = scene ? scene.actions : [];
422
- for (var j = 0; j < own.length; j++) if (own[j].bind && own[j].key === k) return own[j];
467
+ for (var j = 0; j < own.length; j++) if (own[j].bind && keys.place[j] === k) return own[j];
468
+ if (keys.options.key === k) return keys.options;
423
469
  return null;
424
470
  }
425
471
  // Bubble, on window: a scene or pop-up that used the key has called preventDefault by now.
@@ -460,6 +506,7 @@
460
506
  ACTIONS: ACTIONS,
461
507
  actionsFor: actionsFor,
462
508
  keyFor: keyFor,
509
+ resolveKeys: resolveKeys,
463
510
  creditsText: creditsText,
464
511
  character: character,
465
512
  pickVersion: pickVersion,
@@ -6,6 +6,13 @@ Covers: `visor.js` + `visor.css`, loaded on Home, `/city`, `/goal-map` and `/thi
6
6
 
7
7
  The visor is the control and the scene is the view: every main call to action in game mode is on the glass (the accent button, the key hints, the Actions sheet), and a scene carries none of its own. A scene that has something to do registers it with `OTBVisor.scene({ actions, keys })`. ADR 0403 §3 lists the scenes that do not keep it yet; the Governor City keeps it since task 1005209 the sky since task 1005210, and Home's gallery since task 1005211 (below).
8
8
 
9
+ ## One owner per key (task 1005248, owner 2026-11-12)
10
+
11
+ - **The bug.** The Artist saw Esc twice: "Options" on the glass, then in the Actions sheet both Close and the Options row said Esc.
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
+ - **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
+ - **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
+
9
16
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
10
17
 
11
18
  - **The scene contract grew two flags.** A scene action's `key` is still a key the scene binds itself, shown beside it in the sheet. With `bind: true` the visor binds that key instead, after the character's keys (a clash goes to the character), for a scene button that had no key of its own. With `lead: true` the action is also the first quiet key hint on the glass (`.visor__hint--place`). Every scene action is still a row under "This place" in the sheet.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.1",
3
+ "version": "1.25.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.1",
9
+ "version": "1.25.2",
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.25.1",
3
+ "version": "1.25.2",
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",
@@ -11700,5 +11700,11 @@
11700
11700
  "id": "1005040",
11701
11701
  "text": "Added the Stripe Connect test-mode payment adapter (economy.processor port). Every payout, cash-out, balance and ledger-check call now has a processor to talk to once a test key is set; nothing is live."
11702
11702
  }
11703
+ ],
11704
+ "1.25.2": [
11705
+ {
11706
+ "id": "1005248",
11707
+ "text": "In game mode, no two buttons on the visor share a hotkey any more, and Options now sits only inside the Actions menu with its own key."
11708
+ }
11703
11709
  ]
11704
11710
  }
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.25.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.2'; // 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');
@@ -77,6 +77,64 @@ test("a place's own key: the visor binds it only when the scene asks, and the ch
77
77
  assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key wins a clash");
78
78
  });
79
79
 
80
+ // task 1005248 (owner, 2026-11-12): a key the visor shows is a recommendation, and no two
81
+ // things on the glass or in the sheet ever share one. Every key comes from resolveKeys.
82
+ function shownKeys(r) {
83
+ const out = [['a', 'Actions'], ['esc', 'Close']];
84
+ r.actions.forEach((a) => out.push([a.key, a.id]));
85
+ r.place.forEach((k, i) => out.push([k, 'place ' + i]));
86
+ r.lines.forEach((k, i) => out.push([k, 'line ' + i]));
87
+ out.push([r.options.key, 'options']);
88
+ return out.filter(([k]) => k);
89
+ }
90
+ function assertNoClash(V, who) {
91
+ for (const c of ['artisan', 'artist', 'patron']) {
92
+ const keys = shownKeys(V.resolveKeys(c)).map(([k]) => k);
93
+ assert.equal(new Set(keys).size, keys.length, `${who} / ${c}: one owner per key (${keys.join(' ')})`);
94
+ }
95
+ }
96
+ const SKY = { name: 'sky', place: '', actions: [{ label: 'Write a thought', key: '/', lead: true, run() {} }, { label: 'Jump to a goal or a thought', key: 'j', bind: true, run() {} }, { label: 'List', key: 'L', run() {} }, { label: 'Tour', key: '?', run() {} }, { label: 'Fit', key: '0', run() {} }, { label: 'Clock · off', key: '', run() {} }], keys: [{ label: 'Look around', key: 'drag' }, { label: 'Zoom', key: '+ −' }] };
97
+ const CITY = { name: 'city', place: 'Governor city', actions: [{ label: 'decide 37 in a row', key: 'd', bind: true, lead: true, run() {} }, { label: 'Archived: Street', run() {} }], keys: [{ label: 'On a card: answer', key: '1–9' }, { label: 'On a card: next', key: '← →' }] };
98
+
99
+ test('no key ever means two things: every character over every real scene, and Options never on Escape', () => {
100
+ const V = loadPure();
101
+ assertNoClash(V, 'no scene');
102
+ for (const sc of [SKY, CITY, { name: 'home', place: 'Home' }]) { V.scene(sc); assertNoClash(V, sc.name); }
103
+ for (const c of ['artisan', 'artist', 'patron']) {
104
+ const r = V.resolveKeys(c);
105
+ assert.notEqual(r.options.key, 'esc', `${c}: Options is not on Escape; Escape is Close`);
106
+ assert.ok(r.options.key, `${c}: Options has a key of its own`);
107
+ }
108
+ assert.equal(V.resolveKeys('artist').options.key, 'o', 'O is Options where nothing else holds it');
109
+ assert.equal(V.resolveKeys('patron').options.key, 'p', "the Patron's O is the Board room, so Options moves to its next letter");
110
+ });
111
+
112
+ test('a clash moves the later claimant to a free letter of its own name, and its key follows', () => {
113
+ const V = loadPure();
114
+ const ev = (key) => ({ key, defaultPrevented: false, ctrlKey: false, metaKey: false, altKey: false });
115
+ const run = () => {};
116
+ // the worst a scene can register: its own keys on A, Escape, a character key, and twice on one key
117
+ V.scene({ name: 'x', place: 'X', actions: [
118
+ { label: 'Grab', key: 'a', run }, { label: 'Exit', key: 'Esc', run }, { label: 'Go', key: 'g', run }, { label: 'Again', key: 'g', run },
119
+ { label: 'Claim here', key: 'c', bind: true, run }, { label: 'Thing', key: 't', bind: true, run }, { label: 'Open', key: 'o', bind: true, run },
120
+ ], keys: [{ label: 'Same as Go', key: 'G' }] });
121
+ assertNoClash(V, 'hostile scene');
122
+ const r = V.resolveKeys('artisan');
123
+ const plain = (x) => JSON.parse(JSON.stringify(x));
124
+ assert.deepEqual(plain(r.place.slice(0, 4)), ['', '', 'g', ''], "A and Escape are the visor's; the scene keeps G once, and a second claim shows no key");
125
+ assert.equal(r.lines[0], '', 'a key line on a taken key shows no key');
126
+ assert.equal(r.actions.find((a) => a.id === 'sky').key, 'o', 'Goal sky lost G to the scene, so it moves to the next letter of its name, O');
127
+ assert.equal(V.keyFor('artisan', ev('o')).id, 'sky', 'and O runs it');
128
+ assert.equal(V.keyFor('artisan', ev('g')), null, "G is the scene's own now, so the visor does not take it");
129
+ assert.ok(!['c', 'l', 'a'].includes(r.place[4]), 'Claim here: C is taken, L and A are never handed out');
130
+ assert.equal(V.keyFor('artisan', ev(r.place[4])).label, 'Claim here');
131
+ assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key still wins");
132
+ assert.ok(r.place[6] && r.place[6] !== 'o', "Open asked for O after Goal sky took it, so it moves");
133
+ assert.equal(V.keyFor('artisan', ev(r.place[6])).label, 'Open');
134
+ assert.equal(V.keyFor('artisan', ev(r.options.key)).id, 'options', 'and Options takes the next free letter');
135
+ assert.equal(V.keyFor('artisan', ev('Escape')), null, 'the ambient glass never takes Escape');
136
+ });
137
+
80
138
  // task 1005203: the four segments are the version the project is building, in quarters.
81
139
  test('progress: the starred version while open, else the one building, else none', () => {
82
140
  const V = loadPure();
@@ -238,7 +296,8 @@ test('in game mode the glass shows the place, the main action, the key hints, th
238
296
  assert.equal(g.credits, '56,021 credits');
239
297
  assert.deepEqual([g.num, g.unit], ['56,021', 'credits'], 'the count and its word are two spans of the same text');
240
298
  // task 1005195: every other control is a quiet key hint. Actions kept its key and its sheet; nothing was dropped.
241
- assert.deepEqual(g.hints, ['TYour thinking', 'AActions', 'EscOptions']);
299
+ // task 1005248: Options is only in the sheet, so Escape is on the glass nowhere.
300
+ assert.deepEqual(g.hints, ['TYour thinking', 'AActions']);
242
301
  assert.equal(g.arcs, 2, 'two arcs frame the glass');
243
302
  assert.equal(g.sprite, '/builders/characters/artisan-visor.png', 'your picture on the glass is you wearing the visor (task 1005218)');
244
303
  assert.deepEqual(errors, []);
@@ -295,7 +354,7 @@ test('a lead scene action is the first key hint, and its click and its key run i
295
354
  const { ctx, page, errors } = await open();
296
355
  await page.evaluate(() => OTBVisor.scene({ name: 'test', place: 'Test city', actions: [{ label: 'Decide 2 in a row', key: 'd', bind: true, lead: true, run() { window.decided = (window.decided || 0) + 1; } }] }));
297
356
  const hints = await page.$$eval('.visor__hints .visor__hint', (h) => h.map((x) => x.textContent));
298
- assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions', 'EscOptions']);
357
+ assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions']);
299
358
  await page.click('.visor__hint--place');
300
359
  await page.keyboard.press('d');
301
360
  assert.equal(await page.evaluate(() => window.decided), 2, 'the hint and D both press it');
@@ -311,7 +370,8 @@ test('the sheet lists the character\'s actions, then this place, and runs a scen
311
370
  await page.click('[data-visor-sheet]');
312
371
  const rows = await page.$$eval('.visor__sheet .visor__row', (r) => r.map((x) => [...x.children].map((c) => c.textContent).join(' ')));
313
372
  // task 1005200: Claim a task and Blockers open classic pages, so they are off the glass.
314
- assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options Esc']);
373
+ assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options O']);
374
+ assert.equal(await page.$eval('.visor__sheet [data-visor-close]', (b) => b.textContent), 'Esc', 'Escape is the Close of the sheet, and only that');
315
375
  await page.click('[data-visor-scene="0"]');
316
376
  assert.deepEqual(await page.evaluate(() => ({ centred: window.centred, open: OTBVisor.isOpen() })), { centred: true, open: false });
317
377
  await ctx.close();