@bongos/core 1.20.55 → 1.20.57

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.
Files changed (39) hide show
  1. package/.bongos-core.json +78 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +54 -3
  8. package/docs/api-reference.md +7 -1
  9. package/docs/copy-inventory.md +61 -57
  10. package/docs/copy-registry.json +100 -64
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +13 -5
  13. package/docs/page-readings.json +111 -112
  14. package/modules/hall-ui/public/founding-mark.js +67 -0
  15. package/modules/hall-ui/public/government.js +3 -1
  16. package/modules/hall-ui/public/hall-widgets.js +31 -0
  17. package/modules/hall-ui/public/index-founding.states.json +13 -0
  18. package/modules/hall-ui/public/index.html +2 -1
  19. package/modules/hall-ui/public/profile.html +2 -1
  20. package/modules/hall-ui/public/profile.js +18 -0
  21. package/modules/hall-ui/public/ranks-founding.states.json +12 -0
  22. package/modules/hall-ui/public/ranks.html +11 -1
  23. package/modules/hall-ui/public/ranks.js +25 -0
  24. package/modules/hall-ui/public/roster.html +1 -0
  25. package/modules/hall-ui/public/roster.js +18 -2
  26. package/modules/hall-ui/public/style.css +35 -0
  27. package/modules/hall-ui/records/founding-mark.md +17 -0
  28. package/modules/onboarding/founding-builders.js +127 -0
  29. package/modules/onboarding/founding-mode.js +27 -3
  30. package/modules/onboarding/routes/onboarding.js +11 -0
  31. package/modules/ui-design/kit/fixtures/founding-builders-sample.json +9 -0
  32. package/modules/ui-design/kit/fixtures/founding-builders.json +4 -0
  33. package/modules/ui-design/kit/serve.js +4 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/founding_builders.mjs +263 -0
  39. package/tests/government_recommendations.mjs +1 -0
@@ -0,0 +1,67 @@
1
+ // founding-mark.js — THE FOUNDING MARK in the hall (task 1004424 / BV2.PS10).
2
+ //
3
+ // Everyone who joined while the project was being founded carries a seed from its
4
+ // genesis, numbered in join order; the project owner is "founder". The server works
5
+ // that out (modules/onboarding/founding-builders.js, GET /api/bongos/founding/builders);
6
+ // this file only shows it, in the hall's own badge grammar: a pill the height and type
7
+ // of .rank-badge, with a seed dot in the accent. Four surfaces read it — the roster,
8
+ // the profile, the ranks page and the founding wall on Home — and each one renders
9
+ // exactly as before when the list is empty, which it is on every project that was
10
+ // never founded (the blend rule: founding is a mode the hall wears, never a re-skin).
11
+ //
12
+ // It is a mark, not a rank: nothing here gates anything.
13
+ (function () {
14
+ 'use strict';
15
+
16
+ var STAGE_NAME = {
17
+ decide: 'decide', scope: 'scope', strategize: 'strategize',
18
+ charter: 'charter', legalize: 'legalize', legislation: 'legislation',
19
+ };
20
+
21
+ function esc(s) {
22
+ if (window.OTB && typeof window.OTB.escapeHtml === 'function') return window.OTB.escapeHtml(s);
23
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
24
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
25
+ });
26
+ }
27
+
28
+ // One request per page, shared by every surface on it. Any failure (signed out, the
29
+ // onboarding module off, a network blip) is an empty list: no marks, today's page.
30
+ var pending = null;
31
+ function load() {
32
+ if (pending) return pending;
33
+ pending = fetch('/api/bongos/founding/builders', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
34
+ .then(function (r) { return r.ok ? r.json() : { builders: [] }; })
35
+ .then(function (d) { return Array.isArray(d && d.builders) ? d.builders : []; })
36
+ .catch(function () { return []; });
37
+ return pending;
38
+ }
39
+
40
+ // byId(list) — { builderId: mark } for the per-row surfaces.
41
+ function byId(list) {
42
+ var out = {};
43
+ (list || []).forEach(function (m) { if (m && m.builder_id != null) out[String(m.builder_id)] = m; });
44
+ return out;
45
+ }
46
+
47
+ // "founder" | "founding · #2" — the words on the pill.
48
+ function label(m) {
49
+ return m.founder ? 'founder' : 'founding · #' + m.number;
50
+ }
51
+
52
+ // "Founded the project" | "Joined at scope, day 3 of genesis" — the plain sentence.
53
+ function joinedLine(m) {
54
+ if (m.founder) return 'Founded the project';
55
+ var at = 'Joined at ' + (STAGE_NAME[m.stage] || m.stage || 'genesis');
56
+ return m.day ? at + ', day ' + m.day + ' of genesis' : at + ' during genesis';
57
+ }
58
+
59
+ function chipHtml(m) {
60
+ if (!m) return '';
61
+ var title = (m.founder ? 'Founder' : 'Founding builder #' + m.number) + ' — ' + joinedLine(m) + '. A mark for good; it grants nothing.';
62
+ return '<span class="founding-mark" title="' + esc(title) + '">'
63
+ + '<span class="founding-mark__seed" aria-hidden="true"></span>' + esc(label(m)) + '</span>';
64
+ }
65
+
66
+ window.OTBFounding = { load: load, byId: byId, label: label, joinedLine: joinedLine, chipHtml: chipHtml };
67
+ }());
@@ -866,10 +866,12 @@
866
866
  </form>`;
867
867
  }
868
868
 
869
+ // The name and what the rank can do — one line (task 1004479). The
870
+ // description is still stored on the rank when it is added; shown here too,
871
+ // it said the same thing twice.
869
872
  function recoRankRow(r) {
870
873
  return `<li class="ov-row"><div class="ov-row__main">
