@bongos/core 1.23.9 → 1.23.10

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 +92 -37
  2. package/docs/copy-inventory.md +858 -724
  3. package/docs/copy-registry.json +2538 -1155
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +41 -4
  7. package/docs/page-readings.json +1240 -1156
  8. package/modules/public-landing/public/account.html +11 -0
  9. package/modules/public-landing/public/assets/cosmos.css +9 -1
  10. package/modules/public-landing/public/assets/hub-bar.js +1 -1
  11. package/modules/public-landing/public/community-empty.states.json +15 -0
  12. package/modules/public-landing/public/community-recruiter.states.json +15 -0
  13. package/modules/public-landing/public/community.html +469 -0
  14. package/modules/public-landing/public/community.probes.json +92 -0
  15. package/modules/public-landing/public/community.states.json +23 -0
  16. package/modules/public-landing/public/connections.html +11 -0
  17. package/modules/public-landing/public/contact.html +11 -0
  18. package/modules/public-landing/public/guild.html +11 -0
  19. package/modules/public-landing/public/index.html +18 -1
  20. package/modules/public-landing/public/index.probes.json +1 -1
  21. package/modules/public-landing/public/invitations.html +11 -0
  22. package/modules/public-landing/public/privacy.html +11 -0
  23. package/modules/public-landing/public/projects.html +12 -1
  24. package/modules/public-landing/public/terms.html +11 -0
  25. package/modules/ui-design/kit/fixtures/community-leaderboard-empty.json +4 -0
  26. package/modules/ui-design/kit/fixtures/community-leaderboard.json +28 -0
  27. package/modules/ui-design/kit/fixtures/community-search.json +10 -0
  28. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +11 -0
  29. package/modules/ui-design/kit/fixtures/scouting.json +4 -0
  30. package/modules/ui-design/kit/serve.js +55 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +6 -0
  34. package/src/bongos/serve-internal.js +4 -2
  35. package/src/module-api.js +1 -1
  36. package/src/platform-server.js +11 -0
  37. package/tests/hub_account_page.mjs +2 -0
  38. package/tests/hub_community_page.mjs +475 -0
  39. package/tests/landing_legal_pages.mjs +3 -0
  40. package/tests/landing_page.mjs +1 -1
  41. package/tests/render_check.mjs +2 -2
  42. package/tests/ui_design_kit.mjs +2 -2
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.9",
3
+ "version": "1.23.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.9",
9
+ "version": "1.23.10",
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.9",
3
+ "version": "1.23.10",
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",
@@ -10441,5 +10441,11 @@
10441
10441
  "id": "1004897",
10442
10442
  "text": "The record of this session's work on goal 1000110 is written down for the next person who picks it up."
10443
10443
  }
10444
+ ],
10445
+ "1.23.10": [
10446
+ {
10447
+ "id": "1003363",
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
+ }
10444
10450
  ]
10445
10451
  }
@@ -1005,8 +1005,10 @@ function mountInternalSurfaces(app) {
1005
1005
  // /people itself (owner call, 2026-09-25 — ADR 0337 D5): cross-project
1006
1006
  // discovery belongs to the hub, and /roster is the hall's directory. Both URLs
1007
1007
  // survive as redirects to /roster so a bookmark lands somewhere instead of a
1008
- // 404. 302, not 301: /roster is interim until the hub community page (task
1009
- // 1003363) ships, and a browser caches a 301 past any re-point. No page gate:
1008
+ // 404. They stay on /roster now the hub's community pages exist (task 1003363):
1009
+ // each hall is its own instance and only the hub serves /community, so a child
1010
+ // hall has nowhere else to send them. 302, not 301, so a re-point is never
1011
+ // stuck in a browser cache. No page gate:
1010
1012
  // the redirect discloses nothing, and /roster is deliberately public.
1011
1013
  // GET /scouting's own gate (platform-identity) is untouched by this.
1012
1014
  const RETIRED_DIRECTORY_RE = /^\/builders\/(?:people|scouting)(?:\.html)?\/?$/;
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.9'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.10'; // 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');
@@ -337,6 +337,17 @@ function buildPlatformApp() {
337
337
  if (!html) return next();
338
338
  res.type('html').send(html);
339
339
  });
340
+ // THE COMMUNITY PAGES (task 1003363, ADR 0217 D1, ADR 0337 D1/D3): who is building
341
+ // across every project — the leaderboard, the builder search (the same for every
342
+ // viewer) and the recruiter list GET /scouting's own door admits. One file serves all
343
+ // three; its script shows the one the path names. Hub-only for the /account reason:
344
+ // every read it makes is a platform-identity route.
345
+ app.get(['/community', '/community/find', '/community/recruiters'], (req, res, next) => {
346
+ if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
347
+ const html = landingPage('community.html');
348
+ if (!html) return next();
349
+ res.type('html').send(html);
350
+ });
340
351
  // THE GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6): /g/<slug>, the namespace
341
352
  // D6 reserved. Hub-only for the /account reason — every read it makes is a
342
353
  // platform-identity route. ONE shell for every slug: the page reads the public
