@bongos/core 1.21.57 → 1.21.59

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 (52) hide show
  1. package/.bongos-core.json +91 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
  8. package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +233 -6
  11. package/docs/api-reference.md +8 -5
  12. package/docs/copy-inventory.md +384 -376
  13. package/docs/copy-registry.json +514 -432
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +930 -919
  18. package/modules/hall-ui/public/goals-page.js +20 -1
  19. package/modules/hall-ui/public/task-lib.js +13 -0
  20. package/modules/hall-ui/public/work.js +22 -1
  21. package/modules/platform-identity/application-echo.js +11 -5
  22. package/modules/platform-identity/guild-engagements.js +255 -0
  23. package/modules/platform-identity/guilds.js +3 -1
  24. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  25. package/modules/platform-identity/routes/guilds.js +35 -1
  26. package/modules/platform-identity/routes/my-projects.js +27 -3
  27. package/modules/platform-identity/routes/projects.js +44 -0
  28. package/modules/platform-identity/routes/sso.js +21 -3
  29. package/modules/public-landing/public/assets/cosmos.css +7 -0
  30. package/modules/public-landing/public/guild.html +64 -2
  31. package/modules/public-landing/public/guild.probes.json +13 -3
  32. package/modules/public-landing/public/guild.states.json +10 -5
  33. package/modules/public-landing/public/projects.html +252 -16
  34. package/modules/public-landing/public/projects.probes.json +26 -1
  35. package/modules/public-landing/public/projects.states.json +4 -0
  36. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  37. package/modules/ui-design/kit/serve.js +54 -5
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +12 -0
  41. package/scripts/gds/run-unit-tests.js +5 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/applicant_profile_boundary.mjs +104 -1
  44. package/tests/application_echo.mjs +91 -5
  45. package/tests/focus_version_filter_default.mjs +47 -0
  46. package/tests/guild_engagement.mjs +881 -0
  47. package/tests/guild_engagement_db.mjs +240 -0
  48. package/tests/hall_mine_lens.mjs +1 -0
  49. package/tests/hub_engagement_cards.mjs +307 -0
  50. package/tests/hub_guild_page.mjs +94 -7
  51. package/tests/privacy_matrix.mjs +99 -9
  52. package/tests/project_invite_ui.mjs +187 -3
@@ -17,6 +17,9 @@
17
17
  // POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
18
18
  // where to invite — the project's hall (invite-authz.js,
19
19
  // invite-notices.js); writes nothing (ADR 0353)
20
+ // POST /projects/invite-guild — the same gate: invite a PUBLIC guild — records the
21
+ // `invited` engagement and answers one hall link per
22
+ // member on its public roster (ADR 0365 D3)
20
23
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
21
24
  // PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
22
25
  // §5.1). Setting 'hidden' pins the row so the ADR 0246
@@ -33,6 +36,8 @@ const projectCatalog = require('../project-catalog');
33
36
  const projectStats = require('../project-stats');
34
37
  const inviteAuthz = require('../invite-authz');
35
38
  const inviteNotices = require('../invite-notices');
39
+ const guilds = require('../guilds');
40
+ const guildEngagements = require('../guild-engagements');
36
41
 
37
42
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
38
43
  parsePagination, pageMeta } = api;
@@ -168,6 +173,45 @@ module.exports = function projectsRoutes() {
168
173
  },
169
174
  );
170
175
 
