@bongos/core 1.23.10 → 1.23.12

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 (35) hide show
  1. package/.bongos-core.json +65 -35
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +1 -0
  3. package/docs/copy-inventory.md +857 -816
  4. package/docs/copy-registry.json +1517 -1091
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +15 -3
  7. package/docs/page-readings.json +588 -576
  8. package/modules/public-landing/public/account.html +2 -1
  9. package/modules/public-landing/public/assets/hub-bar.js +4 -2
  10. package/modules/public-landing/public/community-owner-two.states.json +16 -0
  11. package/modules/public-landing/public/community-owner.states.json +16 -0
  12. package/modules/public-landing/public/community.html +124 -11
  13. package/modules/public-landing/public/community.states.json +3 -1
  14. package/modules/public-landing/public/connections-nosuggest.states.json +15 -0
  15. package/modules/public-landing/public/connections.html +23 -3
  16. package/modules/public-landing/public/connections.states.json +1 -0
  17. package/modules/public-landing/public/contact.html +2 -1
  18. package/modules/public-landing/public/guild.html +2 -1
  19. package/modules/public-landing/public/index.html +6 -2
  20. package/modules/public-landing/public/invitations.html +2 -1
  21. package/modules/public-landing/public/privacy.html +2 -1
  22. package/modules/public-landing/public/projects.html +4 -1
  23. package/modules/public-landing/public/terms.html +2 -1
  24. package/modules/ui-design/kit/fixtures/connections-nosuggest.json +12 -0
  25. package/modules/ui-design/kit/fixtures/my-projects-owner-two.json +1 -0
  26. package/modules/ui-design/kit/fixtures/my-projects-owner.json +1 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +12 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/hub_account_page.mjs +7 -1
  32. package/tests/hub_community_page.mjs +149 -9
  33. package/tests/hub_connections_page.mjs +53 -4
  34. package/tests/hub_guild_page.mjs +1 -0
  35. package/tests/landing_legal_pages.mjs +7 -1
@@ -861,6 +861,7 @@ summary{min-height:24px;padding:3px 0;}
861
861
  <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
862
862
  <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
863
863
  <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
864
+ <li class="bandCell knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
864
865
  </ul>
865
866
  </div>
866
867
  </div><!--apex-only:end-->
@@ -873,7 +874,7 @@ summary{min-height:24px;padding:3px 0;}
873
874
  <li class="bandCell"><a class="bandT" href="?view=map&amp;sub=table" data-nav="map" data-sub="table">Table</a><span class="bandC">the same projects as rows</span></li>
874
875
  <li class="bandCell"><a class="bandT" href="?view=mine" data-nav="mine">Your projects</a><span class="bandC">the ones you own</span></li>
875
876
  <li class="bandCell"><a class="bandT" href="?view=new" data-nav="new">New project</a><span class="bandC">five questions and a home</span></li>
876
- <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
877
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li><!--apex-only:end-->
877
878
  </ul>
878
879
  <!-- About and Docs are site pages, not project views: they live in the
879
880
  footer row with Privacy / Terms / Contact (owner, 2026-08-28; task 1003332). -->
@@ -11808,6 +11809,8 @@ summary{min-height:24px;padding:3px 0;}
11808
11809
  /* every Your-projects item a band carries (§6 asked for querySelectorAll so
11809
11810
  a second data-nav="mine" item could never become a signed-out leak) */
11810
11811
  document.querySelectorAll('[data-nav="mine"]').forEach(function (a) { (a.closest('.bandCell') || a).hidden = !signedIn; });
11812
+ /* Builders you may know reads your own connections (task 1004256) */
11813
+ document.querySelectorAll('.knowCell').forEach(function (c) { c.hidden = !signedIn; });
11811
11814
  $('onlyMine').closest('.switch').hidden = !signedIn;
11812
11815
  if (!signedIn) closeMenus();
11813
11816
  if (!signedIn && $('onlyMine').checked) { $('onlyMine').checked = false; applyMapFilter(); }
@@ -72,6 +72,7 @@
72
72
  <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
73
73
  <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
74
74
  <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
75
+ <li class="bandCell knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
75
76
  </ul>
76
77
  </div>
77
78
  </div><!--apex-only:end-->
@@ -82,7 +83,7 @@
82
83
  <li class="bandCell"><a class="bandT" href="/projects?view=map&amp;sub=table">Table</a><span class="bandC">the same projects as rows</span></li>
83
84
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
84
85
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
85
- <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
86
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li><!--apex-only:end-->
86
87
  </ul>
87
88
  </div>
88
89
  </div>
