@bongos/core 1.21.65 → 1.21.67

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 (53) hide show
  1. package/.bongos-core.json +114 -49
  2. package/docs/copy-inventory.md +432 -405
  3. package/docs/copy-registry.json +840 -571
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +45 -13
  7. package/docs/page-readings.json +969 -917
  8. package/modules/hall-ui/public/profile.html +4 -7
  9. package/modules/hall-ui/public/profile.js +0 -5
  10. package/modules/platform-identity/routes/my-projects.js +1 -1
  11. package/modules/public-landing/public/account.html +1 -1
  12. package/modules/public-landing/public/assets/cosmos.css +12 -10
  13. package/modules/public-landing/public/contact.html +1 -1
  14. package/modules/public-landing/public/guild.html +1 -1
  15. package/modules/public-landing/public/index-cli.states.json +20 -0
  16. package/modules/public-landing/public/index.html +68 -1
  17. package/modules/public-landing/public/index.states.json +4 -4
  18. package/modules/public-landing/public/invitations-empty.states.json +56 -0
  19. package/modules/public-landing/public/invitations-picker.states.json +84 -0
  20. package/modules/public-landing/public/invitations.html +956 -0
  21. package/modules/public-landing/public/invitations.probes.json +430 -0
  22. package/modules/public-landing/public/invitations.states.json +473 -0
  23. package/modules/public-landing/public/privacy.html +1 -1
  24. package/modules/public-landing/public/projects.html +18 -543
  25. package/modules/public-landing/public/projects.probes.json +0 -29
  26. package/modules/public-landing/public/projects.states.json +0 -6
  27. package/modules/public-landing/public/terms.html +1 -1
  28. package/modules/ui-design/kit/fixtures/bongos-cli-latest-sample.json +10 -0
  29. package/modules/ui-design/kit/fixtures/featured-invitations.json +183 -0
  30. package/modules/ui-design/kit/fixtures/guilds-empty.json +1 -0
  31. package/modules/ui-design/kit/fixtures/me-guild-engagements-empty.json +1 -0
  32. package/modules/ui-design/kit/fixtures/me-guild-requests-empty.json +1 -0
  33. package/modules/ui-design/kit/fixtures/my-projects-applied.json +29 -0
  34. package/modules/ui-design/kit/fixtures/my-projects-invites-empty.json +1 -0
  35. package/modules/ui-design/kit/serve.js +5 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +16 -0
  39. package/src/module-api.js +1 -1
  40. package/src/platform-server.js +11 -0
  41. package/tests/canonical_profile_url.mjs +23 -157
  42. package/tests/hub_account_page.mjs +1 -1
  43. package/tests/hub_engagement_cards.mjs +44 -40
  44. package/tests/hub_guild_cards.mjs +24 -22
  45. package/tests/hub_invitations_page.mjs +414 -0
  46. package/tests/join_relay_outcome.mjs +1 -1
  47. package/tests/landing_legal_pages.mjs +8 -6
  48. package/tests/landing_page.mjs +125 -3
  49. package/tests/my_projects_surface.mjs +14 -6
  50. package/tests/public_landing_projects.mjs +12 -14
  51. package/tests/render_check.mjs +3 -2
  52. package/tests/ui_design_kit.mjs +3 -3
  53. package/modules/hall-ui/public/profile-canonical.js +0 -137
@@ -9,14 +9,15 @@
9
9
  // OWN WHERE CLAUSE and throws on a placeholder or a literal comparison it did
10
10
  // not consume, so a predicate this file cannot read can never degrade into "no
11
11
  // constraint" and quietly pass. The REAL core router runs above it over a REAL
12
- // loopback socket, and the REAL browser script runs in a vm above a stub DOM.
13
- // Nothing here asserts against a canned response.
12
+ // loopback socket. Nothing here asserts against a canned response.
14
13
  //
15
- // THE FLAG IS FALSE TODAY (ADR 0214 D1), so the three render behaviours are all
16
- // no-ops in production. Testing only that would be testing nothing, so the
17
- // client tests run the SAME source with the one constant flipped — and assert
18
- // the substitution changed exactly one thing, because a mutation that misses is
19
- // the failure mode this file exists to avoid.
14
+ // THE FLAG IS FALSE (ADR 0214 D1), and the page that would flip it (goal
15
+ // 1000048 R14, task 1002365) was abandoned. So the browser half that would have
16
+ // rendered rel=canonical, the link-out and the numeric-id redirect was removed
17
+ // (task 1003451): it shipped 137 lines of permanently dead branches to every
18
+ // /profile reader. The server half stays — getPublicHandle is live, and the
19
+ // resolver is what a rebuilt page will call. The test below pins the absence,
20
+ // and fails the moment the flag flips without the browser half coming back.
20
21
  //
21
22
  // LANE: DB-free. It must run on every PR, where there is no Postgres. The last
22
23
  // test asserts that membership from the runner's own source (learning 1000218 —
@@ -26,7 +27,6 @@
26
27
  import { test } from 'node:test';
27
28
  import assert from 'node:assert/strict';
28
29
  import http from 'node:http';
29
- import vm from 'node:vm';
30
30
  import express from 'express';
