@bongos/core 1.19.474 → 1.19.476

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 (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/docs/adr/0229-the-title-is-the-door-and-the-record-exits-to-where-it-was-opened-from.md +38 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/copy-inventory.md +255 -258
  5. package/docs/copy-registry.json +376 -414
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/builders.js +13 -64
  8. package/modules/hall-ui/public/dom-utils.js +2 -1
  9. package/modules/hall-ui/public/goals-page.js +4 -3
  10. package/modules/hall-ui/public/goals-render.js +5 -3
  11. package/modules/hall-ui/public/hall-render.js +10 -142
  12. package/modules/hall-ui/public/hall-widgets.js +6 -16
  13. package/modules/hall-ui/public/index.html +0 -3
  14. package/modules/hall-ui/public/index.states.json +3 -2
  15. package/modules/hall-ui/public/profile.js +1 -1
  16. package/modules/hall-ui/public/roster.html +63 -0
  17. package/modules/hall-ui/public/roster.js +227 -0
  18. package/modules/hall-ui/public/roster.states.json +100 -0
  19. package/modules/hall-ui/public/shell.js +28 -4
  20. package/modules/hall-ui/public/style.css +20 -18
  21. package/modules/hall-ui/public/task-detail.js +16 -4
  22. package/modules/hall-ui/public/task.html +5 -3
  23. package/modules/hall-ui/public/task.js +58 -4
  24. package/modules/hall-ui/public/task.states.json +1 -0
  25. package/modules/hall-ui/public/work.js +10 -11
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/module-api.js +1 -1
  29. package/tests/hall_board_world.mjs +69 -0
  30. package/tests/hall_nav.mjs +36 -1
  31. package/tests/hall_page_gate_map.mjs +8 -0
  32. package/tests/hall_record_world.mjs +83 -4
  33. package/tests/rank_tier_single_source.mjs +8 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.474",
3
+ "version": "1.19.476",
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",
package/src/module-api.js CHANGED
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
34
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
35
  // the entry to that file. Look for a version's history there, not here.
36
36
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.474'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
+ const CORE_VERSION = '1.19.476'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
38
 
39
39
  // A namespaced logger so a module's log lines are attributable + consistent.
40
40
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -34,6 +34,7 @@ import { test } from 'node:test';
34
34
  import assert from 'node:assert/strict';
35
35
  import fs from 'node:fs';
36
36
  import path from 'node:path';
37
+ import vm from 'node:vm';
37
38
  import { fileURLToPath } from 'node:url';
38
39
 
39
40
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -151,3 +152,71 @@ test('both re-skinned pages have a states file, so the floors are reproducible',
151
152
  assert.ok(Object.keys(json.states).length >= 4, `${f} renders more than the default state`);
152
153
  }
153
154
  });
