@bongos/core 1.19.457 → 1.19.459

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.bongos-core.json +126 -46
  2. package/.claude/skills/design/SKILL.md +10 -0
  3. package/docs/adr/0219-a-look-is-a-branding-pack-the-style-library.md +30 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/branding-contract.md +1 -1
  6. package/docs/copy-inventory.md +160 -177
  7. package/docs/copy-registry.json +445 -606
  8. package/docs/file-map.md +2 -2
  9. package/docs/module-api-changelog.md +4 -0
  10. package/docs/recipes/ui-look-before-you-ship.md +2 -0
  11. package/modules/hall-ui/public/gate.css +20 -88
  12. package/modules/hall-ui/public/gate.html +24 -12
  13. package/modules/hall-ui/public/gate.js +77 -60
  14. package/modules/hall-ui/public/gate.states.json +19 -1
  15. package/modules/hall-ui/public/government.css +96 -154
  16. package/modules/hall-ui/public/government.html +80 -52
  17. package/modules/hall-ui/public/government.js +227 -136
  18. package/modules/hall-ui/public/government.states.json +71 -1
  19. package/modules/hall-ui/public/harbor.css +9 -120
  20. package/modules/hall-ui/public/harbor.html +23 -11
  21. package/modules/hall-ui/public/harbor.js +109 -83
  22. package/modules/hall-ui/public/harbor.states.json +19 -1
  23. package/modules/hall-ui/public/oversight.css +296 -0
  24. package/modules/hall-ui/public/watch.css +56 -170
  25. package/modules/hall-ui/public/watch.html +48 -37
  26. package/modules/hall-ui/public/watch.js +363 -298
  27. package/modules/hall-ui/public/watch.states.json +48 -1
  28. package/modules/ui-design/kit/README.md +7 -5
  29. package/modules/ui-design/kit/check-mock.js +12 -5
  30. package/modules/ui-design/kit/contrast.js +13 -6
  31. package/modules/ui-design/kit/lib.js +39 -2
  32. package/modules/ui-design/kit/probe.js +4 -1
  33. package/modules/ui-design/kit/render.js +7 -4
  34. package/modules/ui-design/kit/serve.js +9 -2
  35. package/modules/ui-design/styles/README.md +54 -2
  36. package/modules/ui-design/styles/chrome-world/DESIGN.md +56 -0
  37. package/modules/ui-design/styles/chrome-world/mock.html +275 -0
  38. package/modules/ui-design/styles/chrome-world/mock.states.json +11 -0
  39. package/modules/ui-design/styles/chrome-world/pack.json +33 -0
  40. package/modules/ui-design/styles/expedition/DESIGN.md +141 -0
  41. package/modules/ui-design/styles/expedition/mock.html +285 -0
  42. package/modules/ui-design/styles/expedition/mock.states.json +11 -0
  43. package/modules/ui-design/styles/expedition/pack.json +33 -0
  44. package/modules/ui-design/styles/grove/DESIGN.md +148 -0
  45. package/modules/ui-design/styles/grove/mock.html +294 -0
  46. package/modules/ui-design/styles/grove/mock.states.json +11 -0
  47. package/modules/ui-design/styles/grove/pack.json +33 -0
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/src/module-api.js +1 -1
  51. package/tests/cosmos_token_contrast.mjs +9 -2
  52. package/tests/hall_oversight_world.mjs +118 -0
  53. package/tests/hall_tokens.mjs +15 -6
  54. package/tests/ui_design_styles.mjs +218 -0
  55. package/tests/world_tokens.mjs +9 -2
@@ -58,6 +58,14 @@
58
58
  // What simplifying may NOT touch is ADR 0175 §8: an objection still carries a
59
59
  // reason and names one section. That requirement is why voting lives in the
60
60
  // hall and not on a chat reaction.
61
+ //
62
+ // TASK 1003469 put the page in the chrome world's COMPOSITION, not just its
63
+ // tokens: every section is a shell .scroll panel with the display-voice heading,
64
+ // every repeated thing is the oversight family's .ov-row (oversight.css), the
65
+ // five unrelated lists that shared `.gov-assign-row` each render as the row they
66
+ // are, the permission grid's bordered boxes became hairline groups, and a rank's
67
+ // DESCRIPTION — collected by the create form and never shown — is the row's sub
68
+ // line. Nothing about the data, the locks, the tab ids or the ballot changed.
61
69
 
