@bongos/core 1.21.22 → 1.21.24

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.
Files changed (58) hide show
  1. package/.bongos-core.json +62 -62
  2. package/docs/copy-inventory.md +128 -123
  3. package/docs/copy-registry.json +222 -187
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +251 -248
  6. package/modules/hall-ui/public/atlas.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/board-room.html +1 -1
  9. package/modules/hall-ui/public/city.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/commands.html +1 -1
  12. package/modules/hall-ui/public/copy-desk.html +1 -1
  13. package/modules/hall-ui/public/deploy.html +1 -1
  14. package/modules/hall-ui/public/diagrams.html +1 -1
  15. package/modules/hall-ui/public/drachmae.html +2 -2
  16. package/modules/hall-ui/public/fleet.html +1 -1
  17. package/modules/hall-ui/public/gate.html +1 -1
  18. package/modules/hall-ui/public/goal-map.html +1 -1
  19. package/modules/hall-ui/public/goals.html +1 -1
  20. package/modules/hall-ui/public/government.html +1 -1
  21. package/modules/hall-ui/public/idea.html +5 -7
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +1 -1
  24. package/modules/hall-ui/public/modules.html +1 -1
  25. package/modules/hall-ui/public/primer.html +2 -2
  26. package/modules/hall-ui/public/profile.html +1 -1
  27. package/modules/hall-ui/public/project-settings.html +1 -1
  28. package/modules/hall-ui/public/ranks.html +1 -1
  29. package/modules/hall-ui/public/record.css +4 -19
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.css +16 -0
  33. package/modules/hall-ui/public/sessions.html +14 -2
  34. package/modules/hall-ui/public/sessions.js +111 -3
  35. package/modules/hall-ui/public/settings.html +1 -1
  36. package/modules/hall-ui/public/shell.js +56 -5
  37. package/modules/hall-ui/public/street.html +1 -1
  38. package/modules/hall-ui/public/studio.html +1 -1
  39. package/modules/hall-ui/public/task.html +6 -9
  40. package/modules/hall-ui/public/task.js +15 -50
  41. package/modules/hall-ui/public/task.states.json +1 -1
  42. package/modules/hall-ui/public/thinking.html +1 -1
  43. package/modules/hall-ui/public/tweak-editor.css +4 -0
  44. package/modules/hall-ui/public/tweak-editor.html +5 -3
  45. package/modules/hall-ui/public/watch.html +1 -1
  46. package/modules/hall-ui/public/work.html +1 -1
  47. package/modules/hall-ui/records/hall-navigation.md +7 -4
  48. package/modules/sessions/db.js +4 -0
  49. package/package-lock.json +2 -2
  50. package/package.json +1 -1
  51. package/release-notes.json +12 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/hall_fullscreen.mjs +88 -12
  54. package/tests/hall_record_world.mjs +41 -63
  55. package/tests/hall_sessions_ledger.mjs +40 -0
  56. package/tests/hall_tweak_editor.mjs +3 -2
  57. package/tests/nav_permission_atoms.mjs +1 -1
  58. package/tests/session_transcript.mjs +5 -0
@@ -513,9 +513,16 @@
513
513
  // not a drill-down and shows none: the sidebar click leaves a one-shot note
514
514
  // (sessionStorage) naming its target, and the page it lands on reads it.
515
515
  // The origin is `?from=` (a link that knows where it came from), else the
516
- // same-origin referrer. A page drawing its own back link (.rec-back — task,
517
- // idea — or any element marked data-own-back, the tweak editor's "← studio")
518
- // keeps it and the bar adds no second one.
516
+ // same-origin referrer.
517
+ //
518
+ // task 1004477: this is the ONLY place a hall page's way back is decided —
519
+ // no page draws its own Back link any more. Two hooks cover what the page
520
+ // copies used to:
521
+ // - <body data-back-fallback="/work"> names where Back goes when there is
522
+ // no origin at all (a deep-linked task or idea, the tweak editor);
523
+ // - a link marked [data-shell-exit] is a second exit the page places (an
524
+ // error state's button, the tweak editor's bar in full screen, where the
525
+ // top bar is hidden), and the shell fills its address and its words.
519
526
  var NAV_HOP_KEY = 'hall-nav-hop';
520
527
  function hallPath(url) {
521
528
  return String(url.pathname).replace(/^\/builders(?=\/|$)/, '').replace(/\.html$/, '').replace(/\/index$/, '') || '/';
@@ -527,8 +534,10 @@
527
534
  window.sessionStorage.removeItem(NAV_HOP_KEY);
528
535
  } catch (_) { /* private mode: no note, so fall through */ }
529
536
  if (hop && hop === hallPath(location)) return null;