176
+ // rank: metic+archon — own-or-metic in effect, the SAME gate as POST /projects/invite
177
+ // above and for the same reasons (a non-inviter gets that route's unchanged refusal,
178
+ // identical whether or not the project exists): a project invites a PUBLIC guild
179
+ // (task 1002303, ADR 0365 D3, amending ADR 0336 D7).
180
+ //
181
+ // IT WRITES NO INVITES (ADR 0353). It records the `invited` engagement — so members
182
+ // the inviter could not see get a "P invited this guild" notice and may apply
183
+ // themselves, and only while P is on the map and Public (ADR 0365 D4) — and answers
184
+ // one hall hand-off link per member on the guild's PUBLIC roster at this moment.
185
+ // The inviter sends each from the project's hall, so every invite is the project's
186
+ // own row. Members who join later inherit nothing. An unlisted guild cannot be
187
+ // addressed, so it answers the unknown-guild 404 byte for byte.
188
+ router.post(
189
+ '/projects/invite-guild',
190
+ requireBuilder,
191
+ inviteAuthz.requireProjectInviter({ curateGate: api.requirePermission('project.curate'), pool, log }),
192
+ async (req, res) => {
193
+ if (validateOrRespond(req, res, {
194
+ client_id: { required: true, type: 'string', maxLength: 200 },
195
+ guild: { required: true, type: 'string', maxLength: 39 },
196
+ })) return;
197
+ try {
198
+ const client = req.inviteTargetClient || await pi.getClient(req.body.client_id, { pool });
199
+ if (!client || client.status !== 'active' || !inviteNotices.hallInviteUrl(client.origin)) {
200
+ return res.fail('unknown_project', 404);
201
+ }
202
+ const slug = guilds.lookupSlug(req.body.guild);
203
+ const out = slug ? await guildEngagements.inviteGuild({
204
+ clientId: client.client_id, origin: client.origin, slug,
205
+ }, { pool }) : null;
206
+ if (!out) return res.fail('guild_not_found', 404);
207
+ return res.json({ invited: false, guild: out.guild, invites: out.invites, count: out.invites.length });
208
+ } catch (err) {
209
+ log.error({ err: err && err.message }, 'POST /projects/invite-guild failed');
210
+ res.fail('invite_guild_failed', { status: 500, message: 'internal error' });
211
+ }
212
+ },
213
+ );
214
+
171
215
  // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
172
216
  // BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
173
217
  // projects already on the map; it is not a way ONTO it, so featuring a dark-matter
@@ -27,6 +27,8 @@ const hubDevice = require('../hub-device');
27
27
  const applicantProfile = require('../applicant-profile');
28
28
  const terms = require('../terms-acceptance');
29
29
  const applicationEcho = require('../application-echo');
30
+ // The guild label the echo carries (task 1002303, ADR 0365 D5).
31
+ const guildEngagements = require('../guild-engagements');
30
32
  const instanceFeedback = require('../instance-feedback');
31
33
 
32
34
  // One limiter per hub process for POST /sso/feedback (task 1004462).
@@ -638,8 +640,11 @@ module.exports = function ssoRoutes() {
638
640
  // profile view for its own reviewer queue, LIVE at render (privacy spec D7,
639
641
  // task 1002972). Server-to-server, client_id+client_secret auth, like the two
640
642
  // routes above. Answers { profile: null | { view:'public', … } | { view:
641
- // 'sliver', … } } — the D7 rule, decided entirely inside
642
- // ../applicant-profile.js.
643
+ // 'sliver', … }, guild } — the D7 rule, decided entirely inside
644
+ // ../applicant-profile.js, plus the guild the applicant applied with
645
+ // (ADR 0365 D5): { slug, name } while they are still a member of it (slug null
646
+ // for an unlisted guild), else null. A project on an older core reads only
647
+ // `profile` and ignores the rest (the ADR 0201 D2.2 compatibility rule).
643
648
  //
644
649
  // WHY A POST FOR A READ. The same reason /sso/activity/rollup is one: the
645
650
  // credentials are in the body, and a client_secret in a query string lands in
@@ -693,12 +698,25 @@ module.exports = function ssoRoutes() {
693
698
  );
694
699
  if (!applied) return res.fail('no_application', 404);
695
700
  const profile = await applicantProfile.getApplicantProfileView(b.github_id, { pool });
701
+ // The guild label rides only beside a profile: an applicant who discloses
702
+ // nothing discloses no guild either. BEST-EFFORT — a label is a convenience
703
+ // on the reviewer's queue, so a failed read answers null and never costs
704
+ // the profile. Read only here, after the echo gate above.
705
+ let guild = null;
706
+ if (profile) {
707
+ try {
708
+ guild = await guildEngagements.echoGuildLabel({ githubId: b.github_id, clientId: client.client_id }, { pool });
709
+ } catch (e) {
710
+ log.error('[platform-identity] /sso/applicant-profile guild label read failed (non-fatal)', e && e.message);
711
+ guild = null;
712
+ }
713
+ }
696
714
  // The rollup half is live-derived and hide must beat any cache (ADR 0171
697
715
  // D5) — and D7's "live at review time" says the same thing louder: a
698
716
  // cached applicant view is a snapshot, which is the one thing the rule
699
717
  // forbids.
700
718
  res.set('Cache-Control', 'no-store');
701
- res.json({ profile });
719
+ res.json({ profile, guild });
702
720
  } catch (err) {
703
721
  log.error('[platform-identity] /sso/applicant-profile', err);
704
722
  res.fail('applicant_profile_failed', { status: 500, message: 'internal error' });
@@ -1325,6 +1325,13 @@ 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
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);}
1332
+ .engageRow .mineJoinNote{max-width:62ch;}
1333
+ .engageRow .projMsg{margin-top:6px;min-height:0;}
1334
+ .engageRow .projMsg:empty{margin:0;}
1328
1335
 
