@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.
@@ -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
- <p class="gov-grid-note">Three dials decide how this project is governed: <strong>who sits on the board</strong> (a rank — everyone holding it is a member), <strong>what counts as passing</strong> (a first ratifier, or the consent of the sitting), and <strong>how long a vote stays open</strong> (a timed sitting, or no clock at all). They are configuration on the same machinery at every setting — a project starts with the founder ratifying alone and opens up by changing numbers, not code.</p>
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
- <p class="gov-grid-note">Where a Full Idea that cleared the completeness grade comes to be ratified — and where the constitution itself is amended. Open items appear here with their clock and a live tally; an objection must carry a reason and name the section it faults, which is why votes are cast here and only mirrored to chat.</p>
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
- const strong = keys.map((k) => `<strong>${escapeHtml(k)}</strong>`);
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>${escapeHtml(String(board.window_minutes))} minutes</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
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 under <code>${escapeHtml(h.decided_under.pass_rule)}</code>`
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 <code>${escapeHtml(v.section_key || '')}</code> — ${escapeHtml(v.reason_md || '')}</div>`
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
- <div class="gov-perm">
750
- <div class="gov-perm__main">
751
- <code class="gov-perm__key">${escapeHtml(SECTION_LABELS[key])}</code>
752
- <div class="gov-perm__guards">${escapeHtml(String(fv[key] ?? ''))}</div>
753
- </div>
754
- </div>`).join('');
755
- const score = item.subject.completeness_score;
756
- return `<details class="gov-section"><summary class="gov-section__head">The idea, in full${score != null ? ` \u00b7 completeness ${escapeHtml(String(score))}/100` : ''}</summary>${rows}</details>`;
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" style="cursor:pointer"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
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">This is your idea. You can vote on it \u2014 your position belongs on the record \u2014 but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
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">An objection returns the idea to its author with your reason and the section it faults \u2014 the feedback they revise against.</p>
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 state = item.closed_at
814
- ? `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`
815
- : `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`;
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
- ? `<div class="gov-perm__guards">proposes: <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</div>`
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
- const myLine = item.closed_at ? '' : (mine
821
- ? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
822
- : `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
823
- return `<section class="gov-section" id="board-item-${escapeHtml(item.id)}">
824
- <div class="gov-section__head">${subjectLine(item)} ${state}</div>
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
- <p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
827
- ${myLine}
828
- ${item.closed_at ? '' : ideaSectionsHtml(item)}
829
- ${ballotHtml(item, viewerId)}
830
- ${voteFormHtml(item, viewerId)}
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
- document.getElementById('gov-sub').textContent = 'How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.';
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.385",
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.385",
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.385",
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
- const lines = raw.split('\n');
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.385'; // CI auto-patch carrier (ADR 0161); see changelog above
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');
@@ -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
+ });