871
874
  <span class="ov-row__title">${escapeHtml(r.label)}</span>
872
- <p class="ov-row__sub">${escapeHtml(r.description)}</p>
873
875
  <p class="ov-row__sub">Can: ${escapeHtml(r.can)}</p>
874
876
  </div></li>`;
875
877
  }
@@ -110,6 +110,37 @@
110
110
  '<p class="card-foot"><a href="#standing">All your work →</a></p>',
111
111
  { view: 'home', zone: 'home-main', labelledby: 'home-recent-h', hiddenByDefault: true });
112
112
 
113
+ // 0f. The founding wall (task 1004424) — everyone who joined while the project was
114
+ // being founded, founder first, each with their mark. Hidden until the founding
115
+ // list comes back non-empty, so a project that was never founded keeps today's Home.
116
+ // It fills itself rather than going through builders.js: the list is one read the
117
+ // other founding surfaces share (founding-mark.js), and nothing else on Home needs it.
118
+ reg({
119
+ id: 'home-founding', label: 'Founding builders', order: 30, core: true,
120
+ view: 'home', zone: 'home-main', labelledby: 'home-founding-h', hiddenByDefault: true,
121
+ mount(host) {
122
+ host.innerHTML =
123
+ '<h2 id="home-founding-h" class="scroll__h">The founding wall</h2>' +
124
+ '<p class="scroll__lede">Everyone who joined while this project was being founded, in the order they arrived. The mark is for good.</p>' +
125
+ '<ul class="founding-wall" id="founding-wall"></ul>';
126
+ if (!window.OTBFounding) return;
127
+ window.OTBFounding.load().then(function (list) {
128
+ if (!list.length) return;
129
+ var esc = window.OTB.escapeHtml;
130
+ var wall = host.querySelector('#founding-wall');
131
+ wall.innerHTML = list.map(function (m) {
132
+ var name = esc(m.display_name || m.github_login || 'builder');
133
+ var sub = m.founder ? 'founder' : '#' + m.number + ' · ' + esc(m.stage || 'genesis');
134
+ return '<li class="founding-wall__item">' +
135
+ '<span class="founding-wall__seed" aria-hidden="true"></span>' +
136
+ '<a class="founding-wall__name" href="/profile?id=' + encodeURIComponent(m.builder_id) + '">' + name + '</a>' +
137
+ '<span class="founding-wall__sub">' + sub + '</span></li>';
138
+ }).join('');
139
+ host.hidden = false;
140
+ });
141
+ },
142
+ });
143
+
113
144
  /* ---------------- Standing ---------------- */
114
145
 
115
146
  // 1. Profile — the identity band at the top of Standing (#726). No `group`:
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "The founding wall on Home (task 1004424): the home-founding widget a founded project shows, from the kit fixture founding-builders-sample.json. The page itself is index.states.json's; only the founding read differs. The never-founded case is index.states.json, where the wall stays hidden. Run it with --states (Home has no last path segment).",
3
+ "page": "/builders/",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/", "fixtures": { "founding-builders": "founding-builders-sample" } },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
8
+ "_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s, so they are not this variant's.",
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
+ "states": {
11
+ "founding": { "auth": true, "actions": [["wait", 600], ["focus", ".founding-wall__name"]], "expect": { "visible": ["#home-founding"] } }
12
+ }
13
+ }
@@ -119,7 +119,8 @@
119
119
  script (served only when the module is enabled) is injected by the server
120
120
  immediately before the builders.js tag below — that anchor is load-bearing
121
121
  (serve-internal.js injectModuleHallWidgets). -->
122
- <script src="/builders/hall-widgets.js?v=2026-06-27-r75" defer></script>
122
+ <script src="/builders/founding-mark.js?v=2026-09-30-founding" defer></script>
123
+ <script src="/builders/hall-widgets.js?v=2026-09-30-founding" defer></script>
123
124
  <!-- BV1.R78 (task 1694): the Goals widget registers into the same registry. -->
124
125
  <script src="/builders/goals.js?v=2026-07-01-goals" defer></script>
125
126
  <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
@@ -210,6 +210,7 @@
210
210
  exists when renderProfile runs; profile.js guards on it, so a page that
211
211
  omits this script simply has no charts section content. -->
212
212
  <script src="/builders/profile-charts.js?v=2026-09-04-calendar"></script>
213
- <script src="/builders/profile.js?v=2026-09-02-rail"></script>
213
+ <script src="/builders/founding-mark.js?v=2026-09-30-founding"></script>
214
+ <script src="/builders/profile.js?v=2026-09-30-founding"></script>
214
215
  </body>
215
216
  </html>
@@ -114,6 +114,23 @@
114
114
  </div>`;
