@bongos/core 1.21.65 → 1.21.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.bongos-core.json +114 -49
  2. package/docs/copy-inventory.md +432 -405
  3. package/docs/copy-registry.json +840 -571
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +45 -13
  7. package/docs/page-readings.json +969 -917
  8. package/modules/hall-ui/public/profile.html +4 -7
  9. package/modules/hall-ui/public/profile.js +0 -5
  10. package/modules/platform-identity/routes/my-projects.js +1 -1
  11. package/modules/public-landing/public/account.html +1 -1
  12. package/modules/public-landing/public/assets/cosmos.css +12 -10
  13. package/modules/public-landing/public/contact.html +1 -1
  14. package/modules/public-landing/public/guild.html +1 -1
  15. package/modules/public-landing/public/index-cli.states.json +20 -0
  16. package/modules/public-landing/public/index.html +68 -1
  17. package/modules/public-landing/public/index.states.json +4 -4
  18. package/modules/public-landing/public/invitations-empty.states.json +56 -0
  19. package/modules/public-landing/public/invitations-picker.states.json +84 -0
  20. package/modules/public-landing/public/invitations.html +956 -0
  21. package/modules/public-landing/public/invitations.probes.json +430 -0
  22. package/modules/public-landing/public/invitations.states.json +473 -0
  23. package/modules/public-landing/public/privacy.html +1 -1
  24. package/modules/public-landing/public/projects.html +18 -543
  25. package/modules/public-landing/public/projects.probes.json +0 -29
  26. package/modules/public-landing/public/projects.states.json +0 -6
  27. package/modules/public-landing/public/terms.html +1 -1
  28. package/modules/ui-design/kit/fixtures/bongos-cli-latest-sample.json +10 -0
  29. package/modules/ui-design/kit/fixtures/featured-invitations.json +183 -0
  30. package/modules/ui-design/kit/fixtures/guilds-empty.json +1 -0
  31. package/modules/ui-design/kit/fixtures/me-guild-engagements-empty.json +1 -0
  32. package/modules/ui-design/kit/fixtures/me-guild-requests-empty.json +1 -0
  33. package/modules/ui-design/kit/fixtures/my-projects-applied.json +29 -0
  34. package/modules/ui-design/kit/fixtures/my-projects-invites-empty.json +1 -0
  35. package/modules/ui-design/kit/serve.js +5 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +16 -0
  39. package/src/module-api.js +1 -1
  40. package/src/platform-server.js +11 -0
  41. package/tests/canonical_profile_url.mjs +23 -157
  42. package/tests/hub_account_page.mjs +1 -1
  43. package/tests/hub_engagement_cards.mjs +44 -40
  44. package/tests/hub_guild_cards.mjs +24 -22
  45. package/tests/hub_invitations_page.mjs +414 -0
  46. package/tests/join_relay_outcome.mjs +1 -1
  47. package/tests/landing_legal_pages.mjs +8 -6
  48. package/tests/landing_page.mjs +125 -3
  49. package/tests/my_projects_surface.mjs +14 -6
  50. package/tests/public_landing_projects.mjs +12 -14
  51. package/tests/render_check.mjs +3 -2
  52. package/tests/ui_design_kit.mjs +3 -3
  53. package/modules/hall-ui/public/profile-canonical.js +0 -137
