@bongos/core 1.19.622 → 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.
@@ -25,39 +25,41 @@
25
25
  // gated server-side on the `government.manage` atom (archon floor). On 401 →
26
26
  // web sign-in.
27
27
  //
28
- // SINCE R15 (goal 1000069, ADR 0175) this page carries a three-section IA,
29
- // because Government is the page that answers "how is this project governed"
30
- // and administration is only one part of that answer. Task 1003269 reordered
31
- // the tabs to Permissions · Board Room · Constitution (the administering Archon
32
- // works from the first of those, and it used to sit last) and shortened the
33
- // "Ranks & Permissions" label to "Permissions". Tabs are OTBKit.makeTabs with
34
- // hash deep links, and the HASH IDS DID NOT CHANGE with that reorder
35
- // (#constitution / #board-room / #ranks) they are linked from Discord and
36
- // docs.
28
+ // SINCE ADR 0266 THIS PAGE CARRIES TWO SECTIONS, not three: Permissions and
29
+ // Constitution. Government is still the page that answers "how is this project
30
+ // governed", and administration is only one part of that answer but the BOARD
31
+ // ROOM, which used to be the third section, is now its own page at /board-room
32
+ // (modules/hall-ui/public/board-room.js), gated on the `board.vote.cast`
33
+ // permission instead of this page's rank floor, so the room's reach equals the
34
+ // right to vote. Task 1003269 had earlier reordered the tabs and shortened
35
+ // "Ranks & Permissions" to "Permissions"; what survives from that is the
36
+ // ordering rule — the administering Archon works from the first tab.
37
37
  //
38
- // The Constitution and Board Room sections began as FRAMES (named here by
39
- // section, not by position, so a reorder like 1003269's cannot make this
40
- // comment lie): R18 filled Constitution with the live values + change history,
41
- // R19 added the charter library, and R16/R17 filled the Board Room with open
42
- // items and the vote. Until a frame is filled it states what belongs there
43
- // and shows an honest kit empty state never a fake or a hardcoded value that
44
- // could drift from the real config.
38
+ // Tabs are OTBKit.makeTabs with hash deep links. #constitution and #ranks are
39
+ // unchanged and still linked from Discord and docs. #board-room is NOT a
40
+ // section here any more: redirectLegacyBoardHash() in init() forwards it to
41
+ // /board-room, preserving ?item=N. That redirect is CLIENT-SIDE on purpose a
42
+ // fragment never reaches the server, so no route could ever catch it and it
43
+ // stays for at least one release, because Discord announcements already in
44
+ // channel history carry the old form forever.
45
45
  //
46
- // TASK 1003270 rebuilt the BOARD ROOM CARD around one claim: the idea is the
47
- // subject, everything else is apparatus. So the idea's title renders at title
48
- // size and its five sections render open as prose; the ballot sits directly
49
- // beneath the title; and the government apparatus who votes, how it passes,
50
- // the clock, the open ballot moved behind one summary that still shows the
51
- // tally. Two rules came out of it and should survive the next edit here:
52
- // NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
53
- // `why_matters` are grammar for the server; a person gets the sentence
54
- // (decidedByHtml, rankNames, SECTION_LABELS). The single exception is a
55
- // membership predicate that cannot be PARSED, where the broken string is
56
- // the actionable fact.
46
+ // The Constitution section began as a FRAME (named here by section, not by
47
+ // position, so a reorder like 1003269's cannot make this comment lie): R18
48
+ // filled it with the live values + change history, R19 added the charter
49
+ // library, and task 1003739 (ADR 0268) added the divergence warning that fires
50
+ // when the board ratified a constitution that is not the one in force. Until a
51
+ // frame is filled it states what belongs there and shows an honest kit empty
52
+ // state never a fake or a hardcoded value that could drift from the real
53
+ // config. The Board Room's own history R16/R17's open items and vote, and
54
+ // task 1003270's card — moved to board-room.js with the room; read it there.
55
+ //
56
+ // Two rules from task 1003270 outlived that move and still govern this page:
57
+ // • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` are
58
+ // grammar for the server; a person gets the sentence (membershipSentence,
59
+ // passRuleSentence, windowSentence, fieldValueHtml, rankNames). The single
60
+ // exception is a membership predicate that cannot be PARSED, where the
61
+ // broken string is the actionable fact.
57
62
  // • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