@@ -0,0 +1,12 @@
1
+ {
2
+ "_": "The connections graph with nothing to suggest (task 1004256): one connection, nothing pending, no builder you may know. Opened by the Community menu's item (/connections#connSuggest), the panel says so rather than vanishing.",
3
+ "connections": [
4
+ { "id": "41", "connected_at": "2026-09-30T10:00:00Z", "handle": "mira", "display_name": "Mira Okafor", "avatar_url": "/__kit/avatar.svg" }
5
+ ],
6
+ "incoming": [],
7
+ "outgoing": [],
8
+ "suggestions": [],
9
+ "directory": [
10
+ { "handle": "nadia", "display_name": "Nadia Haddad", "avatar_url": "/__kit/avatar.svg" }
11
+ ]
12
+ }
@@ -0,0 +1 @@
1
+ {"_":"GET /my-projects for a builder who owns TWO projects (task 1002292): the community pages' Recruit door asks which project it recruits to.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"},{"client_id":"tidepool","name":"Tidepool","origin":"https://tidepool.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-09-02T10:00:00.000Z","last_active":"2026-10-24T18:00:00.000Z"}],"building":[],"applied":[]}
@@ -0,0 +1 @@
1
+ {"_":"GET /my-projects for a builder who owns ONE project (task 1002292): the community pages' Recruit door shows on every row and opens Mercury's hall invite box.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"}],"building":[{"client_id":"otb","name":"Example World","origin":"https://example.com","hall_origin":"https://builders.example.com","membership_kind":"member","joined_at":"2026-07-20T10:00:00.000Z","last_active":"2026-08-27T18:00:00.000Z"}],"applied":[]}
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.10",
3
+ "version": "1.23.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.10",
9
+ "version": "1.23.12",
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.23.10",
3
+ "version": "1.23.12",
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",
@@ -10447,5 +10447,17 @@
10447
10447
  "id": "1003363",
10448
10448
  "text": "cloudbongos.com now has a Community menu: a leaderboard of builders across every project (by week, month, year or all time, and by craft), a search for builders by name, and, for project owners, a list of builders who keep the"
10449
10449
  }
10450
+ ],
10451
+ "1.23.11": [
10452
+ {
10453
+ "id": "1004256",
10454
+ "text": "Signed-in builders now find 'Builders you may know' in the Community menu; it opens their suggestions on the Connections page, which says so plainly when there's no one to suggest yet."
10455
+ }
10456
+ ],
10457
+ "1.23.12": [
10458
+ {
10459
+ "id": "1002292",
10460
+ "text": "Project owners can now press Recruit next to any builder on the community leaderboard or search, which opens their project's invite page with that builder's name already filled in."
10461
+ }
10450
10462
  ]
10451
10463
  }
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.23.10'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.12'; // 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');
@@ -137,6 +137,7 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
137
137
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
138
138
  // the Community menu's three sub-pages, folded into the band under 480px (task 1003363)
139
139
  ['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
140
+ ['Builders you may know', '/connections#connSuggest'],
140
141
  ], 'this page has no views of its own, so every item names /projects in full');
141
142
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
142
143
  ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