530
- if (!document.querySelector || document.querySelector('.rec-back, [data-own-back]')) return null;
531
- var here = hallPath(location) + (location.search || '');
537
+ return linkOrigin() || fallbackOrigin();
538
+ }
539
+ // The page this one was opened from: ?from=, then a same-origin referrer.
540
+ function linkOrigin() {
532
541
  var from = null;
533
542
  var viaReferrer = false;
534
543
  try {
@@ -544,9 +553,48 @@
544
553
  var params = new URLSearchParams(from.search);
545
554
  params.delete('from');
546
555
  var search = params.toString() ? '?' + params.toString() : '';
556
+ // This page's address without its own ?from=, so `?from=` naming the page
557
+ // itself (a loop, not a way back) is caught like any other self-link.
558
+ var ownParams = new URLSearchParams(location.search || '');
559
+ ownParams.delete('from');
560
+ var here = hallPath(location) + (ownParams.toString() ? '?' + ownParams.toString() : '');
547
561
  if (path + search === here) return null;
548
562
  return { href: path + search + (from.hash || ''), path: path, label: backLabel(path, from.hash), viaReferrer: viaReferrer };
549
563
  }
564
+ // Where Back goes with no origin: the page's own <body data-back-fallback>,
565
+ // honoured only as a path on this host (one leading slash, never the API).
566
+ function fallbackOrigin() {
567
+ var f = document.body && document.body.dataset && document.body.dataset.backFallback;
568
+ if (!f || !/^\/(?!\/)/.test(f) || /[\\\s]/.test(f)) return null;
569
+ var u;
570
+ try { u = new URL(f, location.origin); } catch (_) { return null; }
571
+ if (u.origin !== location.origin) return null;
572
+ var path = hallPath(u);
573
+ if (/^\/(api|auth|lib)(\/|$)/.test(path) || path === hallPath(location)) return null;
574
+ return { href: path + (u.search || '') + (u.hash || ''), path: path, label: backLabel(path, u.hash), viaReferrer: false };
575
+ }
576
+ // "Back to tasks", "Back to goal 12": a nav label reads as a place mid-sentence,
577
+ // so it is lowercased unless it carries an acronym ("API docs").
578
+ function exitWords(label) {
579
+ return 'Back to ' + (/[A-Z]{2}/.test(label) ? label : label.toLowerCase());
580
+ }
581
+ // Fill every [data-shell-exit] link under `root` with the way back. With no
582
+ // way back at all the exit still works: it goes home. A page script that
583
+ // renders an exit later (task.js's failure states) calls this once it has.
584
+ function fillExits(root) {
585
+ var scope = root || document;
586
+ if (!scope.querySelectorAll) return;
587
+ var target = BACK || { href: '/', label: 'Home', viaReferrer: false };
588
+ var links = scope.querySelectorAll('[data-shell-exit]');
589
+ for (var i = 0; i < links.length; i++) {
590
+ links[i].setAttribute('href', target.href);
591
+ links[i].textContent = exitWords(target.label);
592
+ if (!links[i].dataset.shellExitWired) {
593
+ links[i].dataset.shellExitWired = '1';
594
+ links[i].addEventListener('click', onBackClick);
595
+ }
596
+ }
597
+ }
550
598
  function backLabel(path, hash) {
551
599
  if (path === '/') {
552
600
  var m = /^#\/(task|idea|blocker)\/(\d+)/.exec(hash || '');
@@ -1381,6 +1429,7 @@
1381
1429
  applyFullscreen(storedFullscreen());
1382
1430
  applyAccess(null); // default: everything gated until access is known
1383
1431
  wire();
1432
+ fillExits();
1384
1433
  if (IS_INDEX) {
1385
1434
  route({ scroll: false });
1386
1435
  } else {
@@ -1407,5 +1456,7 @@
1407
1456
  // task 1004544: a page with its own light/dark switch (the governor city
1408
1457
  // and street) repaints the top bar's after writing 'hall-theme'.
1409
1458
  applyTheme: applyTheme,
1459
+ // task 1004477: a page that renders an exit after boot asks the shell to fill it.
1460
+ fillExits: fillExits,
1410
1461
  };
1411
1462
  })();
@@ -203,7 +203,7 @@
203
203
  </div>
204
204
  </div>
205
205
 
206
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
206
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
207
207
  <script src="/builders/palette.js" defer></script>
208
208
  <script src="/builders/city-draw.js?v=2026-10-02-street1"></script>
209
209
  <script src="/builders/street-draw.js?v=2026-10-02-street1"></script>
@@ -176,7 +176,7 @@
176
176
 
177
177
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
178
178
 
179
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
179
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
180
180
  <script src="/builders/palette.js" defer></script>
181
181
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
182
182
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -15,7 +15,7 @@
15
15
  <link rel="stylesheet" href="/builders/record.css">
16
16
  <link rel="stylesheet" href="/builders/task.css">
17
17
  </head>
18
- <body data-page="task" data-page-title="Task">
18
+ <body data-page="task" data-page-title="Task" data-back-fallback="/work">
19
19
 
20
20
  <div class="app">
21
21
  <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
@@ -24,14 +24,11 @@
24
24
  <header class="app-topbar" id="app-topbar"></header>
25
25
  <div class="app-content">
26
26
  <div class="page-head">
27
- <!-- task 2175: the breadcrumb sits in the page head, above the card, so a
28
- deep-linked visitor (this page is deliberately not in the nav) has a
29
- way back before the task body has loaded. task 1003468: task.js
30
- points it at where the reader came from (?from=, then the
31
- referrer); /work is only the fallback it ships with. -->
27
+ <!-- task 1004477: the way back is the shell's top-bar Back link, set
28
+ before the task body loads. This page is deliberately not in the
29
+ nav, so <body data-back-fallback> gives a deep-linked visitor one
30
+ even with no ?from= and no referrer. -->
32
31
  <div class="rec-crumb">
33
- <a class="rec-back" id="task-back" href="/work">
34
- <svg class="rec-back__ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.4 3.6 5 8l4.4 4.4"/><path d="M5 8h7.2"/></svg><span id="task-back-label">Back to tasks</span></a>
35
32
  <p class="rec-sub" id="task-sub">Loading task</p>
36
33
  </div>
37
34
  </div>
@@ -57,7 +54,7 @@
57
54
 
58
55
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
59
56
  time so the chrome exists before the deferred page scripts execute. -->
60
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
57
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
61
58
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
62
59
  <script src="/builders/palette.js" defer></script>
63
60
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -182,57 +182,20 @@
182
182
  return m ? m[1] : null;
183
183
  }