155
+
156
+ // ---- 7. the way INTO a record is the title ----------------------------------
157
+ //
158
+ // task 1003468. Since 1759/1869 the card body toggles selection (bulk claim is
159
+ // the primary card interaction) and the only door into the record was a 26px ⤢
160
+ // glyph in --ink-faint — one the owner never found. The title is now a real
161
+ // link on both surfaces that render the card, the board and the goal pane, and
162
+ // the glyph is gone. A title that becomes a control owes the 24px floor on both
163
+ // axes, and the card's line must not re-space to pay for it. The link also
164
+ // carries WHERE it was opened from, which is the record's way back (§10 of
165
+ // tests/hall_record_world.mjs).
166
+
167
+ const cardWorkJs = read('work.js');
168
+ const cardGoalsRenderJs = read('goals-render.js');
169
+ const cardGoalsPageJs = read('goals-page.js');
170
+ const cardTaskDetailJs = read('task-detail.js');
171
+
172
+ test('the task title is a link on the board and on the goal pane, and the ⤢ glyph is gone', () => {
173
+ for (const [name, src] of [['work.js', cardWorkJs], ['goals-render.js', cardGoalsRenderJs]]) {
174
+ assert.match(src, /<p class="task__title"><a class="task__link" href="\$\{escapeHtml\(taskHref\(/,
175
+ `${name} renders the title as the door`);
176
+ assert.doesNotMatch(src, /task__open/, `${name} still renders the glyph`);
177
+ }
178
+ assert.doesNotMatch(cardGoalsPageJs, /task__open/, 'goals-page.js still listens for the glyph');
179
+ assert.doesNotMatch(styleCss.replace(/\/\*[\s\S]*?\*\//g, ''), /\.task__open/, 'style.css still styles a glyph nothing renders');
180
+ });
181
+
182
+ test('both click handlers let the title link through instead of toggling selection under it', () => {
183
+ for (const [name, src] of [['work.js', cardWorkJs], ['goals-page.js', cardGoalsPageJs]]) {
184
+ assert.match(src, /if \(e\.target\.closest\('\.task__link'\)\) return;/, `${name} returns before the card-body toggle`);
185
+ }
186
+ });
187
+
188
+ test('.task__link buys the 24px floor on both axes and hands the room back', () => {
189
+ const link = rule(styleCss, '.task__link');
190
+ assert.ok(link, '.task__link has a rule');
191
+ assert.match(link, /display:\s*inline-block/, 'inline-block, so min-height applies to a run of text');
192
+ assert.match(link, /min-width:\s*24px/, 'width is half the floor');
193
+ assert.match(link, /min-height:\s*24px/, 'height is the other half');
194
+ const pad = /padding:\s*(\d+)px\s+(\d+)px/.exec(link);
195
+ const mar = /margin:\s*-(\d+)px\s+-(\d+)px/.exec(link);
196
+ assert.ok(pad && mar, 'padding and a negative margin are both declared');
197
+ assert.deepEqual([mar[1], mar[2]], [pad[1], pad[2]], 'the negative margin equals the padding, so the card does not re-space');
198
+ assert.match(link, /color:\s*inherit/, 'ink at rest — sixty accent titles would take the board over');
199
+ });
200
+
201
+ test('the link a list writes carries WHERE it was opened from — path, query and hash', () => {
202
+ const sandbox = {
203
+ window: { BongosClient: { createClient: () => ({}) }, addEventListener() {}, OTB: {} },
204
+ document: { readyState: 'complete', addEventListener() {}, getElementById() { return null; }, body: {} },
205
+ location: { hostname: 'cloudbongos.com', pathname: '/builders/goals', search: '', hash: '#/goal/1000081', href: '' },
206
+ sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
207
+ console, setTimeout, clearTimeout, Date,
208
+ };
209
+ sandbox.globalThis = sandbox;
210
+ sandbox.window.location = sandbox.location;
211
+ vm.createContext(sandbox);
212
+ vm.runInContext(cardTaskDetailJs, sandbox, { filename: 'task-detail.js' });
213
+ const api = sandbox.window.OTBTaskDetail;
214
+ assert.equal(api.href('1003468'), '/builders/task/1003468?from=%2Fbuilders%2Fgoals%23%2Fgoal%2F1000081',
215
+ 'the goal\'s #/goal/N rides the query, where a reload and a copied link keep it');
216
+ api.open('1003468');
217
+ assert.equal(sandbox.location.href, api.href('1003468'), 'open() navigates to the same link the title carries');
218
+ sandbox.location.hostname = 'builders.cloudbongos.com';
219
+ sandbox.location.pathname = '/work';
220
+ sandbox.location.hash = '#backlog';
221
+ assert.equal(api.href('7'), '/task/7?from=%2Fwork%23backlog', 'on the builders host the hall is mounted at /');
222
+ });
@@ -42,11 +42,17 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
42
42
  return el;
43
43
  };
44
44
 
45
+ // replace() is recorded, not ignored: task 1003441 retired the #leaderboard
46
+ // VIEW and route() answers that hash with location.replace('/roster'). A stub
47
+ // without it throws the moment a boot carries that hash, which is precisely
48
+ // why the redirect shipped untested the first time.
49
+ const replaced = [];
45
50
  const location = {
46
51
  hostname: 'builders.cloudbongos.com',
47
52
  origin: 'https://builders.cloudbongos.com',
48
53
  pathname, hash,
49
54
  assign() {},
55
+ replace(target) { replaced.push(String(target)); },
50
56
  };
51
57
 
52
58
  const documentObj = {
@@ -90,6 +96,7 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
90
96
  sidebarHtml: documentObj.getElementById('app-sidebar').innerHTML,
91
97
  viewHidden: (v) => documentObj.getElementById('view-' + v).hidden,
92
98
  shell: windowObj.OTBShell,
99
+ replaced,
93
100
  };
94
101
  }
