@bongos/core 1.19.621 → 1.19.623

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,517 @@
1
+ // modules/hall-ui/public/board-room.js — the Board Room (/builders/board-room).
2
+ //
3
+ // The hall's only UPWARD-POINTING gate (ADR 0175): a Full Idea that cleared the
4
+ // completeness grade is ratified here, and the constitution itself is amended
5
+ // here. Nothing else is a board subject.
6
+ //
7
+ // CARVED OUT OF government.js BY ADR 0266. It was the `#board-room` tab of the
8
+ // /government page, which is wrong in two ways the move fixes:
9
+ // • that page's nav item is labelled "Permissions" and gated archon, so a
10
+ // Metic who SITS on the board had no door to the room at all;
11
+ // • the page shell asks `page.view.government` (metic+) while the vote route
12
+ // asks `board.vote.cast` (floors at XENOS, deliberately, so that widening
13
+ // the board works). Widen membership below Metic and you get members who
14
+ // may cast a vote through the API and cannot load the page to cast it on.
15
+ // This page is gated on the vote atom itself (auth.js requireBoardVotePage), so
16
+ // its reach equals the right to vote and widening the board stays a config
17
+ // change — the property ADR 0175 §6 claimed and did not have.
18
+ //
19
+ // DEFENCE IN DEPTH, as everywhere in the hall: the page gate decides who gets
20
+ // the shell and nothing more. `GET /government/board/items` carries the same
21
+ // atom server-side, and MEMBERSHIP is checked in-handler per item against that
22
+ // item's own snapshotted constitution — so a holder who sits on no board sees
23
+ // the room and no ballot. That is the honest state, not a bug: ADR 0175 §9 made
24
+ // the ballot open on purpose, because a board whose votes are invisible
25
+ // constrains nobody.
26
+ //
27
+ // TASK 1003270's two rules came with the card and still govern edits here:
28
+ // • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
29
+ // `why_matters` are grammar for the server; a person gets the sentence
30
+ // (decidedByHtml, rankNames, SECTION_LABELS). The single exception is a
31
+ // membership predicate that cannot be PARSED, where the broken string is
32
+ // the actionable fact.
33
+ // • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
34
+ // What simplifying may NOT touch is ADR 0175 §8: an objection still carries a
35
+ // reason and names one section. That requirement is why voting lives in the
36
+ // hall and not on a chat reaction.
37
+ //
38
+ // ON THE DUPLICATED HELPERS. `pill` / `groupHead` / `when` / `fetchJson` /
39
+ // `sendJson` are per-page in this module by convention (gate.js, harbor.js and
40
+ // watch.js each carry their own — a hall page script is a self-contained IIFE
41
+ // over the DOM). Two are a real drift risk rather than boilerplate and are
42
+ // called out where they sit: `membershipRankKeys` mirrors the SERVER's predicate
43
+ // grammar, and `fmtWindow` is also read by the Constitution panel that stayed in
44
+ // government.js. tests/government_board_vote_ui.mjs holds the first equal to the
45
+ // server; keep the second in step by hand.
46
+ //
47
+ // The function ORDER here is deliberate and matches the pre-carve source:
48
+ // tests/government_board_room_copy.mjs slices this file by function-name
49
+ // landmarks (itemCard → loadBoard, ideaSectionsHtml → rankNames, and so on), so
50
+ // reordering these definitions silently changes what those assertions read.
51
+
52
+ (() => {
53
+ 'use strict';
54
+
55
+ const API = '/api/bongos';
56
+ // Generated typed client (task 1996 / R09), served as window.BongosClient by
57
+ // /lib/bongos-client.js. baseUrl:'' passes the full `${API}${path}` URLs below
58
+ // through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
59
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
60
+ const { escapeHtml, fmtInt } = window.OTB;
61
+ const { emptyStateHtml } = window.OTBKit;
62
+ const toast = (msg) => window.OTB.toast(msg);
63
+
64
+ // ---- fetch helpers (mirror gate.js) --------------------------------------
65
+
66
+ async function fetchJson(path) {
67
+ const res = await api.request('GET', `${API}${path}`);
68
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
69
+ if (res.status === 403) { throw { kind: '403' }; }
70
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
71
+ return res.data;
72
+ }
73
+
74
+ async function sendJson(method, path, body) {
75
+ const res = await api.request(method, `${API}${path}`, { body: body || {} });
76
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
77
+ const data = res.data || {};
78
+ if (!res.ok) {
79
+ const err = data.error || {};
80
+ throw new Error(err.message && err.message !== err.code ? err.message : friendlyError(err.code) || `${path} → ${res.status}`);
81
+ }
82
+ return data;
83
+ }
84
+
85
+ // The vote refusals (R08's route), translated for the person clicking. The
86
+ // rank/permission-CRUD refusals stayed with the Permissions surface — this page
87
+ // cannot provoke them.
88
+ const ERROR_COPY = {
89
+ not_board_member: 'Only members of this item’s board may vote on it — membership follows the rules that were in force when the window opened.',
90
+ item_closed: 'This sitting has closed — refreshing.',
91
+ vote_window_expired: 'This sitting’s clock has run out — the sweep is closing it.',
92
+ objection_needs_reason: 'An objection must say why — write the reason.',
93
+ objection_needs_section: 'An objection must point at exactly one section of the idea.',
94
+ yes_carries_nothing: 'A yes carries no reason or section — those belong to objections.',
95
+ bad_direction: 'A vote is yes or no — nothing else.',
96
+ item_not_found: 'That item no longer exists — refreshing.',
97
+ bad_item_id: 'That item id isn’t valid.',
98
+ };
99
+ const friendlyError = (code) => ERROR_COPY[code] || null;
100
+
101
+ // ---- shared row pieces ---------------------------------------------------
102
+
103
+ // A state pill in the shell's vocabulary. `mod` is one of the .fact-pill
104
+ // states (ok / warn / danger / info / quiet); the label is what the reader sees.
105
+ const pill = (mod, label) => `<span class="fact-pill fact-pill--${mod}">${escapeHtml(label)}</span>`;
106
+ const groupHead = (name, n, note) =>
107
+ `<div class="ov-group__head"><span class="ov-group__name">${escapeHtml(name)}</span>` +
108
+ (n === undefined ? '' : `<span class="ov-group__n">${fmtInt(n)}</span>`) +
109
+ (note ? `<span class="ov-group__note">${escapeHtml(note)}</span>` : '') +
110
+ '</div>';
111
+ // A timestamp the way the rows have always shown one: "2026-08-30 14:05".
112
+ const when = (iso) => escapeHtml(String(iso || '').slice(0, 16).replace('T', ' '));
113
+
114
+ // The predicate grammar, in plain language (task 1003094). This MIRRORS
115
+ // modules/government/membership-predicate.js — browser code cannot import the
116
+ // server module, the established pattern here (the five section labels do the
117
+ // same) — and tests/government_board_vote_ui.mjs holds the two equal.
118
+ //
119
+ // Rendering the raw predicate was fine while the only form was `rank:<key>`.
120
+ // It is not fine now: `rank:metic+` would read as "builders holding the metic+
121
+ // rank", which names a rank that does not exist and hides the very thing the
122
+ // suffix says.
123
+ const STANDARD_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
124
+
125
+ function membershipRankKeys(predicate) {
126
+ const m = String(predicate || '');
127
+ if (!m.startsWith('rank:')) return null;
128
+ const body = m.slice(5);
129
+ if (!body) return null;
130
+ const keys = [];
131
+ for (const element of body.split(',')) {
132
+ const match = /^([a-z][a-z0-9_-]*)(\+?)$/.exec(element);
133
+ if (!match) return null;
134
+ let expanded;
135
+ if (match[2] === '+') {
136
+ const i = STANDARD_LADDER.indexOf(match[1]);
137
+ if (i === -1) return null; // "and above" is undefined off the ladder
138
+ expanded = STANDARD_LADDER.slice(i);
139
+ } else {
140
+ expanded = [match[1]];
141
+ }
142
+ for (const k of expanded) if (keys.indexOf(k) === -1) keys.push(k);
143
+ }
144
+ return keys.length ? keys : null;
145
+ }
146
+
147
+ // ---- the Board Room panel (R16) --------------------------------------------
148
+
149
+ // The room renders what the server assembled: open items with the database's
150
+ // own countdown, the OPEN BALLOT (people, directions, timestamps, objections),
151
+ // and the closed history.
152
+
153
+ function fmtRemaining(seconds) {
154
+ if (seconds === null || seconds === undefined) return null;
155
+ const s = Number(seconds);
156
+ if (!Number.isFinite(s) || s <= 0) return 'closing…';
157
+ if (s < 90) return `closes in ${Math.max(1, Math.round(s))}s`;
158
+ if (s < 5400) return `closes in ${Math.round(s / 60)}m`;
159
+ return `closes in ${(s / 3600).toFixed(1)}h`;
160
+ }
161
+
162
+ function subjectLine(item) {
163
+ if (item.subject_type === 'full_idea') {
164
+ const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
165
+ return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
166
+ }
167
+ return `Constitutional amendment ${pill('quiet', 'amendment')}`;
168
+ }
169
+
170
+ function clockLine(item) {
171
+ if (item.closes_at) {
172
+ const rem = fmtRemaining(item.remaining_seconds);
173
+ return rem || 'timed sitting';
174
+ }
175
+ const rule = item.constitution && item.constitution.pass_rule;
176
+ if (rule === 'first_ratifier') return 'no clock — closes on the first vote';
177
+ // A clockless majority sitting is worth naming honestly: without a deadline
178
+ // it waits indefinitely for a majority, which is a real state a project can
179
+ // configure itself into (task 1003273).
180
+ if (rule === 'majority') return 'no clock — waits for a majority';
181
+ // Same honesty for unanimity: with no deadline it waits indefinitely for
182
+ // someone to be in favour, which is a state a project can configure (ADR 0267).
183
+ if (rule === 'unanimous') return 'no clock — waits for a unanimous yes';
184
+ return 'no clock';
185
+ }
186
+
187
+ // The open ballot: one voter, one dense row — who, then the direction pill
188
+ // and the time as facts, and an objection's section and reason as the sub line.
189
+ function ballotHtml(item, viewerId) {
190
+ if (!item.votes.length) {
191
+ return emptyStateHtml(`No votes yet${item.closed_at ? '. Closed unopposed' : ''}.`);
192
+ }
193
+ const rows = item.votes.map((v) => {
194
+ const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
195
+ const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
196
+ // Task 1003270: the faulted section by its LABEL — the words the author
197
+ // wrote under and the objector picked from. `why_matters` is a jsonb key,
198
+ // not something a reader should have to decode.
199
+ const objection = v.direction === 'no'
200
+ ? `<p class="ov-row__sub">objected to <strong>${escapeHtml(SECTION_LABELS[v.section_key] || v.section_key || 'the idea')}</strong>: ${escapeHtml(v.reason_md || '')}</p>`
201
+ : '';
202
+ return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
203
+ <span class="ov-row__title">${who}${mine}</span>
204
+ ${objection}
205
+ <div class="ov-row__facts">${v.direction === 'no' ? pill('danger', 'objected') : pill('ok', 'yes')}<span class="ov-row__when">${when(v.cast_at)}</span></div>
206
+ </div></li>`;
207
+ }).join('');
208
+ return `<ul class="ov-rows">${rows}</ul>`;
209
+ }
210
+
211
+ // ---- the vote form (R17) -----------------------------------------------------
212
+
213
+ // The five section labels, mirrored from the base template declaration
214
+ // (modules/ideas/templates.js — the words the author saw when writing; a test
215
+ // holds the two equal, since browser code cannot import a server module).
216
+ const SECTION_LABELS = {
217
+ purpose: 'Purpose of Idea',
218
+ big_picture: 'What does the Big Picture look like',
219
+ why_had: 'Why did you have the Idea',
220
+ why_matters: 'Why does the Idea Matter',
221
+ fit: 'How does the Idea Fit into the big picture of the project',
222
+ };
223
+
224
+ // The idea's five sections, IN FRONT of the voter — the entire reason voting
225
+ // happens in the hall rather than on a reaction. Rendered in template order.
226
+ //
227
+ // TASK 1003270 — OPEN, AND SET AS PROSE. This used to be a <details> summarised
228
+ // "The idea, in full · completeness 100/100": the substance the vote is about
229
+ // sat behind a click while the constitutional metadata above it stayed expanded,
230
+ // which is exactly backwards. The labels were <code> pills over
231
+ // `.gov-perm__guards`, so an author's answers were styled as config rows. The
232
+ // score moved into the collapsed metadata (decidedByHtml) — it describes the
233
+ // grade the idea cleared, not the idea.
234
+ function ideaSectionsHtml(item) {
235
+ const fv = item.subject && item.subject.field_values;
236
+ if (!fv) return '';
237
+ const rows = Object.keys(SECTION_LABELS).map((key) => {
238
+ // An unanswered section is dropped rather than rendered as a bare heading:
239
+ // a graded Full Idea has all five, and a blank one is only noise.
240
+ const body = String(fv[key] ?? '').trim();
241
+ if (!body) return '';
242
+ return `<h4 class="ov-prose__l">${escapeHtml(SECTION_LABELS[key])}</h4>
243
+ <p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
244
+ }).join('');
245
+ return rows
246
+ ? `<div class="ov-prose gov-idea">${rows}</div>`
247
+ : emptyStateHtml('This idea has no written sections.');
248
+ }
249
+
250
+ // ---- the sitting's rules, in plain words (task 1003270) --------------------
251
+
252
+ // The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
253
+ // membership <rank:archon>` — raw config keys, in a reader's face, ABOVE the
254
+ // idea they were being asked to decide. These say the same thing in the words
255
+ // a person thinks in, filed under a summary where secondary information
256
+ // belongs.
257
+ //
258
+ // Read from the item's SNAPSHOT constitution (the rules in force when the
259
+ // window opened), never today's config — the same rules the server tallies
260
+ // under. Rank KEYS never surface: the display label comes from the branding
261
+ // pack via OTB.rankLabel, as on every other hall surface. The one place a raw
262
+ // predicate still shows is an UNREADABLE one, where the broken string is the
263
+ // actionable fact and hiding it would help nobody.
264
+ function rankNames(keys) {
265
+ const labels = keys.map((k) => escapeHtml(window.OTB.rankLabel(k)));
266
+ if (labels.length === 1) return labels[0];
267
+ return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
268
+ }
269
+
270
+ // A sitting length in the unit a person would say it in. The config stores
271
+ // minutes; "2880 minutes" is a number nobody converts in their head.
272
+ //
273
+ // DUPLICATED, KNOWINGLY: government.js keeps its own copy for the Constitution
274
+ // panel's windowSentence. Two readers, two files, one wording — if you change
275
+ // the phrasing here, change it there.
276
+ function fmtWindow(minutes) {
277
+ const m = Number(minutes);
278
+ if (!Number.isFinite(m) || m <= 0) return `${escapeHtml(String(minutes))} minutes`;
279
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
280
+ if (m % 1440 === 0) return plural(m / 1440, 'day');
281
+ if (m % 60 === 0) return plural(m / 60, 'hour');
282
+ return plural(m, 'minute');
283
+ }
284
+
285
+ const PASS_RULE_PLAIN = {
286
+ first_ratifier: 'The first vote decides it: a yes ratifies, an objection returns it to its author.',
287
+ consent: 'It passes unless someone objects — silence is agreement, and one reasoned objection returns it.',
288
+ majority: 'It passes only when more than half the board votes yes. Silence is not agreement here.',
289
+ unanimous: 'Everyone who votes must vote yes, and at least one member must. One objection sends it back for revision.',
290
+ };
291
+
292
+ function decidedByHtml(item) {
293
+ const c = item.constitution || {};
294
+ const past = !!item.closed_at;
295
+ const keys = membershipRankKeys(c.membership);
296
+ const who = keys
297
+ ? `Every active <strong>${rankNames(keys)}</strong> ${past ? 'could vote on this' : 'may vote on this'}.`
298
+ : `Nobody ${past ? 'could vote' : 'may vote'} — the membership setting <code>${escapeHtml(String(c.membership || 'unset'))}</code> cannot be read.`;
299
+ const rule = PASS_RULE_PLAIN[c.pass_rule] || 'How this sitting passes could not be read.';
300
+ const clock = c.window_minutes === null || c.window_minutes === undefined
301
+ ? `No deadline — it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
302
+ : `The sitting ${past ? 'ran' : 'runs'} ${fmtWindow(c.window_minutes)}${c.close_early_on_full_turnout ? ', closing early once every member has voted' : ''}.`;
303
+ const score = item.subject ? item.subject.completeness_score : null;
304
+ const grade = score == null
305
+ ? ''
306
+ : `<p class="gov-grid-note">It cleared the completeness grade at ${escapeHtml(String(score))}/100.</p>`;
307
+ return `<p class="gov-grid-note">${who}</p>
308
+ <p class="gov-grid-note">${rule}</p>
309
+ <p class="gov-grid-note">${clock}</p>${grade}`;
310
+ }
311
+
312
+ // TASK 1003270: the ballot is the point of the card, so its copy earns its
313
+ // place or goes. What stayed is the ADR 0175 §8 requirement itself — an
314
+ // objection carries a REASON and names ONE SECTION — because that requirement
315
+ // is the whole reason voting lives in the hall instead of on a chat reaction.
316
+ // Simplifying may not weaken it, so the reason box and the five-way picker are
317
+ // untouched; only the sentences around them shrank.
318
+ function voteFormHtml(item, viewerId) {
319
+ if (item.closed_at) return '';
320
+ const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
321
+ if (mine) return '';
322
+ // TASK 1003273: the author gets a REAL ballot. The old branch returned a note
323
+ // and no form, which is what made the Board Room show nine sittings and no
324
+ // way to vote on any of them — every one of them was the reader's own. They
325
+ // may now cast; what they cannot do is decide alone (the close trigger needs
326
+ // a voice that is not theirs, and a majority made only of them cannot carry).
327
+ // The note says so instead of refusing, so the limit is visible rather than
328
+ // discovered by clicking.
329
+ const isOwnIdea = item.subject_type === 'full_idea'
330
+ && item.subject && String(item.subject.author_id) === String(viewerId);
331
+ const sections = Object.keys(SECTION_LABELS).map((key) => `
332
+ <label class="gov-vote__section"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
333
+ const ownNote = isOwnIdea
334
+ ? `<p class="gov-grid-note">Your own idea. You may vote, but it can’t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
335
+ : '';
336
+ return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
337
+ ${ownNote}
338
+ <button type="button" class="btn-accent" data-vote="yes">Vote yes</button>
339
+ <details class="gov-vote__no">
340
+ <summary class="btn-ghost">Object…</summary>
341
+ <p class="gov-grid-note">This sends the idea back to its author. Say why, and which section.</p>
342
+ <textarea data-vote-reason placeholder="Why — the reason the author will act on" maxlength="5000" rows="3"></textarea>
343
+ <div class="gov-vote__sections">${sections}</div>
344
+ <button type="button" class="btn-ghost btn-ghost--danger" data-vote="no">Cast objection</button>
345
+ </details>
346
+ </div>`;
347
+ }
348
+
349
+ async function castVoteFromRoom(itemId, direction, container) {
350
+ const body = { direction };
351
+ if (direction === 'no') {
352
+ const reason = (container.querySelector('[data-vote-reason]') || {}).value || '';
353
+ const picked = container.querySelector(`input[name="vote-section-${itemId}"]:checked`);
354
+ body.reason_md = reason;
355
+ if (picked) body.section_key = picked.value;
356
+ }
357
+ try {
358
+ const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
359
+ toast(out.item_closed
360
+ ? `Vote cast — the sitting closed: ${out.outcome}.`
361
+ : 'Vote cast.');
362
+ } catch (err) {
363
+ if (err && err.kind === '401') return;
364
+ toast(`Could not vote: ${err.message || err}`);
365
+ }
366
+ loadBoard(); // re-render either way — the room shows what stands
367
+ }
368
+
369
+ // ONE SITTING, ONE DECISION (task 1003270). The card reads top-down in the
370
+ // order a person actually needs it:
371
+ //
372
+ // 1. what is being decided — the idea's TITLE, at title size. It used to
373
+ // render through `.gov-section__head`, i.e. 11.5px uppercase letterspaced:
374
+ // the subject of the vote styled as a table header.
375
+ // 2. the ACTION, immediately under it. Everything that used to sit between
376
+ // the two — a tally of raw config keys, a sentence explaining that the UI
377
+ // had no vote form (it does), and a collapsed idea — is gone or moved, so
378
+ // "Vote yes" lands above the fold on an ordinary window instead of below
379
+ // a wall of prose.
380
+ // 3. the idea itself, open (ideaSectionsHtml).
381
+ // 4. the constitutional metadata, collapsed — who votes, how it passes, the
382
+ // clock, and the open ballot. Still one click away, never invisible: the
383
+ // tally rides the summary, which is the part of it a reader scans for
384
+ // (ADR 0175 §9 — a board whose votes are invisible constrains nobody).
385
+ //
386
+ // A CLOSED sitting has no action and no idea body: its head carries the
387
+ // outcome and the record lives under the same summary.
388
+ //
389
+ // In v3 a sitting IS the panel (task 1003469): the card is the shell's .scroll,
390
+ // its title the panel's display-voice heading.
391
+ function itemCard(item, viewerId) {
392
+ const yes = item.votes.filter((v) => v.direction === 'yes').length;
393
+ const no = item.votes.filter((v) => v.direction === 'no').length;
394
+ const isOpen = !item.closed_at;
395
+ const state = isOpen
396
+ ? `${pill('info', 'open')} <span>${escapeHtml(clockLine(item))}</span>`
397
+ : (item.outcome === 'passed' ? pill('ok', item.outcome) : pill('warn', item.outcome || ''));
398
+ const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
399
+ ? `<p class="gov-item__amend">Proposes <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</p>`
400
+ : '';
401
+ const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
402
+ // A voter who has cast sees WHAT they cast where the buttons were. The line
403
+ // that used to fill this slot for everyone else — announcing that the reader
404
+ // had not voted and that casting was still to come — is deleted outright: it
405
+ // explained the UI to itself, and the form it promised shipped in R17.
406
+ const action = !isOpen ? ''
407
+ : mine
408
+ ? `<p class="gov-item__cast">You ${mine.direction === 'no' ? 'objected' : 'voted yes'}. One vote per member.</p>`
409
+ : voteFormHtml(item, viewerId);
410
+ const tally = `${yes} yes · ${no} objection${no === 1 ? '' : 's'}`;
411
+ return `<section class="scroll gov-item" id="board-item-${escapeHtml(item.id)}">
412
+ <div class="gov-item__head">
413
+ <h3 class="gov-item__title">${subjectLine(item)}</h3>
414
+ <span class="gov-item__state">${state}</span>
415
+ </div>
416
+ ${amendmentDetail}
417
+ ${action ? `<div class="gov-item__act">${action}</div>` : ''}
418
+ ${isOpen ? ideaSectionsHtml(item) : ''}
419
+ <details class="gov-item__meta">
420
+ <summary class="gov-item__summary">${tally} · who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
421
+ <div class="gov-item__metabody">
422
+ ${decidedByHtml(item)}
423
+ ${ballotHtml(item, viewerId)}
424
+ </div>
425
+ </details>
426
+ </section>`;
427
+ }
428
+
429
+ async function loadBoard() {
430
+ const box = document.getElementById('gov-board');
431
+ let data;
432
+ try {
433
+ data = await fetchJson('/government/board/items');
434
+ } catch (err) {
435
+ if (err && err.kind === '401') return;
436
+ if (err && err.kind === '403') { showSealed(); return; }
437
+ box.innerHTML = emptyStateHtml('Could not load the Board Room just now.');
438
+ return;
439
+ }
440
+ const viewerId = data.viewer_id;
441
+ const openItems = data.open || [];
442
+ const closedItems = data.closed || [];
443
+ const open = openItems.map((i) => itemCard(i, viewerId)).join('');
444
+ const closed = closedItems.map((i) => itemCard(i, viewerId)).join('');
445
+ box.innerHTML =
446
+ (open || `<section class="scroll">${emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.')}</section>`)
447
+ + `<div class="gov-board__divider">${groupHead('Closed sittings', closedItems.length)}</div>`
448
+ + (closed || `<section class="scroll">${emptyStateHtml('No sitting has closed yet.')}</section>`);
449
+ // The subtitle counts what is actually waiting, so the room says on arrival
450
+ // what the nav badge said from outside it (ADR 0266).
451
+ const sub = document.getElementById('board-sub');
452
+ if (sub) {
453
+ sub.textContent = openItems.length
454
+ ? `${openItems.length} open ${openItems.length === 1 ? 'sitting' : 'sittings'}.`
455
+ : 'Nothing is before the board.';
456
+ }
457
+ // R21: the Discord announcement deep-links ?item=<id> — land the reader on
458
+ // THAT sitting. It carried `#board-room` too while this was a tab; the hash
459
+ // is now meaningless here and government.js redirects the old form (ADR
460
+ // 0266). A missing or unknown id is simply the room as usual. The mark is
461
+ // the world's lit grammar: the accent, and only on the one live thing.
462
+ const wanted = new URLSearchParams(window.location.search).get('item');
463
+ if (wanted) {
464
+ const el = document.getElementById(`board-item-${wanted}`);
465
+ if (el) {
466
+ el.scrollIntoView({ block: 'start' });
467
+ el.style.outline = '2px solid var(--accent)';
468
+ el.style.outlineOffset = '2px';
469
+ }
470
+ }
471
+ // R17: the cast buttons. One delegated listener; the SERVER refuses every
472
+ // illegitimate cast regardless (membership, anti-self, closed, expired) —
473
+ // the form is a courtesy, the route is the wall.
474
+ box.querySelectorAll('.gov-vote [data-vote]').forEach((btn) => {
475
+ btn.addEventListener('click', () => {
476
+ const wrap = btn.closest('.gov-vote');
477
+ btn.disabled = true;
478
+ castVoteFromRoom(wrap.dataset.item, btn.dataset.vote, wrap);
479
+ });
480
+ });
481
+ }
482
+
483
+ // ---- init ----------------------------------------------------------------
484
+
485
+ // Unlike its neighbours in the Government group, the seal here is NOT a rank
486
+ // notice. The page's atom (`board.vote.cast`) floors at xenos, so a visitor who
487
+ // reaches this state is one the permission was withheld from rather than one
488
+ // standing below a rung — saying "available from Trusted up" would be a guess
489
+ // and usually a wrong one (ADR 0266).
490
+ function showSealed() {
491
+ document.getElementById('board-sealed').hidden = false;
492
+ document.getElementById('board-body').hidden = true;
493
+ document.getElementById('board-sub').textContent = 'Restricted.';
494
+ const body = document.getElementById('board-sealed-body');
495
+ if (body) {
496
+ body.textContent = 'The Board Room is open to the builders this project’s constitution seats on its board.';
497
+ }
498
+ }
499
+
500
+ async function init() {
501
+ // The page gate already decided this visitor may be here; the /me read is
502
+ // for the 403 courtesy only, exactly as on the other government surfaces.
503
+ try {
504
+ await fetchJson('/me');
505
+ } catch (err) {
506
+ if (err && err.kind === '401') return;
507
+ if (err && err.kind === '403') { showSealed(); return; }
508
+ // Any other failure is not an authorization answer — show the room and let
509
+ // the board read speak for itself.
510
+ }
511
+ document.getElementById('board-sub').textContent = 'Loading sittings…';
512
+ document.getElementById('board-body').hidden = false;
513
+ loadBoard();
514
+ }
515
+
516
+ init();
517
+ })();
@@ -0,0 +1,104 @@
1
+ {
2
+ "_": "Board Room — the sittings, the ballot and the vote form, carved out of the /government tab strip onto its own page by ADR 0266. Unlike its neighbours in the Government group this page is gated on the `board.vote.cast` atom (xenos floor) rather than page.view.government, so the sealed state here means the permission was withheld, not that the reader stands below a rung.",
3
+ "page": "/builders/board-room",
4
+ "surface": "hall-ui",
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
8
+ "modeQuery": false,
9
+ "_feeds": "The kit's own stub carries no feed for this page, so against it the page renders its EMPTY and SKELETON states — which is the point: those are states the floors must hold in. Against the hall-preview harness the same states render populated. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
10
+ "ignoreRequests": [
11
+ "/api/(gds|bongos)/me$",
12
+ "/api/(gds|bongos)/me/",
13
+ "/api/(gds|bongos)/goals",
14
+ "/api/(gds|bongos)/inbox/",
15
+ "/api/(gds|bongos)/versions",
16
+ "/api/(gds|bongos)/government"
17
+ ],
18
+ "ignoreConsole": [
19
+ "/api/(gds|bongos)/",
20
+ "\\b401\\b",
21
+ "\\b403\\b",
22
+ "\\b404\\b",
23
+ "\\b501\\b",
24
+ "Unauthorized",
25
+ "Forbidden",
26
+ "Not Found",
27
+ "Not Implemented",
28
+ "load failed"
29
+ ],
30
+ "states": {
31
+ "room": {
32
+ "auth": true,
33
+ "actions": [
34
+ [
35
+ "wait",
36
+ 600
37
+ ]
38
+ ],
39
+ "expect": {
40
+ "visible": [
41
+ "#board-body",
42
+ "#gov-board"
43
+ ]
44
+ }
45
+ },
46
+ "out": {
47
+ "auth": false
48
+ },
49
+ "sitting-meta": {
50
+ "auth": true,
51
+ "actions": [
52
+ [
53
+ "wait",
54
+ 600
55
+ ],
56
+ [
57
+ "click",
58
+ ".gov-item__summary"
59
+ ],
60
+ [
61
+ "wait",
62
+ 300
63
+ ]
64
+ ],
65
+ "expect": {
66
+ "visible": [
67
+ "#gov-board"
68
+ ]
69
+ }
70
+ },
71
+ "objection-form": {
72
+ "auth": true,
73
+ "actions": [
74
+ [
75
+ "wait",
76
+ 600
77
+ ],
78
+ [
79
+ "click",
80
+ ".gov-vote__no > summary"
81
+ ],
82
+ [
83
+ "wait",
84
+ 300
85
+ ]
86
+ ],
87
+ "expect": {
88
+ "visible": [
89
+ "#gov-board"
90
+ ]
91
+ }
92
+ }
93
+ },
94
+ "reducedMotion": {
95
+ "auth": true,
96
+ "actions": [
97
+ [
98
+ "wait",
99
+ 600
100
+ ]
101
+ ]
102
+ },
103
+ "_states": "The room itself, plus the two disclosures a sitting card carries — the collapsed constitutional metadata (`.gov-item__summary`, where ADR 0175 §9's tally and open ballot live) and the objection form (`.gov-vote__no`, whose open state takes the full row and is the widest thing on the page). Both are measured because both were the states the pre-carve page never rendered against a fixture. A state whose click target is absent — no open sitting in the feed — still measures the room, which is the honest empty floor."
104
+ }