@bongos/core 1.21.56 → 1.21.57

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