@bongos/core 1.21.62 → 1.21.64

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 (37) hide show
  1. package/.bongos-core.json +55 -40
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/api/openapi.json +7 -3
  4. package/docs/api-reference.md +1 -1
  5. package/docs/copy-inventory.md +343 -343
  6. package/docs/copy-registry.json +390 -390
  7. package/docs/module-api-changelog.md +9 -0
  8. package/docs/page-inventory.json +4 -1
  9. package/docs/page-readings.json +753 -728
  10. package/modules/hall-ui/public/watch.css +15 -0
  11. package/modules/hall-ui/public/watch.html +3 -0
  12. package/modules/hall-ui/public/watch.js +87 -1
  13. package/modules/hall-ui/records/oversight-family.md +6 -0
  14. package/modules/onboarding/routes/access-requests.js +10 -4
  15. package/modules/platform-identity/guilds.js +32 -0
  16. package/modules/platform-identity/routes/guilds.js +10 -3
  17. package/modules/public-landing/public/assets/cosmos.css +29 -3
  18. package/modules/public-landing/public/projects.html +294 -4
  19. package/modules/public-landing/public/projects.probes.json +18 -0
  20. package/modules/public-landing/public/projects.states.json +3 -0
  21. package/modules/ui-design/kit/fixtures/guilds.json +7 -0
  22. package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
  23. package/modules/ui-design/kit/serve.js +41 -0
  24. package/package-lock.json +9 -5
  25. package/package.json +1 -1
  26. package/release-notes.json +12 -0
  27. package/src/bongos/auth-admission.js +33 -2
  28. package/src/bongos/auth.js +2 -1
  29. package/src/module-api.js +7 -1
  30. package/tests/applicant_view_guild.mjs +101 -0
  31. package/tests/application_lifecycle.mjs +38 -1
  32. package/tests/guilds_api.mjs +31 -0
  33. package/tests/guilds_db.mjs +7 -0
  34. package/tests/hub_engagement_cards.mjs +9 -6
  35. package/tests/hub_guild_cards.mjs +392 -0
  36. package/tests/module_api.mjs +1 -0
  37. package/tests/watch_applications_queue.mjs +112 -2
@@ -71,3 +71,18 @@
71
71
  /* A link inside a ledger cell (a session's tasks) is a control at a 13.5px line
72
72
  box: the padding buys the 24px floor and the negative margin hands it back. */
73
73
  .ledger td a:not(.lb-name) { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; line-height: 18px; }
74
+
75
+ /* A guild's applicants under one head (task 1002304, ADR 0365 D5): the guild's
76
+ name, the count, and "Invite all" at the inline end. The head wraps at phone
77
+ width so the button drops under the name rather than overflowing, and centres
78
+ because a pill on the label's baseline sits low. */
79
+ .gate-guild .ov-group__head { flex-wrap: wrap; align-items: center; row-gap: 8px; }
80
+ .gate-guild .ov-group__head .btn-ghost { margin-left: auto; }
81
+ /* The name is a link to the guild's hub page when it has one — a control, so it
82
+ owes the 24px floor: the padding buys the box, the margin hands it back. */
83
+ a.gate-guild__link { display: inline-block; min-height: 24px; min-width: 24px; padding: 3px 0; margin: -3px 0; text-decoration: none; }
84
+ a.gate-guild__link:hover { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px; }
85
+ /* The profile block's sentences ("Private account — …", "hides their record")
86
+ ride .ov-row__when, which never wraps; at 320px they pushed the page 54px
87
+ sideways. Inside the profile they are prose, so they wrap. */
88
+ .ov-row__profile .ov-row__when { white-space: normal; }
@@ -5,6 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Watch — {{worldName}}</title>
7
7
  <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, roster, reviews, and repo health.">
8
+ <!-- The hub's origin, for the link from a guild's head in the applications queue to
9
+ its page on the hub (task 1002304). Empty on a vanilla instance: no link. -->
10
+ <meta name="hub-origin" content="{{hubOrigin}}">
8
11
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -523,6 +523,90 @@
523
523
  );
524
524
  }
525
525
 
