@bongos/core 1.21.21 → 1.21.23

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 (56) hide show
  1. package/.bongos-core.json +60 -60
  2. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  3. package/docs/copy-inventory.md +105 -108
  4. package/docs/copy-registry.json +129 -166
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +246 -243
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/city.html +1 -1
  11. package/modules/hall-ui/public/collab.html +1 -1
  12. package/modules/hall-ui/public/commands.html +1 -1
  13. package/modules/hall-ui/public/copy-desk.html +1 -1
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +2 -2
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goal-map.html +1 -1
  20. package/modules/hall-ui/public/goals.html +1 -1
  21. package/modules/hall-ui/public/government.html +1 -1
  22. package/modules/hall-ui/public/idea.html +5 -7
  23. package/modules/hall-ui/public/ideas.html +1 -1
  24. package/modules/hall-ui/public/index.html +1 -1
  25. package/modules/hall-ui/public/modules.html +1 -1
  26. package/modules/hall-ui/public/primer.html +2 -2
  27. package/modules/hall-ui/public/profile.html +1 -1
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/record.css +4 -19
  31. package/modules/hall-ui/public/roadmap.html +1 -1
  32. package/modules/hall-ui/public/roster.html +1 -1
  33. package/modules/hall-ui/public/sessions.html +1 -1
  34. package/modules/hall-ui/public/settings.html +1 -1
  35. package/modules/hall-ui/public/shell.js +56 -5
  36. package/modules/hall-ui/public/street.html +1 -1
  37. package/modules/hall-ui/public/studio.html +1 -1
  38. package/modules/hall-ui/public/task.html +6 -9
  39. package/modules/hall-ui/public/task.js +15 -50
  40. package/modules/hall-ui/public/task.states.json +1 -1
  41. package/modules/hall-ui/public/thinking.html +1 -1
  42. package/modules/hall-ui/public/tweak-editor.css +4 -0
  43. package/modules/hall-ui/public/tweak-editor.html +5 -3
  44. package/modules/hall-ui/public/watch.html +1 -1
  45. package/modules/hall-ui/public/work.html +1 -1
  46. package/modules/hall-ui/records/hall-navigation.md +7 -4
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +12 -0
  50. package/scripts/gds/provision-repo.js +9 -2
  51. package/src/module-api.js +1 -1
  52. package/tests/hall_fullscreen.mjs +88 -12
  53. package/tests/hall_record_world.mjs +41 -63
  54. package/tests/hall_tweak_editor.mjs +3 -2
  55. package/tests/nav_permission_atoms.mjs +1 -1
  56. package/tests/provision.mjs +7 -2
@@ -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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.21",
3
+ "version": "1.21.23",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.21",
9
+ "version": "1.21.23",
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.21",
3
+ "version": "1.21.23",
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",
@@ -8882,5 +8882,17 @@
8882
8882
  "id": "1004544",
8883
8883
  "text": "The governor city and the street now open inside the normal hall, with the sidebar and top bar around them like every other page. A full-screen button in the corner hides both so the city or the street fills the window again,"
8884
8884
  }
8885
+ ],
8886
+ "1.21.22": [
8887
+ {
8888
+ "id": "1004556",
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
+ }
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
+ }
8885
8897
  ]
8886
8898
  }