58
- // What simplifying may NOT touch is ADR 0175 §8: an objection still carries a
59
- // reason and names one section. That requirement is why voting lives in the
60
- // hall and not on a chat reaction.
61
63
  //
62
64
  // TASK 1003469 put the page in the chrome world's COMPOSITION, not just its
63
65
  // tokens: every section is a shell .scroll panel with the display-voice heading,
@@ -65,7 +67,7 @@
65
67
  // five unrelated lists that shared `.gov-assign-row` each render as the row they
66
68
  // are, the permission grid's bordered boxes became hairline groups, and a rank's
67
69
  // 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.
70
+ // line. Nothing about the data, the locks or the tab ids changed.
69
71
 
70
72
  (() => {
71
73
  'use strict';
@@ -487,14 +489,16 @@
487
489
  // "Ranks & Permissions"; ranks are still what the panel lists, but permissions
488
490
  // are the atom (ADR 0151) and the shorter label is the clearer one.
489
491
  //
490
- // THE HASH IDS ARE LOAD-BEARING AND MUST NOT CHANGE: the Discord board
491
- // announcement deep-links ?item=<id>#board-room (board-broadcast.js, and the
492
- // reader in loadBoard below), and #constitution/#ranks are linked from docs.
493
- // Reorder and relabel freely; the ids are the contract.
492
+ // THE HASH IDS ARE LOAD-BEARING AND MUST NOT CHANGE: #constitution and #ranks
493
+ // are linked from docs. Reorder and relabel freely; the ids are the contract.
494
+ //
495
+ // TWO ROOMS SINCE ADR 0266, not three the Board Room moved to /board-room.
496
+ // `#board-room` is therefore no longer a tab id here, and redirectLegacyBoardHash
497
+ // below carries the old link across.
494
498
  //
495
499
  // A non-archon still lands on the Constitution (the page's IA claim: this is
496
500
  // the page that answers "how is this project governed"); the Archon still
497
- // lands on their working surface, which is now also the first tab.
501
+ // lands on their working surface, which is also the first tab.
498
502
  function wireTabs({ isArchon }) {
499
503
  window.OTBKit.makeTabs({
500
504
  mount: document.getElementById('gov-tabs'),
@@ -502,12 +506,30 @@
502
506
  initial: isArchon ? 'ranks' : 'constitution',
503
507
  tabs: [
504
508
  { id: 'ranks', label: 'Permissions', panel: 'gov-panel-ranks' },
505
- { id: 'board-room', label: 'Board Room', panel: 'gov-panel-board' },
506
509
  { id: 'constitution', label: 'Constitution', panel: 'gov-panel-constitution' },
507
510
  ],
508
511
  });
509
512
  }
510
513
 
514
+ // The compatibility path for every `#board-room` link already in the world —
515
+ // Discord announcements sitting in channel history, bookmarks, older docs
516
+ // (ADR 0266).
517
+ //
518
+ // IT HAS TO BE HERE, in the browser. A URL fragment is never sent to the
519
+ // server, so no route or redirect in serve-internal.js can see `#board-room`;
520
+ // adding one looks like the obvious fix and silently never fires. The `?item=N`
521
+ // query IS sent, and board-room.js reads it, so it is carried across intact.
522
+ //
523
+ // Runs before anything else in init() so the reader never sees this page paint.
524
+ // Keep it for at least one release: announcements in Discord history carry the
525
+ // old form forever.
526
+ function redirectLegacyBoardHash() {
527
+ if (window.location.hash !== '#board-room') return false;
528
+ const item = new URLSearchParams(window.location.search).get('item');
529
+ window.location.replace(`/board-room${item ? `?item=${encodeURIComponent(item)}` : ''}`);
530
+ return true;
531
+ }
532
+
511
533
  // ---- the Constitution panel (R18) -------------------------------------------
512
534
 
513
535
  // Plain language over the three dials, straight from the SERVER's resolved
@@ -660,6 +682,19 @@
660
682
  close_early_on_full_turnout: 'Closing early on full turnout',
661
683
  };
662
684
 
685
+ // A list of rank keys as their display labels, in a phrase a person would say
686
+ // ("Metic or Archon"). Carved-region residue with a twist (ADR 0266): the copy
687
+ // that lived here went to board-room.js with the sitting card, and this one
688
+ // came BACK because the divergence warning below (task 1003739, ADR 0268)
689
+ // landed a new reader on the Constitution side while the room was moving out.
690
+ // Its only caller is fieldValueHtml; membershipSentence above spells its own
691
+ // labels inline, so do not route that through here expecting one wording.
692
+ function rankNames(keys) {
693
+ const labels = keys.map((k) => escapeHtml(window.OTB.rankLabel(k)));
694
+ if (labels.length === 1) return labels[0];
695
+ return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
696
+ }
697
+
663
698
  // One constitution VALUE, in the words a person would use — the same rule the
664
699
  // dials keep, applied to the two halves of a divergence. Without this the
665
700
  // warning would be the one place on the page reading "ratified as
@@ -681,6 +716,33 @@
681
716
 
682
717
  // One dial, one row: the dial's name as the title, the live sentence as the
683
718
  // sub line. The sentence is trusted HTML from the helpers above.
719
+ // A sitting length in the unit a person would say it in. The config stores
720
+ // minutes; "2880 minutes" is a number nobody converts in their head.
721
+ //
722
+ // Lived in the Board Room region until ADR 0266 carved that room onto its own
723
+ // page. It stayed because windowSentence above reads it; board-room.js keeps
724
+ // its own copy for the sitting card. Two readers, two files, one wording — if
725
+ // you change the phrasing here, change it there.
726
+ function fmtWindow(minutes) {
727
+ const m = Number(minutes);
728
+ if (!Number.isFinite(m) || m <= 0) return `${escapeHtml(String(minutes))} minutes`;
729
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
730
+ if (m % 1440 === 0) return plural(m / 1440, 'day');
731
+ if (m % 60 === 0) return plural(m / 60, 'hour');
732
+ return plural(m, 'minute');
733
+ }
734
+
735
+ // The short name of a pass rule, for a place a whole sentence will not fit —
736
+ // the change-history line below. Also carved-region residue (ADR 0266): the
737
+ // sentence-length twin PASS_RULE_PLAIN went to board-room.js with the card,
738
+ // this one stayed with its only reader, historyRow.
739
+ const PASS_RULE_NAME = {
740
+ first_ratifier: 'by first ratifier',
741
+ consent: 'by consent',
742
+ majority: 'by majority',
743
+ unanimous: 'by unanimous agreement',
744
+ };
745
+
684
746
  function dialRow(name, sentenceHtml) {
685
747
  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>`;
686
748
  }
@@ -843,337 +905,25 @@
843
905
  `;
844
906
  }
845
907
 
846
- // ---- the Board Room panel (R16) --------------------------------------------
847
-
848
- // The room renders what the server assembled: open items with the database's
849
- // own countdown, the OPEN BALLOT (people, directions, timestamps, objections),
850
- // and the closed history. Nothing here decides anything — casting arrives with
851
- // R17; this panel is the accountability surface (§9: a board whose votes are
852
- // invisible constrains nobody).
853
-
854
- function fmtRemaining(seconds) {
855
- if (seconds === null || seconds === undefined) return null;
856
- const s = Number(seconds);
857
- if (!Number.isFinite(s) || s <= 0) return 'closing…';
858
- if (s < 90) return `closes in ${Math.max(1, Math.round(s))}s`;
859
- if (s < 5400) return `closes in ${Math.round(s / 60)}m`;
860
- return `closes in ${(s / 3600).toFixed(1)}h`;
861
- }
862
-
863
- function subjectLine(item) {
864
- if (item.subject_type === 'full_idea') {
865
- const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
866
- return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
867
- }
868
- return `Constitutional amendment ${pill('quiet', 'amendment')}`;
869
- }
870
-
871
- function clockLine(item) {
872
- if (item.closes_at) {
873
- const rem = fmtRemaining(item.remaining_seconds);
874
- return rem || 'timed sitting';
875
- }
876
- const rule = item.constitution && item.constitution.pass_rule;
877
- if (rule === 'first_ratifier') return 'no clock — closes on the first vote';
878
- // A clockless majority sitting is worth naming honestly: without a deadline
879
- // it waits indefinitely for a majority, which is a real state a project can
880
- // configure itself into (task 1003273).
881
- if (rule === 'majority') return 'no clock — waits for a majority';
882
- // Same honesty for unanimity: with no deadline it waits indefinitely for
883
- // someone to be in favour, which is a state a project can configure (ADR 0267).
884
- if (rule === 'unanimous') return 'no clock — waits for a unanimous yes';
885
- return 'no clock';
886
- }
887
-
888
- // The open ballot: one voter, one dense row — who, then the direction pill
889
- // and the time as facts, and an objection's section and reason as the sub line.
890
- function ballotHtml(item, viewerId) {
891
- if (!item.votes.length) {
892
- return emptyStateHtml(`No votes yet${item.closed_at ? '. Closed unopposed' : ''}.`);
893
- }
894
- const rows = item.votes.map((v) => {
895
- const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
896
- const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
897
- // Task 1003270: the faulted section by its LABEL — the words the author
898
- // wrote under and the objector picked from. `why_matters` is a jsonb key,
899
- // not something a reader should have to decode.
900
- const objection = v.direction === 'no'
901
- ? `<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>`
902
- : '';
903
- return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
904
- <span class="ov-row__title">${who}${mine}</span>
905
- ${objection}
906
- <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>
907
- </div></li>`;
908
- }).join('');
909
- return `<ul class="ov-rows">${rows}</ul>`;
910
- }
911
-
912
- // ---- the vote form (R17) -----------------------------------------------------
913
-
914
- // The five section labels, mirrored from the base template declaration
915
- // (modules/ideas/templates.js — the words the author saw when writing; a test
916
- // holds the two equal, since browser code cannot import a server module).
917
- const SECTION_LABELS = {
918
- purpose: 'Purpose of Idea',
919
- big_picture: 'What does the Big Picture look like',
920
- why_had: 'Why did you have the Idea',
921
- why_matters: 'Why does the Idea Matter',
922
- fit: 'How does the Idea Fit into the big picture of the project',
923
- };
924
-
925
- // The idea's five sections, IN FRONT of the voter — the entire reason voting
926
- // happens in the hall rather than on a reaction. Rendered in template order.
927
- //
928
- // TASK 1003270 — OPEN, AND SET AS PROSE. This used to be a <details> summarised
929
- // "The idea, in full · completeness 100/100": the substance the vote is about
930
- // sat behind a click while the constitutional metadata above it stayed expanded,
931
- // which is exactly backwards. The labels were <code> pills over
932
- // `.gov-perm__guards`, so an author's answers were styled as config rows. The
933
- // score moved into the collapsed metadata (decidedByHtml) — it describes the
934
- // grade the idea cleared, not the idea.
935
- function ideaSectionsHtml(item) {
936
- const fv = item.subject && item.subject.field_values;
937
- if (!fv) return '';
938
- const rows = Object.keys(SECTION_LABELS).map((key) => {
939
- // An unanswered section is dropped rather than rendered as a bare heading:
940
- // a graded Full Idea has all five, and a blank one is only noise.
941
- const body = String(fv[key] ?? '').trim();
942
- if (!body) return '';
943
- return `<h4 class="ov-prose__l">${escapeHtml(SECTION_LABELS[key])}</h4>
944
- <p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
945
- }).join('');
946
- return rows
947
- ? `<div class="ov-prose gov-idea">${rows}</div>`
948
- : emptyStateHtml('This idea has no written sections.');
949
- }
950
-
951
- // ---- the sitting's rules, in plain words (task 1003270) --------------------
952
-
953
- // The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
954
- // membership <rank:archon>` — raw config keys, in a reader's face, ABOVE the
955
- // idea they were being asked to decide. These say the same thing in the words
956
- // a person thinks in, filed under a summary where secondary information
957
- // belongs.
958
- //
959
- // Read from the item's SNAPSHOT constitution (the rules in force when the
960
- // window opened), never today's config — the same rules the server tallies
961
- // under. Rank KEYS never surface: the display label comes from the branding
962
- // pack via OTB.rankLabel, as on every other hall surface. The one place a raw
963
- // predicate still shows is an UNREADABLE one, where the broken string is the
964
- // actionable fact and hiding it would help nobody.
965
- function rankNames(keys) {
966
- const labels = keys.map((k) => escapeHtml(window.OTB.rankLabel(k)));
967
- if (labels.length === 1) return labels[0];
968
- return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
969
- }
970
-
971
- // A sitting length in the unit a person would say it in. The config stores
972
- // minutes; "2880 minutes" is a number nobody converts in their head.
973
- function fmtWindow(minutes) {
974
- const m = Number(minutes);
975
- if (!Number.isFinite(m) || m <= 0) return `${escapeHtml(String(minutes))} minutes`;
976
- const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
977
- if (m % 1440 === 0) return plural(m / 1440, 'day');
978
- if (m % 60 === 0) return plural(m / 60, 'hour');
979
- return plural(m, 'minute');
980
- }
981
-
982
- // The short name of a pass rule, for a place a whole sentence will not fit.
983
- const PASS_RULE_NAME = {
984
- first_ratifier: 'by first ratifier',
985
- consent: 'by consent',
986
- majority: 'by majority',
987
- unanimous: 'by unanimous agreement',
988
- };
989
-
990
- const PASS_RULE_PLAIN = {
991
- first_ratifier: 'The first vote decides it: a yes ratifies, an objection returns it to its author.',
992
- consent: 'It passes unless someone objects — silence is agreement, and one reasoned objection returns it.',
993
- majority: 'It passes only when more than half the board votes yes. Silence is not agreement here.',
994
- unanimous: 'Everyone who votes must vote yes, and at least one member must. One objection sends it back for revision.',
995
- };
996
-
997
- function decidedByHtml(item) {
998
- const c = item.constitution || {};
999
- const past = !!item.closed_at;
1000
- const keys = membershipRankKeys(c.membership);
1001
- const who = keys
1002
- ? `Every active <strong>${rankNames(keys)}</strong> ${past ? 'could vote on this' : 'may vote on this'}.`
1003
- : `Nobody ${past ? 'could vote' : 'may vote'} — the membership setting <code>${escapeHtml(String(c.membership || 'unset'))}</code> cannot be read.`;
1004
- const rule = PASS_RULE_PLAIN[c.pass_rule] || 'How this sitting passes could not be read.';
1005
- const clock = c.window_minutes === null || c.window_minutes === undefined
1006
- ? `No deadline — it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
1007
- : `The sitting ${past ? 'ran' : 'runs'} ${fmtWindow(c.window_minutes)}${c.close_early_on_full_turnout ? ', closing early once every member has voted' : ''}.`;
1008
- const score = item.subject ? item.subject.completeness_score : null;
1009
- const grade = score == null
1010
- ? ''
1011
- : `<p class="gov-grid-note">It cleared the completeness grade at ${escapeHtml(String(score))}/100.</p>`;
1012
- return `<p class="gov-grid-note">${who}</p>
1013
- <p class="gov-grid-note">${rule}</p>
1014
- <p class="gov-grid-note">${clock}</p>${grade}`;
1015
- }
1016
-
1017
- // TASK 1003270: the ballot is the point of the card, so its copy earns its
1018
- // place or goes. What stayed is the ADR 0175 §8 requirement itself — an
1019
- // objection carries a REASON and names ONE SECTION — because that requirement
1020
- // is the whole reason voting lives in the hall instead of on a chat reaction.
1021
- // Simplifying may not weaken it, so the reason box and the five-way picker are
1022
- // untouched; only the sentences around them shrank.
1023
- function voteFormHtml(item, viewerId) {
1024
- if (item.closed_at) return '';
1025
- const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
1026
- if (mine) return '';
1027
- // TASK 1003273: the author gets a REAL ballot. The old branch returned a note
1028
- // and no form, which is what made the Board Room show nine sittings and no
1029
- // way to vote on any of them — every one of them was the reader's own. They
1030
- // may now cast; what they cannot do is decide alone (the close trigger needs
1031
- // a voice that is not theirs, and a majority made only of them cannot carry).
1032
- // The note says so instead of refusing, so the limit is visible rather than
1033
- // discovered by clicking.
1034
- const isOwnIdea = item.subject_type === 'full_idea'
1035
- && item.subject && String(item.subject.author_id) === String(viewerId);
1036
- const sections = Object.keys(SECTION_LABELS).map((key) => `
1037
- <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('');
1038
- const ownNote = isOwnIdea
1039
- ? `<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>`
1040
- : '';
1041
- return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
1042
- ${ownNote}
1043
- <button type="button" class="btn-accent" data-vote="yes">Vote yes</button>
1044
- <details class="gov-vote__no">
1045
- <summary class="btn-ghost">Object…</summary>
1046
- <p class="gov-grid-note">This sends the idea back to its author. Say why, and which section.</p>
1047
- <textarea data-vote-reason placeholder="Why — the reason the author will act on" maxlength="5000" rows="3"></textarea>
1048
- <div class="gov-vote__sections">${sections}</div>
1049
- <button type="button" class="btn-ghost btn-ghost--danger" data-vote="no">Cast objection</button>
1050
- </details>
1051
- </div>`;
1052
- }
1053
-
1054
- async function castVoteFromRoom(itemId, direction, container) {
1055
- const body = { direction };
1056
- if (direction === 'no') {
1057
- const reason = (container.querySelector('[data-vote-reason]') || {}).value || '';
1058
- const picked = container.querySelector(`input[name="vote-section-${itemId}"]:checked`);
1059
- body.reason_md = reason;
1060
- if (picked) body.section_key = picked.value;
1061
- }
1062
- try {
1063
- const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
1064
- toast(out.item_closed
1065
- ? `Vote cast — the sitting closed: ${out.outcome}.`
1066
- : 'Vote cast.');
1067
- } catch (err) {
1068
- if (err && err.kind === '401') return;
1069
- toast(`Could not vote: ${err.message || err}`);
1070
- }
1071
- loadBoard(); // re-render either way — the room shows what stands
1072
- }
1073
-
1074
- // ONE SITTING, ONE DECISION (task 1003270). The card reads top-down in the
1075
- // order a person actually needs it:
908
+ // ---- the Board Room: MOVED OUT (ADR 0266) ---------------------------------
1076
909
  //
1077
- // 1. what is being decided the idea's TITLE, at title size. It used to
1078
- // render through `.gov-section__head`, i.e. 11.5px uppercase letterspaced:
1079
- // the subject of the vote styled as a table header.
1080
- // 2. the ACTION, immediately under it. Everything that used to sit between
1081
- // the two a tally of raw config keys, a sentence explaining that the UI
1082
- // had no vote form (it does), and a collapsed idea is gone or moved, so
1083
- // "Vote yes" lands above the fold on an ordinary window instead of below
1084
- // a wall of prose.
1085
- // 3. the idea itself, open (ideaSectionsHtml).
1086
- // 4. the constitutional metadata, collapsed — who votes, how it passes, the
1087
- // clock, and the open ballot. Still one click away, never invisible: the
1088
- // tally rides the summary, which is the part of it a reader scans for
1089
- // (ADR 0175 §9 — a board whose votes are invisible constrains nobody).
910
+ // The Board Room was this page's `#board-room` tab. It now has its own page
911
+ // at /board-room (modules/hall-ui/public/board-room.js), gated on the
912
+ // `board.vote.cast` atom instead of this page's `page.view.government`, so
913
+ // the room's reach equals the right to vote. Two things drove the move: this
914
+ // page's nav item is labelled "Permissions" and gated archon, so a Metic who
915
+ // sat on the board had no door to the room at all; and the vote atom floors
916
+ // at xenos on purpose, so widening membership below Metic produced members
917
+ // who could cast through the API and not load the page to cast on.
1090
918
  //
1091
- // A CLOSED sitting has no action and no idea body: its head carries the
1092
- // outcome and the record lives under the same summary.
919
+ // What stayed behind, and why, so neither reads as a leftover:
920
+ // fmtWindow — windowSentence (the Constitution's clock dial) reads it.
921
+ // • PASS_RULE_NAME — historyRow (the amendment history line) reads it.
922
+ // Both are duplicated in board-room.js rather than shared; a hall page script
923
+ // is a self-contained IIFE, which is the convention here.
1093
924
  //
1094
- // In v3 a sitting IS the panel (task 1003469): the card is the shell's .scroll,
1095
- // its title the panel's display-voice heading.
1096
- function itemCard(item, viewerId) {
1097
- const yes = item.votes.filter((v) => v.direction === 'yes').length;
1098
- const no = item.votes.filter((v) => v.direction === 'no').length;
1099
- const isOpen = !item.closed_at;
1100
- const state = isOpen
1101
- ? `${pill('info', 'open')} <span>${escapeHtml(clockLine(item))}</span>`
1102
- : (item.outcome === 'passed' ? pill('ok', item.outcome) : pill('warn', item.outcome || ''));
1103
- const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
1104
- ? `<p class="gov-item__amend">Proposes <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</p>`
1105
- : '';
1106
- const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
1107
- // A voter who has cast sees WHAT they cast where the buttons were. The line
1108
- // that used to fill this slot for everyone else — announcing that the reader
1109
- // had not voted and that casting was still to come — is deleted outright: it
1110
- // explained the UI to itself, and the form it promised shipped in R17.
1111
- const action = !isOpen ? ''
1112
- : mine
1113
- ? `<p class="gov-item__cast">You ${mine.direction === 'no' ? 'objected' : 'voted yes'}. One vote per member.</p>`
1114
- : voteFormHtml(item, viewerId);
1115
- const tally = `${yes} yes · ${no} objection${no === 1 ? '' : 's'}`;
1116
- return `<section class="scroll gov-item" id="board-item-${escapeHtml(item.id)}">
1117
- <div class="gov-item__head">
1118
- <h3 class="gov-item__title">${subjectLine(item)}</h3>
1119
- <span class="gov-item__state">${state}</span>
1120
- </div>
1121
- ${amendmentDetail}
1122
- ${action ? `<div class="gov-item__act">${action}</div>` : ''}
1123
- ${isOpen ? ideaSectionsHtml(item) : ''}
1124
- <details class="gov-item__meta">
1125
- <summary class="gov-item__summary">${tally} · who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
1126
- <div class="gov-item__metabody">
1127
- ${decidedByHtml(item)}
1128
- ${ballotHtml(item, viewerId)}
1129
- </div>
1130
- </details>
1131
- </section>`;
1132
- }
1133
-
1134
- async function loadBoard() {
1135
- const box = document.getElementById('gov-board');
1136
- let data;
1137
- try {
1138
- data = await fetchJson('/government/board/items');
1139
- } catch (err) {
1140
- if (err && err.kind === '401') return;
1141
- box.innerHTML = emptyStateHtml('Could not load the Board Room just now.');
1142
- return;
1143
- }
1144
- const viewerId = data.viewer_id;
1145
- const openItems = data.open || [];
1146
- const closedItems = data.closed || [];
1147
- const open = openItems.map((i) => itemCard(i, viewerId)).join('');
1148
- const closed = closedItems.map((i) => itemCard(i, viewerId)).join('');
1149
- box.innerHTML =
1150
- (open || `<section class="scroll">${emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.')}</section>`)
1151
- + `<div class="gov-board__divider">${groupHead('Closed sittings', closedItems.length)}</div>`
1152
- + (closed || `<section class="scroll">${emptyStateHtml('No sitting has closed yet.')}</section>`);
1153
- // R21: the Discord announcement deep-links ?item=<id>#board-room — land the
1154
- // reader on THAT sitting. Query, not hash (the hash is the tab, makeTabs
1155
- // owns it); a missing or unknown id is simply the room as usual. The mark is
1156
- // the world's lit grammar: the accent, and only on the one live thing.
1157
- const wanted = new URLSearchParams(window.location.search).get('item');
1158
- if (wanted) {
1159
- const el = document.getElementById(`board-item-${wanted}`);
1160
- if (el) {
1161
- el.scrollIntoView({ block: 'start' });
1162
- el.style.outline = '2px solid var(--accent)';
1163
- el.style.outlineOffset = '2px';
1164
- }
1165
- }
1166
- // R17: the cast buttons. One delegated listener; the SERVER refuses every
1167
- // illegitimate cast regardless (membership, anti-self, closed, expired) —
1168
- // the form is a courtesy, the route is the wall.
1169
- box.querySelectorAll('.gov-vote [data-vote]').forEach((btn) => {
1170
- btn.addEventListener('click', () => {
1171
- const wrap = btn.closest('.gov-vote');
1172
- btn.disabled = true;
1173
- castVoteFromRoom(wrap.dataset.item, btn.dataset.vote, wrap);
1174
- });
1175
- });
1176
- }
925
+ // The old deep link is redirected below in init() a fragment never reaches
926
+ // the server, so #board-room could not be caught by a route.
1177
927
 
1178
928
  // ---- init ----------------------------------------------------------------
1179
929
 
@@ -1205,6 +955,9 @@
1205
955
  }
1206
956
 
1207
957
  async function init() {
958
+ // Before anything paints: an old #board-room link goes to the room's own
959
+ // page now (ADR 0266). Returns true when it navigated, so nothing else runs.
960
+ if (redirectLegacyBoardHash()) return;
1208
961
  let me;
1209
962
  try {
1210
963
  me = await fetchJson('/me');
@@ -1221,8 +974,7 @@
1221
974
  document.getElementById('gov-sub').textContent = 'How this project is governed.';
1222
975
  document.getElementById('gov-body').hidden = false;
1223
976
  wireTabs({ isArchon });
1224
- loadBoard(); // the room loads for everyone the page admits
1225
- loadConstitution(); // so does the rule it runs under (R18)
977
+ loadConstitution(); // the rule this project runs under (R18)
1226
978
 
1227
979
  if (!isArchon) { sealRanksPanel(); return; }
1228
980
  wireForms();
@@ -30,7 +30,7 @@
30
30
  // Anchors must stay in step with their source of truth; the test asserts it:
31
31
  // standing → builders.js STANDING_GROUPS (+ shell.js VIEW_ALIASES)
32
32
  // work → work.js makeTabs ids
33
- // government → government.js makeTabs ids (the R15 three-section IA)
33
+ // government → government.js makeTabs ids (two rooms since ADR 0266)
34
34
  // settings → settings.js RAIL_GROUPS
35
35
  var SECTIONS = [
36
36
  { parent: 'Standing', gate: 'nav-standing', items: [
@@ -45,9 +45,12 @@
45
45
  { label: 'Backlog', href: '/work#backlog' },
46
46
  { label: 'Completed', href: '/work#completed' },
47
47
  ] },
48
+ // Two entries, not three: the Board Room is a PAGE since ADR 0266, so it is
49
+ // read live from the sidebar like every other page and must not be declared
50
+ // here as well — a sub-destination and a page of the same name would offer
51
+ // the reader two rows to the same room.
48
52
  { parent: 'Government', gate: 'nav-government', items: [
49
53
  { label: 'Constitution', href: '/government#constitution' },
50
- { label: 'Board Room', href: '/government#board-room' },
51
54
  { label: 'Ranks & Permissions', href: '/government#ranks' },
52
55
  ] },
53
56
  { parent: 'Settings', gate: 'shell-user', items: [
@@ -246,6 +246,22 @@
246
246
  // Archon-only, so a group header reading "Archon" over items a Metic can open
247
247
  // would misdescribe the ladder to the builders most likely to be learning it.
248
248
  { group: 'Government', gate: 'government', items: [
249
+ // FIRST in the group because it is the one page here you ACT on — the
250
+ // Community group's ordering rule, applied (a sitting waits on a decision;
251
+ // the rest of this group is monitoring). Carved out of the /government tab
252
+ // strip by ADR 0266, which is also why it needs its own item at all: while
253
+ // it was a tab, the group's only door to it was labelled "Permissions".
254
+ //
255
+ // THE GATE HERE IS COSMETIC AND DELIBERATELY CONSERVATIVE. The page's real
256
+ // wall is `board.vote.cast` (auth.js requireBoardVotePage), which floors at
257
+ // xenos so a widened board can reach the room. This nav gate cannot say
258
+ // that: gateAllows() speaks rank, not permission. It is set to the group's
259
+ // floor because today's constitution seats `rank:archon`, so no sub-Metic
260
+ // member exists to hide the link from. An instance that widens membership
261
+ // below Metic gets a room it can reach by URL and by the board_votes need's
262
+ // deep link, but no sidebar entry — teaching the shell a permission-aware
263
+ // gate is filed rather than guessed at here.
264
+ { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'government' },
249
265
  { id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'government' },
250
266
  { id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
251
267
  { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.622",
3
+ "version": "1.19.623",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.622",
9
+ "version": "1.19.623",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",