62
70
  (() => {
63
71
  'use strict';
@@ -67,7 +75,8 @@
67
75
  // /lib/bongos-client.js. baseUrl:'' passes the full `${API}${path}` URLs below
68
76
  // through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
69
77
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
70
- const { escapeHtml } = window.OTB;
78
+ const { escapeHtml, fmtInt } = window.OTB;
79
+ const { emptyStateHtml } = window.OTBKit;
71
80
  const toast = (msg) => window.OTB.toast(msg);
72
81
 
73
82
  // ---- fetch helpers (mirror gate.js) --------------------------------------
@@ -104,15 +113,15 @@
104
113
  bad_rank_key: 'Rank keys are kebab-case: lowercase letters, digits, dashes.',
105
114
  validation_failed: 'Missing or invalid fields.',
106
115
  // the vote refusals (R17 over R08's route)
107
- not_board_member: 'Only members of this item\u2019s board may vote on it \u2014 membership follows the rules that were in force when the window opened.',
108
- item_closed: 'This sitting has closed \u2014 refreshing.',
109
- vote_window_expired: 'This sitting\u2019s clock has run out \u2014 the sweep is closing it.',
110
- objection_needs_reason: 'An objection must say why \u2014 write the reason.',
116
+ 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.',
117
+ item_closed: 'This sitting has closed — refreshing.',
118
+ vote_window_expired: 'This sitting’s clock has run out — the sweep is closing it.',
119
+ objection_needs_reason: 'An objection must say why — write the reason.',
111
120
  objection_needs_section: 'An objection must point at exactly one section of the idea.',
112
- yes_carries_nothing: 'A yes carries no reason or section \u2014 those belong to objections.',
113
- bad_direction: 'A vote is yes or no \u2014 nothing else.',
114
- item_not_found: 'That item no longer exists \u2014 refreshing.',
115
- bad_item_id: 'That item id isn\u2019t valid.',
121
+ yes_carries_nothing: 'A yes carries no reason or section — those belong to objections.',
122
+ bad_direction: 'A vote is yes or no — nothing else.',
123
+ item_not_found: 'That item no longer exists — refreshing.',
124
+ bad_item_id: 'That item id isn’t valid.',
116
125
  };
117
126
  const friendlyError = (code) => ERROR_COPY[code] || null;
118
127
 
@@ -126,26 +135,45 @@
126
135
 
127
136
  const selectedRank = () => ranks.find((r) => r.rank_key === selectedRankKey) || null;
128
137
 
138
+ // ---- shared row pieces ---------------------------------------------------
139
+
140
+ // A state pill in the shell's vocabulary. `mod` is one of the .fact-pill
141
+ // states (ok / warn / danger / info / quiet); the label is what the reader sees.
142
+ const pill = (mod, label) => `<span class="fact-pill fact-pill--${mod}">${escapeHtml(label)}</span>`;
143
+ const groupHead = (name, n, note) =>
144
+ `<div class="ov-group__head"><span class="ov-group__name">${escapeHtml(name)}</span>` +
145
+ (n === undefined ? '' : `<span class="ov-group__n">${fmtInt(n)}</span>`) +
146
+ (note ? `<span class="ov-group__note">${escapeHtml(note)}</span>` : '') +
147
+ '</div>';
148
+ // A timestamp the way the rows have always shown one: "2026-08-30 14:05".
149
+ const when = (iso) => escapeHtml(String(iso || '').slice(0, 16).replace('T', ' '));
150
+
129
151
  // ---- render: rank list ---------------------------------------------------
130
152
 
153
+ // One rank, one selectable oversight row: the label, the DESCRIPTION the
154
+ // create form collects (task 1003469 — it was written and never read back),
155
+ // then the kind pill, the key and the permission count as the facts line.
131
156
  function renderRanks() {
132
157
  const list = document.getElementById('gov-rank-list');
133
158
  list.innerHTML = '';
134
159
  for (const rank of ranks) {
135
160
  // The Archon row is called out as read-only, because it is the one rank in
136
161
  // this list a click will not change and the reader should know before trying.
137
- const pill = isArchonRank(rank)
138
- ? `<span class="gov-pill gov-pill--locked">ladder · read-only</span>`
139
- : rank.is_system
140
- ? `<span class="gov-pill gov-pill--seeded">ladder</span>`
141
- : `<span class="gov-pill gov-pill--custom">custom</span>`;
162
+ const kind = isArchonRank(rank)
163
+ ? pill('danger', 'ladder · read-only')
164
+ : rank.is_system ? pill('info', 'ladder') : pill('ok', 'custom');
165
+ const n = (rank.permissions || []).length;
166
+ const desc = String(rank.description || '').trim();
142
167
  const btn = document.createElement('button');
143
168
  btn.type = 'button';
144
- btn.className = 'gov-rank';
169
+ btn.className = 'ov-row';
145
170
  btn.setAttribute('aria-pressed', rank.rank_key === selectedRankKey ? 'true' : 'false');
146
171
  btn.innerHTML =
147
- `<span class="gov-rank__head"><span class="gov-rank__label">${escapeHtml(rank.label || rank.rank_key)}</span>${pill}</span>
148
- <span class="gov-rank__meta"><code>${escapeHtml(rank.rank_key)}</code> · ${(rank.permissions || []).length} permission${(rank.permissions || []).length === 1 ? '' : 's'}</span>`;
172
+ `<span class="ov-row__main">
173
+ <span class="ov-row__title">${escapeHtml(rank.label || rank.rank_key)}</span>
174
+ ${desc ? `<span class="ov-row__sub">${escapeHtml(desc)}</span>` : ''}
175
+ <span class="ov-row__facts">${kind}<span class="ov-mono">${escapeHtml(rank.rank_key)}</span><span class="ov-fact"><b>${fmtInt(n)}</b> permission${n === 1 ? '' : 's'}</span></span>
176
+ </span>`;
149
177
  btn.addEventListener('click', () => { selectedRankKey = rank.rank_key; renderRanks(); renderGrid(); });
150
178
  list.appendChild(btn);
151
179
  }
@@ -165,10 +193,13 @@
165
193
  return !!p.grantable;
166
194
  }
167
195
 
196
+ // One permission, one dense oversight row: the key as the title in the
197
+ // machine's face, the guard sentence as the sub line, the floor as a chip, and
198
+ // the control — a grant toggle, or the lock with its reason — on the right.
168
199
  function permRow(p, rank) {
169
200
  const held = !!rank && (rank.permissions || []).includes(p.key);
170
- const row = document.createElement('div');
171
- row.className = 'gov-perm';
201
+ const row = document.createElement('li');
202
+ row.className = 'ov-row ov-row--dense';
172
203
 
173
204
  // Why locked, in the words that tell the reader whether it is a policy they
174
205
  // could change or a fact they cannot. Three causes, three sentences.
@@ -182,31 +213,35 @@
182
213
  if (!rank) {
183
214
  control = '';
184
215
  } else if (!grantableTo(p, rank)) {
185
- control = `<span class="gov-pill gov-pill--locked" title="${escapeHtml(lockReason)}">locked</span>`;
216
+ control = `<span class="fact-pill fact-pill--quiet" title="${escapeHtml(lockReason)}">locked</span>`;
186
217
  // The Archon rank is read-only rather than empty-handed, so say what it holds.
187
218
  if (held && isArchonRank(rank)) control = `<span class="gov-held-note">held</span>${control}`;
188
219
  } else {
189
- control = `<button type="button" class="gov-toggle" aria-pressed="${held ? 'true' : 'false'}" data-perm="${escapeHtml(p.key)}">${held ? 'granted' : 'grant'}</button>`;
220
+ control = `<button type="button" class="ov-toggle" aria-pressed="${held ? 'true' : 'false'}" data-perm="${escapeHtml(p.key)}">${held ? 'held' : 'grant'}</button>`;
190
221
  }
191
222
 
192
223
  row.innerHTML =
193
- `<div class="gov-perm__main">
194
- <code class="gov-perm__key">${escapeHtml(p.key)}</code>
195
- <span class="gov-pill gov-pill--floor">${escapeHtml(p.floor || 'principal')}</span>
196
- <div class="gov-perm__guards">${escapeHtml(p.guards || '')}</div>
224
+ `<div class="ov-row__main">
225
+ <span class="gov-perm__key">${escapeHtml(p.key)}</span>
226
+ ${p.guards ? `<p class="ov-row__sub">${escapeHtml(p.guards)}</p>` : ''}
227
+ <div class="ov-row__facts"><span class="fact-chip">${escapeHtml(p.floor || 'principal')}</span></div>
197
228
  </div>
198
- ${control}`;
229
+ <div class="ov-row__right"><div class="ov-row__actions">${control}</div></div>`;
199
230
 
200
- const btn = row.querySelector('button.gov-toggle');
231
+ const btn = row.querySelector('button.ov-toggle');
201
232
  if (btn) btn.addEventListener('click', () => toggleGrant(rank, p.key, !held, btn));
202
233
  return row;
203
234
  }
204
235
 
205
- function section(title, rows) {
236
+ // A named run of permission rows inside the panel: the group head, then the rows.
237
+ function section(title, rows, note) {
206
238
  const el = document.createElement('section');
207
- el.className = 'gov-section';
208
- el.innerHTML = `<div class="gov-section__head">${escapeHtml(title)}</div>`;
209
- for (const r of rows) el.appendChild(r);
239
+ el.className = 'ov-group';
240
+ el.innerHTML = groupHead(title, rows.length, note);
241
+ const list = document.createElement('ul');
242
+ list.className = 'ov-rows';
243
+ for (const r of rows) list.appendChild(r);
244
+ el.appendChild(list);
210
245
  return el;
211
246
  }
212
247
 
@@ -225,7 +260,7 @@
225
260
  const grid = document.getElementById('gov-grid');
226
261
  const rank = selectedRank();
227
262
  document.getElementById('gov-grid-title').textContent = rank
228
- ? `Permissions — ${rank.label || rank.rank_key}` : 'Permissions';
263
+ ? `Permissions for ${rank.label || rank.rank_key}` : 'Permissions';
229
264
  document.getElementById('gov-grid-note').textContent = gridNote(rank);
230
265
 
231
266
  // The reset escape hatch — only a ladder rank has a catalog default, and the
@@ -235,7 +270,7 @@
235
270
  if (isRankRank(rank) && !isArchonRank(rank)) {
236
271
  const btn = document.createElement('button');
237
272
  btn.type = 'button';
238
- btn.className = 'gov-btn gov-btn--ghost gov-btn--small';
273
+ btn.className = 'btn-ghost';
239
274
  btn.textContent = 'Reset to default';
240
275
  btn.addEventListener('click', () => resetRank(rank, btn));
241
276
  resetBox.appendChild(btn);
@@ -251,13 +286,11 @@
251
286
  const ownerPowers = humanPerms.filter((p) => p.system && !p.substrate);
252
287
  const operational = humanPerms.filter((p) => !p.system);
253
288
 
254
- const ownerHead = !rank || isRankRank(rank)
255
- ? `Owner powers (${ownerPowers.length})`
256
- : `Owner powers — locked for custom ranks (${ownerPowers.length})`;
289
+ const ownerNote = !rank || isRankRank(rank) ? '' : 'locked for custom ranks';
257
290
 
258
- grid.appendChild(section(`Operational (${operational.length})`, operational.map((p) => permRow(p, rank))));
259
- grid.appendChild(section(ownerHead, ownerPowers.map((p) => permRow(p, rank))));
260
- grid.appendChild(section(`Enforced outside the app — always locked (${substrate.length})`, substrate.map((p) => permRow(p, rank))));
291
+ grid.appendChild(section('Operational', operational.map((p) => permRow(p, rank))));
292
+ grid.appendChild(section('Owner powers', ownerPowers.map((p) => permRow(p, rank)), ownerNote));
293
+ grid.appendChild(section('Enforced outside the app', substrate.map((p) => permRow(p, rank)), 'always locked'));
261
294
  }
262
295
 
263
296
  // Restore a ladder rank to the shipped ladder. Confirmed because it is the one
@@ -292,29 +325,36 @@
292
325
 
293
326
  // ---- render: assignments -------------------------------------------------
294
327
 
328
+ // One assignment, one oversight row: the builder, then their id and the rank
329
+ // pill as facts, and Remove on the right for a custom rank.
295
330
  function renderAssignments() {
296
331
  const box = document.getElementById('gov-assignments');
297
332
  box.innerHTML = '';
298
333
  if (assignments.length === 0) {
299
- box.innerHTML = `<p class="gov-empty">No rank assignments yet.</p>`;
334
+ box.innerHTML = emptyStateHtml('No rank assignments yet.');
300
335
  return;
301
336
  }
302
- const wrap = document.createElement('section');
303
- wrap.className = 'gov-section';
337
+ const list = document.createElement('ul');
338
+ list.className = 'ov-rows';
304
339
  for (const a of assignments) {
305
- const row = document.createElement('div');
306
- row.className = 'gov-assign-row';
307
- const who = `${escapeHtml(a.display_name || a.github_login || `builder ${a.builder_id}`)} <span class="muted">(#${escapeHtml(String(a.builder_id))} · ${escapeHtml(a.rank || '')})</span>`;
340
+ const row = document.createElement('li');
341
+ row.className = 'ov-row';
342
+ const who = escapeHtml(a.display_name || a.github_login || `builder ${a.builder_id}`);
308
343
  const rankPill = a.is_system
309
- ? `<span class="gov-pill gov-pill--seeded">${escapeHtml(a.rank_label || a.rank_key)} · via rank</span>`
310
- : `<span class="gov-pill gov-pill--custom">${escapeHtml(a.rank_label || a.rank_key)}</span>`;
311
- const action = a.is_system ? '' : `<button type="button" class="gov-btn gov-btn--ghost gov-btn--small" data-act="unassign">Remove</button>`;
312
- row.innerHTML = `<span class="gov-assign-row__who">${who}</span>${rankPill}${action}`;
344
+ ? pill('info', `${a.rank_label || a.rank_key} · via rank`)
345
+ : pill('ok', a.rank_label || a.rank_key);
346
+ const action = a.is_system ? '' : '<button type="button" class="btn-ghost" data-act="unassign">Remove</button>';
347
+ row.innerHTML =
348
+ `<div class="ov-row__main">
349
+ <span class="ov-row__title">${who}</span>
350
+ <div class="ov-row__facts">${rankPill}<span class="ov-mono">#${escapeHtml(String(a.builder_id))}</span>${a.rank ? `<span class="ov-fact">rank <b>${escapeHtml(window.OTB.rankLabel(a.rank))}</b></span>` : ''}</div>
351
+ </div>
352
+ <div class="ov-row__right"><div class="ov-row__actions">${action}</div></div>`;
313
353
  const btn = row.querySelector('[data-act=unassign]');
314
354
  if (btn) btn.addEventListener('click', () => unassign(a, btn));
315
- wrap.appendChild(row);
355
+ list.appendChild(row);
316
356
  }
317
- box.appendChild(wrap);
357
+ box.appendChild(list);
318
358
  }
319
359
 
320
360
  function renderAssignSelect() {
@@ -322,7 +362,7 @@
322
362
  const custom = ranks.filter((r) => !r.is_system);
323
363
  sel.innerHTML = custom.length
324
364
  ? custom.map((r) => `<option value="${escapeHtml(r.rank_key)}">${escapeHtml(r.label || r.rank_key)}</option>`).join('')
325
- : `<option value="" disabled selected>No custom ranks yet</option>`;
365
+ : '<option value="" disabled selected>No custom ranks yet</option>';
326
366
  }
327
367
 
328
368
  async function unassign(a, btn) {
@@ -340,22 +380,29 @@
340
380
 
341
381
  // ---- render: principals --------------------------------------------------
342
382
 
383
+ // One machine identity, one dense row: the identity as the title, what it holds
384
+ // and under which guard as the sub line, and the lock as its one fact.
343
385
  function renderPrincipals() {
344
386
  const box = document.getElementById('gov-principals');
345
387
  box.innerHTML = '';
346
388
  if (principals.length === 0) {
347
- box.innerHTML = `<p class="gov-empty">No machine principals declared.</p>`;
389
+ box.innerHTML = emptyStateHtml('No machine principals declared.');
348
390
  return;
349
391
  }
350
- const wrap = document.createElement('section');
351
- wrap.className = 'gov-section';
392
+ const list = document.createElement('ul');
393
+ list.className = 'ov-rows';
352
394
  for (const p of principals) {
353
- const row = document.createElement('div');
354
- row.className = 'gov-principal';
355
- row.innerHTML = `<code>${escapeHtml(p.system_role)}</code> holds <code>${escapeHtml(p.permission)}</code> — ${escapeHtml(p.guards || '')} <span class="gov-pill gov-pill--locked">locked</span>`;
356
- wrap.appendChild(row);
395
+ const row = document.createElement('li');
396
+ row.className = 'ov-row ov-row--dense';
397
+ row.innerHTML =
398
+ `<div class="ov-row__main">
399
+ <span class="gov-perm__key">${escapeHtml(p.system_role)}</span>
400
+ <p class="ov-row__sub">holds <code>${escapeHtml(p.permission)}</code>${p.guards ? ` · ${escapeHtml(p.guards)}` : ''}</p>
401
+ </div>
402
+ <div class="ov-row__right">${pill('quiet', 'locked')}</div>`;
403
+ list.appendChild(row);
357
404
  }
358
- box.appendChild(wrap);
405
+ box.appendChild(list);
359
406
  }
360
407
 
361
408
  // ---- loads ---------------------------------------------------------------
@@ -543,7 +590,7 @@
543
590
  // the axis these three rules actually differ on and the one that surprises
544
591
  // people: under the other two an unvoted item passes, under this one it does not.
545
592
  if (board.pass_rule === 'majority') {
546
- return 'Items pass by <strong>majority</strong>: more than half of the board must vote yes \u2014 not just more yes than no. '
593
+ return 'Items pass by <strong>majority</strong>: more than half of the board must vote yes — not just more yes than no. '
547
594
  + 'Silence is <em>not</em> agreement here, so an item that never reaches a majority is returned to its author. '
548
595
  + 'An author may vote on their own idea, but it can never be carried by their vote alone.';
549
596
  }
@@ -560,21 +607,27 @@
560
607
  return `A sitting runs <strong>${fmtWindow(board.window_minutes)}</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
561
608
  }
562
609
 
610
+ // One dial, one row: the dial's name as the title, the live sentence as the
611
+ // sub line. The sentence is trusted HTML from the helpers above.
612
+ function dialRow(name, sentenceHtml) {
613
+ return `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(name)}</span><p class="ov-row__sub">${sentenceHtml}</p></div></li>`;
614
+ }
615
+
616
+ // One closed amendment sitting, one row: who proposed what, the rationale as
617
+ // the sub line, the outcome pill and the date and rule as facts.
563
618
  function historyRow(h) {
564
619
  const who = escapeHtml(h.proposer_name || h.proposer_login || (h.proposed_by ? `builder ${h.proposed_by}` : 'unknown'));
565
- const when = escapeHtml(String(h.closed_at).slice(0, 16).replace('T', ' '));
566
- const pill = h.outcome === 'passed'
567
- ? '<span class="gov-pill gov-pill--seeded">ratified</span>'
568
- : '<span class="gov-pill gov-pill--locked">returned</span>';
620
+ const outcome = h.outcome === 'passed' ? pill('ok', 'ratified') : pill('warn', 'returned');
569
621
  // Task 1003270: the rule by name, not by config key. An unknown value still
570
622
  // shows verbatim — a rule this build does not know about is worth seeing.
571
623
  const under = h.decided_under && h.decided_under.pass_rule
572
- ? ` · decided ${escapeHtml(PASS_RULE_NAME[h.decided_under.pass_rule] || `under ${h.decided_under.pass_rule}`)}`
624
+ ? `<span class="ov-fact">decided ${escapeHtml(PASS_RULE_NAME[h.decided_under.pass_rule] || `under ${h.decided_under.pass_rule}`)}</span>`
573
625
  : '';
574
- return `<div class="gov-assign-row">
575
- <span class="gov-assign-row__who">${who} proposed <code>${escapeHtml(JSON.stringify(h.proposed))}</code>${h.rationale_md ? ` — ${escapeHtml(h.rationale_md)}` : ''}</span>
576
- ${pill} <span class="muted">${when}${under}</span>
577
- </div>`;
626
+ return `<li class="ov-row"><div class="ov-row__main">
627
+ <span class="ov-row__title">${who} proposed <code>${escapeHtml(JSON.stringify(h.proposed))}</code></span>
628
+ ${h.rationale_md ? `<p class="ov-row__sub">${escapeHtml(h.rationale_md)}</p>` : ''}
629
+ <div class="ov-row__facts">${outcome}<span class="ov-row__when">${when(h.closed_at)}</span>${under}</div>
630
+ </div></li>`;
578
631
  }
579
632
 
580
633
  // ---- the charter library (R19) ---------------------------------------------
@@ -643,52 +696,75 @@
643
696
  ];
644
697
  const LADDER = 'solo/pair → circle (3–8) → ~9 name an executive → ~25 → ~50 written charter + council → ~150 adjudication separates → beyond: federate';
645
698
 
699
+ // One charter, one row: the name, the blurb as the sub line, whether it is
700
+ // selectable and recommended as pills, and the configuration an owner would
701
+ // type as the machine-voiced line under it.
646
702
  function charterCard(c, recommendedKey) {
647
- const pill = c.selectable
648
- ? '<span class="gov-pill gov-pill--seeded">selectable today</span>'
649
- : '<span class="gov-pill gov-pill--locked">documented for later</span>';
650
- const rec = c.key === recommendedKey ? ' <span class="gov-pill gov-pill--custom">recommended at your headcount</span>' : '';
651
- const how = c.how ? `<div class="gov-perm__guards">as configuration: ${escapeHtml(c.how)}</div>` : '';
652
- return `<div class="gov-assign-row"><span class="gov-assign-row__who"><strong>${escapeHtml(c.name)}</strong> — ${escapeHtml(c.blurb)}${how}</span>${pill}${rec}</div>`;
703
+ const facts = [
704
+ c.selectable ? pill('ok', 'selectable today') : pill('quiet', 'documented for later'),
705
+ c.key === recommendedKey ? pill('info', 'recommended at your headcount') : '',
706
+ ].join('');
707
+ const how = c.how ? `<p class="gov-charter__how">as configuration: ${escapeHtml(c.how)}</p>` : '';
708
+ return `<li class="ov-row"><div class="ov-row__main">
709
+ <span class="ov-row__title">${escapeHtml(c.name)}</span>
710
+ <p class="ov-row__sub">${escapeHtml(c.blurb)}</p>
711
+ <div class="ov-row__facts">${facts}</div>
712
+ ${how}
713
+ </div></li>`;
653
714
  }
654
715
 
716
+ // The library and the trajectories as one panel: the seven structures, then
717
+ // the three trajectories under their own group head, then the ladder as the
718
+ // panel's foot line.
655
719
  function charterLibraryHtml(data) {
656
720
  const rec = data.recommended || {};
657
721
  return `
658
- <h2 class="gov-h">Charter library</h2>
659
- <p class="gov-grid-note">The seven government structures, for getting a team up to speed before deciding when to flip a switch. <strong>Selectable today</strong> means expressible through the real configuration — a rank-based membership plus a pass rule — with no new code. This project has <strong>${escapeHtml(String(data.headcount ?? '?'))}</strong> active builder${data.headcount === 1 ? '' : 's'}: ${escapeHtml(rec.why || '')} <em>(${escapeHtml(rec.rung || '')})</em>.</p>
660
- <div class="gov-section">${CHARTERS.map((c) => charterCard(c, rec.key)).join('')}</div>
661
- <h2 class="gov-h">Trajectories</h2>
662
- <p class="gov-grid-note">How a project moves between structures over time — summarized from the synthesis:</p>
663
- <div class="gov-section">${TRAJECTORIES.map((t) => `<div class="gov-assign-row"><span class="gov-assign-row__who"><strong>${escapeHtml(t.name)}</strong> — ${escapeHtml(t.blurb)}</span></div>`).join('')}</div>
664
- <p class="gov-grid-note muted">The developmental ladder: ${escapeHtml(LADDER)}. Formality is adopted only where informal coordination breaks.</p>
722
+ <section class="scroll" aria-labelledby="gov-charters-h">
723
+ <h2 class="scroll__h" id="gov-charters-h">Charter library</h2>
724
+ <p class="scroll__lede">The seven government structures, for getting a team up to speed before deciding when to flip a switch. <strong>Selectable today</strong> means expressible through the real configuration — a rank-based membership plus a pass rule — with no new code. This project has <strong>${escapeHtml(String(data.headcount ?? '?'))}</strong> active builder${data.headcount === 1 ? '' : 's'}: ${escapeHtml(rec.why || '')} <em>(${escapeHtml(rec.rung || '')})</em>.</p>
725
+ <ul class="ov-rows">${CHARTERS.map((c) => charterCard(c, rec.key)).join('')}</ul>
726
+ <section class="ov-group">
727
+ ${groupHead('Trajectories', TRAJECTORIES.length, 'how a project moves between structures')}
728
+ <ul class="ov-rows">${TRAJECTORIES.map((t) => `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(t.name)}</span><p class="ov-row__sub">${escapeHtml(t.blurb)}</p></div></li>`).join('')}</ul>
729
+ </section>
730
+ <p class="ov-foot">The developmental ladder: ${escapeHtml(LADDER)}. Formality is adopted only where informal coordination breaks.</p>
731
+ </section>
665
732
  `;
666
733
  }
667
734
 
668
735
  async function loadConstitution() {
736
+ const live = document.getElementById('gov-constitution-live');
669
737
  const box = document.getElementById('gov-constitution');
670
738
  let data;
671
739
  try {
672
740
  data = await fetchJson('/government/constitution');
673
741
  } catch (err) {
674
742
  if (err && err.kind === '401') return;
675
- box.innerHTML = `<p class="gov-empty">Could not load the constitution just now.</p>`;
743
+ live.innerHTML = emptyStateHtml('Could not load the constitution just now.');
744
+ box.innerHTML = '';
676
745
  return;
677
746
  }
678
747
  const b = data.board || {};
679
748
  const history = data.history || [];
749
+ // The three dials as rows inside the Constitution panel, then the
750
+ // configuration they resolve from as the panel's foot line.
751
+ live.innerHTML = `
752
+ <ul class="ov-rows">
753
+ ${dialRow('Who sits on the board', membershipSentence(b))}
754
+ ${dialRow('What counts as passing', passRuleSentence(b))}
755
+ ${dialRow('How long a sitting runs', windowSentence(b))}
756
+ </ul>
757
+ <p class="ov-foot">As configuration: <code>${escapeHtml(JSON.stringify(b))}</code>. Changing it is a board act — propose an amendment, and it is decided under the rule above.</p>
758
+ `;
759
+ // The history and the library are panels of their own, after the live view.
680
760
  box.innerHTML = `
681
- <section class="gov-section">
682
- <div class="gov-section__head">What this project runs today</div>
683
- <p class="gov-grid-note">${membershipSentence(b)}</p>
684
- <p class="gov-grid-note">${passRuleSentence(b)}</p>
685
- <p class="gov-grid-note">${windowSentence(b)}</p>
686
- <p class="gov-grid-note muted">As configuration: <code>${escapeHtml(JSON.stringify(b))}</code>. Changing it is a board act — propose an amendment, and it is decided under the rule above.</p>
761
+ <section class="scroll" aria-labelledby="gov-history-h">
762
+ <h2 class="scroll__h" id="gov-history-h">How it has changed</h2>
763
+ <p class="scroll__lede">Every closed amendment sitting, ratified and returned alike, with the rule it was decided under.</p>
764
+ ${history.length
765
+ ? `<ul class="ov-rows">${history.map(historyRow).join('')}</ul>`
766
+ : emptyStateHtml('Never amended. This project still runs its founding constitution.')}
687
767
  </section>
688
- <h2 class="gov-h">How it has changed</h2>
689
- ${history.length
690
- ? `<div class="gov-section">${history.map(historyRow).join('')}</div>`
691
- : `<p class="gov-empty">Never amended — this project still runs its founding constitution.</p>`}
692
768
  ${charterLibraryHtml(data)}
693
769
  `;
694
770
  }
@@ -713,9 +789,9 @@
713
789
  function subjectLine(item) {
714
790
  if (item.subject_type === 'full_idea') {
715
791
  const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
716
- return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> <span class="gov-pill gov-pill--floor">Full Idea</span>`;
792
+ return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
717
793
  }
718
- return `Constitutional amendment <span class="gov-pill gov-pill--floor">amendment</span>`;
794
+ return `Constitutional amendment ${pill('quiet', 'amendment')}`;
719
795
  }
