@bongos/core 1.20.54 → 1.20.56

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 (42) hide show
  1. package/.bongos-core.json +81 -41
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +54 -3
  8. package/docs/api-reference.md +7 -1
  9. package/docs/copy-inventory.md +46 -42
  10. package/docs/copy-registry.json +92 -56
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +13 -5
  13. package/docs/page-readings.json +102 -103
  14. package/modules/government/recommendations.js +12 -0
  15. package/modules/government/routes/government.js +2 -2
  16. package/modules/hall-ui/public/founding-mark.js +67 -0
  17. package/modules/hall-ui/public/government.js +2 -2
  18. package/modules/hall-ui/public/hall-widgets.js +31 -0
  19. package/modules/hall-ui/public/index-founding.states.json +13 -0
  20. package/modules/hall-ui/public/index.html +2 -1
  21. package/modules/hall-ui/public/profile.html +2 -1
  22. package/modules/hall-ui/public/profile.js +18 -0
  23. package/modules/hall-ui/public/ranks-founding.states.json +12 -0
  24. package/modules/hall-ui/public/ranks.html +11 -1
  25. package/modules/hall-ui/public/ranks.js +25 -0
  26. package/modules/hall-ui/public/roster.html +1 -0
  27. package/modules/hall-ui/public/roster.js +18 -2
  28. package/modules/hall-ui/public/style.css +35 -0
  29. package/modules/hall-ui/records/founding-mark.md +17 -0
  30. package/modules/onboarding/founding-builders.js +127 -0
  31. package/modules/onboarding/founding-mode.js +27 -3
  32. package/modules/onboarding/routes/onboarding.js +11 -0
  33. package/modules/ui-design/kit/fixtures/founding-builders-sample.json +9 -0
  34. package/modules/ui-design/kit/fixtures/founding-builders.json +4 -0
  35. package/modules/ui-design/kit/serve.js +4 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/founding_builders.mjs +263 -0
  41. package/tests/government_recommendations.mjs +13 -0
  42. package/tests/government_routes.mjs +1 -1
@@ -34,6 +34,7 @@ const express = require('express');
34
34
  const api = require('../../../src/module-api');
35
35
  const onboardingPort = require('../port');
36
36
  const buildAccessRequestsRouter = require('./access-requests');
37
+ const foundingBuilders = require('../founding-builders');
37
38
 
38
39
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
39
40
  // consumer list and the graceful-degradation contract.
@@ -50,5 +51,15 @@ module.exports = function buildOnboardingRouter() {
50
51
  // Archon-only resolve routes). Mounted on this same /api/bongos router by the
51
52
  // loader, before the public catch-all — byte-identical paths.
52
53
  router.use(buildAccessRequestsRouter());
54
+ // The founding mark (task 1004424): who joined while the project was founding, in
55
+ // join order, with the stage each joined at. Read by the roster, the profile, the
56
+ // ranks page and the home's founding wall; an empty list on a project that was
57
+ // never founded, so those surfaces render exactly as before. Signed-in, like the
58
+ // hall pages that read it; it carries nothing the roster does not already show.
59
+ router.get('/founding/builders', api.requireBuilder, async (req, res) => {
60
+ const builders = await foundingBuilders.foundingBuildersFor();
61
+ res.set('Cache-Control', 'private, max-age=60');
62
+ res.json({ builders });
63
+ });
53
64
  return router;
54
65
  };