95
102
 
@@ -155,7 +162,11 @@ test('sidebar groups read by audience, in order', () => {
155
162
  // First within the group because it is the one page here you ACT on; the
156
163
  // leaderboard and the directory are browsing surfaces. Updating this oracle is
157
164
  // the reviewed decision the pin exists to force.
158
- assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'leaderboard', 'people'], 'Community = everyone else');
165
+ // task 1003441: `leaderboard` → `roster`. The id changed because the THING
166
+ // changed: it was a hash view on index.html (href /#leaderboard, view:
167
+ // 'leaderboard') and is now a real page at /roster labelled Builders. That is
168
+ // what made this group read as one real page plus a link into the home page.
169
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster', 'people'], 'Community = everyone else');
159
170
  assert.deepEqual(nav[3].items.map((i) => i.id),
160
171
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
161
172
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
@@ -194,6 +205,30 @@ test('group-gate check actually CATCHES a head gated wider than its items', () =
194
205
  assert.equal(headOutOfRange({ label: 'X', headGate: 'archon', items: [{ gate: 'archon' }] }), false);
195
206
  });
196
207
 
208
+ test('#leaderboard REDIRECTS to /roster from the index, and only from the index', () => {
209
+ // task 1003441: the Roll became a page, so its hash has no host view to
210
+ // alias onto. Without the redirect viewFromHash returns nothing and route()
211
+ // leaves the reader on Home — a dead link that looks like it worked, which
212
+ // is the failure this pins. The nav item points at the page, not the hash.
213
+ const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
214
+ assert.ok(!ids.includes('leaderboard'), 'the hash-view nav entry retired with the view');
215
+ assert.ok(ids.includes('roster'), 'the page took its place');
216
+
217
+ const onIndex = boot({ page: 'home', hash: '#leaderboard', pathname: '/' });
218
+ assert.deepEqual(onIndex.replaced, ['/roster'],
219
+ 'the index answers the retired hash with a replace() to the page');
220
+
221
+ // The guard matters: the hash is not this shell’s to interpret on a page
222
+ // that has no views, and hijacking it there would break an in-page anchor
223
+ // that happens to be spelled the same.
224
+ const offIndex = boot({ page: 'settings', hash: '#leaderboard', pathname: '/settings' });
225
+ assert.deepEqual(offIndex.replaced, [], 'a non-index page redirects nothing');
226
+
227
+ // And a hash the map does not name is still left alone entirely.
228
+ const unknown = boot({ page: 'home', hash: '#nope', pathname: '/' });
229
+ assert.deepEqual(unknown.replaced, [], 'an unknown hash is not a redirect');
230
+ });
231
+
197
232
  test('Achievements has no nav entry, but #achievements still routes to Standing', () => {
198
233
  const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
199
234
  assert.ok(!ids.includes('achievements'), 'the nav entry retired with task 2175');
@@ -79,6 +79,14 @@ const ORACLE = {
79
79
 
80
80
  // Deliberately UNGATED — the landing card, the rank ladder, the reward
81
81
  // schedule and a builder's public portfolio all render for a stranger.
82
+ //
83
+ // /roster joins them (task 1003441): it is this project’s own ledger, and its
84
+ // one data source — GET /leaderboard — is deliberately unauthenticated,
85
+ // mirroring the public status surface. The page must not be gated tighter than
86
+ // the API it fronts, which is the same rule that puts /people at
87
+ // project.curate. It was reachable to a stranger as the #leaderboard view on
88
+ // the ungated index, so this preserves the audience rather than widening it.
89
+ '/builders/roster': null,
82
90
  '/builders/': null,
83
91
  '/builders/ranks': null,
84
92
  '/builders/drachmae': null,
@@ -259,7 +259,7 @@ test('the preview harness can reach both record pages', () => {
259
259
  // re-implementation of it.
260
260
  const TASK_SRC = read('task.js');
261
261
 
262
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200 } = {}) {
262
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '' } = {}) {
263
263
  const els = new Map();
264
264
  const makeEl = () => ({
265
265
  dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
@@ -273,6 +273,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
273
273
  let onReady = null;
274
274
  const documentObj = {
275
275
  title: 'Task — Cloud Bongos',
276
+ referrer,
276
277
  body: { dataset: { page: 'task' } },
277
278
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
278
279
  getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
@@ -302,17 +303,22 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
302
303
  categoryChip: () => '',
303
304
  toast() {},
304
305
  },
305
- location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, origin: 'https://builders.cloudbongos.com', assign() {} },
306
+ location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, search, origin: 'https://builders.cloudbongos.com', assign() {} },
306
307
  addEventListener() {},
307
308
  confirm: () => true,
308
309
  navigator: { clipboard: { writeText: async () => {} } },
309
310
  };
310
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
311
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams };
311
312
  sandbox.globalThis = sandbox;
312
313
  vm.createContext(sandbox);
313
314
  vm.runInContext(TASK_SRC, sandbox, { filename: 'task.js' });
314
315
  assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
315
- return { boot: onReady, html: () => documentObj.getElementById('task-body').innerHTML, sub: () => documentObj.getElementById('task-sub').textContent };
316
+ return {
317
+ boot: onReady,
318
+ html: () => documentObj.getElementById('task-body').innerHTML,
319
+ sub: () => documentObj.getElementById('task-sub').textContent,
320
+ el: (id) => documentObj.getElementById(id),
321
+ };
316
322
  }