184
184
 
185
- // task 1003468 — where the reader came from, so the record can send them back
186
- // there rather than always to the work board. Precedence: `?from=` (written
187
- // by the list that opened the record — a query param so it survives a reload
188
- // and a copied link) → a same-origin referrer → the work board. A value is
189
- // honoured only as a path on THIS host: one leading slash (a second is another
190
- // origin), no backslash (browsers read `/\host` as `//host`), no whitespace,
191
- // never the API and never this same record.
192
- const EXIT_MAX_LEN = 2048;
193
- function safeHallPath(v) {
194
- if (typeof v !== 'string' || !v || v.length > EXIT_MAX_LEN) return null;
195
- if (!/^\/(?!\/)/.test(v) || /[\\\s]/.test(v)) return null;
196
- if (/^(\/builders)?\/(api|lib)\//.test(v)) return null;
197
- if (v.split(/[?#]/)[0] === location.pathname) return null;
198
- return v;
199
- }
200
- function referrerPath() {
201
- let u;
202
- try { u = new URL(document.referrer); } catch (_) { return null; }
203
- if (u.origin !== location.origin) return null;
204
- return safeHallPath(u.pathname + u.search + u.hash);
205
- }
206
- // "goal 1000081", "task 12", "the work board", "sessions" — said the way the
207
- // sidebar says it, so the exit reads as a place and not as a URL.
208
- function exitLabel(href) {
209
- const rel = href.replace(/^\/builders(?=[/?#]|$)/, '');
210
- let m;
211
- if ((m = /#\/goal\/(\d+)/.exec(rel))) return `goal ${m[1]}`;
212
- if ((m = /^\/(task|idea)\/(\d+)/.exec(rel))) return `${m[1]} ${m[2]}`;
213
- const seg = (/^\/([a-z][a-z-]*)/.exec(rel) || [])[1];
214
- if (!seg) return 'home';
215
- if (seg === 'work') return 'the work board';
216
- return seg.replace(/-/g, ' ');
217
- }
218
- function exitTarget() {
219
- let from = null;
220
- try { from = new URLSearchParams(location.search).get('from'); } catch (_) { /* no query */ }
221
- const href = safeHallPath(from) || referrerPath() || `${hallBase()}/work`;
222
- return { href, label: exitLabel(href) };
223
- }
185
+ // task 1004477 — the way back is the SHELL's, not this page's. task 1003468
186
+ // worked out where the reader came from here (?from=, then the referrer, then
187
+ // the board); shell.js now decides that for every hall page, and this page
188
+ // only places the exit: a [data-shell-exit] link the shell fills with the same
189
+ // address and words as its top-bar Back link. The static href is what a
190
+ // reader without the shell (a failed script) still gets.
224
191
  function exitLinkHtml() {
225
- const { href, label } = exitTarget();
226
- return `<a class="td-btn" href="${escapeHtml(href)}">Back to ${escapeHtml(label)}</a>`;
192
+ return `<a class="td-btn" data-shell-exit href="${hallBase()}/work">Back to tasks</a>`;
227
193
  }
228
- // The crumb in the page head is set before the fetch, so the way back exists
229
- // in every state — loading, not found, failed — not just the happy one.
230
- function applyExit() {
231
- const { href, label } = exitTarget();
232
- const back = $('#task-back');
233
- if (back) back.href = href;
234
- const lbl = $('#task-back-label');
235
- if (lbl) lbl.textContent = `Back to ${label}`;
194
+ // Called after every render that places an exit, so the button and the top
195
+ // bar never disagree about where Back goes.
196
+ function fillExits(root) {
197
+ const shell = window.OTBShell;
198
+ if (shell && typeof shell.fillExits === 'function') shell.fillExits(root);
236
199
  }
237
200
 
238
201
  function rankText(rr) {
@@ -852,6 +815,7 @@
852
815
 
853
816
  const body = $('#task-body');
854
817
  if (body) body.innerHTML = parts.join('');
818
+ fillExits(body);
855
819
  announceShown(t);
856
820
 
857
821
  const copyBtn = $('#copy-prompt-btn');
@@ -1043,6 +1007,7 @@
1043
1007
  `;
1044
1008
  const sub = $('#task-sub');
1045
1009
  if (sub) sub.textContent = 'Not found';
1010
+ fillExits(body);
1046
1011
  }
1047
1012
 
1048
1013
  function renderError(id) {
@@ -1055,10 +1020,10 @@
1055
1020
  `;
1056
1021
  const sub = $('#task-sub');
1057
1022
  if (sub) sub.textContent = 'Could not load';
1023
+ fillExits(body);
1058
1024
  }
1059
1025
 
1060
1026
  async function load() {
1061
- applyExit();
1062
1027
  const id = taskIdFromPath();
1063
1028
  if (!id) { renderNotFound(''); return; }
1064
1029
  try {
@@ -8,7 +8,7 @@
8
8
  "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
11
- "from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
11
+ "from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#topbar-back", "#copy-prompt-btn"], "attr": [["#topbar-back", "href", "/goals#/goal/1000081"]] } },
12
12
  "missing": { "auth": true, "url": "/task/1003999", "actions": [["wait", 600]], "expect": { "visible": ["#task-h"] } }
13
13
  },
14
14
  "reducedMotion": { "auth": true, "actions": [["wait", 600]] }
@@ -138,7 +138,7 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
141
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
142
142
  <script src="/builders/palette.js" defer></script>
143
143
 
144
144
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -177,6 +177,10 @@ html[data-fullscreen="on"] .tw-bar { width: min(1200px, calc(100% - 144px)); }
177
177
  /* With the chrome showing, the shell's fixed top bar holds the window's top,
178
178
  so the stuck bar stops under it instead of sliding beneath it. */
179
179
  html:not([data-fullscreen="on"]) .tw-bar { top: calc(var(--topbar-h, 56px) + 12px); }
180
+ /* task 1004477: the bar's exit is the shell's Back link, filled by shell.js.
181
+ With the chrome showing, the top bar already carries it, so the bar shows it
182
+ only in full screen and the page never offers two. */
183
+ html:not([data-fullscreen="on"]) .tw-stage .tw-back { display: none; }
180
184
  .tw-stage .tw-back {
181
185
  display: inline-flex;
182
186
  align-items: center;
@@ -29,7 +29,7 @@
29
29
  data-fullscreen-switch: the shell's full-screen switch (task 1004327). The
30
30
  artboard is the editor over the whole window, so the editor opens full
31
31
  screen the first time (tweak-editor.js), and the switch is remembered. -->
32
- <body data-page="tweak-editor" data-page-title="Tweak a page" data-fullscreen-switch>
32
+ <body data-page="tweak-editor" data-page-title="Tweak a page" data-fullscreen-switch data-back-fallback="/studio">
33
33
 
34
34
  <div class="app">
35
35
  <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
@@ -49,7 +49,9 @@
49
49
  <!-- A <div>, not a <header>: the shell's top bar is the page's one
50
50
  <header> (the kit's one-header-main-h1 floor). -->
51
51
  <div class="tw-bar" id="tw-bar">
52
- <a class="tw-back" data-own-back href="/builders/studio">← studio</a>
52
+ <!-- task 1004477: the shell's exit, shown only in full screen, where the
53
+ top bar (and its Back link) is hidden. The shell fills it. -->
54
+ <a class="tw-back" data-shell-exit href="/builders/studio">Back to studio</a>
53
55
  <span class="tw-name"><span id="tw-name">Tweak a page</span> <span class="tw-surface" id="tw-surface"></span></span>
54
56
  <span class="tw-counter" id="tw-counter" aria-live="polite"></span>
55
57
  <div class="tw-actions">
@@ -140,7 +142,7 @@
140
142
 
141
143
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
142
144
 
143
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
145
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
144
146
  <script src="/builders/palette.js" defer></script>
145
147
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
146
148
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
165
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-10-02-theme"></script>
174
+ <script src="/builders/shell.js?v=2026-10-02-back"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -1,4 +1,4 @@
1
- # Hall navigation — the Back link and the standard full-screen switch (task 1004430)
1
+ # Hall navigation — the Back link and the standard full-screen switch (tasks 1004430, 1004477)
2
2
 
3
3
  Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html` joining the chrome.
4
4
 
@@ -7,16 +7,19 @@ Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html`
7
7
  - `shell.js` renders `#topbar-back` ("← <label>") at the head of the top bar when the page was reached from INSIDE another hall page. The origin is `?from=` first, then a same-origin `document.referrer`; the label is that page's `NAV` label (or "task N" / "idea N" / "goal N" for a record).
8
8
  - **A sidebar hop is not a drill-down.** A sidebar link click writes a one-shot `sessionStorage['hall-nav-hop']` naming its target; the page it lands on reads and deletes it, and shows no Back link. Without this every page reached from the sidebar would carry a Back link, which is just history.
9
9
  - When the origin came from the referrer, the click runs `history.back()`, so a list comes back with its scroll and filters; the `href` is still the real address for a new tab.
10
- - A page drawing its own record back link (`.rec-back` — `task.html`, `idea.html`) keeps it; the bar adds no second one. The origin's own `?from=` is dropped so back links never chain.
10
+ - **No page draws its own Back link (task 1004477).** `task.html`, `idea.html` and the tweak editor used to, and the bar skipped its link on those pages. Now the shell decides the way back for every page, with two hooks:
11
+ - `<body data-back-fallback="/work">` names where Back goes when there is no origin (a deep-linked task falls back to Tasks, an idea to Your thinking, the tweak editor to Studio). It is honoured only as a path on this host; a sidebar hop still shows nothing.
12
+ - A link marked `[data-shell-exit]` is a second exit a page places: the task record's actions row and its two failure renders, the primer and drachmae error notes, and the tweak editor's bar. The shell fills its `href` and its words ("Back to goal 12", "Back to tasks"), and with no way back at all it goes home. A page that renders one after boot calls `OTBShell.fillExits(root)`.
13
+ - The origin's own `?from=` is dropped so back links never chain, and a `?from=` naming the page itself is refused.
11
14
  - `/profile?id=N` highlights **Builders**, not your own Profile, unless `/me` says the id is yours.
12
15
 
13
16
  ## Full screen is standard
14
17
 
15
18
  - Every shelled page gets the switch. `<body data-fullscreen-switch="off">` opts out. The bare attribute means "this page lays itself out for full screen" (sky, studio, tweak editor); a page without it is **plain**, and the shell sets `html[data-fullscreen-plain="on"]` so `style.css` pads `.app-content` clear of the corner button.
16
- - In full screen the button is a labelled pill, an icon plus "Exit full screen", in `--bg-card` with a ring and a drop shadow, not the hall's near-clear glass. Under 640px it drops the words, and the tweak editor keeps the 36px round button because its bar is laid out for one. A page whose own back link is not `.rec-back` marks it `data-own-back`. On the black sky in the light theme the glass button was a dark icon on black, and because the choice is remembered per page, `/thinking` looked stuck with no bars. Entering full screen (or a page opening in it) also shows a 4.5s `#fullscreen-hint` saying how to leave.
19
+ - In full screen the button is a labelled pill, an icon plus "Exit full screen", in `--bg-card` with a ring and a drop shadow, not the hall's near-clear glass. Under 640px it drops the words, and the tweak editor keeps the 36px round button because its bar is laid out for one. Full screen hides the top bar and its Back link, so the tweak editor's bar carries the shell's exit and shows it only in full screen (`tweak-editor.css`); with the chrome showing, the page never offers two. On the black sky in the light theme the glass button was a dark icon on black, and because the choice is remembered per page, `/thinking` looked stuck with no bars. Entering full screen (or a page opening in it) also shows a 4.5s `#fullscreen-hint` saying how to leave.
17
20
 
18
21
  ## The project sky joined the hall
19
22
 
20
23
  `goal-map.html` now uses thinking.html's structure: sidebar and top-bar mounts, `style.css`, `shell.js`, the sky inside `<main id="hall-main" class="sky-stage">`, and a `<div class="brand">` because the shell's bar is the page's one `<header>`. In `thinking.css` both pages share one stage rule, `body[data-page] .sky-stage`. No hall page is standalone any more (`tests/hall_audit.mjs` `STANDALONE_EXEMPT` is empty).
21
24
 
22
- Tests: `tests/hall_fullscreen.mjs` (the standard switch, `plain`, Back link cases), `tests/goal_map_page.mjs`, `tests/hall_audit.mjs` §8.
25
+ Tests: `tests/hall_fullscreen.mjs` (the standard switch, `plain`, Back link, fallback and exit cases), `tests/hall_record_world.mjs` §10 (the record places its exit), `tests/goal_map_page.mjs`, `tests/hall_audit.mjs` §8.
@@ -585,6 +585,10 @@ const SESSION_SUMMARY_COLS = `
585
585
  -- here means the row predates the classifier and the backfill has not reached
586
586
  -- it, which the ledger renders as a dash rather than claiming a verdict.
587
587
  sr.session_type,
588
+ -- ADR 0051 / task 1002830: whether a full transcript is stored, so the ledger only
589
+ -- offers a Read door where there is something behind it (rows that predate
590
+ -- transcript capture have none, and a door to a 404 is worse than no door).
591
+ EXISTS (SELECT 1 FROM session_transcripts st WHERE st.session_id = sr.session_id) AS has_transcript,
588
592
  sr.uploaded_at`;
589
593
 
590
594
  // searchSessions — cross-builder search over the corpus, filtered by session
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.22",
3
+ "version": "1.21.24",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.22",
9
+ "version": "1.21.24",
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.21.22",
3
+ "version": "1.21.24",
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",
@@ -8888,5 +8888,17 @@
8888
8888
  "id": "1004556",
8889
8889
  "text": "New projects are now always started on the latest released version of Cloud Bongos, even when the service that sets them up is running an older one. Until now, a lagging setup service passed its own old version to every new pr"
8890
8890
  }
8891
+ ],
8892
+ "1.21.23": [
8893
+ {
8894
+ "id": "1004477",
8895
+ "text": "Every page in the builders hall now goes back the same way: one Back button in the top bar. Task and idea pages used to draw their own back links, and the tweak editor had its own too. Opened from a goal, a task now shows \"Bac"
8896
+ }
8897
+ ],
8898
+ "1.21.24": [
8899
+ {
8900
+ "id": "1002830",
8901
+ "text": "Sessions now has a Read button on each row that opens the full conversation for that session. Only the session's owner or an administrator can read it, and administrator reads are logged."
8902
+ }
8891
8903
  ]
8892
8904
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.24'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -28,10 +28,12 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
28
28
  // is SHARED across boots on purpose: a second boot over the same store is a reload.
29
29
  // `optIn`: true = the bare attribute (the page lays itself out for full screen),
30
30
  // null = no attribute (a plain page — the switch is standard since task
31
- // 1004430), 'off' = the page opts out. `referrer` / `search` / `session` /
32
- // `recBack` drive the top bar's Back link (task 1004430).
31
+ // 1004430), 'off' = the page opts out. `referrer` / `search` / `session` drive
32
+ // the top bar's Back link (task 1004430); `fallback` is the page's
33
+ // <body data-back-fallback> and `exits` how many [data-shell-exit] links the
34
+ // page placed for the shell to fill (task 1004477).
33
35
  function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false,
34
- referrer = '', search = '', session = {}, recBack = false, path = null, meId = null } = {}) {
36
+ referrer = '', search = '', session = {}, fallback = null, exits = 0, path = null, meId = null } = {}) {
35
37
  const els = new Map();
36
38
  const windowListeners = {};
37
39
  const timers = [];
@@ -65,9 +67,10 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
65
67
  return { href, dataset: {}, getAttribute: (k) => (k === 'href' ? href : null),
66
68
  classList: { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) } };
67
69
  });
70
+ const exitLinks = Array.from({ length: exits }, () => makeEl(null));
68
71
  const attr = optIn === true ? '' : optIn === false ? null : optIn;
69
72
  const body = {
70
- dataset: { page },
73
+ dataset: fallback == null ? { page } : { page, backFallback: fallback },
71
74
  hasAttribute: (k) => k === 'data-fullscreen-switch' && attr !== null,
72
75
  getAttribute: (k) => (k === 'data-fullscreen-switch' ? attr : null),
73
76
  appendChild() {},
@@ -77,8 +80,9 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
77
80
  referrer,
78
81
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
79
82
  getElementById,
80
- querySelector: (sel) => (/\.rec-back/.test(sel) && recBack ? {} : null),
81
- querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item' ? navItems : []),
83
+ querySelector: () => null,
84
+ querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item' ? navItems
85
+ : sel === '[data-shell-exit]' ? exitLinks : []),
82
86
  createElement() { return makeEl(null); },
83
87
  addEventListener() {}, dispatchEvent() {},
84
88
  get cookie() { return ''; }, set cookie(_v) {},
@@ -123,6 +127,8 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
123
127
  plain: () => root.dataset.fullscreenPlain === 'on',
124
128
  active: () => navItems.filter((a) => a.classList.contains('nav-item--active')).map((a) => a.href),
125
129
  topbar: () => topbar._html,
130
+ exits: () => exitLinks.map((a) => ({ href: a.getAttribute('href'), text: a.textContent, wired: !!(a._on && a._on.click) })),
131
+ shell: () => windowObj.OTBShell,
126
132
  session,
127
133
  resizes: () => resizes,
128
134
  // Press a key the way the browser dispatches it: every window listener,
@@ -170,14 +176,69 @@ test('Back: ?from= wins over the referrer, and a record origin is named as the r
170
176
  assert.match(p.topbar(), />task 42<\/span>/);
171
177
  });
172
178
 
173
- test('Back: none after a sidebar hop, from another site, from the page itself, or where the page draws its own', () => {
179
+ test('Back: none after a sidebar hop, from another site, or from the page itself', () => {
174
180
  const hop = boot({ page: 'goals', optIn: false, referrer: 'https://h/work', session: { 'hall-nav-hop': '/goals' } });
175
181
  assert.doesNotMatch(hop.topbar(), /topbar-back/, 'the sidebar is not a drill-down');
176
182
  assert.equal(hop.session['hall-nav-hop'], undefined, 'the note is one-shot');
177
183
  assert.doesNotMatch(boot({ page: 'goals', optIn: false, referrer: 'https://elsewhere.example/roster' }).topbar(), /topbar-back/);
178
184
  assert.doesNotMatch(boot({ page: 'goals', optIn: false, referrer: 'https://h/goals' }).topbar(), /topbar-back/);
179
- assert.doesNotMatch(boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/goals', recBack: true }).topbar(), /topbar-back/,
180
- 'the task record keeps its own back link, and the bar adds no second one');
185
+ });
186
+
187
+ // task 1004477: no page draws its own Back any more, so the shell's is the
188
+ // record's too — the task page opened from a goal shows Back to that goal.
189
+ test('Back: a record page gets the bar\'s Back like every other page', () => {
190
+ const p = boot({ page: 'task', optIn: false, path: '/task/9', search: '?from=' + encodeURIComponent('/goals#/goal/12'), fallback: '/work' });
191
+ assert.match(p.topbar(), /<a class="topbar-back" id="topbar-back" href="\/goals#\/goal\/12"/);
192
+ assert.match(p.topbar(), />goal 12<\/span>/);
193
+ assert.doesNotMatch(SHELL, /rec-back|data-own-back/, 'the suppression for page-drawn links is gone');
194
+ });
195
+
196
+ test('Back: with no origin, the page\'s data-back-fallback is the way back', () => {
197
+ const task = boot({ page: 'task', optIn: false, path: '/task/9', fallback: '/work' });
198
+ assert.match(task.topbar(), /href="\/work"/, 'a deep-linked task falls back to the board');
199
+ assert.match(task.topbar(), />Tasks<\/span>/);
200
+ const idea = boot({ page: 'idea', optIn: false, path: '/idea/4', referrer: 'https://elsewhere.example/', fallback: '/thinking' });
201
+ assert.match(idea.topbar(), /href="\/thinking"/, 'a cross-site arrival falls back too');
202
+ assert.doesNotMatch(boot({ page: 'goals', optIn: false }).topbar(), /topbar-back/, 'a page with no fallback still shows none');
203
+ assert.doesNotMatch(boot({ page: 'studio', optIn: false, path: '/studio', fallback: '/studio' }).topbar(), /topbar-back/,
204
+ 'a fallback to the page itself is not a way back');
205
+ });
206
+
207
+ test('Back: a hostile ?from= is refused, and the fallback stands in', () => {
208
+ const bad = ['//evil.example', '/\\evil.example', 'javascript:alert(1)', 'https://evil.example/x',
209
+ '/api/gds/auth/web/callback', '/lib/bongos-client.js', '/task/9'];
210
+ for (const from of bad) {
211
+ const p = boot({ page: 'task', optIn: false, path: '/task/9', search: '?from=' + encodeURIComponent(from), fallback: '/work' });
212
+ assert.match(p.topbar(), /id="topbar-back" href="\/work"/, `from=${JSON.stringify(from)}`);
213
+ }
214
+ for (const fallback of ['//evil.example', 'https://evil.example/', '/api/x', 'work']) {
215
+ assert.doesNotMatch(boot({ page: 'task', optIn: false, path: '/task/9', fallback }).topbar(), /topbar-back/,
216
+ `a fallback is a path on this host or nothing: ${JSON.stringify(fallback)}`);
217
+ }
218
+ });
219
+
220
+ test('Back: a sidebar hop beats the fallback — the sidebar is still not a drill-down', () => {
221
+ const p = boot({ page: 'studio', optIn: false, path: '/studio', fallback: '/work', session: { 'hall-nav-hop': '/studio' } });
222
+ assert.doesNotMatch(p.topbar(), /topbar-back/);
223
+ });
224
+
225
+ test('exits: every [data-shell-exit] link carries the bar\'s way back, in a sentence', () => {
226
+ const p = boot({ page: 'task', optIn: false, path: '/task/9', search: '?from=' + encodeURIComponent('/goals#/goal/12'), exits: 2 });
227
+ assert.deepEqual(p.exits(), [
228
+ { href: '/goals#/goal/12', text: 'Back to goal 12', wired: true },
229
+ { href: '/goals#/goal/12', text: 'Back to goal 12', wired: true },
230
+ ]);
231
+ const board = boot({ page: 'task', optIn: false, path: '/task/9', fallback: '/work', exits: 1 });
232
+ assert.deepEqual(board.exits()[0], { href: '/work', text: 'Back to tasks', wired: true }, 'a nav label reads lowercase mid-sentence');
233
+ const lost = boot({ page: 'primer', optIn: false, path: '/primer', exits: 1 });
234
+ assert.deepEqual(lost.exits()[0], { href: '/', text: 'Back to home', wired: true }, 'with no way back at all, an exit goes home');
235
+ });
236
+
237
+ test('exits: a page that renders one later asks the shell, and asking twice wires it once', () => {
238
+ const p = boot({ page: 'task', optIn: false, path: '/task/9', fallback: '/work', exits: 1 });
239
+ assert.equal(typeof p.shell().fillExits, 'function', 'the shell exposes it');
240
+ p.shell().fillExits();
241
+ assert.deepEqual(p.exits()[0], { href: '/work', text: 'Back to tasks', wired: true });
181
242
  });
182
243
 
183
244
  test('someone else\'s profile highlights Builders; your own hands the mark back to Profile', async () => {
@@ -191,9 +252,24 @@ test('someone else\'s profile highlights Builders; your own hands the mark back
191
252
  assert.deepEqual(boot({ page: 'profile', optIn: false }).active(), ['/profile'], 'no id: your own profile');
192
253
  });
193
254
 
194
- test('Back: a page\'s own back link (data-own-back, the tweak editor\'s "← studio") suppresses the bar\'s', () => {
195
- assert.match(read('tweak-editor.html'), /<a class="tw-back" data-own-back href="\/builders\/studio">/);
196
- assert.match(SHELL, /querySelector\('\.rec-back, \[data-own-back\]'\)/);
255
+ // task 1004477: the pages that used to draw their own. Source checks, labelled
256
+ // as such: what each page now CARRIES is markup, and the behaviour of what it
257
+ // carries is the shell's, driven above.
258
+ test('no hall page draws its own back link; each carries the shell\'s hooks instead', () => {
259
+ const editor = read('tweak-editor.html');
260
+ assert.match(editor, /<a class="tw-back" data-shell-exit href="\/builders\/studio">/,
261
+ 'the editor\'s bar keeps an exit, because full screen hides the top bar');
262
+ assert.match(editor, /<body [^>]*data-back-fallback="\/studio"/);
263
+ assert.match(read('tweak-editor.css'), /html:not\(\[data-fullscreen="on"\]\) \.tw-stage \.tw-back \{ display: none; \}/,
264
+ 'and shows it only in full screen, so the page never offers two');
265
+ assert.match(read('idea.html'), /<body [^>]*data-back-fallback="\/thinking"/);
266
+ for (const f of ['primer.html', 'drachmae.html']) {
267
+ assert.match(read(f), /<a class="btn btn--ghost" data-shell-exit href="\/builders">/, `${f}: its error exit is the shell's`);
268
+ }
269
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.html'))) {
270
+ assert.doesNotMatch(read(f), /class="rec-back|data-own-back/, `${f} draws no back link of its own`);
271
+ }
272
+ assert.doesNotMatch(read('record.css'), /\.rec-back/, 'and the archetype sheet styles none');
197
273
  });
198
274
 
199
275
  test('Back: an origin\'s own ?from= is dropped, so one page\'s way back never chains into the next', () => {