@bongos/core 1.21.57 → 1.21.58

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 (47) hide show
  1. package/.bongos-core.json +82 -47
  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 +360 -352
  13. package/docs/copy-registry.json +479 -397
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +2 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +828 -811
  18. package/modules/platform-identity/application-echo.js +11 -5
  19. package/modules/platform-identity/guild-engagements.js +255 -0
  20. package/modules/platform-identity/guilds.js +3 -1
  21. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  22. package/modules/platform-identity/routes/guilds.js +35 -1
  23. package/modules/platform-identity/routes/my-projects.js +27 -3
  24. package/modules/platform-identity/routes/projects.js +44 -0
  25. package/modules/platform-identity/routes/sso.js +21 -3
  26. package/modules/public-landing/public/assets/cosmos.css +7 -0
  27. package/modules/public-landing/public/guild.html +64 -2
  28. package/modules/public-landing/public/guild.probes.json +13 -3
  29. package/modules/public-landing/public/guild.states.json +10 -5
  30. package/modules/public-landing/public/projects.html +252 -16
  31. package/modules/public-landing/public/projects.probes.json +26 -1
  32. package/modules/public-landing/public/projects.states.json +4 -0
  33. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  34. package/modules/ui-design/kit/serve.js +54 -5
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +6 -0
  38. package/scripts/gds/run-unit-tests.js +5 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/applicant_profile_boundary.mjs +104 -1
  41. package/tests/application_echo.mjs +91 -5
  42. package/tests/guild_engagement.mjs +881 -0
  43. package/tests/guild_engagement_db.mjs +240 -0
  44. package/tests/hub_engagement_cards.mjs +307 -0
  45. package/tests/hub_guild_page.mjs +94 -7
  46. package/tests/privacy_matrix.mjs +99 -9
  47. package/tests/project_invite_ui.mjs +187 -3
@@ -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
  },
@@ -963,6 +963,11 @@ 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 you are in, engaging a project (task 1002303, R27; ADR 0365):
967
+ "G is applying to P" and "P invited G", from GET /me/guild-engagements.
968
+ Each card's Apply is the ordinary join request with `guild` added, so
969
+ you apply as yourself, never as the guild. -->
970
+ <ul class="mineRows" id="engageList"></ul>
966
971
  </section>
967
972
 
968
973
  <section aria-labelledby="mineBuildingH">
@@ -1281,6 +1286,17 @@ summary{min-height:24px;padding:3px 0;}
1281
1286
  <ul class="suggList" id="mInviteSugg"></ul>
1282
1287
  <ul class="inviteList" id="mInviteList"></ul>
1283
1288
  <div class="projMsg" id="mInviteMsg" role="status" aria-live="polite"></div>
1289
+ <!-- invite a guild (task 1002303, R27; ADR 0365 D3): POST /projects/invite-guild
1290
+ { client_id, guild } records that the project invited the guild and
1291
+ answers one hall hand-off link per member on the guild's public page,
1292
+ drawn as the same lines as a single invite above. -->
1293
+ <p class="finePrint">Or invite a guild, by its name or its /g/ address. Each member gets their own invite, and only the members shown on the guild’s public page are listed.</p>
1294
+ <div class="attach">
1295
+ <label class="vh" for="mInviteGuild">Guild name or address</label>
1296
+ <input type="text" id="mInviteGuild" placeholder="Guild name or /g/ address" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
1297
+ <button type="button" class="btn small ghost" data-invite-guild="mInvite">Invite the guild</button>
1298
+ </div>
1299
+ <div class="projMsg" id="mInviteGuildMsg" role="status" aria-live="polite"></div>
1284
1300
  </div>
1285
1301
  </div>
1286
1302
 
@@ -3616,10 +3632,9 @@ summary{min-height:24px;padding:3px 0;}
3616
3632
  hub has no accept route at all. Decline still works; it only clears the
3617
3633
  hub's own pending row. */