@@ -0,0 +1,956 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-mode="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <!-- THE INVITATIONS PAGE (task 1004276; the owner's calls of 2026-09-25, ADR 0337
7
+ D5). What asks you IN, on a page of its own instead of under your projects:
8
+ a project's own invite, a guild inviting you, a guild you are in engaging a
9
+ project, and someone asking to join a guild you own — plus applying to any
10
+ project by its address. Moved whole from /projects?view=mine, where task
11
+ 1003774 put the project invites and the join field and tasks 1002303 and
12
+ 1004583 added the guild cards; the code below is that code, unchanged but
13
+ for this page's own sign-in return and its empty line.
14
+ Hub-only, like /account: platform-server.js serves /invitations only where
15
+ platform-identity is on, because every read and write below is one of that
16
+ module's own-scoped routes (GET /my-projects/invites, POST
17
+ /my-projects/invites/:clientId/decline, POST /my-projects/join, GET
18
+ /me/guild-engagements, GET /me/guild-requests, POST /guild-requests/:id/…).
19
+ Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
20
+ <title>{{productName}} — Invitations</title>
21
+ <meta name="description" content="Your {{productName}} invitations: projects and guilds asking you in, and applying to a project by its address.">
22
+ <meta name="robots" content="noindex">
23
+ <link rel="preconnect" href="https://fonts.googleapis.com">
24
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
25
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
26
+ <link rel="stylesheet" href="/assets/cosmos.css">
27
+ <!-- Dark only, like every apex page (ADR 0204): data-mode="dark" is fixed on <html>. -->
28
+ <style>
29
+ /* The page's own frame, in the account page's voice (a lowercase light heading
30
+ and one lede); the rows, the guild cards, the join field and the buttons are
31
+ cosmos.css's own rules, exactly as they were
32
+ under /projects. No :root block — the pack's injected theme owns the tokens. */
33
+ /* the skip link un-hides itself on focus, reversing everything .vh sets (the hub's rule) */
34
+ .skip:focus{position:fixed;top:8px;left:8px;z-index:100;width:auto;height:auto;margin:0;overflow:visible;clip-path:none;white-space:normal;
35
+ font-size:14px;font-weight:800;text-decoration:none;color:var(--ink);background:var(--bg-card);
36
+ border:2px solid var(--ctl-line);border-radius:12px;padding:10px 16px;}
37
+ /* z-index 2, not 1: the join field's project list drops past the page's end, and at 1
38
+ (the footer's own) the later footer painted over it. The bar and its bands sit in
39
+ .shell at 50, so this stays under them. */
40
+ .acct{position:relative;z-index:2;max-width:1060px;margin:0 auto;padding:clamp(6px,1.5vw,18px) clamp(16px,4vw,40px) 56px;}
41
+ .acct h1{font-family:var(--font-display);font-weight:300;font-size:clamp(28px,3.4vw,44px);letter-spacing:-.035em;line-height:1.04;text-transform:lowercase;color:var(--ink);margin:18px 0 0;}
42
+ .acct>.lede{margin:8px 0 18px;max-width:62ch;font-size:15.5px;line-height:1.55;color:var(--ink-soft);font-weight:400;}
43
+ .acct .loading{font-size:15px;color:var(--ink-soft);}
44
+ .aDoor{border-top:1px solid var(--rule);padding:30px 0 10px;max-width:62ch;}
45
+ .aDoor p{margin:0 0 18px;font-size:15.5px;line-height:1.55;color:var(--ink-soft);}
46
+ .aDoor .gh{width:18px;height:18px;fill:currentColor;}
47
+ .aDoor .btn{text-transform:none;}
48
+ /* the mine view's quieter voice for these, which it scoped to #view-mine (its buttons are
49
+ cosmos.css's own #invt rules) */
50
+ .acct .mineEmpty{font-weight:400;color:var(--ink-soft);padding:0 0 6px;}
51
+ .acct .linklike{font-weight:400;color:var(--ink-soft);}
52
+ .mineExtra section:first-child{margin-top:12px;}
53
+ /* the project picker on the join field (owner ask, 2026-10-06): the field searches the
54
+ map, and its list drops under it. cosmos.css styles the input itself (.mineJoinRow
55
+ input); this only gives it a box to anchor the list to. */
56
+ .joinPick{position:relative;flex:1 1 260px;min-width:0;display:flex;}
57
+ .joinPick input{flex:1 1 auto;width:100%;}
58
+ .joinList{position:absolute;left:0;right:0;top:calc(100% + 6px);z-index:20;margin:0;padding:6px;list-style:none;
59
+ background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:var(--radius);box-shadow:var(--shadow-card);
60
+ max-height:min(330px,52vh);overflow-y:auto;}
61
+ .joinOpt{display:flex;align-items:baseline;gap:12px;min-height:44px;padding:10px 12px;border-radius:var(--radius-sm);cursor:pointer;}
62
+ .joinOpt__name{font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
63
+ .joinOpt__host{flex:1 1 auto;min-width:0;font-size:13.5px;color:var(--ink-soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
64
+ .joinOpt__tag{flex:none;font-size:12.5px;color:var(--ink-soft);}
65
+ .joinOpt[aria-selected="true"],.joinOpt:hover{background:color-mix(in srgb,var(--ink) 9%,transparent);}
66
+ .joinOpt[aria-disabled="true"]{cursor:default;}
67
+ .joinOpt[aria-disabled="true"] .joinOpt__name{color:var(--ink-soft);}
68
+ .joinList__note{padding:10px 12px;font-size:14px;color:var(--ink-soft);}
69
+ @media (max-width:520px){.joinOpt{flex-wrap:wrap;gap:2px 10px;}.joinOpt__host{flex-basis:100%;order:3;}}
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <!-- THE BAR and its two bands are the hub's own (projects.html, task 1004455):
74
+ same markup, same cosmos.css rules, so /projects → /invitations keeps the chrome.
75
+ No map search (it only filters the map), and the band links name /projects
76
+ in full because this page has no views of its own. assets/hub-bar.js runs it,
77
+ shared with the three footer doors (task 1004489). -->
78
+ <a class="skip vh" href="#invt">Skip to content</a>
79
+ <header class="shell bar">
80
+ <div class="barLeft">
81
+ <a class="barMark" id="barMark" href="/">
82
+ <svg viewBox="0 0 26 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4"
83
+ stroke-linecap="round" stroke-linejoin="round">
84
+ <ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>
85
+ <path d="M1.8 5.6v6.6a6.6 2.9 0 0 0 13.2 0V5.6"/>
86
+ <ellipse cx="19.6" cy="8.4" rx="4.7" ry="2.1"/>
87
+ <path d="M14.9 8.4v4.6a4.7 2.1 0 0 0 9.4 0V8.4"/>
88
+ </svg>
89
+ <span>{{productName}}</span>
90
+ </a>
91
+ </div>
92
+ <div class="barRight">
93
+ <button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
94
+ <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
95
+ Signed out it stays hidden and the sign-in door takes its slot. -->
96
+ <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden><img id="whoImg" alt="" hidden><span class="whoName" id="whoLogin"></span><svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
97
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/invitations&amp;repos=1">Sign in</a>
98
+ </div>
99
+ </header>
100
+
101
+ <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
102
+ <div class="bandIn">
103
+ <ul class="bandRow">
104
+ <li class="bandCell"><a class="bandT" href="/projects?view=map">Sky</a><span class="bandC">the sky of every project</span></li>
105
+ <li class="bandCell"><a class="bandT" href="/projects?view=map&amp;sub=table">Table</a><span class="bandC">the same projects as rows</span></li>
106
+ <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own or build on</span></li>
107
+ <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
108
+ </ul>
109
+ </div>
110
+ </div>
111
+ <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
112
+ <div class="bandIn bandProfile">
113
+ <p class="bandWho" id="bandWho"><img id="bandWhoImg" alt="" hidden><span class="whoName" id="bandWhoLogin"></span></p>
114
+ <ul class="bandRow">
115
+ <li class="bandCell"><a class="bandT" href="/projects?view=mine">My projects</a></li>
116
+ <li class="bandCell"><a class="bandT" href="/invitations" aria-current="page">Invitations</a></li><li class="bandCell"><a class="bandT" href="/account">Account</a></li>
117
+ <!-- the honest hover target; the delegated click upgrades it to the POST -->
118
+ <li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>
119
+ </ul>
120
+ </div>
121
+ </div>
122
+
123
+ <main class="acct" id="invt">
124
+ <h1>invitations</h1>
125
+ <p class="lede">What asks you in: projects and guilds that invite you, and people asking to join a guild you run. You can also apply to any project by its address.</p>
126
+
127
+ <!-- signed-out door: revealed when GET /me answers nobody, the /account shape -->
128
+ <section class="aDoor" id="door" hidden aria-labelledby="door-h">
129
+ <h2 class="vh" id="door-h">Sign in</h2>
130
+ <p>Sign in with GitHub to see your invitations and apply to join a project.</p>
131
+ <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/invitations&amp;repos=1">
132
+ <!-- keep the spaces inside this path's numbers: same shape, and they are load-bearing (task 1004401) -->
133
+ <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23 .82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2 .82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12 .51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29 .25.54.73 .54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15 .46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
134
+ Sign in with GitHub
135
+ </a>
136
+ </section>
137
+
138
+ <p class="loading" id="loading">Reading your invitations…</p>
139
+
140
+ <!-- ══ INVITATIONS · JOIN BY ADDRESS (task 1003774, moved here by task 1004276) ══
141
+ No Accept button on a project's invite — ADR 0335 retired that hub-only flip
142
+ as a dead end; an invite is a link into the project instead. ══ -->
143
+ <div class="mineExtra" id="invtBody" hidden>
144
+ <section id="mineInvites" aria-labelledby="mineInvitesH">
145
+ <h2 id="mineInvitesH">Waiting on you</h2>
146
+ <p class="mineEmpty" id="invitesNone" hidden>Nothing is waiting on you right now. When a project or a guild invites you, it shows up here.</p>
147
+ <ul class="mineRows" id="invitesList"></ul>
148
+ <!-- a guild inviting you (task 1004583; ADR 0336 D4 §3, ADR 0337 D4.4):
149
+ each card says, BEFORE you accept, that your numbers count toward the
150
+ guild's rounded totals, and offers the switch right there. -->
151
+ <ul class="mineRows" id="guildInviteList"></ul>
152
+ <!-- a guild you are in, engaging a project (task 1002303, R27; ADR 0365):
153
+ "G is applying to P" and "P invited G", from GET /me/guild-engagements.
154
+ Each card's Apply is the ordinary join request with `guild` added, so
155
+ you apply as yourself, never as the guild. -->
156
+ <ul class="mineRows" id="engageList"></ul>
157
+ <!-- someone asking to join a guild you own (task 1004583): Accept / Decline.
158
+ Their own counted switch rode their request, so this card has none. -->
159
+ <ul class="mineRows" id="guildRequestList"></ul>
160
+ </section>
161
+
162
+ <section aria-labelledby="mineJoinH">
163
+ <h2 id="mineJoinH">Join a project</h2>
164
+ <p class="lede">Pick a project from the map, or paste any project’s address — its owner sees your application in their own queue.</p>
165
+ <div class="mineJoinRow">
166
+ <div class="joinPick">
167
+ <label class="vh" for="joinOrigin">Project name or address</label>
168
+ <input id="joinOrigin" type="text" inputmode="url" placeholder="Search projects, or paste an address" autocomplete="off" spellcheck="false"
169
+ role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="joinList">
170
+ <ul id="joinList" class="joinList" role="listbox" aria-label="Projects on the map" hidden></ul>
171
+ </div>
172
+ <button id="joinBtn" type="button" class="btn small">Apply to join</button>
173
+ </div>
174
+ <label class="vh" for="joinNote">Add a note (optional)</label>
175
+ <textarea id="joinNote" class="mineJoinNote" rows="2" maxlength="400" placeholder="A note for the owner (optional) — what you’d like to work on, who you are."></textarea>
176
+ <p class="projMsg" id="joinStatus" role="status" aria-live="polite"></p>
177
+ </section>
178
+ </div>
179
+ </main>
180
+
181
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
182
+
183
+ <script src="/assets/hub-bar.js"></script>
184
+ <script>
185
+ (function () {
186
+ 'use strict';
187
+ var API = '/api/bongos';
188
+ function $(id) { return document.getElementById(id); }
189
+ // The bar's chip, bands and sign out are assets/hub-bar.js (task 1004489), loaded
190
+ // just above; its `ready` settles with who GET /me says is signed in, and that is
191
+ // what picks the page's state. Without the asset nobody is signed in here.
192
+ var bar = window.HubBar || { paint: function () {}, ready: Promise.resolve(null) };
193
+
194
+ function safeHttpUrl(raw) {
195
+ try {
196
+ var u = new URL(String(raw));
197
+ return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : '';
198
+ } catch (e) { return ''; }
199
+ }
200
+ function hostOf(origin) {
201
+ return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
202
+ }
203
+ function apiError(data, status) {
204
+ var e = (data && data.error) || {};
205
+ return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} };
206
+ }
207
+ function jsonOrEmpty(res) { return res.json().catch(function () { return {}; }); }
208
+ /* ── the invitation rows (task 1003774; moved from /projects by task 1004276) ──
209
+ One row shape for every list: a name, its host in the mono face, and the
210
+ actions on the right. textContent-only, matching the deleted hall page
211
+ this restores. */
212
+ function mineEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
213
+ function mineRow(p) {
214
+ var row = mineEl('li', 'mineRow');
215
+ var main = mineEl('div', 'mineRow__main');
216
+ var name = mineEl('div', 'mineRow__name');
217
+ name.textContent = p.name || p.client_id;
218
+ main.appendChild(name);
219
+ var host = hostOf(p.origin);
220
+ if (host) { var h = mineEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
221
+ row.appendChild(main);
222
+ return row;
223
+ }
224
+
225
+ /* the leave/decline shape: disable, POST, drop the row on success, restore
226
+ the button on failure. `onRemoved` carries whatever else the caller needs
227
+ after the row is gone (the empty-line flip). */
228
+ function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
229
+ btn.disabled = true;
230
+ if (idleLabel) btn.textContent = idleLabel.busy;
231
+ fetch(url, { method: 'POST', credentials: 'same-origin' })
232
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
233
+ .then(function () { row.remove(); if (onRemoved) onRemoved(); })
234
+ .catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
235
+ }
236
+
237
+ /* Each invitation renders as a way IN, never an Accept button: the hub's own
238
+ accept flip never admits anyone to the project's own instance (ADR 0335)
239
+ — signing in there is what actually accepts it (task 1002969). Since task
240
+ 1002818 (ADR 0353) the row is the project's own notice of its invite and the
241
+ hub has no accept route at all. Decline still works; it only clears the
242
+ hub's own pending row. */
243
+ function renderInvitesList(invites) {
244
+ var ul = $('invitesList');
245
+ ul.textContent = '';
246
+ if (!invites || !invites.length) { paintInvitesSection(); return; }
247
+ invites.forEach(function (inv) {
248
+ var row = mineRow(inv);
249
+ var hint = mineEl('p', 'mineRow__hint');
250
+ hint.textContent = 'Sign in there to accept.';
251
+ row.querySelector('.mineRow__main').appendChild(hint);
252
+ var actions = mineEl('div', 'mineRow__actions');
253
+ var href = safeHttpUrl(inv.origin);
254
+ if (href) {
255
+ var open = document.createElement('a');
256
+ open.className = 'btn small';
257
+ open.href = href;
258
+ open.target = '_blank';
259
+ open.rel = 'noopener';
260
+ open.textContent = 'Open project →';
261
+ actions.appendChild(open);
262
+ }
263
+ var decline = document.createElement('button');
264
+ decline.type = 'button';
265
+ decline.className = 'btn small ghost';
266
+ decline.textContent = 'Decline';
267
+ decline.addEventListener('click', function () {
268
+ postThenRemoveRow(
269
+ API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline, null,
270
+ paintInvitesSection
271
+ );
272
+ });
273
+ actions.appendChild(decline);
274
+ row.appendChild(actions);
275
+ ul.appendChild(row);
276
+ });
277
+ paintInvitesSection();
278
+ }
279
+
280
+ /* the section holds four lists — the projects' own invites, a guild's invites
281
+ to you, your guilds' engagements, and the join requests to a guild you own.
282
+ On /projects it hid while all four were empty; here it is the page, so it
283
+ stays and says so in one line, once every read has answered (said any sooner
284
+ it would flash "nothing" over a list still on its way). */
285
+ var INVITE_LISTS = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'];
286
+ var invitesRead = false;
287
+ function paintInvitesSection() {
288
+ $('invitesNone').hidden = !invitesRead || INVITE_LISTS.some(function (id) { return $(id).children.length; });
289
+ }
290
+
291
+ function loadInvites() {
292
+ return Promise.all([
293
+ fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
294
+ .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
295
+ .then(function (d) { renderInvitesList(d.invites || []); })
296
+ .catch(function () { renderInvitesList([]); }),
297
+ loadEngagements(),
298
+ loadGuildQueue()
299
+ ]).then(function () { invitesRead = true; paintInvitesSection(); });
300
+ }
301
+
302
+ /* ── a guild you are in, engaging a project (task 1002303, R27; ADR 0365) ──
303
+ GET /me/guild-engagements names what the guilds you are in have going: "G is
304
+ applying to P" (the guild's owner put it forward) and "P invited G" (the
305
+ project invited the guild; its hall sends each member on the guild's public
306
+ page their own invite, and the rest of the guild hears it here). The read only
307
+ names a project that is on the map and public, and leaves out one you already
308
+ build on, are invited to, or have applied to. Each card's Apply is the SAME
309
+ relay as the join field below — postJoinRequest — with `guild` added:
310
+ you apply as yourself, never as the guild, with your own note if you like. A
311
+ guild that no longer holds for you (you left it, or its 30 days ran out) is a
312
+ 409 and nothing is relayed; the card then offers the plain application.
313
+ textContent / createElement only. Proof: tests/hub_engagement_cards.mjs */
314
+ var ENGAGE_GONE = 'That guild application has ended — you can still apply on your own.';
315
+ function engageLine(e) {
316
+ var g = e.guild.name || e.guild.slug;
317
+ var p = (e.project && e.project.name) || hostOf(e.project.origin);
318
+ return e.kind === 'invited' ? p + ' invited ' + g : g + ' is applying to ' + p + ' — apply with them?';
319
+ }
320
+ function sessionExpired(el) {
321
+ el.className = 'projMsg err';
322
+ el.textContent = 'Your session expired — ';
323
+ var a = document.createElement('a');
324
+ a.href = API + '/auth/web/start?return=/invitations&repos=1';
325
+ a.textContent = 'sign in again';
326
+ el.appendChild(a);
327
+ el.appendChild(document.createTextNode('.'));
328
+ }
329
+ function engageRow(e) {
330
+ var origin = e.project.origin;
331
+ var slug = e.guild.slug;
332
+ var row = mineRow({ name: engageLine(e), origin: origin });
333
+ row.className = 'mineRow engageRow';
334
+ var main = row.querySelector('.mineRow__main');
335
+ var hint = mineEl('p', 'mineRow__hint');
336
+ hint.textContent = 'You apply as yourself — add a note for the owner if you like.';
337
+ main.appendChild(hint);
338
+ var note = document.createElement('textarea');
339
+ note.className = 'mineJoinNote';
340
+ note.rows = 2;
341
+ note.maxLength = 400;
342
+ note.placeholder = 'A note for the owner (optional) — what you’d like to work on, who you are.';
343
+ note.setAttribute('aria-label', 'A note for the owner (optional)');
344
+ note.hidden = true;
345
+ main.appendChild(note);
346
+ var msg = mineEl('p', 'projMsg');
347
+ msg.setAttribute('role', 'status');
348
+ msg.setAttribute('aria-live', 'polite');
349
+ main.appendChild(msg);
350
+ var actions = mineEl('div', 'mineRow__actions');
351
+ var noteBtn = document.createElement('button');
352
+ noteBtn.type = 'button';
353
+ noteBtn.className = 'linklike';
354
+ noteBtn.textContent = 'Add a note';
355
+ noteBtn.addEventListener('click', function () { note.hidden = false; noteBtn.hidden = true; note.focus(); });
356
+ var apply = document.createElement('button');
357
+ apply.type = 'button';
358
+ apply.className = 'btn small';
359
+ apply.textContent = 'Apply to join';
360
+ /* with the guild until the server says it no longer holds, then on your own */
361
+ var withGuild = true;
362
+ /* "Apply to join", the join field's and the map card's word for this same relay (the
363
+ owner's call of 2026-09-25: one word everywhere) */
364
+ function idle() { apply.disabled = false; apply.textContent = withGuild ? 'Apply to join' : 'Apply to join on my own'; }
365
+ apply.addEventListener('click', function () {
366
+ apply.disabled = true;
367
+ apply.textContent = 'Applying…';
368
+ msg.className = 'projMsg';
369
+ msg.textContent = '';
370
+ postJoinRequest(origin, (note.value || '').trim() || null, withGuild ? slug : null).then(function (res) {
371
+ if (res.unauthenticated) { idle(); sessionExpired(msg); return; }
372
+ if (res.ok && res.data && res.data.status === 'requested') {
373
+ /* no longer pressable, so no longer the accent (the One Warm Thing rule) */
374
+ apply.className = 'btn small ghost';
375
+ apply.textContent = 'Applied';
376
+ hint.hidden = true;
377
+ note.hidden = true;
378
+ noteBtn.hidden = true;
379
+ msg.className = 'projMsg ok';
380
+ msg.textContent = res.data.message || 'Applied.';
381
+ return;
382
+ }
383
+ var err = apiError(res.data, res.status);
384
+ if (res.status === 409 && err.code === 'guild_engagement_gone') {
385
+ withGuild = false;
386
+ idle();
387
+ msg.textContent = ENGAGE_GONE;
388
+ return;
389
+ }
390
+ idle();
391
+ /* the relay's own answer (invite_only, already_member …) in its own words */
392
+ msg.className = 'projMsg' + (res.ok ? '' : ' err');
393
+ msg.textContent = (res.ok && res.data && res.data.message) || err.message || 'Could not reach that project — try again in a moment.';
394
+ }).catch(function () {
395
+ idle();
396
+ msg.className = 'projMsg err';
397
+ msg.textContent = 'Couldn’t reach the platform — check your connection and try again.';
398
+ });
399
+ });
400
+ actions.appendChild(noteBtn);
401
+ actions.appendChild(apply);
402
+ row.appendChild(actions);
403
+ return row;
404
+ }
405
+ /* a row this page cannot act on (an unknown kind, no guild to name, no address
406
+ to apply to) is dropped rather than drawn as a card whose button fails */
407
+ function renderEngagements(rows) {
408
+ var ul = $('engageList');
409
+ ul.textContent = '';
410
+ (rows || []).forEach(function (e) {
411
+ if (!e || (e.kind !== 'apply' && e.kind !== 'invited')) return;
412
+ if (!e.guild || typeof e.guild.slug !== 'string' || !e.guild.slug) return;
413
+ if (!e.project || !safeHttpUrl(e.project.origin)) return;
414
+ ul.appendChild(engageRow(e));
415
+ });
416
+ paintInvitesSection();
417
+ }
418
+ function loadEngagements() {
419
+ return fetch(API + '/me/guild-engagements', { credentials: 'same-origin' })
420
+ .then(function (res) { return res.ok ? res.json() : { engagements: [] }; })
421
+ .then(function (d) { renderEngagements(d.engagements || []); })
422
+ .catch(function () { renderEngagements([]); });
423
+ }
424
+
425
+ /* ── a guild inviting you, and a join request to a guild you own (task 1004583;
426
+ ADR 0336 D3/D4 §3, ADR 0337 D4.4) ──
427
+ GET /me/guild-requests names the invites addressed to you, each carrying its
428
+ card: the guild's name, description and CURRENT visibility. Accept echoes back
429
+ the visibility this card showed — the consent is to what the card said, so a
430
+ guild that changed since is a 409 and the card re-draws for a fresh decision —
431
+ and the counted_in_totals choice made on the card. The D4.4 notice is said for
432
+ the choice as it stands, BEFORE accept, in guild.html's own words (the test
433
+ holds the two pages to one sentence). The guilds you OWN answer their join
434
+ requests here too, from the same read's owner_incoming — one request for
435
+ every guild you own, never one per guild. The requester's own switch rode
436
+ their request, so the owner's card has none. A decline is silent on both sides (204, the other side is never
437
+ told). textContent / createElement only. Proof: tests/hub_guild_cards.mjs */
438
+ var GUILD_COUNTED_ON = 'Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. ' +
439
+ 'They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. ' +
440
+ 'Leave them out of any guild you’d rather not share them with.';
441
+ var GUILD_COUNTED_OFF = 'Your numbers are left out of this guild’s totals, from its next weekly count.';
442
+ var GUILD_SHOWN = {
443
+ public: 'It’s public: joining lists your name and picture on its page while your account is public. You can turn that off there.',
444
+ unlisted: 'It’s unlisted: it has no public page, so joining lists you nowhere.'
445
+ };
446
+ var GUILD_OFFLINE = 'Couldn’t reach the platform — check your connection and try again.';
447
+ /* a POST on the cookie session; a 204 (decline) has no body to read */
448
+ function guildPost(path, body) {
449
+ var init = { method: 'POST', credentials: 'same-origin' };
450
+ if (body) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); }
451
+ return fetch(API + path, init).then(function (res) {
452
+ return (res.status === 204 ? Promise.resolve({}) : jsonOrEmpty(res)).then(function (data) {
453
+ return { ok: res.ok, status: res.status, data: data };
454
+ });
455
+ });
456
+ }
457
+ function guildButton(label, cls) {
458
+ var b = document.createElement('button');
459
+ b.type = 'button';
460
+ b.className = cls;
461
+ b.textContent = label;
462
+ return b;
463
+ }
464
+ /* the row's status line, quiet until it has something to say */
465
+ function guildMsg(main) {
466
+ var msg = mineEl('p', 'projMsg');
467
+ msg.setAttribute('role', 'status');
468
+ msg.setAttribute('aria-live', 'polite');
469
+ main.appendChild(msg);
470
+ return msg;
471
+ }
472
+ /* Decline: silent to the other side. The row goes; a failure keeps it and says so. */
473
+ function guildDecline(row, id, btns, msg) {
474
+ btns.forEach(function (b) { b.disabled = true; });
475
+ msg.className = 'projMsg';
476
+ msg.textContent = '';
477
+ guildPost('/guild-requests/' + encodeURIComponent(id) + '/decline').then(function (res) {
478
+ if (res.ok) { row.remove(); paintInvitesSection(); return; }
479
+ btns.forEach(function (b) { b.disabled = false; });
480
+ if (res.status === 401) { sessionExpired(msg); return; }
481
+ msg.className = 'projMsg err';
482
+ msg.textContent = res.status === 404 ? 'That has already been answered, or it ran out.' : 'Couldn’t decline just now — try again in a moment.';
483
+ }).catch(function () {
484
+ btns.forEach(function (b) { b.disabled = false; });
485
+ msg.className = 'projMsg err';
486
+ msg.textContent = GUILD_OFFLINE;
487
+ });
488
+ }
489
+
490
+ function guildInviteRow(inv) {
491
+ var name = inv.name || inv.slug;
492
+ var vis = inv.visibility;
493
+ var row = mineRow({ name: 'You’re invited to join ' + name });
494
+ row.className = 'mineRow guildRow guildRow--invite';
495
+ var main = row.querySelector('.mineRow__main');
496
+ if (inv.description) {
497
+ var desc = mineEl('p', 'guildRow__desc');
498
+ desc.textContent = inv.description;
499
+ main.appendChild(desc);
500
+ }
501
+ var shown = mineEl('p', 'mineRow__hint');
502
+ main.appendChild(shown);
503
+ /* the counted switch — the guild page's pair, in its words, defaulting on as
504
+ the membership does; the notice under it is said for the choice as it stands */
505
+ var band = mineEl('div', 'guildCount');
506
+ var label = mineEl('span', 'guildCount__label');
507
+ label.id = 'guildCount-' + inv.id;
508
+ label.textContent = 'In this guild’s totals';
509
+ band.appendChild(label);
510
+ var group = mineEl('div', 'guildCount__opts');
511
+ group.setAttribute('role', 'radiogroup');
512
+ group.setAttribute('aria-labelledby', label.id);
513
+ 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) {
514
+ var opt = mineEl('label', 'guildCount__opt');
515
+ var input = document.createElement('input');
516
+ input.type = 'radio';
517
+ input.name = 'guildCounted-' + inv.id;
518
+ input.value = o[0];
519
+ input.checked = o[0] === 'on';
520
+ var text = mineEl('span');
521
+ var b = mineEl('b');
522
+ b.textContent = o[1];
523
+ text.appendChild(b);
524
+ text.appendChild(document.createTextNode(o[2]));
525
+ opt.appendChild(input);
526
+ opt.appendChild(text);
527
+ group.appendChild(opt);
528
+ return input;
529
+ });
530
+ band.appendChild(group);
531
+ var what = mineEl('p', 'guildCount__what');
532
+ band.appendChild(what);
533
+ main.appendChild(band);
534
+ var msg = guildMsg(main);
535
+ function counted() { return !radios[1].checked; }
536
+ function paintWhat() { what.textContent = counted() ? GUILD_COUNTED_ON : GUILD_COUNTED_OFF; }
537
+ radios.forEach(function (r) { r.addEventListener('change', paintWhat); });
538
+ paintWhat();
539
+
540
+ var actions = mineEl('div', 'mineRow__actions');
541
+ var page = document.createElement('a');
542
+ page.className = 'linklike';
543
+ page.href = '/g/' + encodeURIComponent(inv.slug);
544
+ page.textContent = 'See its page';
545
+ function paintShown() { shown.textContent = GUILD_SHOWN[vis]; page.hidden = vis !== 'public'; }
546
+ paintShown();
547
+ var accept = guildButton('Accept', 'btn small');
548
+ var decline = guildButton('Decline', 'btn small ghost');
549
+ function idle() {
550
+ accept.disabled = false;
551
+ decline.disabled = false;
552
+ accept.textContent = 'Accept';
553
+ }
554
+ /* an invite that is gone (withdrawn, run out, answered elsewhere) cannot be
555
+ answered again: say so and leave nothing pressable */
556
+ function ended() {
557
+ accept.hidden = true;
558
+ decline.hidden = true;
559
+ radios.forEach(function (r) { r.disabled = true; });
560
+ msg.className = 'projMsg';
561
+ msg.textContent = 'That invite has ended — it was withdrawn, or it ran out.';
562
+ }
563
+ accept.addEventListener('click', function () {
564
+ var on = counted();
565
+ accept.disabled = true;
566
+ decline.disabled = true;
567
+ accept.textContent = 'Joining…';
568
+ msg.className = 'projMsg';
569
+ msg.textContent = '';
570
+ guildPost('/guild-requests/' + encodeURIComponent(inv.id) + '/accept', { visibility: vis, counted_in_totals: on }).then(function (res) {
571
+ if (res.ok) {
572
+ /* no longer pressable, so no longer the accent (the One Warm Thing rule) */
573
+ accept.className = 'btn small ghost';
574
+ accept.textContent = 'Joined';
575
+ decline.hidden = true;
576
+ radios.forEach(function (r) { r.disabled = true; });
577
+ page.hidden = false;
578
+ msg.className = 'projMsg ok';
579
+ msg.textContent = 'You’ve joined ' + name + (on ? ', and your numbers count toward its totals.' : ', and your numbers are left out of its totals.');
580
+ return;
581
+ }
582
+ if (res.status === 401) { idle(); sessionExpired(msg); return; }
583
+ var err = apiError(res.data, res.status);
584
+ if (res.status === 409 && err.code === 'guild_visibility_changed' && err.details && GUILD_SHOWN[err.details.visibility]) {
585
+ vis = err.details.visibility;
586
+ paintShown();
587
+ idle();
588
+ msg.className = 'projMsg err';
589
+ msg.textContent = 'This guild changed since the card was drawn: it’s ' + vis + ' now. Read it again, then accept.';
590
+ return;
591
+ }
592
+ if (res.status === 404) { ended(); return; }
593
+ idle();
594
+ msg.className = 'projMsg err';
595
+ msg.textContent = 'Couldn’t accept just now — try again in a moment.';
596
+ }).catch(function () {
597
+ idle();
598
+ msg.className = 'projMsg err';
599
+ msg.textContent = GUILD_OFFLINE;
600
+ });
601
+ });
602
+ decline.addEventListener('click', function () { guildDecline(row, inv.id, [accept, decline], msg); });
603
+ actions.appendChild(page);
604
+ actions.appendChild(accept);
605
+ actions.appendChild(decline);
606
+ row.appendChild(actions);
607
+ return row;
608
+ }
609
+ /* a row id is a bigserial (digits, within the bigint range the route checks);
610
+ a card with no id, no slug or a visibility this page cannot say is dropped
611
+ rather than drawn with a button that fails */
612
+ function guildRowId(r) {
613
+ return !!r && typeof r.id === 'string' && /^\d{1,19}$/.test(r.id) && (r.id.length < 19 || r.id <= '9223372036854775807');
614
+ }
615
+ function renderGuildInvites(rows) {
616
+ var ul = $('guildInviteList');
617
+ ul.textContent = '';
618
+ (rows || []).forEach(function (inv) {
619
+ if (!guildRowId(inv) || typeof inv.slug !== 'string' || !inv.slug || !GUILD_SHOWN[inv.visibility]) return;
620
+ ul.appendChild(guildInviteRow(inv));
621
+ });
622
+ paintInvitesSection();
623
+ }
624
+
625
+ function guildRequestRow(r) {
626
+ var who = r.display_name || '@' + r.handle;
627
+ var row = mineRow({ name: who + ' asked to join ' + (r.guild.name || r.guild.slug) });
628
+ row.className = 'mineRow guildRow';
629
+ var main = row.querySelector('.mineRow__main');
630
+ /* the handle under a display name, never twice */
631
+ if (r.display_name) {
632
+ var handle = mineEl('span', 'mineRow__host');
633
+ handle.textContent = '@' + r.handle;
634
+ main.appendChild(handle);
635
+ }
636
+ var hint = mineEl('p', 'mineRow__hint');
637
+ hint.textContent = 'Accepting makes them a member. A decline is never told to them.';
638
+ main.appendChild(hint);
639
+ var msg = guildMsg(main);
640
+ var actions = mineEl('div', 'mineRow__actions');
641
+ var accept = guildButton('Accept', 'btn small');
642
+ var decline = guildButton('Decline', 'btn small ghost');
643
+ accept.addEventListener('click', function () {
644
+ accept.disabled = true;
645
+ decline.disabled = true;
646
+ accept.textContent = 'Accepting…';
647
+ msg.className = 'projMsg';
648
+ msg.textContent = '';
649
+ guildPost('/guild-requests/' + encodeURIComponent(r.id) + '/accept').then(function (res) {
650
+ if (res.ok) {
651
+ accept.className = 'btn small ghost';
652
+ accept.textContent = 'Accepted';
653
+ decline.hidden = true;
654
+ hint.hidden = true;
655
+ msg.className = 'projMsg ok';
656
+ msg.textContent = who + ' is in ' + (r.guild.name || r.guild.slug) + ' now.';
657
+ return;
658
+ }
659
+ accept.textContent = 'Accept';
660
+ if (res.status === 404) {
661
+ accept.hidden = true;
662
+ decline.hidden = true;
663
+ msg.className = 'projMsg';
664
+ msg.textContent = 'That request has ended — it was withdrawn, it ran out, or their account is closed.';
665
+ return;
666
+ }
667
+ accept.disabled = false;
668
+ decline.disabled = false;
669
+ if (res.status === 401) { sessionExpired(msg); return; }
670
+ msg.className = 'projMsg err';
671
+ msg.textContent = 'Couldn’t accept just now — try again in a moment.';
672
+ }).catch(function () {
673
+ accept.disabled = false;
674
+ decline.disabled = false;
675
+ accept.textContent = 'Accept';
676
+ msg.className = 'projMsg err';
677
+ msg.textContent = GUILD_OFFLINE;
678
+ });
679
+ });
680
+ decline.addEventListener('click', function () { guildDecline(row, r.id, [accept, decline], msg); });
681
+ actions.appendChild(accept);
682
+ actions.appendChild(decline);
683
+ row.appendChild(actions);
684
+ return row;
685
+ }
686
+ function renderGuildRequests(rows) {
687
+ var ul = $('guildRequestList');
688
+ ul.textContent = '';
689
+ (rows || []).forEach(function (r) {
690
+ if (!guildRowId(r) || typeof r.handle !== 'string' || !r.handle) return;
691
+ if (!r.guild || typeof r.guild.slug !== 'string' || !r.guild.slug) return;
692
+ ul.appendChild(guildRequestRow(r));
693
+ });
694
+ paintInvitesSection();
695
+ }
696
+ /* ONE read for both lists: the invites to you, and the requests waiting on you
697
+ as the owner of a guild; a read that fails is silent, as the other lists' are */
698
+ function loadGuildQueue() {
699
+ return fetch(API + '/me/guild-requests', { credentials: 'same-origin' })
700
+ .then(function (res) { return res.ok ? res.json() : {}; })
701
+ .then(function (d) { renderGuildInvites(d.incoming || []); renderGuildRequests(d.owner_incoming || []); })
702
+ .catch(function () { renderGuildInvites([]); renderGuildRequests([]); });
703
+ }
704
+
705
+ /* POST /my-projects/join — the same relay /projects' map card drives (its copy of
706
+ this function also keeps the map's Applied echo, which this page has no map
707
+ for). A page cannot import another page's script, so the two copies are held
708
+ to ONE wire shape by tests/hub_invitations_page.mjs, which runs both. The join
709
+ field and the guild cards here share this one. The 401 case is told apart so
710
+ the caller can say the session expired.
711
+ `guild` (a slug, optional) is a guild card's: the application is still the
712
+ caller's own, and the hub records which guild they came with (task 1002303,
713
+ R27; ADR 0365 D1/D2). */
714
+ function postJoinRequest(origin, note, guild) {
715
+ var body = { origin: origin };
716
+ if (note) body.note = note;
717
+ if (guild) body.guild = guild;
718
+ return fetch(API + '/my-projects/join', {
719
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
720
+ body: JSON.stringify(body),
721
+ }).then(function (res) {
722
+ if (res.status === 401) return { unauthenticated: true };
723
+ return jsonOrEmpty(res).then(function (data) {
724
+ return { ok: res.ok, status: res.status, data: data };
725
+ });
726
+ });
727
+ }
728
+
729
+ /* ── the project picker (owner ask, 2026-10-06: typing an address is tedious) ──
730
+ The join field searches the map: a name or an address typed into it lists the
731
+ matching projects from GET /projects/featured, the feed /projects draws, and
732
+ picking one fills its address in. Left out: the hub itself, a row with no
733
+ public address (a stealth project is on the map anonymously, ADR 0182 D2a),
734
+ and a project you already own or build on (GET /my-projects). One you have
735
+ applied to stays listed as "Applied" and cannot be picked again. Any other
736
+ project can still be pasted by address, as before. Both reads happen on the
737
+ first focus, once — a visitor who never opens the field never pays for the
738
+ map. The keyboard contract is the hall palette's (palette.js): a combobox
739
+ whose aria-activedescendant names the highlighted option. */
740
+ var PICK_MAX = 8;
741
+ var picker = { projects: null, loading: null, shown: [], active: -1 };
742
+ function pickHost(origin) {
743
+ try { return new URL(String(origin)).host.toLowerCase(); } catch (e) { return ''; }
744
+ }
745
+ function readJson(path) {
746
+ return fetch(API + path, { credentials: 'same-origin' })
747
+ .then(function (res) { return res.ok ? res.json() : null; })
748
+ .catch(function () { return null; });
749
+ }
750
+ function loadPicker() {
751
+ if (picker.loading) return picker.loading;
752
+ picker.loading = Promise.all([readJson('/projects/featured'), readJson('/my-projects')]).then(function (r) {
753
+ var mine = {};
754
+ var applied = {};
755
+ var m = r[1] || {};
756
+ (m.owned || []).concat(m.building || []).forEach(function (x) { if (x && x.origin) mine[pickHost(x.origin)] = true; });
757
+ (m.applied || []).forEach(function (x) { if (x && x.origin) applied[pickHost(x.origin)] = true; });
758
+ var self = String((window.location && window.location.host) || '').toLowerCase();
759
+ var seen = {};
760
+ picker.projects = (((r[0] || {}).projects) || []).filter(function (p) {
761
+ var host = p && p.origin ? pickHost(p.origin) : '';
762
+ if (!host || !p.name || host === self || mine[host] || seen[host]) return false;
763
+ seen[host] = true;
764
+ return true;
765
+ }).map(function (p) {
766
+ var host = pickHost(p.origin);
767
+ return { name: String(p.name), origin: String(p.origin), host: host, applied: !!applied[host] };
768
+ });
769
+ return picker.projects;
770
+ });
771
+ return picker.loading;
772
+ }
773
+ function pickMatches(q) {
774
+ var s = String(q || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
775
+ return (picker.projects || []).filter(function (p) {
776
+ return !s || p.name.toLowerCase().indexOf(s) !== -1 || p.host.indexOf(s) !== -1;
777
+ });
778
+ }
779
+ function pickNote(text) {
780
+ var li = document.createElement('li');
781
+ li.className = 'joinList__note';
782
+ li.setAttribute('role', 'presentation');
783
+ li.textContent = text;
784
+ return li;
785
+ }
786
+ function closePicker() {
787
+ var list = $('joinList');
788
+ list.hidden = true;
789
+ $('joinOrigin').setAttribute('aria-expanded', 'false');
790
+ $('joinOrigin').removeAttribute('aria-activedescendant');
791
+ picker.active = -1;
792
+ }
793
+ function setActive(i) {
794
+ picker.active = i;
795
+ var opts = $('joinList').querySelectorAll('[role="option"]');
796
+ for (var k = 0; k < opts.length; k++) opts[k].setAttribute('aria-selected', k === i ? 'true' : 'false');
797
+ if (i >= 0 && opts[i]) {
798
+ $('joinOrigin').setAttribute('aria-activedescendant', opts[i].id);
799
+ if (typeof opts[i].scrollIntoView === 'function') opts[i].scrollIntoView({ block: 'nearest' });
800
+ } else {
801
+ $('joinOrigin').removeAttribute('aria-activedescendant');
802
+ }
803
+ }
804
+ function choose(p) {
805
+ if (!p || p.applied) return;
806
+ $('joinOrigin').value = p.origin;
807
+ $('joinStatus').className = 'projMsg';
808
+ $('joinStatus').textContent = '';
809
+ closePicker();
810
+ }
811
+ function renderPicker() {
812
+ var input = $('joinOrigin');
813
+ var list = $('joinList');
814
+ while (list.firstChild) list.removeChild(list.firstChild);
815
+ picker.shown = [];
816
+ picker.active = -1;
817
+ input.removeAttribute('aria-activedescendant');
818
+ if (!picker.projects) {
819
+ list.appendChild(pickNote('Loading projects…'));
820
+ } else if (!picker.projects.length) {
821
+ closePicker();
822
+ return;
823
+ } else {
824
+ var all = pickMatches(input.value);
825
+ if (!all.length) {
826
+ list.appendChild(pickNote('No project on the map matches. You can still paste its address.'));
827
+ }
828
+ picker.shown = all.slice(0, PICK_MAX);
829
+ picker.shown.forEach(function (p, i) {
830
+ var li = document.createElement('li');
831
+ li.className = 'joinOpt';
832
+ li.id = 'joinOpt' + i;
833
+ li.setAttribute('role', 'option');
834
+ li.setAttribute('aria-selected', 'false');
835
+ if (p.applied) li.setAttribute('aria-disabled', 'true');
836
+ var name = document.createElement('span');
837
+ name.className = 'joinOpt__name';
838
+ name.textContent = p.name;
839
+ var host = document.createElement('span');
840
+ host.className = 'joinOpt__host';
841
+ host.textContent = p.host;
842
+ li.appendChild(name);
843
+ li.appendChild(host);
844
+ if (p.applied) {
845
+ var tag = document.createElement('span');
846
+ tag.className = 'joinOpt__tag';
847
+ tag.textContent = 'Applied';
848
+ li.appendChild(tag);
849
+ }
850
+ /* mousedown, not click: it fires before the input's blur closes the list */
851
+ li.addEventListener('mousedown', function (ev) { ev.preventDefault(); choose(p); });
852
+ list.appendChild(li);
853
+ });
854
+ if (all.length > PICK_MAX) list.appendChild(pickNote((all.length - PICK_MAX) + ' more on the map. Keep typing to narrow it.'));
855
+ }
856
+ list.hidden = false;
857
+ input.setAttribute('aria-expanded', 'true');
858
+ }
859
+ function openPicker() {
860
+ renderPicker();
861
+ loadPicker().then(function () {
862
+ if (document.activeElement === $('joinOrigin')) renderPicker();
863
+ });
864
+ }
865
+ function stepActive(dir) {
866
+ var n = picker.shown.length;
867
+ if (!n) return;
868
+ var i = picker.active;
869
+ for (var tries = 0; tries < n; tries++) {
870
+ i = i < 0 ? (dir > 0 ? 0 : n - 1) : (i + dir + n) % n;
871
+ if (!picker.shown[i].applied) { setActive(i); return; }
872
+ }
873
+ }
874
+ $('joinOrigin').addEventListener('focus', openPicker);
875
+ $('joinOrigin').addEventListener('input', openPicker);
876
+ $('joinOrigin').addEventListener('blur', closePicker);
877
+ $('joinOrigin').addEventListener('keydown', function (ev) {
878
+ var open = !$('joinList').hidden;
879
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
880
+ ev.preventDefault();
881
+ if (!open) { openPicker(); return; }
882
+ stepActive(ev.key === 'ArrowDown' ? 1 : -1);
883
+ } else if (ev.key === 'Escape') {
884
+ if (open) { ev.preventDefault(); closePicker(); }
885
+ } else if (ev.key === 'Enter') {
886
+ ev.preventDefault();
887
+ if (open && picker.active >= 0) { choose(picker.shown[picker.active]); return; }
888
+ closePicker();
889
+ $('joinBtn').click();
890
+ }
891
+ });
892
+ /* A name typed in full and never picked still means that project. */
893
+ function originForTyped(typed) {
894
+ var t = String(typed || '').trim().toLowerCase();
895
+ var hits = (picker.projects || []).filter(function (p) { return p.name.toLowerCase() === t; });
896
+ return hits.length === 1 ? hits[0].origin : typed;
897
+ }
898
+
899
+ /* Join by address, with the applicant's own optional note (task 1002330). */
900
+ $('joinBtn').addEventListener('click', function () {
901
+ var origin = originForTyped(($('joinOrigin').value || '').trim());
902
+ var status = $('joinStatus');
903
+ if (!origin) { status.className = 'projMsg err'; status.textContent = 'Pick a project or enter its address.'; return; }
904
+ /* a bare host ("grove.cloudbongos.com") never resolves — the server's own
905
+ origin lookup is an exact match against the registered https:// form —
906
+ and would otherwise surface as the generic "could not reach" message
907
+ for what is really a typo. Assume https:// rather than making the
908
+ visitor retype it. */
909
+ if (!/^https?:\/\//i.test(origin)) origin = 'https://' + origin;
910
+ var note = ($('joinNote').value || '').trim();
911
+ var btn = $('joinBtn');
912
+ btn.disabled = true;
913
+ status.className = 'projMsg';
914
+ status.textContent = 'Applying…';
915
+ postJoinRequest(origin, note).then(function (res) {
916
+ btn.disabled = false;
917
+ if (res.unauthenticated) { sessionExpired(status); return; }
918
+ if (res.ok && res.data && res.data.message) {
919
+ status.className = 'projMsg' + (res.data.status === 'requested' ? ' ok' : '');
920
+ status.textContent = res.data.message;
921
+ if (res.data.status === 'requested') {
922
+ $('joinOrigin').value = '';
923
+ $('joinNote').value = '';
924
+ /* the picker lists it as Applied from now on, as the hub's echo will */
925
+ var appliedHost = pickHost((res.data.project && res.data.project.origin) || origin);
926
+ (picker.projects || []).forEach(function (p) { if (p.host === appliedHost) p.applied = true; });
927
+ }
928
+ } else {
929
+ var e = apiError(res.data, res.status);
930
+ status.className = 'projMsg err';
931
+ status.textContent = e.message || 'Could not reach that project — try again in a moment.';
932
+ }
933
+ }).catch(function () {
934
+ btn.disabled = false;
935
+ status.className = 'projMsg err';
936
+ status.textContent = 'Couldn’t reach the platform — check your connection and try again.';
937
+ });
938
+ });
939
+
940
+ // ---- the page's three states: reading, signed out, signed in ----
941
+ function showDoor() {
942
+ $('loading').hidden = true;
943
+ $('invtBody').hidden = true;
944
+ $('door').hidden = false;
945
+ }
946
+ bar.ready.then(function (me) {
947
+ if (!me) { showDoor(); return; }
948
+ $('loading').hidden = true;
949
+ $('door').hidden = true;
950
+ $('invtBody').hidden = false;
951
+ loadInvites();
952
+ });
953
+ })();
954
+ </script>
955
+ </body>
956
+ </html>