@bongos/core 1.19.385 → 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 +22 -17
- 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/scripts/gds/docs-entropy.js +11 -1
- package/src/module-api.js +5 -1
- package/tests/docs_entropy.mjs +86 -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",
|
|
@@ -89,7 +89,17 @@ function parseFile(absPath) {
|
|
|
89
89
|
return { headings: [], links: [], paragraphs: [] };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// Split on CRLF **or** LF, and drop a leading BOM (task 1003314). A Windows
|
|
93
|
+
// checkout gets CRLF from git, and splitting on a bare newline then leaves a
|
|
94
|
+
// trailing carriage return on every line — which the heading regex below
|
|
95
|
+
// cannot match, because `.` excludes line terminators and `$` is not in
|
|
96
|
+
// multiline mode. The file parsed as having ZERO headings, so every
|
|
97
|
+
// [text](file.md#anchor) pointing into it was reported as a broken anchor:
|
|
98
|
+
// doc_broken_anchors read 21 on Windows and 6 in CI, and the quality ratchet
|
|
99
|
+
// a Windows builder ran was pure noise. Same failure family as task 1002526's
|
|
100
|
+
// `git ls-files` quoting bug, one layer in — a gate that reports confidently
|
|
101
|
+
// and wrongly on one platform.
|
|
102
|
+
const lines = raw.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
93
103
|
const headings = [];
|
|
94
104
|
const links = [];
|
|
95
105
|
const paragraphs = [];
|
package/src/module-api.js
CHANGED
|
@@ -1210,8 +1210,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1210
1210
|
// landed since 1.19.383 with no explicit bump. run 33116184189. (task 1002620)
|
|
1211
1211
|
// 1.19.385 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1212
1212
|
// landed since 1.19.384 with no explicit bump. run 33118682829. (task 1002620)
|
|
1213
|
+
// 1.19.386 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
1213
1217
|
// ---------------------------------------------------------------------------
|
|
1214
|
-
const CORE_VERSION = '1.19.
|
|
1218
|
+
const CORE_VERSION = '1.19.387'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1215
1219
|
|
|
1216
1220
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1217
1221
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/docs_entropy.mjs
CHANGED
|
@@ -12,12 +12,14 @@ import { test } from 'node:test';
|
|
|
12
12
|
import { execFileSync } from 'node:child_process';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
15
17
|
import path from 'node:path';
|
|
16
18
|
|
|
17
19
|
const require = createRequire(import.meta.url);
|
|
18
20
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
21
|
const SCANNER = path.join(ROOT, 'scripts', 'gds', 'docs-entropy.js');
|
|
20
|
-
const { collectMarkdownFiles } = require(SCANNER); // require.main-guarded → no scan on import
|
|
22
|
+
const { collectMarkdownFiles, parseFile } = require(SCANNER); // require.main-guarded → no scan on import
|
|
21
23
|
|
|
22
24
|
test('collectMarkdownFiles passes the glob as an argv array, never a shell string', () => {
|
|
23
25
|
let seen = null;
|
|
@@ -53,3 +55,86 @@ test('the real scan enumerates the repo (the regression: it reported on 0 files)
|
|
|
53
55
|
assert.ok(Number.isFinite(result.stale_links));
|
|
54
56
|
assert.ok(Number.isFinite(result.broken_anchors));
|
|
55
57
|
});
|
|
58
|
+
|
|
59
|
+
// --- CRLF: a Windows checkout must parse exactly like a Linux one (task 1003314) ---
|
|
60
|
+
//
|
|
61
|
+
// The bug: parseFile() split on a bare '\n', so on a Windows checkout (git hands
|
|
62
|
+
// out CRLF) every line kept a trailing '\r'. The heading regex /^(#{1,6})\s+(.+)$/
|
|
63
|
+
// cannot match that — '.' excludes line terminators and '$' is not in multiline
|
|
64
|
+
// mode — so EVERY CRLF file parsed as having zero headings, and every
|
|
65
|
+
// [text](file.md#anchor) pointing into one was reported broken. doc_broken_anchors
|
|
66
|
+
// read 21 on Windows against a baseline of 6, which made the quality ratchet
|
|
67
|
+
// unusable there: a builder could not tell a real regression from platform noise.
|
|
68
|
+
// Same family as the `git ls-files` quoting bug above — a gate that reports
|
|
69
|
+
// confidently and WRONGLY on one platform only.
|
|
70
|
+
|
|
71
|
+
const DOC_LINES = [
|
|
72
|
+
'# Title one',
|
|
73
|
+
'',
|
|
74
|
+
'Some prose with a [link](other.md#deep-anchor) in it.',
|
|
75
|
+
'',
|
|
76
|
+
'## 12. Session Update Protocol',
|
|
77
|
+
'',
|
|
78
|
+
'More prose.',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Write `text` to a temp .md and parse it — parseFile takes a path, not a string.
|
|
82
|
+
function parseString(text) {
|
|
83
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-entropy-eol-'));
|
|
84
|
+
try {
|
|
85
|
+
const file = path.join(dir, 'sample.md');
|
|
86
|
+
fs.writeFileSync(file, text);
|
|
87
|
+
return parseFile(file);
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('parseFile reads CRLF and LF identically — headings, slugs and links', () => {
|
|
94
|
+
const lf = parseString(DOC_LINES.join('\n'));
|
|
95
|
+
const crlf = parseString(DOC_LINES.join('\r\n'));
|
|
96
|
+
|
|
97
|
+
assert.equal(lf.headings.length, 2, 'fixture sanity: the LF form has two headings');
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
crlf.headings, lf.headings,
|
|
100
|
+
'a CRLF file must parse to the same headings as the LF one — before this fix it parsed to NONE',
|
|
101
|
+
);
|
|
102
|
+
assert.deepEqual(crlf.links.map((l) => l.href), lf.links.map((l) => l.href));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('parseFile slugs a CRLF heading without a trailing carriage return', () => {
|
|
106
|
+
const slugs = parseString(DOC_LINES.join('\r\n')).headings.map((h) => h.slug);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
slugs, ['title-one', '12-session-update-protocol'],
|
|
109
|
+
'the real anchor CLAUDE.md cites — a stray \\r in the slug is what made it report as "missing"',
|
|
110
|
+
);
|
|
111
|
+
assert.ok(!slugs.some((s) => s.includes('\r')), 'no slug carries a carriage return');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('parseFile ignores a leading BOM, so a BOM file is not one heading short', () => {
|
|
115
|
+
// Same class, different byte: PowerShell writes UTF-8 WITH BOM by default
|
|
116
|
+
// (scripts/gds/CLAUDE.md warns about this for JSON bodies) and
|
|
117
|
+
// readFileSync('utf8') does not strip it — the BOM would sit before the first
|
|
118
|
+
// '#' and hide that heading.
|
|
119
|
+
const withBom = parseString('' + DOC_LINES.join('\r\n'));
|
|
120
|
+
assert.equal(withBom.headings.length, 2, 'the first heading survives a BOM');
|
|
121
|
+
assert.equal(withBom.headings[0].text, 'Title one');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the real scan on THIS checkout matches the CI baselines (the platform-parity proof)', () => {
|
|
125
|
+
// The point of the fix: the number a Windows builder sees is the number CI
|
|
126
|
+
// sees. Read the baseline rather than hardcoding it, so this stays honest as
|
|
127
|
+
// the ratchet legitimately tightens.
|
|
128
|
+
const stdout = execFileSync(process.execPath, [SCANNER, '--json'], {
|
|
129
|
+
cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
130
|
+
});
|
|
131
|
+
const result = JSON.parse(stdout);
|
|
132
|
+
const baselines = JSON.parse(
|
|
133
|
+
fs.readFileSync(path.join(ROOT, 'config', 'fitness-baselines.json'), 'utf8'),
|
|
134
|
+
).metrics;
|
|
135
|
+
assert.ok(
|
|
136
|
+
result.broken_anchors <= baselines.doc_broken_anchors,
|
|
137
|
+
`broken_anchors ${result.broken_anchors} exceeds the baseline ${baselines.doc_broken_anchors} — `
|
|
138
|
+
+ 'on a CRLF checkout this read 21, because every heading in every CRLF file went unparsed',
|
|
139
|
+
);
|
|
140
|
+
});
|