@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.
- package/.bongos-core.json +60 -30
- package/docs/copy-inventory.md +90 -82
- package/docs/copy-registry.json +205 -111
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/board-room.css +83 -0
- package/modules/hall-ui/public/board-room.html +70 -0
- package/modules/hall-ui/public/board-room.js +517 -0
- package/modules/hall-ui/public/board-room.states.json +104 -0
- package/modules/hall-ui/public/government.css +10 -57
- package/modules/hall-ui/public/government.html +10 -12
- package/modules/hall-ui/public/government.js +119 -367
- package/modules/hall-ui/public/palette.js +5 -2
- package/modules/hall-ui/public/shell.js +16 -0
- package/modules/lifecycle/db-tasks.js +24 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-vocabulary.js +7 -0
- package/scripts/gds/start.js +29 -11
- package/src/bongos/auth.js +27 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +6 -5
- package/tests/cross_discipline_priority_direction.mjs +196 -0
- package/tests/government_board_hash_redirect.mjs +129 -0
- package/tests/government_board_room_copy.mjs +41 -15
- package/tests/government_board_vote_ui.mjs +5 -1
- package/tests/hall_nav.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +11 -0
- package/tests/hall_palette.mjs +9 -3
- package/tests/rank_tier_single_source.mjs +8 -0
|
@@ -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
|
+
}
|