31
31
  import { createRequire } from 'node:module';
32
32
  import { readFileSync } from 'node:fs';
@@ -48,7 +48,6 @@ const require = createRequire(import.meta.url);
48
48
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
49
49
  const read = (...p) => readFileSync(join(ROOT, ...p), 'utf8');
50
50
 
51
- const CLIENT_SRC = read('modules', 'hall-ui', 'public', 'profile-canonical.js');
52
51
  const MODULE_SRC = read('modules', 'platform-identity', 'canonical-profile.js');
53
52
  const PROFILE_JS = read('modules', 'hall-ui', 'public', 'profile.js');
54
53
  const PROFILE_HTML = read('modules', 'hall-ui', 'public', 'profile.html');
@@ -429,154 +428,21 @@ test('an older hub provider without getPublicHandle degrades to null instead of
429
428
  test.after(() => server.close());
430
429
 
431
430
  // ===========================================================================
432
- // The browser script — the three behaviours, run for real in a vm
431
+ // The wiring: the page and the stamp
433
432
  // ===========================================================================
434
433
 
435
- const LIVE_SRC = CLIENT_SRC.replace('const CANONICAL_PROFILE_PAGE_LIVE = false;', 'const CANONICAL_PROFILE_PAGE_LIVE = true;');
436
-
437
- test('the live-source substitution changes exactly one thing (the instrument, checked first)', () => {
438
- assert.notEqual(LIVE_SRC, CLIENT_SRC, 'the constant this file flips is not spelled the way the test expects — every client assertion below would be testing the dark path');
439
- const diffs = CLIENT_SRC.split('\n').filter((l, i) => l !== LIVE_SRC.split('\n')[i]);
440
- assert.equal(diffs.length, 1, 'the substitution must flip one line, not rewrite the module');
441
- });
442
-
443
- function boot(src, { hubOriginMeta, modules, search } = {}) {
444
- const head = { children: [], appendChild(el) { this.children.push(el); } };
445
- const els = new Map();
446
- const el = (id) => {
447
- if (!els.has(id)) els.set(id, { id, hidden: true, _html: '', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; }, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } });
448
- return els.get(id);
449
- };
450
- const meta = hubOriginMeta === undefined ? null : { getAttribute: () => hubOriginMeta };
451
- const replaced = [];
452
- const assigned = [];
453
- const sandbox = {
454
- console: { error() {}, warn() {}, log() {} },
455
- URL,
456
- URLSearchParams,
457
- document: {
458
- head,
459
- querySelector: (sel) => (sel === 'meta[name="hub-origin"]' ? meta : (head.children.find((c) => c.attrs.rel === 'canonical') || null)),
460
- getElementById: (id) => (id === 'xp-scroll' || id === 'xp-body' ? el(id) : null),
461
- createElement: () => ({ attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; } }),
462
- },
463
- // `assign` is stubbed too, and deliberately records into a SEPARATE list:
464
- // swapping replace→assign must fail on the assertion (the numeric URL would
465
- // stay in history as a place Back returns to), not crash on an undefined
466
- // method, which would "pass" the mutation check for the wrong reason.
467
- location: { search: search || '', replace: (u) => replaced.push(u), assign: (u) => assigned.push(u), href: '' },
468
- window: { OTB: { escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }, __MODULES__: modules || [] },
469
- };
470
- sandbox.globalThis = sandbox;
471
- vm.createContext(sandbox);
472
- vm.runInContext(src, sandbox, { filename: 'profile-canonical.js' });
473
- return {
474
- api: sandbox.window.OTBCanonical,
475
- canonicalHref: () => { const l = head.children.find((c) => c.attrs.rel === 'canonical'); return l ? l.attrs.href : null; },
476
- xp: () => el('xp-scroll'),
477
- xpHtml: () => el('xp-body').innerHTML,
478
- replaced,
479
- assigned,
480
- };
481
- }
482
-
483
- const FEDERATED = { hubOriginMeta: 'https://cloudbongos.com', modules: ['lifecycle', 'hall-ui'] };
484
- const HUB = { hubOriginMeta: 'https://cloudbongos.com', modules: ['platform-identity', 'hall-ui'] };
485
-
486
- test('DARK today: with the shipped source nothing is emitted, anywhere', () => {
487
- const fed = boot(CLIENT_SRC, FEDERATED);
488
- fed.api.render({ handle: 'ada' });
489
- assert.equal(fed.canonicalHref(), null, 'a canonical must not name a URL that 404s');
490
- assert.equal(fed.xp().hidden, true);
491
- const hub = boot(CLIENT_SRC, { ...HUB, search: '?id=7' });
492
- hub.api.render({ handle: 'ada' });
493
- assert.deepEqual(hub.replaced, [], 'redirecting a working page to a 404 would be the worst of the three');
494
- });
495
-
496
- test('LIVE, federated: rel=canonical points at the hub and the link-out is visible', () => {
497
- const page = boot(LIVE_SRC, FEDERATED);
498
- page.api.render({ handle: 'ada' });
499
- assert.equal(page.canonicalHref(), 'https://cloudbongos.com/u/ada');
500
- assert.equal(page.xp().hidden, false);
501
- assert.match(page.xpHtml(), /href="https:\/\/cloudbongos\.com\/u\/ada"/);
502
- assert.match(page.xpHtml(), /See the cross-project record/,
503
- 'the link-out is what ADR 0171 D1 asks a federated instance to render instead of inlining numbers');
504
- assert.deepEqual(page.replaced, [], 'a federated instance shows its OWN record — it must never redirect away from it');
505
- });
506
-
507
- test('LIVE, hub: the numeric ?id= form redirects to the canonical handle URL', () => {
508
- const page = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
509
- page.api.render({ handle: 'ada' });
510
- assert.deepEqual(page.replaced, ['https://cloudbongos.com/u/ada']);
511
- assert.equal(page.canonicalHref(), null, 'a page that redirects must not also declare a canonical');
512
- });
513
-
514
- test('LIVE, hub without ?id=: the self view is not redirected', () => {
515
- const page = boot(LIVE_SRC, { ...HUB, search: '' });
516
- page.api.render({ handle: 'ada' });
517
- assert.deepEqual(page.replaced, [], 'the handle-less /profile is the viewer\'s own page, not a numeric address');
518
- });
519
-
520
- test('LIVE: no handle means no canonical, no link-out and NO REDIRECT — the fallback is a working page', () => {
521
- for (const data of [{}, { handle: null }, { handle: '' }]) {
522
- const fed = boot(LIVE_SRC, FEDERATED);
523
- fed.api.render(data);
524
- assert.equal(fed.canonicalHref(), null);
525
- assert.equal(fed.xp().hidden, true);
526
-
527
- const hub = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
528
- hub.api.render(data);
529
- assert.deepEqual(hub.replaced, [],
530
- 'absent, provisional and private all arrive here as a missing handle and all get the ordinary local page — there is no error shape to read a signal off');
531
- }
532
- });
533
-
534
- test('LIVE: a vanilla instance with no hub origin emits nothing', () => {
535
- for (const meta of [undefined, '', ' ', 'http://cloudbongos.com', 'https://cloudbongos.com/u']) {
536
- const page = boot(LIVE_SRC, { hubOriginMeta: meta, modules: [] });
537
- page.api.render({ handle: 'ada' });
538
- assert.equal(page.canonicalHref(), null, `an unusable hub-origin meta (${JSON.stringify(meta)}) must produce no canonical, never a relative one`);
539
- assert.equal(page.xp().hidden, true);
540
- }
541
- });
542
-
543
- test('LIVE: a hostile handle from the wire never reaches an href', () => {
544
- for (const handle of ['ada/../../admin', 'ada"onload=x', '../evil', 'a b']) {
545
- const page = boot(LIVE_SRC, FEDERATED);
546
- page.api.render({ handle });
547
- assert.equal(page.canonicalHref(), null, `handle ${JSON.stringify(handle)} must be refused, not encoded`);
548
- }
549
- });
550
-
551
- // ===========================================================================
552
- // The wiring: the mirror, the page, and the stamp
553
- // ===========================================================================
554
-
555
- test('the client mirror and the module agree on both constants', () => {
556
- const liveInModule = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(MODULE_SRC);
557
- const liveInClient = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(CLIENT_SRC);
558
- assert.ok(liveInModule && liveInClient, 'both files must spell the flag as a plain const so this guard can read it');
559
- assert.equal(liveInClient[1], liveInModule[1],
560
- 'the browser mirror has drifted from modules/platform-identity/canonical-profile.js — flip both or neither');
561
- assert.equal(String(CANONICAL_PROFILE_PAGE_LIVE), liveInModule[1], 'the module export disagrees with its own source');
562
-
563
- const prefixInClient = /const CANONICAL_PROFILE_PREFIX = '([^']+)';/.exec(CLIENT_SRC);
564
- assert.ok(prefixInClient, 'the client must spell the prefix as a plain const');
565
- assert.equal(prefixInClient[1], CANONICAL_PROFILE_PREFIX, 'the two canonical namespaces have drifted apart');
566
- });
567
-
568
- test('the profile page loads the script BEFORE profile.js, and profile.js calls it', () => {
569
- // Match the <script> TAGS, not the filenames: both names also appear in the
570
- // page's prose, and an ordering assertion that reads a comment proves nothing.
434
+ test('while the page is dark, /profile ships no canonical wiring at all (task 1003451)', () => {
435
+ // The browser half (profile-canonical.js) was removed when R14 — the
436
+ // /u/<handle> page that would have turned it on — was abandoned: 137 lines
437
+ // whose every branch sat behind a false flag, shipped to every /profile
438
+ // reader. Rebuild it with the page. Until then the module's flag is the one
439
+ // statement of "dark", and nothing in the hall may branch on it.
440
+ assert.equal(CANONICAL_PROFILE_PAGE_LIVE, false,
441
+ 'the flag flipped: the /u/<handle> page now resolves, so the browser half (rel=canonical, link-out, numeric-id redirect) has to be rebuilt in this same change');
571
442
  const tags = [...PROFILE_HTML.matchAll(/<script\s+src="\/builders\/([a-z-]+\.js)/g)].map((m) => m[1]);
572
- assert.ok(tags.includes('profile-canonical.js'),
573
- 'the page never loads the script, so nothing above runs in a browser');
574
- assert.ok(
575
- tags.indexOf('profile-canonical.js') < tags.indexOf('profile.js'),
576
- `profile.js must load AFTER profile-canonical.js so window.OTBCanonical exists when renderProfile runs — got ${tags.join(', ')}`,
577
- );
578
- assert.match(PROFILE_JS, /window\.OTBCanonical\.render\(data\)/,
579
- 'profile.js must hand the profile response to the canonical wiring');
443
+ assert.ok(tags.includes('profile.js'), 'the tag reader matched nothing, so the next assertion would pass vacuously');
444
+ assert.ok(!tags.includes('profile-canonical.js'), 'the page loads a canonical script whose every branch is dead');
445
+ assert.doesNotMatch(PROFILE_JS, /OTBCanonical/, 'profile.js branches on a canonical wiring that does not exist');
580
446
  });
581
447
 
582
448
  test('the hub origin is stamped as a meta, and never straight into an href', () => {
@@ -593,9 +459,9 @@ test('the hub origin is stamped as a meta, and never straight into an href', ()
593
459
  });
594
460
 
595
461
  test('the resolver is the ONLY place that spells the canonical namespace', () => {
596
- // Two files may carry it: the module and its browser mirror (checked equal
597
- // above). A third would be a second opinion about where a profile lives.
598
- const owners = ['modules/platform-identity/canonical-profile.js', 'modules/hall-ui/public/profile-canonical.js'];
462
+ // One file carries it: the module. A second would be a second opinion
463
+ // about where a profile lives.
464
+ const owners = ['modules/platform-identity/canonical-profile.js'];
599
465
  const offenders = [];
600
466
  const walk = (rel) => {
601
467
  for (const e of require('node:fs').readdirSync(join(ROOT, rel), { withFileTypes: true })) {
@@ -134,7 +134,7 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
134
134
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
135
135
  ], 'this page has no views of its own, so every item names /projects in full');
136
136
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
137
- ['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
137
+ ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
138
138
  ]);
139
139
  assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/account" aria-current="page">Account']);
140
140
  });
@@ -1,15 +1,16 @@
1
- // tests/hub_engagement_cards.mjs — the guild cards in /projects?view=mine's
2
- // Invitations, EXECUTED (task 1002303, R27; ADR 0365 D2/D3).
1
+ // tests/hub_engagement_cards.mjs — the guild cards on the hub's /invitations page,
2
+ // EXECUTED (task 1002303, R27; ADR 0365 D2/D3). They lived under /projects?view=mine
3
+ // until task 1004276 gave the invitations a page of their own.
3
4
  //
4
5
  // GET /me/guild-engagements names what the guilds a builder is in have going with
5
6
  // a project: "G is applying to P" (the guild's owner put it forward) and "P
6
7
  // invited G". Each card's Apply is the hub's ONE join relay (postJoinRequest,
7
- // tests/card_apply_ui.mjs pins it for the map's card) with `guild` added — the
8
+ // tests/card_apply_ui.mjs pins /projects' copy for the map's card) with `guild` added — the
8
9
  // builder applies as themselves, never as the guild. What these pin:
9
10
  // • the two kinds draw as two sentences, with the project's host and a plain
10
11
  // hint; a row the page cannot act on is dropped, never drawn with a dead button
11
- // • the Invitations section shows while EITHER list has a row, and hides when
12
- // neither does (the projects' own invites and the guild cards are one section)
12
+ // • the page's one empty line shows only once the reads have answered and NO list
13
+ // has a row (the projects' own invites and the guild cards are one section)
13
14
  // • Apply sends exactly { origin, guild } — plus the builder's own note when they
14
15
  // open "Add a note" and write one — on the cookie session, to /my-projects/join
15
16
  // • `requested` is the only answer that reads as applied; the relay's other
@@ -19,7 +20,7 @@
19
20
  // • an expired session and a dead network are said as such
20
21
  // • textContent / createElement only — no markup built from strings
21
22
  //
22
- // The idiom is tests/project_invite_ui.mjs: lift the region out of projects.html,
23
+ // The idiom is tests/project_invite_ui.mjs: lift the region out of invitations.html,
23
24
  // run it in a node:vm under a DOM stub, drive real clicks, and assert the slice
24
25
  // boundaries, so a rename can never leave this file executing an empty string.
25
26
  //
@@ -34,7 +35,7 @@ import { fileURLToPath } from 'node:url';
34
35
 
35
36
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
37
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
37
- const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
38
+ const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
38
39
  const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
39
40
 
40
41
  // the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
@@ -79,12 +80,12 @@ function el(tag) {
79
80
 
80
81
  const TIDEPOOL = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin: 'https://tidepool.example.com' }, created_at: '2026-10-03T12:00:00.000Z' };
81
82
  const LANTERN = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'invited', project: { name: 'Lantern Works', origin: 'https://lantern.example.com' }, created_at: '2026-10-02T09:30:00.000Z' };
82
- const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested access. The project’s owner admits new builders.' } };
83
+ const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Applied. The project’s owner admits new builders.' } };
83
84
 
84
- function boot({ answers = [] } = {}) {
85
+ function boot({ answers = [], read = true } = {}) {
85
86
  const ids = new Map();
86
- for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
87
- ids.get('mineInvites').hidden = true;
87
+ for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
88
+ ids.get('invitesNone').hidden = true;
88
89
  const requests = [];
89
90
  const queued = answers.slice();
90
91
  const fetch = (url, init = {}) => {
@@ -98,21 +99,22 @@ function boot({ answers = [] } = {}) {
98
99
  document, fetch, console, Promise, JSON, String, Object, Array, URL,
99
100
  $: (id) => ids.get(id) || null,
100
101
  API: '/api/bongos',
101
- appliedOrigins: {}, mineOrigins: {},
102
102
  hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
103
103
  safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
104
104
  jsonOrEmpty: (res) => res.json().catch(() => ({})),
105
105
  apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '' }; },
106
106
  };
107
107
  vm.createContext(sandbox);
108
- vm.runInContext(RELAY, sandbox, { filename: 'projects.html#join-relay' });
109
- vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
108
+ vm.runInContext(RELAY, sandbox, { filename: 'invitations.html#join-relay' });
109
+ vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
110
+ // the renders below run as if loadInvites had answered; its own test reads the flag flip
111
+ if (read) sandbox.invitesRead = true;
110
112
  const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
111
113
  const cards = () => ids.get('engageList').children;
112
114
  const part = (card, cls) => card.querySelector('.' + cls);
113
115
  const button = (card, cls) => part(card, 'mineRow__actions').children.find((b) => String(b.className).split(' ').includes(cls));
114
116
  const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
115
- return { sandbox, ids, requests, settle, cards, part, button, click, section: () => ids.get('mineInvites') };
117
+ return { sandbox, ids, requests, settle, cards, part, button, click, none: () => ids.get('invitesNone') };
116
118
  }
117
119
 
118
120
  // ── drawing the cards ────────────────────────────────────────────────────────
@@ -127,12 +129,12 @@ test('the two kinds draw as two plain sentences, with the project’s host, a hi
127
129
  assert.equal(ui.part(a, 'mineRow__host').textContent, 'tidepool.example.com');
128
130
  assert.equal(ui.part(a, 'mineRow__hint').textContent, 'You apply as yourself — add a note for the owner if you like.');
129
131
  assert.equal(a.className, 'mineRow engageRow');
130
- assert.equal(ui.button(a, 'btn').textContent, 'Apply');
132
+ assert.equal(ui.button(a, 'btn').textContent, 'Apply to join', 'the join field’s word for the same relay, on the same page');
131
133
  assert.equal(ui.button(a, 'linklike').textContent, 'Add a note');
132
134
  assert.equal(ui.part(a, 'mineJoinNote').hidden, true, 'the note waits behind "Add a note"');
133
135
  assert.equal(ui.part(a, 'mineJoinNote').maxLength, 400, 'the join note’s own limit');
134
136
  assert.equal(ui.part(a, 'projMsg').getAttribute('role'), 'status');
135
- assert.equal(ui.section().hidden, false, 'guild cards alone show the Invitations section');
137
+ assert.equal(ui.none().hidden, true, 'guild cards alone: the page does not say nothing is waiting');
136
138
  });
137
139
 
138
140
  test('a row the page cannot act on is dropped, never drawn with a button that fails', () => {
@@ -146,49 +148,52 @@ test('a row the page cannot act on is dropped, never drawn with a button that fa
146
148
  null,
147
149
  ]);
148
150
  assert.equal(ui.cards().length, 0);
149
- assert.equal(ui.section().hidden, true, 'nothing to show: the section hides');
151
+ assert.equal(ui.none().hidden, false, 'nothing to show: the page says so in one line');
150
152
  });