720
796
 
721
797
  function clockLine(item) {
@@ -732,25 +808,28 @@
732
808
  return 'no clock';
733
809
  }
734
810
 
811
+ // The open ballot: one voter, one dense row — who, then the direction pill
812
+ // and the time as facts, and an objection's section and reason as the sub line.
735
813
  function ballotHtml(item, viewerId) {
736
814
  if (!item.votes.length) {
737
- return `<p class="gov-empty">No votes yet${item.closed_at ? ' — closed unopposed' : ''}.</p>`;
815
+ return emptyStateHtml(`No votes yet${item.closed_at ? '. Closed unopposed' : ''}.`);
738
816
  }
739
817
  const rows = item.votes.map((v) => {
740
818
  const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
741
819
  const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
742
- const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
743
820
  // Task 1003270: the faulted section by its LABEL — the words the author
744
821
  // wrote under and the objector picked from. `why_matters` is a jsonb key,
745
822
  // not something a reader should have to decode.
746
823
  const objection = v.direction === 'no'
747
- ? `<div class="gov-perm__guards">objected to <strong>${escapeHtml(SECTION_LABELS[v.section_key] || v.section_key || 'the idea')}</strong> — ${escapeHtml(v.reason_md || '')}</div>`
824
+ ? `<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>`
748
825
  : '';
749
- return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
750
- <span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction === 'no' ? 'objected' : 'yes'}</span>
751
- <span class="muted">${when}</span>${objection}</div>`;
826
+ return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
827
+ <span class="ov-row__title">${who}${mine}</span>
828
+ ${objection}
829
+ <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>
830
+ </div></li>`;
752
831
  }).join('');