526
+ // ---- a guild's applicants, together (task 1002304, ADR 0365 D5) ----------
527
+ //
528
+ // An applicant who applied WITH their guild carries the hub's label for it,
529
+ // { slug, name } (slug null for an unlisted guild), and the queue gathers those
530
+ // rows under the guild's name so an owner reads a guild's application as one
531
+ // thing. "Invite all" is N ordinary invites — one PATCH per row, the same
532
+ // write the row's own button sends — because admission stays per person on the
533
+ // project (ADR 0365: N clicks, not one, is the honest cost; this only makes the
534
+ // N clicks one gesture). Nothing here is authority (ADR 0016): every row keeps
535
+ // its own pill, profile and buttons.
536
+ //
537
+ // The label counts only on a VOUCHED row. The server resolves it from the vouch
538
+ // alone, and an unvouched row wearing a guild would be the impersonation shape
539
+ // the vouch pill exists to expose. When no row carries a guild (an older hub, no
540
+ // hub, nobody applied with one) the list is exactly what it always was: one
541
+ // list, no group heads.
542
+ function guildOf(r) {
543
+ const g = r && r.vouched ? r.guild : null;
544
+ return g && typeof g.name === 'string' && g.name ? g : null;
545
+ }
546
+
547
+ // Groups in the order their newest applicant arrived; rows keep the server's
548
+ // order inside each. Two unlisted guilds that share a name share a head — the
549
+ // hub names an unlisted guild without an address, so the name is all there is.
550
+ function groupByGuild(requests) {
551
+ const groups = new Map();
552
+ const solo = [];
553
+ for (const r of requests) {
554
+ const g = guildOf(r);
555
+ if (!g) { solo.push(r); continue; }
556
+ const key = g.slug ? `slug:${g.slug}` : `name:${g.name}`;
557
+ if (!groups.has(key)) groups.set(key, { guild: g, rows: [] });
558
+ groups.get(key).rows.push(r);
559
+ }
560
+ return { groups: [...groups.values()], solo };
561
+ }
562
+
563
+ // The guild's own page on the hub when it has one. An unlisted guild has no
564
+ // slug and a vanilla instance has no hub; either way the name is plain text,
565
+ // never a link to nowhere.
566
+ function guildNameHtml(g) {
567
+ const hub = typeof window.OTB.hubOrigin === 'function' ? window.OTB.hubOrigin() : null;
568
+ const name = escapeHtml(g.name);
569
+ return hub && g.slug
570
+ ? `<a class="ov-group__name gate-guild__link" href="${escapeHtml(`${hub}/g/${encodeURIComponent(g.slug)}`)}">${name}</a>`
571
+ : `<span class="ov-group__name">${name}</span>`;
572
+ }
573
+
574
+ // "Invite all" covers the rows still waiting, and only two or more of them —
575
+ // a lone applicant already has their own Invite. The decided views offer no
576
+ // bulk move at all: a mass rescind is not a gesture this queue makes.
577
+ function bulkInviteHtml(rows) {
578
+ if (gateView !== 'pending') return '';
579
+ const ids = rows.filter((r) => r.status === 'pending').map((r) => r.id);
580
+ if (ids.length < 2) return '';
581
+ return ghost(`data-gate-bulk="invited" data-ids="${escapeHtml(ids.join(','))}"`, `Invite all ${ids.length}`);
582
+ }
583
+
584
+ function gateListHtml(requests) {
585
+ const list = (rows) => '<ul class="ov-rows">' + rows.map(gateRowHtml).join('') + '</ul>';
586
+ const { groups, solo } = groupByGuild(requests);
587
+ if (!groups.length) return list(solo);
588
+ const head = (name, n, extra) => `<div class="ov-group__head">${name}<span class="ov-group__n">${n}</span>${extra}</div>`;
589
+ return groups.map(({ guild, rows }) => (
590
+ `<section class="ov-group gate-guild">${head(guildNameHtml(guild), rows.length, bulkInviteHtml(rows))}${list(rows)}</section>`
591
+ )).join('') + (solo.length
592
+ ? `<section class="ov-group">${head('<span class="ov-group__name">On their own</span>', solo.length, '')}${list(solo)}</section>`
593
+ : '');
594
+ }
595
+
596
+ // Each invite settles on its own, so one refusal cannot strand the rest; the
597
+ // toast says how many landed, and the re-read shows exactly which.
598
+ async function inviteAll(ids) {
599
+ const settled = await Promise.allSettled(ids.map((id) =>
600
+ patchJson(`/access-requests/${encodeURIComponent(id)}`, { status: 'invited' })));
601
+ const failed = settled.filter((s) => s.status === 'rejected').map((s) => s.reason);
602
+ if (failed.some((e) => e && e.kind === '401')) throw failed.find((e) => e && e.kind === '401');
603
+ if (failed.length === ids.length) throw failed[0];
604
+ toast(failed.length
605
+ ? `Invited ${ids.length - failed.length} of ${ids.length} — ${failed[0].message || failed[0]}`
606
+ : `Invited all ${ids.length}.`);
607
+ await loadGate();
608
+ }
609
+
526
610
  function renderGate(data, target) {
527
611
  const requests = Array.isArray(data.requests) ? data.requests : [];
528
612
  // The attention tile counts WAITING work, never whatever list is on screen:
@@ -531,10 +615,12 @@
531
615
  if (gateView === 'pending') setAttention('att-access', requests.length);
532
616
  const head = inviteFormHtml() + '<div id="gate-view"></div>';
533
617
  target.innerHTML = requests.length
534
- ? head + '<ul class="ov-rows">' + requests.map(gateRowHtml).join('') + '</ul>'
618
+ ? head + gateListHtml(requests)
535
619
  : head + emptyStateHtml(gateEmptyMsg());
536
620
 
537
621
  if (requests.length) {
622
+ wireActions(target, '[data-gate-bulk]', (btn) =>
623
+ inviteAll(String(btn.getAttribute('data-ids') || '').split(',').filter(Boolean)), 'Could not invite');
538
624
  wireActions(target, '[data-gate-action]', async (btn) => {
539
625
  const id = btn.getAttribute('data-id');
540
626
  const status = btn.getAttribute('data-gate-action');
@@ -16,4 +16,10 @@ Also gone, and the one rule in that list: a sealed state is a `.scroll` panel wh
16
16
  - **The three lifecycle states sit behind one `makeFilterBar` switch**, so the decided history is readable and the RESCIND (`invited → dismissed`) the route has always taken is finally offered — ADR 0208 recorded that nothing on this page did. A declined row offers no button: reviving one is the invite form above, which is what the server's own 409 names. The switch is rebuilt each render (the same lifecycle `wireInviteForm` lives with, since `section` replaces the whole body).
17
17
  - **The `att-access` tile counts pending only.** Its sub line says "pending", so the count is guarded on the pending view — reading the history must not repaint the board with a number that means something else.
18
18
 
19
+ **The queue gathers a guild's applicants under the guild's name (task 1002304, R28; ADR 0365 D5).** Rows carry the hub's `guild` label beside `profile`, and `gateListHtml` groups the VOUCHED rows that have one into `.ov-group` sections — the guild's name (a link to `<hub>/g/<slug>` when the page carries a hub origin and the guild is public), the count, and, on the pending view with two or more waiting, an "Invite all N" that sends N ordinary `PATCH {status:'invited'}`s and re-reads once. Three rules it keeps:
20
+
21
+ - **No guild anywhere means no group heads at all** — an older hub, no hub, or nobody applying with a guild renders the one list it always did. The rest sit under "On their own" only when at least one guild group exists.
22
+ - **An unvouched row is never filed under a guild**, even if it carries one: the server resolves the label from the vouch alone, and a guild head over an unvouched name would be the impersonation shape the pill exists to expose.
23
+ - **Admission stays per person.** Every grouped row keeps its own pill, profile and buttons; "Invite all" is N clicks made one gesture, never a new write, and the decided views offer no bulk move. `tests/watch_applications_queue.mjs` drives all of it; the harness fixture `access-requests.json` carries a three-member public guild and a one-member unlisted one.
24
+
19
25
  Also gone: harbor's 280px card deck with a 4px coloured "roof" band per box and gate's crate with a verdict band (state is a `.fact-pill`; the fleet reads in three grouped runs — running, parked or in transition, no box); government's bordered `.gov-section` boxes and the one `.gov-assign-row` five unrelated lists shared (each renders as the row it is); watch's third copy of the stat tile, its `table.watch`, its `.watch-btn`; three bare `<p>` sealed states (each is a `.scroll` panel whose lede is written from `OTB.rankLabel('metic')`, so the floor cannot drift from ADR 0157). The harness gained fixtures for `/boxes`, `/gate-approvals` and five session rows, because a page that only ever renders its error state cannot be redesigned by looking.
@@ -139,6 +139,12 @@ const MAX_LIVE_PROFILE_READS = 25;
139
139
  //
140
140
  // Each read fails soft to null independently (the doorway port swallows), so one
141
141
  // slow or missing account cannot fail the others or the listing.
142
+ //
143
+ // THE GUILD LABEL RIDES THE SAME READ (ADR 0365 D5, task 1002304): `guild` is
144
+ // { slug, name } when the applicant applied with their guild, else null — so it
145
+ // is keyed on the vouch and budgeted by the cap exactly as the profile is, and
146
+ // the queue groups a guild's applicants by it. An older hub sends no guild, and
147
+ // every row then carries `guild: null` and renders as it always did.
142
148
  async function attachApplicantProfiles(rows) {
143
149
  // The budget is spent UP FRONT, by index, rather than by decrementing a shared
144
150
  // counter inside the concurrent callbacks. Both are correct today — `.map`
@@ -151,10 +157,10 @@ async function attachApplicantProfiles(rows) {
151
157
  rows.forEach((r, i) => {
152
158
  if (r.applicant_github_id && resolvable.size < MAX_LIVE_PROFILE_READS) resolvable.add(i);
153
159
  });
154
- return Promise.all(rows.map(async (r, i) => ({
155
- ...r,
156
- profile: resolvable.has(i) ? await api.readApplicantProfileFromHub(r.applicant_github_id) : null,
157
- })));
160
+ return Promise.all(rows.map(async (r, i) => {
161
+ const view = resolvable.has(i) ? await api.readApplicantViewFromHub(r.applicant_github_id) : null;
162
+ return { ...r, profile: view ? view.profile : null, guild: view ? view.guild : null };
163
+ }));
158
164
  }
159
165
 
160
166
  // THE HUB'S NOTICE OF THIS PROJECT'S INVITES (task 1002818, ADR 0353). On a hosted
@@ -647,6 +647,37 @@ async function listGuildRequests({ guildId, ownerGithubId }, { pool = defaultPoo
647
647
  return { incoming, outgoing };
648
648
  }
649
649
 
650
+ // The join requests waiting on the caller as an OWNER, across every guild they
651
+ // own, in ONE statement (task 1004583): the hub's Invitations reads this rather
652
+ // than one queue per owned guild. listGuildRequests' request leg keyed on the
653
+ // caller instead of one guild — a requester floors on consent alone, the owner
654
+ // test re-asserted per row — and its seen_at stamp. Each row names its guild.
655
+ async function listOwnedRequests({ githubId }, { pool = defaultPool } = {}) {
656
+ const { rows } = await pool.query(
657
+ `SELECT r.id::text AS id, r.created_at, r.seen_at, g.slug, g.name, ${PERSON_COLUMNS}
658
+ FROM platform_identity_guild_requests r
659
+ JOIN platform_identity_guilds g ON g.id = r.guild_id
660
+ JOIN platform_identity_accounts pa ON pa.github_id = r.github_id
661
+ WHERE r.kind = 'request' AND r.status = 'pending'
662
+ AND r.created_at > now() - make_interval(days => $2::int)
663
+ AND ${ownerExistsSql('r.guild_id', '$1')}
664
+ AND ${accountActiveSql('pa')}
665
+ ORDER BY r.created_at DESC, r.id DESC
666
+ LIMIT $3`,
667
+ [githubId, REQUEST_EXPIRY_DAYS, LIST_MAX],
668
+ );
669
+ const unseen = rows.filter((r) => !r.seen_at).map((r) => r.id);
670
+ if (unseen.length) {
671
+ await pool.query(
672
+ `UPDATE platform_identity_guild_requests r SET seen_at = now()
673
+ WHERE r.id = ANY($1::bigint[]) AND r.kind = 'request' AND r.seen_at IS NULL
674
+ AND ${ownerExistsSql('r.guild_id', '$2')}`,
675
+ [unseen, githubId],
676
+ );
677
+ }
678
+ return rows.map(({ slug, name, ...r }) => ({ ...r, guild: { slug, name } }));
679
+ }
680
+
650
681
  // ---------------------------------------------------------------------------
651
682
  // The public read (D4)
652
683
  // ---------------------------------------------------------------------------
@@ -707,5 +738,6 @@ module.exports = {
707
738
  cancelRequest,
708
739
  listMyRequests,
709
740
  listGuildRequests,
741
+ listOwnedRequests,
710
742
  getPublicGuild,
711
743
  };
@@ -12,6 +12,7 @@
12
12
  // POST /guilds create {slug, name, description?, visibility}
13
13
  // GET /me/guilds my guilds, with my own two switches
14
14
  // GET /me/guild-requests invites to me (the card) + requests I sent
15
+ // + requests to the guilds I own (owner_incoming)
15
16
  // GET /guilds/:slug/members the members' roster (members only; 404 else)
16
17
  // PATCH /guilds/:slug/membership my switches {shown_publicly?, counted_in_totals?}
17
18
  // DELETE /guilds/:slug/membership leave (the owner transfers first, or is last and dissolves)
@@ -154,13 +155,19 @@ module.exports = function guildRoutes() {
154
155
  }));
155
156
 
156
157
  // rank: any-builder — MY queue: invites addressed to me, each carrying the
157
- // card (the guild's current visibility, which an accept must echo back), and
158
- // the join requests I sent. Stamps seen_at on the invites.
158
+ // card (the guild's current visibility, which an accept must echo back), the
159
+ // join requests I sent, and the join requests waiting on me as the owner of a
160
+ // guild, each naming its guild (task 1004583 — one read for the hub, never one
161
+ // per owned guild). Stamps seen_at on the invites and on those requests.
159
162
  router.get('/me/guild-requests', requireBuilder, handle('GET /me/guild-requests', 'guild_requests_read_failed', async (req, res) => {
160
163
  const me = await actingAccount(req.builder);
161
164
  if (me.fail) return res.fail(...me.fail);
162
165
  const { incoming, outgoing } = await guilds.listMyRequests({ githubId: me.githubId }, { pool });
163
- res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
166
+ const ownerIncoming = await guilds.listOwnedRequests({ githubId: me.githubId }, { pool });
167
+ res.json({
168
+ incoming, outgoing, owner_incoming: ownerIncoming,
169
+ incoming_count: incoming.length, outgoing_count: outgoing.length, owner_incoming_count: ownerIncoming.length,
170
+ });
164
171
  }));
165
172
 
166
173
  // rank: any-builder — the members' roster, for members only (the own-scope
@@ -1325,13 +1325,39 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1325
1325
  .mineJoinRow input::placeholder{color:var(--ink-faint);}
1326
1326
  .mineJoinNote{display:block;width:100%;box-sizing:border-box;margin-top:10px;min-height:64px;padding:12px 16px;font:inherit;font-size:14px;line-height:1.5;color:var(--ink);background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:14px;resize:vertical;}
1327
1327
  .mineJoinNote::placeholder{color:var(--ink-faint);}
1328
- /* the guild cards (task 1002303, R27): a second list under the invitations, one
1329
- hairline between the two when both have rows; each card's note waits behind
1328
+ /* the guild cards (task 1002303, R27): more lists under the invitations, one
1329
+ hairline between any two that have rows; each card's note waits behind
1330
1330
  "Add a note", and its status line takes no room until it has something to say */
1331
- .mineRows:not(:empty)+.mineRows .mineRow:first-child{border-top:1px solid var(--rule-soft);}
1331
+ .mineRows:not(:empty)~.mineRows .mineRow:first-child{border-top:1px solid var(--rule-soft);}
1332
1332
  .engageRow .mineJoinNote{max-width:62ch;}
1333
1333
  .engageRow .projMsg{margin-top:6px;min-height:0;}
1334
1334
  .engageRow .projMsg:empty{margin:0;}
1335
+ /* a guild's invite to you and a join request to your guild (task 1004583). The
1336
+ invite carries the guild page's counted pair (guild.html's .aOpt voice, set
1337
+ tighter for a row) with the D4.4 notice under it, said before accept. */
1338
+ .guildRow .projMsg{margin-top:6px;min-height:0;}
1339
+ .guildRow .projMsg:empty{margin:0;}
1340
+ /* the invite's buttons sit at its foot, after the notice they answer */
1341
+ .guildRow--invite .mineRow__actions{align-self:flex-end;}
1342
+ .guildRow__desc{margin:4px 0 0;font-size:14px;line-height:1.5;color:var(--ink-soft);max-width:62ch;}
1343
+ .guildCount{display:grid;gap:8px;margin-top:12px;max-width:66ch;}
1344
+ .guildCount__label{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);}
1345
+ .guildCount__opts{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
1346
+ .guildCount__opt{position:relative;display:block;margin:0;padding:12px 16px 14px;border-left:1px solid var(--rule-soft);cursor:pointer;}
1347
+ .guildCount__opt:first-child{border-left:0;padding-left:0;}
1348
+ .guildCount__opt input{position:absolute;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);}
1349
+ .guildCount__opt>span{display:block;font-size:13px;line-height:1.45;color:var(--ink-faint);}
1350
+ .guildCount__opt b{display:flex;align-items:center;gap:10px;margin-bottom:4px;font-family:var(--font-body);font-weight:500;font-size:14.5px;color:var(--ink);}
1351
+ .guildCount__opt b::before{content:"";width:7px;height:7px;border-radius:50%;border:1px solid var(--rule);background:var(--bg);flex:none;}
1352
+ .guildCount__opt:has(input:checked) b::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
1353
+ .guildCount__opt:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
1354
+ .guildCount__opt:has(input:disabled){cursor:default;}
1355
+ .guildCount__what{margin:0;font-size:13px;line-height:1.5;color:var(--ink-soft);max-width:70ch;}
1356
+ @media (max-width:480px){
1357
+ .guildCount__opts{grid-template-columns:1fr;}
1358
+ .guildCount__opt{padding:10px 0 12px;border-left:0;border-top:1px solid var(--rule-soft);}
1359
+ .guildCount__opt:first-child{border-top:0;}
1360
+ }
1335
1361
 
1336
1362
  /* ── the ledger row: planet · name and chip · the facts · the rail · the
1337
1363
  doors. The planet and the doors are fixed widths; the three middle tracks
@@ -963,11 +963,18 @@ summary{min-height:24px;padding:3px 0;}
963
963
  <section id="mineInvites" hidden aria-labelledby="mineInvitesH">
964
964
  <h2 id="mineInvitesH">Invitations</h2>
965
965
  <ul class="mineRows" id="invitesList"></ul>
966
+ <!-- a guild inviting you (task 1004583; ADR 0336 D4 §3, ADR 0337 D4.4):
967
+ each card says, BEFORE you accept, that your numbers count toward the
968
+ guild's rounded totals, and offers the switch right there. -->
969
+ <ul class="mineRows" id="guildInviteList"></ul>
966
970
  <!-- a guild you are in, engaging a project (task 1002303, R27; ADR 0365):
967
971
  "G is applying to P" and "P invited G", from GET /me/guild-engagements.
968
972
  Each card's Apply is the ordinary join request with `guild` added, so
969
973
  you apply as yourself, never as the guild. -->
970
974
  <ul class="mineRows" id="engageList"></ul>
975
+ <!-- someone asking to join a guild you own (task 1004583): Accept / Decline.
976
+ Their own counted switch rode their request, so this card has none. -->
977
+ <ul class="mineRows" id="guildRequestList"></ul>
971
978
  </section>
972
979
 
973
980
  <section aria-labelledby="mineBuildingH">
@@ -3689,10 +3696,12 @@ summary{min-height:24px;padding:3px 0;}
3689
3696
  paintInvitesSection();
3690
3697
  }
3691
3698
 
3692
- /* the Invitations section holds two lists — the projects' own invites and the
3693
- guild cards below — and shows while either has a row */
3699
+ /* the Invitations section holds four lists — the projects' own invites, a
3700
+ guild's invites to you, your guilds' engagements, and the join requests to a
3701
+ guild you own — and shows while any of them has a row */
3702
+ var INVITE_LISTS = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'];
3694
3703
  function paintInvitesSection() {
3695
- $('mineInvites').hidden = !$('invitesList').children.length && !$('engageList').children.length;
3704
+ $('mineInvites').hidden = !INVITE_LISTS.some(function (id) { return $(id).children.length; });
3696
3705
  }
3697
3706
 
3698
3707
  function loadInvites() {
@@ -3701,7 +3710,8 @@ summary{min-height:24px;padding:3px 0;}
3701
3710
  .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
3702
3711
  .then(function (d) { renderInvitesList(d.invites || []); })
3703
3712
  .catch(function () { renderInvitesList([]); }),
3704
- loadEngagements()
3713
+ loadEngagements(),
3714
+ loadGuildQueue()
3705
3715
  ]);
3706
3716
  }
3707
3717
 
@@ -3826,6 +3836,286 @@ summary{min-height:24px;padding:3px 0;}
3826
3836
  .catch(function () { renderEngagements([]); });
3827
3837
  }