@@ -135,6 +135,8 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
135
135
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
136
136
  ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&amp;sub=table'],
137
137
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
138
+ // the Community menu's three sub-pages, folded into the band under 480px (task 1003363)
139
+ ['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
138
140
  ], 'this page has no views of its own, so every item names /projects in full');
139
141
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
140
142
  ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
@@ -0,0 +1,475 @@
1
+ // tests/hub_community_page.mjs — the hub's community pages (task 1003363; ADR 0217 D1,
2
+ // ADR 0337 D1/D3/D4.1, docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8).
3
+ //
4
+ // One file served as three sub-pages of the bar's Community menu (owner, 2026-10-27):
5
+ // /community the leaderboard, /community/find the builder search, /community/recruiters
6
+ // the name-only list of hidden builders open to recruiters. They render three existing
7
+ // platform-identity reads and add no rule of their own. What this file pins:
8
+ // • the markup: the hub's bar with the Community menu as the current section, no top
9
+ // block, noindex, dark only, the routes' own windows and query limits
10
+ // • viewing is ungated: the leaderboard and the search read the same routes for every
11
+ // viewer, and nothing on them asks who is signed in
12
+ // • the page RUN in a vm, once per sub-page: the path picks the section and the menu
13
+ // item marked current; the board in the server's order with its `place`, the window
14
+ // dropdown and the crafts, the empty, failed and rate-limited board; the search and
15
+ // its refusals; the recruiter list as a door signed out, a refusal on a 403, and rows
16
+ // only from a 200 — name, face and login, never the ranked half's numbers
17
+ // • the Community menu on every page that wears the hub's bar (apex-only where the page
18
+ // also serves a child), folded into the Projects band on phones
19
+ // • the routes: the three paths served on the hub's apex only
20
+ // • the kit: the states files, and the stub answering as the three reads do
21
+ //
22
+ // DB-free. Run: node --test tests/hub_community_page.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test, before, after } from 'node:test';
26
+ import http from 'node:http';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import vm from 'node:vm';
30
+ import { spawn } from 'node:child_process';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
37
+ const PI = path.join(ROOT, 'modules', 'platform-identity');
38
+ const read = (f) => fs.readFileSync(path.join(LANDING, f), 'utf8').replace(/\r\n/g, '\n');
39
+ const PAGE = read('community.html');
40
+ const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
41
+ // the code alone: the comments name what they rule out
42
+ const CODE = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
43
+ const STYLE = PAGE.split('<style>')[1].split('</style>')[0].replace(/\/\*[\s\S]*?\*\//g, '');
44
+ const MAIN = PAGE.split('<main class="acct" id="comm">')[1].split('</main>')[0];
45
+ const BOARD_JS = fs.readFileSync(path.join(PI, 'community-leaderboard.js'), 'utf8');
46
+ const SEARCH_JS = fs.readFileSync(path.join(PI, 'builder-search.js'), 'utf8');
47
+ const SUBPAGES = [['/community', 'Leaderboard'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
48
+
49
+ // ── 1. the markup ────────────────────────────────────────────────────────────
50
+
51
+ test('the pages wear the hub’s bar with the Community menu as the current section; dark only, no page :root, not indexed', () => {
52
+ assert.match(PAGE, /<html lang="en" data-mode="dark">/);
53
+ assert.match(PAGE, /<meta name="robots" content="noindex">/, 'public, but kept out of search until the SEO goal decides (owner, 2026-10-27)');
54
+ assert.match(PAGE, /<title>\{\{productName\}\} — Community<\/title>/);
55
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/);
56
+ assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>/);
57
+ assert.match(PAGE, /<script src="\/assets\/invite-badge\.js"><\/script>/);
58
+ assert.doesNotMatch(STYLE, /:root/, 'the pack’s injected theme owns the tokens');
59
+ assert.doesNotMatch(STYLE, /#[0-9a-fA-F]{3,8}\b/, 'every colour is a token or a mix of one');
60
+ assert.match(PAGE, /<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity" aria-current="true">Community<svg class="cv"/);
61
+ assert.deepEqual(PAGE.match(/aria-current="page"/g), null, 'which sub-page is current is the script’s, from the path');
62
+ assert.match(PAGE, /<a class="skip vh" href="#comm">Skip to content<\/a>/);
63
+ assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, 1, 'hub-only: the footer’s platform sentence is the one apex-only region');
64
+ assert.ok(PAGE.indexOf('<!--apex-only:start-->') > PAGE.indexOf('<footer class="foot">'));
65
+ });
66
+
67
+ test('the bar and bands are /connections’ but for the sign-in return and the current section', () => {
68
+ const bar = (html) => html.slice(html.indexOf('<header class="shell bar">'), html.indexOf('<main '))
69
+ .replace(/return=\/\w+&amp;/g, 'return=X&amp;').replace(/ aria-current="(page|true)"/g, '');
70
+ assert.equal(bar(PAGE), bar(read('connections.html')));
71
+ });
72
+
73
+ test('no top block: one visually hidden h1, then one section per sub-page, only the leaderboard shown in the markup', () => {
74
+ assert.match(MAIN, /^\s*<h1 class="vh">Community<\/h1>\s*(<!--[\s\S]*?-->\s*)?<div class="mineExtra">/, 'nothing is drawn above the first section (the /design floor)');
75
+ const order = ['<section id="board" data-view="leaderboard" aria-labelledby="boardH">', '<section id="find" data-view="find" aria-labelledby="findH" hidden>', '<section id="recruit" data-view="recruiters" aria-labelledby="recruitH" hidden>'].map((s) => MAIN.indexOf(s));
76
+ assert.ok(order.every((i) => i > 0), 'every sub-page is real markup the script only shows');
77
+ assert.deepEqual([...order].sort((a, b) => a - b), order);
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
+ assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
80
+ assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
81
+ assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
82
+ assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&amp;repos=1">/);
83
+ for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
84
+ });
85
+
86
+ test('the window dropdown and the query limits are the routes’ own (boundary-forced copies)', () => {
87
+ const days = BOARD_JS.match(/const WINDOW_DAYS = Object\.freeze\(\{([^}]*)\}\);/);
88
+ const windows = [...days[1].matchAll(/'?(\w+)'?:/g)].map((m) => m[1]);
89
+ assert.deepEqual(windows, ['7d', '30d', '365d', 'all']);
90
+ assert.match(SCRIPT, new RegExp(`var WINDOWS = \\[${windows.map((w) => `'${w}'`).join(', ')}\\];`));
91
+ const select = MAIN.split('<select id="boardWindow">')[1].split('</select>')[0];
92
+ assert.deepEqual([...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]), windows, 'one option per window, in the route’s order');
93
+ assert.match(BOARD_JS, /const DEFAULT_WINDOW = 'all';/);
94
+ assert.match(select, /<option value="all" selected>All time<\/option>/, 'the page opens on the route’s default window');
95
+ assert.match(MAIN, /<label class="vh" for="boardWindow">/, 'the dropdown is labelled');
96
+ const min = SEARCH_JS.match(/const MIN_QUERY_CHARS = (\d+);/)[1];
97
+ const max = SEARCH_JS.match(/const MAX_QUERY_CHARS = (\d+);/)[1];
98
+ assert.match(SCRIPT, new RegExp(`var QUERY_MIN = ${min};`));
99
+ assert.match(SCRIPT, new RegExp(`var QUERY_MAX = ${max};`));
100
+ assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
101
+ });
102
+
103
+ test('the script builds no markup from strings, reads only the three routes, and never re-ranks or reads the ranked tier', () => {
104
+ 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
+ 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']);
108
+ assert.doesNotMatch(CODE, /method:/, 'it only reads');
109
+ assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
110
+ assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
111
+ assert.doesNotMatch(CODE, /\.builders\b|total_credits|total_tasks_shipped|project_count/, 'the directory’s ranked half is never drawn here');
112
+ assert.doesNotMatch(CODE, /\brank\b/, '`place` is a position on the board, never a builder rank');
113
+ });
114
+
115
+ // ── 2. the pages, run ────────────────────────────────────────────────────────
116
+
117
+ function attrsOf(tag) {
118
+ const out = {};
119
+ for (const m of tag.matchAll(/ ([\w-]+)(?:="([^"]*)")?/g)) out[m[1]] = m[2] === undefined ? '' : m[2];
120
+ return out;
121
+ }
122
+ function el(tag, attrs = {}) {
123
+ const n = { tag, children: [], _text: '', className: attrs.class || '', hidden: 'hidden' in attrs, disabled: false, value: attrs.value || '', attrs: { ...attrs }, _on: {}, parent: null };
124
+ Object.defineProperty(n, 'textContent', {
125
+ get() { return n._text + n.children.map((c) => c.textContent).join(''); },
126
+ set(v) { n._text = String(v); n.children = []; },
127
+ });
128
+ n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
129
+ n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
130
+ n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
131
+ n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
132
+ n.querySelectorAll = (sel) => (sel === '.commTab' ? n.children.filter((c) => /\bcommTab\b/.test(c.className)) : []);
133
+ n.closest = (sel) => { const a = (sel.match(/^\[([\w-]+)\]$/) || [])[1]; return a && a in n.attrs ? n : null; };
134
+ return n;
135
+ }
136
+ const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
137
+ const kids = (n) => n.children;
138
+ const find = (n, cls) => {
139
+ for (const c of n.children) {
140
+ if (String(c.className).split(' ').includes(cls)) return c;
141
+ const d = find(c, cls);
142
+ if (d) return d;
143
+ }
144
+ return null;
145
+ };
146
+
147
+ const ROW = (place, handle, credits, extra = {}) => ({ place, handle, github_login: handle, display_name: handle.toUpperCase(), avatar_url: `https://avatars.example/${handle}`, credits, tasks_shipped: 2, karma: 1, disciplines: ['engineer'], ...extra });
148
+ const READS = () => ({
149
+ '/community/leaderboard?window=all': { status: 200, body: { window: 'all', count: 99, results: [ROW(26, 'ana', 1200), ROW(27, 'bo', 1500, { tasks_shipped: 1, karma: 0, avatar_url: 'javascript:alert(1)', display_name: null })] } },
150
+ '/community/leaderboard?window=30d': { status: 200, body: { window: '30d', results: [ROW(1, 'cy', 40)] } },
151
+ '/community/leaderboard?window=all&discipline=artist': { status: 200, body: { window: 'all', results: [] } },
152
+ '/community/search?q=pri': { status: 200, body: { results: [{ handle: 'priya', github_login: 'priyan', display_name: 'Priya', avatar_url: null, disciplines: ['engineer', 'ui'] }, { handle: 'pri-hidden', github_login: 'ph', display_name: 'Pri <b>H</b>', avatar_url: null, disciplines: null }] } },
153
+ '/community/search?q=pri&discipline=artist': { status: 200, body: { results: [] } },
154
+ '/scouting': { status: 403, body: { error: { code: 'forbidden' } } },
155
+ });
156
+
157
+ async function boot({ pathname = '/community', me = null, reads = READS() } = {}) {
158
+ const ids = new Map();
159
+ for (const m of PAGE.matchAll(/<(\w+)([^>]* id="([^"]+)"[^>]*)>/g)) {
160
+ const attrs = attrsOf(m[2]);
161
+ if (attrs['data-unit']) attrs['data-unit'] = 'credits';
162
+ const n = el(m[1], attrs);
163
+ n.id = m[3];
164
+ if (m[1] === 'select') n.value = (PAGE.split(m[0])[1].split('</select>')[0].match(/<option value="([^"]*)" selected>/) || [, ''])[1];
165
+ ids.set(m[3], n);
166
+ }
167
+ const body = PAGE.split('id="craftTabs"')[1].split('</div>')[0];
168
+ for (const b of body.matchAll(/<button([^>]*)>([^<]+)<\/button>/g)) { const n = el('button', attrsOf(b[1])); n.textContent = b[2]; ids.get('craftTabs').appendChild(n); }
169
+ // every menu link, so the script can mark the sub-page's items current
170
+ const anchors = [...PAGE.matchAll(/<a class="bandT" href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => { const n = el('a', { href: m[1] }); n.textContent = m[2]; return n; });
171
+ const requests = [];
172
+ const fetch = (url, init = {}) => {
173
+ requests.push({ url, credentials: init.credentials });
174
+ const a = reads[url.replace(/^\/api\/bongos/, '')];
175
+ 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) });
177
+ };
178
+ const document = {
179
+ title: 'Cloud Bongos — Community',
180
+ getElementById: (id) => ids.get(id) || null,
181
+ createElement: (t) => el(t),
182
+ querySelectorAll: (sel) => { const h = (sel.match(/^a\[href="([^"]+)"\]$/) || [])[1]; return anchors.filter((a) => a.attrs.href === h); },
183
+ };
184
+ const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) }, location: { pathname } };
185
+ const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, Number, isFinite, encodeURIComponent };
186
+ vm.createContext(sandbox);
187
+ vm.runInContext(SCRIPT, sandbox, { filename: 'community.html' });
188
+ await settle();
189
+ const tab = (value) => kids(ids.get('craftTabs')).find((b) => b.attrs['data-craft'] === value);
190
+ const craft = async (value) => { for (const f of ids.get('craftTabs')._on.click || []) f({ target: tab(value) }); await settle(); };
191
+ const pick = async (value) => { ids.get('boardWindow').value = value; for (const f of ids.get('boardWindow')._on.change || []) f({}); await settle(); };
192
+ const submit = async () => { const e = { preventDefault() { e.prevented = true; } }; for (const f of ids.get('findForm')._on.submit || []) f(e); await settle(); return e; };
193
+ const current = () => anchors.filter((a) => a.attrs['aria-current'] === 'page').map((a) => a.attrs.href);
194
+ return { ids, requests, document, gets: () => requests.map((r) => r.url), tab, craft, pick, submit, current, $: (id) => ids.get(id) };
195
+ }
196
+
197
+ test('each path shows its one section, marks its menu items current and names itself; any other shape is the leaderboard', async () => {
198
+ for (const [pathname, title, section] of [['/community', 'Leaderboard', 'board'], ['/community/', 'Leaderboard', 'board'], ['/community/find', 'Find a builder', 'find'], ['/community/recruiters/', 'Open to recruiters', 'recruit']]) {
199
+ const ui = await boot({ pathname });
200
+ assert.deepEqual(['board', 'find', 'recruit'].filter((s) => !ui.$(s).hidden), [section], `${pathname}: one section`);
201
+ const href = SUBPAGES.find(([, t]) => t === title)[0];
202
+ assert.deepEqual(ui.current(), [href, href], `${pathname}: its Community item and its folded twin in the Projects band`);
203
+ assert.equal(ui.document.title, `Cloud Bongos — Community · ${title}`);
204
+ assert.equal(ui.$('signinTop').attrs.href, `/api/bongos/auth/web/start?return=${href}&repos=1`, 'signing in comes back to this sub-page');
205
+ }
206
+ });
207
+
208
+ test('the leaderboard, signed out: the all-time board in the server’s order with the server’s places, and nothing asked about the viewer', async () => {
209
+ const ui = await boot();
210
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all']);
211
+ assert.ok(ui.requests.every((r) => r.credentials === 'same-origin'));
212
+ assert.equal(ui.$('boardLoading').hidden, true);
213
+ assert.equal(ui.$('boardFailed').hidden, true);
214
+ assert.equal(ui.$('boardEmpty').hidden, true);
215
+ const [ana, bo] = kids(ui.$('boardList'));
216
+ assert.deepEqual([find(ana, 'commPlace').textContent, find(bo, 'commPlace').textContent], ['26', '27'], 'the place is the server’s, never renumbered');
217
+ assert.equal(find(ana, 'mineRow__name').textContent, 'ANA');
218
+ assert.equal(find(ana, 'mineRow__host').textContent, '@ana');
219
+ assert.equal(find(ana, 'commNum').textContent, (1200).toLocaleString());
220
+ assert.equal(find(ana, 'commUnit').textContent, 'credits');
221
+ assert.equal(find(ana, 'mineRow__hint').textContent, '2 works shipped · 1 karma');
222
+ assert.equal(find(bo, 'commNum').textContent, (1500).toLocaleString(), 'a higher number below stays below: the server ranked it');
223
+ assert.equal(find(bo, 'mineRow__name').textContent, 'bo', 'no display name: the handle stands in');
224
+ assert.equal(find(bo, 'connAv').tag, 'span', 'an avatar that is not http(s) is never loaded');
225
+ assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
226
+ assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
227
+ 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');
229
+ });
230
+
231
+ test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
232
+ const ui = await boot();
233
+ await ui.pick('30d');
234
+ assert.equal(ui.gets().pop(), '/api/bongos/community/leaderboard?window=30d');
235
+ assert.equal(ui.$('boardWindow').value, '30d');
236
+ assert.equal(ui.$('boardNote').textContent, 'Credits earned in the last 30 days, across every project.');
237
+ assert.deepEqual(kids(ui.$('boardList')).map((r) => find(r, 'mineRow__host').textContent), ['@cy']);
238
+ const before = ui.requests.length;
239
+ await ui.pick('30d');
240
+ assert.equal(ui.requests.length, before, 'picking the window already shown reads nothing');
241
+ await ui.pick('1d');
242
+ assert.equal(ui.requests.length, before, 'a window the route does not know is never asked for');
243
+ await ui.pick('all');
244
+ await ui.craft('artist');
245
+ assert.equal(ui.gets().pop(), '/api/bongos/community/leaderboard?window=all&discipline=artist');
246
+ assert.equal(ui.tab('artist').attrs['aria-pressed'], 'true');
247
+ assert.equal(ui.tab('').attrs['aria-pressed'], 'false');
248
+ assert.equal(ui.$('boardEmpty').hidden, false);
249
+ assert.equal(ui.$('boardEmpty').textContent, 'No artists on the leaderboard yet.');
250
+ assert.equal(kids(ui.$('boardList')).length, 0);
251
+ });
252
+
253
+ test('a failed board is an alert, never an empty board; a rate limit says to wait', async () => {
254
+ const reads = READS();
255
+ reads['/community/leaderboard?window=all'] = { status: 500, body: {} };
256
+ const failed = await boot({ reads });
257
+ assert.equal(failed.$('boardFailed').hidden, false);
258
+ assert.equal(failed.$('boardFailed').textContent, 'The leaderboard could not be read. Reload the page to try again.');
259
+ assert.equal(failed.$('boardEmpty').hidden, true, 'a failure never reads as nobody on the board');
260
+ reads['/community/leaderboard?window=all'] = { status: 429, body: { error: { code: 'rate_limited' } } };
261
+ assert.match((await boot({ reads })).$('boardFailed').textContent, /^Too many requests from your network just now\./);
262
+ reads['/community/leaderboard?window=all'] = { reject: true };
263
+ assert.equal((await boot({ reads })).$('boardFailed').hidden, false, 'offline is a failure too');
264
+ });
265
+
266
+ test('find: nothing read on arrival; too short is refused before any read; a hidden builder is found by name with no craft line', async () => {
267
+ const ui = await boot({ pathname: '/community/find' });
268
+ assert.deepEqual(ui.requests, [], 'the search page reads nothing until asked');
269
+ ui.$('findQ').value = ' p ';
270
+ const e = await ui.submit();
271
+ assert.ok(e.prevented, 'the form never navigates');
272
+ assert.deepEqual(ui.requests, []);
273
+ assert.equal(ui.$('findStatus').textContent, 'Type at least 2 letters of a name.');
274
+ ui.$('findQ').value = 'pri';
275
+ await ui.submit();
276
+ assert.equal(ui.gets().pop(), '/api/bongos/community/search?q=pri');
277
+ assert.equal(ui.$('findStatus').textContent, '2 builders found.');
278
+ const [priya, hidden] = kids(ui.$('findList'));
279
+ assert.equal(find(priya, 'mineRow__hint').textContent, 'engineer · designer');
280
+ assert.equal(find(hidden, 'mineRow__name').textContent, 'Pri <b>H</b>', 'textContent: a name is never markup');
281
+ assert.equal(find(hidden, 'mineRow__hint'), null, 'disciplines null (hidden numbers): nothing said about their craft');
282
+ assert.equal(ui.$('findBtn').disabled, false);
283
+ ui.$('findCraft').value = 'artist';
284
+ await ui.submit();
285
+ assert.equal(ui.gets().pop(), '/api/bongos/community/search?q=pri&discipline=artist');
286
+ assert.equal(ui.$('findStatus').textContent, 'No builder by that name in that role.');
287
+ assert.equal(kids(ui.$('findList')).length, 0);
288
+ });
289
+
290
+ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as name, face and login', async () => {
291
+ const at = '/community/recruiters';
292
+ const shown = (ui) => ['recruitLoading', 'recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty'].filter((s) => !ui.$(s).hidden);
293
+ const out = await boot({ pathname: at });
294
+ assert.deepEqual(out.requests, [], 'signed out, the door is not even asked');
295
+ assert.deepEqual(shown(out), ['recruitDoor']);
296
+ const refused = await boot({ pathname: at, me: { github_login: 'someone' } });
297
+ assert.deepEqual(refused.gets(), ['/api/bongos/scouting'], 'signed in: the route’s door decides, and the board is not read here');
298
+ assert.deepEqual(shown(refused), ['recruitClosed'], 'a viewer the door does not admit is told it is for project owners');
299
+ assert.equal(kids(refused.$('recruitList')).length, 0);
300
+ const reads = READS();
301
+ reads['/scouting'] = { status: 200, body: {
302
+ builders: [{ github_id: '9', github_login: 'ranked', display_name: 'Ranked One', total_credits: 777777, total_tasks_shipped: 4242 }],
303
+ name_only: [{ github_login: 'kemi-a', display_name: 'Kemi', avatar_url: 'https://avatars.example/k' }, { github_login: 'yuki-t', display_name: null, avatar_url: null }],
304
+ } };
305
+ const admitted = await boot({ pathname: at, me: { github_login: 'owner' }, reads });
306
+ assert.deepEqual(shown(admitted), []);
307
+ const [kemi, yuki] = kids(admitted.$('recruitList'));
308
+ assert.equal(find(kemi, 'mineRow__name').textContent, 'Kemi');
309
+ assert.equal(find(kemi, 'mineRow__host').textContent, 'GitHub: kemi-a');
310
+ assert.equal(find(yuki, 'mineRow__name').textContent, 'yuki-t');
311
+ assert.equal(find(kemi, 'mineRow__hint'), null, 'no numbers, no crafts: the name-only tier is a name');
312
+ assert.doesNotMatch(admitted.$('recruitList').textContent, /Ranked One|777|4242/, 'the ranked half is never drawn');
313
+ reads['/scouting'] = { status: 200, body: { builders: [], name_only: [] } };
314
+ assert.deepEqual(shown(await boot({ pathname: at, me: { github_login: 'owner' }, reads })), ['recruitEmpty']);
315
+ reads['/scouting'] = { status: 401, body: {} };
316
+ assert.deepEqual(shown(await boot({ pathname: at, me: { github_login: 'stale' }, reads })), ['recruitDoor'], 'an expired session is a door');
317
+ reads['/scouting'] = { status: 500, body: {} };
318
+ const failed = await boot({ pathname: at, me: { github_login: 'owner' }, reads });
319
+ assert.deepEqual(shown(failed), ['recruitFailed']);
320
+ assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
321
+ });
322
+
323
+ // ── 3. every bar carries the Community menu ──────────────────────────────────
324
+
325
+ const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
326
+ const HUB_ONLY = ['account.html', 'connections.html', 'invitations.html', 'guild.html', 'community.html'];
327
+ const wrap = (apex, s) => (apex ? `<!--apex-only:start-->${s}<!--apex-only:end-->` : s);
328
+ const ITEMS = [
329
+ ['/community', 'Leaderboard', 'who earned the most, across every project'],
330
+ ['/community/find', 'Find a builder', 'anyone building here, by name or role'],
331
+ ['/community/recruiters', 'Open to recruiters', 'who is open to recruiting, for project owners'],
332
+ ];
333
+
334
+ test('every page that wears the hub’s bar carries the Community menu before Projects; apex-only where the page serves a child', () => {
335
+ const { stripApexOnly } = require('../src/platform-server.js');
336
+ const trig = '<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity"';
337
+ 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('');
340
+ for (const f of [...SERVES_A_CHILD, ...HUB_ONLY]) {
341
+ const html = read(f);
342
+ const apex = SERVES_A_CHILD.includes(f);
343
+ const at = html.indexOf(apex ? `<!--apex-only:start-->${trig}` : trig);
344
+ assert.ok(at > 0, `${f}: the Community trigger`);
345
+ assert.ok(html.indexOf('id="projTrig"') > at, `${f}: Community comes before Projects`);
346
+ assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its three sub-pages`);
347
+ assert.ok(html.indexOf('id="menuCommunity"') < html.indexOf('id="menuProjects"'), `${f}: the bands in the bar’s order`);
348
+ 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`);
350
+ }
351
+ });
352
+
353
+ 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
+ const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
355
+ 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');
356
+ assert.match(css, /\n\.bandFold\{display:none;\}\n/, 'above 480 the Projects band does not repeat the menu');
357
+ assert.match(css.split('@media (max-width:480px){')[1].split('\n}')[0], /\.commTrig\{display:none;\}\s*\.bandFold\{display:block;\}/);
358
+ const index = read('index.html');
359
+ assert.match(index, /\n\.bandFold\{display:none\}\n/);
360
+ assert.match(index.split('@media (max-width:900px){')[1], /^[\s\S]*?\.commTrig\{display:none\}\s*\.bandFold\{display:block\}/);
361
+ for (const f of ['assets/hub-bar.js', 'projects.html', 'index.html']) {
362
+ assert.match(read(f), /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null; \}\);/, `${f}: the arrows skip a cell CSS does not draw`);
363
+ }
364
+ assert.doesNotMatch(css + index, /\.barLink/, 'the plain link this menu replaced is gone');
365
+ });
366
+
367
+ // ── 4. the routes ────────────────────────────────────────────────────────────
368
+
369
+ const { buildPlatformApp } = require('../src/platform-server.js');
370
+ const modules = require('../src/modules.js');
371
+ let server;
372
+ let port;
373
+ before(async () => {
374
+ server = http.createServer(buildPlatformApp());
375
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
376
+ port = server.address().port;
377
+ });
378
+ after(async () => {
379
+ modules.setModuleOverrides({});
380
+ await new Promise((resolve) => server.close(resolve));
381
+ });
382
+ function get(pathname, host) {
383
+ return new Promise((resolve, reject) => {
384
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
385
+ let body = '';
386
+ res.on('data', (c) => (body += c));
387
+ res.on('end', () => resolve({ status: res.statusCode, body }));
388
+ }).on('error', reject);
389
+ });
390
+ }
391
+
392
+ test('GET /community, /community/find and /community/recruiters: one stamped page on the hub’s apex; never on a child or another host', async () => {
393
+ modules.setModuleOverrides({ 'platform-identity': false });
394
+ for (const [p] of SUBPAGES) assert.equal((await get(p, 'game.test.local')).status, 404, `${p}: a child instance has none of the routes these pages read`);
395
+ modules.setModuleOverrides({ 'platform-identity': true });
396
+ const bodies = [];
397
+ for (const [p] of SUBPAGES) {
398
+ const hub = await get(p, 'game.test.local');
399
+ assert.equal(hub.status, 200, p);
400
+ assert.match(hub.body, /— Community<\/title>/);
401
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/, 'every token stamped, the unit word included');
402
+ bodies.push(hub.body);
403
+ }
404
+ assert.ok(bodies.every((b) => b === bodies[0]), 'byte-identical for every sub-page: the script picks the view');
405
+ assert.doesNotMatch((await get('/community/everyone', 'game.test.local')).body, /— Community<\/title>/, 'only the three sub-pages');
406
+ for (const host of ['builders.test.local', 'status.test.local']) {
407
+ assert.doesNotMatch((await get('/community', host)).body, /— Community<\/title>/, `${host} must not serve /community`);
408
+ }
409
+ });
410
+
411
+ // ── 5. the kit ───────────────────────────────────────────────────────────────
412
+
413
+ test('the kit renders each sub-page for everyone, the empty board, and a project owner’s recruiter list', () => {
414
+ const json = (f) => JSON.parse(read(f));
415
+ const states = json('community.states.json');
416
+ assert.equal(states.page, '/community');
417
+ assert.deepEqual(states.modes, ['dark']);
418
+ for (const s of ['out', 'out-30d', 'out-artists', 'out-find', 'out-find-search', 'out-recruiters', 'in-recruiters', 'in']) assert.ok(states.states[s], `kit state ${s}`);
419
+ assert.equal(states.states['out-find'].url, '/community/find');
420
+ assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
421
+ 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']]) {
423
+ assert.equal(json(file).stub.fixtures[name], fixture);
424
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
425
+ }
426
+ assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
427
+ });
428
+
429
+ function stub(env = {}) {
430
+ return new Promise((resolve, reject) => {
431
+ const child = spawn(process.execPath, [path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js')], {
432
+ env: { ...process.env, STUB_PORT: '0', ...env }, stdio: ['ignore', 'pipe', 'inherit'],
433
+ });
434
+ let out = '';
435
+ child.stdout.on('data', (c) => {
436
+ out += c;
437
+ const m = out.match(/stub on (http:\/\/127\.0\.0\.1:\d+)\//);
438
+ if (m) resolve({ origin: m[1], stop: () => child.kill() });
439
+ });
440
+ child.on('exit', (code) => reject(new Error(`stub exited ${code}: ${out}`)));
441
+ });
442
+ }
443
+ async function call(origin, p) {
444
+ const res = await globalThis.fetch(origin + '/api/bongos' + p);
445
+ return { status: res.status, body: await res.json() };
446
+ }
447
+
448
+ test('the kit stub answers the board, the search and the recruiting door the way the routes do', async () => {
449
+ const s = await stub();
450
+ try {
451
+ const all = await call(s.origin, '/community/leaderboard');
452
+ assert.equal(all.body.window, 'all');
453
+ assert.deepEqual(all.body.results.map((r) => r.place), all.body.results.map((_, i) => i + 1));
454
+ assert.equal(all.body.count, all.body.results.length);
455
+ const artists = await call(s.origin, '/community/leaderboard?window=all&discipline=artist');
456
+ assert.ok(artists.body.results.length && artists.body.results.every((r) => r.disciplines.includes('artist')));
457
+ assert.equal(artists.body.results[0].place, 1, 'a filtered board is numbered on its own');
458
+ assert.equal((await call(s.origin, '/community/leaderboard?window=1d')).body.error.code, 'bad_window');
459
+ assert.equal((await call(s.origin, '/community/search?q=p')).body.error.code, 'query_too_short');
460
+ const byName = await call(s.origin, '/community/search?q=sam');
461
+ assert.deepEqual(byName.body.results.map((r) => [r.handle, r.disciplines]), [['sam-rivera', null]], 'a hidden builder is found by name');
462
+ assert.equal((await call(s.origin, '/community/search?q=sam&discipline=engineer')).body.results.length, 0, 'and drops out of a craft search');
463
+ assert.equal((await call(s.origin, '/scouting')).status, 401, 'signed out, the door answers 401');
464
+ } finally { s.stop(); }
465
+ const member = await stub({ AUTH: '1' });
466
+ try {
467
+ assert.equal((await call(member.origin, '/scouting')).status, 403, 'signed in, most builders are refused');
468
+ } finally { member.stop(); }
469
+ const owner = await stub({ AUTH: '1', FIXTURE_ALIAS: JSON.stringify({ scouting: 'scouting-recruiter' }) });
470
+ try {
471
+ const r = await call(owner.origin, '/scouting');
472
+ assert.equal(r.status, 200);
473
+ assert.ok(r.body.name_only.every((n) => Object.keys(n).sort().join() === 'avatar_url,display_name,github_login'), 'the name-only tier is three columns');
474
+ } finally { owner.stop(); }
475
+ });
@@ -193,7 +193,10 @@ for (const p of PAGES) {
193
193
  assert.deepEqual(bandLinks(bandOf(html, 'menuProjects')), [
194
194
  ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&amp;sub=table'],
195
195
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
196
+ // the Community menu's three sub-pages, folded into the band under 480px (task 1003363); apex-only
197
+ ['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
196
198
  ], 'this page has no views of its own, so every item names /projects in full');
199
+ assert.deepEqual(bandLinks(bandOf(stripApexOnly(html, false), 'menuProjects')).map((l) => l[0]), ['Sky', 'Table', 'Your projects', 'New project'], 'a child has no /community');
197
200
  assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
198
201
  ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
199
202
  ]);
@@ -594,7 +594,7 @@ test('the bands are one disclosure: Escape returns focus, arrows cycle, Tab out
594
594
  assert.match(CODE, /else openMenu\(t, ev\.detail === 0\);/, 'a keyboard-fired click is told apart from a pointer');
595
595
  assert.match(CODE, /if \(ev\.key === 'Escape'\) \{ closeMenus\(\); return; \}/);
596
596
  assert.match(CODE, /if \(ev\.key !== 'ArrowDown' && ev\.key !== 'ArrowUp'\) return;/);
597
- assert.match(CODE, /return !a\.closest\('\[hidden\]'\);/, 'the arrows skip the hidden Your-projects cell');
597
+ assert.match(CODE, /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null;/, 'the arrows skip the hidden Your-projects cell, and the Community cell above 900px, which CSS does not draw (task 1003363)');
598
598
  assert.match(CODE, /b\.addEventListener\('focusout', function \(ev\) \{[\s\S]*?if \(!to \|\| b\.contains\(to\) \|\| \(to\.closest && to\.closest\('\.trig'\)\)\) return;[\s\S]*?closeMenus\(\);/, 'Tab out closes; a null relatedTarget is left alone');
599
599
  assert.match(CODE, /if \(ev\.target\.closest && \(ev\.target\.closest\('\.menuBand'\) \|\| ev\.target\.closest\('\.trig'\)\)\) return;[\s\S]*?closeMenus\(\);/, 'a click outside closes');
600
600
  assert.doesNotMatch(HTML, /role="menu"|aria-haspopup/, 'plain links in a disclosure — what is announced is what ships');
@@ -25,8 +25,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
25
25
  assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
26
26
  assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
27
27
  // the account page links the hub's sheet too since task 1004259, the guild page since task 1002301,
28
- // the invitations page since task 1004276, and the connections page since task 1004278
29
- assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'connections.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
28
+ // the invitations page since task 1004276, the connections page since task 1004278, and the community page since task 1003363
29
+ assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'community.states.json', 'connections.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
30
30
  });
31
31
 
32
32
  test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {