@bongos/core 1.20.67 → 1.20.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,17 @@
1
1
  {
2
- "_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the four sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
2
+ "_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the four sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
3
3
  "page": "/account",
4
4
  "surface": "public-landing",
5
5
  "modes": ["dark"],
6
6
  "modeQuery": false,
7
- "ignoreRequests": ["/api/(gds|bongos)/me/cross-project$"],
8
- "ignoreConsole": ["/api/(gds|bongos)/me/cross-project", "\\b401\\b", "Unauthorized"],
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/cross-project$"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
9
9
  "ignoreControls": [".vh"],
10
10
  "states": {
11
- "out": { "auth": false, "actions": [["wait", 300]], "expect": { "visible": ["#door", "#signin"], "hidden": ["#acctBody", "#aWho", "#loading"] } },
12
- "in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#signOut"], "text": [["#whoName", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
11
+ "out": { "auth": false, "actions": [["wait", 300]], "expect": { "visible": ["#door", "#signin", "#signinTop", "#projTrig"], "hidden": ["#acctBody", "#who", "#loading"] } },
12
+ "in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#who"], "text": [["#whoLogin", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading", "#signinTop", "#menuProjects", "#menuProfile"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
13
+ "in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
14
+ "in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
13
15
  "in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "text": [["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
14
16
  },
15
17
  "reducedMotion": { "auth": true, "actions": [["wait", 300]] }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.67",
3
+ "version": "1.20.68",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.67",
9
+ "version": "1.20.68",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.67",
3
+ "version": "1.20.68",
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",
@@ -8531,5 +8531,11 @@
8531
8531
  "id": "1004487",
8532
8532
  "text": "The hall no longer offers a Claim button on work the system will refuse. If a task gets a new prerequisite after it was opened up for claiming, its page and its goal card now say it is waiting on that other task — and name whi"
8533
8533
  }
8534
+ ],
8535
+ "1.20.68": [
8536
+ {
8537
+ "id": "1004455",
8538
+ "text": "The account page on cloudbongos.com now has the same top bar as the projects page — the Projects menu and your profile menu (My projects, Account, Sign out) — so moving between the two no longer swaps the whole header."
8539
+ }
8534
8540
  ]
8535
8541
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.67'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.68'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -13,7 +13,12 @@
13
13
  // 4. the route: served on the apex only where platform-identity runs, never on
14
14
  // the builders/status hosts, and never on a child instance;
15
15
  // 5. the hall's side: the two privacy sections are gone, one link-out row
16
- // remains, and its hub origin is a bare https origin or nothing.
16
+ // remains, and its hub origin is a bare https origin or nothing;
17
+ // 6. the bar (task 1004455): the hub's own bar and two bands rather than a
18
+ // header of its own — the same markup as projects.html, cosmos.css's rules,
19
+ // no map search, links into /projects, Account as the current place — and
20
+ // the bands EXECUTED: the chip from GET /me, open/close, Escape, the arrows,
21
+ // a click outside, and sign out as the POST.
17
22
  //
18
23
  // DB-free. Run: node --test tests/hub_account_page.mjs
19
24
 
@@ -32,6 +37,8 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
32
37
  const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
33
38
  const PAGE = fs.readFileSync(path.join(LANDING, 'account.html'), 'utf8').replace(/\r\n/g, '\n');
34
39
  const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
40
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
41
+ const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
35
42
 
36
43
  const pi = require('../modules/platform-identity/platform-identity.js');
37
44
  const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
@@ -69,10 +76,64 @@ test('the script builds no markup from strings, and the controls are real markup
69
76
  test('the door returns here, and the page calls only its own-scoped routes', () => {
70
77
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account"/, 'sign-in lands back on /account');
71
78
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
72
- assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
79
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /me', 'GET /me/cross-project','PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
73
80
  assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
74
81
  });
75
82
 
83
+ // ── 1b. the bar is the hub's (task 1004455) ──────────────────────────────────
84
+
85
+ const bandOf = (html, id) => {
86
+ const m = html.match(new RegExp(`<div class="menuBand" id="${id}"[\\s\\S]*?\\n</div>`));
87
+ assert.ok(m, `#${id} is a whole band`);
88
+ return m[0];
89
+ };
90
+ const bandLinks = (band) => [...band.matchAll(/<a class="bandT[^"]*" href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => [m[2], m[1]]);
91
+
92
+ test('the bar is the hub’s own: the same markup as /projects, no header of its own, no map search', () => {
93
+ assert.match(PAGE, /<header class="shell bar">/);
94
+ assert.doesNotMatch(PAGE, /<header class="top">|class="btn-space"|id="aWho"|id="signOut"/, 'the simple header and its in-page sign-out line are gone');
95
+ assert.doesNotMatch(PAGE, /id="(mapQ|qWrap|qToggle|onlyMine)"/, 'the search only filters the map, so it stays on the map');
96
+ // byte-equal pieces of projects.html: a rename on either page fails here
97
+ for (const piece of [
98
+ '<a class="barMark" id="barMark" href="/">',
99
+ '<ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>',
100
+ '<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>',
101
+ '<button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden>',
102
+ '<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
103
+ '<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
104
+ '<div class="bandIn bandProfile">',
105
+ '<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
106
+ ]) {
107
+ assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
108
+ assert.ok(PAGE.includes(piece), `account.html does not carry the hub's: ${piece}`);
109
+ }
110
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
111
+ assert.match(PAGE, /<a class="skip vh" href="#acct">/);
112
+ assert.match(PAGE, /<main class="acct" id="acct">/, 'the skip link has its landmark');
113
+ });
114
+
115
+ test('the bands say what the hub’s say and lead into /projects; Account is the one current place', () => {
116
+ const clauses = (band) => [...band.matchAll(/>([^<]+)<\/a><span class="bandC">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
117
+ assert.deepEqual(clauses(bandOf(PAGE, 'menuProjects')), clauses(bandOf(HUB, 'menuProjects')), 'the Projects band’s items and clauses are the hub’s');
118
+ assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
119
+ ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&amp;sub=table'],
120
+ ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
121
+ ], 'this page has no views of its own, so every item names /projects in full');
122
+ assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
123
+ ['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
124
+ ]);
125
+ assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/account" aria-current="page">Account']);
126
+ });
127
+
128
+ test('the bar is not restyled here: the page takes cosmos.css’s rules', () => {
129
+ const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
130
+ assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
131
+ for (const rule of ['.bar{', '.barMark{', '.trig{', '.signinDoor{', '.menuBand{', '.bandCell:has([aria-current="page"])::before{']) {
132
+ assert.ok(COSMOS.includes(rule), `cosmos.css still carries ${rule}`);
133
+ }
134
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/);
135
+ });
136
+
76
137
  // ── 2. the mirrors hold ──────────────────────────────────────────────────────
77
138
 
78
139
  test('the page’s limits are the module’s own (boundary-forced copies)', () => {
@@ -105,15 +166,42 @@ const ACCOUNT = () => ({
105
166
  function boot(answer) {
106
167
  const els = new Map();
107
168
  const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
108
- const el = (id, hidden) => listeners({
109
- id, hidden, value: '', textContent: '', className: '', disabled: false, style: {},
169
+ const el = (id, hidden, attrs) => listeners({
170
+ id, hidden, attrs, value: '', textContent: '', className: '', disabled: false, style: {},
110
171
  appendChild(c) { this.textContent += c.textContent; return c; },
111
- getAttribute() { return null; },
112
- focus() { dom.focused = id; },
172
+ getAttribute(n) { return n in attrs ? attrs[n] : null; },
173
+ setAttribute(n, v) { attrs[n] = v; },
174
+ closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
175
+ focus() { dom.focused = id; document.activeElement = this; },
113
176
  });
114
177
  for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
115
- els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4])));
178
+ const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
179
+ els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
116
180
  }
181
+ // The bar: the two triggers, and each band with its links in markup order. An
182
+ // item's cell is its <li>; only #mineCell carries an id, so only it can hide.
183
+ const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
184
+ const bands = [...PAGE.matchAll(/<div class="menuBand" id="(\w+)"[\s\S]*?\n<\/div>/g)].map((m) => {
185
+ const band = els.get(m[1]);
186
+ band.items = [...m[0].matchAll(/<li class="bandCell"(?: id="(\w+)")?><a class="([^"]+)" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g)].map((a) => ({
187
+ cls: a[2], href: a[3], text: a[5], cell: els.get(a[1]) || { hidden: false },
188
+ focus() { document.activeElement = this; },
189
+ closest(sel) {
190
+ if (sel === 'a') return this;
191
+ if (sel === '[aria-current="page"]') return a[4].includes(' aria-current="page"') ? this : null;
192
+ if (sel === 'a.signout') return /\bsignout\b/.test(this.cls) ? this : null;
193
+ if (sel === '.menuBand') return band;
194
+ if (sel === '[hidden]') return this.cell.hidden ? this.cell : null;
195
+ return null;
196
+ },
197
+ }));
198
+ band.contains = (x) => band.items.includes(x);
199
+ band.querySelector = () => band.items.find((i) => !i.cell.hidden) || null;
200
+ band.querySelectorAll = () => band.items;
201
+ return band;
202
+ });
203
+ const docOn = {};
204
+ const event = (extra) => ({ prevented: false, preventDefault() { this.prevented = true; }, ...extra });
117
205
  const radios = [...PAGE.matchAll(/<input type="radio" name="(\w+)" value="(\w+)">/g)]