115
115
  }
116
116
 
117
+ // ---- render: the founding mark (task 1004424) ----------------------------
118
+ // A builder who joined while the project was founding wears the seed badge beside
119
+ // their rank, and the head says when they joined. Added to the head renderHead
120
+ // already drew; a builder without the mark (or a project never founded) leaves the
121
+ // head exactly as it was.
122
+ function renderFoundingMark(builder) {
123
+ if (!window.OTBFounding || builder.id == null) return;
124
+ window.OTBFounding.load().then((list) => {
125
+ const mark = window.OTBFounding.byId(list)[String(builder.id)];
126
+ const row = document.querySelector('#profile-head .profile__name-row');
127
+ if (!mark || !row || row.querySelector('.founding-mark')) return;
128
+ row.insertAdjacentHTML('beforeend', window.OTBFounding.chipHtml(mark));
129
+ const stats = document.querySelector('#profile-head .profile__stats');
130
+ if (stats) stats.insertAdjacentHTML('afterend', `<p class="profile__founding">${escapeHtml(window.OTBFounding.joinedLine(mark))}</p>`);
131
+ });
132
+ }
133
+
117
134
  // ---- render: by the numbers ---------------------------------------------
118
135
 
119
136
  function tile(label, num, sub) {
@@ -764,6 +781,7 @@
764
781
  const shipped = Number(data.shipped_count) || 0;
765
782
  showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
766
783
  renderHead(builder);
784
+ renderFoundingMark(builder);
767
785
  renderNumbers(data.shipped_count, data.achievements);
768
786
  // The charts (task 1003489) read the activity route and the credits ledger
769
787
  // (task 1003491), so they need the same session the ledgers do; on the
@@ -0,0 +1,12 @@
1
+ {
2
+ "_": "The founding mark (task 1004424) on the Ranks page: the Founding builders ledger (builder, rank, mark) a founded project shows, from the kit fixture founding-builders-sample.json. The page itself is ranks.states.json's; only the founding read differs. The never-founded case is ranks.states.json, where the section stays hidden.",
3
+ "page": "/builders/ranks",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/", "fixtures": { "founding-builders": "founding-builders-sample" } },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/me/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized", "404 \\(Not Found\\)", "goals load failed"],
9
+ "states": {
10
+ "founding": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#founding-scroll"] } }
11
+ }
12
+ }
@@ -39,6 +39,15 @@
39
39
  </div>
40
40
  </section>
41
41
 
42
+ <!-- The founding builders (task 1004424) — everyone who joined while the
43
+ project was founding, with their mark beside their rank. Hidden, and
44
+ never fetched into view, on a project that was never founded. -->
45
+ <section class="scroll" id="founding-scroll" aria-labelledby="founding-h" hidden>
46
+ <h2 id="founding-h" class="scroll__h">Founding builders</h2>
47
+ <p class="scroll__lede">Rank is earned by shipping work. The founding mark is for good: it is numbered in the order people joined while the project was being founded, and it grants nothing.</p>
48
+ <div id="founding-body"></div>
49
+ </section>
50
+
42
51
  <!-- The ladder — one card per rank. Current + next are shown in full;
43
52
  ranks beyond the next keep their unlock details hidden until the