753
- return `<div class="gov-section">${rows}</div>`;
832
+ return `<ul class="ov-rows">${rows}</ul>`;
754
833
  }
755
834
 
756
835
  // ---- the vote form (R17) -----------------------------------------------------
@@ -784,14 +863,12 @@
784
863
  // a graded Full Idea has all five, and a blank one is only noise.
785
864
  const body = String(fv[key] ?? '').trim();
786
865
  if (!body) return '';
787
- return `<div class="gov-idea__part">
788
- <h4 class="gov-idea__label">${escapeHtml(SECTION_LABELS[key])}</h4>
789
- <p class="gov-idea__body">${escapeHtml(body)}</p>
790
- </div>`;
866
+ return `<h4 class="ov-prose__l">${escapeHtml(SECTION_LABELS[key])}</h4>
867
+ <p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
791
868
  }).join('');
792
869
  return rows
793
- ? `<div class="gov-idea">${rows}</div>`
794
- : '<p class="gov-empty">This idea has no written sections.</p>';
870
+ ? `<div class="ov-prose gov-idea">${rows}</div>`
871
+ : emptyStateHtml('This idea has no written sections.');
795
872
  }
796
873
 
797
874
  // ---- the sitting's rules, in plain words (task 1003270) --------------------
@@ -834,7 +911,7 @@
834
911
 
835
912
  const PASS_RULE_PLAIN = {
836
913
  first_ratifier: 'The first vote decides it: a yes ratifies, an objection returns it to its author.',
837
- consent: 'It passes unless someone objects \u2014 silence is agreement, and one reasoned objection returns it.',
914
+ consent: 'It passes unless someone objects — silence is agreement, and one reasoned objection returns it.',
838
915
  majority: 'It passes only when more than half the board votes yes. Silence is not agreement here.',
839
916
  };
840
917
 
@@ -844,10 +921,10 @@
844
921
  const keys = membershipRankKeys(c.membership);
845
922
  const who = keys
846
923
  ? `Every active <strong>${rankNames(keys)}</strong> ${past ? 'could vote on this' : 'may vote on this'}.`
847
- : `Nobody ${past ? 'could vote' : 'may vote'} \u2014 the membership setting <code>${escapeHtml(String(c.membership || 'unset'))}</code> cannot be read.`;
924
+ : `Nobody ${past ? 'could vote' : 'may vote'} — the membership setting <code>${escapeHtml(String(c.membership || 'unset'))}</code> cannot be read.`;
848
925
  const rule = PASS_RULE_PLAIN[c.pass_rule] || 'How this sitting passes could not be read.';
849
926
  const clock = c.window_minutes === null || c.window_minutes === undefined
850
- ? `No deadline \u2014 it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
927
+ ? `No deadline — it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
851
928
  : `The sitting ${past ? 'ran' : 'runs'} ${fmtWindow(c.window_minutes)}${c.close_early_on_full_turnout ? ', closing early once every member has voted' : ''}.`;
852
929
  const score = item.subject ? item.subject.completeness_score : null;
853
930
  const grade = score == null
@@ -878,19 +955,19 @@
878
955
  const isOwnIdea = item.subject_type === 'full_idea'
879
956
  && item.subject && String(item.subject.author_id) === String(viewerId);
880
957
  const sections = Object.keys(SECTION_LABELS).map((key) => `