118
206
  .map((m) => listeners({ name: m[1], value: m[2], checked: false }));
119
207
  const links = [...PAGE.matchAll(/id="(fLink\w+)" data-link="(\w+)"/g)].map((m) => {
@@ -123,7 +211,17 @@ function boot(answer) {
123
211
  });
124
212
  const calls = [];
125
213
  const dom = {
126
- els, radios, calls, focused: null,
214
+ els, radios, calls, focused: null, assigned: null,
215
+ active: () => document.activeElement,
216
+ // a click bubbles: the target's own listeners, its band's, then the document's
217
+ click(target, detail = 1) {
218
+ const band = bands.find((b) => b.items.includes(target));
219
+ const ev = event({ target, detail });
220
+ for (const f of [...(target._on ? target._on.click || [] : []), ...(band ? band._on.click || [] : []), ...(docOn.click || [])]) f(ev);
221
+ return ev;
222
+ },
223
+ key(key) { const ev = event({ key }); for (const f of docOn.keydown || []) f(ev); return ev; },
224
+ focusout(band, relatedTarget) { for (const f of band._on.focusout || []) f({ relatedTarget }); },
127
225
  pick(name, value) {
128
226
  for (const r of radios.filter((x) => x.name === name)) r.checked = r.value === value;
129
227
  for (const f of radios.find((x) => x.name === name && x.value === value)._on.change || []) f({});
@@ -132,15 +230,20 @@ function boot(answer) {
132
230
  text: (id) => els.get(id).textContent,
133
231
  };
134
232
  const document = {
135
- cookie: '',
233
+ cookie: '', activeElement: null,
234
+ addEventListener: (t, f) => { (docOn[t] ||= []).push(f); },
136
235
  getElementById: (id) => els.get(id) || null,
137
236
  querySelector(sel) {
237
+ if (sel === '.menuBand:not([hidden])') return bands.find((b) => !b.hidden) || null;
238
+ if (sel === '.trig[aria-expanded="true"]') return trigs.find((t) => t.attrs['aria-expanded'] === 'true') || null;
138
239
  const m = sel.match(/^input\[name="(\w+)"\]:checked$/);
139
240
  if (!m) throw new Error(`unstubbed selector: ${sel}`);
140
241
  return radios.find((r) => r.name === m[1] && r.checked) || null;
141
242
  },
142
243
  querySelectorAll(sel) {
143
244
  if (sel === '#profileForm [data-link]') return links;
245
+ if (sel === '.trig') return trigs;
246
+ if (sel === '.menuBand') return bands;
144
247
  const names = [...sel.matchAll(/input\[name="(\w+)"\]/g)].map((m) => m[1]);
145
248
  if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
146
249
  return radios.filter((r) => names.includes(r.name));
@@ -156,7 +259,7 @@ function boot(answer) {
156
259
  return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
157
260
  };
158
261
  const location = { hostname: 'cloudbongos.com', pathname: '/account' };
159
- vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign() {} } }, URL, Array, Object, JSON, Promise });
262
+ vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
160
263
  return dom;
161
264
  }
