@bongos/core 1.21.66 → 1.21.68

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 (49) hide show
  1. package/.bongos-core.json +111 -41
  2. package/docs/copy-inventory.md +414 -386
  3. package/docs/copy-registry.json +815 -537
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +45 -12
  7. package/docs/page-readings.json +959 -907
  8. package/modules/platform-identity/routes/my-projects.js +1 -1
  9. package/modules/public-landing/public/account.html +1 -1
  10. package/modules/public-landing/public/assets/cosmos.css +12 -10
  11. package/modules/public-landing/public/contact.html +1 -1
  12. package/modules/public-landing/public/guild.html +1 -1
  13. package/modules/public-landing/public/index-cli.states.json +20 -0
  14. package/modules/public-landing/public/index.html +68 -1
  15. package/modules/public-landing/public/index.states.json +4 -4
  16. package/modules/public-landing/public/invitations-empty.states.json +56 -0
  17. package/modules/public-landing/public/invitations-picker.states.json +84 -0
  18. package/modules/public-landing/public/invitations.html +965 -0
  19. package/modules/public-landing/public/invitations.probes.json +430 -0
  20. package/modules/public-landing/public/invitations.states.json +473 -0
  21. package/modules/public-landing/public/privacy.html +1 -1
  22. package/modules/public-landing/public/projects.html +18 -543
  23. package/modules/public-landing/public/projects.probes.json +0 -29
  24. package/modules/public-landing/public/projects.states.json +0 -6
  25. package/modules/public-landing/public/terms.html +1 -1
  26. package/modules/ui-design/kit/fixtures/bongos-cli-latest-sample.json +10 -0
  27. package/modules/ui-design/kit/fixtures/featured-invitations.json +183 -0
  28. package/modules/ui-design/kit/fixtures/guilds-empty.json +1 -0
  29. package/modules/ui-design/kit/fixtures/me-guild-engagements-empty.json +1 -0
  30. package/modules/ui-design/kit/fixtures/me-guild-requests-empty.json +1 -0
  31. package/modules/ui-design/kit/fixtures/my-projects-applied.json +29 -0
  32. package/modules/ui-design/kit/fixtures/my-projects-invites-empty.json +1 -0
  33. package/modules/ui-design/kit/serve.js +5 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +16 -0
  37. package/src/module-api.js +1 -1
  38. package/src/platform-server.js +11 -0
  39. package/tests/hub_account_page.mjs +1 -1
  40. package/tests/hub_engagement_cards.mjs +44 -40
  41. package/tests/hub_guild_cards.mjs +24 -22
  42. package/tests/hub_invitations_page.mjs +429 -0
  43. package/tests/join_relay_outcome.mjs +1 -1
  44. package/tests/landing_legal_pages.mjs +8 -6
  45. package/tests/landing_page.mjs +125 -3
  46. package/tests/my_projects_surface.mjs +14 -6
  47. package/tests/public_landing_projects.mjs +12 -14
  48. package/tests/render_check.mjs +3 -2
  49. package/tests/ui_design_kit.mjs +3 -3
@@ -1,15 +1,16 @@
1
- // tests/hub_engagement_cards.mjs — the guild cards in /projects?view=mine's
2
- // Invitations, EXECUTED (task 1002303, R27; ADR 0365 D2/D3).
1
+ // tests/hub_engagement_cards.mjs — the guild cards on the hub's /invitations page,
2
+ // EXECUTED (task 1002303, R27; ADR 0365 D2/D3). They lived under /projects?view=mine
3
+ // until task 1004276 gave the invitations a page of their own.
3
4
  //
4
5
  // GET /me/guild-engagements names what the guilds a builder is in have going with
5
6
  // a project: "G is applying to P" (the guild's owner put it forward) and "P
6
7
  // invited G". Each card's Apply is the hub's ONE join relay (postJoinRequest,
7
- // tests/card_apply_ui.mjs pins it for the map's card) with `guild` added — the
8
+ // tests/card_apply_ui.mjs pins /projects' copy for the map's card) with `guild` added — the
8
9
  // builder applies as themselves, never as the guild. What these pin:
9
10
  // • the two kinds draw as two sentences, with the project's host and a plain
10
11
  // hint; a row the page cannot act on is dropped, never drawn with a dead button
11
- // • the Invitations section shows while EITHER list has a row, and hides when
12
- // neither does (the projects' own invites and the guild cards are one section)
12
+ // • the page's one empty line shows only once the reads have answered and NO list
13
+ // has a row (the projects' own invites and the guild cards are one section)
13
14
  // • Apply sends exactly { origin, guild } — plus the builder's own note when they