@@ -0,0 +1,9 @@
1
+ {
2
+ "_": "The founding mark (task 1004424): GET /founding/builders on a project that was founded. Builder 1 is the kit's own signed-in builder (me.json), so the ranks and Home renders show the viewer as founder. Only the founding variant states files point at this; the default name has no file, so every other render is a never-founded project.",
3
+ "builders": [
4
+ { "builder_id": 1, "display_name": "A Builder", "github_login": "builder", "avatar_url": null, "rank": "archon", "founder": true, "number": 1, "stage": "decide", "day": 1 },
5
+ { "builder_id": 2, "display_name": "Avery Okonkwo-Lindqvist", "github_login": "avery", "avatar_url": null, "rank": "metic", "founder": false, "number": 2, "stage": "scope", "day": 3 },
6
+ { "builder_id": 3, "display_name": "Jordan", "github_login": "jordan", "avatar_url": null, "rank": "thetes", "founder": false, "number": 3, "stage": "strategize", "day": 8 },
7
+ { "builder_id": 4, "display_name": "Sam", "github_login": "sam", "avatar_url": null, "rank": "xenos", "founder": false, "number": 4, "stage": "legislation", "day": 19 }
8
+ ]
9
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "GET /founding/builders on a project that was never founded (task 1004424): the empty list every hall surface reads as 'no marks', so each render is today's page. The founding variants point the name at founding-builders-sample.json instead.",
3
+ "builders": []
4
+ }
@@ -105,7 +105,10 @@ const FEEDS = {
105
105
  '/public/leaderboard': 'leaderboard',
106
106
  '/projects/featured': 'featured',
107
107
  };
108
- const AUTHED = { '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites' };
108
+ // /founding/builders (task 1004424) answers a never-founded project's empty list by default,
109
+ // so every existing render stays today's page; a founding variant states file points it at
110
+ // founding-builders-sample via stub.fixtures.
111
+ const AUTHED = { '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites', '/founding/builders': 'founding-builders' };
109
112
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
110
113
 
111
114
  // GET /provisioning/starter-bundles as the create wizard's module picker reads it
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.54",
3
+ "version": "1.20.56",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.54",
9
+ "version": "1.20.56",
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.54",
3
+ "version": "1.20.56",
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",
@@ -8441,5 +8441,17 @@
8441
8441
  "id": "1004400",
8442
8442
  "text": "The Government page now recommends a starting government and a starting set of ranks for your kind of project (game, research, business, non-profit) and its size. It only ever recommends a form you can adopt today. A team of a"
8443
8443
  }
8444
+ ],
8445
+ "1.20.55": [
8446
+ {
8447
+ "id": "1004478",
8448
+ "text": "The recommended ranks on the Government page now say in plain words what each one can do, for example \"Check finished work, vote on tasks, and sort incoming ideas\", instead of listing system codes. The confirmation after addin"
8449
+ }
8450
+ ],
8451
+ "1.20.56": [
8452
+ {
8453
+ "id": "1004424",
8454
+ "text": "Everyone who joins a project while it is being founded now becomes a founding builder, numbered in the order they arrived, and the owner is marked founder. The mark shows as a small seed badge on the builders list and on their"
8455
+ }
8444
8456
  ]
