@bongos/core 1.21.1 → 1.21.3

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 (34) hide show
  1. package/.bongos-core.json +56 -36
  2. package/docs/copy-inventory.md +50 -49
  3. package/docs/copy-registry.json +60 -51
  4. package/docs/module-api-changelog.md +5 -1
  5. package/docs/page-inventory.json +2 -1
  6. package/docs/page-readings.json +49 -41
  7. package/migrations/core_270_craft_achievements.sql +128 -0
  8. package/migrations/core_271_bongos_shipper_badge_name.sql +14 -0
  9. package/modules/economy/achievements.js +199 -4
  10. package/modules/economy/credits.js +16 -0
  11. package/modules/economy/reward.js +3 -0
  12. package/modules/hall-ui/public/builders.js +1 -1
  13. package/modules/hall-ui/public/hall-render.js +24 -2
  14. package/modules/hall-ui/public/roster.css +21 -0
  15. package/modules/hall-ui/public/roster.html +3 -0
  16. package/modules/hall-ui/public/roster.js +130 -31
  17. package/modules/hall-ui/public/roster.states.json +26 -1
  18. package/modules/hall-ui/records/builders-roster.md +20 -0
  19. package/modules/lifecycle/publish-reconciler.js +25 -0
  20. package/modules/status-ui/public/status.js +51 -18
  21. package/modules/status-ui/public/style.css +24 -0
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/release-notes.json +16 -0
  25. package/scripts/hall-preview/refresh-fixtures.js +2 -0
  26. package/src/bongos/db.js +13 -0
  27. package/src/bongos/role-stats.js +36 -2
  28. package/src/bongos/routes/public.js +13 -2
  29. package/src/module-api.js +1 -1
  30. package/tests/achievements_reconcile.mjs +13 -0
  31. package/tests/craft_achievements.mjs +388 -0
  32. package/tests/founding_builders.mjs +2 -1
  33. package/tests/hall_builders_page_pass.mjs +2 -1
  34. package/tests/leaderboard_roles.mjs +272 -0
@@ -429,9 +429,21 @@ async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }
429
429
  if (rowCount > 0) credited += p.delta;
430
430
  booked.push({ ...p, booked: rowCount > 0 });
431
431
  }
432
+ // task 1004438: an idea credit is an ideator's moment (First Thought Ignited,
433
+ // First Full Idea, Ratified by the Board, A Thought That Built Ten), and it is
434
+ // booked inside someone else's ship, so the shipper's reconcile never reaches
435
+ // the payee. Every idea stream books through here; each payee just paid is
436
+ // reconciled, and the hook can never fail the payment.
437
+ for (const p of booked) {
438
+ if (p.booked) await craftAchievements().reconcileAfterCraftCredit(client, p.builderId, { taskId });
439
+ }
432
440
  return { credited, payees: booked };
433
441
  }
434
442
 
443
+ // Lazy: achievements.js sits beside this file and requires nothing of it, but a
444
+ // load-time require would make the two files' load order matter for no reason.
445
+ function craftAchievements() { return require('./achievements'); }
446
+
435
447
  // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
436
448
  // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
437
449
  // of the reward port — the caller passes its `client` (a pool works too, since