14
15
  // open "Add a note" and write one — on the cookie session, to /my-projects/join
15
16
  // • `requested` is the only answer that reads as applied; the relay's other
@@ -19,7 +20,7 @@
19
20
  // • an expired session and a dead network are said as such
20
21
  // • textContent / createElement only — no markup built from strings
21
22
  //
22
- // The idiom is tests/project_invite_ui.mjs: lift the region out of projects.html,
23
+ // The idiom is tests/project_invite_ui.mjs: lift the region out of invitations.html,
23
24
  // run it in a node:vm under a DOM stub, drive real clicks, and assert the slice
24
25
  // boundaries, so a rename can never leave this file executing an empty string.
25
26
  //
@@ -34,7 +35,7 @@ import { fileURLToPath } from 'node:url';
34
35
 
35
36
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
37
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
37
- const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
38
+ const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
38
39
  const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
39
40
 
40
41
  // the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
@@ -79,12 +80,12 @@ function el(tag) {
79
80
 
80
81
  const TIDEPOOL = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin: 'https://tidepool.example.com' }, created_at: '2026-10-03T12:00:00.000Z' };
81
82
  const LANTERN = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'invited', project: { name: 'Lantern Works', origin: 'https://lantern.example.com' }, created_at: '2026-10-02T09:30:00.000Z' };
82
- const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested access. The project’s owner admits new builders.' } };
83
+ const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Applied. The project’s owner admits new builders.' } };
83
84
 
84
- function boot({ answers = [] } = {}) {
85
+ function boot({ answers = [], read = true } = {}) {
85
86
  const ids = new Map();
86
- for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
87
- ids.get('mineInvites').hidden = true;
87
+ for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
88
+ ids.get('invitesNone').hidden = true;
88
89
  const requests = [];
89
90
  const queued = answers.slice();
90
91
  const fetch = (url, init = {}) => {
@@ -98,21 +99,22 @@ function boot({ answers = [] } = {}) {
98
99
  document, fetch, console, Promise, JSON, String, Object, Array, URL,
99
100
  $: (id) => ids.get(id) || null,
100
101
  API: '/api/bongos',
101
- appliedOrigins: {}, mineOrigins: {},
102
102
  hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
103
103
  safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
104
104
  jsonOrEmpty: (res) => res.json().catch(() => ({})),
105
105
  apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '' }; },
106
106
  };
107
107
  vm.createContext(sandbox);
108
- vm.runInContext(RELAY, sandbox, { filename: 'projects.html#join-relay' });
109
- vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
108
+ vm.runInContext(RELAY, sandbox, { filename: 'invitations.html#join-relay' });
109
+ vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
110
+ // the renders below run as if loadInvites had answered; its own test reads the flag flip
111
+ if (read) sandbox.invitesRead = true;
110
112
  const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
111
113
  const cards = () => ids.get('engageList').children;
112
114
  const part = (card, cls) => card.querySelector('.' + cls);
113
115
  const button = (card, cls) => part(card, 'mineRow__actions').children.find((b) => String(b.className).split(' ').includes(cls));
114
116
  const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
115
- return { sandbox, ids, requests, settle, cards, part, button, click, section: () => ids.get('mineInvites') };
117
+ return { sandbox, ids, requests, settle, cards, part, button, click, none: () => ids.get('invitesNone') };
116
118
  }
117
119
 
118
120
  // ── drawing the cards ────────────────────────────────────────────────────────
@@ -127,12 +129,12 @@ test('the two kinds draw as two plain sentences, with the project’s host, a hi
127
129
  assert.equal(ui.part(a, 'mineRow__host').textContent, 'tidepool.example.com');
128
130
  assert.equal(ui.part(a, 'mineRow__hint').textContent, 'You apply as yourself — add a note for the owner if you like.');
129
131
  assert.equal(a.className, 'mineRow engageRow');
130
- assert.equal(ui.button(a, 'btn').textContent, 'Apply');
132
+ assert.equal(ui.button(a, 'btn').textContent, 'Apply to join', 'the join field’s word for the same relay, on the same page');
131
133
  assert.equal(ui.button(a, 'linklike').textContent, 'Add a note');
132
134
  assert.equal(ui.part(a, 'mineJoinNote').hidden, true, 'the note waits behind "Add a note"');
133
135
  assert.equal(ui.part(a, 'mineJoinNote').maxLength, 400, 'the join note’s own limit');
134
136
  assert.equal(ui.part(a, 'projMsg').getAttribute('role'), 'status');
135
- assert.equal(ui.section().hidden, false, 'guild cards alone show the Invitations section');
137
+ assert.equal(ui.none().hidden, true, 'guild cards alone: the page does not say nothing is waiting');
136
138
  });