1329
1336
  /* ── the ledger row: planet · name and chip · the facts · the rail · the
1330
1337
  doors. The planet and the doors are fixed widths; the three middle tracks
@@ -13,7 +13,9 @@
13
13
  GET /guilds/:slug/members the OWN-SCOPE read, signed in only: a member's
14
14
  door to their guild, unlisted ones included,
15
15
  with their own two switches;
16
- and writes one: PATCH /guilds/:slug/membership (those two switches). A
16
+ and writes two: PATCH /guilds/:slug/membership (those two switches) and, for
17
+ the guild's owner only, POST /guilds/:slug/engagements (the guild applying to
18
+ a project, task 1002303, R27; ADR 0365 D2). A
17
19
  stranger, an unknown slug and an unlisted guild seen from outside are one
18
20
  "no guild here", so the page never confirms an unlisted guild exists.
19
21
  Hub-only, like /account: platform-server.js serves /g/<slug> only where
@@ -75,6 +77,12 @@
75
77
  .gAsk{display:grid;gap:14px;padding:18px 0 20px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
76
78
  .gAsk p{margin:0;font-size:14.5px;line-height:1.55;color:var(--ink-soft);max-width:62ch;}
77
79
  .gAsk p b{display:block;margin-bottom:4px;font-family:var(--font-body);font-weight:500;font-size:17px;color:var(--ink);}
80
+ /* the owner's one field (apply to a project): the account page's .aField, a bare
81
+ input closed by one hairline that takes ink on focus */
82
+ .aField{display:grid;gap:4px;min-width:0;max-width:52ch;}
83
+ .aField input{width:100%;box-sizing:border-box;min-height:44px;font-family:var(--font-body);font-size:15.5px;font-weight:400;line-height:1.5;color:var(--ink);background:none;border:0;border-bottom:1px solid var(--ctl-line);border-radius:0;padding:8px 0;}
84
+ .aField input::placeholder{color:var(--ink-faint);}
85
+ .aField input:focus{outline:none;border-bottom-color:var(--ink);box-shadow:0 1px 0 var(--ink);}
78
86
  /* the record: four band floors as figures, two by two so a long band ("200,000+")
79
87
  keeps its cell, a hairline above each (the ledger grammar) */
80
88
  .gFigs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 28px;margin:0;}
@@ -215,6 +223,22 @@
215
223
  </form>
216
224
  </section>
217
225
 
