@bongos/core 1.24.25 → 1.24.26

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 (62) hide show
  1. package/.bongos-core.json +81 -66
  2. package/docs/copy-inventory.md +217 -212
  3. package/docs/copy-registry.json +298 -233
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-readings.json +855 -852
  6. package/modules/hall-ui/public/ai-studio.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/blockers.js +3 -1
  9. package/modules/hall-ui/public/board-room.html +2 -0
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/collab.js +4 -0
  12. package/modules/hall-ui/public/commands.html +2 -2
  13. package/modules/hall-ui/public/copy-desk.html +2 -2
  14. package/modules/hall-ui/public/criterion-uat-ui.js +1 -1
  15. package/modules/hall-ui/public/deploy.html +2 -0
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +2 -0
  18. package/modules/hall-ui/public/gate.html +2 -0
  19. package/modules/hall-ui/public/goals-page.js +1 -1
  20. package/modules/hall-ui/public/goals.css +3 -2
  21. package/modules/hall-ui/public/goals.html +2 -2
  22. package/modules/hall-ui/public/goals.js +1 -1
  23. package/modules/hall-ui/public/government.html +2 -0
  24. package/modules/hall-ui/public/hall-widgets.js +9 -9
  25. package/modules/hall-ui/public/ideas.html +2 -2
  26. package/modules/hall-ui/public/index.html +2 -0
  27. package/modules/hall-ui/public/lightbulb.js +1 -1
  28. package/modules/hall-ui/public/market-watch-item.html +1 -1
  29. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  30. package/modules/hall-ui/public/market-watch-settings.html +2 -0
  31. package/modules/hall-ui/public/market-watch.html +1 -1
  32. package/modules/hall-ui/public/modules.html +1 -1
  33. package/modules/hall-ui/public/my-agents.html +2 -2
  34. package/modules/hall-ui/public/primer.html +1 -1
  35. package/modules/hall-ui/public/profile.html +5 -5
  36. package/modules/hall-ui/public/project-settings.html +2 -0
  37. package/modules/hall-ui/public/ranks.html +3 -3
  38. package/modules/hall-ui/public/roadmap.html +1 -1
  39. package/modules/hall-ui/public/roster.html +1 -1
  40. package/modules/hall-ui/public/sessions.html +2 -0
  41. package/modules/hall-ui/public/settings-software-update.js +1 -1
  42. package/modules/hall-ui/public/settings-specialities.js +1 -1
  43. package/modules/hall-ui/public/settings.html +3 -1
  44. package/modules/hall-ui/public/settings.js +3 -0
  45. package/modules/hall-ui/public/studio.js +3 -3
  46. package/modules/hall-ui/public/style.css +90 -1
  47. package/modules/hall-ui/public/task-detail.js +2 -2
  48. package/modules/hall-ui/public/tweak-editor.html +1 -1
  49. package/modules/hall-ui/public/watch.html +2 -0
  50. package/modules/hall-ui/public/watch.js +1 -1
  51. package/modules/hall-ui/public/work.html +3 -3
  52. package/modules/hall-ui/records/loader.md +35 -0
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +10 -0
  56. package/scripts/hall-preview/README.md +1 -0
  57. package/scripts/hall-preview/server.js +9 -0
  58. package/src/bongos/apex-hall-redirects.js +53 -0
  59. package/src/bongos/serve-internal.js +3 -18
  60. package/src/module-api.js +1 -1
  61. package/tests/hall_loading.mjs +153 -0
  62. package/tests/software_update.mjs +33 -0
@@ -1186,6 +1186,9 @@
1186
1186
  function hideLayout(hide) {
1187
1187
  const layout = document.getElementById('settings-layout');
1188
1188
  if (layout) layout.hidden = !!hide;
1189
+ // Every path past /me decides the layout here, so the page loader stops here too (task 1005043).
1190
+ const loading = document.getElementById('settings-loading');
1191
+ if (loading) loading.hidden = true;
1189
1192
  }
1190
1193
 
1191
1194
  // ---- bootstrap -----------------------------------------------------------
@@ -755,7 +755,7 @@
755
755
 