@@ -515,8 +515,14 @@ function runningCoreVersion({ instanceRoot, coreV }) {
515
515
  // publish as candidates, the platform hall runs ahead of the registry's `latest` label by
516
516
  // design — and the ladder below vends whatever the hall runs, so every new project would
517
517
  // be born on a version nobody released. When the hall is AHEAD of the release, the project
518
- // gets the release instead. Equal (or the hall behind it, which a release can't normally
519
- // produce) → the running core, byte-exact, as before.
518
+ // gets the release instead. Equal → the running core, byte-exact, as before.
519
+ //
520
+ // BEHIND the release is real too (task 1004556): the runner that scaffolds is not always
521
+ // the platform hall. On cloudbongos.com it ran from a patch-channel install that could not
522
+ // cross the 1.21.0 minor, so it sat on 1.20.82 and every new project was born there,
523
+ // without founding mode (1.21+). The release is what ADR 0361 D5 promises a new project,
524
+ // and a released core has already run on the platform hall, so the runner's lag must not
525
+ // set the project's version: behind → the release, packed from the registry, too.
520
526
  //
521
527
  // No readable label → the running core, deliberately (fail-open). Until candidates are
522
528
  // armed the running core IS the release; vendoring is the path that works with no
@@ -525,6 +531,7 @@ function runningCoreVersion({ instanceRoot, coreV }) {
525
531
  function projectCoreDecision({ running, released }) {
526
532
  if (!released || !isStable(released)) return { version: running, source: 'running', reason: 'no readable released (latest) label — using the core this hall runs' };
527
533
  if (compareSemver(running, released) > 0) return { version: released, source: 'released', reason: `this hall runs ${running}, ahead of the release` };
534
+ if (compareSemver(running, released) < 0) return { version: released, source: 'released', reason: `this runner is on ${running}, behind the release` };
528
535
  return { version: running, source: 'running', reason: `this hall runs the released core ${released}` };
529
536
  }
530
537
 
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.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.23'; // 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', () => {
@@ -306,7 +306,8 @@ const CLAIM_ACTION_SRC = read('claim-action.js');
306
306
  // all of them, because what these tests are actually about is the REQUEST the
307
307
  // page issues — `requests` carries paths only and is left alone so the older
308
308
  // assertions on it keep meaning what they meant.
309
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
309
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims, shell = false } = {}) {
310
+ const fills = []; // task 1004477: each time the page asked the shell to fill its exits
310
311
  const requests = [];
311
312
  const writes = [];
312
313
  const toasts = [];
@@ -400,6 +401,9 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
400
401
  confirm: () => true,
401
402
  navigator: { clipboard: { writeText: async () => {} } },
402
403
  };
404
+ // task 1004477: the shell is what fills a [data-shell-exit] link. `shell: true`
405
+ // stands it in so a test can see the page ask; absent, the page must still work.
406
+ if (shell) windowObj.OTBShell = { fillExits(root) { fills.push(root); } };
403
407
  class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
404
408
  const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
405
409
  sandbox.globalThis = sandbox;
@@ -410,6 +414,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
410
414
  assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
411
415
  return {
412
416
  boot: onReady,
417
+ fills,
413
418
  html: () => documentObj.getElementById('task-body').innerHTML,
414
419
  sub: () => documentObj.getElementById('task-sub').textContent,
415
420
  el: (id) => documentObj.getElementById(id),
@@ -536,77 +541,50 @@ test('the idea grade badge IS the archetype pill, not a private copy of it', ()
536
541
  'idea.css carried a near-verbatim copy of .rec-pill down to the token triples — a hundred lines waiting to drift');
537
542
  });
538
543
 
539
- // ---- 10. the way out — the record exits to where it was opened from ----------
544
+ // ---- 10. the way out — the record exits through the shell's Back ------------
540
545
  //