226
+ <!-- APPLY TO A PROJECT — the guild's owner only (task 1002303, R27; ADR 0365
227
+ D2): POST /guilds/:slug/engagements { origin }. It applies for nobody —
228
+ each member sees it in their invitations on the projects page and applies
229
+ as themselves, the owner too. A project that is unknown, not on the map or
230
+ not public gets one answer, so the page never says which. -->
231
+ <section class="aSec" id="gApply" hidden aria-labelledby="gApply-h">
232
+ <h2 id="gApply-h">apply to a project</h2>
233
+ <p class="aSide">Put this guild forward for a project. It applies for nobody: each member sees it in their invitations and applies as themselves, you included.</p>
234
+ <form class="aBody" id="applyForm" novalidate>
235
+ <label class="aField"><span class="aLabel">Project address</span>
236
+ <input type="url" id="applyOrigin" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://project.example.com"></label>
237
+ <div class="actions"><button type="submit" class="btn" id="applySend">apply as this guild</button></div>
238
+ <p class="projMsg" id="applyMsg" role="status" aria-live="polite"></p>
239
+ </form>
240
+ </section>
241
+
218
242
  <!-- THE RECORD — R26's rounded weekly totals (ADR 0337 D4.4), from the public
219
243
  read only: band floors ("100+"), never an exact sum, and no count of who
220
244
  is in them. An unlisted guild publishes none. -->
@@ -367,7 +391,7 @@
367
391
  }
368
392
 
369
393
  // ---- paint: every region from the server's answers ----
370
- function paint() { paintHead(); paintYou(); paintRecord(); paintRoster(); }
394
+ function paint() { paintHead(); paintYou(); paintApply(); paintRecord(); paintRoster(); }
371
395
 
372
396
  function guild() { return (mine && mine.guild) || pub.guild; }
373
397
 
@@ -498,6 +522,44 @@
498
522
  say($('youMsg'), 'You’re staying off its public page. You can change that here at any time.');
499
523
  });
500
524
 