@@ -213,6 +214,8 @@ function boot(answer) {
213
214
  // The bar: the two triggers, and each band with its links in markup order. An
214
215
  // item's cell is its <li>; only #mineCell carries an id, so only it can hide.
215
216
  const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
217
+ // the signed-in-only "Builders you may know" cells (task 1004256), by class: a band and its phone fold
218
+ const knowCells = [...PAGE.matchAll(/<li class="bandCell(?: bandFold)? knowCell" hidden>/g)].map(() => ({ hidden: true }));
216
219
  const bands = [...PAGE.matchAll(/<div class="menuBand" id="(\w+)"[\s\S]*?\n<\/div>/g)].map((m) => {
217
220
  const band = els.get(m[1]);
218
221
  band.items = [...m[0].matchAll(/<li class="bandCell"(?: id="(\w+)")?><a class="([^"]+)" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g)].map((a) => ({
@@ -243,7 +246,7 @@ function boot(answer) {
243
246
  });
244
247
  const calls = [];
245
248
  const dom = {
246
- els, radios, calls, focused: null, assigned: null,
249
+ els, radios, calls, knowCells, focused: null, assigned: null,
247
250
  active: () => document.activeElement,
248
251
  // a click bubbles: the target's own listeners, its band's, then the document's
249
252
  click(target, detail = 1) {
@@ -274,6 +277,7 @@ function boot(answer) {
274
277
  },
275
278
  querySelectorAll(sel) {
276
279
  if (sel === '#profileForm [data-link]') return links;
280
+ if (sel === '.knowCell') return knowCells;
277
281
  if (sel === '.trig') return trigs;
278
282
  if (sel === '.menuBand') return bands;
279
283
  const names = [...sel.matchAll(/input\[name="(\w+)"\]/g)].map((m) => m[1]);
@@ -311,6 +315,7 @@ test('signed out: a 401 opens the door and shows nothing else', async () => {
311
315
  assert.equal(dom.els.get('who').hidden, true, 'no chip without a session');
312
316
  assert.equal(dom.els.get('signinTop').hidden, false, 'the sign-in door takes its slot');
313
317
  assert.equal(dom.els.get('mineCell').hidden, true, 'and the Projects band offers no Your projects');
318
+ assert.deepEqual(dom.knowCells.map((c) => c.hidden), [true, true], 'nor Builders you may know, in the Community band or its fold');
314
319
  });
315
320
 
316
321
  test('signed in: every control is painted from the server’s answer', async () => {
@@ -680,6 +685,7 @@ test('the chip is the hub’s identity read: GET /me fills the trigger and the p
680
685
  assert.equal(dom.els.get(id).hidden, false);
681
686
  }
682
687
  assert.equal(dom.els.get('mineCell').hidden, false);
688
+ assert.deepEqual(dom.knowCells.map((c) => c.hidden), [false, false]);
683
689
  });
684
690
 
685
691
  test('an account with no avatar gets a chip with no picture rather than a broken one', async () => {
@@ -78,6 +78,7 @@ test('no top block: one visually hidden h1, then one section per sub-page, only
78
78
  assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
79
79
  assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
80
80
  assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
81
+ for (const id of ['boardFor', 'findFor']) assert.match(MAIN, new RegExp(`<div class="commFor" id="${id}" hidden></div>`), `${id}: the Recruit door’s line waits for GET /my-projects (task 1002292)`);
81
82
  assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
82
83
  assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&amp;repos=1">/);
83
84
  for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
@@ -100,11 +101,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
100
101
  assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
101
102
  });
102
103
 
103
- test('the script builds no markup from strings, reads only the three routes, and never re-ranks or reads the ranked tier', () => {
104
+ test('the script builds no markup from strings, reads only the four routes, and never re-ranks or reads the ranked tier', () => {
104
105
  assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
105
- // every path literal in the code: the API base, the three reads, the three sub-pages and the sign-in door
106
+ // every path literal in the code: the API base, the four reads, the three sub-pages, the
107
+ // sign-in door and the hall's invite box the Recruit door opens (task 1002292)
106
108
  const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
107
- assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/scouting']);
109
+ assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/my-projects', '/scouting']);
108
110
  assert.doesNotMatch(CODE, /method:/, 'it only reads');
109
111
  assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
110
112
  assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
@@ -173,7 +175,9 @@ async function boot({ pathname = '/community', me = null, reads = READS() } = {}
173
175
  requests.push({ url, credentials: init.credentials });
174
176
  const a = reads[url.replace(/^\/api\/bongos/, '')];
175
177
  if (!a || a.reject) return Promise.reject(new Error('offline'));
176
- return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) });
178
+ const res = { ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) };
179
+ // `slow`: answers on a timer, after every microtask-settled read (the order a slow network gives)
180
+ return a.slow ? new Promise((resolve) => setTimeout(() => resolve(res), 5)) : Promise.resolve(res);
177
181
  };
178
182
  const document = {
179
183
  title: 'Cloud Bongos — Community',
@@ -225,7 +229,8 @@ test('the leaderboard, signed out: the all-time board in the server’s order wi
225
229
  assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
226
230
  assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
227
231
  const signedIn = await boot({ me: { github_login: 'someone' } });
228
- assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed in, the same one read: viewing is ungated');
232
+ assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects'], 'signed in, the same board read: viewing is ungated, and the one more read is the Recruit door’s');
233
+ assert.equal(signedIn.$('boardList').textContent, ui.$('boardList').textContent, 'the same rows for every viewer');
229
234
  });
230
235
 
231
236
  test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
@@ -320,6 +325,114 @@ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as
320
325
  assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
321
326
  });
322
327
 
328
+ // ── 2b. the Recruit door (task 1002292; spec D6, owner 2026-10-27) ───────────
329
+ // A project OWNER gets a Recruit link on each row: the hub cannot read a project's
330
+ // `project.recruit` (ADR 0335 D1), so GET /my-projects' `owned` stands in. The link is the
331
+ // project's own hall invite box with the GitHub login filled in (ADR 0353), and the
332
+ // project decides.
333
+
334
+ const OWNED = (...ps) => ({ status: 200, body: {
335
+ owned: ps.map(([client_id, origin, name]) => ({ client_id, name, origin, hall_origin: null, membership_kind: 'owner' })),
336
+ building: [{ client_id: 'zed', name: 'Zed', origin: 'https://zed.example', hall_origin: null, membership_kind: 'member' }],
337
+ applied: [],
338
+ } });
339
+ const recruitOf = (row) => find(row, 'commRecruit');
340
+
341
+ test('recruit: an owner gets a Recruit link on every row but their own, opening their project’s hall invite box with the GitHub login filled in', async () => {
342
+ const reads = READS();
343
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example/x?y=1', 'Mercury']);
344
+ const ui = await boot({ me: { github_login: 'BO' }, reads });
345
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects']);
346
+ const [ana, bo] = kids(ui.$('boardList'));
347
+ const a = recruitOf(ana);
348
+ assert.equal(a.tag, 'a');
349
+ assert.equal(a.textContent, 'Recruit');
350
+ assert.equal(a.attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the project’s origin and nothing else of the address');
351
+ assert.equal(a.attrs.target, '_blank', 'the board stays open behind it');
352
+ assert.equal(a.attrs.rel, 'noopener');
353
+ assert.equal(a.attrs['aria-label'], 'Recruit ANA to Mercury (opens in a new tab)', 'the new tab is said, not sprung');
354
+ assert.equal(recruitOf(bo).tag, 'span', 'your own row: an invisible twin keeps the credits in line');
355
+ assert.equal(recruitOf(bo).attrs['aria-hidden'], 'true');
356
+ assert.match(ui.$('boardList').className, /\bcanRecruit\b/);
357
+ assert.equal(ui.$('boardFor').hidden, false);
358
+ assert.equal(ui.$('boardFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
359
+ await ui.pick('30d');
360
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=cy', 'a new window’s rows carry it too');
361
+ reads['/community/leaderboard?window=all'].slow = true;
362
+ const late = await boot({ me: { github_login: 'BO' }, reads });
363
+ assert.equal(kids(late.$('boardList')).length, 0, 'the board has not answered yet');
364
+ assert.equal(late.$('boardFor').hidden, false, 'the owner’s line is up before the board');
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ await settle();
367
+ assert.equal(recruitOf(kids(late.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'a board that answers after GET /my-projects draws its doors as it paints');
368
+ delete reads['/community/leaderboard?window=all'].slow;
369
+ reads['/community/leaderboard?window=all'].body.results[0].github_login = 'a b&c';
370
+ const odd = await boot({ me: { github_login: 'x' }, reads });
371
+ assert.equal(recruitOf(kids(odd.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=a%20b%26c', 'the login is encoded into the query');
372
+ });
373
+
374
+ test('recruit: none for a builder who owns no project, signed out, on a failed read, or for a project with no web address; the rows stay the same', async () => {
375
+ const reads = READS();
376
+ reads['/my-projects'] = OWNED();
377
+ const member = await boot({ me: { github_login: 'someone' }, reads });
378
+ assert.equal(member.gets().pop(), '/api/bongos/my-projects');
379
+ assert.ok(kids(member.$('boardList')).every((r) => !recruitOf(r)), 'building on a project is not owning one');
380
+ assert.equal(member.$('boardFor').hidden, true);
381
+ assert.doesNotMatch(member.$('boardList').className, /canRecruit/);
382
+ const out = await boot({ reads });
383
+ assert.deepEqual(out.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed out, nobody is asked about projects');
384
+ for (const answer of [{ status: 500, body: {} }, { status: 401, body: {} }, { reject: true }, OWNED(['evil', 'javascript:alert(1)', 'Evil'], ['none', null, 'None'])]) {
385
+ reads['/my-projects'] = answer;
386
+ const ui = await boot({ me: { github_login: 'someone' }, reads });
387
+ assert.ok(kids(ui.$('boardList')).every((r) => !recruitOf(r)), 'no door, and the board is drawn all the same');
388
+ assert.equal(kids(ui.$('boardList')).length, 2);
389
+ assert.equal(ui.$('boardFailed').hidden, true, 'a failed projects read is never the board’s failure');
390
+ assert.equal(ui.$('boardFor').hidden, true);
391
+ }
392
+ });
393
+
394
+ test('recruit: several projects are one “Recruit to” choice, and picking one re-points every row', async () => {
395
+ const reads = READS();
396
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury'], ['venus', 'http://venus.example:8080', 'Venus']);
397
+ const ui = await boot({ me: { github_login: 'nobody' }, reads });
398
+ const pick = find(ui.$('boardFor'), 'commForPick');
399
+ assert.equal(pick.tag, 'select');
400
+ assert.equal(pick.id, 'boardForPick');
401
+ assert.deepEqual(kids(pick).map((o) => [o.value, o.textContent]), [['0', 'Mercury'], ['1', 'Venus']]);
402
+ assert.equal(kids(ui.$('boardFor'))[0].attrs.for, 'boardForPick', 'the choice is labelled');
403
+ assert.match(ui.$('boardFor').textContent, /^Recruit to/);
404
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the first project, as the server listed them');
405
+ pick.value = '1';
406
+ for (const f of pick._on.change || []) f({});
407
+ await settle();
408
+ const [ana, bo] = kids(ui.$('boardList'));
409
+ assert.equal(recruitOf(ana).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana');
410
+ assert.equal(recruitOf(bo).attrs['aria-label'], 'Recruit bo to Venus (opens in a new tab)');
411
+ assert.equal(find(ui.$('boardFor'), 'commForPick'), pick, 'the choice is not redrawn, so it keeps the focus');
412
+ pick.value = '7';
413
+ for (const f of pick._on.change || []) f({});
414
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana', 'a value it never offered changes nothing');
415
+ });
416
+
417
+ test('recruit: found builders carry it too; the recruiter list never asks', async () => {
418
+ const reads = READS();
419
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury']);
420
+ const ui = await boot({ pathname: '/community/find', me: { github_login: 'ph' }, reads });
421
+ assert.deepEqual(ui.gets(), ['/api/bongos/my-projects'], 'nothing searched until asked');
422
+ assert.equal(ui.$('findFor').hidden, false);
423
+ assert.equal(ui.$('findFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
424
+ assert.equal(ui.$('boardFor').hidden, true, 'only the shown sub-page’s line');
425
+ ui.$('findQ').value = 'pri';
426
+ await ui.submit();
427
+ const [priya, hidden] = kids(ui.$('findList'));
428
+ const acts = find(priya, 'mineRow__actions');
429
+ assert.equal(recruitOf(acts).attrs.href, 'https://mercury.example/builders/watch?invite=priyan', 'by GitHub login, never the handle');
430
+ assert.equal(recruitOf(hidden), null, 'your own row: no door, and no twin in a row that keeps no columns');
431
+ assert.equal(ui.$('findStatus').textContent, '2 builders found.');
432
+ const recruiters = await boot({ pathname: '/community/recruiters', me: { github_login: 'owner' }, reads });
433
+ assert.deepEqual(recruiters.gets(), ['/api/bongos/scouting'], 'the recruiter list draws no Recruit door');
434
+ });
435
+
323
436
  // ── 3. every bar carries the Community menu ──────────────────────────────────
324
437
 
325
438
  const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
@@ -330,13 +443,17 @@ const ITEMS = [
330
443
  ['/community/find', 'Find a builder', 'anyone building here, by name or role'],
331
444
  ['/community/recruiters', 'Open to recruiters', 'who is open to recruiting, for project owners'],
332
445
  ];
446
+ // signed in only, and it opens /connections' own panel rather than a second one here (task 1004256)
447
+ const KNOW = '<a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>';
333
448
 
334
449
  test('every page that wears the hub’s bar carries the Community menu before Projects; apex-only where the page serves a child', () => {
335
450
  const { stripApexOnly } = require('../src/platform-server.js');
336
451
  const trig = '<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity"';
337
452
  const band = ['<div class="menuBand" id="menuCommunity" data-menu="community" hidden>', ' <div class="bandIn">', ' <ul class="bandRow">',
338
- ...ITEMS.map(([h, t, c]) => ` <li class="bandCell"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`), ' </ul>', ' </div>', '</div>'].join('\n');
339
- const fold = ITEMS.map(([h, t, c]) => `<li class="bandCell bandFold"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`).join('');
453
+ ...ITEMS.map(([h, t, c]) => ` <li class="bandCell"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`),
454
+ ` <li class="bandCell knowCell" hidden>${KNOW}`, ' </ul>', ' </div>', '</div>'].join('\n');
455
+ const fold = ITEMS.map(([h, t, c]) => `<li class="bandCell bandFold"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`).join('')
456
+ + `<li class="bandCell bandFold knowCell" hidden>${KNOW}`;
340
457
  for (const f of [...SERVES_A_CHILD, ...HUB_ONLY]) {
341
458
  const html = read(f);
342
459
  const apex = SERVES_A_CHILD.includes(f);
@@ -346,10 +463,20 @@ test('every page that wears the hub’s bar carries the Community menu before Pr
346
463
  assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its three sub-pages`);
347
464
  assert.ok(html.indexOf('id="menuCommunity"') < html.indexOf('id="menuProjects"'), `${f}: the bands in the bar’s order`);
348
465
  assert.ok(html.includes(wrap(apex, fold)), `${f}: the three folded into the Projects band for phones`);
349
- if (apex) assert.doesNotMatch(stripApexOnly(html, false).split('<body>')[1], /\/community|commTrig|menuCommunity/, `${f}: a child has no community pages, so no trace of the menu`);
466
+ if (apex) assert.doesNotMatch(stripApexOnly(html, false).split('<body>')[1], /\/community|commTrig|menuCommunity|connSuggest/, `${f}: a child has no community pages, so no trace of the menu`);
350
467
  }
351
468
  });
352
469
 
470
+ test('Builders you may know: shown only once GET /me names someone, and drawn once, on /connections', () => {
471
+ // every bar script that paints the signed-in state shows or hides both cells with it
472
+ assert.match(read('assets/hub-bar.js'), /\$\('mineCell'\)\.hidden = !me;\n\s*Array\.prototype\.forEach\.call\(document\.querySelectorAll\('\.knowCell'\), function \(c\) \{ c\.hidden = !me; \}\);/);
473
+ assert.match(read('projects.html'), /function paintShellIdentity\(\) \{[\s\S]*?document\.querySelectorAll\('\.knowCell'\)\.forEach\(function \(c\) \{ c\.hidden = !signedIn; \}\);/);
474
+ assert.match(read('index.html'), /if \(mine\) mine\.hidden = false;\n\s*document\.querySelectorAll\('\.knowCell'\)\.forEach\(function \(c\) \{ c\.hidden = false; \}\);/);
475
+ // the panel it opens exists, and the community page reads no suggestions of its own
476
+ assert.match(read('connections.html'), /<section id="connSuggest" aria-labelledby="connSuggestH" hidden>/);
477
+ assert.doesNotMatch(read('community.html').split('<script>')[1], /connections\/suggestions/);
478
+ });
479
+
353
480
  test('the menu folds into the Projects band under 480px on the hub and 900px on the landing; the arrows skip what CSS does not draw', () => {
354
481
  const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
355
482
  assert.match(css, /\.trig\[aria-expanded="true"\],\.trig\[aria-current="true"\]\{color:var\(--ink\);text-decoration:underline;/, 'on its own pages the menu reads as the current section');
@@ -357,6 +484,10 @@ test('the menu folds into the Projects band under 480px on the hub and 900px on
357
484
  assert.match(css.split('@media (max-width:480px){')[1].split('\n}')[0], /\.commTrig\{display:none;\}\s*\.bandFold\{display:block;\}/);
358
485
  const index = read('index.html');
359
486
  assert.match(index, /\n\.bandFold\{display:none\}\n/);
487
+ // a hidden fold cell (signed in only, task 1004256) must stay down where the fold shows:
488
+ // cosmos.css's [hidden] rule is !important; the landing has none, so it carries its own
489
+ assert.match(css, /\n\[hidden\]\{display:none !important;\}\n/);
490
+ assert.match(index, /\n\.bandFold\[hidden\]\{display:none\}\n/);
360
491
  assert.match(index.split('@media (max-width:900px){')[1], /^[\s\S]*?\.commTrig\{display:none\}\s*\.bandFold\{display:block\}/);
361
492
  for (const f of ['assets/hub-bar.js', 'projects.html', 'index.html']) {
362
493
  assert.match(read(f), /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null; \}\);/, `${f}: the arrows skip a cell CSS does not draw`);
@@ -419,11 +550,20 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
419
550
  assert.equal(states.states['out-find'].url, '/community/find');
420
551
  assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
421
552
  assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
422
- for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
553
+ for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter'], ['community-owner.states.json', 'my-projects', 'my-projects-owner'], ['community-owner-two.states.json', 'my-projects', 'my-projects-owner-two']]) {
423
554
  assert.equal(json(file).stub.fixtures[name], fixture);
424
555
  assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
425
556
  }
426
557
  assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
558
+ // the Recruit door (task 1002292): an owner of one project on the board and the search, and of two with the choice
559
+ assert.ok(json('community-owner.states.json').states['in-owner'].expect.count.some(([sel, n]) => sel === '#boardList a.commRecruit' && n === 6));
560
+ assert.equal(json('community-owner.states.json').states['in-owner-find'].url, '/community/find');
561
+ assert.ok(json('community-owner-two.states.json').states['in-owner-two-picked'].actions.some((a) => a[0] === 'select' && a[1] === '#boardForPick'));
562
+ for (const f of ['my-projects-owner', 'my-projects-owner-two']) {
563
+ const owned = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', f + '.json'), 'utf8')).owned;
564
+ assert.ok(owned.length && owned.every((p) => p.membership_kind === 'owner' && /^https:\/\//.test(p.origin)), `${f}: owned projects with web addresses`);
565
+ }
566
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'my-projects.json'), 'utf8')).owned, [], 'the default builder owns nothing, so every other state draws no door');
427
567
  });
428
568
 
429
569
  function stub(env = {}) {
@@ -128,6 +128,7 @@ function el(tag) {
128
128
  n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
129
129
  n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
130
130
  n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
131
+ n.scrollIntoView = () => { n.scrolled = (n.scrolled || 0) + 1; };
131
132
  return n;
132
133
  }
133
134
  const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
@@ -161,7 +162,7 @@ const GRAPH = () => ({
161
162
  ], count: 2 } },
162
163
  });
163
164
 
164
- async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRAPH(), writes = [] } = {}) {
165
+ async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRAPH(), writes = [], hash = '' } = {}) {
165
166
  const ids = new Map();
166
167
  for (const id of [...PAGE.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])) {
167
168
  const n = el('x');
@@ -180,15 +181,18 @@ async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRA
180
181
  return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
181
182
  };
182
183
  const document = { getElementById: (id) => ids.get(id) || null, createElement: el };
183
- const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) } };
184
- const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, Date, isNaN, encodeURIComponent };
184
+ const winOn = {};
185
+ const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) }, addEventListener: (t, f) => { (winOn[t] ||= []).push(f); } };
186
+ const location = { hash };
187
+ const sandbox = { window, document, fetch, location, console, Promise, JSON, String, Object, Array, URL, Date, isNaN, encodeURIComponent };
185
188
  vm.createContext(sandbox);
186
189
  vm.runInContext(SCRIPT, sandbox, { filename: 'connections.html' });
187
190
  await settle();
188
191
  const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
189
192
  const key = async (n, k) => { const e = { key: k, preventDefault() { e.prevented = true; } }; for (const f of n._on.keydown || []) f(e); await settle(); return e; };
190
193
  const gets = () => requests.filter((r) => r.method === 'GET').map((r) => r.url);
191
- return { ids, requests, gets, click, key, $: (id) => ids.get(id) };
194
+ const hashTo = async (h) => { location.hash = h; for (const f of winOn.hashchange || []) f({}); await settle(); };
195
+ return { ids, requests, gets, click, key, hashTo, $: (id) => ids.get(id) };
192
196
  }
193
197
  const button = (row, label) => all(row, 'btn').find((b) => b.textContent === label);
194
198
 
@@ -283,6 +287,51 @@ test('a failed suggestions read drops only that panel', async () => {
283
287
  assert.equal(kids(ui.$('connList')).length, 2);
284
288
  });
285
289
 
290
+ test('opened by the Community menu’s item (#connSuggest): scrolled to once, and an empty panel says so', async () => {
291
+ // the panel draws after the browser's own jump to the fragment, so the page makes it, once
292
+ let ui = await boot({ hash: '#connSuggest', writes: [{ status: 201, body: { status: 'pending' } }] });
293
+ assert.equal(ui.$('connSuggest').hidden, false);
294
+ assert.equal(ui.$('suggestNone').hidden, true, 'suggestions need no explaining');
295
+ assert.equal(ui.$('connSuggest').scrolled, 1);
296
+ await ui.click(button(kids(ui.$('suggestList'))[0], 'Connect'));
297
+ assert.equal(ui.gets().length, 6);
298
+ assert.equal(ui.$('connSuggest').scrolled, 1, 'a re-read after Connect does not jump the page again');
299
+
300
+ const none = GRAPH();
301
+ none['/connections/suggestions'].body = { suggestions: [] };
302
+ ui = await boot({ hash: '#connSuggest', reads: none });
303
+ assert.equal(ui.$('connSuggest').hidden, false, 'asked for by name, the panel answers rather than vanishing');
304
+ assert.equal(ui.$('suggestNone').hidden, false);
305
+ assert.match(PAGE, /<p class="mineEmpty" id="suggestNone" hidden>No one to suggest yet\. Suggestions come from the connections and projects you have in common with other builders\.<\/p>/);
306
+
307
+ const failed = GRAPH();
308
+ failed['/connections/suggestions'] = { status: 500, body: {} };
309
+ ui = await boot({ hash: '#connSuggest', reads: failed });
310
+ assert.equal(ui.$('connSuggest').hidden, true, 'an unread list is not "no one to suggest"');
311
+ assert.equal(ui.$('connSuggest').scrolled, undefined);
312
+ });
313
+
314
+ test('the menu item pressed while already on the page: the hash change is answered as an arrival', async () => {
315
+ const none = GRAPH();
316
+ none['/connections/suggestions'].body = { suggestions: [] };
317
+ let ui = await boot({ reads: none });
318
+ assert.equal(ui.$('connSuggest').hidden, true, 'a plain visit: no suggestions, no empty shell');
319
+ await ui.hashTo('#connSuggest');
320
+ assert.equal(ui.gets().length, 6, 'one re-read');
321
+ assert.equal(ui.$('connSuggest').hidden, false);
322
+ assert.equal(ui.$('suggestNone').hidden, false);
323
+
324
+ ui = await boot();
325
+ await ui.hashTo('#connSuggest');
326
+ assert.equal(ui.$('connSuggest').scrolled, 1, 'scrolled to once the panel is drawn');
327
+ await ui.hashTo('#connList');
328
+ assert.equal(ui.gets().length, 6, 'any other hash reads nothing');
329
+
330
+ ui = await boot({ me: null });
331
+ await ui.hashTo('#connSuggest');
332
+ assert.deepEqual(ui.requests, [], 'signed out, the door stands and nothing is read');
333
+ });
334
+
286
335
  test('Accept, Decline, Remove and Withdraw: one call each, then the page re-reads', async () => {
287
336
  const cases = [
288
337
  ['incomingList', 'Accept', 'POST', '/api/bongos/connections/51/accept', { status: 200, body: { status: 'accepted' } }, 'Connected.'],
@@ -216,6 +216,7 @@ function boot({ pathname = '/g/forge-crew', me = null, answer = () => NOT_FOUND,
216
216
  querySelectorAll(sel) {
217
217
  if (sel === '.trig') return trigs;
218
218
  if (sel === '.menuBand') return bands;
219
+ if (sel === '.knowCell') return [];
219
220
  const m = sel.match(/^input\[name="(\w+)"\]$/);
220
221
  if (!m) throw new Error(`unstubbed selector: ${sel}`);
221
222
  return radios.filter((r) => r.name === m[1]);
@@ -195,6 +195,7 @@ for (const p of PAGES) {
195
195
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
196
196
  // the Community menu's three sub-pages, folded into the band under 480px (task 1003363); apex-only
197
197
  ['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
198
+ ['Builders you may know', '/connections#connSuggest'],
198
199
  ], 'this page has no views of its own, so every item names /projects in full');
199
200
  assert.deepEqual(bandLinks(bandOf(stripApexOnly(html, false), 'menuProjects')).map((l) => l[0]), ['Sky', 'Table', 'Your projects', 'New project'], 'a child has no /community');
200
201
  assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
@@ -264,6 +265,8 @@ function bootBar(html, answer, pathname) {
264
265
  els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
265
266
  }
266
267
  const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
268
+ // the signed-in-only "Builders you may know" cells (task 1004256), by class; a child has none
269
+ const knowCells = [...html.matchAll(/<li class="bandCell(?: bandFold)? knowCell" hidden>/g)].map(() => ({ hidden: true }));
267
270
  const bands = [...html.matchAll(/<div class="menuBand" id="(\w+)"[\s\S]*?\n<\/div>/g)].map((m) => {
268
271
  const band = els.get(m[1]);
269
272
  band.items = [...m[0].matchAll(/<li class="bandCell"(?: id="(\w+)")?><a class="([^"]+)" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g)].map((a) => ({
@@ -287,7 +290,7 @@ function bootBar(html, answer, pathname) {
287
290
  const event = (extra) => ({ prevented: false, preventDefault() { this.prevented = true; }, ...extra });
288
291
  const calls = [];
289
292
  const dom = {
290
- els, calls, assigned: null,
293
+ els, calls, knowCells, assigned: null,
291
294
  active: () => document.activeElement,
292
295
  band: (id) => els.get(id),
293
296
  item: (id, text) => els.get(id).items.find((i) => i.text === text),
@@ -312,6 +315,7 @@ function bootBar(html, answer, pathname) {
312
315
  querySelectorAll(sel) {
313
316
  if (sel === '.trig') return trigs;
314
317
  if (sel === '.menuBand') return bands;
318
+ if (sel === '.knowCell') return knowCells;
315
319
  throw new Error(`unstubbed selector: ${sel}`);
316
320
  },
317
321
  };
@@ -341,6 +345,7 @@ for (const p of PAGES) {
341
345
  assert.equal(out.els.get('who').hidden, true, 'no chip without a session');
342
346
  assert.equal(out.els.get('signinTop').hidden, false);
343
347
  assert.equal(out.els.get('mineCell').hidden, true, 'and the Projects band offers no Your projects');
348
+ assert.deepEqual(out.knowCells.map((c) => c.hidden), hub ? [true, true] : [], 'nor Builders you may know; a child has no community menu at all');
344
349
  assert.equal(await out.window.HubBar.ready, null);
345
350
 
346
351
  const dom = bootBar(served, signedIn, p.route);
@@ -352,6 +357,7 @@ for (const p of PAGES) {
352
357
  assert.equal(dom.els.get('who').getAttribute('aria-label'), 'Account, octocat');
353
358
  for (const id of ['whoImg', 'bandWhoImg']) assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
354
359
  assert.equal(dom.els.get('mineCell').hidden, false);
360
+ assert.deepEqual(dom.knowCells.map((c) => c.hidden), hub ? [false, false] : []);
355
361
  assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Invitations', 'Connections', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
356
362
  });
357
363
  }