@@ -1243,6 +1255,10 @@ async function awardArtistPageCredit(client, { taskId, config = rewardConfig() }
1243
1255
  [t.artist_id, taskId, priced.effective, reason,
1244
1256
  `Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
1245
1257
  );
1258
+ // task 1004438: an approved page is the artist's moment (First Page Approved,
1259
+ // Ten Pages Approved, A Whole Surface). Reconciled here as well as at ship, so
1260
+ // a re-sweep that pays the round outside the ship still reaches the shelf.
1261
+ if (rowCount > 0) await craftAchievements().reconcileAfterCraftCredit(client, t.artist_id, { taskId });
1246
1262
  return rowCount > 0
1247
1263
  ? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
1248
1264
  : { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
@@ -55,6 +55,9 @@ module.exports = {
55
55
  // fires on EVERY ship path (incl. confirmTask / grader_bypass, which never
56
56
  // evaluated achievements before, so most builders only ever got first-claim).
57
57
  reconcileBuilderAchievements: (...a) => achievements.reconcileBuilderAchievements(...a),
58
+ // task 1004438: the craft badges a migration cannot backfill ("A Whole Surface"
59
+ // needs the page inventory, a file) — the lifecycle's sweep runs this once per start.
60
+ backfillCraftAchievements: (...a) => achievements.backfillCraftAchievements(...a),
58
61
  // pure helpers + constants (display / tests)
59
62
  applyKindMultiplier: (...a) => credits.applyKindMultiplier(...a),
60
63
  computeSessionTokenReward: (...a) => credits.computeSessionTokenReward(...a),
@@ -1174,7 +1174,7 @@
1174
1174
  getJSON(`${API}/achievements`),
1175
1175
  getJSON(`${API}/me/recent-ships?limit=all`),
1176
1176
  getJSON(`${API}/versions`),
1177
- getJSON(`${API}/leaderboard`),
1177
+ getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1178
1178
  getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
1179
1179
  ]);
1180
1180
 
@@ -578,6 +578,7 @@
578
578
  <span>Member since ${fmtDate(builder.created_at)}</span>
579
579
  <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
580
580
  ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
581
+ ${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
581
582
  </div>
582
583
  <div class="profile__disciplines">
583
584
  <span class="profile__disc-label">Preferred work:</span>
@@ -611,7 +612,7 @@
611
612
  setText('stat-karma-sub', karmaTrendText);
612
613
  const statRank = document.getElementById('stat-rank');
613
614
  if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
614
- setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
615
+ setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
615
616
  const statsSection = document.getElementById('home-stats');
616
617
  if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
617
618
 
@@ -1274,7 +1275,28 @@
1274
1275
  (a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
1275
1276
  );
1276
1277
  const idx = ranked.findIndex((r) => r.github_login === login);
1277
- return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1278
+ if (idx === -1) return null;
1279
+ // task 1004437: the place inside the builder's MAIN role too, the roster's
1280
+ // role toggle read the same way (credits earned in that craft, among the
1281
+ // builders it is shown for), so an artist's card can say where they stand
1282
+ // as an artist and not only beside every engineer's session pay.
1283
+ const me = ranked[idx];
1284
+ const main = me.roles && me.roles.main;
1285
+ let role = null;
1286
+ if (main && me.roles.credits_by_craft) {
1287
+ const credit = (r) => Number(r.roles && r.roles.credits_by_craft && r.roles.credits_by_craft[main]) || 0;
1288
+ const peers = ranked.filter((r) => r.roles && Array.isArray(r.roles.shown) && r.roles.shown.includes(main));
1289
+ const order = peers.map((r, i) => ({ r, i })).sort((a, b) => (credit(b.r) - credit(a.r)) || (a.i - b.i));
1290
+ const at = order.findIndex((x) => x.r === me);
1291
+ if (at !== -1) role = { craft: main, place: at + 1, total: peers.length };
1292
+ }
1293
+ return { place: idx + 1, total: ranked.length, role };
1294
+ }
1295
+ const ROLE_AS = { engineer: 'an engineer', artist: 'an artist', ideator: 'an ideator' };
1296
+ // "#2 as an artist" — only when it says something the overall place does not.
1297
+ function rolePlaceText(p) {
1298
+ if (!p || !p.role || !ROLE_AS[p.role.craft] || p.role.place === p.place) return '';
1299
+ return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
1278
1300
  }
1279
1301
 
1280
1302
  // task 1003459 — the Claim button on each Home "Next up" row, through the
@@ -22,6 +22,27 @@
22
22
  .dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
23
23
  .dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
24
24
  .dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
25
+ /* ---- the roles (task 1004437, the approved mock's leaderboard) -------------
26
+ The toggle: pill chips on the glass, the pressed one filled in ink (the mock's
27
+ .chip). The roles column: one pill per role shown, the main one a step
28
+ stronger (the mock's .role / .role--lead, the profile header's chips). */
29
+ .roster-roles { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--sp-sm); }
30
+ .roster-role {
31
+ min-height: 34px; padding: 0 14px; border: 0; border-radius: var(--radius-pill);
32
+ background: var(--glass); color: var(--ink-soft);
33
+ font: inherit; font-size: 13px; cursor: pointer;
34
+ }
35
+ .roster-role:hover { color: var(--ink); background: var(--glass-strong); }
36
+ .roster-role[aria-pressed="true"] { background: var(--ink); color: var(--bg-card); }
37
+ .lb-roles { display: inline-flex; flex-wrap: wrap; gap: 4px; }
38
+ .lb-role {
39
+ display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px;
40
+ border-radius: var(--radius-pill); background: var(--glass);
41
+ font-size: 12.5px; color: var(--ink-soft); white-space: nowrap;
42
+ }
43
+ .lb-role--lead { background: var(--glass-strong); color: var(--ink); }
44
+ .lb-roles--none, .lb-none { color: var(--ink-faint); }
45
+
25
46
  /* The roster's place cell carries two facts: the place, and under it the
26
47
  builder's number in the same mono/faint voice the login uses (task
27
48
  1002837). Block display so the id never wraps up beside the place. */
@@ -34,6 +34,9 @@
34
34
  link into index.html. It is a page now; /#leaderboard redirects here. -->
35
35
  <section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
36
36
  <h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
37
+ <!-- The role toggle (task 1004437, owner decision (b)): Everyone, then one
38
+ chip per craft; a craft ranks by the credits earned in it. -->
39
+ <div class="roster-roles" id="roster-roles" role="group" aria-label="Show"></div>
37
40
  <div id="roster-filters"></div>
38
41
  <div id="roster-body">
39
42
  <div class="profile__placeholder">Loading the roster…</div>
@@ -14,7 +14,7 @@
14
14
  // marked `private` still appears on the roster of a project they build on
15
15
  // (owner, 2026-08-30; ADR 0225, task 1003443).
16
16
  //
17
- // The laurels (ACH_EMOJI + achRowHtml) and the sort lenses MOVED here from
17
+ // The laurels (ACH + achRowHtml) and the sort lenses MOVED here from
18
18
  // hall-render.js rather than being copied — the leaderboard row was their only
19
19
  // caller, so the home page is left carrying no dead renderer.
20
20
  (function () {
@@ -43,23 +43,36 @@
43
43
  return '<span class="rank-badge ' + variant + '" aria-label="' + esc(aria) + '">' + esc(rankLabel(rank)) + '</span>';
44
44
  }
45
45
 
46
- var ACH_EMOJI = {
47
- 'first-ship': '\u{1F6A2}',
48
- 'first-claim': '\u{1FA99}',
49
- 'first-parallel-claim': '⚓',
50
- 'streak-3': '\u{1F525}',
51
- 'streak-7': '\u{1F30B}',
52
- 'art-pipeline-shipper': '\u{1F3A8}',
53
- 'gds-shipper': '⚙️',
54
- 'breaking-the-100c-bar': '\u{1F4AF}',
46
+ // Each laurel's emoji AND its catalog name (the achievements table's `name`,
47
+ // migrations 006/019/031/core_270), so a hover and a screen reader say what the
48
+ // badge is called (task 1004532) — never the id, and never words derived from it,
49
+ // which disagree with the catalog the moment a badge is renamed.
50
+ // tests/craft_achievements.mjs holds every row to the catalog.
51
+ var ACH = {
52
+ 'first-ship': ['\u{1F6A2}', 'First Ship'],
53
+ 'first-claim': ['\u{1FA99}', 'First Claim'],
54
+ 'first-parallel-claim': ['⚓', 'First Parallel Claim'],
55
+ 'streak-3': ['\u{1F525}', 'Streak: 3 Nights'],
56
+ 'streak-7': ['\u{1F30B}', 'Streak: 7 Nights'],
57
+ // task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
58
+ 'art-pipeline-shipper': ['\u{1F6E0}\u{FE0F}', 'Art Pipeline Engineer'],
59
+ 'subtractor': ['\u{1FA93}', 'Subtractor'],
60
+ 'gds-shipper': ['⚙️', 'Bongos Shipper'],
61
+ 'breaking-the-100c-bar': ['\u{1F4AF}', 'Breaking the 100c Bar'],
62
+ 'first-thought-ignited': ['\u{1F4A1}', 'First Thought Ignited'],
63
+ 'first-full-idea': ['\u{1F4DC}', 'First Full Idea'],
64
+ 'first-board-ratification': ['\u{1F3DB}\u{FE0F}', 'Ratified by the Board'],
65
+ 'thought-built-ten': ['\u{1F31F}', 'A Thought That Built Ten'],
66
+ 'first-page-approved': ['\u{1F3A8}', 'First Page Approved'],
67
+ 'ten-pages-approved': ['\u{1F5BC}\u{FE0F}', 'Ten Pages Approved'],
68
+ 'whole-surface-tweaked': ['\u{1F5FA}\u{FE0F}', 'A Whole Surface'],
55
69
  };
56
70
  function achRowHtml(ids) {
57
71
  if (!Array.isArray(ids) || !ids.length) return '';
58
72
  var inner = ids.map(function (id) {
59
- var emoji = ACH_EMOJI[id];
60
- // Title is the achievement id (kebab) — terse enough that hover discloses
61
- // the laurel without overstating the prose.
62
- return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
73
+ var a = ACH[id];
74
+ // The title is the badge's catalog name: what a hover shows and a screen reader says.
75
+ return a ? '<span class="lb-ach" title="' + esc(a[1]) + '" aria-label="' + esc(a[1]) + '">' + a[0] + '</span>' : '';
63
76
  }).join('');
64
77
  return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
65
78
  }
@@ -94,9 +107,34 @@
94
107
  return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
95
108
  }
96
109
 
110
+ // task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
111
+ // list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
112
+ // main role first, then every craft the builder has EARNED in — evidence of
113
+ // work, not only what they declared — and `credits_by_craft` is the ledger
114
+ // split. Owner decision (b): a column naming them AND the role toggle above,
115
+ // which ranks by the credits earned in that craft. The figure a role ranks by
116
+ // is in the row (ADR 0255: a public list is ordered only by what it publishes).
117
+ var CRAFTS = ['engineer', 'artist', 'ideator'];
118
+ var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
119
+ var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
120
+ function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
121
+ function craftCredits(b, craft) {
122
+ var by = b.roles && b.roles.credits_by_craft;
123
+ return by ? (Number(by[craft]) || 0) : 0;
124
+ }
125
+ function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
126
+ function rolesHtml(b) {
127
+ var shown = shownOf(b);
128
+ if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
129
+ return '<span class="lb-roles">' + shown.map(function (c, i) {
130
+ return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
131
+ }).join('') + '</span>';
132
+ }
133
+
97
134
  var all = [];
98
135
  var query = '';
99
136
  var sortBy = '';
137
+ var role = '';
100
138
 
101
139
  // PLACE IS CANONICAL, not the row's position in the current sort.
102
140
  // hall-render's leaderboardPlace() already defines a builder's place as
@@ -108,6 +146,16 @@
108
146
  rows.slice()
109
147
  .sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
110
148
  .forEach(function (b, i) { b.__place = i + 1; });
149
+ // A role's place is the same idea inside that role: credits earned in the
150
+ // craft, descending, among the builders it is shown for. Stamped once too,
151
+ // so the place under a role toggle does not renumber under a search.
152
+ rows.forEach(function (b) { b.__placeBy = {}; });
153
+ CRAFTS.forEach(function (c) {
154
+ rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
155
+ .map(function (b, i) { return { b: b, i: i }; })
156
+ .sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
157
+ .forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
158
+ });
111
159
  // The efficiency title is stamped here too, so the division and the string
112
160
  // work happen ONCE per load rather than per visible row per render (every
113
161
  // sort, search keystroke and page turn re-renders).
@@ -116,6 +164,7 @@
116
164
  }
117
165
 
118
166
  function matches(b) {
167
+ if (role && shownOf(b).indexOf(role) === -1) return false;
119
168
  if (!query) return true;
120
169
  var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
121
170
  return hay.indexOf(query) !== -1;
@@ -130,7 +179,8 @@
130
179
  tokens: function (b) { return -(Number(b.tokens_used) || 0); },
131
180
  };
132
181
  function ordered(rows) {
133
- var key = SORTS[sortBy];
182
+ // The default lens is credits; under a role it is the credits that role earned.
183
+ var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
134
184
  if (!key) return rows;
135
185
  return rows.map(function (b, i) { return { b: b, i: i }; })
136
186
  .sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
@@ -140,7 +190,14 @@
140
190
  var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
141
191
  var ledger = null;
142
192
 
193
+ // Tokens only mean something for AI sessions. A builder who works in the
194
+ // browser (an artist in the studio, an ideator in the room) records none, and
195
+ // a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
196
+ // words instead, and the tokens-per-credit figure is offered only where there
197
+ // are tokens to divide.
198
+ function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
143
199
  function efficiencyTitle(b) {
200
+ if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
144
201
  var credits = Number(b.total_credits) || 0;
145
202
  var one = currencyLabel().toLowerCase().replace(/s$/, '');
146
203
  if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
@@ -168,20 +225,69 @@
168
225
  }
169
226
 
170
227
  function rowHtml(b) {
228
+ var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
229
+ var tokens = hasTokens(b)
230
+ ? esc(fmtInt(b.tokens_used))
231
+ : '<span class="lb-none" aria-label="No AI sessions">—</span>';
171
232
  return '<tr>'
172
- + '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
233
+ + '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
173
234
  + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
235
+ + '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
174
236
  + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
175
- + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
237
+ + '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
176
238
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
177
- + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + esc(fmtInt(b.tokens_used)) + '</td>'
239
+ + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
178
240
  + '</tr>';
179
241
  }
180
242
 
243
+ function columns() {
244
+ return ['Place', 'Builder', 'Roles', 'Rank',
245
+ { label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
246
+ { label: 'Karma', class: 'ledger__num' },
247
+ { label: 'Tokens', class: 'ledger__num' }];
248
+ }
249
+
250
+ function makeRosterLedger() {
251
+ body.textContent = '';
252
+ body.className = '';
253
+ ledger = kit.makeLedger({
254
+ mount: body,
255
+ columns: columns(),
256
+ rowHtml: rowHtml,
257
+ emptyMsg: role
258
+ ? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
259
+ : 'No builder on the roster matches that search.',
260
+ pager: pager,
261
+ });
262
+ }
263
+
181
264
  function rerender() {
182
265
  if (ledger) ledger.render(ordered(all.filter(matches)));
183
266
  }
184
267
 
268
+ // The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
269
+ // chip ranks by the credits that craft earned and shows only the builders it
270
+ // is shown for. The ledger is rebuilt so the credits header names the craft.
271
+ function roleChipsHtml() {
272
+ return ROLE_CHIPS.map(function (c) {
273
+ return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
274
+ }).join('');
275
+ }
276
+ function mountRoleChips() {
277
+ var mount = document.getElementById('roster-roles');
278
+ if (!mount) return;
279
+ mount.innerHTML = roleChipsHtml();
280
+ mount.addEventListener('click', function (e) {
281
+ var btn = e.target.closest('[data-role]');
282
+ if (!btn || btn.dataset.role === role) return;
283
+ role = btn.dataset.role;
284
+ mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
285
+ pager.page = 1;
286
+ makeRosterLedger();
287
+ rerender();
288
+ });
289
+ }
290
+
185
291
  function notice(msg) {
186
292
  body.innerHTML = '';
187
293
  body.className = 'note';
@@ -221,23 +327,16 @@
221
327
  rerender();
222
328
  },
223
329
  });
224
- body.textContent = '';
225
- body.className = '';
226
- ledger = kit.makeLedger({
227
- mount: body,
228
- columns: ['Place', 'Builder', 'Rank',
229
- { label: currencyLabel(), class: 'ledger__num' },
230
- { label: 'Karma', class: 'ledger__num' },
231
- { label: 'Tokens', class: 'ledger__num' }],
232
- rowHtml: rowHtml,
233
- emptyMsg: 'No builder on the roster matches that search.',
234
- pager: pager,
235
- });
330
+ mountRoleChips();
331
+ makeRosterLedger();
236
332
  rerender();
237
333
  }
238
334
 
239
335
  var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
240
- fetch('/api/bongos/leaderboard', { headers: { Accept: 'application/json' } })
336
+ // The PUBLIC list (task 1004437): the same rows the authed /leaderboard
337
+ // carries plus each builder's roles, and the one the status page reads, so
338
+ // the hall and the public page cannot rank a role differently.
339
+ fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
241
340
  .then(function (r) {
242
341
  if (!r.ok) throw new Error('http ' + r.status);
243
342
  return r.json();
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/leaderboard.json carries thirteen builders with avatars, ranks, karma and tokens).",
2
+ "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/public__leaderboard.json, since task 1004437 the list the page reads: fifteen builders with avatars, ranks, karma, tokens and each one's roles, including an artist and an ideator with no AI sessions).",
3
3
  "page": "/builders/roster",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -44,6 +44,31 @@
44
44
  ]
45
45
  }
46
46
  },
47
+ "artists": {
48
+ "_": "The role toggle with Artists pressed (task 1004437, owner decision (b)): only the builders shown as artists, ranked by the credits that craft earned, the credits header naming the craft. Against the harness the browser-only artist (2001) leads, with a dash where an AI builder's tokens would be. The harness serves public__leaderboard.json, which carries the roles.",
49
+ "auth": true,
50
+ "actions": [
51
+ [
52
+ "wait",
53
+ 700
54
+ ],
55
+ [
56
+ "click",
57
+ "[data-role=\"artist\"]"
58
+ ],
59
+ [
60
+ "wait",
61
+ 200
62
+ ]
63
+ ],
64
+ "expect": {
65
+ "visible": [
66
+ "#roster-scroll",
67
+ "#roster-roles",
68
+ ".lb-roles"
69
+ ]
70
+ }
71
+ },
47
72
  "search": {
48
73
  "auth": true,
49
74
  "actions": [
@@ -8,3 +8,23 @@
8
8
  - **It is deliberately UNGATED,** and `tests/hall_page_gate_map.mjs` spells that out rather than defaulting. Its one data source, `GET /leaderboard`, is deliberately unauthenticated and mirrors the public status surface; a page must not be gated tighter than the API it fronts, and it was already reachable to a stranger as a view on the ungated index. No visibility predicate is applied and that is correct — privacy suppresses cross-project surfaces only, so a `private` builder still appears on the roster of a project they build on (ADR 0225).
9
9
 
10
10
  Pinned by `tests/hall_nav.mjs` (the Community group's middle id is `roster`) and `tests/hall_page_gate_map.mjs`. Measured through `roster.states.json` — whose `out` state renders the REAL page, since the page is ungated.
11
+
12
+ ## The roles (task 1004437, WA6.RP07)
13
+
14
+ Owner decision (b), 2026-09-30: the leaderboard gets BOTH a column naming each builder's roles and the Everyone / Engineers / Artists / Ideators toggle above it. The approved mock is `docs/design/mocks/profile-roles/` (`?state=board`, `?state=board-artists`).
15
+
16
+ - **The roster now reads `GET /public/leaderboard`,** the list the status page reads, which carries each row's `roles` (`src/bongos/role-stats.js` `readRolesForBuilders`: the profile's own main/shown rules, two reads for the whole page) and, since this task, `karma`. The hall and the public page cannot rank a role differently. The authed `/leaderboard` is unchanged and no longer read by the hall.
17
+ - **Roles column:** one pill per role in `roles.shown`, the main one a step stronger. `shown` is the main role plus every craft the builder has EARNED in, so it is evidence of work, not only a declared preference.
18
+ - **The toggle** keeps the builders a craft is shown for and ranks them by the credits that craft earned (`roles.credits_by_craft`, in the row: ADR 0255). The place column becomes the place inside that craft, the credits header says "Credits as artist", and the karma/rank/tokens lenses still apply on top.
19
+ - **Tokens only where they apply.** A builder with no recorded AI session (an artist in the studio, an ideator in the room) reads a dash, and the cell's tooltip says no AI sessions were recorded, instead of a 0 and a "0 tokens per credit" figure that read as "did nothing".
20
+ - **Home's Standing card** reads the same list and adds "#N as an artist" beside "#N on the leaderboard" when the place inside the builder's main role differs from the overall one (`hall-render.js` `rolePlaceText`).
21
+ - **The status page** names each builder's roles under their name, on that page's own palette (`status.js` `renderRoles`).
22
+
23
+ Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js over the harness fixture with the toggle clicked, the Standing card's place and the status renderer, run). The `artists` state in `roster.states.json` renders the toggle pressed.
24
+
25
+ ### Deliberate differences from the mock (RP07)
26
+
27
+ - **No "Best known for" column.** Each row's headline count (works shipped, pages approved, thoughts ignited) is a per-builder read in three different modules; the list would pay three more reads for every page of builders. The profile, one click away, leads with exactly that number.
28
+ - **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
29
+ - **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
30
+ - **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
@@ -944,6 +944,29 @@ async function runPromotionSweep(deps = {}) {
944
944
  }
945
945
  }
946
946
 
947
+ // The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
948
+ // migration cannot backfill, "A Whole Surface", needs the page inventory, which is
949
+ // a file the database never sees. Run ONCE per process start, on the first tick,
950
+ // through the reward port (economy is optional; no port, no sweep). Pure DB and
951
+ // idempotent, so a restart that runs it again unlocks nothing twice; a failure is
952
+ // logged and never touches the other sweeps.
953
+ let craftBackfillDone = false;
954
+ async function runCraftAchievementBackfill(deps = {}) {
955
+ const { logFn, errFn } = loggers(deps);
956
+ if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
957
+ craftBackfillDone = true;
958
+ const reward = deps.reward || seams.resolveOptional('reward');
959
+ if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
960
+ try {
961
+ const out = await reward.backfillCraftAchievements({});
962
+ if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
963
+ return out;
964
+ } catch (e) {
965
+ errFn(`[publish-reconciler] craft achievement backfill (non-blocking): ${e && e.message}`);
966
+ return { skipped: 'error', error: e && e.message };
967
+ }
968
+ }
969
+
947
970
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
948
971
  // by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
949
972
  // default whenever a push credential is configured; the route's getPublishStatus
@@ -964,6 +987,7 @@ function start(deps = {}) {
964
987
  runCostSweep(deps).catch(crashHandler('cost sweep'));
965
988
  runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
966
989
  runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
990
+ runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
967
991
  };
968
992
 
969
993
  firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
@@ -1002,6 +1026,7 @@ module.exports = {
1002
1026
  runCostSweep,
1003
1027
  runPromotionSweep,
1004
1028
  runCriterionSweep,
1029
+ runCraftAchievementBackfill,
1005
1030
  couldBeStaleGenerated,
1006
1031
  describeTarget,
1007
1032
  INTERVAL_MS,
@@ -1076,19 +1076,31 @@ function updateHeadlineShippedFromItems() {
1076
1076
 
1077
1077
  // ---------- leaderboard ----------
1078
1078
 
1079
- // Set is locked at 8 for V2 (per limitations/pms-v2.md), so hardcoding the
1080
- // emoji map here is cheaper than another API round-trip per page load.
1081
- // Mirrors public-builders/builders.js — keep both in sync if the catalog
1082
- // ever shifts (would require an ADR + version bump per the V2 contract).
1083
- const ACH_EMOJI = {
1084
- 'first-ship': '🚢',
1085
- 'first-claim': '🪙',
1086
- 'first-parallel-claim': '⚓',
1087
- 'streak-3': '🔥',
1088
- 'streak-7': '🌋',
1089
- 'art-pipeline-shipper': '🎨',
1090
- 'gds-shipper': '⚙️',
1091
- 'breaking-the-100c-bar': '💯',
1079
+ // The catalog is small and changes only by migration (the latest: the seven
1080
+ // craft achievements of task 1004438, migrations/core_270), so hardcoding it here
1081
+ // is cheaper than another API round-trip per page load. Each laurel carries its
1082
+ // emoji AND its catalog name (task 1004532), so a hover and a screen reader say
1083
+ // what the badge is called — never the id, and never words derived from it.
1084
+ // Mirrors modules/hall-ui/public/roster.js ACH — tests/craft_achievements.mjs
1085
+ // holds both maps to the catalog's ids, icons and names.
1086
+ const ACH = {
1087
+ 'first-ship': ['🚢', 'First Ship'],
1088
+ 'first-claim': ['🪙', 'First Claim'],
1089
+ 'first-parallel-claim': ['⚓', 'First Parallel Claim'],
1090
+ 'streak-3': ['🔥', 'Streak: 3 Nights'],
1091
+ 'streak-7': ['🌋', 'Streak: 7 Nights'],
1092
+ // task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
1093
+ 'art-pipeline-shipper': ['🛠️', 'Art Pipeline Engineer'],
1094
+ 'subtractor': ['🪓', 'Subtractor'],
1095
+ 'gds-shipper': ['⚙️', 'Bongos Shipper'],
1096
+ 'breaking-the-100c-bar': ['💯', 'Breaking the 100c Bar'],
1097
+ 'first-thought-ignited': ['💡', 'First Thought Ignited'],
1098
+ 'first-full-idea': ['📜', 'First Full Idea'],
1099
+ 'first-board-ratification': ['🏛️', 'Ratified by the Board'],
1100
+ 'thought-built-ten': ['🌟', 'A Thought That Built Ten'],
1101
+ 'first-page-approved': ['🎨', 'First Page Approved'],
1102
+ 'ten-pages-approved': ['🖼️', 'Ten Pages Approved'],
1103
+ 'whole-surface-tweaked': ['🗺️', 'A Whole Surface'],
1092
1104
  };
1093
1105
 
1094
1106
  function renderAchievementRow(ids) {
@@ -1097,18 +1109,37 @@ function renderAchievementRow(ids) {
1097
1109
  row.className = 'leader__achievements';
1098
1110
  row.setAttribute('aria-label', 'achievements');
1099
1111
  for (const id of ids) {
1100
- const emoji = ACH_EMOJI[id];
1101
- if (!emoji) continue;
1112
+ const a = ACH[id];
1113
+ if (!a) continue;
1102
1114
  const span = document.createElement('span');
1103
1115
  span.className = 'leader__ach';
1104
- span.title = id;
1105
- span.setAttribute('aria-label', id);
1106
- span.textContent = emoji;
1116
+ span.title = a[1];
1117
+ span.setAttribute('aria-label', a[1]);
1118
+ span.textContent = a[0];
1107
1119
  row.appendChild(span);
1108
1120
  }
1109
1121
  return row.children.length > 0 ? row : null;
1110
1122
  }
1111
1123
 
1124
+ // task 1004437 (WA6.RP07): each row names its roles, main first — the crafts
1125
+ // the builder has EARNED in, from the row's own `roles.shown`, not only what
1126
+ // they declared. The same words the hall's roster uses. Text only, never markup.
1127
+ const ROLE_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
1128
+ function renderRoles(roles) {
1129
+ const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => ROLE_WORD[c]) : [];
1130
+ if (!shown.length) return null;
1131
+ const row = document.createElement('span');
1132
+ row.className = 'leader__roles';
1133
+ row.setAttribute('aria-label', 'roles');
1134
+ shown.forEach((c, i) => {
1135
+ const chip = document.createElement('span');
1136
+ chip.className = i === 0 ? 'leader__role leader__role--lead' : 'leader__role';
1137
+ chip.textContent = ROLE_WORD[c];
1138
+ row.appendChild(chip);
1139
+ });
1140
+ return row;
1141
+ }
1142
+
1112
1143
  async function loadLeaderboard() {
1113
1144
  const data = await fetchJson('/api/bongos/public/leaderboard');
1114
1145
  const rows = data.leaderboard || [];
@@ -1138,6 +1169,8 @@ async function loadLeaderboard() {
1138
1169
  login.className = 'leader__login';
1139
1170
  login.textContent = `@${b.github_login}`;
1140
1171
  name.appendChild(login);
1172
+ const roles = renderRoles(b.roles);
1173
+ if (roles) name.appendChild(roles);
1141
1174
  const badges = renderAchievementRow(b.achievements);
1142
1175
  if (badges) name.appendChild(badges);
1143
1176
  const credits = document.createElement('div');
@@ -749,6 +749,30 @@ main {
749
749
  letter-spacing: 0;
750
750
  }
751
751
 
752
+ /* The roles under each builder's name (task 1004437): one small tag per role
753
+ the builder has earned in, the main one a step stronger, as the hall's
754
+ roster and the approved mock draw them, on this page's own palette. */
755
+ .leader__roles {
756
+ display: flex;
757
+ flex-wrap: wrap;
758
+ gap: 4px;
759
+ margin-top: 4px;
760
+ }
761
+ .leader__role {
762
+ display: inline-flex;
763
+ align-items: center;
764
+ min-height: 22px;
765
+ padding: 0 8px;
766
+ border: 1px solid var(--rule-soft);
767
+ font-family: var(--font-body);
768
+ font-size: 0.78rem;
769
+ font-weight: 400;
770
+ letter-spacing: 0;
771
+ color: var(--ink-soft);
772
+ text-transform: none;
773
+ }
774
+ .leader__role--lead { color: var(--ink); border-color: var(--rule); }
775
+
752
776
  /* Inline laurels next to each architect's name. Slightly dimmed so the
753
777
  name still leads the row; emoji glyphs would otherwise punch above the
754
778
  marble palette. PMS-V2 #54 */