3618
3634
  function renderInvitesList(invites) {
3619
- var section = $('mineInvites');
3620
3635
  var ul = $('invitesList');
3621
3636
  ul.textContent = '';
3622
- if (!invites || !invites.length) { section.hidden = true; return; }
3637
+ if (!invites || !invites.length) { paintInvitesSection(); return; }
3623
3638
  invites.forEach(function (inv) {
3624
3639
  var row = mineRow(inv);
3625
3640
  var hint = mineEl('p', 'mineRow__hint');
@@ -3643,21 +3658,151 @@ summary{min-height:24px;padding:3px 0;}
3643
3658
  decline.addEventListener('click', function () {
3644
3659
  postThenRemoveRow(
3645
3660
  API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline, null,
3646
- function () { if (!ul.children.length) section.hidden = true; }
3661
+ paintInvitesSection
3647
3662
  );
3648
3663
  });
3649
3664
  actions.appendChild(decline);
3650
3665
  row.appendChild(actions);
3651
3666
  ul.appendChild(row);
3652
3667
  });
3653
- section.hidden = false;
3668
+ paintInvitesSection();
3669
+ }
3670
+
3671
+ /* the Invitations section holds two lists — the projects' own invites and the
3672
+ guild cards below — and shows while either has a row */
3673
+ function paintInvitesSection() {
3674
+ $('mineInvites').hidden = !$('invitesList').children.length && !$('engageList').children.length;
3654
3675
  }
3655
3676
 