881
- <label class="gov-perm"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
958
+ <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('');
882
959
  const ownNote = isOwnIdea
883
- ? `<p class="gov-grid-note">Your own idea. You may vote, but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
960
+ ? `<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>`
884
961
  : '';
885
962
  return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
886
963
  ${ownNote}
887
- <button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
964
+ <button type="button" class="btn-accent" data-vote="yes">Vote yes</button>
888
965
  <details class="gov-vote__no">
889
- <summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
966
+ <summary class="btn-ghost">Object…</summary>
890
967
  <p class="gov-grid-note">This sends the idea back to its author. Say why, and which section.</p>
891
- <textarea class="gov-input" data-vote-reason placeholder="Why \u2014 the reason the author will act on" maxlength="5000" rows="3"></textarea>
968
+ <textarea data-vote-reason placeholder="Why — the reason the author will act on" maxlength="5000" rows="3"></textarea>
892
969
  <div class="gov-vote__sections">${sections}</div>
893
- <button type="button" class="gov-btn" data-vote="no">Cast objection</button>
970
+ <button type="button" class="btn-ghost btn-ghost--danger" data-vote="no">Cast objection</button>
894
971
  </details>
895
972
  </div>`;
896
973
  }
@@ -906,7 +983,7 @@
906
983
  try {
907
984
  const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
908
985
  toast(out.item_closed
909
- ? `Vote cast \u2014 the sitting closed: ${out.outcome}.`
986
+ ? `Vote cast — the sitting closed: ${out.outcome}.`
910
987
  : 'Vote cast.');
911
988
  } catch (err) {
912
989
  if (err && err.kind === '401') return;
@@ -934,15 +1011,18 @@
934
1011
  //
935
1012
  // A CLOSED sitting has no action and no idea body: its head carries the
936
1013
  // outcome and the record lives under the same summary.
1014
+ //
1015
+ // In v3 a sitting IS the panel (task 1003469): the card is the shell's .scroll,
1016
+ // its title the panel's display-voice heading.
937
1017
  function itemCard(item, viewerId) {
938
1018
  const yes = item.votes.filter((v) => v.direction === 'yes').length;
939
1019
  const no = item.votes.filter((v) => v.direction === 'no').length;
940
1020
  const isOpen = !item.closed_at;
941
1021
  const state = isOpen
942
- ? `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`
943
- : `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`;
1022
+ ? `${pill('info', 'open')} <span>${escapeHtml(clockLine(item))}</span>`
1023
+ : (item.outcome === 'passed' ? pill('ok', item.outcome) : pill('warn', item.outcome || ''));
944
1024
  const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
945
- ? `<p class="gov-item__amend">Proposes <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` \u2014 ${escapeHtml(item.subject.rationale_md)}` : ''}</p>`
1025
+ ? `<p class="gov-item__amend">Proposes <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</p>`
946
1026
  : '';
947
1027
  const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
948
1028
  // A voter who has cast sees WHAT they cast where the buttons were. The line
@@ -953,8 +1033,8 @@
953
1033
  : mine
954
1034
  ? `<p class="gov-item__cast">You ${mine.direction === 'no' ? 'objected' : 'voted yes'}. One vote per member.</p>`
955
1035
  : voteFormHtml(item, viewerId);
956
- const tally = `${yes} yes \u00b7 ${no} objection${no === 1 ? '' : 's'}`;
957
- return `<section class="gov-item" id="board-item-${escapeHtml(item.id)}">
1036
+ const tally = `${yes} yes · ${no} objection${no === 1 ? '' : 's'}`;
1037
+ return `<section class="scroll gov-item" id="board-item-${escapeHtml(item.id)}">
958
1038
  <div class="gov-item__head">
959
1039
  <h3 class="gov-item__title">${subjectLine(item)}</h3>
960
1040
  <span class="gov-item__state">${state}</span>
@@ -963,7 +1043,7 @@
963
1043
  ${action ? `<div class="gov-item__act">${action}</div>` : ''}
964
1044
  ${isOpen ? ideaSectionsHtml(item) : ''}
965
1045
  <details class="gov-item__meta">
966
- <summary class="gov-item__summary">${tally} \u00b7 who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
1046
+ <summary class="gov-item__summary">${tally} · who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
967
1047
  <div class="gov-item__metabody">
968
1048
  ${decidedByHtml(item)}
969
1049
  ${ballotHtml(item, viewerId)}
@@ -979,25 +1059,29 @@
979
1059
  data = await fetchJson('/government/board/items');
980
1060
  } catch (err) {
981
1061
  if (err && err.kind === '401') return;
982
- box.innerHTML = `<p class="gov-empty">Could not load the Board Room just now.</p>`;
1062
+ box.innerHTML = emptyStateHtml('Could not load the Board Room just now.');
983
1063
  return;
984
1064
  }
985
1065
  const viewerId = data.viewer_id;
986
- const open = (data.open || []).map((i) => itemCard(i, viewerId)).join('');
987
- const closed = (data.closed || []).map((i) => itemCard(i, viewerId)).join('');
1066
+ const openItems = data.open || [];
1067
+ const closedItems = data.closed || [];
1068
+ const open = openItems.map((i) => itemCard(i, viewerId)).join('');
1069
+ const closed = closedItems.map((i) => itemCard(i, viewerId)).join('');
988
1070
  box.innerHTML =
989
- (open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
990
- + `<h2 class="gov-h">Closed sittings</h2>`
991
- + (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
1071
+ (open || `<section class="scroll">${emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.')}</section>`)
1072
+ + `<div class="gov-board__divider">${groupHead('Closed sittings', closedItems.length)}</div>`
1073
+ + (closed || `<section class="scroll">${emptyStateHtml('No sitting has closed yet.')}</section>`);
992
1074
  // R21: the Discord announcement deep-links ?item=<id>#board-room — land the
993
1075
  // reader on THAT sitting. Query, not hash (the hash is the tab, makeTabs
994
- // owns it); a missing or unknown id is simply the room as usual.
1076
+ // owns it); a missing or unknown id is simply the room as usual. The mark is
1077
+ // the world's lit grammar: the accent, and only on the one live thing.
995
1078
  const wanted = new URLSearchParams(window.location.search).get('item');
996
1079
  if (wanted) {
997
1080
  const el = document.getElementById(`board-item-${wanted}`);
998
1081
  if (el) {
999
1082
  el.scrollIntoView({ block: 'start' });
1000
- el.style.outline = '2px solid var(--color-text-success, #4a4)';
1083
+ el.style.outline = '2px solid var(--accent)';
1084
+ el.style.outlineOffset = '2px';
1001
1085
  }
1002
1086
  }
1003
1087
  // R17: the cast buttons. One delegated listener; the SERVER refuses every
@@ -1018,6 +1102,9 @@
1018
1102
  document.getElementById('gov-sealed').hidden = false;
1019
1103
  document.getElementById('gov-body').hidden = true;
1020
1104
  document.getElementById('gov-sub').textContent = 'Restricted.';
1105
+ // The floor in the pack's own words (ADR 0157: metic), never a rank spelled into the source.
1106
+ const body = document.getElementById('gov-sealed-body');
1107
+ if (body) body.textContent = `Available from the ${window.OTB.rankLabel('metic')} rank up.`;
1021
1108
  }
1022
1109
 
1023
1110
  // Since R16 the page shell admits page.view.government (metic+), and only the
@@ -1029,8 +1116,12 @@
1029
1116
  function sealRanksPanel() {
1030
1117
  const panel = document.getElementById('gov-panel-ranks');
1031
1118
  const body = document.getElementById('gov-body');
1032
- // Replace the admin surface with the sealed note, inside its own tab only.
1033
- panel.innerHTML = '<p class="gov-sealed">Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>';
1119
+ // Replace the admin surface with the sealed panel, inside its own tab only.
1120
+ panel.innerHTML =
1121
+ `<section class="scroll" aria-labelledby="gov-ranks-sealed-h">
1122
+ <h2 class="scroll__h" id="gov-ranks-sealed-h">${escapeHtml(window.OTB.rankLabel('archon'))}s only</h2>
1123
+ <p class="scroll__lede">Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>
1124
+ </section>`;
1034
1125
  body.hidden = false;
1035
1126
  }
1036
1127
 
@@ -1061,7 +1152,7 @@
1061
1152
  } catch (err) {
1062
1153
  if (err && err.kind === '401') return;
1063
1154
  if (err && err.kind === '403') { sealRanksPanel(); return; }
1064
- document.getElementById('gov-grid').innerHTML = `<p class="gov-empty">Could not load government data just now.</p>`;
1155
+ document.getElementById('gov-grid').innerHTML = emptyStateHtml('Could not load government data just now.');
1065
1156
  }
1066
1157
  }
1067
1158