@bongos/core 1.19.386 → 1.19.387
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 +20 -15
- package/docs/copy-inventory.md +79 -81
- package/docs/copy-registry.json +151 -169
- package/modules/hall-ui/public/government.css +70 -0
- package/modules/hall-ui/public/government.html +9 -2
- package/modules/hall-ui/public/government.js +175 -32
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/government_board_room_copy.mjs +131 -0
|
@@ -101,3 +101,73 @@
|
|
|
101
101
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
102
102
|
color: var(--accent-text); background: var(--surface-2); padding: 1px 5px; border-radius: 4px;
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/* ---- the Board Room: one sitting, one decision (task 1003270) -------------
|
|
106
|
+
The item card is NOT `.gov-section`. It used to be, which meant the title of
|
|
107
|
+
the idea being voted on rendered through `.gov-section__head` — 11.5px,
|
|
108
|
+
uppercase, letterspaced, i.e. styled as a table header. Here the title is a
|
|
109
|
+
title, the ballot sits directly under it, the idea's own words are body text,
|
|
110
|
+
and the constitutional metadata is the only thing behind a click. */
|
|
111
|
+
.gov-item {
|
|
112
|
+
margin: 0 0 18px; border: 1px solid var(--border); border-radius: var(--radius);
|
|
113
|
+
background: var(--surface); overflow: hidden;
|
|
114
|
+
}
|
|
115
|
+
.gov-item__head {
|
|
116
|
+
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
|
|
117
|
+
gap: 6px 12px; padding: 12px 14px 10px;
|
|
118
|
+
}
|
|
119
|
+
.gov-item__title {
|
|
120
|
+
margin: 0; min-width: 0; font-size: 16px; font-weight: 600; line-height: 1.35;
|
|
121
|
+
color: var(--ink); overflow-wrap: anywhere;
|
|
122
|
+
}
|
|
123
|
+
.gov-item__title a { color: inherit; }
|
|
124
|
+
.gov-item__state { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; }
|
|
125
|
+
.gov-item__amend { margin: 0; padding: 0 14px 10px; color: var(--ink-soft); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
126
|
+
|
|
127
|
+
/* The primary action, directly under the title — that adjacency is the fix. */
|
|
128
|
+
.gov-item__act { padding: 2px 14px 14px; }
|
|
129
|
+
.gov-item__cast { margin: 0; color: var(--ink-soft); font-size: 12.5px; }
|
|
130
|
+
|
|
131
|
+
/* The idea itself: open, and set as prose rather than as permission rows. */
|
|
132
|
+
.gov-idea { padding: 12px 14px 2px; border-top: 1px solid var(--border); }
|
|
133
|
+
.gov-idea__part { margin: 0 0 12px; }
|
|
134
|
+
.gov-idea__label { margin: 0 0 3px; font-size: 12px; font-weight: 600; color: var(--ink-soft); }
|
|
135
|
+
.gov-idea__body {
|
|
136
|
+
margin: 0; color: var(--ink); font-size: 13.5px; line-height: 1.5;
|
|
137
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Secondary: the tally rides the summary (visible without a click — ADR 0175
|
|
141
|
+
§9), the rules and the open ballot sit inside it. */
|
|
142
|
+
.gov-item__meta { border-top: 1px solid var(--border); }
|
|
143
|
+
.gov-item__summary {
|
|
144
|
+
padding: 8px 14px; cursor: pointer; color: var(--ink-soft); font-size: 12px;
|
|
145
|
+
list-style-position: inside;
|
|
146
|
+
}
|
|
147
|
+
.gov-item__summary:hover { color: var(--ink); }
|
|
148
|
+
.gov-item__metabody { padding: 10px 14px 2px; background: var(--surface-2); }
|
|
149
|
+
.gov-item__metabody .gov-section { margin: 0 0 12px; background: var(--surface); }
|
|
150
|
+
.gov-item__metabody .gov-empty { padding: 0 0 12px; }
|
|
151
|
+
|
|
152
|
+
/* ---- the ballot controls ------------------------------------------------- */
|
|
153
|
+
.gov-vote { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
|
154
|
+
.gov-vote > .gov-grid-note { margin: 0; max-width: 62ch; }
|
|
155
|
+
.gov-vote__no { align-self: stretch; }
|
|
156
|
+
/* The disclosure IS the secondary button, so its marker must go — otherwise the
|
|
157
|
+
triangle sits inside the pill. */
|
|
158
|
+
.gov-vote__no > summary { display: inline-block; cursor: pointer; }
|
|
159
|
+
.gov-vote__no > summary::marker { content: ''; }
|
|
160
|
+
.gov-vote__no > summary::-webkit-details-marker { display: none; }
|
|
161
|
+
.gov-vote__no > *:not(summary) { margin-top: 8px; }
|
|
162
|
+
.gov-vote__no > textarea.gov-input { display: block; width: 100%; max-width: 62ch; }
|
|
163
|
+
.gov-vote__sections { display: flex; flex-wrap: wrap; gap: 0 18px; }
|
|
164
|
+
.gov-vote__sections .gov-perm {
|
|
165
|
+
padding: 2px 0; border-bottom: 0; cursor: pointer; font-size: 12.5px; color: var(--ink);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* The ballot rows reuse `.gov-assign-row`, whose four flex children (name, pill,
|
|
169
|
+
timestamp, objection) cannot fit one 390w line — the objection is a paragraph,
|
|
170
|
+
not a cell, so it takes its own row and the rest wraps. Scoped to the metabody
|
|
171
|
+
so the Permissions and Constitution tabs keep their single-line rows. */
|
|
172
|
+
.gov-item__metabody .gov-assign-row { flex-wrap: wrap; }
|
|
173
|
+
.gov-item__metabody .gov-assign-row .gov-perm__guards { flex: 1 1 100%; margin-top: 4px; }
|
|
@@ -37,14 +37,21 @@
|
|
|
37
37
|
R18/R19; until then the section frames the model honestly. -->
|
|
38
38
|
<section id="gov-panel-constitution" hidden>
|
|
39
39
|
<h2 class="gov-h">Constitution</h2>
|
|
40
|
-
|
|
40
|
+
<!-- Task 1003270: the ~60-word explainer that used to sit here said,
|
|
41
|
+
at length, what the three live sentences below say from the real
|
|
42
|
+
config. A reader met the paragraph before the values. -->
|
|
43
|
+
<p class="gov-grid-note">Three dials, all settings rather than code: <strong>who sits on the board</strong>, <strong>what counts as passing</strong>, and <strong>how long a vote stays open</strong>.</p>
|
|
41
44
|
<div id="gov-constitution"></div>
|
|
42
45
|
</section>
|
|
43
46
|
|
|
44
47
|
<!-- §2 Board Room — open items, the clock, your vote (R16/R17). -->
|
|
45
48
|
<section id="gov-panel-board" hidden>
|
|
46
49
|
<h2 class="gov-h">Board Room</h2>
|
|
47
|
-
|
|
50
|
+
<!-- Task 1003270: one line, then the ballot. Everything the old
|
|
51
|
+
~50-word paragraph explained is now said by the cards themselves
|
|
52
|
+
— each one carries who votes and how it passes, in plain words,
|
|
53
|
+
under its own summary. -->
|
|
54
|
+
<p class="gov-grid-note">Read an idea, then decide. An objection needs a reason and the section it faults.</p>
|
|
48
55
|
<div id="gov-board"></div>
|
|
49
56
|
</section>
|
|
50
57
|
|
|
@@ -42,6 +42,22 @@
|
|
|
42
42
|
// items and the vote. Until a frame is filled it states what belongs there
|
|
43
43
|
// and shows an honest kit empty state — never a fake or a hardcoded value that
|
|
44
44
|
// could drift from the real config.
|
|
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.
|
|
57
|
+
// • 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.
|
|
45
61
|
|
|
46
62
|
(() => {
|
|
47
63
|
'use strict';
|
|
@@ -497,7 +513,12 @@
|
|
|
497
513
|
// and fixable, a board described as working is neither.
|
|
498
514
|
return `Membership predicate <code>${escapeHtml(raw || 'unset')}</code> cannot be read, so <strong>nobody</strong> sits on the board until it is corrected.`;
|
|
499
515
|
}
|
|
500
|
-
|
|
516
|
+
// Task 1003270: the DISPLAY label, not the Greek enum key. The enum is the
|
|
517
|
+
// grammar an owner types into the predicate (and the charter library below
|
|
518
|
+
// still shows it verbatim, which is that library's whole job); this sentence
|
|
519
|
+
// is the one that answers "who governs", so it uses the words the rest of
|
|
520
|
+
// the hall shows a rank in.
|
|
521
|
+
const strong = keys.map((k) => `<strong>${escapeHtml(window.OTB.rankLabel(k))}</strong>`);
|
|
501
522
|
if (keys.length === 1) {
|
|
502
523
|
return `Every active builder holding the ${strong[0]} rank — and only that rank — sits on the board. Membership follows the rank, live.`;
|
|
503
524
|
}
|
|
@@ -536,7 +557,7 @@
|
|
|
536
557
|
const early = board.close_early_on_full_turnout
|
|
537
558
|
? ', closing early the moment every member has voted'
|
|
538
559
|
: '';
|
|
539
|
-
return `A sitting runs <strong>${
|
|
560
|
+
return `A sitting runs <strong>${fmtWindow(board.window_minutes)}</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
|
|
540
561
|
}
|
|
541
562
|
|
|
542
563
|
function historyRow(h) {
|
|
@@ -545,8 +566,10 @@
|
|
|
545
566
|
const pill = h.outcome === 'passed'
|
|
546
567
|
? '<span class="gov-pill gov-pill--seeded">ratified</span>'
|
|
547
568
|
: '<span class="gov-pill gov-pill--locked">returned</span>';
|
|
569
|
+
// Task 1003270: the rule by name, not by config key. An unknown value still
|
|
570
|
+
// shows verbatim — a rule this build does not know about is worth seeing.
|
|
548
571
|
const under = h.decided_under && h.decided_under.pass_rule
|
|
549
|
-
? ` · decided
|
|
572
|
+
? ` · decided ${escapeHtml(PASS_RULE_NAME[h.decided_under.pass_rule] || `under ${h.decided_under.pass_rule}`)}`
|
|
550
573
|
: '';
|
|
551
574
|
return `<div class="gov-assign-row">
|
|
552
575
|
<span class="gov-assign-row__who">${who} proposed <code>${escapeHtml(JSON.stringify(h.proposed))}</code>${h.rationale_md ? ` — ${escapeHtml(h.rationale_md)}` : ''}</span>
|
|
@@ -717,11 +740,14 @@
|
|
|
717
740
|
const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
|
|
718
741
|
const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
|
|
719
742
|
const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
|
|
743
|
+
// Task 1003270: the faulted section by its LABEL — the words the author
|
|
744
|
+
// wrote under and the objector picked from. `why_matters` is a jsonb key,
|
|
745
|
+
// not something a reader should have to decode.
|
|
720
746
|
const objection = v.direction === 'no'
|
|
721
|
-
? `<div class="gov-perm__guards">objected to <
|
|
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>`
|
|
722
748
|
: '';
|
|
723
749
|
return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
|
|
724
|
-
<span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction}</span>
|
|
750
|
+
<span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction === 'no' ? 'objected' : 'yes'}</span>
|
|
725
751
|
<span class="muted">${when}</span>${objection}</div>`;
|
|
726
752
|
}).join('');
|
|
727
753
|
return `<div class="gov-section">${rows}</div>`;
|
|
@@ -742,20 +768,102 @@
|
|
|
742
768
|
|
|
743
769
|
// The idea's five sections, IN FRONT of the voter — the entire reason voting
|
|
744
770
|
// happens in the hall rather than on a reaction. Rendered in template order.
|
|
771
|
+
//
|
|
772
|
+
// TASK 1003270 — OPEN, AND SET AS PROSE. This used to be a <details> summarised
|
|
773
|
+
// "The idea, in full · completeness 100/100": the substance the vote is about
|
|
774
|
+
// sat behind a click while the constitutional metadata above it stayed expanded,
|
|
775
|
+
// which is exactly backwards. The labels were <code> pills over
|
|
776
|
+
// `.gov-perm__guards`, so an author's answers were styled as config rows. The
|
|
777
|
+
// score moved into the collapsed metadata (decidedByHtml) — it describes the
|
|
778
|
+
// grade the idea cleared, not the idea.
|
|
745
779
|
function ideaSectionsHtml(item) {
|
|
746
780
|
const fv = item.subject && item.subject.field_values;
|
|
747
781
|
if (!fv) return '';
|
|
748
|
-
const rows = Object.keys(SECTION_LABELS).map((key) =>
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
782
|
+
const rows = Object.keys(SECTION_LABELS).map((key) => {
|
|
783
|
+
// An unanswered section is dropped rather than rendered as a bare heading:
|
|
784
|
+
// a graded Full Idea has all five, and a blank one is only noise.
|
|
785
|
+
const body = String(fv[key] ?? '').trim();
|
|
786
|
+
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>`;
|
|
791
|
+
}).join('');
|
|
792
|
+
return rows
|
|
793
|
+
? `<div class="gov-idea">${rows}</div>`
|
|
794
|
+
: '<p class="gov-empty">This idea has no written sections.</p>';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---- the sitting's rules, in plain words (task 1003270) --------------------
|
|
798
|
+
|
|
799
|
+
// The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
|
|
800
|
+
// membership <rank:archon>` — raw config keys, in a reader's face, ABOVE the
|
|
801
|
+
// idea they were being asked to decide. These say the same thing in the words
|
|
802
|
+
// a person thinks in, filed under a summary where secondary information
|
|
803
|
+
// belongs.
|
|
804
|
+
//
|
|
805
|
+
// Read from the item's SNAPSHOT constitution (the rules in force when the
|
|
806
|
+
// window opened), never today's config — the same rules the server tallies
|
|
807
|
+
// under. Rank KEYS never surface: the display label comes from the branding
|
|
808
|
+
// pack via OTB.rankLabel, as on every other hall surface. The one place a raw
|
|
809
|
+
// predicate still shows is an UNREADABLE one, where the broken string is the
|
|
810
|
+
// actionable fact and hiding it would help nobody.
|
|
811
|
+
function rankNames(keys) {
|
|
812
|
+
const labels = keys.map((k) => escapeHtml(window.OTB.rankLabel(k)));
|
|
813
|
+
if (labels.length === 1) return labels[0];
|
|
814
|
+
return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// A sitting length in the unit a person would say it in. The config stores
|
|
818
|
+
// minutes; "2880 minutes" is a number nobody converts in their head.
|
|
819
|
+
function fmtWindow(minutes) {
|
|
820
|
+
const m = Number(minutes);
|
|
821
|
+
if (!Number.isFinite(m) || m <= 0) return `${escapeHtml(String(minutes))} minutes`;
|
|
822
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
823
|
+
if (m % 1440 === 0) return plural(m / 1440, 'day');
|
|
824
|
+
if (m % 60 === 0) return plural(m / 60, 'hour');
|
|
825
|
+
return plural(m, 'minute');
|
|
757
826
|
}
|
|
758
827
|
|
|
828
|
+
// The short name of a pass rule, for a place a whole sentence will not fit.
|
|
829
|
+
const PASS_RULE_NAME = {
|
|
830
|
+
first_ratifier: 'by first ratifier',
|
|
831
|
+
consent: 'by consent',
|
|
832
|
+
majority: 'by majority',
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
const PASS_RULE_PLAIN = {
|
|
836
|
+
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.',
|
|
838
|
+
majority: 'It passes only when more than half the board votes yes. Silence is not agreement here.',
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
function decidedByHtml(item) {
|
|
842
|
+
const c = item.constitution || {};
|
|
843
|
+
const past = !!item.closed_at;
|
|
844
|
+
const keys = membershipRankKeys(c.membership);
|
|
845
|
+
const who = keys
|
|
846
|
+
? `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.`;
|
|
848
|
+
const rule = PASS_RULE_PLAIN[c.pass_rule] || 'How this sitting passes could not be read.';
|
|
849
|
+
const clock = c.window_minutes === null || c.window_minutes === undefined
|
|
850
|
+
? `No deadline \u2014 it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
|
|
851
|
+
: `The sitting ${past ? 'ran' : 'runs'} ${fmtWindow(c.window_minutes)}${c.close_early_on_full_turnout ? ', closing early once every member has voted' : ''}.`;
|
|
852
|
+
const score = item.subject ? item.subject.completeness_score : null;
|
|
853
|
+
const grade = score == null
|
|
854
|
+
? ''
|
|
855
|
+
: `<p class="gov-grid-note">It cleared the completeness grade at ${escapeHtml(String(score))}/100.</p>`;
|
|
856
|
+
return `<p class="gov-grid-note">${who}</p>
|
|
857
|
+
<p class="gov-grid-note">${rule}</p>
|
|
858
|
+
<p class="gov-grid-note">${clock}</p>${grade}`;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// TASK 1003270: the ballot is the point of the card, so its copy earns its
|
|
862
|
+
// place or goes. What stayed is the ADR 0175 §8 requirement itself — an
|
|
863
|
+
// objection carries a REASON and names ONE SECTION — because that requirement
|
|
864
|
+
// is the whole reason voting lives in the hall instead of on a chat reaction.
|
|
865
|
+
// Simplifying may not weaken it, so the reason box and the five-way picker are
|
|
866
|
+
// untouched; only the sentences around them shrank.
|
|
759
867
|
function voteFormHtml(item, viewerId) {
|
|
760
868
|
if (item.closed_at) return '';
|
|
761
869
|
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
@@ -770,16 +878,16 @@
|
|
|
770
878
|
const isOwnIdea = item.subject_type === 'full_idea'
|
|
771
879
|
&& item.subject && String(item.subject.author_id) === String(viewerId);
|
|
772
880
|
const sections = Object.keys(SECTION_LABELS).map((key) => `
|
|
773
|
-
<label class="gov-perm"
|
|
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('');
|
|
774
882
|
const ownNote = isOwnIdea
|
|
775
|
-
? `<p class="gov-grid-note">
|
|
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>`
|
|
776
884
|
: '';
|
|
777
885
|
return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
|
|
778
886
|
${ownNote}
|
|
779
887
|
<button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
|
|
780
888
|
<details class="gov-vote__no">
|
|
781
889
|
<summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
|
|
782
|
-
<p class="gov-grid-note">
|
|
890
|
+
<p class="gov-grid-note">This sends the idea back to its author. Say why, and which section.</p>
|
|
783
891
|
<textarea class="gov-input" data-vote-reason placeholder="Why \u2014 the reason the author will act on" maxlength="5000" rows="3"></textarea>
|
|
784
892
|
<div class="gov-vote__sections">${sections}</div>
|
|
785
893
|
<button type="button" class="gov-btn" data-vote="no">Cast objection</button>
|
|
@@ -807,27 +915,60 @@
|
|
|
807
915
|
loadBoard(); // re-render either way — the room shows what stands
|
|
808
916
|
}
|
|
809
917
|
|
|
918
|
+
// ONE SITTING, ONE DECISION (task 1003270). The card reads top-down in the
|
|
919
|
+
// order a person actually needs it:
|
|
920
|
+
//
|
|
921
|
+
// 1. what is being decided — the idea's TITLE, at title size. It used to
|
|
922
|
+
// render through `.gov-section__head`, i.e. 11.5px uppercase letterspaced:
|
|
923
|
+
// the subject of the vote styled as a table header.
|
|
924
|
+
// 2. the ACTION, immediately under it. Everything that used to sit between
|
|
925
|
+
// the two — a tally of raw config keys, a sentence explaining that the UI
|
|
926
|
+
// had no vote form (it does), and a collapsed idea — is gone or moved, so
|
|
927
|
+
// "Vote yes" lands above the fold on an ordinary window instead of below
|
|
928
|
+
// a wall of prose.
|
|
929
|
+
// 3. the idea itself, open (ideaSectionsHtml).
|
|
930
|
+
// 4. the constitutional metadata, collapsed — who votes, how it passes, the
|
|
931
|
+
// clock, and the open ballot. Still one click away, never invisible: the
|
|
932
|
+
// tally rides the summary, which is the part of it a reader scans for
|
|
933
|
+
// (ADR 0175 §9 — a board whose votes are invisible constrains nobody).
|
|
934
|
+
//
|
|
935
|
+
// A CLOSED sitting has no action and no idea body: its head carries the
|
|
936
|
+
// outcome and the record lives under the same summary.
|
|
810
937
|
function itemCard(item, viewerId) {
|
|
811
938
|
const yes = item.votes.filter((v) => v.direction === 'yes').length;
|
|
812
939
|
const no = item.votes.filter((v) => v.direction === 'no').length;
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
940
|
+
const isOpen = !item.closed_at;
|
|
941
|
+
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>`;
|
|
816
944
|
const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
|
|
817
|
-
? `<
|
|
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>`
|
|
818
946
|
: '';
|
|
819
947
|
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
948
|
+
// A voter who has cast sees WHAT they cast where the buttons were. The line
|
|
949
|
+
// that used to fill this slot for everyone else — announcing that the reader
|
|
950
|
+
// had not voted and that casting was still to come — is deleted outright: it
|
|
951
|
+
// explained the UI to itself, and the form it promised shipped in R17.
|
|
952
|
+
const action = !isOpen ? ''
|
|
953
|
+
: mine
|
|
954
|
+
? `<p class="gov-item__cast">You ${mine.direction === 'no' ? 'objected' : 'voted yes'}. One vote per member.</p>`
|
|
955
|
+
: 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)}">
|
|
958
|
+
<div class="gov-item__head">
|
|
959
|
+
<h3 class="gov-item__title">${subjectLine(item)}</h3>
|
|
960
|
+
<span class="gov-item__state">${state}</span>
|
|
961
|
+
</div>
|
|
825
962
|
${amendmentDetail}
|
|
826
|
-
|
|
827
|
-
${
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
963
|
+
${action ? `<div class="gov-item__act">${action}</div>` : ''}
|
|
964
|
+
${isOpen ? ideaSectionsHtml(item) : ''}
|
|
965
|
+
<details class="gov-item__meta">
|
|
966
|
+
<summary class="gov-item__summary">${tally} \u00b7 who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
|
|
967
|
+
<div class="gov-item__metabody">
|
|
968
|
+
${decidedByHtml(item)}
|
|
969
|
+
${ballotHtml(item, viewerId)}
|
|
970
|
+
</div>
|
|
971
|
+
</details>
|
|
831
972
|
</section>`;
|
|
832
973
|
}
|
|
833
974
|
|
|
@@ -905,7 +1046,9 @@
|
|
|
905
1046
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
906
1047
|
const isArchon = rank === 'archon';
|
|
907
1048
|
|
|
908
|
-
|
|
1049
|
+
// Task 1003270: the subtitle used to restate the three tab labels sitting
|
|
1050
|
+
// directly beneath it, in ~20 words, above everything actionable.
|
|
1051
|
+
document.getElementById('gov-sub').textContent = 'How this project is governed.';
|
|
909
1052
|
document.getElementById('gov-body').hidden = false;
|
|
910
1053
|
wireTabs({ isArchon });
|
|
911
1054
|
loadBoard(); // the room loads for everyone the page admits
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.387",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.387",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.387",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -1212,8 +1212,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1212
1212
|
// landed since 1.19.384 with no explicit bump. run 33118682829. (task 1002620)
|
|
1213
1213
|
// 1.19.386 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1214
1214
|
// landed since 1.19.385 with no explicit bump. run 33120468176. (task 1002620)
|
|
1215
|
+
// 1.19.387 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1216
|
+
// landed since 1.19.386 with no explicit bump. run 33121066592. (task 1002620)
|
|
1215
1217
|
// ---------------------------------------------------------------------------
|
|
1216
|
-
const CORE_VERSION = '1.19.
|
|
1218
|
+
const CORE_VERSION = '1.19.387'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1217
1219
|
|
|
1218
1220
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1219
1221
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// tests/government_board_room_copy.mjs — the Board Room card reads as one
|
|
2
|
+
// decision (task 1003270, goal 1000068).
|
|
3
|
+
//
|
|
4
|
+
// The owner's report was that the room was "extremely poor and unclear": the
|
|
5
|
+
// idea being voted on was collapsed while raw config keys — `first_ratifier`,
|
|
6
|
+
// `rank:archon`, `why_matters` — were expanded above it, and the Vote button
|
|
7
|
+
// sat under a wall of prose. Copy rots back, so the two rules that came out of
|
|
8
|
+
// that pass are pinned here rather than left to the next reader's judgment:
|
|
9
|
+
//
|
|
10
|
+
// 1. NO RAW CONFIG KEY REACHES A READER. Every pass rule the server can hold
|
|
11
|
+
// has a plain sentence, checked against the REAL PASS_RULES list — a rule
|
|
12
|
+
// added server-side with no copy fails here instead of shipping a card
|
|
13
|
+
// that prints its enum. Rank keys go through OTB.rankLabel.
|
|
14
|
+
// 2. THE ORDER IS title → action → idea → apparatus. That order IS the fix;
|
|
15
|
+
// an edit that puts prose back between the title and the ballot has undone
|
|
16
|
+
// the task.
|
|
17
|
+
//
|
|
18
|
+
// Source pins, like tests/government_board_vote_ui.mjs: the hall script is an
|
|
19
|
+
// IIFE over the DOM and cannot be imported into node wholesale.
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
30
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.html'), 'utf8');
|
|
31
|
+
const { PASS_RULES } = require(path.join(ROOT, 'modules', 'government', 'config.js'));
|
|
32
|
+
|
|
33
|
+
const card = () => CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
|
|
34
|
+
|
|
35
|
+
// Deleted copy is asserted against the RENDERING source only. The comments in
|
|
36
|
+
// government.js deliberately quote what was removed and why — that history is
|
|
37
|
+
// the point of them, and a test that forbade it would push the record out of
|
|
38
|
+
// the file it explains.
|
|
39
|
+
const CODE = CLIENT.split(/\r?\n/).filter((l) => !/^\s*\/\//.test(l)).join('\n');
|
|
40
|
+
|
|
41
|
+
test('every pass rule the server can hold has a plain-language sentence', () => {
|
|
42
|
+
const block = CLIENT.slice(CLIENT.indexOf('const PASS_RULE_PLAIN'), CLIENT.indexOf('function decidedByHtml'));
|
|
43
|
+
assert.ok(PASS_RULES.length >= 3, `sanity: found the real rule set (${PASS_RULES})`);
|
|
44
|
+
for (const rule of PASS_RULES) {
|
|
45
|
+
assert.ok(block.includes(`${rule}:`), `PASS_RULE_PLAIN must explain '${rule}' — a rule with no copy renders its enum key`);
|
|
46
|
+
}
|
|
47
|
+
// And the sentence must not simply echo the key back at the reader.
|
|
48
|
+
for (const rule of PASS_RULES) {
|
|
49
|
+
const line = block.split('\n').find((l) => l.trim().startsWith(`${rule}:`));
|
|
50
|
+
assert.ok(!/'[^']*\b(first_ratifier|consent_rule|pass_rule)\b/.test(line), `'${rule}' copy leaks a config key: ${line}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the short name map covers the same rule set (the history line)', () => {
|
|
55
|
+
const block = CLIENT.slice(CLIENT.indexOf('const PASS_RULE_NAME'), CLIENT.indexOf('const PASS_RULE_PLAIN'));
|
|
56
|
+
for (const rule of PASS_RULES) {
|
|
57
|
+
assert.ok(block.includes(`${rule}:`), `PASS_RULE_NAME must name '${rule}'`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a rank reaches the reader as a LABEL, never as the enum key', () => {
|
|
62
|
+
assert.match(CLIENT, /function rankNames\(keys\)[\s\S]*?window\.OTB\.rankLabel\(k\)/,
|
|
63
|
+
'the board card resolves rank keys through the branding pack');
|
|
64
|
+
const membership = CLIENT.slice(CLIENT.indexOf('function membershipSentence'), CLIENT.indexOf('function passRuleSentence'));
|
|
65
|
+
assert.match(membership, /window\.OTB\.rankLabel\(k\)/,
|
|
66
|
+
'so does the Constitution sentence — `archon` is an enum, "Owner" is what a person reads');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the card order is title → action → the idea → the apparatus', () => {
|
|
70
|
+
const c = card();
|
|
71
|
+
const at = (needle) => {
|
|
72
|
+
const i = c.indexOf(needle);
|
|
73
|
+
assert.notEqual(i, -1, `itemCard no longer contains ${needle}`);
|
|
74
|
+
return i;
|
|
75
|
+
};
|
|
76
|
+
assert.ok(at('gov-item__title') < at('gov-item__act'),
|
|
77
|
+
'the action follows the title directly — that adjacency is the whole fix');
|
|
78
|
+
assert.ok(at('gov-item__act') < at('ideaSectionsHtml(item)'),
|
|
79
|
+
'nothing may be inserted between the title and the ballot');
|
|
80
|
+
assert.ok(at('ideaSectionsHtml(item)') < at('decidedByHtml(item)'),
|
|
81
|
+
'the idea outranks the rules it is decided under');
|
|
82
|
+
assert.ok(at('decidedByHtml(item)') < at('ballotHtml(item, viewerId)'),
|
|
83
|
+
'who votes, then who has');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the idea renders OPEN — never behind a summary', () => {
|
|
87
|
+
const sections = CLIENT.slice(CLIENT.indexOf('function ideaSectionsHtml'), CLIENT.indexOf('function rankNames'));
|
|
88
|
+
assert.ok(!/<details/.test(sections),
|
|
89
|
+
'the substance of the vote is not a disclosure — that was the reported bug');
|
|
90
|
+
assert.match(sections, /gov-idea__body/, 'and it is set as prose, not as a config row');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the tally stays visible without a click (ADR 0175 §9)', () => {
|
|
94
|
+
const c = card();
|
|
95
|
+
assert.match(c, /gov-item__summary">\$\{tally\}/,
|
|
96
|
+
'a board whose votes are invisible constrains nobody — the count rides the summary');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the self-explaining copy is DELETED, not reworded', () => {
|
|
100
|
+
assert.ok(!/Casting arrives with the vote form/.test(CODE),
|
|
101
|
+
'the sentence that explained the UI to itself — and the form it promised has shipped');
|
|
102
|
+
assert.ok(!/tally: \$\{yes\} yes/.test(CLIENT), 'the raw-config tally line is gone');
|
|
103
|
+
assert.ok(!/The idea, in full/.test(CODE), 'so is the summary that hid the idea');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
|
|
107
|
+
const ballot = CLIENT.slice(CLIENT.indexOf('function ballotHtml'), CLIENT.indexOf('// ---- the vote form'));
|
|
108
|
+
assert.match(ballot, /SECTION_LABELS\[v\.section_key\]/,
|
|
109
|
+
'`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
|
|
113
|
+
// The requirement simplifying may not weaken: it is why voting lives in the
|
|
114
|
+
// hall instead of on a chat reaction.
|
|
115
|
+
const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
|
|
116
|
+
assert.match(form, /data-vote-reason/, 'the reason box survives');
|
|
117
|
+
assert.match(form, /gov-vote__sections/, 'so does the five-way section picker');
|
|
118
|
+
assert.match(form, /Object\.keys\(SECTION_LABELS\)\.map/, 'with one radio per declared section');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the page frames its panels in one line each, not a paragraph', () => {
|
|
122
|
+
// The two intros used to run ~60 and ~50 words before anything actionable.
|
|
123
|
+
for (const heading of ['Constitution', 'Board Room']) {
|
|
124
|
+
const i = HTML.indexOf(`<h2 class="gov-h">${heading}</h2>`);
|
|
125
|
+
assert.notEqual(i, -1, `${heading} panel heading missing`);
|
|
126
|
+
const note = /<p class="gov-grid-note">([\s\S]*?)<\/p>/.exec(HTML.slice(i));
|
|
127
|
+
assert.ok(note, `${heading} has no framing line`);
|
|
128
|
+
const words = note[1].replace(/<[^>]+>/g, ' ').trim().split(/\s+/).length;
|
|
129
|
+
assert.ok(words <= 30, `${heading}'s intro is ${words} words — a reader meets it before anything actionable`);
|
|
130
|
+
}
|
|
131
|
+
});
|