3828
3838
 
3839
+ /* ── a guild inviting you, and a join request to a guild you own (task 1004583;
3840
+ ADR 0336 D3/D4 §3, ADR 0337 D4.4) ──
3841
+ GET /me/guild-requests names the invites addressed to you, each carrying its
3842
+ card: the guild's name, description and CURRENT visibility. Accept echoes back
3843
+ the visibility this card showed — the consent is to what the card said, so a
3844
+ guild that changed since is a 409 and the card re-draws for a fresh decision —
3845
+ and the counted_in_totals choice made on the card. The D4.4 notice is said for
3846
+ the choice as it stands, BEFORE accept, in guild.html's own words (the test
3847
+ holds the two pages to one sentence). The guilds you OWN answer their join
3848
+ requests here too, from the same read's owner_incoming — one request for
3849
+ every guild you own, never one per guild. The requester's own switch rode
3850
+ their request, so the owner's card has none. A decline is silent on both sides (204, the other side is never
3851
+ told). textContent / createElement only. Proof: tests/hub_guild_cards.mjs */
3852
+ var GUILD_COUNTED_ON = 'Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. ' +
3853
+ 'They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. ' +
3854
+ 'Leave them out of any guild you’d rather not share them with.';
3855
+ var GUILD_COUNTED_OFF = 'Your numbers are left out of this guild’s totals, from its next weekly count.';
3856
+ var GUILD_SHOWN = {
3857
+ public: 'It’s public: joining lists your name and picture on its page while your account is public. You can turn that off there.',
3858
+ unlisted: 'It’s unlisted: it has no public page, so joining lists you nowhere.'
3859
+ };
3860
+ var GUILD_OFFLINE = 'Couldn’t reach the platform — check your connection and try again.';
3861
+ /* a POST on the cookie session; a 204 (decline) has no body to read */
3862
+ function guildPost(path, body) {
3863
+ var init = { method: 'POST', credentials: 'same-origin' };
3864
+ if (body) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); }
3865
+ return fetch(API + path, init).then(function (res) {
3866
+ return (res.status === 204 ? Promise.resolve({}) : jsonOrEmpty(res)).then(function (data) {
3867
+ return { ok: res.ok, status: res.status, data: data };
3868
+ });
3869
+ });
3870
+ }
3871
+ function guildButton(label, cls) {
3872
+ var b = document.createElement('button');
3873
+ b.type = 'button';
3874
+ b.className = cls;
3875
+ b.textContent = label;
3876
+ return b;
3877
+ }
3878
+ /* the row's status line, quiet until it has something to say */
3879
+ function guildMsg(main) {
3880
+ var msg = mineEl('p', 'projMsg');
3881
+ msg.setAttribute('role', 'status');
3882
+ msg.setAttribute('aria-live', 'polite');
3883
+ main.appendChild(msg);
3884
+ return msg;
3885
+ }
3886
+ /* Decline: silent to the other side. The row goes; a failure keeps it and says so. */
3887
+ function guildDecline(row, id, btns, msg) {
3888
+ btns.forEach(function (b) { b.disabled = true; });
3889
+ msg.className = 'projMsg';
3890
+ msg.textContent = '';
3891
+ guildPost('/guild-requests/' + encodeURIComponent(id) + '/decline').then(function (res) {
3892
+ if (res.ok) { row.remove(); paintInvitesSection(); return; }
3893
+ btns.forEach(function (b) { b.disabled = false; });
3894
+ if (res.status === 401) { sessionExpired(msg); return; }
3895
+ msg.className = 'projMsg err';
3896
+ msg.textContent = res.status === 404 ? 'That has already been answered, or it ran out.' : 'Couldn’t decline just now — try again in a moment.';
3897
+ }).catch(function () {
3898
+ btns.forEach(function (b) { b.disabled = false; });
3899
+ msg.className = 'projMsg err';
3900
+ msg.textContent = GUILD_OFFLINE;
3901
+ });
3902
+ }
3903
+
3904
+ function guildInviteRow(inv) {
3905
+ var name = inv.name || inv.slug;
3906
+ var vis = inv.visibility;
3907
+ var row = mineRow({ name: 'You’re invited to join ' + name });
3908
+ row.className = 'mineRow guildRow guildRow--invite';
3909
+ var main = row.querySelector('.mineRow__main');
3910
+ if (inv.description) {
3911
+ var desc = mineEl('p', 'guildRow__desc');
3912
+ desc.textContent = inv.description;
3913
+ main.appendChild(desc);
3914
+ }
3915
+ var shown = mineEl('p', 'mineRow__hint');
3916
+ main.appendChild(shown);
3917
+ /* the counted switch — the guild page's pair, in its words, defaulting on as
3918
+ the membership does; the notice under it is said for the choice as it stands */
3919
+ var band = mineEl('div', 'guildCount');
3920
+ var label = mineEl('span', 'guildCount__label');
3921
+ label.id = 'guildCount-' + inv.id;
3922
+ label.textContent = 'In this guild’s totals';
3923
+ band.appendChild(label);
3924
+ var group = mineEl('div', 'guildCount__opts');
3925
+ group.setAttribute('role', 'radiogroup');
3926
+ group.setAttribute('aria-labelledby', label.id);
3927
+ var radios = [['on', 'Count my numbers', 'They add to this guild’s totals.'], ['off', 'Leave them out', 'The totals add up everyone else.']].map(function (o) {
3928
+ var opt = mineEl('label', 'guildCount__opt');
3929
+ var input = document.createElement('input');
3930
+ input.type = 'radio';
3931
+ input.name = 'guildCounted-' + inv.id;
3932
+ input.value = o[0];
3933
+ input.checked = o[0] === 'on';
3934
+ var text = mineEl('span');
3935
+ var b = mineEl('b');
3936
+ b.textContent = o[1];
3937
+ text.appendChild(b);
3938
+ text.appendChild(document.createTextNode(o[2]));
3939
+ opt.appendChild(input);
3940
+ opt.appendChild(text);
3941
+ group.appendChild(opt);
3942
+ return input;
3943
+ });
3944
+ band.appendChild(group);
3945
+ var what = mineEl('p', 'guildCount__what');
3946
+ band.appendChild(what);
3947
+ main.appendChild(band);
3948
+ var msg = guildMsg(main);
3949
+ function counted() { return !radios[1].checked; }
3950
+ function paintWhat() { what.textContent = counted() ? GUILD_COUNTED_ON : GUILD_COUNTED_OFF; }
3951
+ radios.forEach(function (r) { r.addEventListener('change', paintWhat); });
3952
+ paintWhat();
3953
+
3954
+ var actions = mineEl('div', 'mineRow__actions');
3955
+ var page = document.createElement('a');
3956
+ page.className = 'linklike';
3957
+ page.href = '/g/' + encodeURIComponent(inv.slug);
3958
+ page.textContent = 'See its page';
3959
+ function paintShown() { shown.textContent = GUILD_SHOWN[vis]; page.hidden = vis !== 'public'; }
3960
+ paintShown();
3961
+ var accept = guildButton('Accept', 'btn small');
3962
+ var decline = guildButton('Decline', 'btn small ghost');
3963
+ function idle() {
3964
+ accept.disabled = false;
3965
+ decline.disabled = false;
3966
+ accept.textContent = 'Accept';
3967
+ }
3968
+ /* an invite that is gone (withdrawn, run out, answered elsewhere) cannot be
3969
+ answered again: say so and leave nothing pressable */
3970
+ function ended() {
3971
+ accept.hidden = true;
3972
+ decline.hidden = true;
3973
+ radios.forEach(function (r) { r.disabled = true; });
3974
+ msg.className = 'projMsg';
3975
+ msg.textContent = 'That invite has ended — it was withdrawn, or it ran out.';
3976
+ }
3977
+ accept.addEventListener('click', function () {
3978
+ var on = counted();
3979
+ accept.disabled = true;
3980
+ decline.disabled = true;
3981
+ accept.textContent = 'Joining…';
3982
+ msg.className = 'projMsg';
3983
+ msg.textContent = '';
3984
+ guildPost('/guild-requests/' + encodeURIComponent(inv.id) + '/accept', { visibility: vis, counted_in_totals: on }).then(function (res) {
3985
+ if (res.ok) {
3986
+ /* no longer pressable, so no longer the accent (the One Warm Thing rule) */
3987
+ accept.className = 'btn small ghost';
3988
+ accept.textContent = 'Joined';
3989
+ decline.hidden = true;
3990
+ radios.forEach(function (r) { r.disabled = true; });
3991
+ page.hidden = false;
3992
+ msg.className = 'projMsg ok';
3993
+ msg.textContent = 'You’ve joined ' + name + (on ? ', and your numbers count toward its totals.' : ', and your numbers are left out of its totals.');
3994
+ return;
3995
+ }
3996
+ if (res.status === 401) { idle(); sessionExpired(msg); return; }
3997
+ var err = apiError(res.data, res.status);
3998
+ if (res.status === 409 && err.code === 'guild_visibility_changed' && err.details && GUILD_SHOWN[err.details.visibility]) {
3999
+ vis = err.details.visibility;
4000
+ paintShown();
4001
+ idle();
4002
+ msg.className = 'projMsg err';
4003
+ msg.textContent = 'This guild changed since the card was drawn: it’s ' + vis + ' now. Read it again, then accept.';
4004
+ return;
4005
+ }
4006
+ if (res.status === 404) { ended(); return; }
4007
+ idle();
4008
+ msg.className = 'projMsg err';
4009
+ msg.textContent = 'Couldn’t accept just now — try again in a moment.';
4010
+ }).catch(function () {
4011
+ idle();
4012
+ msg.className = 'projMsg err';
4013
+ msg.textContent = GUILD_OFFLINE;
4014
+ });
4015
+ });
4016
+ decline.addEventListener('click', function () { guildDecline(row, inv.id, [accept, decline], msg); });
4017
+ actions.appendChild(page);
4018
+ actions.appendChild(accept);
4019
+ actions.appendChild(decline);
4020
+ row.appendChild(actions);
4021
+ return row;
4022
+ }
4023
+ /* a row id is a bigserial (digits, within the bigint range the route checks);
4024
+ a card with no id, no slug or a visibility this page cannot say is dropped
4025
+ rather than drawn with a button that fails */
4026
+ function guildRowId(r) {
4027
+ return !!r && typeof r.id === 'string' && /^\d{1,19}$/.test(r.id) && (r.id.length < 19 || r.id <= '9223372036854775807');
4028
+ }
4029
+ function renderGuildInvites(rows) {
4030
+ var ul = $('guildInviteList');
4031
+ ul.textContent = '';
4032
+ (rows || []).forEach(function (inv) {
4033
+ if (!guildRowId(inv) || typeof inv.slug !== 'string' || !inv.slug || !GUILD_SHOWN[inv.visibility]) return;
4034
+ ul.appendChild(guildInviteRow(inv));
4035
+ });
4036
+ paintInvitesSection();
4037
+ }
4038
+
4039
+ function guildRequestRow(r) {
4040
+ var who = r.display_name || '@' + r.handle;
4041
+ var row = mineRow({ name: who + ' asked to join ' + (r.guild.name || r.guild.slug) });
4042
+ row.className = 'mineRow guildRow';
4043
+ var main = row.querySelector('.mineRow__main');
4044
+ /* the handle under a display name, never twice */
4045
+ if (r.display_name) {
4046
+ var handle = mineEl('span', 'mineRow__host');
4047
+ handle.textContent = '@' + r.handle;
4048
+ main.appendChild(handle);
4049
+ }
4050
+ var hint = mineEl('p', 'mineRow__hint');
4051
+ hint.textContent = 'Accepting makes them a member. A decline is never told to them.';
4052
+ main.appendChild(hint);
4053
+ var msg = guildMsg(main);
4054
+ var actions = mineEl('div', 'mineRow__actions');
4055
+ var accept = guildButton('Accept', 'btn small');
4056
+ var decline = guildButton('Decline', 'btn small ghost');
4057
+ accept.addEventListener('click', function () {
4058
+ accept.disabled = true;
4059
+ decline.disabled = true;
4060
+ accept.textContent = 'Accepting…';
4061
+ msg.className = 'projMsg';
4062
+ msg.textContent = '';
4063
+ guildPost('/guild-requests/' + encodeURIComponent(r.id) + '/accept').then(function (res) {
4064
+ if (res.ok) {
4065
+ accept.className = 'btn small ghost';
4066
+ accept.textContent = 'Accepted';
4067
+ decline.hidden = true;
4068
+ hint.hidden = true;
4069
+ msg.className = 'projMsg ok';
4070
+ msg.textContent = who + ' is in ' + (r.guild.name || r.guild.slug) + ' now.';
4071
+ return;
4072
+ }
4073
+ accept.textContent = 'Accept';
4074
+ if (res.status === 404) {
4075
+ accept.hidden = true;
4076
+ decline.hidden = true;
4077
+ msg.className = 'projMsg';
4078
+ msg.textContent = 'That request has ended — it was withdrawn, it ran out, or their account is closed.';
4079
+ return;
4080
+ }
4081
+ accept.disabled = false;
4082
+ decline.disabled = false;
4083
+ if (res.status === 401) { sessionExpired(msg); return; }
4084
+ msg.className = 'projMsg err';
4085
+ msg.textContent = 'Couldn’t accept just now — try again in a moment.';
4086
+ }).catch(function () {
4087
+ accept.disabled = false;
4088
+ decline.disabled = false;
4089
+ accept.textContent = 'Accept';
4090
+ msg.className = 'projMsg err';
4091
+ msg.textContent = GUILD_OFFLINE;
4092
+ });
4093
+ });
4094
+ decline.addEventListener('click', function () { guildDecline(row, r.id, [accept, decline], msg); });
4095
+ actions.appendChild(accept);
4096
+ actions.appendChild(decline);
4097
+ row.appendChild(actions);
4098
+ return row;
4099
+ }
4100
+ function renderGuildRequests(rows) {
4101
+ var ul = $('guildRequestList');
4102
+ ul.textContent = '';
4103
+ (rows || []).forEach(function (r) {
4104
+ if (!guildRowId(r) || typeof r.handle !== 'string' || !r.handle) return;
4105
+ if (!r.guild || typeof r.guild.slug !== 'string' || !r.guild.slug) return;
4106
+ ul.appendChild(guildRequestRow(r));
4107
+ });
4108
+ paintInvitesSection();
4109
+ }
4110
+ /* ONE read for both lists: the invites to you, and the requests waiting on you
4111
+ as the owner of a guild; a read that fails is silent, as the other lists' are */
4112
+ function loadGuildQueue() {
4113
+ return fetch(API + '/me/guild-requests', { credentials: 'same-origin' })
4114
+ .then(function (res) { return res.ok ? res.json() : {}; })
4115
+ .then(function (d) { renderGuildInvites(d.incoming || []); renderGuildRequests(d.owner_incoming || []); })
4116
+ .catch(function () { renderGuildInvites([]); renderGuildRequests([]); });
4117
+ }
4118
+
3829
4119
  /* the one relay both the map's per-card Apply (submitApply) and the
3830
4120
  address-typed join field drive — POST /my-projects/join, the 401 case and
3831
4121
  the 'requested' echo update kept in ONE place (task 1003774). Keys the