44
53
  builder is one rank below them. -->
@@ -73,6 +82,7 @@
73
82
  <script src="/builders/palette.js" defer></script>
74
83
  <!-- shared frontend toolkit (window.OTB) — loaded before ranks.js, which pulls from it. -->
75
84
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
76
- <script src="/builders/ranks.js?v=2026-06-14-domutils"></script>
85
+ <script src="/builders/founding-mark.js?v=2026-09-30-founding"></script>
86
+ <script src="/builders/ranks.js?v=2026-09-30-founding"></script>
77
87
  </body>
78
88
  </html>
@@ -200,6 +200,31 @@
200
200
  // /me may return the builder under `.builder` or at the top level.
201
201
  const builder = (payload && payload.builder) ? payload.builder : payload;
202
202
  render(builder || {});
203
+ renderFounding();
204
+ }
205
+
206
+ // ---- the founding builders (task 1004424) ----
207
+ // A ledger of everyone who joined while the project was founding: builder, rank,
208
+ // and the mark. The section stays hidden when nobody carries one, so a project
209
+ // that was never founded shows today's page.
210
+ function renderFounding() {
211
+ const section = $('#founding-scroll');
212
+ const body = $('#founding-body');
213
+ if (!section || !body || !window.OTBFounding) return;
214
+ window.OTBFounding.load().then((list) => {
215
+ if (!list.length) return;
216
+ const rows = list.map((m) => {
217
+ const name = escapeHtml(m.display_name || m.github_login || 'builder');
218
+ return `<tr>
219
+ <td><a class="founding-name" href="/profile?id=${encodeURIComponent(m.builder_id)}">${name}</a>
220
+ <span class="founding-line">${escapeHtml(window.OTBFounding.joinedLine(m))}</span></td>
221
+ <td>${badge(m.rank)}</td>
222
+ <td>${window.OTBFounding.chipHtml(m)}</td>
223
+ </tr>`;
224
+ }).join('');
225
+ body.innerHTML = `<table class="ledger"><thead><tr><th>Builder</th><th>Rank</th><th>Mark</th></tr></thead><tbody>${rows}</tbody></table>`;
226
+ section.hidden = false;
227
+ });
203
228
  }
204
229
 
205
230
  load();
@@ -58,6 +58,7 @@
58
58
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
59
59
  <script src="/builders/brand-apply.js"></script>
60
60
  <script src="/builders/hall-kit.js"></script>
61
+ <script src="/builders/founding-mark.js?v=2026-09-30-founding"></script>
61
62
  <script src="/builders/roster.js"></script>
62
63
  </body>
63
64
  </html>
@@ -157,10 +157,20 @@
157
157
  return '<span class="ledger__id">#' + esc(String(b.id)) + '</span>';
158
158
  }
159
159
 
160
+ // task 1004424: the founding mark — a seed badge under the builder for everyone who
161
+ // joined while the project was founding. Empty on a project that was never founded,
162
+ // so the row is exactly today's.
163
+ var marks = {};
164
+ function foundingHtml(b) {
165
+ var m = b.id != null ? marks[String(b.id)] : null;
166
+ if (!m || !window.OTBFounding) return '';
167
+ return '<span class="lb-founding">' + window.OTBFounding.chipHtml(m) + '</span>';
168
+ }
169
+
160
170
  function rowHtml(b) {
161
171
  return '<tr>'
162
172
  + '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
163
- + '<td>' + whoHtml(b) + achRowHtml(b.achievements) + '</td>'
173
+ + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
164
174
  + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
165
175
  + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
166
176
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
@@ -226,11 +236,17 @@
226
236
  rerender();
227
237
  }
228
238
 
239
+ var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
229
240
  fetch('/api/bongos/leaderboard', { headers: { Accept: 'application/json' } })
230
241
  .then(function (r) {
231
242
  if (!r.ok) throw new Error('http ' + r.status);
232
243
  return r.json();
233
244
  })
234
- .then(function (data) { mountRoster(data && data.leaderboard); })
245
+ .then(function (data) {
246
+ return foundingLoad.then(function (list) {
247
+ marks = window.OTBFounding ? window.OTBFounding.byId(list) : {};
248
+ mountRoster(data && data.leaderboard);
249
+ });
250
+ })
235
251
  .catch(function () { notice('Couldn’t load the roster right now.'); });
236
252
  }());
@@ -698,6 +698,41 @@ main:focus { outline: none; }
698
698
  .founding-band__desc { display: none; }
699
699
  .founding-band__stage { margin-left: 0; white-space: normal; }
700
700
  }
