@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
|
@@ -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
|
|
29
|
-
//
|
|
30
|
-
// and administration is only one part of that answer
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// the
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
|
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:
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
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
|
-
//
|
|
1092
|
-
//
|
|
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
|
-
//
|
|
1095
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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' },
|