@bongos/core 1.21.56 → 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 (51) hide show
  1. package/.bongos-core.json +106 -46
  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 +361 -315
  13. package/docs/copy-registry.json +868 -419
  14. package/docs/file-map.md +4 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +38 -4
  17. package/docs/page-readings.json +856 -771
  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 +633 -0
  28. package/modules/public-landing/public/guild.probes.json +63 -0
  29. package/modules/public-landing/public/guild.states.json +43 -0
  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/guilds.json +73 -0
  34. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  35. package/modules/ui-design/kit/serve.js +99 -5
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/scripts/gds/run-unit-tests.js +5 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +13 -0
  42. package/tests/applicant_profile_boundary.mjs +104 -1
  43. package/tests/application_echo.mjs +91 -5
  44. package/tests/guild_engagement.mjs +881 -0
  45. package/tests/guild_engagement_db.mjs +240 -0
  46. package/tests/hub_engagement_cards.mjs +307 -0
  47. package/tests/hub_guild_page.mjs +624 -0
  48. package/tests/privacy_matrix.mjs +216 -14
  49. package/tests/project_invite_ui.mjs +187 -3
  50. package/tests/render_check.mjs +2 -2
  51. package/tests/ui_design_kit.mjs +3 -3
@@ -0,0 +1,633 @@
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 GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6, ADR 0337 D4.4). A guild
7
+ is a group of builders across projects, so its page lives on the hub, at the
8
+ /g/<slug> namespace ADR 0336 D6 reserved. One shell for every slug: the
9
+ script reads the slug from the path and asks two routes of platform-identity —
10
+ GET /guilds/:slug the PUBLIC read: name, public roster, size and
11
+ the rounded weekly totals; 404 for an unknown
12
+ slug AND for an unlisted guild, to everyone;
13
+ GET /guilds/:slug/members the OWN-SCOPE read, signed in only: a member's
14
+ door to their guild, unlisted ones included,
15
+ with their own two switches;
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
19
+ stranger, an unknown slug and an unlisted guild seen from outside are one
20
+ "no guild here", so the page never confirms an unlisted guild exists.
21
+ Hub-only, like /account: platform-server.js serves /g/<slug> only where
22
+ platform-identity is on. noindex while one shell answers every slug (a guild
23
+ that does not exist would otherwise index as a page); putting guilds in
24
+ search is the SEO goal's call (ADR 0336 D6). Server-stamped: {{…}} tokens are
25
+ filled by landingPage(). -->
26
+ <title>{{productName}} — Guild</title>
27
+ <meta name="description" content="A guild on {{productName}}: the builders in it, and what they have built together.">
28
+ <meta name="robots" content="noindex">
29
+ <link rel="preconnect" href="https://fonts.googleapis.com">
30
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
31
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
32
+ <link rel="stylesheet" href="/assets/cosmos.css">
33
+ <!-- Dark only, like every apex page (ADR 0204): data-mode="dark" is fixed on <html>. -->
34
+ <style>
35
+ /* The guild page's own layout, in the account page's voice (account.html): a
36
+ lowercase light heading, hairline sections with the title left and the body
37
+ right, the choice cells with the dot. No :root block — the pack's injected
38
+ theme owns the tokens; everything below reads them. The bar and the bands are
39
+ cosmos.css's own rules, not restyled here. */
40
+ /* the skip link un-hides itself on focus, reversing everything .vh sets (the hub's rule) */
41
+ .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;
42
+ font-size:14px;font-weight:800;text-decoration:none;color:var(--ink);background:var(--bg-card);
43
+ border:2px solid var(--ctl-line);border-radius:12px;padding:10px 16px;}
44
+ .gld{position:relative;z-index:1;max-width:1060px;margin:0 auto;padding:clamp(6px,1.5vw,18px) clamp(16px,4vw,40px) 56px;}
45
+ .gld h1{font-family:var(--font-display);font-weight:300;font-size:clamp(30px,4.2vw,56px);letter-spacing:-.04em;line-height:1.02;text-transform:lowercase;color:var(--ink);margin:6px 0 0;overflow-wrap:anywhere;}
46
+ .gld .lede{margin:12px 0 0;max-width:62ch;font-size:15.5px;font-weight:400;line-height:1.55;color:var(--ink-soft);white-space:pre-line;overflow-wrap:anywhere;}
47
+ .gld .loading{font-size:15px;color:var(--ink-soft);}
48
+ .gKicker,.gMeta{margin:18px 0 0;font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);}
49
+ .gMeta{margin:16px 0 26px;font-variant-numeric:tabular-nums;}
50
+ .gMiss{padding:0 0 10px;max-width:62ch;}
51
+ .gMiss h1{margin-top:28px;}
52
+ .gMiss .lede{margin-bottom:22px;}
53
+ .gMiss .gh{width:18px;height:18px;fill:currentColor;}
54
+ .gMiss .btn{text-transform:none;}
55
+ /* the sections: the account page's .aSec grid */
56
+ .aSec{display:grid;grid-template-columns:minmax(200px,300px) 1fr;grid-template-rows:auto 1fr;gap:6px 40px;border-top:1px solid var(--rule);padding:30px 0 34px;}
57
+ .aSec>h2{grid-column:1;font-family:var(--font-display);font-weight:300;font-size:clamp(22px,2.4vw,28px);letter-spacing:-.03em;line-height:1.08;text-transform:lowercase;color:var(--ink);margin:0;}
58
+ .aSec>.aSide{grid-column:1;align-self:start;margin:4px 0 0;max-width:34ch;font-size:13.5px;line-height:1.5;color:var(--ink-faint);}
59
+ .aSec>.aBody{grid-column:2;grid-row:1/span 2;display:grid;gap:18px;align-content:start;min-width:0;}
60
+ .aLabel{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);}
61
+ .aBand{display:grid;gap:8px;}
62
+ .aBand [role="radiogroup"]{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
63
+ .aOpt{position:relative;display:block;margin:0;padding:16px 18px 18px;border-left:1px solid var(--rule-soft);cursor:pointer;}
64
+ .aOpt:first-child{border-left:0;padding-left:0;}
65
+ .aOpt input{position:absolute;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);}
66
+ .aOpt>span{display:block;font-size:13px;line-height:1.45;color:var(--ink-faint);}
67
+ .aOpt b{display:flex;align-items:center;gap:10px;margin-bottom:6px;font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
68
+ .aOpt b::before{content:"";width:7px;height:7px;border-radius:50%;border:1px solid var(--rule);background:var(--bg);flex:none;}
69
+ .aOpt:has(input:checked) b::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
70
+ .aOpt:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
71
+ .aWhat{margin:0;font-size:13.5px;line-height:1.5;color:var(--ink-soft);max-width:70ch;}
72
+ .gld .actions{margin:0;gap:14px;}
73
+ .gld .btn{min-height:44px;padding:0 22px;font-family:var(--font-body);font-weight:500;font-size:15px;text-transform:lowercase;white-space:nowrap;box-shadow:none;}
74
+ .gld .btn:hover,.gld .btn:active{transform:none;box-shadow:none;}
75
+ .gld .projMsg{margin:0;min-height:0;font-weight:400;}
76
+ /* the prompt after a guild goes public (ADR 0336 D4 §3): asked, never assumed */
77
+ .gAsk{display:grid;gap:14px;padding:18px 0 20px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
78
+ .gAsk p{margin:0;font-size:14.5px;line-height:1.55;color:var(--ink-soft);max-width:62ch;}
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);}
86
+ /* the record: four band floors as figures, two by two so a long band ("200,000+")
87
+ keeps its cell, a hairline above each (the ledger grammar) */
88
+ .gFigs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 28px;margin:0;}
89
+ .gFigs>div{display:grid;gap:6px;align-content:start;padding:14px 0 4px;border-top:1px solid var(--rule-soft);min-width:0;}
90
+ .gFigs dt{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);}
91
+ .gFigs dd{margin:0;font-family:var(--font-display);font-weight:300;font-size:clamp(28px,3.2vw,44px);letter-spacing:-.035em;line-height:1;color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap;}
92
+ .gFigs dd.under{font-size:clamp(20px,2vw,26px);letter-spacing:-.02em;color:var(--ink-soft);}
93
+ /* the roster: one hairline row per member */
94
+ .gRoster{list-style:none;margin:0;padding:0;}
95
+ .gRoster li{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:0 14px;align-items:center;padding:12px 0;border-top:1px solid var(--rule-soft);}
96
+ .gRoster li:last-child{border-bottom:1px solid var(--rule-soft);}
97
+ .gRoster .gAv{width:36px;height:36px;border-radius:50%;object-fit:cover;background:var(--bg-card);}
98
+ .gRoster span.gAv{display:grid;place-items:center;font-family:var(--font-body);font-weight:500;font-size:15px;text-transform:uppercase;color:var(--ink-soft);border:1px solid var(--rule);box-sizing:border-box;}
99
+ .gRoster .gWho{display:grid;gap:2px;min-width:0;}
100
+ .gRoster .gName{font-family:var(--font-body);font-weight:500;font-size:15.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
101
+ .gRoster .gHandle{font-family:var(--font-mono);font-size:12.5px;color:var(--ink-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
102
+ .gRoster .gKind{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-soft);}
103
+ @media (max-width:900px){
104
+ .aSec{grid-template-columns:1fr;gap:12px;}
105
+ .aSec>h2,.aSec>.aSide,.aSec>.aBody{grid-column:1;grid-row:auto;}
106
+ .aSec>.aSide{max-width:none;}
107
+ }
108
+ @media (max-width:560px){
109
+ .gFigs{gap:0 18px;}
110
+ .aBand [role="radiogroup"]{grid-template-columns:1fr;}
111
+ .aOpt{padding:14px 0 16px;border-left:0;border-top:1px solid var(--rule-soft);}
112
+ .aOpt:first-child{border-top:0;}
113
+ }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <!-- THE BAR and its two bands are the hub's own (projects.html, task 1004455):
118
+ the account page's markup with no current place, so /projects → /g/<slug>
119
+ keeps the chrome. assets/hub-bar.js runs it. The sign-in doors name no return
120
+ in the markup — one shell serves every slug — so the page script points them
121
+ back at this guild once it has read the slug (and without script, the sign-in
122
+ route falls back to the Referer, which is this page). -->
123
+ <a class="skip vh" href="#guild">Skip to content</a>
124
+ <header class="shell bar">
125
+ <div class="barLeft">
126
+ <a class="barMark" id="barMark" href="/">
127
+ <svg viewBox="0 0 26 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4"
128
+ stroke-linecap="round" stroke-linejoin="round">
129
+ <ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>
130
+ <path d="M1.8 5.6v6.6a6.6 2.9 0 0 0 13.2 0V5.6"/>
131
+ <ellipse cx="19.6" cy="8.4" rx="4.7" ry="2.1"/>
132
+ <path d="M14.9 8.4v4.6a4.7 2.1 0 0 0 9.4 0V8.4"/>
133
+ </svg>
134
+ <span>{{productName}}</span>
135
+ </a>
136
+ </div>
137
+ <div class="barRight">
138
+ <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>
139
+ <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
140
+ Signed out it stays hidden and the sign-in door takes its slot. -->
141
+ <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>
142
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?repos=1">Sign in</a>
143
+ </div>
144
+ </header>
145
+
146
+ <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
147
+ <div class="bandIn">
148
+ <ul class="bandRow">
149
+ <li class="bandCell"><a class="bandT" href="/projects?view=map">Sky</a><span class="bandC">the sky of every project</span></li>
150
+ <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>
151
+ <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>
152
+ <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
153
+ </ul>
154
+ </div>
155
+ </div>
156
+ <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
157
+ <div class="bandIn bandProfile">
158
+ <p class="bandWho" id="bandWho"><img id="bandWhoImg" alt="" hidden><span class="whoName" id="bandWhoLogin"></span></p>
159
+ <ul class="bandRow">
160
+ <li class="bandCell"><a class="bandT" href="/projects?view=mine">My projects</a></li>
161
+ <li class="bandCell"><a class="bandT" href="/account">Account</a></li>
162
+ <!-- the honest hover target; the delegated click upgrades it to the POST -->
163
+ <li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>
164
+ </ul>
165
+ </div>
166
+ </div>
167
+
168
+ <main class="gld" id="guild">
169
+ <p class="loading" id="loading">Reading this guild…</p>
170
+ <div class="alert" id="pageAlert" role="alert"></div>
171
+
172
+ <!-- NO GUILD HERE: an unknown slug, a malformed one, and an unlisted guild seen
173
+ by anyone but its members get this one answer (ADR 0336 D4). Signed out,
174
+ it offers sign-in, because a member reaches an unlisted guild that way. -->
175
+ <section class="gMiss" id="missing" hidden aria-labelledby="missing-h">
176
+ <h1 id="missing-h">no guild here</h1>
177
+ <p class="lede" id="missingWhy"></p>
178
+ <a class="btn" id="missingSignin" hidden href="/api/bongos/auth/web/start?repos=1">
179
+ <!-- keep the spaces inside this path's numbers: same shape, and they are load-bearing (task 1004401) -->
180
+ <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>
181
+ Sign in with GitHub
182
+ </a>
183
+ </section>
184
+
185
+ <!-- THE GUILD: ships hidden, revealed once either read answers -->
186
+ <div id="guildBody" hidden>
187
+ <p class="gKicker" id="gKicker">Guild</p>
188
+ <h1 id="gName"></h1>
189
+ <p class="lede" id="gDesc" hidden></p>
190
+ <p class="gMeta" id="gMeta"></p>
191
+
192
+ <!-- YOUR PLACE — members only, from the own-scope read. Two switches, each the
193
+ member's own (ADR 0336 D4, ADR 0337 D4.4): shown on the public page, and
194
+ counted in the totals. Only what changed is sent. -->
195
+ <section class="aSec" id="gYou" hidden aria-labelledby="gYou-h">
196
+ <h2 id="gYou-h">your place</h2>
197
+ <p class="aSide" id="gYouSide"></p>
198
+ <form class="aBody" id="youForm" novalidate>
199
+ <!-- the prompt after an unlisted → public flip: a member who joined an
200
+ unlisted guild is not shown on its public page until they say yes -->
201
+ <div class="gAsk" id="showAsk" hidden>
202
+ <p><b>Show you on this guild’s public page?</b>This guild is public, and you’re a member who isn’t on its public page. Nobody is listed there until they say yes.</p>
203
+ <div class="actions"><button type="button" class="btn" id="askYes">show me</button><button type="button" class="btn ghost" id="askNo">not now</button></div>
204
+ </div>
205
+ <div class="aBand" id="shownBand">
206
+ <span class="aLabel" id="shownLabel">On this guild’s public page</span>
207
+ <div role="radiogroup" aria-labelledby="shownLabel">
208
+ <label class="aOpt"><input type="radio" name="shown" value="on"><span><b>Show me</b>Your name and picture are listed on its public page, while your account is public.</span></label>
209
+ <label class="aOpt"><input type="radio" name="shown" value="off"><span><b>Keep me off</b>You stay a member, and the public page doesn’t list you.</span></label>
210
+ </div>
211
+ </div>
212
+ <p class="aWhat" id="shownUnlisted" hidden></p>
213
+ <div class="aBand" id="countBand">
214
+ <span class="aLabel" id="countLabel">In this guild’s totals</span>
215
+ <div role="radiogroup" aria-labelledby="countLabel">
216
+ <label class="aOpt"><input type="radio" name="counted" value="on"><span><b>Count my numbers</b>They add to this guild’s totals.</span></label>
217
+ <label class="aOpt"><input type="radio" name="counted" value="off"><span><b>Leave them out</b>The totals add up everyone else.</span></label>
218
+ </div>
219
+ <p class="aWhat" id="countWhat"></p>
220
+ </div>
221
+ <div class="actions"><button type="submit" class="btn" id="youSave">save</button></div>
222
+ <p class="projMsg" id="youMsg" role="status" aria-live="polite"></p>
223
+ </form>
224
+ </section>
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
+
242
+ <!-- THE RECORD — R26's rounded weekly totals (ADR 0337 D4.4), from the public
243
+ read only: band floors ("100+"), never an exact sum, and no count of who
244
+ is in them. An unlisted guild publishes none. -->
245
+ <section class="aSec" id="gRecord" aria-labelledby="gRecord-h">
246
+ <h2 id="gRecord-h">the record</h2>
247
+ <p class="aSide">What its members have built together, across every project. Rounded down to a band, and counted once a week.</p>
248
+ <div class="aBody">
249
+ <dl class="gFigs" id="gFigs">
250
+ <div><dt>Works shipped</dt><dd id="tShipped">—</dd></div>
251
+ <div><dt>Credits</dt><dd id="tCredits">—</dd></div>
252
+ <div><dt>Karma</dt><dd id="tKarma">—</dd></div>
253
+ <div><dt>Projects</dt><dd id="tProjects">—</dd></div>
254
+ </dl>
255
+ <p class="aWhat" id="gRecordNote"></p>
256
+ </div>
257
+ </section>
258
+
259
+ <!-- MEMBERS — the public roster (ADR 0336 D4: handle, display name, avatar,
260
+ kind; never a GitHub id or login), or, for a member, the members' roster,
261
+ which lists everyone in the guild. -->
262
+ <section class="aSec" id="gMembers" aria-labelledby="gMembers-h">
263
+ <h2 id="gMembers-h">members</h2>
264
+ <p class="aSide" id="gMembersSide"></p>
265
+ <div class="aBody">
266
+ <ul class="gRoster" id="gRoster"></ul>
267
+ <p class="aWhat" id="gRosterEmpty" hidden>Nobody is listed on this page yet.</p>
268
+ </div>
269
+ </section>
270
+ </div>
271
+ </main>
272
+
273
+ <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>
274
+
275
+ <script src="/assets/hub-bar.js"></script>
276
+ <script>
277
+ (function () {
278
+ 'use strict';
279
+ var API = '/api/bongos';
280
+ function $(id) { return document.getElementById(id); }
281
+ // The bar's chip, bands and sign out are assets/hub-bar.js (task 1004489), loaded
282
+ // just above; bar.ready settles with GET /me's answer, null when nobody is
283
+ // signed in. Without the asset the page still shows the public guild.
284
+ var bar = window.HubBar || { paint: function () {}, ready: Promise.resolve(null) };
285
+
286
+ // The slug rule is guilds.js's (normalizeHandle's charset, a two-character
287
+ // floor, at most 39) — boundary-forced duplication (ADR 0091 §2): a page cannot
288
+ // import a module. A path that cannot be a slug is "no guild here" without a
289
+ // request. tests/hub_guild_page.mjs holds this to the module's rule.
290
+ var SLUG_RE = /^(?=.{2,39}$)[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
291
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
292
+ var BASE_TITLE = document.title;
293
+
294
+ // What the server last said: the public read and the own-scope read. Every
295
+ // save re-paints from the server's answer, never from what was clicked.
296
+ var slug = null;
297
+ var pub = null;
298
+ var mine = null;
299
+
300
+ function request(method, path, body) {
301
+ var init = { method: method, credentials: 'same-origin', headers: { Accept: 'application/json' } };
302
+ if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
303
+ return fetch(API + path, init).then(function (res) {
304
+ return res.json().catch(function () { return null; }).then(function (data) {
305
+ return { status: res.status, ok: res.ok, data: data };
306
+ });
307
+ });
308
+ }
309
+ function serverWords(res, fallback) {
310
+ var m = res && res.data && res.data.error && res.data.error.message;
311
+ if (typeof m !== 'string' || !m) return fallback;
312
+ return m.charAt(0).toUpperCase() + m.slice(1) + (/[.!?]$/.test(m) ? '' : '.');
313
+ }
314
+ function say(el, text, kind) {
315
+ el.className = 'projMsg' + (kind ? ' ' + kind : '');
316
+ el.textContent = text;
317
+ }
318
+ function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
319
+ function monthYear(iso) {
320
+ var d = new Date(iso);
321
+ return isNaN(d.getTime()) ? '' : MONTHS[d.getUTCMonth()] + ' ' + d.getUTCFullYear();
322
+ }
323
+ function dayMonthYear(iso) {
324
+ var d = new Date(iso);
325
+ return isNaN(d.getTime()) ? '' : d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()] + ' ' + d.getUTCFullYear();
326
+ }
327
+ // A total is published as its band floor (guild-totals.js bandFloor): 0 means
328
+ // below the first band, which a page says as "under 10".
329
+ function band(n) {
330
+ var v = Number(n);
331
+ if (!isFinite(v) || v <= 0) return { text: 'under 10', under: true };
332
+ return { text: Math.floor(v).toLocaleString('en-US') + '+', under: false };
333
+ }
334
+
335
+ // ---- the slug, and the sign-in doors that return to it ----
336
+ function slugFromPath(pathname) {
337
+ var m = /^\/g\/([^/]+)\/?$/.exec(pathname || '');
338
+ if (!m) return null;
339
+ var s;
340
+ try { s = decodeURIComponent(m[1]).toLowerCase(); } catch (e) { return null; }
341
+ return SLUG_RE.test(s) ? s : null;
342
+ }
343
+ function pointSigninHere() {
344
+ // the slug is SLUG_RE-clean ([a-z0-9-]), so it needs no encoding: the shape the
345
+ // other hub doors send (return=/account&repos=1)
346
+ var href = API + '/auth/web/start?return=/g/' + slug + '&repos=1';
347
+ $('signinTop').setAttribute('href', href);
348
+ $('missingSignin').setAttribute('href', href);
349
+ }
350
+
351
+ // ---- the page's states: reading, no guild here, the guild ----
352
+ function showAlert(text) {
353
+ $('loading').hidden = true;
354
+ var a = $('pageAlert');
355
+ a.textContent = text;
356
+ a.className = 'alert on';
357
+ }
358
+ function showMissing(signedIn) {
359
+ $('loading').hidden = true;
360
+ $('guildBody').hidden = true;
361
+ $('missingWhy').textContent = signedIn
362
+ ? 'There is no guild at this address that you can see. A guild that is unlisted is shown only to its members.'
363
+ : 'There is no public guild at this address. If it is an unlisted guild you belong to, sign in to see it.';
364
+ $('missingSignin').hidden = !!signedIn;
365
+ $('missing').hidden = false;
366
+ }
367
+
368
+ function load() {
369
+ slug = slugFromPath(location.pathname);
370
+ if (!slug) { return bar.ready.then(function (me) { showMissing(!!me); }); }
371
+ pointSigninHere();
372
+ return Promise.all([
373
+ request('GET', '/guilds/' + slug),
374
+ // the own-scope read only for someone signed in: a stranger's page makes one call
375
+ bar.ready.then(function (me) { return me ? request('GET', '/guilds/' + slug + '/members') : null; }),
376
+ ]).then(function (both) {
377
+ var p = both[0], m = both[1];
378
+ var signedIn = !!m && m.status !== 401;
379
+ pub = p.ok && p.data && p.data.guild ? p.data : null;
380
+ mine = m && m.ok && m.data && m.data.guild ? m.data : null;
381
+ if (!pub && !mine) {
382
+ if (p.status === 404 && (!m || m.status === 404 || m.status === 401)) { showMissing(signedIn); return; }
383
+ showAlert('Couldn’t read this guild right now — refresh in a moment.');
384
+ return;
385
+ }
386
+ paint();
387
+ $('loading').hidden = true;
388
+ $('missing').hidden = true;
389
+ $('guildBody').hidden = false;
390
+ }, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
391
+ }
392
+
393
+ // ---- paint: every region from the server's answers ----
394
+ function paint() { paintHead(); paintYou(); paintApply(); paintRecord(); paintRoster(); }
395
+
396
+ function guild() { return (mine && mine.guild) || pub.guild; }
397
+
398
+ function paintHead() {
399
+ var g = guild();
400
+ document.title = g.name + ' — ' + BASE_TITLE.replace(/ — Guild$/, '');
401
+ $('gName').textContent = g.name;
402
+ var desc = g.description ? String(g.description).trim() : '';
403
+ $('gDesc').textContent = desc;
404
+ $('gDesc').hidden = !desc;
405
+ // a member is told which kind of guild this is; the public sees only public ones
406
+ $('gKicker').textContent = mine ? 'Guild · ' + (mine.guild.visibility === 'public' ? 'public' : 'unlisted') : 'Guild';
407
+ var parts = [];
408
+ if (mine) {
409
+ parts.push(plural(mine.count, 'member', 'members'));
410
+ if (pub) parts.push(pub.size + ' on the public page');
411
+ } else {
412
+ parts.push(plural(pub.size, 'member', 'members'));
413
+ }
414
+ var since = pub && pub.guild.created_at ? monthYear(pub.guild.created_at) : '';
415
+ if (since) parts.push('since ' + since);
416
+ $('gMeta').textContent = parts.join(' · ');
417
+ }
418
+
419
+ // ---- your place: the member's own two switches ----
420
+ var ASK_KEY = 'cb-guild-ask-';
421
+ function askDismissed() {
422
+ try { return window.localStorage.getItem(ASK_KEY + slug) === 'no'; } catch (e) { return false; }
423
+ }
424
+ function dismissAsk() {
425
+ try { window.localStorage.setItem(ASK_KEY + slug, 'no'); } catch (e) { /* a per-viewer convenience only */ }
426
+ }
427
+ function pick(name, value) {
428
+ document.querySelectorAll('input[name="' + name + '"]').forEach(function (r) { r.checked = r.value === value; });
429
+ }
430
+ function picked(name) {
431
+ var r = document.querySelector('input[name="' + name + '"]:checked');
432
+ return r ? r.value : null;
433
+ }
434
+ function paintYou() {
435
+ if (!mine) { $('gYou').hidden = true; return; }
436
+ var you = mine.you;
437
+ var isPublic = mine.guild.visibility === 'public';
438
+ $('gYou').hidden = false;
439
+ $('gYouSide').textContent = (you.membership_kind === 'owner' ? 'You own this guild.' : 'You’re a member of this guild.') +
440
+ ' Only you see this, and only you can change it.';
441
+ // Asked, never assumed: while the prompt stands it IS the control, so the pair
442
+ // for the same switch waits until it is answered.
443
+ var asking = isPublic && !you.shown_publicly && !askDismissed();
444
+ $('showAsk').hidden = !asking;
445
+ $('shownBand').hidden = !isPublic || asking;
446
+ $('shownUnlisted').hidden = isPublic;
447
+ // A visibility change flips only the OWNER's own flag (ADR 0336 D4 §3), so the
448
+ // owner is listed the moment they make it public; everyone else is asked.
449
+ $('shownUnlisted').textContent = 'This guild is unlisted, so it has no public page. ' + (you.membership_kind === 'owner'
450
+ ? 'If you make it public, you’ll be listed on it as its owner, and each member will be asked before they are.'
451
+ : 'If it is made public, you’ll be asked here before you’re listed on it.');
452
+ pick('shown', you.shown_publicly ? 'on' : 'off');
453
+ pick('counted', you.counted_in_totals ? 'on' : 'off');
454
+ paintCountWhat();
455
+ }
456
+ // The notice IS the consent (ADR 0337 D4.4): said for the choice as it stands
457
+ // on the page, before anything is saved.
458
+ function paintCountWhat() {
459
+ $('countWhat').textContent = picked('counted') === 'off'
460
+ ? 'Your numbers are left out of this guild’s totals, from its next weekly count.'
461
+ : 'Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. ' +
462
+ 'They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. ' +
463
+ 'Leave them out of any guild you’d rather not share them with.';
464
+ }
465
+ document.querySelectorAll('input[name="counted"]').forEach(function (r) { r.addEventListener('change', paintCountWhat); });
466
+
467
+ // Re-read both after a save: the public roster and size move with the switch.
468
+ function reread() {
469
+ return Promise.all([
470
+ request('GET', '/guilds/' + slug),
471
+ request('GET', '/guilds/' + slug + '/members'),
472
+ ]).then(function (both) {
473
+ if (both[0].ok && both[0].data && both[0].data.guild) pub = both[0].data;
474
+ else if (both[0].status === 404) pub = null;
475
+ if (both[1].ok && both[1].data && both[1].data.guild) mine = both[1].data;
476
+ paint();
477
+ });
478
+ }
479
+ function saveSwitches(body, done, btns) {
480
+ var msg = $('youMsg');
481
+ btns.forEach(function (b) { b.disabled = true; });
482
+ say(msg, 'Saving…');
483
+ return request('PATCH', '/guilds/' + slug + '/membership', body).then(function (res) {
484
+ btns.forEach(function (b) { b.disabled = false; });
485
+ if (res.status === 401) { say(msg, 'Your session expired — sign in again to save.', 'err'); bar.paint(null); return; }
486
+ if (!res.ok) { say(msg, serverWords(res, 'Couldn’t save that. Try again.'), 'err'); return; }
487
+ mine.you.shown_publicly = !!res.data.shown_publicly;
488
+ mine.you.counted_in_totals = !!res.data.counted_in_totals;
489
+ paint();
490
+ say(msg, done, 'ok');
491
+ return reread();
492
+ }, function () { btns.forEach(function (b) { b.disabled = false; }); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
493
+ }
494
+ $('youForm').addEventListener('submit', function (ev) {
495
+ ev.preventDefault();
496
+ var you = mine.you;
497
+ var body = {};
498
+ var said = [];
499
+ var shown = picked('shown');
500
+ if (mine.guild.visibility === 'public' && shown && (shown === 'on') !== !!you.shown_publicly) {
501
+ body.shown_publicly = shown === 'on';
502
+ said.push(shown === 'on' ? 'you’re listed on its public page' : 'you’re off its public page');
503
+ // a member who answered here has answered the prompt too
504
+ dismissAsk();
505
+ }
506
+ var counted = picked('counted');
507
+ if (counted && (counted === 'on') !== !!you.counted_in_totals) {
508
+ body.counted_in_totals = counted === 'on';
509
+ said.push(counted === 'on' ? 'your numbers count toward its totals' : 'your numbers are left out of its totals');
510
+ }
511
+ if (!Object.keys(body).length) { say($('youMsg'), 'Nothing has changed.'); return; }
512
+ saveSwitches(body, 'Saved — ' + said.join(', and ') + '.', [$('youSave')]);
513
+ });
514
+ $('askYes').addEventListener('click', function () {
515
+ dismissAsk();
516
+ saveSwitches({ shown_publicly: true }, 'Saved — you’re listed on its public page.', [$('askYes'), $('askNo'), $('youSave')]);
517
+ });
518
+ $('askNo').addEventListener('click', function () {
519
+ dismissAsk();
520
+ $('showAsk').hidden = true;
521
+ $('shownBand').hidden = false;
522
+ say($('youMsg'), 'You’re staying off its public page. You can change that here at any time.');
523
+ });
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
+
563
+ // ---- the record ----
564
+ function paintRecord() {
565
+ var t = pub && pub.totals;
566
+ [['tShipped', 'works_shipped'], ['tCredits', 'credits'], ['tKarma', 'karma'], ['tProjects', 'projects']].forEach(function (pair) {
567
+ var dd = $(pair[0]);
568
+ if (!t) { dd.textContent = '—'; dd.className = 'under'; return; }
569
+ var b = band(t[pair[1]]);
570
+ dd.textContent = b.text;
571
+ dd.className = b.under ? 'under' : '';
572
+ });
573
+ $('gFigs').hidden = !pub;
574
+ var note;
575
+ if (!pub) note = 'This guild is unlisted, so its record isn’t published anywhere.';
576
+ else if (!t) note = 'Not counted yet. A guild’s record is counted once a week.';
577
+ else note = 'Counted on ' + dayMonthYear(t.as_of) + '. Projects are the public projects its listed members build on.';
578
+ $('gRecordNote').textContent = note;
579
+ }
580
+
581
+ // ---- the roster ----
582
+ function avatarOf(m) {
583
+ var url = typeof m.avatar_url === 'string' && /^https?:\/\//i.test(m.avatar_url) ? m.avatar_url : null;
584
+ if (url) {
585
+ var img = document.createElement('img');
586
+ img.className = 'gAv';
587
+ img.alt = '';
588
+ img.loading = 'lazy';
589
+ img.src = url;
590
+ return img;
591
+ }
592
+ var span = document.createElement('span');
593
+ span.className = 'gAv';
594
+ span.setAttribute('aria-hidden', 'true');
595
+ span.textContent = Array.from(String(m.display_name || m.handle || '?').trim())[0] || '?';
596
+ return span;
597
+ }
598
+ function rowOf(m) {
599
+ var li = document.createElement('li');
600
+ li.appendChild(avatarOf(m));
601
+ var who = document.createElement('span');
602
+ who.className = 'gWho';
603
+ var name = document.createElement('span');
604
+ name.className = 'gName';
605
+ name.textContent = m.display_name || m.handle;
606
+ var handle = document.createElement('span');
607
+ handle.className = 'gHandle';
608
+ handle.textContent = m.handle;
609
+ who.appendChild(name);
610
+ who.appendChild(handle);
611
+ li.appendChild(who);
612
+ var kind = document.createElement('span');
613
+ kind.className = 'gKind';
614
+ kind.textContent = m.membership_kind === 'owner' ? 'Owner' : '';
615
+ li.appendChild(kind);
616
+ return li;
617
+ }
618
+ function paintRoster() {
619
+ var list = mine ? mine.members : pub.members;
620
+ $('gMembersSide').textContent = mine
621
+ ? 'Everyone in the guild. Only its members see this list; the public page lists only the members who chose to be on it.'
622
+ : 'The members who chose to be listed on this page.';
623
+ var ul = $('gRoster');
624
+ ul.textContent = '';
625
+ (list || []).forEach(function (m) { ul.appendChild(rowOf(m)); });
626
+ $('gRosterEmpty').hidden = !!(list && list.length);
627
+ }
628
+
629
+ load();
630
+ })();
631
+ </script>
632
+ </body>
633
+ </html>