525
+ // ---- apply to a project: the owner files, and it applies for nobody ----
526
+ // Shown from the own-scope read only, to the owner; the route refuses anyone
527
+ // else on its own (a member is told the act is the owner's, a stranger 404s).
528
+ function paintApply() {
529
+ $('gApply').hidden = !(mine && mine.you.membership_kind === 'owner');
530
+ }
531
+ $('applyForm').addEventListener('submit', function (ev) {
532
+ ev.preventDefault();
533
+ var msg = $('applyMsg');
534
+ var field = $('applyOrigin');
535
+ var origin = String(field.value || '').trim();
536
+ if (!origin) { say(msg, 'Enter the project’s address.', 'err'); return; }
537
+ // the hub's join field's rule: a bare host is assumed https://, the form the
538
+ // server's exact origin lookup matches
539
+ if (!/^https?:\/\//i.test(origin)) origin = 'https://' + origin;
540
+ var btn = $('applySend');
541
+ btn.disabled = true;
542
+ say(msg, 'Sending…');
543
+ return request('POST', '/guilds/' + slug + '/engagements', { origin: origin }).then(function (res) {
544
+ btn.disabled = false;
545
+ if (res.status === 401) { say(msg, 'Your session expired — sign in again to send this.', 'err'); bar.paint(null); return; }
546
+ var e = res.ok && res.data && res.data.engagement;
547
+ if (e) {
548
+ field.value = '';
549
+ var gName = (e.guild && e.guild.name) || guild().name;
550
+ var pName = (e.project && e.project.name) || origin;
551
+ say(msg, 'Done. ' + gName + '’s members will see ' + pName + ' in their invitations, and each one applies as themselves. ' +
552
+ 'That includes you — this didn’t apply for you.', 'ok');
553
+ return;
554
+ }
555
+ // unknown, inactive, off the map or not public are ONE refusal on the
556
+ // server, so they are one line here: it never says a private project exists
557
+ var code = res.data && res.data.error && res.data.error.code;
558
+ if (res.status === 404 && code === 'unknown_project') { say(msg, 'No public project on the map at that address.', 'err'); return; }
559
+ say(msg, serverWords(res, 'Couldn’t send that. Try again.'), 'err');
560
+ }, function () { btn.disabled = false; say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
561
+ });
562
+
501
563
  // ---- the record ----
502
564
  function paintRecord() {
503
565
  var t = pub && pub.totals;
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The guild page's interaction contract for modules/ui-design/kit/probe.js (task 1002301): signed out, the public page and the sign-in doors pointing back at the guild; signed in, the member's two switches driven end to end against the stub's held guilds (serve.js keeps fixtures/guilds.json for the run and refuses what PATCH /guilds/:slug/membership refuses), so a save the page re-reads comes back saved. The probes are a SEQUENCE over one stub: the prompt's yes lists the builder, later probes put them back off, and the 'not now' probe runs in a fresh browser (no remembered answer). A probe with `continue` keeps the previous page.",
2
+ "_": "The guild page's interaction contract for modules/ui-design/kit/probe.js (task 1002301): signed out, the public page and the sign-in doors pointing back at the guild; signed in, the member's two switches driven end to end against the stub's held guilds (serve.js keeps fixtures/guilds.json for the run and refuses what PATCH /guilds/:slug/membership refuses), so a save the page re-reads comes back saved. The probes are a SEQUENCE over one stub: the prompt's yes lists the builder, later probes put them back off, and the 'not now' probe runs in a fresh browser (no remembered answer). The owner of night-shift (task 1002303, R27) puts the guild forward for a project: an empty field sends nothing, a project not public on the map is one honest line, and a bare host is taken as https:// — the stub mirrors POST /guilds/:slug/engagements' refusals (a member's 403, one 404 for any project not public on the map). A probe with `continue` keeps the previous page.",
3
3
  "page": "/guild",
4
4
  "surface": "public-landing",
5
5
  "probes": [
@@ -16,7 +16,7 @@
16
16
  "expect": { "visible": ["#missing"], "hidden": ["#guildBody"] } },
17
17
  { "name": "signed in, a member not on the public page: asked first, the pair waits", "auth": true, "width": 1440, "url": "/g/forge-crew",
18
18
  "steps": [["wait", 500]],
19
- "expect": { "visible": ["#gYou", "#showAsk", "#askYes", "#askNo"], "hidden": ["#shownBand"], "count": [["#gRoster li", 6]],
19
+ "expect": { "visible": ["#gYou", "#showAsk", "#askYes", "#askNo"], "hidden": ["#shownBand", "#gApply"], "count": [["#gRoster li", 6]],
20
20
  "text": [["#countWhat", "Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. Leave them out of any guild you’d rather not share them with."]] } },
21
21
  { "name": "show me: the switch saves, the prompt gives way to the pair, set to Show me", "continue": true,
22
22
  "steps": [["click", "#askYes"], ["wait", 500]],
@@ -36,7 +36,17 @@
36
36
  "text": [["#youMsg", "You’re staying off its public page. You can change that here at any time."]] } },
37
37
  { "name": "the owner of an unlisted guild: no public page, so no pair and no record", "auth": true, "width": 1440, "url": "/g/night-shift",
38
38
  "steps": [["wait", 500]],
39
- "expect": { "visible": ["#gYou", "#shownUnlisted"], "hidden": ["#shownBand", "#showAsk", "#gFigs"], "text": [["#gKicker", "Guild · unlisted"], ["#gYouSide", "You own this guild. Only you see this, and only you can change it."]] } },
39
+ "expect": { "visible": ["#gYou", "#shownUnlisted", "#gApply", "#applyOrigin"], "hidden": ["#shownBand", "#showAsk", "#gFigs"], "text": [["#gKicker", "Guild · unlisted"], ["#gYouSide", "You own this guild. Only you see this, and only you can change it."]] } },
40
+ { "name": "apply to a project: an empty field asks for the address and sends nothing", "continue": true,
41
+ "steps": [["click", "#applySend"], ["wait", 150]],
42
+ "expect": { "text": [["#applyMsg", "Enter the project’s address."]], "hasClass": [["#applyMsg", "err"]] } },
43
+ { "name": "apply to a project: a project that is not public on the map is one honest line, and the address stays", "continue": true,
44
+ "steps": [["fill", "#applyOrigin", "https://hidden.example.com"], ["click", "#applySend"], ["wait", 400]],
45
+ "expect": { "text": [["#applyMsg", "No public project on the map at that address."]], "value": [["#applyOrigin", "https://hidden.example.com"]] } },
46
+ { "name": "apply to a project: a bare host is taken as https://, and the line says it applied for nobody", "continue": true,
47
+ "steps": [["fill", "#applyOrigin", "demo.cloudbongos.com"], ["click", "#applySend"], ["wait", 400]],
48
+ "expect": { "hasClass": [["#applyMsg", "ok"]], "value": [["#applyOrigin", ""]],
49
+ "text": [["#applyMsg", "Done. Night Shift’s members will see Demo in their invitations, and each one applies as themselves. That includes you — this didn’t apply for you."]] } },
40
50
  { "name": "a public guild you are not in: the public page, nothing of yours", "auth": true, "width": 1440, "url": "/g/open-hands",
41
51
  "steps": [["wait", 500]],
42
52
  "expect": { "visible": ["#guildBody"], "hidden": ["#gYou", "#missing"], "text": [["#gRecordNote", "Not counted yet. A guild’s record is counted once a week."]] } },
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The guild page's states for modules/ui-design/kit/render.js (task 1002301, R25; ADR 0336 D4, ADR 0337 D4.4). One shell serves every /g/<slug>; the stub answers three guilds from fixtures/guilds.json. Signed out: forge-crew is the public page (four listed members, the rounded record), and night-shift — unlisted — is the same 'no guild here' an unknown slug gets, with sign-in. Signed in as the fixture builder: forge-crew is the members' view (everyone, six, with the prompt to be listed because they are a member who is not on its public page; the pair for that switch waits until the prompt is answered), night-shift is their unlisted guild (no public page, no published record), open-hands is a public guild they are not in (the public view, not counted yet), and an unknown slug is 'no guild here' without the sign-in. Dark only, like every apex page (ADR 0204).",
2
+ "_": "The guild page's states for modules/ui-design/kit/render.js (task 1002301, R25; ADR 0336 D4, ADR 0337 D4.4). One shell serves every /g/<slug>; the stub answers three guilds from fixtures/guilds.json. Signed out: forge-crew is the public page (four listed members, the rounded record), and night-shift — unlisted — is the same 'no guild here' an unknown slug gets, with sign-in. Signed in as the fixture builder: forge-crew is the members' view (everyone, six, with the prompt to be listed because they are a member who is not on its public page; the pair for that switch waits until the prompt is answered), night-shift is their unlisted guild (no public page, no published record), open-hands is a public guild they are not in (the public view, not counted yet), and an unknown slug is 'no guild here' without the sign-in. As night-shift's owner they also see 'apply to a project' (task 1002303, R27): the field, and the guild put forward for a project on the map — it applies for nobody, so the line says each member applies as themselves. Dark only, like every apex page (ADR 0204).",
3
3
  "page": "/guild",
4
4
  "surface": "public-landing",
5
5
  "modes": ["dark"],
@@ -9,28 +9,33 @@
9
9
  "ignoreControls": [".vh"],
10
10
  "states": {
11
11
  "out-public": { "url": "/g/forge-crew", "auth": false, "actions": [["wait", 400]],
12
- "expect": { "visible": ["#guildBody", "#gName", "#gRoster", "#gFigs", "#signinTop"], "hidden": ["#gYou", "#missing", "#loading", "#who"],
12
+ "expect": { "visible": ["#guildBody", "#gName", "#gRoster", "#gFigs", "#signinTop"], "hidden": ["#gYou", "#gApply", "#missing", "#loading", "#who"],
13
13
  "text": [["#gName", "Forge Crew"], ["#gKicker", "Guild"], ["#gMeta", "4 members · since Sep 2026"], ["#tShipped", "100+"], ["#tCredits", "2,000+"], ["#tKarma", "50+"], ["#tProjects", "under 10"], ["#gRecordNote", "Counted on 1 Oct 2026. Projects are the public projects its listed members build on."]],
14
14
  "count": [["#gRoster li", 4]] } },
15
15
  "out-unlisted": { "url": "/g/night-shift", "auth": false, "actions": [["wait", 400]],
16
16
  "expect": { "visible": ["#missing", "#missingSignin"], "hidden": ["#guildBody", "#loading"],
17
17
  "text": [["#missingWhy", "There is no public guild at this address. If it is an unlisted guild you belong to, sign in to see it."]] } },
18
18
  "in-member": { "url": "/g/forge-crew", "auth": true, "actions": [["wait", 500]],
19
- "expect": { "visible": ["#guildBody", "#gYou", "#showAsk", "#countWhat", "#who"], "hidden": ["#shownBand", "#shownUnlisted", "#missing", "#signinTop"],
19
+ "expect": { "visible": ["#guildBody", "#gYou", "#showAsk", "#countWhat", "#who"], "hidden": ["#shownBand", "#shownUnlisted", "#gApply", "#missing", "#signinTop"],
20
20
  "text": [["#gKicker", "Guild · public"], ["#gMeta", "6 members · 4 on the public page · since Sep 2026"], ["#gYouSide", "You’re a member of this guild. Only you see this, and only you can change it."]],
21
21
  "checked": "input[name=shown][value=off]",
22
22
  "count": [["#gRoster li", 6]] } },
23
23
  "in-unlisted-owner": { "url": "/g/night-shift", "auth": true, "actions": [["wait", 500]],
24
- "expect": { "visible": ["#guildBody", "#gYou", "#shownUnlisted"], "hidden": ["#showAsk", "#shownBand", "#gFigs", "#gDesc"],
24
+ "expect": { "visible": ["#guildBody", "#gYou", "#shownUnlisted", "#gApply"], "hidden": ["#showAsk", "#shownBand", "#gFigs", "#gDesc"],
25
25
  "text": [["#gKicker", "Guild · unlisted"], ["#shownUnlisted", "This guild is unlisted, so it has no public page. If you make it public, you’ll be listed on it as its owner, and each member will be asked before they are."], ["#gMeta", "3 members"], ["#gRecordNote", "This guild is unlisted, so its record isn’t published anywhere."], ["#countWhat", "Your numbers are left out of this guild’s totals, from its next weekly count."]],
26
26
  "checked": "input[name=counted][value=off]" } },
27
27
  "in-not-member": { "url": "/g/open-hands", "auth": true, "actions": [["wait", 500]],
28
- "expect": { "visible": ["#guildBody", "#gRoster"], "hidden": ["#gYou", "#missing"],
28
+ "expect": { "visible": ["#guildBody", "#gRoster"], "hidden": ["#gYou", "#gApply", "#missing"],
29
29
  "text": [["#gKicker", "Guild"], ["#gMeta", "2 members · since Oct 2026"], ["#tShipped", "—"], ["#gRecordNote", "Not counted yet. A guild’s record is counted once a week."]],
30
30
  "count": [["#gRoster li", 2]] } },
31
31
  "in-missing": { "url": "/g/no-such-guild", "auth": true, "actions": [["wait", 500]],
32
32
  "expect": { "visible": ["#missing"], "hidden": ["#guildBody", "#missingSignin"],
33
33
  "text": [["#missingWhy", "There is no guild at this address that you can see. A guild that is unlisted is shown only to its members."]] } },
34
+ "in-owner-apply": { "url": "/g/night-shift", "auth": true, "actions": [["wait", 500], ["focus", "#applyOrigin"], ["wait", 150]],
35
+ "expect": { "visible": ["#gApply", "#applyOrigin", "#applySend"], "text": [["#gApply-h", "apply to a project"], ["#applyMsg", ""]], "value": [["#applyOrigin", ""]] } },
36
+ "in-owner-applied": { "url": "/g/night-shift", "auth": true, "actions": [["wait", 500], ["fill", "#applyOrigin", "example.com"], ["click", "#applySend"], ["wait", 400]],
37
+ "expect": { "visible": ["#gApply", "#applyMsg"], "hasClass": [["#applyMsg", "ok"]], "value": [["#applyOrigin", ""]],
38
+ "text": [["#applyMsg", "Done. Night Shift’s members will see Example World in their invitations, and each one applies as themselves. That includes you — this didn’t apply for you."]] } },
34
39
  "in-profile-band": { "url": "/g/forge-crew", "auth": true, "actions": [["wait", 500], ["click", "#who"], ["wait", 300]],
35
40
  "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"]] } }
36
41
  },