@bongos/core 1.20.50 → 1.20.51

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 (54) hide show
  1. package/.bongos-core.json +64 -59
  2. package/docs/copy-inventory.md +54 -52
  3. package/docs/copy-registry.json +106 -88
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-inventory.json +6 -1
  6. package/docs/page-readings.json +449 -427
  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/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 +1 -1
  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 +29 -14
  19. package/modules/hall-ui/public/goal-map.states.json +12 -0
  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 +1 -1
  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 +1 -1
  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/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.html +1 -1
  33. package/modules/hall-ui/public/settings.html +1 -1
  34. package/modules/hall-ui/public/shell.js +145 -15
  35. package/modules/hall-ui/public/studio.html +1 -1
  36. package/modules/hall-ui/public/style.css +61 -5
  37. package/modules/hall-ui/public/task.html +1 -1
  38. package/modules/hall-ui/public/thinking.css +15 -11
  39. package/modules/hall-ui/public/thinking.html +1 -1
  40. package/modules/hall-ui/public/tweak-editor.css +6 -0
  41. package/modules/hall-ui/public/tweak-editor.html +2 -2
  42. package/modules/hall-ui/public/watch.html +1 -1
  43. package/modules/hall-ui/public/work.html +1 -1
  44. package/modules/hall-ui/records/hall-navigation.md +22 -0
  45. package/modules/hall-ui/records/sky.md +2 -0
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +6 -0
  49. package/src/module-api.js +1 -1
  50. package/tests/goal_map_page.mjs +10 -7
  51. package/tests/hall_audit.mjs +3 -4
  52. package/tests/hall_fullscreen.mjs +99 -14
  53. package/tests/hall_tweak_editor.mjs +1 -1
  54. package/tests/nav_permission_atoms.mjs +1 -1
@@ -1051,15 +1051,71 @@ html[data-fullscreen="on"] .app-topbar {
1051
1051
  }
1052
1052
  html[data-fullscreen="on"] .app-topbar > :not(.topbar-side),
1053
1053
  html[data-fullscreen="on"] .topbar-side > :not(.fullscreen-toggle) { display: none; }
1054
+ /* task 1004430: the way back must read on ANY ground. The hall's glass is
1055
+ near-clear, so over the black sky in the light theme it was a dark glyph on
1056
+ black — the page looked stuck without its bars. The card tone plus a drop
1057
+ shadow and a ring keeps it legible on paper, on the sky and in either mode. */
1054
1058
  html[data-fullscreen="on"] .fullscreen-toggle {
1055
1059
  pointer-events: auto;
1056
- width: 36px; height: 36px;
1060
+ width: auto; height: 36px;
1061
+ gap: 6px;
1062
+ padding: 0 14px 0 10px;
1057
1063
  color: var(--ink);
1058
- background: var(--glass);
1059
- box-shadow: inset 0 0 0 1px var(--glass-edge);
1060
- backdrop-filter: blur(var(--glass-blur));
1061
- -webkit-backdrop-filter: blur(var(--glass-blur));
1064
+ background: var(--bg-card);
1065
+ box-shadow: 0 0 0 1px var(--rule), 0 4px 16px rgba(0, 0, 0, .35);
1066
+ font-size: 13px;
1067
+ font-weight: 500;
1068
+ white-space: nowrap;
1069
+ }
1070
+ /* A phone has no room for the words; the round button keeps its card tone. */
1071
+ @media (max-width: 640px) {
1072
+ html[data-fullscreen="on"] .fullscreen-toggle { width: 36px; padding: 0; }
1073
+ .fullscreen-toggle__label { display: none; }
1074
+ }
1075
+ html[data-fullscreen="on"] .fullscreen-toggle:hover { color: var(--accent-ink); }
1076
+ /* A page with no full-screen layout of its own steps its content clear of the
1077
+ corner button; the sky and the studio lay themselves out (shell.js `plain`). */
1078
+ html[data-fullscreen="on"][data-fullscreen-plain="on"] .app-content { padding-top: 60px; }
1079
+ /* Said once on entering, since the choice is remembered and a page can open in it. */
1080
+ .fullscreen-hint {
1081
+ position: fixed;
1082
+ top: 56px;
1083
+ right: 12px;
1084
+ z-index: var(--z-topbar);
1085
+ max-width: min(320px, calc(100vw - 24px));
1086
+ padding: 8px 12px;
1087
+ border-radius: 10px;
1088
+ background: var(--bg-card);
1089
+ color: var(--ink);
1090
+ font-size: 13px;
1091
+ line-height: 1.4;
1092
+ box-shadow: 0 0 0 1px var(--rule), 0 4px 16px rgba(0, 0, 0, .35);
1093
+ pointer-events: none;
1094
+ }
1095
+
1096
+ /* THE BACK LINK (task 1004430): at the head of the bar when the page was
1097
+ reached from inside another hall page. 24px floor on both axes. */
1098
+ .topbar-back {
1099
+ display: inline-flex;
1100
+ align-items: center;
1101
+ gap: 4px;
1102
+ min-height: 32px;
1103
+ min-width: 32px;
1104
+ padding: 0 10px 0 6px;
1105
+ margin-right: -4px;
1106
+ border-radius: var(--radius-pill);
1107
+ color: var(--ink-soft);
1108
+ font-size: 13px;
1109
+ text-decoration: none;
1110
+ white-space: nowrap;
1111
+ flex-shrink: 0;
1112
+ max-width: 40vw;
1113
+ overflow: hidden;
1114
+ text-overflow: ellipsis;
1062
1115
  }