8445
8457
  }
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.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.56'; // 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');
@@ -0,0 +1,263 @@
1
+ // tests/founding_builders.mjs — THE FOUNDING MARK (task 1004424 / BV2.PS10, goal 1000121).
2
+ //
3
+ // Everyone who joins while a project is founding becomes a founding builder, numbered in
4
+ // join order, with the stage they joined at; the founder is "founder". It is a mark, not
5
+ // a rank (spec D1). Done-when: joiners during genesis are numbered and marked on all
6
+ // four surfaces (roster, profile, ranks, the founding wall on Home), and joiners after
7
+ // it are not. The blend rule rides along: a project that was never founded must render
8
+ // every one of those surfaces exactly as before.
9
+ //
10
+ // The server half is RUN (the pure shaper, the read over a fake pool, the write-once
11
+ // stamps setFoundingMode now keeps). The client half is RUN too: founding-mark.js and
12
+ // the real roster.js / hall-widgets.js in a vm, so the row and the wall are the bytes a
13
+ // browser would get, not a copy of them.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
26
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
27
+ const fb = require('../modules/onboarding/founding-builders.js');
28
+ const fm = require('../modules/onboarding/founding-mode.js');
29
+
30
+ const START = '2026-10-01T00:00:00Z';
31
+ const STAMPS = {
32
+ decide: '2026-10-01T00:00:00Z',
33
+ scope: '2026-10-02T00:00:00Z',
34
+ strategize: '2026-10-05T00:00:00Z',
35
+ };
36
+ const B = (id, created_at, extra = {}) => ({ id, created_at, display_name: `b${id}`, github_login: `b${id}`, rank: 'xenos', ...extra });
37
+
38
+ // ── the shaper ───────────────────────────────────────────────────────────────
39
+
40
+ test('a project that was never founded has no marks at all', () => {
41
+ assert.deepEqual(fb.shapeFoundingBuilders({ startedAt: null, founderId: 1, builders: [B(1, START)] }), []);
42
+ assert.deepEqual(fb.shapeFoundingBuilders({}), []);
43
+ });
44
+
45
+ test('joiners during genesis are numbered in join order from #2; the founder is "founder"', () => {
46
+ const marks = fb.shapeFoundingBuilders({
47
+ startedAt: START, stageAt: STAMPS, founderId: 1,
48
+ // deliberately out of order: the order is join time, not the array or the id
49
+ builders: [B(9, '2026-10-06T09:00:00Z'), B(1, '2026-09-20T00:00:00Z', { rank: 'archon' }), B(4, '2026-10-03T12:00:00Z')],
50
+ });
51
+ assert.deepEqual(marks.map((m) => [m.builder_id, m.number, m.founder]), [[1, 1, true], [4, 2, false], [9, 3, false]]);
52
+ assert.equal(marks[0].rank, 'archon', 'the rank rides along for the ranks page');
53
+ });
54
+
55
+ test('each joiner records the stage they joined at and the day of genesis', () => {
56
+ const marks = fb.shapeFoundingBuilders({
57
+ startedAt: START, stageAt: STAMPS, founderId: 1,
58
+ builders: [B(1, START), B(2, '2026-10-01T05:00:00Z'), B(3, '2026-10-03T00:00:00Z'), B(4, '2026-10-06T00:00:00Z')],
59
+ });
60
+ assert.deepEqual(marks.slice(1).map((m) => [m.stage, m.day]), [['decide', 1], ['scope', 3], ['strategize', 6]]);
61
+ });
62
+
63
+ test('joiners after genesis carry no mark; builders from before it carry none either', () => {
64
+ const marks = fb.shapeFoundingBuilders({
65
+ startedAt: START, endedAt: '2026-10-10T00:00:00Z', stageAt: STAMPS, founderId: 1,
66
+ builders: [B(1, '2026-01-01T00:00:00Z'), B(2, '2026-06-01T00:00:00Z'), B(3, '2026-10-04T00:00:00Z'), B(4, '2026-10-10T00:00:00Z'), B(5, '2026-11-01T00:00:00Z')],
67
+ });
68
+ assert.deepEqual(marks.map((m) => m.builder_id), [1, 3], 'only the founder and the in-window joiner');
69
+ assert.equal(marks[1].number, 2);
70
+ });
71
+
72
+ test('stageAt walks the stamps in genesis order, and an unstamped history is decide', () => {
73
+ assert.equal(fb.stageAt('2026-10-04T00:00:00Z', STAMPS), 'scope');
74
+ assert.equal(fb.stageAt('2026-10-04T00:00:00Z', {}), 'decide');
75
+ assert.equal(fb.stageAt('not a date', STAMPS), 'decide');
76
+ });
77
+
78
+ // ── the read ─────────────────────────────────────────────────────────────────
79
+
80
+ function settingsStore(values) {
81
+ return async (key) => (values[key] != null ? { key, value: values[key] } : null);
82
+ }
83
+
84
+ test('the read asks for the founder and the window, and shapes what comes back', async () => {
85
+ const calls = [];
86
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [B(1, START), B(2, '2026-10-02T06:00:00Z')] }; } };
87
+ const marks = await fb.foundingBuildersFor({
88
+ getSetting: settingsStore({ [fm.KEYS.STARTED_AT]: START, [fm.stageAtKey('scope')]: STAMPS.scope }),
89
+ getFoundingBuilder: async () => ({ id: 1 }),
90
+ pool,
91
+ });
92
+ assert.equal(calls.length, 1);
93
+ assert.deepEqual(calls[0].params, [1, START, null], 'founder id, window start, open end');
94
+ assert.match(calls[0].sql, /system_role IS NULL/, 'system principals are never founding builders');
95
+ assert.deepEqual(marks.map((m) => [m.builder_id, m.number, m.stage]), [[1, 1, 'decide'], [2, 2, 'scope']]);
96
+ });
97
+
98
+ test('the read never queries a project that was never founded, and never throws', async () => {
99
+ let queried = false;
100
+ const pool = { query: async () => { queried = true; return { rows: [] }; } };
101
+ assert.deepEqual(await fb.foundingBuildersFor({ getSetting: settingsStore({}), getFoundingBuilder: async () => ({ id: 1 }), pool }), []);
102
+ assert.equal(queried, false);
103
+ const broken = { query: async () => { throw new Error('db down'); } };
104
+ assert.deepEqual(await fb.foundingBuildersFor({
105
+ getSetting: settingsStore({ [fm.KEYS.STARTED_AT]: START }), getFoundingBuilder: async () => null, pool: broken,
106
+ }), []);
107
+ });
108
+
109
+ // ── the founding record setFoundingMode keeps ───────────────────────────────
110
+
111
+ test('setFoundingMode stamps each stage once, and ended_at on the first switch-off', async () => {
112
+ const store = new Map();
113
+ const deps = {
114
+ getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
115
+ setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
116
+ now: new Date('2026-10-01T00:00:00Z'),
117
+ };
118
+ await fm.setFoundingMode({ active: true }, 1, deps);
119
+ assert.equal(store.get(fm.stageAtKey('decide')), '2026-10-01T00:00:00.000Z', 'starting with no stage stamps decide');
120
+ deps.now = new Date('2026-10-03T00:00:00Z');
121
+ await fm.setFoundingMode({ stage: 'scope' }, 1, deps);
122
+ deps.now = new Date('2026-10-04T00:00:00Z');
123
+ await fm.setFoundingMode({ stage: 'scope' }, 1, deps);
124
+ assert.equal(store.get(fm.stageAtKey('scope')), '2026-10-03T00:00:00.000Z', 'a stage stamp is write-once');
125
+ await fm.setFoundingMode({ active: false }, 1, deps);
126
+ deps.now = new Date('2026-10-09T00:00:00Z');
127
+ await fm.setFoundingMode({ active: false }, 1, deps);
128
+ assert.equal(store.get(fm.KEYS.ENDED_AT), '2026-10-04T00:00:00.000Z', 'ended_at is write-once');
129
+ });
130
+
131
+ test('switching off a project that never started founding stamps no end', async () => {
132
+ const store = new Map();
133
+ await fm.setFoundingMode({ active: false }, 1, {
134
+ getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
135
+ setSetting: async (k, v) => { store.set(k, String(v)); },
136
+ });
137
+ assert.equal(store.has(fm.KEYS.ENDED_AT), false);
138
+ });
139
+
140
+ // ── the route ────────────────────────────────────────────────────────────────
141
+
142
+ test('the onboarding router serves GET /founding/builders behind a signed-in builder', () => {
143
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
144
+ assert.match(src, /router\.get\('\/founding\/builders', api\.requireBuilder,/);
145
+ });
146
+
147
+ // ── the client, RUN ──────────────────────────────────────────────────────────
148
+
149
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
150
+ const MARKS = [
151
+ { builder_id: 1, number: 1, founder: true, stage: 'decide', day: 1, display_name: 'Ada', rank: 'archon' },
152
+ { builder_id: 7, number: 2, founder: false, stage: 'scope', day: 3, display_name: 'Avery <x>', rank: 'xenos' },
153
+ ];
154
+
155
+ function sandboxWith(marks, extra = {}) {
156
+ const sandbox = {
157
+ window: { OTB: { escapeHtml, fmtInt: (n) => String(n == null ? 0 : n), rankLabel: (r) => r } },
158
+ fetch: (url) => Promise.resolve(url.includes('/founding/builders')
159
+ ? (marks ? { ok: true, json: () => Promise.resolve({ builders: marks }) } : { ok: false, json: () => Promise.resolve({}) })
160
+ : { ok: true, json: () => Promise.resolve({ leaderboard: [{ id: 1, display_name: 'Ada' }, { id: 7, display_name: 'Avery' }, { id: 8, display_name: 'Rae' }] }) }),
161
+ console,
162
+ Promise,
163
+ ...extra,
164
+ };
165
+ vm.createContext(sandbox);
166
+ vm.runInContext(read('founding-mark.js'), sandbox, { filename: 'founding-mark.js' });
167
+ return sandbox;
168
+ }
169
+
170
+ test('the chip says founder / founding · #N, escapes, and explains itself in its title', () => {
171
+ const { window } = sandboxWith(MARKS);
172
+ const F = window.OTBFounding;
173
+ assert.match(F.chipHtml(MARKS[0]), />founder<\/span>$/);
174
+ assert.match(F.chipHtml(MARKS[1]), /founding · #2/);
175
+ assert.match(F.chipHtml(MARKS[1]), /title="Founding builder #2 — Joined at scope, day 3 of genesis\. A mark for good; it grants nothing\."/);
176
+ assert.equal(F.chipHtml(null), '');
177
+ assert.equal(F.joinedLine(MARKS[0]), 'Founded the project');
178
+ });
179
+
180
+ test('a failed or signed-out founding read is an empty list, never an error', async () => {
181
+ const { window } = sandboxWith(null);
182
+ assert.equal((await window.OTBFounding.load()).length, 0);
183
+ });
184
+
185
+ async function rosterRows(marks) {
186
+ const sandbox = sandboxWith(marks);
187
+ let rowFn = null;
188
+ Object.assign(sandbox.window, {
189
+ OTBBranding: { currencyLabel: () => 'Credits' },
190
+ OTBKit: {
191
+ esc: escapeHtml, makePager: () => ({ page: 1 }), makeFilterBar: () => {}, paginate: (xs) => xs,
192
+ makeLedger: (cfg) => { rowFn = cfg.rowHtml; return { render() {} }; },
193
+ },
194
+ });
195
+ sandbox.document = { getElementById: () => ({ innerHTML: '', className: '', textContent: '', hidden: false }) };
196
+ if (!marks) delete sandbox.window.OTBFounding; // a page without the script at all
197
+ vm.runInContext(read('roster.js'), sandbox, { filename: 'roster.js' });
198
+ await new Promise((r) => setImmediate(r));
199
+ assert.ok(rowFn, 'roster.js mounted its ledger');
200
+ return rowFn;
201
+ }
202
+
203
+ test('the roster marks the founding builders and leaves everyone else exactly as before', async () => {
204
+ const withMarks = await rosterRows(MARKS);
205
+ const without = await rosterRows(null);
206
+ assert.match(withMarks({ id: 7, display_name: 'Avery' }), /class="lb-founding"><span class="founding-mark"[^>]*>.*founding · #2/);
207
+ assert.match(withMarks({ id: 1, display_name: 'Ada' }), />founder<\/span>/);
208
+ assert.equal(withMarks({ id: 8, display_name: 'Rae' }), without({ id: 8, display_name: 'Rae' }), 'an unmarked row is byte-identical');
209
+ assert.doesNotMatch(without({ id: 7, display_name: 'Avery' }), /founding/, 'no founding record, no mark');
210
+ });
211
+
212
+ function wall(marks) {
213
+ const sandbox = sandboxWith(marks);
214
+ const widgets = [];
215
+ sandbox.window.OTB.contributeHallWidget = (w) => { widgets.push(w); return w; };
216
+ sandbox.window.__BRANDING__ = {};
217
+ sandbox.window.OTBKit = { pagerBarHtml: () => '' }; // later widgets' skeletons ask the kit for a pager bar
218
+ vm.runInContext(read('hall-widgets.js'), sandbox, { filename: 'hall-widgets.js' });
219
+ const w = widgets.find((x) => x.id === 'home-founding');
220
+ const list = { innerHTML: '' };
221
+ const host = { innerHTML: '', hidden: true, querySelector: () => list };
222
+ return { w, host, list };
223
+ }
224
+
225
+ test('the founding wall registers on Home, inside the budgeted main zone, hidden by default', () => {
226
+ const { w } = wall(MARKS);
227
+ assert.ok(w, 'a home-founding widget is registered');
228
+ assert.equal(w.view, 'home');
229
+ assert.equal(w.zone, 'home-main');
230
+ assert.equal(w.hiddenByDefault, true);
231
+ });
232
+
233
+ test('the wall shows the founder first and each joiner with number and stage', async () => {
234
+ const { w, host, list } = wall(MARKS);
235
+ w.mount(host);
236
+ await new Promise((r) => setImmediate(r));
237
+ assert.equal(host.hidden, false);
238
+ const items = list.innerHTML.split('</li>').filter(Boolean);
239
+ assert.equal(items.length, 2);
240
+ assert.match(items[0], /Ada<\/a><span class="founding-wall__sub">founder/);
241
+ assert.match(items[1], /Avery &lt;x&gt;<\/a><span class="founding-wall__sub">#2 · scope/);
242
+ });
243
+
244
+ test('a project that was never founded keeps the wall hidden', async () => {
245
+ const { w, host } = wall([]);
246
+ w.mount(host);
247
+ await new Promise((r) => setImmediate(r));
248
+ assert.equal(host.hidden, true);
249
+ });
250
+
251
+ test('the profile and the ranks page read the same list, and every page loads the script first', () => {
252
+ const profile = read('profile.js');
253
+ const ranks = read('ranks.js');
254
+ assert.match(profile, /renderHead\(builder\);\s*\n\s*renderFoundingMark\(builder\);/);
255
+ assert.match(ranks, /render\(builder \|\| \{\}\);\s*\n\s*renderFounding\(\);/);
256
+ assert.match(read('ranks.html'), /id="founding-scroll"[^>]*hidden/, 'the ranks section is hidden in the markup');
257
+ for (const [html, page] of [['roster.html', 'roster.js'], ['profile.html', 'profile.js'], ['ranks.html', 'ranks.js'], ['index.html', 'hall-widgets.js']]) {
258
+ const src = read(html);
259
+ const a = src.indexOf('/builders/founding-mark.js');
260
+ const b = src.indexOf(`/builders/${page}`);
261
+ assert.ok(a > 0 && a < b, `${html} loads founding-mark.js before ${page}`);
262
+ }
263
+ });
@@ -117,3 +117,16 @@ test('recommendationsFor carries both answers and the vocabulary to ask about an
117
117
  assert.equal(r.project_types.length, TYPES.length);
118
118
  for (const b of r.size_bands) assert.equal(reco.sizeBandFor(b.example).key, b.key, 'each size option lands in its own band');
119
119
  });
120
+
121
+ test('the panel says what a recommended rank can do in words, never in permission keys (task 1004478)', () => {
122
+ for (const job of Object.keys(reco.JOB_GRANTS)) {
123
+ const can = reco.JOB_CAN[job];
124
+ assert.ok(typeof can === 'string' && can.length > 20, `job ${job} needs a plain sentence`);
125
+ assert.ok(!/[a-z]+\.[a-z_]+/.test(can), `job ${job}'s sentence must not spell a permission key`);
126
+ }
127
+ for (const r of reco.recommendedRankSet('game', 200).ranks) assert.equal(r.can, reco.JOB_CAN[r.job]);
128
+ const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
129
+ const row = client.slice(client.indexOf('function recoRankRow'), client.indexOf('function recommendationHtml'));
130
+ assert.match(row, /r\.can/, 'the rank row renders the sentence');
131
+ assert.doesNotMatch(row, /permissions/, 'the rank row does not render the raw keys');
132
+ });
@@ -254,7 +254,7 @@ test('POST /government/rank-sets/apply creates the missing ranks with their gran
254
254
  try {
255
255
  const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
256
256
  assert.equal(r.status, 200);
257
- assert.deepEqual(r.json.skipped, ['design-lead']);
257
+ assert.deepEqual(r.json.skipped, [{ rank_key: 'design-lead', label: 'Design lead' }], 'a kept rank is named by its label, not its key (task 1004478)');
258
258
  assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
259
259
  const creates = calls.filter(([k]) => k === 'createRank');
260
260
  assert.equal(creates.length, 1);