701
+ /* task 1004424 (BV2.PS10): THE FOUNDING MARK. A pill in .rank-badge's grammar (same
702
+ height, type and radius) so it sits beside a rank without competing with it; the
703
+ seed is the accent, the founding band's live-state colour, because the mark is
704
+ that state made permanent. It confers nothing, so it is never a link. */
705
+ .founding-mark {
706
+ display: inline-flex;
707
+ align-items: center;
708
+ gap: 6px;
709
+ height: 24px;
710
+ padding: 0 9px 0 7px;
711
+ border-radius: 999px;
712
+ font-size: 11.5px;
713
+ font-weight: 600;
714
+ letter-spacing: 0.02em;
715
+ white-space: nowrap;
716
+ color: var(--ink-soft);
717
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
718
+ border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
719
+ }
720
+ .founding-mark__seed,
721
+ .founding-wall__seed {
722
+ width: 8px; height: 8px; border-radius: 50%; flex: none;
723
+ background: var(--accent);
724
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
725
+ }
726
+ .lb-founding { display: block; margin-top: 4px; }
727
+ .profile__founding { margin: 2px 0 0; font-size: 13px; color: var(--ink-soft); }
728
+ /* The prose-link 24px floor: the target grows by padding, handed back by margin. */
729
+ .founding-name { display: inline-block; padding: 3px 6px; margin: -3px -6px; }
730
+ .founding-line { display: block; font-size: 12px; color: var(--ink-faint); }
731
+ .founding-wall { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
732
+ .founding-wall__item { display: grid; justify-items: center; gap: 4px; text-align: center; padding: 12px 8px; }
733
+ .founding-wall__seed { width: 14px; height: 14px; margin-bottom: 4px; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); }
734
+ .founding-wall__name { padding: 4px 6px; margin: -4px -6px; font-weight: 500; color: var(--ink); }
735
+ .founding-wall__sub { font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); }
701
736
  .nav-item--asleep { opacity: 0.55; }