151
153
 
152
- test('the section shows while EITHER list has a row, and a decline that empties both hides it', async () => {
154
+ test('the empty line stays down while EITHER list has a row, and a decline that empties both puts it up', async () => {
153
155
  const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true } }] });
154
156
  ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
155
157
  ui.sandbox.renderEngagements([]);
156
- assert.equal(ui.section().hidden, false, 'a project’s own invite alone');
158
+ assert.equal(ui.none().hidden, true, 'a project’s own invite alone');
157
159
  ui.sandbox.renderEngagements([TIDEPOOL]);
158
160
  ui.sandbox.renderInvitesList([]);
159
- assert.equal(ui.section().hidden, false, 'guild cards alone');
161
+ assert.equal(ui.none().hidden, true, 'guild cards alone');
160
162
  ui.sandbox.renderEngagements([]);
161
- assert.equal(ui.section().hidden, true, 'neither');
163
+ assert.equal(ui.none().hidden, false, 'neither');
162
164
 
163
165
  ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
164
166
  const row = ui.ids.get('invitesList').children[0];
165
167
  const decline = row.querySelector('.mineRow__actions').children.find((b) => b.textContent === 'Decline');
166
168
  await ui.click(decline);
167
169
  assert.equal(ui.ids.get('invitesList').children.length, 0);