3656
3677
  function loadInvites() {
3657
- return fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
3658
- .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
3659
- .then(function (d) { renderInvitesList(d.invites || []); })
3660
- .catch(function () { $('mineInvites').hidden = true; });
3678
+ return Promise.all([
3679
+ fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
3680
+ .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
3681
+ .then(function (d) { renderInvitesList(d.invites || []); })
3682
+ .catch(function () { renderInvitesList([]); }),
3683
+ loadEngagements()
3684
+ ]);
3685
+ }
3686
+
3687
+ /* ── a guild you are in, engaging a project (task 1002303, R27; ADR 0365) ──
3688
+ GET /me/guild-engagements names what the guilds you are in have going: "G is
3689
+ applying to P" (the guild's owner put it forward) and "P invited G" (the
3690
+ project invited the guild; its hall sends each member on the guild's public
3691
+ page their own invite, and the rest of the guild hears it here). The read only
3692
+ names a project that is on the map and public, and leaves out one you already
3693
+ build on, are invited to, or have applied to. Each card's Apply is the SAME
3694
+ relay as everything else in this view — postJoinRequest — with `guild` added:
3695
+ you apply as yourself, never as the guild, with your own note if you like. A
3696
+ guild that no longer holds for you (you left it, or its 30 days ran out) is a
3697
+ 409 and nothing is relayed; the card then offers the plain application.
3698
+ textContent / createElement only. Proof: tests/hub_engagement_cards.mjs */
3699
+ var ENGAGE_GONE = 'That guild application has ended — you can still apply on your own.';
3700
+ function engageLine(e) {
3701
+ var g = e.guild.name || e.guild.slug;
3702
+ var p = (e.project && e.project.name) || hostOf(e.project.origin);
3703
+ return e.kind === 'invited' ? p + ' invited ' + g : g + ' is applying to ' + p + ' — apply with them?';
3704
+ }
3705
+ function sessionExpired(el) {
3706
+ el.className = 'projMsg err';
3707
+ el.textContent = 'Your session expired — ';
3708
+ var a = document.createElement('a');
3709
+ a.href = API + '/auth/web/start?return=/projects%3Fview%3Dmine&repos=1';
3710
+ a.textContent = 'sign in again';
3711
+ el.appendChild(a);
3712
+ el.appendChild(document.createTextNode('.'));
3713
+ }
3714
+ function engageRow(e) {
3715
+ var origin = e.project.origin;
3716
+ var slug = e.guild.slug;
3717
+ var row = mineRow({ name: engageLine(e), origin: origin });
3718
+ row.className = 'mineRow engageRow';
3719
+ var main = row.querySelector('.mineRow__main');
3720
+ var hint = mineEl('p', 'mineRow__hint');
3721
+ hint.textContent = 'You apply as yourself — add a note for the owner if you like.';
3722
+ main.appendChild(hint);
3723
+ var note = document.createElement('textarea');
3724
+ note.className = 'mineJoinNote';
3725
+ note.rows = 2;
3726
+ note.maxLength = 400;
3727
+ note.placeholder = 'A note for the owner (optional) — what you’d like to work on, who you are.';
3728
+ note.setAttribute('aria-label', 'A note for the owner (optional)');
3729
+ note.hidden = true;
3730
+ main.appendChild(note);
3731
+ var msg = mineEl('p', 'projMsg');
3732
+ msg.setAttribute('role', 'status');
3733
+ msg.setAttribute('aria-live', 'polite');
3734
+ main.appendChild(msg);
3735
+ var actions = mineEl('div', 'mineRow__actions');
3736
+ var noteBtn = document.createElement('button');
3737
+ noteBtn.type = 'button';
3738
+ noteBtn.className = 'linklike';
3739
+ noteBtn.textContent = 'Add a note';
3740
+ noteBtn.addEventListener('click', function () { note.hidden = false; noteBtn.hidden = true; note.focus(); });
3741
+ var apply = document.createElement('button');
3742
+ apply.type = 'button';
3743
+ apply.className = 'btn small';
3744
+ apply.textContent = 'Apply';
3745
+ /* with the guild until the server says it no longer holds, then on your own */
3746
+ var withGuild = true;
3747
+ function idle() { apply.disabled = false; apply.textContent = withGuild ? 'Apply' : 'Apply on my own'; }
3748
+ apply.addEventListener('click', function () {
3749
+ apply.disabled = true;
3750
+ apply.textContent = 'Applying…';
3751
+ msg.className = 'projMsg';
3752
+ msg.textContent = '';
3753
+ postJoinRequest(origin, (note.value || '').trim() || null, withGuild ? slug : null).then(function (res) {
3754
+ if (res.unauthenticated) { idle(); sessionExpired(msg); return; }
3755
+ if (res.ok && res.data && res.data.status === 'requested') {
3756
+ /* no longer pressable, so no longer the accent (the One Warm Thing rule) */
3757
+ apply.className = 'btn small ghost';
3758
+ apply.textContent = 'Applied';
3759
+ hint.hidden = true;
3760
+ note.hidden = true;
3761
+ noteBtn.hidden = true;
3762
+ msg.className = 'projMsg ok';
3763
+ msg.textContent = res.data.message || 'Requested.';
3764
+ return;
3765
+ }
3766
+ var err = apiError(res.data, res.status);
3767
+ if (res.status === 409 && err.code === 'guild_engagement_gone') {
3768
+ withGuild = false;
3769
+ idle();
3770
+ msg.textContent = ENGAGE_GONE;
3771
+ return;
3772
+ }
3773
+ idle();
3774
+ /* the relay's own answer (invite_only, already_member …) in its own words */
3775
+ msg.className = 'projMsg' + (res.ok ? '' : ' err');
3776
+ msg.textContent = (res.ok && res.data && res.data.message) || err.message || 'Could not reach that project — try again in a moment.';
3777
+ }).catch(function () {
3778
+ idle();
3779
+ msg.className = 'projMsg err';
3780
+ msg.textContent = 'Couldn’t reach the platform — check your connection and try again.';
3781
+ });
3782
+ });
3783
+ actions.appendChild(noteBtn);
3784
+ actions.appendChild(apply);
3785
+ row.appendChild(actions);
3786
+ return row;
3787
+ }
3788
+ /* a row this page cannot act on (an unknown kind, no guild to name, no address
3789
+ to apply to) is dropped rather than drawn as a card whose button fails */
3790
+ function renderEngagements(rows) {
3791
+ var ul = $('engageList');
3792
+ ul.textContent = '';
3793
+ (rows || []).forEach(function (e) {
3794
+ if (!e || (e.kind !== 'apply' && e.kind !== 'invited')) return;
3795
+ if (!e.guild || typeof e.guild.slug !== 'string' || !e.guild.slug) return;
3796
+ if (!e.project || !safeHttpUrl(e.project.origin)) return;
3797
+ ul.appendChild(engageRow(e));
3798
+ });
3799
+ paintInvitesSection();
3800
+ }
3801
+ function loadEngagements() {
3802
+ return fetch(API + '/me/guild-engagements', { credentials: 'same-origin' })
3803
+ .then(function (res) { return res.ok ? res.json() : { engagements: [] }; })
3804
+ .then(function (d) { renderEngagements(d.engagements || []); })
3805
+ .catch(function () { renderEngagements([]); });
3661
3806
  }
3662
3807
 