1116
+ .topbar-back:hover { color: var(--ink); background: var(--rule-soft); }
1117
+ .topbar-back svg { width: 16px; height: 16px; flex-shrink: 0; }
1118
+ @media (max-width: 480px) { .topbar-back__label { display: none; } .topbar-back { padding: 0 8px; } }
1063
1119
  .zoom-toggle__pct {
1064
1120
  font-size: 12px;
1065
1121
  font-weight: 500;
@@ -57,7 +57,7 @@
57
57
 
58
58
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
59
59
  time so the chrome exists before the deferred page scripts execute. -->
60
- <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
60
+ <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
61
61
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
62
62
  <script src="/builders/palette.js" defer></script>
63
63
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -7,11 +7,11 @@
7
7
  ONE SHEET, TWO DOORS (part 4, task 1004232): /thinking and /goal-map are the
8
8
  same page opened in different lenses, so both are named below.
9
9
  IN THE HALL (task 1004327): /thinking now loads style.css and sits in the
10
- hall chrome; /goal-map is still standalone. So the sky's tokens and ground
11
- sit on /thinking's STAGE (main.sky-stage) instead of its body — the chrome
10
+ hall chrome, and /goal-map joined it in task 1004430. So the sky's tokens and
11
+ ground sit on the STAGE (main.sky-stage) instead of its body — the chrome
12
12
  around the stage keeps the hall's own palette in both modes — and the sky's
13
13
  few global rules are written :where()-scoped, so they cost no specificity
14
- (goal-map renders exactly as before) and never reach the chrome. */
14
+ and never reach the chrome. */
15
15
  /* The template declared these on :root. They sit on the page's body here,
16
16
  with the template's values untouched, because the server splices the
17
17
  branding pack's own :root (--ink, --accent, --rule …) into <head> AFTER this
@@ -21,7 +21,7 @@
21
21
  its own — tests/hall_reading_world.mjs; color-scheme inherits from body
22
22
  too). Same reason `body` below is qualified: a pack backdrop is written as
23
23
  `body{background:…}` and the void must win. */
24
- body[data-page="goal-map"], body[data-page="thinking"] .sky-stage {
24
+ body[data-page] .sky-stage {
25
25
  color-scheme: dark;
26
26
  --void: #000000; --ink: #ffffff; --ink-soft: #c0c0c0; --ink-faint: #a8a8a8; --ink-dim: #65656a; --ink-ghost: #3a3a3f;
27
27
  --rule: rgba(255,255,255,.16); --rule-soft: rgba(255,255,255,.08); --glass: rgba(255,255,255,.075); --glass-strong: rgba(255,255,255,.14);
@@ -30,15 +30,15 @@ body[data-page="goal-map"], body[data-page="thinking"] .sky-stage {
30
30
  --face: Manrope, "Segoe UI", system-ui, -apple-system, sans-serif;
31
31
  }
32
32
  html, body { height: 100%; }
33
- body[data-page="goal-map"], body[data-page="thinking"] .sky-stage { margin: 0; background: var(--void); color: var(--ink); font-family: var(--face); font-size: 15px; line-height: 1.5; overflow: hidden; overflow: clip; -webkit-font-smoothing: antialiased; }
33
+ body[data-page] .sky-stage { margin: 0; background: var(--void); color: var(--ink); font-family: var(--face); font-size: 15px; line-height: 1.5; overflow: hidden; overflow: clip; -webkit-font-smoothing: antialiased; }
34
34
  /* clip, not just hidden (task 1004342): a hidden-overflow box is still scrollable, so
35
35
  focus scrolled the stage sideways to show the footer hint and the whole sky slid
36
36
  left. clip cuts the same pixels and cannot be scrolled; hidden stays as the fallback. */
37
- body[data-page="thinking"] { overflow: hidden; overflow: clip; }
38
- :where(body[data-page="goal-map"], .sky-stage) button { font-family: inherit; }
39
- :where(body[data-page="goal-map"], .sky-stage) a { color: var(--accent); }
40
- :where(body[data-page="goal-map"], .sky-stage) .lbl { font: var(--label); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-faint); }
41
- :where(body[data-page="goal-map"], .sky-stage) .mono { font: var(--mono); font-variant-numeric: tabular-nums; }
37
+ body[data-page="thinking"], body[data-page="goal-map"] { overflow: hidden; overflow: clip; }
38
+ :where(.sky-stage) button { font-family: inherit; }
39
+ :where(.sky-stage) a { color: var(--accent); }
40
+ :where(.sky-stage) .lbl { font: var(--label); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-faint); }
41
+ :where(.sky-stage) .mono { font: var(--mono); font-variant-numeric: tabular-nums; }
42
42
  [hidden] { display: none !important; }
43
43
 
44
44
  /* THE STAGE (task 1004327). The box under the hall's top bar and beside its
@@ -55,11 +55,15 @@ html[data-fullscreen="on"] .sky-stage { top: 0; left: 0; }
55
55
  @media (max-width: 768px) { .sky-stage { left: 0; } }
56
56
  /* The full-screen switch floats top right over the stage, where the sky's
57
57
  tools end; the strip steps in by the button's width so nothing sits under it. */
58
- html[data-fullscreen="on"] .sky-stage .top { padding-right: 64px; }
58
+ html[data-fullscreen="on"] .sky-stage .top { padding-right: 184px; }
59
59
  /* Under 900 the brand takes a row of its own (the template's rule below), so
60
60
  only that row steps in and the tools keep their width. */
61
61
  @media (max-width: 900px) {
62
62
  html[data-fullscreen="on"] .sky-stage .top { padding-right: 16px; }
63
+ html[data-fullscreen="on"] .sky-stage .brand { padding-right: 168px; }
64
+ }
65
+ /* Under 640 the exit is the round button again (style.css). */
66
+ @media (max-width: 640px) {
63
67
  html[data-fullscreen="on"] .sky-stage .brand { padding-right: 48px; }
64
68
  }
65
69
  /* style.css rules whose names the template also uses — each one reset to what
@@ -138,7 +138,7 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
141
+ <script src="/builders/shell.js?v=2026-09-30-navback2"></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>
@@ -136,10 +136,16 @@ html[data-fullscreen="on"] .tw-room { top: 0; left: 0; }
136
136
  /* In full screen the shell's one button floats over the night room in either
137
137
  theme, so on this page it wears the room's glass, not the hall's light ink. */
138
138
  html[data-fullscreen="on"] body[data-page="tweak-editor"] .fullscreen-toggle {
139
+ /* task 1004430: the shell's exit is a labelled pill, but this bar is laid out
140
+ for a 36px button in the corner, so the words would sit on submit. The
141
+ room keeps the round button; its name and title still say "Exit". */
142
+ width: 36px;
143
+ padding: 0;
139
144
  color: #ffffff;
140
145
  background: rgba(255, 255, 255, .07);
141
146
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .16);
142
147
  }