541
- // task 1003468. task.js hardcoded "Open the work board" in three places and
542
- // task.html's crumb a fourth, so a builder who walked goal → task had only the
543
- // browser's own Back, and the two failure renders sent them somewhere they had
544
- // never been. The list that opens a record now writes `?from=` on the link
545
- // (task-detail.js's href()) and the record offers THAT as its exit — in the
546
- // crumb before the fetch lands, and in the actions row of every render
547
- // including the two failures. A same-origin referrer is the fallback for a link
548
- // written without it; the board is the fallback for a reader with no history.
549
- // Every value is honoured only as a path on this host.
550
-
551
- const FROM_GOAL = '?from=' + encodeURIComponent('/goals#/goal/1000081');
552
-
553
- test('opened from a goal, the record exits to that goal — crumb and actions row alike', async () => {
554
- const r = renderTask(BASE_TASK, { search: FROM_GOAL });
555
- await r.boot(); await settle();
556
- assert.match(r.html(), /<a class="td-btn" href="\/goals#\/goal\/1000081">Back to goal 1000081<\/a>/);
557
- assert.doesNotMatch(r.html(), /Open the work board/, 'the hardcoded exit is gone from the happy path');
558
- assert.equal(r.el('task-back').href, '/goals#/goal/1000081', 'the crumb points at the goal');
559
- assert.equal(r.el('task-back-label').textContent, 'Back to goal 1000081');
560
- });
561
-
562
- test('opened from the board, the exit is the board — on the tab it was opened from', async () => {
563
- const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/work#backlog') });
564
- await r.boot(); await settle();
565
- assert.match(r.html(), /href="\/work#backlog">Back to the work board</);
566
- });
567
-
568
- test('without ?from=, a same-origin referrer is the way back', async () => {
569
- const r = renderTask(BASE_TASK, { referrer: 'https://builders.cloudbongos.com/sessions?page=2' });
570
- await r.boot(); await settle();
571
- assert.match(r.html(), /href="\/sessions\?page=2">Back to sessions</);
572
- });
573
-
574
- test('a cross-origin referrer, a broken one, or none, falls back to the work board', async () => {
575
- for (const referrer of ['', 'https://example.org/elsewhere', 'not a url']) {
576
- const r = renderTask(BASE_TASK, { referrer });
546
+ // task 1003468 taught the record to exit to where it was opened from; task
547
+ // 1004477 moved that decision into shell.js, which now makes it for every hall
548
+ // page (tests/hall_fullscreen.mjs drives the real shell through ?from=, the
549
+ // referrer, the hostile values and the fallback). What stays the record's is
550
+ // PLACING the exit: an actions-row link in every render, the two failures
551
+ // included, marked [data-shell-exit] so the shell fills it with the same
552
+ // address and words as its top-bar Back — and asking the shell to, once the
553
+ // render that placed it has landed.
554
+
555
+ const EXIT_SLOT = /<a class="td-btn" data-shell-exit href="\/work">Back to tasks<\/a>/;
556
+
557
+ test('every render places one shell exit — the record and both honest failures', async () => {
558
+ const cases = [['record', 200], ['not found', 404], ['feed unavailable', 500]];
559
+ for (const [name, status] of cases) {
560
+ const r = renderTask(BASE_TASK, { status, shell: true });
577
561
  await r.boot(); await settle();
578
- assert.match(r.html(), /href="\/work">Back to the work board</, `referrer ${JSON.stringify(referrer)}`);
579
- assert.equal(r.el('task-back').href, '/work');
562
+ assert.equal((r.html().match(/data-shell-exit/g) || []).length, 1, `${name}: one exit`);
563
+ assert.match(r.html(), EXIT_SLOT, `${name}: the static href is what a reader without the shell still gets`);
564
+ assert.doesNotMatch(r.html(), /Open the work board/, `${name}: the old hardcoded exit stays gone`);
565
+ assert.ok(r.fills.length >= 1, `${name}: the page asked the shell to fill the exit it placed`);
580
566
  }
581
567
  });
582
568
 
583
- test('a hostile or useless ?from= is refused, never followed', async () => {
584
- const bad = [
585
- '//evil.example', '/\\evil.example', 'javascript:alert(1)', 'https://evil.example/x',
586
- '/api/gds/auth/web/callback', '/lib/bongos-client.js',
587
- '/task/1003305', // this same record — a loop, not a way back
588
- 'work', '/ work', '',
589
- ];
590
- for (const from of bad) {
591
- const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent(from) });
592
- await r.boot(); await settle();
593
- assert.match(r.html(), /href="\/work">Back to the work board</, `from=${JSON.stringify(from)}`);
594
- assert.equal(r.el('task-back').href, '/work', `the crumb refuses from=${JSON.stringify(from)} too`);
595
- }
569
+ test('the page no longer decides the way back: ?from= is the shell\'s to read', async () => {
570
+ const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/goals#/goal/1000081'), shell: true });
571
+ await r.boot(); await settle();
572
+ assert.doesNotMatch(r.html(), /href="\/goals#\/goal\/1000081"/, 'task.js writes no origin of its own into the exit');
573
+ assert.doesNotMatch(TASK_SRC, /\bsafeHallPath\b|\breferrerPath\b|\bexitTarget\b|\bapplyExit\b/,
574
+ 'the second copy of the origin logic is gone, so the two cannot drift');
596
575
  });
597
576
 
598
- test('the two honest failures exit the same way the record does', async () => {
599
- for (const status of [404, 500]) {
600
- const r = renderTask(BASE_TASK, { status, search: FROM_GOAL });
601
- await r.boot(); await settle();
602
- assert.match(r.html(), /href="\/goals#\/goal\/1000081">Back to goal 1000081</, `status ${status}`);
603
- assert.doesNotMatch(r.html(), /Open the work board/, `status ${status} still hardcodes the board`);
604
- }
577
+ test('without a shell the record still renders, and its exit still goes somewhere', async () => {
578
+ const r = renderTask(BASE_TASK);
579
+ await r.boot(); await settle();
580
+ assert.match(r.html(), EXIT_SLOT);
581
+ assert.equal(r.fills.length, 0);
605
582
  });
606
583
 
607
- test('the crumb is an element task.js can point, with its label apart from the icon', () => {
608
- assert.match(taskHtml, /<a class="rec-back" id="task-back" href="\/work">/, 'the static href is the fallback the page ships with');
609
- assert.match(taskHtml, /<span id="task-back-label">Back to tasks<\/span>/, 'the label is its own node, so the icon survives a relabel');
584
+ test('the crumb is the record\'s address only; the fallback rides the body', () => {
585
+ assert.doesNotMatch(taskHtml, /rec-back|task-back/, 'the page-drawn back link is gone');
586
+ assert.match(taskHtml, /<body [^>]*data-back-fallback="\/work"/, 'a deep-linked task still has a way back');
587
+ assert.match(taskHtml, /<p class="rec-sub" id="task-sub">/);
610
588
  });
611
589
 
612
590
  // ---- 11. the description is markdown, and renders as it (task 1003465) --------
@@ -635,12 +635,13 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-02-theme/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-02-back/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
642
642
  }
643
- assert.match(HTML, />← studio</);
643
+ // task 1004477: the bar's exit is the shell's, which fills its words.
644
+ assert.match(HTML, /<a class="tw-back" data-shell-exit href="\/builders\/studio">Back to studio</);
644
645
  assert.match(HTML, />submit page</);
645
646
  assert.match(HTML, />back to the studio</);
646
647
  });
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'a8dc2884930ca19a';
90
+ const SHELL_SHA = 'e5094c169d5ee6ef';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -1078,12 +1078,17 @@ await ta('scaffoldStandaloneRepo: a REGISTRY-pinned consumer control plane PACKS
1078
1078
  // ADR 0361: merges publish as candidates and the platform hall runs them, so the core
1079
1079
  // this hall runs is routinely AHEAD of the registry's `latest` label. A new project —
1080
1080
  // and the pin refresh that runs in the same provisioning pass — must get the release.
1081
- t('projectCoreDecision: the hall ahead of the release → the release; level or unreadable → the running core', () => {
1081
+ t('projectCoreDecision: the hall ahead of OR behind the release → the release; level or unreadable → the running core', () => {
1082
1082
  assert.deepEqual(
1083
1083
  (({ version, source }) => ({ version, source }))(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50' })),
1084
1084
  { version: '1.19.50', source: 'released' });
1085
1085
  assert.equal(RP.projectCoreDecision({ running: '1.19.50', released: '1.19.50' }).source, 'running', 'level → byte-exact running core, no registry pack');
1086
- assert.equal(RP.projectCoreDecision({ running: '1.19.40', released: '1.19.50' }).version, '1.19.40', 'never moves a project PAST what the hall has run');
1086
+ // task 1004556: a runner BEHIND the release (a patch-channel install stuck on 1.20.82
1087
+ // while 1.21.x was released) must not hand its stale core to a new project
1088
+ assert.deepEqual(
1089
+ (({ version, source }) => ({ version, source }))(RP.projectCoreDecision({ running: '1.20.82', released: '1.21.20' })),
1090
+ { version: '1.21.20', source: 'released' }, 'behind the release → the release, across a minor too');
1091
+ assert.equal(RP.projectCoreDecision({ running: '1.19.40', released: '1.19.50' }).source, 'released', 'and within a minor');
1087
1092
  assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: null }).version, '1.19.60', 'no label → fail-open to the running core');
1088
1093
  assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50-rc.1' }).source, 'running', 'a prerelease label is not a release');
1089
1094
  assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50;touch /tmp/x' }).source, 'running', 'a non-semver label never reaches a shell');