3663
3808
  /* the one relay both the map's per-card Apply (submitApply) and the
@@ -3665,11 +3810,17 @@ summary{min-height:24px;padding:3px 0;}
3665
3810
  the 'requested' echo update kept in ONE place (task 1003774). Keys the
3666
3811
  echo off the server's OWN resolved origin where the response carries one:
3667
3812
  it need not match what the visitor typed byte-for-byte (a trailing
3668
- slash, a different case) to be the same project the map's catalog names. */
3669
- function postJoinRequest(origin, note) {
3813
+ slash, a different case) to be the same project the map's catalog names.
3814
+ `guild` (a slug, optional) is a guild card's: the application is still the
3815
+ caller's own, and the hub records which guild they came with (task 1002303,
3816
+ R27; ADR 0365 D1/D2). */
3817
+ function postJoinRequest(origin, note, guild) {
3818
+ var body = { origin: origin };
3819
+ if (note) body.note = note;
3820
+ if (guild) body.guild = guild;
3670
3821
  return fetch(API + '/my-projects/join', {
3671
3822
  method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
3672
- body: JSON.stringify(note ? { origin: origin, note: note } : { origin: origin }),
3823
+ body: JSON.stringify(body),
3673
3824
  }).then(function (res) {
3674
3825
  if (res.status === 401) return { unauthenticated: true };
3675
3826
  return jsonOrEmpty(res).then(function (data) {
@@ -10296,11 +10447,17 @@ summary{min-height:24px;padding:3px 0;}
10296
10447
  here, because the project notifies the hub (POST /sso/invites/notify).
10297
10448
 
10298
10449
  WHAT IT PUBLISHES OF AN INVITEE is the login the inviter typed and nothing else.
10450
+
10451
+ A GUILD (task 1002303, R27; ADR 0365 D3), on the project's own page only: the
10452
+ inviter names a public guild and POST /projects/invite-guild { client_id, guild }
10453
+ answers one hand-off link per member on the guild's PUBLIC page — the people the
10454
+ inviter could see, and their handle and display name, which that page already
10455
+ shows. They become the same lines, each finished on the hall like any other.
10299
10456
  Proof: ../../../tests/project_invite_ui.mjs ══ */
10300
10457
 
10301
10458
  var INVITE_MOUNTS = {
10302
10459
  wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
10303
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg' }
10460
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg', guild: 'mInviteGuild', guildMsg: 'mInviteGuildMsg' }
10304
10461
  };
10305
10462
  var inviteSlug = {}; /* mount id → the slug it invites to; '' means no step at all */
10306
10463
  var inviteRows = {}; /* mount id → the invites filed on THIS project this session */
@@ -10320,6 +10477,7 @@ summary{min-height:24px;padding:3px 0;}
10320
10477
  inviteRows[id] = [];
10321
10478
  inviteSugg[id] = null;
10322
10479
  inviteSay(id, '', '');
10480
+ inviteSay(id, '', '', false, 'guildMsg');
10323
10481
  }
10324
10482
  var box = $(id);
10325
10483
  if (box) box.hidden = !next;
@@ -10389,14 +10547,15 @@ summary{min-height:24px;padding:3px 0;}
10389
10547
  }
10390
10548
 
10391
10549
  /* One line per name handed to the hall this session: the login the inviter
10392
- typed, and the link that finishes its invite there. The line says the invite is
10550
+ typed (or, for a guild's member, the display name its public page shows), and
10551
+ the link that finishes its invite there. The line says the invite is
10393
10552
  NOT sent yet, because it is not — the hall's Invite button is what sends it. */
10394
10553
  function renderInviteRows(id) {
10395
10554
  var m = INVITE_MOUNTS[id];
10396
10555
  var list = m && $(m.list);
10397
10556
  if (!list) return;
10398
10557
  list.innerHTML = (inviteRows[id] || []).map(function (r) {
10399
- return '<li><b>' + esc(r.login) + '</b><small>not sent yet — finish it on your project’s hall.</small>'
10558
+ return '<li><b>' + esc(r.name || r.login) + '</b><small>not sent yet — finish it on your project’s hall.</small>'
10400
10559
  + '<a class="btn small" href="' + esc(r.url) + '" target="_blank" rel="noopener">Send the invite →</a></li>';
10401
10560
  }).join('');
10402
10561
  /* the two lists are one answer: a name that just became a pending row is no
@@ -10404,9 +10563,10 @@ summary{min-height:24px;padding:3px 0;}
10404
10563
  renderInviteSugg(id);
10405
10564
  }
10406
10565
 
10407
- function inviteSay(id, text, cls, isHtml) {
10566
+ /* `key` names the line: the mount's message (the default) or the guild row's */
10567
+ function inviteSay(id, text, cls, isHtml, key) {
10408
10568
  var m = INVITE_MOUNTS[id];
10409
- var el = m && $(m.msg);
10569
+ var el = m && m[key || 'msg'] ? $(m[key || 'msg']) : null;
10410
10570
  if (!el) return;
10411
10571
  el.className = 'projMsg' + (cls ? ' ' + cls : '');
10412
10572
  if (isHtml) el.innerHTML = text; else el.textContent = text;
@@ -10461,6 +10621,73 @@ summary{min-height:24px;padding:3px 0;}
10461
10621
  });
10462
10622
  }
10463
10623
 
10624
+ /* inviteGuildSlug(raw) — a guild named the ways a person pastes one: its name in
10625
+ the address ("forge-crew"), "/g/forge-crew", or the guild page's whole address.
10626
+ PURE. Anything else is sent as typed, and the route answers for it. */
10627
+ function inviteGuildSlug(raw) {
10628
+ var s = String(raw == null ? '' : raw).replace(/^\s+|\s+$/g, '');
10629
+ var m = /(?:^|\/)g\/([^\/?#\s]+)\/?(?:[?#].*)?$/i.exec(s);
10630
+ if (m) {
10631
+ s = m[1];
10632
+ try { s = decodeURIComponent(s); } catch (e) { /* sent as typed */ }
10633
+ }
10634
+ return s.toLowerCase();
10635
+ }
10636
+
10637
+ /* doInviteGuild(id) — one public guild, one request: a pending line per member on
10638
+ its public page, each finished on the hall like a single invite. The route
10639
+ records that the project invited the guild and sends nothing, and nor does
10640
+ this. Only a mount that names a guild field has this control. */
10641
+ function doInviteGuild(id) {
10642
+ var m = INVITE_MOUNTS[id];
10643
+ var slug = inviteSlug[id] || '';
10644
+ if (!m || !m.guild || !slug || inviteBusy[id]) return;
10645
+ var field = $(m.guild);
10646
+ var guild = inviteGuildSlug(field && field.value);
10647
+ if (!guild) {
10648
+ inviteSay(id, 'Type the guild’s name or its /g/ address.', 'err', false, 'guildMsg');
10649
+ if (field && field.focus) field.focus();
10650
+ return;
10651
+ }
10652
+ inviteBusy[id] = true;
10653
+ inviteSay(id, 'Finding the guild’s members…', '', false, 'guildMsg');
10654
+ fetch(API + '/projects/invite-guild', {
10655
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
10656
+ body: JSON.stringify({ client_id: slug, guild: guild })
10657
+ }).then(function (res) {
10658
+ return jsonOrEmpty(res).then(function (data) {
10659
+ inviteBusy[id] = false;
10660
+ if (res.status === 401) { inviteSay(id, signInAgain(), 'err', true, 'guildMsg'); return; }
10661
+ var g = res.ok && data && data.guild;
10662
+ if (g) {
10663
+ /* a member already on a line (the same guild twice) is not drawn again */
10664
+ var listed = 0;
10665
+ (data.invites || []).forEach(function (r) {
10666
+ var url = r ? safeHttpUrl(r.invite_url) : '';
10667
+ if (!url || !r.handle) return;
10668
+ listed++;
10669
+ if ((inviteRows[id] || []).some(function (x) { return x.url === url; })) return;
10670
+ inviteRows[id] = (inviteRows[id] || []).concat([{ login: String(r.handle), name: r.display_name ? String(r.display_name) : '', url: url }]);
10671
+ });
10672
+ renderInviteRows(id);
10673
+ if (field) field.value = '';
10674
+ var name = g.name || guild;
10675
+ inviteSay(id, listed
10676
+ ? name + ': ' + listed + (listed === 1 ? ' member' : ' members') + ' to invite. Send each invite from your project’s hall — every line above opens it with their name filled in.'
10677
+ : 'Nobody is listed on ' + name + '’s public page yet, so there is no one to invite from here.', listed ? 'ok' : '', false, 'guildMsg');
10678
+ return;
10679
+ }
10680
+ var e = apiError(data, res.status);
10681
+ /* an unknown guild and an unlisted one are one answer on the server */
10682
+ if (res.status === 404 && e.code === 'guild_not_found') { inviteSay(id, 'No public guild at that name.', 'err', false, 'guildMsg'); return; }
10683
+ inviteSay(id, inviteRefusal(e.code, res.status, e.message), 'err', false, 'guildMsg');
10684
+ });
10685
+ }).catch(function () {
10686
+ inviteBusy[id] = false;
10687
+ inviteSay(id, 'Couldn’t reach the platform — check your connection and try again.', 'err', false, 'guildMsg');
10688
+ });
10689
+ }
10690
+
10464
10691
  /* skipInvite(id) — the step steps aside having sent NOTHING. Focus moves on
10465
10692
  rather than being stranded inside the box that just went away. */
10466
10693
  function skipInvite(id) {
@@ -10479,6 +10706,7 @@ summary{min-height:24px;padding:3px 0;}
10479
10706
  var box = $(id);
10480
10707
  if (box) {
10481
10708
  box.addEventListener('click', function (ev) {
10709
+ if (ev.target.closest && ev.target.closest('[data-invite-guild]')) { doInviteGuild(id); return; }
10482
10710
  var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip],[data-invite-pick]');
10483
10711
  if (!t) return;
10484
10712
  /* a picked suggestion fills the field and takes the SAME path as a typed
@@ -10501,6 +10729,14 @@ summary{min-height:24px;padding:3px 0;}
10501
10729
  doInvite(id);
10502
10730
  });
10503
10731
  }
10732
+ var guildField = INVITE_MOUNTS[id].guild ? $(INVITE_MOUNTS[id].guild) : null;
10733
+ if (guildField) {
10734
+ guildField.addEventListener('keydown', function (ev) {
10735
+ if (ev.key !== 'Enter') return;
10736
+ ev.preventDefault();
10737
+ doInviteGuild(id);
10738
+ });
10739
+ }
10504
10740
  });
10505
10741
 
10506
10742
  /* ── boot: who am I → my instances ────────────────────────────────────── */
@@ -175,6 +175,31 @@
175
175
  "expect": {"visible":["#panel-fork",".doorPill","#wizDemo","#wizStart"]} },
176
176
  { "name": "the wizard driver at 390: the review rows stack and the walk still reaches Create", "auth": true, "width": 390, "url": "/projects?view=new",
177
177
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],
178
- "expect": {"visible":["#panel-look","#wizLook","#doCreate"],"hidden":["#panel-review"],"text":[["#look-h","Give it a look."]]} }
178
+ "expect": {"visible":["#panel-look","#wizLook","#doCreate"],"hidden":["#panel-review"],"text":[["#look-h","Give it a look."]]} },
179
+ { "name": "guild cards (task 1002303, R27): the mine view lists what your guilds have going, each card with its own Apply", "auth": true, "width": 1440, "url": "/projects?view=mine",
180
+ "steps": [["wait",900]],
181
+ "expect": { "visible": ["#mineInvites", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]],
182
+ "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"]] } },
183
+ { "name": "guild cards: Add a note opens the card's own note; Apply sends it with the guild (the stub refuses a guild with no engagement) and the card says it went through", "continue": true,
184
+ "steps": [["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","Happy to take the migrations."],["click","#engageList .engageRow:first-child .btn"],["wait",400]],
185
+ "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"]],
186
+ "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."]] } },
187
+ { "name": "guild cards: the invited card applies on its own, with no note", "continue": true,
188
+ "steps": [["click","#engageList .engageRow:nth-child(2) .btn"],["wait",400]],
189
+ "expect": { "text": [["#engageList .engageRow:nth-child(2) .btn", "Applied"]], "hasClass": [["#engageList .engageRow:nth-child(2) .projMsg", "ok"]] } },
190
+ { "name": "invite a guild (task 1002303, R27): a /g/ address gives one pending line per member on its public page, each a link to the hall", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
191
+ "steps": [["wait",900],["fill","#mInviteGuild","/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
192
+ "expect": { "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]],
193
+ "href": [["#mInviteList li:first-child a", "https://mercury.example.test/builders/watch?invite=mara-quinn"]],
194
+ "text": [["#mInviteGuildMsg", "Forge Crew: 4 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in."]] } },
195
+ { "name": "invite a guild: the same guild again draws no second line", "continue": true,
196
+ "steps": [["fill","#mInviteGuild","forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
197
+ "expect": { "count": [["#mInviteList li", 4]] } },
198
+ { "name": "invite a guild: an unlisted guild is the same one line as an unknown one", "continue": true,
199
+ "steps": [["fill","#mInviteGuild","night-shift"],["click","#mInvite [data-invite-guild]"],["wait",400]],
200
+ "expect": { "text": [["#mInviteGuildMsg", "No public guild at that name."]], "hasClass": [["#mInviteGuildMsg", "err"]], "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", "night-shift"]] } },
201
+ { "name": "invite a guild: an empty field is a question, not a request; Enter in the field sends too", "continue": true,
202
+ "steps": [["fill","#mInviteGuild",""],["focus","#mInviteGuild"],["press","Enter"],["wait",150]],
203
+ "expect": { "text": [["#mInviteGuildMsg", "Type the guild’s name or its /g/ address."]], "activeId": "mInviteGuild" } }
179
204
  ]
180
205
  }