317
323
 
318
324
  const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -410,3 +416,76 @@ test('the idea grade badge IS the archetype pill, not a private copy of it', ()
410
416
  assert.doesNotMatch(decomment(ideaCss), /\.ig-grade(?![\w-])/,
411
417
  'idea.css carried a near-verbatim copy of .rec-pill down to the token triples — a hundred lines waiting to drift');
412
418
  });
419
+
420
+ // ---- 10. the way out — the record exits to where it was opened from ----------
421
+ //
422
+ // task 1003468. task.js hardcoded "Open the work board" in three places and
423
+ // task.html's crumb a fourth, so a builder who walked goal → task had only the
424
+ // browser's own Back, and the two failure renders sent them somewhere they had
425
+ // never been. The list that opens a record now writes `?from=` on the link
426
+ // (task-detail.js's href()) and the record offers THAT as its exit — in the
427
+ // crumb before the fetch lands, and in the actions row of every render
428
+ // including the two failures. A same-origin referrer is the fallback for a link
429
+ // written without it; the board is the fallback for a reader with no history.
430
+ // Every value is honoured only as a path on this host.
431
+
432
+ const FROM_GOAL = '?from=' + encodeURIComponent('/goals#/goal/1000081');
433
+
434
+ test('opened from a goal, the record exits to that goal — crumb and actions row alike', async () => {
435
+ const r = renderTask(BASE_TASK, { search: FROM_GOAL });
436
+ await r.boot(); await settle();
437
+ assert.match(r.html(), /<a class="td-btn" href="\/goals#\/goal\/1000081">Back to goal 1000081<\/a>/);
438
+ assert.doesNotMatch(r.html(), /Open the work board/, 'the hardcoded exit is gone from the happy path');
439
+ assert.equal(r.el('task-back').href, '/goals#/goal/1000081', 'the crumb points at the goal');
440
+ assert.equal(r.el('task-back-label').textContent, 'Back to goal 1000081');
441
+ });
442
+
443
+ test('opened from the board, the exit is the board — on the tab it was opened from', async () => {
444
+ const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/work#backlog') });
445
+ await r.boot(); await settle();
446
+ assert.match(r.html(), /href="\/work#backlog">Back to the work board</);
447
+ });
448
+
449
+ test('without ?from=, a same-origin referrer is the way back', async () => {
450
+ const r = renderTask(BASE_TASK, { referrer: 'https://builders.cloudbongos.com/sessions?page=2' });
451
+ await r.boot(); await settle();
452
+ assert.match(r.html(), /href="\/sessions\?page=2">Back to sessions</);
453
+ });
454
+
455
+ test('a cross-origin referrer, a broken one, or none, falls back to the work board', async () => {
456
+ for (const referrer of ['', 'https://example.org/elsewhere', 'not a url']) {
457
+ const r = renderTask(BASE_TASK, { referrer });
458
+ await r.boot(); await settle();
459
+ assert.match(r.html(), /href="\/work">Back to the work board</, `referrer ${JSON.stringify(referrer)}`);
460
+ assert.equal(r.el('task-back').href, '/work');
461
+ }
462
+ });
463
+
464
+ test('a hostile or useless ?from= is refused, never followed', async () => {
465
+ const bad = [
466
+ '//evil.example', '/\\evil.example', 'javascript:alert(1)', 'https://evil.example/x',
467
+ '/api/gds/auth/web/callback', '/lib/bongos-client.js',
468
+ '/task/1003305', // this same record — a loop, not a way back
469
+ 'work', '/ work', '',
470
+ ];
471
+ for (const from of bad) {
472
+ const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent(from) });
473
+ await r.boot(); await settle();
474
+ assert.match(r.html(), /href="\/work">Back to the work board</, `from=${JSON.stringify(from)}`);
475
+ assert.equal(r.el('task-back').href, '/work', `the crumb refuses from=${JSON.stringify(from)} too`);
476
+ }
477
+ });
478
+
479
+ test('the two honest failures exit the same way the record does', async () => {
480
+ for (const status of [404, 500]) {
481
+ const r = renderTask(BASE_TASK, { status, search: FROM_GOAL });
482
+ await r.boot(); await settle();
483
+ assert.match(r.html(), /href="\/goals#\/goal\/1000081">Back to goal 1000081</, `status ${status}`);
484
+ assert.doesNotMatch(r.html(), /Open the work board/, `status ${status} still hardcodes the board`);
485
+ }
486
+ });
487
+
488
+ test('the crumb is an element task.js can point, with its label apart from the icon', () => {
489
+ assert.match(taskHtml, /<a class="rec-back" id="task-back" href="\/work">/, 'the static href is the fallback the page ships with');
490
+ assert.match(taskHtml, /<span id="task-back-label">Back to tasks<\/span>/, 'the label is its own node, so the icon survives a relabel');
491
+ });
@@ -127,7 +127,12 @@ test('static ladder copies carry the exact canonical ladder', () => {
127
127
  // task 1003206 carved the hall page scripts up; the rank tier maps travelled with
128
128
  // the renderers that read them, so the pin and the allowlist follow the code.
129
129
  const HALL_SCRIPTS = ['modules/hall-ui/public/builders.js', 'modules/hall-ui/public/goals-page.js',
130
- 'modules/hall-ui/public/hall-render.js', 'modules/hall-ui/public/goals-render.js'];
130
+ 'modules/hall-ui/public/hall-render.js', 'modules/hall-ui/public/goals-render.js',
131
+ // task 1003441: the Builders page ranks the roster and draws a rank badge, so
132
+ // it needs both the tier order (its `rank` sort lens) and the known-rank test
133
+ // (the badge variant class). Browser code cannot require db-kernel, so it takes
134
+ // the pinned-copy shape builders.js already takes.
135
+ 'modules/hall-ui/public/roster.js'];
131
136
  // Filtered INSIDE the test, not at module scope: extractRankMaps is declared
132
137
  // further down, so calling it up here is a temporal-dead-zone crash.
133
138
  const staticMapFiles = () => HALL_SCRIPTS.filter(
@@ -160,6 +165,8 @@ const MAP_ALLOWLIST = new Set([
160
165
  // into goals-render.js — so goals-page.js is pruned and hall-render.js is never
161
166
  // added. The allowlist fails on a stale entry, which is the check working.
162
167
  'modules/hall-ui/public/goals-render.js',
168
+ // task 1003441 — see the HALL_SCRIPTS note above: pinned, not unchecked.
169
+ 'modules/hall-ui/public/roster.js',
163
170
  ]);
164
171
  // Where a rank-ladder array literal is allowed to exist.
165
172
  const LADDER_ALLOWLIST = new Set([