162
265
  const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
@@ -166,8 +269,10 @@ test('signed out: a 401 opens the door and shows nothing else', async () => {
166
269
  await settle();
167
270
  assert.equal(dom.els.get('door').hidden, false);
168
271
  assert.equal(dom.els.get('acctBody').hidden, true);
169
- assert.equal(dom.els.get('aWho').hidden, true);
170
272
  assert.equal(dom.els.get('loading').hidden, true);
273
+ assert.equal(dom.els.get('who').hidden, true, 'no chip without a session');
274
+ assert.equal(dom.els.get('signinTop').hidden, false, 'the sign-in door takes its slot');
275
+ assert.equal(dom.els.get('mineCell').hidden, true, 'and the Projects band offers no Your projects');
171
276
  });
172
277
 
173
278
  test('signed in: every control is painted from the server’s answer', async () => {
@@ -175,7 +280,6 @@ test('signed in: every control is painted from the server’s answer', async ()
175
280
  await settle();
176
281
  assert.equal(dom.els.get('acctBody').hidden, false);
177
282
  assert.equal(dom.els.get('door').hidden, true);
178
- assert.equal(dom.text('whoName'), 'octo');
179
283
  assert.equal(dom.els.get('fName').value, 'Octo');
180
284
  assert.equal(dom.els.get('fBio').value, 'Old bio.');
181
285
  assert.equal(dom.els.get('fLinkWebsite').value, 'https://octo.example/');
@@ -315,6 +419,125 @@ test('a session that expired mid-edit reopens the door instead of failing silent
315
419
  await settle();
316
420
  assert.equal(dom.text('profileMsg'), 'Your session expired — sign in again to save.');
317
421
  assert.equal(dom.els.get('door').hidden, false);
422
+ assert.equal(dom.els.get('who').hidden, true, 'the bar stops naming a session that is gone');
423
+ assert.equal(dom.els.get('signinTop').hidden, false);
424
+ });
425
+
426
+ // ── 3b. the bar, executed (task 1004455) ─────────────────────────────────────
427
+
428
+ const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
429
+ const signedIn = (m, p) => (m !== 'GET' ? { status: 200, data: {} } : { status: 200, data: p === '/me' ? ME : ACCOUNT() });
430
+ const item = (band, text) => band.items.find((i) => i.text === text);
431
+
432
+ test('the chip is the hub’s identity read: GET /me fills the trigger and the profile band', async () => {
433
+ const dom = boot(signedIn);
434
+ await settle();
435
+ assert.ok(dom.calls.some((c) => c.method === 'GET' && c.path === '/me'));
436
+ const who = dom.els.get('who');
437
+ assert.equal(who.hidden, false);
438
+ assert.equal(dom.els.get('signinTop').hidden, true);
439
+ assert.equal(dom.text('whoLogin'), 'octocat', 'the GitHub login, as on /projects — not the hub handle');
440
+ assert.equal(dom.text('bandWhoLogin'), 'octocat');
441
+ assert.equal(who.getAttribute('aria-label'), 'Account, octocat');
442
+ for (const id of ['whoImg', 'bandWhoImg']) {
443
+ assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
444
+ assert.equal(dom.els.get(id).hidden, false);
445
+ }
446
+ assert.equal(dom.els.get('mineCell').hidden, false);
447
+ });
448
+
449
+ test('an account with no avatar gets a chip with no picture rather than a broken one', async () => {
450
+ const dom = boot((m, p) => (p === '/me' ? { status: 200, data: { builder: { github_login: 'octocat' } } } : signedIn(m, p)));
451
+ await settle();
452
+ assert.equal(dom.els.get('whoImg').hidden, true);
453
+ assert.equal(dom.els.get('whoImg').src, undefined);
454
+ assert.equal(dom.text('whoLogin'), 'octocat');
455
+ });
456
+
457
+ test('a failed identity read is not a sign-out: the chip falls back to the account’s own handle', async () => {
458
+ const dom = boot((m, p) => (p === '/me' ? { status: 500, data: null } : signedIn(m, p)));
459
+ await settle();
460
+ assert.equal(dom.els.get('acctBody').hidden, false);
461
+ assert.equal(dom.els.get('who').hidden, false, 'the profile band — and Sign out in it — stays reachable');
462
+ assert.equal(dom.els.get('signinTop').hidden, true, 'no Sign in door over a page that is signed in');
463
+ assert.equal(dom.text('whoLogin'), 'octo');
464
+ assert.equal(dom.els.get('whoImg').hidden, true);
465
+ });
466
+
467
+ test('the bands open and close the way the hub’s do: toggle, one at a time, a click outside, Tab out', async () => {
468
+ const dom = boot(signedIn);
469
+ await settle();
470
+ const [projTrig, who] = [dom.els.get('projTrig'), dom.els.get('who')];
471
+ const [projects, profile] = [dom.els.get('menuProjects'), dom.els.get('menuProfile')];
472
+ const expanded = (t) => t.getAttribute('aria-expanded');
473
+
474
+ dom.click(projTrig);
475
+ assert.deepEqual([projects.hidden, expanded(projTrig)], [false, 'true']);
476
+ assert.equal(dom.active(), null, 'a pointer open leaves focus where it was');
477
+ dom.click(projTrig);
478
+ assert.deepEqual([projects.hidden, expanded(projTrig)], [true, 'false'], 'the trigger toggles');
479
+
480
+ dom.click(projTrig);
481
+ dom.click(who);
482
+ assert.deepEqual([projects.hidden, expanded(projTrig), profile.hidden, expanded(who)], [true, 'false', false, 'true'], 'opening one closes the other');
483
+
484
+ dom.click({ closest: () => null });
485
+ assert.deepEqual([profile.hidden, expanded(who)], [true, 'false'], 'a click outside closes');
486
+
487
+ dom.click(who);
488
+ const here = dom.click(item(profile, 'Account'));
489
+ assert.equal(profile.hidden, true, 'an item activated closes its band');
490
+ assert.equal(here.prevented, true, 'Account is this page — no reload, so nothing typed is thrown away');
491
+ assert.equal(dom.calls.some((c) => c.method === 'POST'), false, 'and it is not the sign-out');
492
+
493
+ dom.click(projTrig);
494
+ dom.focusout(projects, null);
495
+ assert.equal(projects.hidden, false, 'a null relatedTarget is left to the document click');
496
+ dom.focusout(projects, dom.els.get('fName'));
497
+ assert.equal(projects.hidden, true, 'Tab out of the band closes it');
498
+ });
499
+
500
+ test('the bands by keyboard: Enter lands on the first item, the arrows walk and wrap, Escape hands focus back', async () => {
501
+ const dom = boot(signedIn);
502
+ await settle();
503
+ const projTrig = dom.els.get('projTrig');
504
+ const projects = dom.els.get('menuProjects');
505
+ assert.equal(dom.key('ArrowDown').prevented, false, 'with nothing open the keys are the page’s');
506
+
507
+ dom.click(projTrig, 0);
508
+ assert.equal(dom.active().text, 'Sky', 'detail 0 is a keyboard activation');
509
+ assert.equal(dom.key('ArrowDown').prevented, true);
510
+ assert.equal(dom.active().text, 'Table');
511
+ dom.key('ArrowUp');
512
+ dom.key('ArrowUp');
513
+ assert.equal(dom.active().text, 'New project', 'ArrowUp wraps past the first item');
514
+ dom.key('ArrowDown');
515
+ assert.equal(dom.active().text, 'Sky');
516
+ dom.key('Escape');
517
+ assert.equal(projects.hidden, true);
518
+ assert.equal(dom.active(), projTrig, 'focus was inside the band, so it returns to the trigger');
519
+ });
520
+
521
+ test('signed out, the arrows skip the hidden Your projects item', async () => {
522
+ const dom = boot(() => ({ status: 401, data: null }));
523
+ await settle();
524
+ dom.click(dom.els.get('projTrig'), 0);
525
+ dom.key('ArrowDown');
526
+ dom.key('ArrowDown');
527
+ assert.equal(dom.active().text, 'New project', 'Sky → Table → New project');
528
+ });
529
+
530
+ test('sign out is the profile band’s plain anchor upgraded to the POST, then a reload of this page', async () => {
531
+ const dom = boot((m, p) => (p === '/auth/logout' ? { status: 204, data: null } : signedIn(m, p)));
532
+ await settle();
533
+ const profile = dom.els.get('menuProfile');
534
+ assert.equal(dom.click(item(profile, 'My projects')).prevented, false, 'every other item is a real navigation');
535
+ dom.click(dom.els.get('who'));
536
+ const ev = dom.click(item(profile, 'Sign out'));
537
+ assert.equal(ev.prevented, true, 'the GET the href names would 404 and leave the session live');
538
+ await settle();
539
+ assert.deepEqual(dom.calls.at(-1), { method: 'POST', path: '/auth/logout', body: undefined });
540
+ assert.equal(dom.assigned, '/account');
318
541
  });
319
542
 
320
543
  // ── 4. the route ─────────────────────────────────────────────────────────────