137
139
 
138
140
  test('a row the page cannot act on is dropped, never drawn with a button that fails', () => {
@@ -146,49 +148,52 @@ test('a row the page cannot act on is dropped, never drawn with a button that fa
146
148
  null,
147
149
  ]);
148
150
  assert.equal(ui.cards().length, 0);
149
- assert.equal(ui.section().hidden, true, 'nothing to show: the section hides');
151
+ assert.equal(ui.none().hidden, false, 'nothing to show: the page says so in one line');
150
152
  });
151
153
 
152
- test('the section shows while EITHER list has a row, and a decline that empties both hides it', async () => {
154
+ test('the empty line stays down while EITHER list has a row, and a decline that empties both puts it up', async () => {
153
155
  const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true } }] });
154
156
  ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
155
157
  ui.sandbox.renderEngagements([]);
156
- assert.equal(ui.section().hidden, false, 'a project’s own invite alone');
158
+ assert.equal(ui.none().hidden, true, 'a project’s own invite alone');
157
159
  ui.sandbox.renderEngagements([TIDEPOOL]);
158
160
  ui.sandbox.renderInvitesList([]);
159
- assert.equal(ui.section().hidden, false, 'guild cards alone');
161
+ assert.equal(ui.none().hidden, true, 'guild cards alone');
160
162
  ui.sandbox.renderEngagements([]);
161
- assert.equal(ui.section().hidden, true, 'neither');
163
+ assert.equal(ui.none().hidden, false, 'neither');
162
164
 
163
165
  ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
164
166
  const row = ui.ids.get('invitesList').children[0];
165
167
  const decline = row.querySelector('.mineRow__actions').children.find((b) => b.textContent === 'Decline');
166
168
  await ui.click(decline);
167
169
  assert.equal(ui.ids.get('invitesList').children.length, 0);
168
- assert.equal(ui.section().hidden, true, 'the last decline hides it when there is no guild card either');
170
+ assert.equal(ui.none().hidden, false, 'the last decline puts it up when there is no guild card either');
169
171
  });
170
172
 
171
- test('loadInvites reads both lists, and a guild read that fails is silent', async () => {
172
- const ui = boot({ answers: [
173
+ test('loadInvites reads both lists, a guild read that fails is silent, and the empty line waits for every answer', async () => {
174
+ const ui = boot({ read: false, answers: [
173
175
  (url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
174
176
  (url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
175
177
  ] });
178
+ ui.sandbox.renderInvitesList([]);
179
+ assert.equal(ui.none().hidden, true, 'no "nothing waiting" line before the reads have answered');
176
180
  await ui.sandbox.loadInvites();
177
181
  await ui.settle();
178
182
  assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
179
183
  ['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
180
184
  for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
181
185
  assert.equal(ui.cards().length, 0);
182
- assert.equal(ui.section().hidden, true);
186
+ assert.equal(ui.sandbox.invitesRead, true);
187
+ assert.equal(ui.none().hidden, false, 'every read answered, nothing in any list');
183
188
 
184
- const ok = boot({ answers: [
189
+ const ok = boot({ read: false, answers: [
185
190
  (url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
186
191
  (url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
187
192
  ] });
188
193
  await ok.sandbox.loadInvites();
189
194
  await ok.settle();
190
195
  assert.equal(ok.cards().length, 1);
191
- assert.equal(ok.section().hidden, false);
196
+ assert.equal(ok.none().hidden, true);
192
197
  });
193
198
 
194
199
  // ── applying ─────────────────────────────────────────────────────────────────
@@ -219,7 +224,7 @@ test('"Add a note" opens the card’s own note, and the note rides along, trimme
219
224
  assert.deepEqual(ui.requests[0].body, { origin: 'https://tidepool.example.com', note: 'I like the boring parts too.', guild: 'forge-crew' });
220
225
  });
221
226
 
222
- test('requested is the one answer that reads as applied: the button says so and stays down, and the map learns it too', async () => {
227
+ test('requested is the one answer that reads as applied: the button says so and stays down', async () => {
223
228
  const ui = boot({ answers: [REQUESTED] });
224
229
  ui.sandbox.renderEngagements([TIDEPOOL]);
225
230
  const card = ui.cards()[0];
@@ -231,8 +236,8 @@ test('requested is the one answer that reads as applied: the button says so and
231
236
  assert.equal(ui.part(card, 'mineJoinNote').hidden, true, 'a sent note is not offered again');
232
237
  assert.equal(ui.part(card, 'mineRow__hint').hidden, true, 'nor is the hint about one');
233
238
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg ok');
234
- assert.equal(ui.part(card, 'projMsg').textContent, 'Requested access. The project’s owner admits new builders.', 'the relay’s own words');
235
- assert.equal(ui.sandbox.appliedOrigins['tidepool.example.com'], true, 'the map’s Apply reads the same echo');
239
+ assert.equal(ui.part(card, 'projMsg').textContent, 'Applied. The project’s owner admits new builders.', 'the relay’s own words');
240
+ assert.doesNotMatch(RELAY, /appliedOrigins/, 'this page has no map, so its relay keeps no Applied echo');
236
241
  });
237
242
 
238
243
  test('409 guild_engagement_gone: the guild application has ended, the plain application is offered, and it sends no guild', async () => {
@@ -241,9 +246,8 @@ test('409 guild_engagement_gone: the guild application has ended, the plain appl
241
246
  const card = ui.cards()[0];
242
247
  await ui.click(ui.button(card, 'btn'));
243
248
  assert.equal(ui.part(card, 'projMsg').textContent, 'That guild application has ended — you can still apply on your own.');
244
- assert.equal(ui.button(card, 'btn').textContent, 'Apply on my own');
249
+ assert.equal(ui.button(card, 'btn').textContent, 'Apply to join on my own');
245
250
  assert.equal(ui.button(card, 'btn').disabled, false);
246
- assert.equal(ui.sandbox.appliedOrigins['lantern.example.com'], undefined, 'nothing was relayed, so nothing reads as applied');
247
251
 
248
252
  await ui.click(ui.button(card, 'btn'));
249
253
  assert.deepEqual(ui.requests[1].body, { origin: 'https://lantern.example.com' }, 'on your own means no guild');
@@ -261,7 +265,7 @@ test('the relay’s other answers are said in its own words, and the card stays
261
265
  await ui.click(ui.button(card, 'btn'));
262
266
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg', 'the door’s answer, not an error');
263
267
  assert.match(ui.part(card, 'projMsg').textContent, /invite-only/);
264
- assert.equal(ui.button(card, 'btn').textContent, 'Apply');
268
+ assert.equal(ui.button(card, 'btn').textContent, 'Apply to join');
265
269
  assert.equal(ui.button(card, 'btn').disabled, false);
266
270
  await ui.click(ui.button(card, 'btn'));
267
271
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg err');
@@ -279,7 +283,7 @@ test('an expired session offers a way back in, built from elements; a dead netwo
279
283
  assert.equal(msg.className, 'projMsg err');
280
284
  assert.equal(msg.textContent, 'Your session expired — sign in again.');
281
285
  const link = msg.children.find((c) => c.tag === 'a');
282
- assert.equal(link.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1', 'back to this view, with the repo scope every hub sign-in carries');
286
+ assert.equal(link.href, '/api/bongos/auth/web/start?return=/invitations&repos=1', 'back to this page, with the repo scope every hub sign-in carries');
283
287
  assert.equal(ui.button(card, 'btn').disabled, false);
284
288
  await ui.click(ui.button(card, 'btn'));
285
289
  assert.equal(msg.textContent, 'Couldn’t reach the platform — check your connection and try again.');
@@ -287,20 +291,20 @@ test('an expired session offers a way back in, built from elements; a dead netwo
287
291
 
288
292
  // ── the markup, the region's rules, the kit ──────────────────────────────────
289
293
 
290
- test('the cards are a real list inside the Invitations section, built with createElement / textContent only', () => {
291
- const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
294
+ test('the cards are a real list inside the invitations section, built with createElement / textContent only', () => {
295
+ const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
292
296
  assert.match(section, /<ul class="mineRows" id="invitesList"><\/ul>/);
293
297
  assert.match(section, /<ul class="mineRows" id="engageList"><\/ul>/, 'ships as real markup the script only fills');
294
298
  assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
295
- assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
299
+ assert.match(SCRIPT, /bar\.ready\.then\(function \(me\) \{[\s\S]*?loadInvites\(\);/, 'signed in, the page reads every list through loadInvites');
296
300
  const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
297
301
  assert.match(COSMOS, /\.mineRows:not\(:empty\)~\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between any two lists that have rows');
298
302
  assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
299
303
  });
300
304
 
301
305
  test('the kit pictures the cards and the stub answers the read and the guild on the join', () => {
302
- const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
303
- assert.ok(states['in-mine-engagements'] && states['in-mine-engagement-applied'], 'two kit states: the cards, and one applied');
306
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
307
+ assert.ok(states['in-engagements'] && states['in-engagement-applied'], 'two kit states: the cards, and one applied');
304
308
  const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
305
309
  assert.match(serve, /'\/me\/guild-engagements': 'me__guild-engagements'/);
306
310
  assert.match(serve, /refuse\(res, 409, 'guild_engagement_gone'/, 'the stub refuses a guild with no engagement, as the route does');
@@ -1,6 +1,7 @@
1
1
  // tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
2
- // you own, in /projects?view=mine's Invitations, EXECUTED (task 1004583; ADR 0336
3
- // D3/D4 §3, ADR 0337 D4.4).
2
+ // you own, on the hub's /invitations page, EXECUTED (task 1004583; ADR 0336
3
+ // D3/D4 §3, ADR 0337 D4.4). Under /projects?view=mine until task 1004276 gave the
4
+ // invitations a page of their own.
4
5
  //
5
6
  // R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
6
7
  // /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
@@ -16,8 +17,8 @@
16
17
  // guild as it is now, and the next accept sends that
17
18
  // • an ended invite or request leaves nothing pressable; an expired session and a
18
19
  // dead network are said as such, and the card stays re-tryable
19
- // • Decline is a body-less POST whose 204 drops the row (the section hides with
20
- // its last row); a failure keeps the row and says so
20
+ // • Decline is a body-less POST whose 204 drops the row (the page's empty line
21
+ // comes up with the last row); a failure keeps the row and says so
21
22
  // • the owner's card names who asked, with the handle once, and accepts with no
22
23
  // body — the requester's switch rode their request
23
24
  // • ONE read feeds both lists — GET /me/guild-requests's incoming and
@@ -26,7 +27,7 @@
26
27
  // • textContent / createElement only
27
28
  //
28
29
  // The idiom is tests/hub_engagement_cards.mjs: lift the region out of
29
- // projects.html, run it in a node:vm under a DOM stub, drive real clicks, and
30
+ // invitations.html, run it in a node:vm under a DOM stub, drive real clicks, and
30
31
  // assert the slice boundaries so a rename can never leave this executing nothing.
31
32
  //
32
33
  // DB-free. Run: node --test tests/hub_guild_cards.mjs
@@ -40,7 +41,7 @@ import { fileURLToPath } from 'node:url';
40
41
 
41
42
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
43
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
43
- const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
44
+ const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
44
45
  const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
45
46
  const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
46
47
 
@@ -88,8 +89,8 @@ const GONE = { ok: false, status: 204, body: undefined };
88
89
 
89
90
  function boot({ answers = [] } = {}) {
90
91
  const ids = new Map();
91
- for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
92
- ids.get('mineInvites').hidden = true;
92
+ for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
93
+ ids.get('invitesNone').hidden = true;
93
94
  const requests = [];
94
95
  const queued = answers.slice();
95
96
  const fetch = (url, init = {}) => {
@@ -110,7 +111,8 @@ function boot({ answers = [] } = {}) {
110
111
  apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
111
112
  };
112
113
  vm.createContext(sandbox);
113
- vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
114
+ vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
115
+ sandbox.invitesRead = true; // as if loadInvites had answered: the empty line may show
114
116
  const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
115
117
  const invites = () => ids.get('guildInviteList').children;
116
118
  const asks = () => ids.get('guildRequestList').children;
@@ -125,7 +127,7 @@ function boot({ answers = [] } = {}) {
125
127
  for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
126
128
  await settle();
127
129
  };
128
- return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, section: () => ids.get('mineInvites') };
130
+ return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, none: () => ids.get('invitesNone') };
129
131
  }
130
132
 
131
133
  // ── the D4.4 notice: one sentence on two pages ───────────────────────────────
@@ -151,7 +153,7 @@ test('an invite card says what joining shows, offers the switch set to count, an
151
153
  const ui = boot();
152
154
  ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
153
155
  assert.equal(ui.requests.length, 0, 'drawing sends nothing');
154
- assert.equal(ui.section().hidden, false);
156
+ assert.equal(ui.none().hidden, true, 'something is waiting');
155
157
  const [open, quiet] = ui.invites();
156
158
  assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
157
159
  assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
@@ -253,7 +255,7 @@ test('an ended invite leaves nothing pressable; an expired session offers a way
253
255
  await out.click(out.button(card, 'Accept'));
254
256
  const a = out.msg(card).children.find((c) => c.tag === 'a');
255
257
  assert.ok(a, 'a sign-in link, built as an element');
256
- assert.equal(a.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1');
258
+ assert.equal(a.href, '/api/bongos/auth/web/start?return=/invitations&repos=1');
257
259
  assert.equal(out.button(card, 'Accept').disabled, false);
258
260
  await out.click(out.button(card, 'Accept'));
259
261
  assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
@@ -270,14 +272,14 @@ test('Decline is a body-less POST whose 204 drops the row, and the last one hide
270
272
  assert.equal(ui.requests[0].method, 'POST');
271
273
  assert.equal(ui.requests[0].body, undefined, 'nothing to send');
272
274
  assert.equal(ui.invites().length, 0);
273
- assert.equal(ui.section().hidden, false, 'the owner’s card still stands');
275
+ assert.equal(ui.none().hidden, true, 'the owner’s card still stands');
274
276
  const ask = ui.asks()[0];
275
277
  await ui.click(ui.button(ask, 'Decline'));
276
278
  assert.equal(ui.asks().length, 1, 'kept on a failure');
277
279
  assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
278
280
  assert.equal(ui.button(ask, 'Decline').disabled, false);
279
281
  ui.sandbox.renderGuildRequests([]);
280
- assert.equal(ui.section().hidden, true, 'no row in any list hides the section');
282
+ assert.equal(ui.none().hidden, false, 'no row in any list: the page says nothing is waiting');
281
283
  });
282
284
 
283
285
  // ── the owner's card ─────────────────────────────────────────────────────────
@@ -333,13 +335,13 @@ test('ONE read feeds both lists — the invites to you and the requests to every
333
335
  assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
334
336
  });
335
337
 
336
- test('a failed or empty read is silent: no cards, and the section stays hidden', async () => {
338
+ test('a failed or empty read is silent: no cards, only the page’s own empty line', async () => {
337
339
  for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
338
340
  const ui = boot({ answers: [answer] });
339
341
  await ui.sandbox.loadGuildQueue();
340
342
  await ui.settle();
341
343
  assert.equal(ui.invites().length + ui.asks().length, 0);
342
- assert.equal(ui.section().hidden, true);
344
+ assert.equal(ui.none().hidden, false);
343
345
  }
344
346
  });
345
347
 
@@ -359,13 +361,13 @@ test('a row the page cannot answer is dropped, never drawn with a button that fa
359
361
 
360
362
  // ── the markup, the region's rules, the kit ──────────────────────────────────
361
363
 
362
- test('the cards are real lists in the Invitations section, built with createElement / textContent only', () => {
363
- const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
364
+ test('the cards are real lists in the invitations section, built with createElement / textContent only', () => {
365
+ const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
364
366
  const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
365
367
  assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
366
368
  assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
367
369
  assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
368
- assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\);/, 'the mine view reads them with the other invitations');
370
+ assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\)/, 'the page reads them with the other invitations');
369
371
  const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
370
372
  for (const rule of ['.guildCount__opts{', '.guildCount__opt:has(input:checked) b::before{', '.guildCount__opt:focus-within{', '.guildRow--invite .mineRow__actions{align-self:flex-end;}']) {
371
373
  assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
@@ -374,9 +376,9 @@ test('the cards are real lists in the Invitations section, built with createElem
374
376
  });
375
377
 
376
378
  test('the kit pictures the cards, and the stub answers the four calls as the routes do', () => {
377
- const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
378
- for (const s of ['in-mine-guild-cards', 'in-mine-guild-count-off', 'in-mine-guild-joined']) assert.ok(states[s], `kit state ${s}`);
379
- const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes.map((p) => p.name);
379
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
380
+ for (const s of ['in-guild-cards', 'in-guild-count-off', 'in-guild-joined']) assert.ok(states[s], `kit state ${s}`);
381
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.probes.json'), 'utf8')).probes.map((p) => p.name);
380
382
  assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
381
383
  const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
382
384
  assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);