148
+ body[data-page="tweak-editor"] .fullscreen-toggle__label { display: none; }
143
149
 
144
150
  /* THE GLASS TOP BAR. Sticky, so the four doors stay in reach down a long page
145
151
  and the paper passes under the glass. In full screen it steps in far enough
@@ -49,7 +49,7 @@
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" href="/builders/studio">← studio</a>
52
+ <a class="tw-back" data-own-back href="/builders/studio">← studio</a>
53
53
  <span class="tw-name"><span id="tw-name">Tweak a page</span> <span class="tw-surface" id="tw-surface"></span></span>
54
54
  <span class="tw-counter" id="tw-counter" aria-live="polite"></span>
55
55
  <div class="tw-actions">
@@ -140,7 +140,7 @@
140
140
 
141
141
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
142
142
 
143
- <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
143
+ <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
146
146
  <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-09-30-founding-commands"></script>
165
+ <script src="/builders/shell.js?v=2026-09-30-navback2"></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-09-30-founding-commands"></script>
174
+ <script src="/builders/shell.js?v=2026-09-30-navback2"></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. -->
@@ -0,0 +1,22 @@
1
+ # Hall navigation — the Back link and the standard full-screen switch (task 1004430)
2
+
3
+ Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html` joining the chrome.
4
+
5
+ ## The Back link
6
+
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
+ - **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
+ - 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.
11
+ - `/profile?id=N` highlights **Builders**, not your own Profile, unless `/me` says the id is yours.
12
+
13
+ ## Full screen is standard
14
+
15
+ - 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.
17
+
18
+ ## The project sky joined the hall
19
+
20
+ `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
+
22
+ Tests: `tests/hall_fullscreen.mjs` (the standard switch, `plain`, Back link cases), `tests/goal_map_page.mjs`, `tests/hall_audit.mjs` §8.
@@ -31,6 +31,8 @@ Pinned by `tests/hall_sky.mjs` (31 cases: the reads, the five objects, the two c
31
31
  - **The sky's palette lives on the stage, not the body, on `/thinking`** — so the chrome around it keeps the hall's own light and dark. `style.css` now loads under `thinking.css`, and the handful of hall rules whose names the template also uses (`.skip`, `.filters`, `.foot`, `:focus-visible`, text inputs, `* { box-sizing }`) are reset under `:where(.sky-stage)` at the same specificity, before the sky's own rules. The sky's global rules are `:where()`-scoped for the same reason, which leaves `/goal-map` byte-for-byte in its old cascade.
32
32
  - **Both pages render in both modes now.** The chrome follows the hall theme; the sky's stage is the void in both. The `full` and `full-back` states in both states files (and `full-list` in the sky's) press the switch.
33
33
 
34
+ **Superseded for `/goal-map` by task 1004430 (owner, 2026-09-30):** the project sky joined the hall chrome the same way, with the same switch. Both pages now share one stage rule (`body[data-page] .sky-stage`), `STANDALONE_EXEMPT` is empty, and the full-screen button uses the card tone so it can be seen on the void. See [`hall-navigation.md`](hall-navigation.md).
35
+
34
36
  ## Two skies, one renderer (task 1004328, BV2.TW21)
35
37
 
36
38
  **Owner decision 2026-09-27: "Your thinking is only your thinking."** Part 4's "one page, two doors" above is SUPERSEDED: the two pages share the renderer and nothing else. The owner confirmed that "the your projects tab" means the whole-project lens, its switch and the project-map features. What this settled:
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.50",
3
+ "version": "1.20.51",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.50",
9
+ "version": "1.20.51",
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.20.50",
3
+ "version": "1.20.51",
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",
@@ -8413,5 +8413,11 @@
8413
8413
  "id": "1004417",
8414
8414
  "text": "Starting a new project now begins with two decisions before any setup. First screen, \"anything you want to make\": the founder ticks that they can sign legally for the platform and acknowledge the terms, with a link to read the"
8415
8415
  }
8416
+ ],
8417
+ "1.20.51": [
8418
+ {
8419
+ "id": "1004430",
8420
+ "text": "Hall navigation is more standard. The top bar now has a Back button when you open a page from inside another one. A builder's profile opened from the Builders list shows \"Back to Builders\" and returns you to the same spot in t"
8421
+ }
8416
8422
  ]
8417
8423
  }
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.20.50'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.51'; // 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');
@@ -422,18 +422,21 @@ await test('Sky sits directly under Goals in the nav, member-gated, at the URL G
422
422
 
423
423
  await test('the page is the sky\'s own surface — the same sheet and the same seven scripts as /thinking, one cache-bust between them, and the old goal map is gone', () => {
424
424
  const html = read('goal-map.html'); const thinking = read('thinking.html');
425
- assert.match(html, /<body data-page="goal-map" data-page-title="The sky" data-sky-lens="all">/);
425
+ assert.match(html, /<body data-page="goal-map" data-page-title="The sky" data-sky-lens="all" data-fullscreen-switch>/);
426
426
  assert.match(html, /<title>The sky — \{\{worldName\}\}<\/title>/);
427
427
  assert.match(html, /<h1>the sky <em id="lensWord">— the whole project<\/em><\/h1>/, 'the h1 is in static markup, so every state has one');
428
- assert.equal(/shell\.js|style\.css/.test(html), false, 'no hall chrome yet — /thinking joined it (task 1004327), the project sky did not');
428
+ // IN THE HALL (task 1004430): the project sky joined /thinking in the chrome.
429
+ assert.match(html, /<script src="\/builders\/shell\.js\?v=/, 'the shell draws its sidebar and top bar');
430
+ assert.match(html, /<link rel="stylesheet" href="\/builders\/style\.css/, 'and the hall sheet styles them');
431
+ assert.match(html, /<main id="hall-main" class="sky-stage">/, 'the sky is drawn in the stage, the skip link\'s target');
429
432
  const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
430
433
  // criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
431
- assert.deepEqual(scripts(html).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
432
- // /thinking also loads the shell (task 1004327); the SKY's files are the same.
434
+ assert.deepEqual(scripts(html).filter((s) => s[0] !== 'shell.js').map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
435
+ // Both doors load the shell (tasks 1004327, 1004430); the SKY's files are the same.
433
436
  const skyScripts = (h) => scripts(h).filter((s) => s[0] !== 'shell.js');
434
437
  assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
435
438
  assert.equal(html.match(/thinking\.css\?v=([^"]+)/)[1], thinking.match(/thinking\.css\?v=([^"]+)/)[1]);
436
- assert.match(read('thinking.css'), /body\[data-page="goal-map"\], body\[data-page="thinking"\] \.sky-stage \{/, 'the sheet names both doors: the project sky\'s body, and the stage /thinking draws in inside the hall');
439
+ assert.match(read('thinking.css'), /body\[data-page\] \.sky-stage \{/, 'the sheet dresses the one stage both doors draw in inside the hall');
437
440
  for (const f of ['goal-map.css', 'goal-map.js', 'goal-map-lib.js']) assert.equal(fs.existsSync(path.join(HALL, f)), false, `${f} is retired`);
438
441
  assert.equal(fs.existsSync(path.join(ROOT, 'tests/goal_map_lib.mjs')), false, 'and so is the lib\'s test');
439
442
  assert.equal(/goal-map-lib|goal-map\.js|goal-map\.css/.test(html), false);
@@ -457,11 +460,11 @@ await test('the ledger drawer accessible name matches its visible heading (docs/
457
460
  // slide the sky again whatever overflows; and the hint now shrinks instead.
458
461
  await test('the sky cannot be scrolled sideways: its box clips, it does not hide (task 1004342)', () => {
459
462
  const css = read('thinking.css');
460
- const rule = /body\[data-page="goal-map"\], body\[data-page="thinking"\] \.sky-stage \{ margin: 0;[^}]*\}/.exec(css);
463
+ const rule = /body\[data-page\] \.sky-stage \{ margin: 0;[^}]*\}/.exec(css);
461
464
  assert.ok(rule, 'the shared stage rule is still there');
462
465
  // hidden first as the fallback for an engine without clip, then clip, which wins where supported
463
466
  assert.match(rule[0], /overflow: hidden; overflow: clip;/, 'the stage clips (not scrollable), with hidden only as a fallback');
464
- assert.match(css, /body\[data-page="thinking"\] \{ overflow: hidden; overflow: clip; \}/, 'and so does the page body');
467
+ assert.match(css, /body\[data-page="thinking"\], body\[data-page="goal-map"\] \{ overflow: hidden; overflow: clip; \}/, 'and so does each door\'s page body');
465
468
  });
466
469
 
467
470
  await test('the footer hint shrinks with an ellipsis instead of running past the stage (task 1004342)', () => {
@@ -367,10 +367,9 @@ test('the module doc still records where the 2179 density pass landed', () => {
367
367
  // tests/hall_sky.mjs against the template. It still stamps the branding token
368
368
  // and the pack's font links like every hall page (the server injects them).
369
369
  // /thinking left this list in task 1004327 (owner decision 2026-09-27): it sits
370
- // in the hall chrome now, with the shell's full-screen switch to hide it.
371
- const STANDALONE_EXEMPT = {
372
- 'goal-map.html': 'the project sky (task 1004232) — the same surface as thinking.html, opened in its whole-project lens',
373
- };
370
+ // in the hall chrome now, with the shell's full-screen switch to hide it. The
371
+ // project sky (/goal-map) followed in task 1004430, so the list is empty again.
372
+ const STANDALONE_EXEMPT = {};
374
373
 
375
374
  test('every hall page loads style.css and shell.js', () => {
376
375
  const missing = [];
@@ -26,7 +26,12 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
26
26
 
27
27
  // Boot the shell once — one page load. `store` is the browser's localStorage and
28
28
  // is SHARED across boots on purpose: a second boot over the same store is a reload.
29
- function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false } = {}) {
29
+ // `optIn`: true = the bare attribute (the page lays itself out for full screen),
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).
33
+ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false,
34
+ referrer = '', search = '', session = {}, recBack = false, path = null, meId = null } = {}) {
30
35
  const els = new Map();
31
36
  const windowListeners = {};
32
37
  const timers = [];
@@ -49,25 +54,41 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
49
54
  const topbar = makeEl('app-topbar');
50
55
  const getElementById = (id) => {
51
56
  if (id === 'app-topbar') return topbar;
52
- if (/^(fullscreen-toggle|theme-toggle|zoom-toggle|topbar-menu|topbar-title|topbar-jump)$/.test(id)
57
+ if (/^(fullscreen-toggle|theme-toggle|zoom-toggle|topbar-menu|topbar-title|topbar-jump|topbar-back)$/.test(id)
53
58
  && !topbar._html.includes(`id="${id}"`)) return null;
54
59
  if (!els.has(id)) els.set(id, makeEl(id));
55
60
  return els.get(id);
56
61
  };
62
+ // Two sidebar items, enough to see which one the shell marks active.
63
+ const navItems = ['/roster', '/profile'].map((href) => {
64
+ const cls = new Set();
65
+ return { href, dataset: {}, getAttribute: (k) => (k === 'href' ? href : null),
66
+ classList: { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) } };
67
+ });
68
+ const attr = optIn === true ? '' : optIn === false ? null : optIn;
57
69
  const body = {
58
70
  dataset: { page },
59
- hasAttribute: (k) => optIn && k === 'data-fullscreen-switch',
71
+ hasAttribute: (k) => k === 'data-fullscreen-switch' && attr !== null,
72
+ getAttribute: (k) => (k === 'data-fullscreen-switch' ? attr : null),
73
+ appendChild() {},
60
74
  };
61
75
  const documentObj = {
62
76
  body,
77
+ referrer,
63
78
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
64
79
  getElementById,
65
- querySelectorAll() { return []; },
80
+ querySelector: (sel) => (/\.rec-back/.test(sel) && recBack ? {} : null),
81
+ querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item' ? navItems : []),
66
82
  createElement() { return makeEl(null); },
67
83
  addEventListener() {}, dispatchEvent() {},
68
84
  get cookie() { return ''; }, set cookie(_v) {},
69
85
  };
70
- const location = { hostname: 'h', origin: 'https://h', pathname: '/' + page, hash: '', search: '', assign() {}, replace() {} };
86
+ const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash: '', search, assign() {}, replace() {} };
87
+ const sessionStorage = {
88
+ getItem: (k) => (k in session ? session[k] : null),
89
+ setItem: (k, v) => { session[k] = String(v); },
90
+ removeItem: (k) => { delete session[k]; },
91
+ };
71
92
  const localStorage = {
72
93
  getItem: (k) => { if (storageThrows) throw new Error('SecurityError'); return k in store ? store[k] : null; },
73
94
  setItem: (k, v) => { if (storageThrows) throw new Error('SecurityError'); store[k] = String(v); },
@@ -75,16 +96,18 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
75
96
  let resizes = 0;
76
97
  const windowObj = {
77
98
  __BRANDING__: {}, __MODULES__: { enabled: {} },
78
- BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
99
+ // `meId` answers /me once with that builder; otherwise the read never settles.
100
+ BongosClient: { createClient: () => ({ request: () => (meId == null ? new Promise(() => {})
101
+ : Promise.resolve({ status: 200, ok: true, data: { builder: { id: meId, rank: 'thetes' } } })) }) },
79
102
  matchMedia: () => ({ matches: false, addEventListener() {} }),
80
- localStorage, location,
103
+ localStorage, sessionStorage, location, clearTimeout() {},
81
104
  requestAnimationFrame() {}, scrollTo() {},
82
105
  addEventListener(type, fn) { (windowListeners[type] ||= []).push(fn); },
83
106
  dispatchEvent(ev) { if (ev && ev.type === 'resize') resizes += 1; return true; },
84
107
  setTimeout: (fn) => { timers.push(fn); return timers.length; },
85
108
  };
86
109
  const sandbox = {
87
- window: windowObj, document: documentObj, location, console, clearTimeout() {}, queueMicrotask,
110
+ window: windowObj, document: documentObj, location, console, clearTimeout() {}, queueMicrotask, URL, URLSearchParams,
88
111
  setTimeout: windowObj.setTimeout,
89
112
  Event: class { constructor(t) { this.type = t; } },
90
113
  CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } },
@@ -97,6 +120,10 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
97
120
  store,
98
121
  button: () => getElementById('fullscreen-toggle'),
99
122
  on: () => root.dataset.fullscreen === 'on',
123
+ plain: () => root.dataset.fullscreenPlain === 'on',
124
+ active: () => navItems.filter((a) => a.classList.contains('nav-item--active')).map((a) => a.href),
125
+ topbar: () => topbar._html,
126
+ session,
100
127
  resizes: () => resizes,
101
128
  // Press a key the way the browser dispatches it: every window listener,
102
129
  // then the page may mark the press used, then the queued timers run.
@@ -109,13 +136,71 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
109
136
  };
110
137
  }
111
138
 
112
- test('a page that does not opt in gets no switch, and never goes full screen', () => {
139
+ test('a page that opts OUT gets no switch, and never goes full screen', () => {
113
140
  const store = { 'hall-fullscreen:work': 'on' };
114
- const p = boot({ page: 'work', optIn: false, store });
141
+ const p = boot({ page: 'work', optIn: 'off', store });
115
142
  assert.equal(p.button(), null, 'no button rendered');
116
143
  assert.equal(p.on(), false, 'a stored value for a page with no switch is ignored');
117
144
  });
118
145
 
146
+ test('THE SWITCH IS STANDARD (task 1004430): a page with no attribute gets it too, marked plain', () => {
147
+ const p = boot({ page: 'goals', optIn: false });
148
+ assert.ok(p.button(), 'goals carries the switch without opting in');
149
+ p.button().click();
150
+ assert.equal(p.on(), true);
151
+ assert.equal(p.plain(), true, 'a plain page: style.css steps its content clear of the corner button');
152
+ p.button().click();
153
+ assert.equal(p.plain(), false, 'and the mark leaves with full screen');
154
+ const sky = boot({ page: 'thinking' });
155
+ sky.button().click();
156
+ assert.equal(sky.plain(), false, 'a page that lays itself out for full screen is not plain');
157
+ });
158
+
159
+ // THE BACK LINK (task 1004430). The owner's case: a profile opened from the
160
+ // Builders list shows "← Builders", and a sidebar hop shows nothing.
161
+ test('a page reached from inside another hall page shows Back to it, by its nav label', () => {
162
+ const p = boot({ page: 'profile', optIn: false, search: '?id=7', referrer: 'https://h/roster' });
163
+ assert.match(p.topbar(), /<a class="topbar-back" id="topbar-back" href="\/roster"/);
164
+ assert.match(p.topbar(), />Builders<\/span><\/a>/);
165
+ });
166
+
167
+ test('Back: ?from= wins over the referrer, and a record origin is named as the record', () => {
168
+ const p = boot({ page: 'profile', optIn: false, search: '?id=7&from=%2Ftask%2F42', referrer: 'https://h/roster' });
169
+ assert.match(p.topbar(), /href="\/task\/42"/);
170
+ assert.match(p.topbar(), />task 42<\/span>/);
171
+ });
172
+
173
+ test('Back: none after a sidebar hop, from another site, from the page itself, or where the page draws its own', () => {
174
+ const hop = boot({ page: 'goals', optIn: false, referrer: 'https://h/work', session: { 'hall-nav-hop': '/goals' } });
175
+ assert.doesNotMatch(hop.topbar(), /topbar-back/, 'the sidebar is not a drill-down');
176
+ assert.equal(hop.session['hall-nav-hop'], undefined, 'the note is one-shot');
177
+ assert.doesNotMatch(boot({ page: 'goals', optIn: false, referrer: 'https://elsewhere.example/roster' }).topbar(), /topbar-back/);
178
+ 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');
181
+ });
182
+
183
+ test('someone else\'s profile highlights Builders; your own hands the mark back to Profile', async () => {
184
+ const other = boot({ page: 'profile', optIn: false, search: '?id=7', meId: 3 });
185
+ assert.deepEqual(other.active(), ['/roster'], 'at boot, before /me answers');
186
+ await new Promise((r) => setImmediate(r));
187
+ assert.deepEqual(other.active(), ['/roster'], 'id 7 is not the viewer (3)');
188
+ const own = boot({ page: 'profile', optIn: false, search: '?id=3', meId: 3 });
189
+ await new Promise((r) => setImmediate(r));
190
+ assert.deepEqual(own.active(), ['/profile'], 'the viewer\'s own id is their Profile');
191
+ assert.deepEqual(boot({ page: 'profile', optIn: false }).active(), ['/profile'], 'no id: your own profile');
192
+ });
193
+
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\]'\)/);
197
+ });
198
+
199
+ test('Back: an origin\'s own ?from= is dropped, so one page\'s way back never chains into the next', () => {
200
+ const p = boot({ page: 'profile', optIn: false, search: '?id=7', referrer: 'https://h/roster?q=a&from=%2Fwork' });
201
+ assert.match(p.topbar(), /href="\/roster\?q=a"/);
202
+ });
203
+
119
204
  test('an opted-in page renders ONE switch in the top bar, off by default', () => {
120
205
  const p = boot();
121
206
  const btn = p.button();
@@ -130,7 +215,8 @@ test('the switch hides the chrome, the same button brings it back', () => {
130
215
  p.button().click();
131
216
  assert.equal(p.on(), true, 'html[data-fullscreen="on"]');
132
217
  assert.equal(p.button().getAttribute('aria-pressed'), 'true');
133
- assert.equal(p.button().getAttribute('aria-label'), 'Leave full screen');
218
+ assert.equal(p.button().getAttribute('aria-label'), 'Exit full screen');
219
+ assert.match(p.button().innerHTML, />Exit full screen<\/span>/, 'a labelled pill, not a bare glyph (task 1004430)');
134
220
  assert.ok(p.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
135
221
  p.button().click();
136
222
  assert.equal(p.on(), false, 'and back');
@@ -174,15 +260,14 @@ test('storage that throws (a private window) leaves the chrome showing and the s
174
260
  assert.equal(p.on(), true, 'the switch still works for this page load');
175
261
  });
176
262
 
177
- test('the studio and Your thinking opt in, inside the hall chrome; the project sky does not (yet)', () => {
178
- for (const f of ['studio.html', 'thinking.html']) {
263
+ test('the studio, Your thinking and the project sky lay themselves out for it, inside the hall chrome', () => {
264
+ for (const f of ['studio.html', 'thinking.html', 'goal-map.html']) {
179
265
  const html = read(f);
180
266
  assert.match(html, /<body[^>]*\bdata-fullscreen-switch\b/, `${f} opts in`);
181
267
  for (const id of ['app-sidebar', 'app-topbar']) assert.match(html, new RegExp(`id="${id}"`), `${f} carries #${id}`);
182
268
  assert.match(html, /<main id="hall-main"/, `${f}: the skip link's target`);
183
269
  assert.equal((html.replace(/<!--[\s\S]*?-->/g, '').match(/<header\b/g) || []).length, 1, `${f}: the top bar is the one <header>`);
184
270
  }
185
- assert.doesNotMatch(read('goal-map.html'), /data-fullscreen-switch|shell\.js/, '/goal-map keeps its standalone form');
186
271
  });
187
272
 
188
273
  // SOURCE ASSERTION (the CSS half): full screen hides the sidebar and the foot
@@ -635,7 +635,7 @@ 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-09-30-founding-commands/);
638
+ assert.match(HTML, /shell\.js\?v=2026-09-30-navback2/);
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`);
@@ -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 = '3e4f905947bc39d2';
90
+ const SHELL_SHA = 'c05e9d87594aca3c';
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);