702
737
  .nav-item__wakes { display: none; margin-left: auto; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
703
738
  .app-sidebar:hover .nav-item__wakes,
@@ -0,0 +1,17 @@
1
+ # The founding mark — a seed badge on the roster, the profile and the ranks page, and the founding wall on Home (task 1004424)
2
+
3
+ **BV2.PS10 (goal 1000121; spec `docs/specs/<redacted>.md` D1).** The canvas frame is `docs/design/mocks/project-startup/FoundingBuilders.dc.html`. Everyone who joined while the project was being founded is a founding builder, numbered in join order; the project owner is "founder". **It is a mark, not a rank: it grants nothing**, and nothing in the hall or on the server reads it to decide anything.
4
+
5
+ Files: `founding-mark.js` (`window.OTBFounding`: one shared read of `GET /api/bongos/founding/builders`, the pill, the plain "Joined at scope, day 3 of genesis" sentence), plus a few lines in `roster.js`, `profile.js`, `ranks.js` (+ a hidden `#founding-scroll` section in `ranks.html`) and the `home-founding` widget in `hall-widgets.js`. The CSS sits beside the founding band in `style.css`. The data is `modules/onboarding/founding-builders.js`.
6
+
7
+ ## Blended, not re-skinned
8
+
9
+ The canvas draws its own hall chrome: a ranks table, seed images, an emblem wall. The live hall wins (the blend rule), so the mark is built from the hall's own parts:
10
+
11
+ - **The pill is `.rank-badge`'s grammar**: the same 24px height, 11.5px/600 type and full radius, so it sits beside a rank without competing with it. The seed is a dot in `--accent`, the founding band's live-state colour, because the mark is that state made permanent. It is not a link, because it confers nothing.
12
+ - **The ranks page has no builders table** (it shows the viewer's own standing), so the canvas's "column on the ranks page" is a `Founding builders` ledger section on that page: builder | rank | mark. It is not a new page.
13
+ - **The wall is a core Home widget in `home-main`**, which brings that zone to its budget of three. It is hidden by default and fills itself from the shared read.
14
+
15
+ ## Off is today's hall
16
+
17
+ A project that was never founded has no founding window, so the read returns an empty list without querying, and every surface renders exactly as before. The roster row of an unmarked builder is byte-identical, the ranks section stays `hidden`, and the wall never unhides. `tests/founding_builders.mjs` runs the real `roster.js` and `hall-widgets.js` to hold this. A signed-out viewer (the profile is public) gets a 401 on the read, which is the same empty list.
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-builders.js — THE FOUNDING MARK: who joined this project
4
+ // while it was being founded, numbered in the order they arrived, and the genesis
5
+ // stage each joined at (task 1004424 / BV2.PS10, goal 1000121; design of record
6
+ // docs/design/mocks/project-startup/FoundingBuilders.dc.html).
7
+ //
8
+ // IT IS A MARK, NOT A RANK (spec D1). It confers nothing: no rank, no council seat, no
9
+ // permission reads it. It exists to be shown — a badge on the roster and the profile,
10
+ // a column on the ranks page, and the founding wall on the project's home.
11
+ //
12
+ // DERIVED, NOT STORED. The founding record founding-mode.js keeps is all the mark
13
+ // needs: the window (founding.started_at .. founding.ended_at) and the time each stage
14
+ // first became current (founding.stage_at.<stage>). A builder whose row was created in
15
+ // the window joined during genesis, so their number is their place in join order and
16
+ // their stage is the last stage stamped at or before their join. Every stamp is
17
+ // write-once, so the mark is for good: nothing a later write does can renumber it.
18
+ // That also means no sign-in path had to change to record a joiner.
19
+ //
20
+ // THE FOUNDER is the project owner (the doorway's getFoundingBuilder: the first human
21
+ // builder) and is marked "founder" rather than numbered. Joiners count from #2, so the
22
+ // founder is always the implied #1 and a founder who joins late cannot shift anyone.
23
+ //
24
+ // A project that was never founded has no window, and every read answers no marks at
25
+ // all, so a hall that is not founding renders exactly as before.
26
+
27
+ const { STAGES, KEYS, stageAtKey, dayOf } = require('./founding-mode');
28
+
29
+ // A founding cohort is the people present at a project's birth — tens, not thousands.
30
+ // The cap only bounds the query; past it the latest joiners simply go unshown.
31
+ const MAX_MARKS = 500;
32
+
33
+ const ms = (v) => {
34
+ if (!v) return NaN;
35
+ const t = new Date(v).getTime();
36
+ return Number.isFinite(t) ? t : NaN;
37
+ };
38
+
39
+ // stageAt(joinedAt, stageAt) — PURE. The stage that was current when a builder joined:
40
+ // the last stage, in genesis order, first stamped at or before that moment. Before any
41
+ // stamp (or with none recorded) it is 'decide', the pre-stage founding opens on.
42
+ function stageAt(joinedAt, stamps) {
43
+ const t = ms(joinedAt);
44
+ let current = 'decide';
45
+ if (!Number.isFinite(t) || !stamps) return current;
46
+ for (const stage of STAGES) {
47
+ const s = ms(stamps[stage]);
48
+ if (Number.isFinite(s) && s <= t) current = stage;
49
+ }
50
+ return current;
51
+ }
52
+
53
+ // shapeFoundingBuilders({ startedAt, endedAt, stageAt, founderId, builders }) — PURE.
54
+ // builders: rows of { id, created_at, ... } in any order. Returns the marks in join
55
+ // order (founder first), or [] when the project was never founded.
56
+ function shapeFoundingBuilders({ startedAt, endedAt, stageAt: stamps, founderId, builders } = {}) {
57
+ const start = ms(startedAt);
58
+ if (!Number.isFinite(start) || !Array.isArray(builders)) return [];
59
+ const end = ms(endedAt);
60
+ const inWindow = (b) => {
61
+ const t = ms(b.created_at);
62
+ return Number.isFinite(t) && t >= start && (!Number.isFinite(end) || t < end);
63
+ };
64
+ const ordered = builders
65
+ .filter((b) => b && b.id != null)
66
+ .slice()
67
+ .sort((a, b) => (ms(a.created_at) - ms(b.created_at)) || (Number(a.id) - Number(b.id)));
68
+ const pick = (b) => ({
69
+ builder_id: Number(b.id),
70
+ display_name: b.display_name || null,
71
+ github_login: b.github_login || null,
72
+ avatar_url: b.avatar_url || null,
73
+ rank: b.rank || null,
74
+ });
75
+ const marks = [];
76
+ const founder = founderId != null ? ordered.find((b) => String(b.id) === String(founderId)) : null;
77
+ if (founder) {
78
+ marks.push({ ...pick(founder), founder: true, number: 1, stage: 'decide', day: 1 });
79
+ }
80
+ let next = 2;
81
+ for (const b of ordered) {
82
+ if (founder && b === founder) continue;
83
+ if (!inWindow(b)) continue;
84
+ marks.push({
85
+ ...pick(b),
86
+ founder: false,
87
+ number: next++,
88
+ stage: stageAt(b.created_at, stamps),
89
+ day: dayOf(startedAt, new Date(b.created_at)),
90
+ });
91
+ }
92
+ return marks;
93
+ }
94
+
95
+ // foundingBuildersFor(deps) — the read behind GET /founding/builders. Never throws:
96
+ // any failure is "no marks", which is how a hall that is not founding looks anyway.
97
+ async function foundingBuildersFor(deps = {}) {
98
+ const api = deps.api || require('../../src/module-api');
99
+ const get = deps.getSetting || api.projectSettings.get;
100
+ try {
101
+ const value = async (key) => { const row = await get(key); return row && row.value ? row.value : null; };
102
+ const startedAt = await value(KEYS.STARTED_AT);
103
+ if (!startedAt) return [];
104
+ const [endedAt, ...stageValues] = await Promise.all([
105
+ value(KEYS.ENDED_AT), ...STAGES.map((s) => value(stageAtKey(s))),
106
+ ]);
107
+ const stamps = {};
108
+ STAGES.forEach((s, i) => { if (stageValues[i]) stamps[s] = stageValues[i]; });
109
+ const founder = await (deps.getFoundingBuilder || api.getFoundingBuilder)();
110
+ const founderId = founder ? founder.id : null;
111
+ const pool = deps.pool || api.pool;
112
+ const { rows } = await pool.query(
113
+ `SELECT id, github_login, display_name, avatar_url, rank, created_at
114
+ FROM builders
115
+ WHERE system_role IS NULL
116
+ AND (id = $1 OR (created_at >= $2 AND ($3::timestamptz IS NULL OR created_at < $3)))
117
+ ORDER BY created_at, id
118
+ LIMIT ${MAX_MARKS}`,
119
+ [founderId, startedAt, endedAt],
120
+ );
121
+ return shapeFoundingBuilders({ startedAt, endedAt, stageAt: stamps, founderId, builders: rows });
122
+ } catch (_) {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ module.exports = { stageAt, shapeFoundingBuilders, foundingBuildersFor, MAX_MARKS };
@@ -47,8 +47,15 @@ const KEYS = Object.freeze({
47
47
  ACTIVE: 'founding.active',
48
48
  STAGE: 'founding.stage',
49
49
  STARTED_AT: 'founding.started_at',
50
+ // When the planet came alive (founding switched off). With STARTED_AT it bounds the
51
+ // window a builder must have joined in to carry the founding mark (task 1004424).
52
+ ENDED_AT: 'founding.ended_at',
50
53
  });
51
54
 
55
+ // The key that records when a stage first became current, so a builder's join time
56
+ // reads back as the stage they joined at (the founding mark, task 1004424).
57
+ function stageAtKey(stage) { return `founding.stage_at.${stage}`; }
58
+
52
59
  // The only 'on' spelling accepted, mirroring mingle's consent-gate parse: an explicit,
53
60
  // recognised yes. Anything else is off.
54
61
  function parseActive(raw) {
@@ -160,26 +167,43 @@ function waitingOnFromGovernment(gov) {
160
167
  // (task 1004423) drives: start founding on a new project, move the stage, and switch
161
168
  // founding off when the planet comes alive. Refuses an unknown stage rather than storing
162
169
  // a value the read would then treat as OFF. Starting stamps started_at once.
170
+ //
171
+ // It also keeps the founding record the mark reads (task 1004424): the first time a
172
+ // stage becomes current it is stamped (starting with no stage is 'decide'), and the
173
+ // first switch-off after a start stamps ended_at. Every stamp is write-once, so the
174
+ // record of who joined when is for good.
163
175
  async function setFoundingMode({ active, stage } = {}, builderId = null, deps = {}) {
164
176
  const settings = require('../../src/module-api').projectSettings;
165
177
  const get = deps.getSetting || settings.get;
166
178
  const set = deps.setSetting || settings.set;
179
+ const nowIso = (deps.now || new Date()).toISOString();
180
+ const stampOnce = async (key) => {
181
+ const cur = await get(key);
182
+ if (!cur || !cur.value) await set(key, nowIso, builderId);
183
+ };
167
184
  if (stage != null && !STAGES.includes(stage)) {
168
185
  const err = new Error(`unknown founding stage '${stage}'`);
169
186
  err.code = 'bad_founding_stage';
170
187
  throw err;
171
188
  }
172
189
  if (active === true) {
190
+ await stampOnce(KEYS.STARTED_AT);
191
+ if (stage == null) await stampOnce(stageAtKey('decide'));
192
+ }
193
+ if (active === false) {
173
194
  const started = await get(KEYS.STARTED_AT);
174
- if (!started || !started.value) await set(KEYS.STARTED_AT, (deps.now || new Date()).toISOString(), builderId);
195
+ if (started && started.value) await stampOnce(KEYS.ENDED_AT);
175
196
  }
176
197
  if (typeof active === 'boolean') await set(KEYS.ACTIVE, active ? 'true' : 'false', builderId);
177
- if (stage != null) await set(KEYS.STAGE, stage, builderId);
198
+ if (stage != null) {
199
+ await stampOnce(stageAtKey(stage));
200
+ await set(KEYS.STAGE, stage, builderId);
201
+ }
178
202
  return foundingModeFor({ getSetting: get, now: deps.now });
179
203
  }
180
204
 
181
205
  module.exports = {
182
- STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS,
206
+ STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS, stageAtKey,
183
207
  parseActive, asleepRooms, dayOf, shapeFoundingMode, waitingOnFromGovernment,
184
208
  foundingModeFor, setFoundingMode,
185
209
  };
@@ -34,6 +34,7 @@ const express = require('express');
34
34
  const api = require('../../../src/module-api');
35
35
  const onboardingPort = require('../port');
36
36
  const buildAccessRequestsRouter = require('./access-requests');
37
+ const foundingBuilders = require('../founding-builders');
37
38
 
38
39
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
39
40
  // consumer list and the graceful-degradation contract.
@@ -50,5 +51,15 @@ module.exports = function buildOnboardingRouter() {
50
51
  // Archon-only resolve routes). Mounted on this same /api/bongos router by the
51
52
  // loader, before the public catch-all — byte-identical paths.
52
53
  router.use(buildAccessRequestsRouter());
54
+ // The founding mark (task 1004424): who joined while the project was founding, in
55
+ // join order, with the stage each joined at. Read by the roster, the profile, the
56
+ // ranks page and the home's founding wall; an empty list on a project that was
57
+ // never founded, so those surfaces render exactly as before. Signed-in, like the
58
+ // hall pages that read it; it carries nothing the roster does not already show.
59
+ router.get('/founding/builders', api.requireBuilder, async (req, res) => {
60
+ const builders = await foundingBuilders.foundingBuildersFor();
61
+ res.set('Cache-Control', 'private, max-age=60');
62
+ res.json({ builders });
63
+ });
53
64
  return router;
54
65
  };
@@ -0,0 +1,9 @@
1
+ {
2
+ "_": "The founding mark (task 1004424): GET /founding/builders on a project that was founded. Builder 1 is the kit's own signed-in builder (me.json), so the ranks and Home renders show the viewer as founder. Only the founding variant states files point at this; the default name has no file, so every other render is a never-founded project.",
3
+ "builders": [
4
+ { "builder_id": 1, "display_name": "A Builder", "github_login": "builder", "avatar_url": null, "rank": "archon", "founder": true, "number": 1, "stage": "decide", "day": 1 },
5
+ { "builder_id": 2, "display_name": "Avery Okonkwo-Lindqvist", "github_login": "avery", "avatar_url": null, "rank": "metic", "founder": false, "number": 2, "stage": "scope", "day": 3 },
6
+ { "builder_id": 3, "display_name": "Jordan", "github_login": "jordan", "avatar_url": null, "rank": "thetes", "founder": false, "number": 3, "stage": "strategize", "day": 8 },
7
+ { "builder_id": 4, "display_name": "Sam", "github_login": "sam", "avatar_url": null, "rank": "xenos", "founder": false, "number": 4, "stage": "legislation", "day": 19 }
8
+ ]
9
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "GET /founding/builders on a project that was never founded (task 1004424): the empty list every hall surface reads as 'no marks', so each render is today's page. The founding variants point the name at founding-builders-sample.json instead.",
3
+ "builders": []
4
+ }
@@ -105,7 +105,10 @@ const FEEDS = {
105
105
  '/public/leaderboard': 'leaderboard',
106
106
  '/projects/featured': 'featured',
107
107
  };
108
- const AUTHED = { '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites' };
108
+ // /founding/builders (task 1004424) answers a never-founded project's empty list by default,
109
+ // so every existing render stays today's page; a founding variant states file points it at
110
+ // founding-builders-sample via stub.fixtures.
111
+ const AUTHED = { '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites', '/founding/builders': 'founding-builders' };
109
112
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
110
113
 
111
114
  // GET /provisioning/starter-bundles as the create wizard's module picker reads it
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.55",
3
+ "version": "1.20.57",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.55",
9
+ "version": "1.20.57",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",