756
756
  function statusHtml() {
757
757
  if (!home.pages) {
758
- return `<p class="st-panel__line">${home.pagesFailed ? 'The pages could not be read just now. Try again in a moment.' : 'Reading the pages…'}</p>`;
758
+ return home.pagesFailed ? '<p class="st-panel__line">The pages could not be read just now. Try again in a moment.</p>' : '<p class="hall-loading" role="status">Reading the pages…</p>';
759
759
  }
760
760
  const rows = lib.surfaceRows(home.pages, lib.landingHost(byId('hall-main') && byId('hall-main').getAttribute('data-apex'), window.location && window.location.host)).map((s) => `
761
761
  <li class="st-surface">
@@ -804,7 +804,7 @@
804
804
 
805
805
  function siteHtml() {
806
806
  if (!home.site) {
807
- return `<p class="st-panel__line">${home.siteFailed ? SITE_FAILED : 'Reading the reviews…'}</p>`;
807
+ return home.siteFailed ? `<p class="st-panel__line">${SITE_FAILED}</p>` : '<p class="hall-loading" role="status">Reading the reviews…</p>';
808
808
  }
809
809
  if (!home.site.length) return `<p class="st-panel__line">${SITE_NONE}</p>`;
810
810
  const rows = home.site.map((r) => `
@@ -1178,7 +1178,7 @@
1178
1178
  }
1179
1179
 
1180
1180
  function siteListPage() {
1181
- if (!home.site) return `<p class="st-panel__line">${home.siteFailed ? SITE_FAILED : 'Reading the reviews…'}</p>`;
1181
+ if (!home.site) return home.siteFailed ? `<p class="st-panel__line">${SITE_FAILED}</p>` : '<p class="hall-loading" role="status">Reading the reviews…</p>';
1182
1182
  if (!home.site.length) return `<p class="st-panel__line">${SITE_NONE}</p>`;
1183
1183
  return `<ul class="st-doc-list">${home.site.map((r) => `<li><a class="st-doc-list__row" href="${escapeHtml(siteHref(r.link))}">
1184
1184
  <span class="st-doc-list__page">${escapeHtml(r.page_title || r.page_path || r.page_id)}</span>
@@ -4317,7 +4317,8 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
4317
4317
  .empty-state__action { display: inline-block; margin-top: 6px; font-size: 13px; }
4318
4318
 
4319
4319
  /* skeleton shimmer — standard-height placeholder rows while data loads, so
4320
- cards don't jump; derives from the ink/bg tokens like everything else */
4320
+ cards don't jump; derives from the ink/bg tokens like everything else. It
4321
+ breathes in the loader's beat (task 1005043; the keyframes sit with it). */
4321
4322
  .kit-skel {
4322
4323
  display: inline-block;
4323
4324
  width: 60%;
@@ -4325,9 +4326,97 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
4325
4326
  height: 12px;
4326
4327
  border-radius: 2px;
4327
4328
  background: var(--skeleton);
4329
+ animation: hall-loading-breathe .8s ease-in-out infinite alternate-reverse;
4328
4330
  }
4329
4331
  .kit-skel-row td { border-bottom: 1px solid var(--rule-soft); }
4330
4332
 
4333
+ /* ---- the loader (task 1005043) ----------------------------------------------
4334
+ One loader for everything a hall page waits on, drawn from the Bongos mark
4335
+ (shell.js's brand-mark SVG: the ring, the core and the accent dot). A page
4336
+ opts in by giving its existing placeholder the class; its own words stay as
4337
+ the caption, so no page gains markup. The ring and the dot turn together round
4338
+ the still core and the ink breathes, after the Vibe skeleton the owner pointed
4339
+ at (vibe.monday.com: 0.4 to 1 and back, 0.8s). Three sizes: the default for a
4340
+ panel, --page for a page's main area, --inline for a line in a list or card.
4341
+
4342
+ DESIGN.md holds it: CSS only, transform and filter only (the breath and the
4343
+ arrival are filter: opacity(), not opacity), every colour a var() or a mix of
4344
+ one; the black in the two masks is alpha, not paint. The 20s floor is for the
4345
+ ambient plates: a loader is the live thing on the page, so it keeps a loader's
4346
+ tempo and the dot spends the accent. Each turn eases out of and back into the
4347
+ logo's own pose, so the mark lands on the logo once per beat. It arrives after
4348
+ 240ms, so a read that answers faster never shows it. Under the Kill Switch
4349
+ nothing moves and the rest frame is the logo itself. ---- */
4350
+ .hall-loading {
4351
+ --hl-size: 40px;
4352
+ --hl-pad: 28px;
4353
+ --hl-beat: 1.6s;
4354
+ position: relative;
4355
+ display: flex;
4356
+ flex-direction: column;
4357
+ align-items: center;
4358
+ gap: 12px;
4359
+ margin: 0;
4360
+ padding: var(--hl-pad) 0;
4361
+ font-size: 13.5px;
4362
+ color: var(--ink-faint);
4363
+ text-align: center;
4364
+ animation: hall-loading-arrive 500ms cubic-bezier(.16, 1, .3, 1) 240ms both;
4365
+ }
4366
+ .hall-loading::before,
4367
+ .hall-loading::after {
4368
+ content: "";
4369
+ display: block;
4370
+ flex: none;
4371
+ width: var(--hl-size);
4372
+ height: var(--hl-size);
4373
+ -webkit-mask: var(--hl-shape) center / contain no-repeat;
4374
+ mask: var(--hl-shape) center / contain no-repeat;
4375
+ animation: hall-loading-turn var(--hl-beat) cubic-bezier(.45, .05, .55, .95) infinite;
4376
+ }
4377
+ /* the ring and the core: one ink, breathing */
4378
+ .hall-loading::before {
4379
+ --hl-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cpath d='M137 31 A78 78 0 1 0 170 66' fill='none' stroke='black' stroke-width='15' stroke-linecap='round'/%3E%3Ccircle cx='100' cy='100' r='55'/%3E%3C/svg%3E");
4380
+ background: color-mix(in srgb, var(--ink) 30%, var(--bg-card));
4381
+ animation:
4382
+ hall-loading-turn var(--hl-beat) cubic-bezier(.45, .05, .55, .95) infinite,
4383
+ hall-loading-breathe calc(var(--hl-beat) / 2) ease-in-out infinite alternate-reverse;
4384
+ }
4385
+ /* the dot: the accent, laid exactly over the mark and turning with it */
4386
+ .hall-loading::after {
4387
+ --hl-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Ccircle cx='156' cy='46' r='12'/%3E%3C/svg%3E");
4388
+ position: absolute;
4389
+ top: var(--hl-pad);
4390
+ left: calc(50% - var(--hl-size) / 2);
4391
+ background: var(--accent);
4392
+ }
4393
+ .hall-loading--page { --hl-size: 64px; --hl-pad: 72px; gap: 16px; }
4394
+ .hall-loading--inline {
4395
+ --hl-size: 16px;
4396
+ --hl-pad: 0px;
4397
+ display: inline-flex;
4398
+ flex-direction: row;
4399
+ gap: 8px;
4400
+ text-align: left;
4401
+ }
4402
+ .hall-loading--inline::after { top: calc(50% - var(--hl-size) / 2); left: 0; }
4403
+ /* --wait: a page whose every section stays [hidden] until it knows who is
4404
+ reading (the sealed/body pairs) puts one loader FIRST in its <main>. It shows
4405
+ only while nothing after it is showing, and steps aside the moment the page
4406
+ reveals anything (the body, the sealed notice, an error), so no page script
4407
+ has to remember to hide it. Without :has() it never shows: today's page. */
4408
+ /* --zones: the same, for a view made of always-present, empty zones that cards
4409
+ mount into (Home, Watch's panes): it shows while no showing zone after it
4410
+ holds a showing child. */
4411
+ .hall-loading--wait, .hall-loading--zones { display: none; }
4412
+ @supports selector(:has(a)) {
4413
+ .hall-loading--wait:not(:has(~ :not([hidden], script, template, style))) { display: flex; }
4414
+ .hall-loading--zones:not(:has(~ :not([hidden]) > :not([hidden], script, template, style))) { display: flex; }
4415
+ }
4416
+ @keyframes hall-loading-arrive { from { filter: opacity(0); transform: translateY(6px); } }
4417
+ @keyframes hall-loading-turn { to { transform: rotate(1turn); } }
4418
+ @keyframes hall-loading-breathe { from { filter: opacity(.45); } }
4419
+
4331
4420
  /* collapsible card body — the Work-backlog <details> idiom, generalized */
4332
4421
  .kit-collapse__summary {
4333
4422
  cursor: pointer;
@@ -413,7 +413,7 @@
413
413
  }
414
414
 
415
415
  async function renderPromote() {
416
- box.innerHTML = '<p class="deeplink-card__note">Loading versions…</p>';
416
+ box.innerHTML = '<p class="hall-loading hall-loading--inline" role="status">Loading versions…</p>';
417
417
  // Both dead ends offer the way back through the same delegated [data-go]
418
418
  // handler the forms use — a builder must never be stranded mid-panel.
419
419
  function bail(message) {
@@ -658,7 +658,7 @@
658
658
  function bodyFor(state) {
659
659
  var nodes = [];
660
660
  var label = '#' + state.id;
661
- if (state.loading) { nodes.push(h('p', 'deeplink-card__title', 'Loading ' + label + '…')); return nodes; }
661
+ if (state.loading) { nodes.push(h('p', 'hall-loading', 'Loading ' + label + '…')); return nodes; }
662
662
  if (state.needsAuth) {
663
663
  nodes.push(h('p', 'deeplink-card__title', label));
664
664
  nodes.push(h('p', 'deeplink-card__note', 'Sign in to the Builders’ Hall to view this record.'));
@@ -74,7 +74,7 @@
74
74
  <p class="tw-hint" id="tw-hint" hidden></p>
75
75
  <div class="tw-notice" id="tw-notice" role="status" hidden></div>
76
76
  <div id="tw-doc">
77
- <p class="tw-loading">Reading the page…</p>
77
+ <p class="hall-loading hall-loading--page" role="status">Reading the page…</p>
78
78
  </div>
79
79
  </article>
80
80
 
@@ -34,6 +34,8 @@
34
34
  </div>
35
35
 
36
36
  <main id="hall-main">
37
+ <!-- task 1005043: the page loader, shown until the sealed notice or a pane has something showing. -->
38
+ <p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…</p>
37
39
  <!-- Restricted notice — shown by watch.js when the viewer is below the
38
40
  floor, with the floor written in from the pack's rank label. -->
39
41
  <section class="scroll" id="sealed-scroll" aria-labelledby="sealed-h" hidden>
@@ -754,7 +754,7 @@
754
754
  if (!btn || !target.contains(btn)) return;
755
755
  const slug = btn.getAttribute('data-doc-open');
756
756
  const view = $('#engagement-doc-view');
757
- if (view) view.innerHTML = foot('Reading…');
757
+ if (view) view.innerHTML = '<p class="hall-loading" role="status">Reading…</p>';
758
758
  try {
759
759
  const res = await fetchJson(`/security/docs/${encodeURIComponent(slug)}`);
760
760
  const doc = res.doc || {};
@@ -105,7 +105,7 @@
105
105
  <p class="claim-count-line" id="claim-count" aria-live="polite"></p>
106
106
 
107
107
  <div class="task-list" id="task-list">
108
- <div class="profile__placeholder">…</div>
108
+ <div class="hall-loading" role="status">Loading…</div>
109
109
  </div>
110
110
  <div id="ready-pager-mount"></div>
111
111
  </section>
@@ -117,7 +117,7 @@
117
117
 
118
118
  <table class="ledger" id="wip-table">
119
119
  <thead><tr><th></th><th>#</th><th>Task</th><th>Builder</th><th>For</th><th></th></tr></thead>
120
- <tbody><tr><td colspan="6" class="ledger__empty">…</td></tr></tbody>
120
+ <tbody><tr><td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…</span></td></tr></tbody>
121
121
  </table>
122
122
  <div id="wip-pager-mount"></div>
123
123
  </section>
@@ -139,7 +139,7 @@
139
139
  <div class="stat-row" id="works-summary" style="margin-bottom: 14px" hidden></div>
140
140
  <p class="scroll__lede">The most recently shipped tasks.</p>
141
141
  <div class="history" id="history-list">
142
- <div class="profile__placeholder">…</div>
142
+ <div class="hall-loading" role="status">Loading…</div>
143
143
  </div>
144
144
  <div id="done-pager-mount"></div>
145
145
  </section>
@@ -0,0 +1,35 @@
1
+ # The loader — the Bongos mark wherever a hall page waits for its data (task 1005043)
2
+
3
+ Goal 1000095. Owner ask, 2026-11-03: a smooth load screen with the Bongos logo whenever the hall
4
+ waits for data, in the spirit of monday.com's Vibe data loaders (vibe.monday.com, Skeleton: grey
5
+ shapes breathing 0.4 to 1 and back over 0.8s, in the place the content will land).
6
+
7
+ - **One component, in `style.css`: `.hall-loading`.** CSS only. `::before` is the ring and the core
8
+ (a mask of shell.js's brand-mark geometry, painted `--ink` at 30%), `::after` is the dot (the same
9
+ box, painted `--accent`). Both turn together round the still core, easing out of and back into the
10
+ logo's own pose every 1.6s; the ink breathes on half that beat. The element's own text is the
11
+ caption. It arrives 240ms late (filter: opacity), so a read that answers fast never shows it.
12
+ Sizes: the default (40px, a panel), `--page` (64px, a page's main area), `--inline` (16px, a line).
13
+ - **A page opts in by putting the class on the placeholder it already had**, and gives it
14
+ `role="status"`. The words did not change; a bare `…` became `Loading…`. A placeholder a script
15
+ rewrites IN PLACE (atlas's caption, sessions' status line, the goal dependency ledes, the profile's
16
+ activity placeholder) keeps its plain words, because the class would stay on the finished sentence.
17
+ - **Pages that are blank while they wait get a page loader that puts itself away.** The sealed/body
18
+ pages (board-room, deploy, fleet, gate, government, market-watch-settings, project-settings,
19
+ sessions) open their `<main>` on `.hall-loading--wait`, which shows only while every section after
20
+ it is `[hidden]`. Home and Watch, whose zones and panes are always present, use
21
+ `.hall-loading--zones`: it shows while no showing zone has a showing child. Both are `:has()`
22
+ rules inside `@supports`, so a browser without it simply never shows them. No page script hides
23
+ them. Settings' layout is never `[hidden]`, so `hideLayout()` puts its loader away.
24
+ - **Lists that paint later carry a loader inside them**: Blockers' list (its first `renderList`
25
+ replaces it), Collab's three ask lists (its failure path now stops them and says why, where the page
26
+ used to stay blank), the table placeholder rows (the loader sits inside the `ledger__empty` cell).
27
+ - **The skeletons breathe too.** `.kit-skel` and the goal list's `.gr-skel__ring` take the loader's
28
+ breath, so the Goals list's row skeleton is the Vibe pattern exactly.
29
+ - **DESIGN.md holds.** Transform and filter only; every colour a token (the black in the masks is
30
+ alpha); the Kill Switch stops it all and leaves the logo. The 20s ambient floor is for the plates:
31
+ a loader is the live thing on the page, which is also why its dot may spend the accent.
32
+ - **Not covered:** the apex pages (account, projects, community) and the status page run their own
33
+ sheets; the city, street, goal map and thinking sky draw their own worlds and kept their own lines.
34
+ - **Look at it:** `node scripts/hall-preview/server.js --port 4630 --fixture-me --latency 5000` holds
35
+ every read 5s (task 1005043 added `--latency`). Tests: `tests/hall_loading.mjs`.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.25",
3
+ "version": "1.24.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.25",
9
+ "version": "1.24.26",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.25",
3
+ "version": "1.24.26",
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",
@@ -11071,5 +11071,15 @@
11071
11071
  "id": "1004593",
11072
11072
  "text": "A hosted project's Settings page no longer offers an unreleased version; it now offers exactly what the Upgrade page offers."
11073
11073
  }
11074
+ ],
11075
+ "1.24.26": [
11076
+ {
11077
+ "id": "1005043",
11078
+ "text": "While a page in the builders' hall is waiting for its data, the Bongos logo now turns gently where the content will appear, instead of a blank space or a line of grey text."
11079
+ },
11080
+ {
11081
+ "id": "1004736",
11082
+ "text": "A hosted project's Update button no longer leads to a missing page; it opens the Upgrade page for that project."
11083
+ }
11074
11084
  ]
11075
11085
  }
@@ -22,6 +22,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
22
22
  | `--modules-off discord,provisioning` | report those modules disabled in `window.__MODULES__` — verifies `moduleOn()` gating (the Discord links, Instances/Start-a-project widgets must vanish) |
23
23
  | `--empty` | drain every array in every fixture — the empty-state screenshot pass |
24
24
  | `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
25
+ | `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
25
26
 
26
27
  Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
27
28
  the canned writes in `server.js` and, since task 1004685, the five per-builder pref
@@ -65,6 +65,10 @@ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.tri
65
65
  // own account live. The screenshot matrix in the README wants a stable subject,
66
66
  // and it is the explicit way to preview the signed-out fallback (task 1003315).
67
67
  const FIXTURE_ME = process.argv.includes('--fixture-me');
68
+ // --latency: hold every data read this many ms before answering (task 1005043),
69
+ // so a page's loading state stays on screen long enough to be looked at. The
70
+ // fixtures answer in a millisecond, which is why no loader had ever been seen.
71
+ const LATENCY = Math.max(0, Number(arg('--latency')) || 0);
68
72
  const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
69
73
  process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
70
74
 
@@ -295,6 +299,11 @@ function fixtureFor(apiPath, query = {}) {
295
299
  // ---- app ----------------------------------------------------------------------
296
300
  const app = express();
297
301
 
302
+ // --latency (above): the live channel is left alone, it is a stream, not a read.
303
+ if (LATENCY) {
304
+ app.use((req, res, next) => (/^\/api\//.test(req.path) && !/\/live\/?$/.test(req.path) ? setTimeout(next, LATENCY) : next()));
305
+ }
306
+
298
307
  // The API stub — intercepts every /api/gds/*, /api/bongos/*, /api/bongos/v1/*
299
308
  // request BEFORE mountInternalSurfaces can reach the real routes (which would
300
309
  // touch Postgres — the harness is DB-less by design). A plain function
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/apex-hall-redirects.js — the apex hands the hall's addresses to the hall's own
4
+ // host: /builders/* (the ADR 0036 cutover) and /deploy, the deploy door every hosted
5
+ // project's Update button names (task 1004736).
6
+ //
7
+ // Moved out of serve-internal.js, which sits at the 1500-line size ratchet, and mounted from
8
+ // it at the same point in the chain. `skip(req)` is true for a module surface host and for
9
+ // the hall's own host (on a single-host instance that is every request), so both answer only
10
+ // on the apex. `hostOf(req)` is the request's host, lower-cased, without a port.
11
+
12
+ // #739 (ADR 0036 cutover): the builder surface's canonical home is the subdomain. On the
13
+ // apex, /builders and /builders/* go to builders.<apex>/<rest> so old links keep working.
14
+ // 301 (permanent) — the cutover is proven live (#741; started as a 302 in #739). The URL
15
+ // fragment (#/task/N deep-links) is preserved by the browser across the redirect; the query
16
+ // string is carried explicitly. The target apex comes from the request host, so it stays
17
+ // brand-agnostic.
18
+ function buildersRedirect({ skip, hostOf }) {
19
+ return (req, res, next) => {
20
+ if (skip(req)) return next();
21
+ if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
22
+ const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
23
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
24
+ return res.redirect(301, `https://builders.${hostOf(req)}/${rest}${qs}`);
25
+ };
26
+ }
27
+
28
+ // THE DEPLOY DOOR ON THE APEX (task 1004736). A hosted project's Update button is
29
+ // `<hub origin>/deploy?project=<slug>` (routes/core-update.js hubDeployUrl, and the
30
+ // HUB_UPGRADE_URL provisioning writes), and the hub origin is its apex, where nothing served
31
+ // /deploy: every hosted owner who pressed Update met a 404. The page is the hall's, so the
32
+ // apex hands the request over, query and all, and the hall decides (its page gate, or
33
+ // Settings where no door runs). Answered here rather than by rewriting the links, because
34
+ // those already sit in every project's web.env and core and would wait for each project's
35
+ // core to move. 302: the apex may serve its own /deploy one day.
36
+ const APEX_DEPLOY_RE = /^\/deploy(?:\.html)?\/?$/;
37
+ function deployRedirect({ skip, hostOf }) {
38
+ return (req, res, next) => {
39
+ if (skip(req)) return next();
40
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
41
+ if (!APEX_DEPLOY_RE.test(req.path)) return next();
42
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
43
+ return res.redirect(302, `https://builders.${hostOf(req)}/deploy${qs}`);
44
+ };
45
+ }
46
+
47
+ /** Mount both on `app`, in order, at the caller's point in the chain. */
48
+ function mountApexHallRedirects(app, { skip, hostOf }) {
49
+ app.use(buildersRedirect({ skip, hostOf }));
50
+ app.use(deployRedirect({ skip, hostOf }));
51
+ }
52
+
53
+ module.exports = { mountApexHallRedirects };
@@ -34,6 +34,7 @@ const fs = require('fs');
34
34
  const express = require('express');
35
35
 
36
36
  const { buildGdsRouter } = require('./routes');
37
+ const { mountApexHallRedirects } = require('./apex-hall-redirects');
37
38
  const { attachFail } = require('./middleware/error-envelope');
38
39
  const metrics = require('./metrics');
39
40
  const { logger } = require('./logger');
@@ -923,24 +924,8 @@ function mountInternalSurfaces(app) {
923
924
  next();
924
925
  });
925
926
 
926
- // #739 (ADR 0036 cutover): the builder surface's canonical home is now the
927
- // subdomain. On the apex/game host, redirect /builders and /builders/* to
928
- // builders.<apex>/<rest> so old links keep working while the subdomain becomes
929
- // the front door. Runs ONLY on the apex (skipped on the status + builders
930
- // hosts; /api is host-agnostic and mounted earlier, and never starts /builders).
931
- // 301 (permanent) — the cutover is proven live + verified end-to-end (#741;
932
- // started as a 302 in #739). The URL fragment (#/task/N deep-links) is preserved
933
- // by the browser across the redirect; the query string is carried explicitly.
934
- // The redirect target apex is read from the request host (strip a leading
935
- // "builders."/"status." if present) so it stays brand-agnostic.
936
- app.use((req, res, next) => {
937
- if (isModuleSurfaceHost(req) || isBuildersHost(req)) return next();
938
- if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
939
- const apex = siteOf(req).host;
940
- const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
941
- const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
942
- return res.redirect(301, `https://builders.${apex}/${rest}${qs}`);
943
- });
927
+ // The apex hands /builders/* (ADR 0036) and /deploy (task 1004736) to the hall's own host.
928
+ mountApexHallRedirects(app, { skip: (req) => isModuleSurfaceHost(req) || isBuildersHost(req), hostOf: (req) => siteOf(req).host });
944
929
 
945
930
  // The /watch monitoring page and the /gate page (task 1179) both sit behind
946
931
  // `page.view.government` — Metic and up since ADR 0157. Same server-side page
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.26'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,153 @@
1
+ // tests/hall_loading.mjs — the hall's one loader, the Bongos mark (task 1005043, goal 1000095).
2
+ //
3
+ // Owner ask, 2026-11-03: a smooth load screen with the Bongos logo anywhere the
4
+ // hall waits for data, in the spirit of monday.com's Vibe data loaders. One CSS
5
+ // component in style.css (.hall-loading) draws the mark from shell.js's
6
+ // brand-mark geometry; a page opts in by putting the class on the placeholder it
7
+ // already had. These cases read the real sheets and pages: the loader keeps
8
+ // DESIGN.md's motion and colour rules, its rest frame is the logo, and no hall
9
+ // page is left with a bare "Loading…" line or a blank wait.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
18
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
19
+ const STYLE = read('style.css');
20
+ const SHELL = read('shell.js');
21
+
22
+ // The loader's section of style.css, from its banner to the next rule family.
23
+ const START = STYLE.indexOf('/* ---- the loader (task 1005043)');
24
+ const LOADER = STYLE.slice(START, STYLE.indexOf('/* collapsible card body', START));
25
+
26
+ function keyframes(name) {
27
+ const m = STYLE.match(new RegExp(`@keyframes ${name} \\{`));
28
+ assert.ok(m, `@keyframes ${name} exists`);
29
+ let depth = 1, i = m.index + m[0].length;
30
+ for (; i < STYLE.length && depth; i++) depth += STYLE[i] === '{' ? 1 : STYLE[i] === '}' ? -1 : 0;
31
+ return STYLE.slice(m.index + m[0].length, i - 1);
32
+ }
33
+
34
+ test('the loader is drawn from the Bongos mark, in three sizes', () => {
35
+ assert.ok(START > 0 && LOADER.length > 500, 'the loader section is found in style.css');
36
+ for (const sel of ['.hall-loading {', '.hall-loading::before {', '.hall-loading::after {', '.hall-loading--page {', '.hall-loading--inline {']) {
37
+ assert.ok(LOADER.includes(sel), `${sel} is declared`);
38
+ }
39
+ // The same ring arc, core and dot the shell's brand mark draws, so the loader IS the logo.
40
+ const ring = SHELL.match(/<path class="mr" d="([^"]+)"/)[1];
41
+ assert.ok(LOADER.includes(`d='${ring}'`), 'the ring is the brand mark\'s arc');
42
+ assert.ok(SHELL.includes('<circle class="mp" cx="100" cy="100" r="55"'), 'the brand mark\'s core');
43
+ assert.ok(LOADER.includes("circle cx='100' cy='100' r='55'"), 'is the loader\'s core');
44
+ assert.ok(SHELL.includes('<circle cx="156" cy="46" r="12" fill="#fb9569"/>'), 'the brand mark\'s dot');
45
+ assert.ok(LOADER.includes("circle cx='156' cy='46' r='12'"), 'is the loader\'s dot');
46
+ });
47
+
48
+ test('it moves in CSS only, on transform and filter only, and rests on the logo', () => {
49
+ const frames = { 'hall-loading-arrive': keyframes('hall-loading-arrive'), 'hall-loading-turn': keyframes('hall-loading-turn'), 'hall-loading-breathe': keyframes('hall-loading-breathe') };
50
+ for (const [name, body] of Object.entries(frames)) {
51
+ const props = [...body.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
52
+ assert.ok(props.length, `${name} animates something`);
53
+ for (const prop of props) assert.ok(['transform', 'filter'].includes(prop), `${name} animates ${prop}, not transform or filter`);
54
+ }
55
+ // The rest frame: the turn has no `from`, so 0deg (the logo's own pose, dot top right)
56
+ // is where it starts and where the Kill Switch leaves it; the arrival and the breath
57
+ // have no `to`, so their resting value is the element as styled, fully shown.
58
+ assert.match(frames['hall-loading-turn'], /^\s*to \{ transform: rotate\(1turn\); \}\s*$/);
59
+ assert.match(frames['hall-loading-arrive'], /^\s*from \{/);
60
+ assert.match(frames['hall-loading-breathe'], /^\s*from \{/);
61
+ // The Kill Switch stops every animation, pseudo-elements included.
62
+ assert.match(STYLE, /@media \(prefers-reduced-motion: reduce\) \{\s*\*, \*::before, \*::after \{\s*animation: none !important;/);
63
+ // No motion authored in JavaScript: nothing in the hall drives the loader from script.
64
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.js'))) {
65
+ assert.doesNotMatch(read(f), /hall-loading[^'"\n]*['"]\)?\.(animate|style)\b/, `${f} does not animate the loader`);
66
+ }
67
+ });
68
+
69
+ test('every colour is a token; the black in the masks is alpha, not paint', () => {
70
+ const css = LOADER.replace(/\/\*[\s\S]*?\*\//g, '');
71
+ assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'no colour literal');
72
+ const paints = [...css.matchAll(/(?:background|color):\s*([^;]+);/g)].map((m) => m[1].trim());
73
+ assert.ok(paints.length >= 3, 'the ink, the dot and the caption are painted');
74
+ for (const p of paints) assert.match(p, /^(var\(--[a-z-]+\)|color-mix\(in srgb, var\(--ink\) \d+%, var\(--bg-card\)\))$/, `${p} is a token or a mix of one`);
75
+ // The one accent is the dot: the live thing on a waiting page (the One Warm Thing Rule).
76
+ assert.equal((css.match(/var\(--accent\)/g) || []).length, 1, 'the accent paints the dot alone');
77
+ const masks = [...css.matchAll(/url\("data:image\/svg\+xml,([^"]+)"\)/g)].map((m) => decodeURIComponent(m[1]));
78
+ assert.equal(masks.length, 2, 'two masks: the ring and core, and the dot');
79
+ for (const m of masks) assert.doesNotMatch(m.replace(/stroke='black'|fill='none'/g, ''), /fill='|stroke='|#/, 'a mask carries no colour of its own');
80
+ });
81
+
82
+ test('the page loaders step aside by themselves, and never show without :has()', () => {
83
+ assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
84
+ assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
85
+ // A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
86
+ for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'market-watch-settings', 'project-settings', 'sessions']) {
87
+ const html = read(`${page}.html`);
88
+ const main = html.slice(html.indexOf('<main id="hall-main">'));
89
+ const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
90
+ assert.match(first, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, `${page} opens its main on the page loader`);
91
+ const after = first.slice(first.indexOf('</p>') + 4);
92
+ const kids = [...after.matchAll(/^ {8}<(section|div|p|nav)\b([^>]*)>/gm)];
93
+ assert.ok(kids.length >= 2, `${page} has sections after the loader`);
94
+ for (const [, tag, attrs] of kids) assert.match(attrs, /\bhidden\b/, `${page}: a <${tag}> after the loader starts hidden, or the loader would never show`);
95
+ }
96
+ assert.match(read('index.html'), /<section class="view" id="view-home"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Home waits on its zones');
97
+ assert.match(read('watch.html'), /<main id="hall-main">\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Watch waits on its panes');
98
+ // Settings' layout is never [hidden] while it loads, so its loader is put away by hideLayout().
99
+ assert.match(read('settings.html'), /id="settings-groups"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page" id="settings-loading" role="status">/);
100
+ assert.match(read('settings.js'), /function hideLayout\(hide\) \{[\s\S]*?getElementById\('settings-loading'\)[\s\S]*?loading\.hidden = true;/);
101
+ });
102
+
103
+ test('no hall page waits on a bare line of text any more', () => {
104
+ // A placeholder an element's own script rewrites IN PLACE keeps its plain words:
105
+ // the class would stay behind on the finished sentence. Each is named with why.
106
+ const IN_PLACE = new Set([
107
+ 'atlas.html#caption', // the map's caption, rewritten by atlas.js
108
+ 'sessions.html#ledger-status', // the ledger's status line, rewritten by sessions.js
109
+ 'goals.html#goal-deps-card-lede', // the dependency card's lede, rewritten by goals-page.js
110
+ 'goals.html#goal-deps-lede',
111
+ 'profile.html#activity-placeholder', // reused for the signed-out notice by profile.js
112
+ ]);
113
+ const WAIT = /<(p|div|li|span|td)\b([^>]*)>\s*(?:Loading|Reading|Opening|Checking)\b[^<]*…\s*<\/\1>|<(p|div|li|span|td)\b([^>]*)>…<\/\4>/g;
114
+ const misses = [];
115
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.html'))) {
116
+ for (const m of read(f).matchAll(WAIT)) {
117
+ const attrs = m[2] || m[5] || '';
118
+ if (/page-head__sub/.test(attrs)) continue; // the page head is hidden on every page but Home (task 1004836)
119
+ const id = (attrs.match(/\bid="([^"]+)"/) || [])[1];
120
+ if (id && IN_PLACE.has(`${f}#${id}`)) continue;
121
+ if (!/\bhall-loading\b/.test(attrs)) misses.push(`${f}: ${m[0].slice(0, 90)}`);
122
+ }
123
+ }
124
+ assert.deepEqual(misses, [], 'every waiting placeholder wears the loader');
125
+ // The table placeholder rows keep their cell, with the loader inside it.
126
+ assert.match(read('work.html'), /<td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
127
+ assert.match(read('hall-widgets.js'), /<td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
128
+ });
129
+
130
+ test('the placeholders the page scripts write wear it too, and a failure stops it', () => {
131
+ const WRITTEN = {
132
+ 'blockers.js': '<li class="hall-loading hall-loading--page" role="status">Loading…</li>',
133
+ 'goals.js': '<div class="hall-loading" role="status">Loading your goals…</div>',
134
+ 'lightbulb.js': '<p class="hall-loading" role="status">Loading…</p>',
135
+ 'task-detail.js': '<p class="hall-loading hall-loading--inline" role="status">Loading versions…</p>',
136
+ 'criterion-uat-ui.js': '<p class="hall-loading hall-loading--inline" role="status">Loading…</p>',
137
+ 'watch.js': '<p class="hall-loading" role="status">Reading…</p>',
138
+ 'studio.js': '<p class="hall-loading" role="status">Reading the reviews…</p>',
139
+ 'settings-specialities.js': '<p class="hall-loading hall-loading--inline" role="status">Loading this project’s skills…</p>',
140
+ 'settings-software-update.js': '<p class="hall-loading hall-loading--inline" role="status">Checking for a newer version…</p>',
141
+ 'goals-page.js': '<div class="hall-loading" role="status">Loading…</div>',
142
+ };
143
+ for (const [f, markup] of Object.entries(WRITTEN)) assert.ok(read(f).includes(markup), `${f} writes the loader`);
144
+ // Blockers' first paint replaces the list wholesale, so its loader cannot outlive the rows.
145
+ assert.match(read('blockers.js'), /listEl\.innerHTML = rows\.length/);
146
+ // Collab used to be blank on a failed read; with loaders in its lists it must stop them.
147
+ const collab = read('collab.js');
148
+ const failure = collab.slice(collab.lastIndexOf('load().catch('));
149
+ assert.match(failure, /\['for-me', 'mine', 'all'\]\.forEach\(\(k\) => \{ const host = \$\(`#cb-\$\{k\}`\); if \(host\) host\.innerHTML = ''; \}\);/);
150
+ // The skeleton bars and the goal list's rings breathe in the loader's beat.
151
+ assert.match(STYLE, /\.kit-skel \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
152
+ assert.match(read('goals.css'), /\.gr-skel__ring \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
153
+ });