168
- assert.equal(ui.section().hidden, true, 'the last decline hides it when there is no guild card either');
170
+ assert.equal(ui.none().hidden, false, 'the last decline puts it up when there is no guild card either');
169
171
  });
170
172
 
171
- test('loadInvites reads both lists, and a guild read that fails is silent', async () => {
172
- const ui = boot({ answers: [
173
+ test('loadInvites reads both lists, a guild read that fails is silent, and the empty line waits for every answer', async () => {
174
+ const ui = boot({ read: false, answers: [
173
175
  (url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
174
176
  (url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
175
177
  ] });
178
+ ui.sandbox.renderInvitesList([]);
179
+ assert.equal(ui.none().hidden, true, 'no "nothing waiting" line before the reads have answered');
176
180
  await ui.sandbox.loadInvites();
177
181
  await ui.settle();
178
182
  assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
179
183
  ['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
180
184
  for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
181
185
  assert.equal(ui.cards().length, 0);
182
- assert.equal(ui.section().hidden, true);
186
+ assert.equal(ui.sandbox.invitesRead, true);
187
+ assert.equal(ui.none().hidden, false, 'every read answered, nothing in any list');
183
188
 
184
- const ok = boot({ answers: [
189
+ const ok = boot({ read: false, answers: [
185
190
  (url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
186
191
  (url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
187
192
  ] });
188
193
  await ok.sandbox.loadInvites();
189
194
  await ok.settle();
190
195
  assert.equal(ok.cards().length, 1);
191
- assert.equal(ok.section().hidden, false);
196
+ assert.equal(ok.none().hidden, true);
192
197
  });
193
198
 
194
199
  // ── applying ─────────────────────────────────────────────────────────────────
@@ -219,7 +224,7 @@ test('"Add a note" opens the card’s own note, and the note rides along, trimme
219
224
  assert.deepEqual(ui.requests[0].body, { origin: 'https://tidepool.example.com', note: 'I like the boring parts too.', guild: 'forge-crew' });
220
225
  });
221
226
 
222
- test('requested is the one answer that reads as applied: the button says so and stays down, and the map learns it too', async () => {
227
+ test('requested is the one answer that reads as applied: the button says so and stays down', async () => {
223
228
  const ui = boot({ answers: [REQUESTED] });
224
229
  ui.sandbox.renderEngagements([TIDEPOOL]);
225
230
  const card = ui.cards()[0];
@@ -231,8 +236,8 @@ test('requested is the one answer that reads as applied: the button says so and
231
236
  assert.equal(ui.part(card, 'mineJoinNote').hidden, true, 'a sent note is not offered again');
232
237
  assert.equal(ui.part(card, 'mineRow__hint').hidden, true, 'nor is the hint about one');
233
238
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg ok');
234
- assert.equal(ui.part(card, 'projMsg').textContent, 'Requested access. The project’s owner admits new builders.', 'the relay’s own words');
235
- assert.equal(ui.sandbox.appliedOrigins['tidepool.example.com'], true, 'the map’s Apply reads the same echo');
239
+ assert.equal(ui.part(card, 'projMsg').textContent, 'Applied. The project’s owner admits new builders.', 'the relay’s own words');
240
+ assert.doesNotMatch(RELAY, /appliedOrigins/, 'this page has no map, so its relay keeps no Applied echo');
236
241
  });
237
242
 
238
243
  test('409 guild_engagement_gone: the guild application has ended, the plain application is offered, and it sends no guild', async () => {
@@ -241,9 +246,8 @@ test('409 guild_engagement_gone: the guild application has ended, the plain appl
241
246
  const card = ui.cards()[0];
242
247
  await ui.click(ui.button(card, 'btn'));
243
248
  assert.equal(ui.part(card, 'projMsg').textContent, 'That guild application has ended — you can still apply on your own.');
244
- assert.equal(ui.button(card, 'btn').textContent, 'Apply on my own');
249
+ assert.equal(ui.button(card, 'btn').textContent, 'Apply to join on my own');
245
250
  assert.equal(ui.button(card, 'btn').disabled, false);
246
- assert.equal(ui.sandbox.appliedOrigins['lantern.example.com'], undefined, 'nothing was relayed, so nothing reads as applied');
247
251
 
248
252
  await ui.click(ui.button(card, 'btn'));
249
253
  assert.deepEqual(ui.requests[1].body, { origin: 'https://lantern.example.com' }, 'on your own means no guild');
@@ -261,7 +265,7 @@ test('the relay’s other answers are said in its own words, and the card stays
261
265
  await ui.click(ui.button(card, 'btn'));
262
266
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg', 'the door’s answer, not an error');
263
267
  assert.match(ui.part(card, 'projMsg').textContent, /invite-only/);
264
- assert.equal(ui.button(card, 'btn').textContent, 'Apply');
268
+ assert.equal(ui.button(card, 'btn').textContent, 'Apply to join');
265
269
  assert.equal(ui.button(card, 'btn').disabled, false);
266
270
  await ui.click(ui.button(card, 'btn'));
267
271
  assert.equal(ui.part(card, 'projMsg').className, 'projMsg err');
@@ -279,7 +283,7 @@ test('an expired session offers a way back in, built from elements; a dead netwo
279
283
  assert.equal(msg.className, 'projMsg err');
280
284
  assert.equal(msg.textContent, 'Your session expired — sign in again.');
281
285
  const link = msg.children.find((c) => c.tag === 'a');
282
- assert.equal(link.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1', 'back to this view, with the repo scope every hub sign-in carries');
286
+ assert.equal(link.href, '/api/bongos/auth/web/start?return=/invitations&repos=1', 'back to this page, with the repo scope every hub sign-in carries');
283
287
  assert.equal(ui.button(card, 'btn').disabled, false);
284
288
  await ui.click(ui.button(card, 'btn'));
285
289
  assert.equal(msg.textContent, 'Couldn’t reach the platform — check your connection and try again.');
@@ -287,20 +291,20 @@ test('an expired session offers a way back in, built from elements; a dead netwo
287
291
 
288
292
  // ── the markup, the region's rules, the kit ──────────────────────────────────
289
293
 
290
- test('the cards are a real list inside the Invitations section, built with createElement / textContent only', () => {
291
- const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
294
+ test('the cards are a real list inside the invitations section, built with createElement / textContent only', () => {
295
+ const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
292
296
  assert.match(section, /<ul class="mineRows" id="invitesList"><\/ul>/);
293
297
  assert.match(section, /<ul class="mineRows" id="engageList"><\/ul>/, 'ships as real markup the script only fills');
294
298
  assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
295
- assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
299
+ assert.match(SCRIPT, /bar\.ready\.then\(function \(me\) \{[\s\S]*?loadInvites\(\);/, 'signed in, the page reads every list through loadInvites');
296
300
  const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
297
301
  assert.match(COSMOS, /\.mineRows:not\(:empty\)~\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between any two lists that have rows');
298
302
  assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
299
303
  });
300
304
 
301
305
  test('the kit pictures the cards and the stub answers the read and the guild on the join', () => {
302
- const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
303
- assert.ok(states['in-mine-engagements'] && states['in-mine-engagement-applied'], 'two kit states: the cards, and one applied');
306
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
307
+ assert.ok(states['in-engagements'] && states['in-engagement-applied'], 'two kit states: the cards, and one applied');
304
308
  const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
305
309
  assert.match(serve, /'\/me\/guild-engagements': 'me__guild-engagements'/);
306
310
  assert.match(serve, /refuse\(res, 409, 'guild_engagement_gone'/, 'the stub refuses a guild with no engagement, as the route does');
@@ -1,6 +1,7 @@
1
1
  // tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
2
- // you own, in /projects?view=mine's Invitations, EXECUTED (task 1004583; ADR 0336
3
- // D3/D4 §3, ADR 0337 D4.4).
2
+ // you own, on the hub's /invitations page, EXECUTED (task 1004583; ADR 0336
3
+ // D3/D4 §3, ADR 0337 D4.4). Under /projects?view=mine until task 1004276 gave the
4
+ // invitations a page of their own.
4
5
  //
5
6
  // R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
6
7
  // /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
@@ -16,8 +17,8 @@
16
17
  // guild as it is now, and the next accept sends that
17
18
  // • an ended invite or request leaves nothing pressable; an expired session and a
18
19
  // dead network are said as such, and the card stays re-tryable
19
- // • Decline is a body-less POST whose 204 drops the row (the section hides with
20
- // its last row); a failure keeps the row and says so
20
+ // • Decline is a body-less POST whose 204 drops the row (the page's empty line
21
+ // comes up with the last row); a failure keeps the row and says so
21
22
  // • the owner's card names who asked, with the handle once, and accepts with no
22
23
  // body — the requester's switch rode their request
23
24
  // • ONE read feeds both lists — GET /me/guild-requests's incoming and
@@ -26,7 +27,7 @@
26
27
  // • textContent / createElement only
27
28
  //
28
29
  // The idiom is tests/hub_engagement_cards.mjs: lift the region out of
29
- // projects.html, run it in a node:vm under a DOM stub, drive real clicks, and
30
+ // invitations.html, run it in a node:vm under a DOM stub, drive real clicks, and
30
31
  // assert the slice boundaries so a rename can never leave this executing nothing.
31
32
  //
32
33
  // DB-free. Run: node --test tests/hub_guild_cards.mjs
@@ -40,7 +41,7 @@ import { fileURLToPath } from 'node:url';
40
41
 
41
42
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
43
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
43
- const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
44
+ const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
44
45
  const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
45
46
  const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
46
47
 
@@ -88,8 +89,8 @@ const GONE = { ok: false, status: 204, body: undefined };
88
89
 
89
90
  function boot({ answers = [] } = {}) {
90
91
  const ids = new Map();
91
- for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
92
- ids.get('mineInvites').hidden = true;
92
+ for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
93
+ ids.get('invitesNone').hidden = true;
93
94
  const requests = [];
94
95
  const queued = answers.slice();
95
96
  const fetch = (url, init = {}) => {
@@ -110,7 +111,8 @@ function boot({ answers = [] } = {}) {
110
111
  apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
111
112
  };
112
113
  vm.createContext(sandbox);
113
- vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
114
+ vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
115
+ sandbox.invitesRead = true; // as if loadInvites had answered: the empty line may show
114
116
  const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
115
117
  const invites = () => ids.get('guildInviteList').children;
116
118
  const asks = () => ids.get('guildRequestList').children;
@@ -125,7 +127,7 @@ function boot({ answers = [] } = {}) {
125
127
  for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
126
128
  await settle();
127
129
  };
128
- return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, section: () => ids.get('mineInvites') };
130
+ return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, none: () => ids.get('invitesNone') };
129
131
  }
130
132
 
131
133
  // ── the D4.4 notice: one sentence on two pages ───────────────────────────────
@@ -151,7 +153,7 @@ test('an invite card says what joining shows, offers the switch set to count, an
151
153
  const ui = boot();
152
154
  ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
153
155
  assert.equal(ui.requests.length, 0, 'drawing sends nothing');
154
- assert.equal(ui.section().hidden, false);
156
+ assert.equal(ui.none().hidden, true, 'something is waiting');
155
157
  const [open, quiet] = ui.invites();
156
158
  assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
157
159
  assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
@@ -253,7 +255,7 @@ test('an ended invite leaves nothing pressable; an expired session offers a way
253
255
  await out.click(out.button(card, 'Accept'));
254
256
  const a = out.msg(card).children.find((c) => c.tag === 'a');
255
257
  assert.ok(a, 'a sign-in link, built as an element');
256
- assert.equal(a.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1');
258
+ assert.equal(a.href, '/api/bongos/auth/web/start?return=/invitations&repos=1');
257
259
  assert.equal(out.button(card, 'Accept').disabled, false);
258
260
  await out.click(out.button(card, 'Accept'));
259
261
  assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
@@ -270,14 +272,14 @@ test('Decline is a body-less POST whose 204 drops the row, and the last one hide
270
272
  assert.equal(ui.requests[0].method, 'POST');
271
273
  assert.equal(ui.requests[0].body, undefined, 'nothing to send');
272
274
  assert.equal(ui.invites().length, 0);
273
- assert.equal(ui.section().hidden, false, 'the owner’s card still stands');
275
+ assert.equal(ui.none().hidden, true, 'the owner’s card still stands');
274
276
  const ask = ui.asks()[0];
275
277
  await ui.click(ui.button(ask, 'Decline'));
276
278
  assert.equal(ui.asks().length, 1, 'kept on a failure');
277
279
  assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
278
280
  assert.equal(ui.button(ask, 'Decline').disabled, false);
279
281
  ui.sandbox.renderGuildRequests([]);
280
- assert.equal(ui.section().hidden, true, 'no row in any list hides the section');
282
+ assert.equal(ui.none().hidden, false, 'no row in any list: the page says nothing is waiting');
281
283
  });
282
284
 
283
285
  // ── the owner's card ─────────────────────────────────────────────────────────
@@ -333,13 +335,13 @@ test('ONE read feeds both lists — the invites to you and the requests to every
333
335
  assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
334
336
  });
335
337
 
336
- test('a failed or empty read is silent: no cards, and the section stays hidden', async () => {
338
+ test('a failed or empty read is silent: no cards, only the page’s own empty line', async () => {
337
339
  for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
338
340
  const ui = boot({ answers: [answer] });
339
341
  await ui.sandbox.loadGuildQueue();
340
342
  await ui.settle();
341
343
  assert.equal(ui.invites().length + ui.asks().length, 0);
342
- assert.equal(ui.section().hidden, true);
344
+ assert.equal(ui.none().hidden, false);
343
345
  }
344
346
  });
345
347
 
@@ -359,13 +361,13 @@ test('a row the page cannot answer is dropped, never drawn with a button that fa
359
361
 
360
362
  // ── the markup, the region's rules, the kit ──────────────────────────────────
361
363
 
362
- test('the cards are real lists in the Invitations section, built with createElement / textContent only', () => {
363
- const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
364
+ test('the cards are real lists in the invitations section, built with createElement / textContent only', () => {
365
+ const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
364
366
  const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
365
367
  assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
366
368
  assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
367
369
  assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
368
- assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\);/, 'the mine view reads them with the other invitations');
370
+ assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\)/, 'the page reads them with the other invitations');
369
371
  const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
370
372
  for (const rule of ['.guildCount__opts{', '.guildCount__opt:has(input:checked) b::before{', '.guildCount__opt:focus-within{', '.guildRow--invite .mineRow__actions{align-self:flex-end;}']) {
371
373
  assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
@@ -374,9 +376,9 @@ test('the cards are real lists in the Invitations section, built with createElem
374
376
  });
375
377
 
376
378
  test('the kit pictures the cards, and the stub answers the four calls as the routes do', () => {
377
- const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
378
- for (const s of ['in-mine-guild-cards', 'in-mine-guild-count-off', 'in-mine-guild-joined']) assert.ok(states[s], `kit state ${s}`);
379
- const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes.map((p) => p.name);
379
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
380
+ for (const s of ['in-guild-cards', 'in-guild-count-off', 'in-guild-joined']) assert.ok(states[s], `kit state ${s}`);
381
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.probes.json'), 'utf8')).probes.map((p) => p.name);
380
382
  assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
381
383
  const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
382
384
  assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);