@bongos/core 1.19.474 → 1.19.476

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 (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/docs/adr/0229-the-title-is-the-door-and-the-record-exits-to-where-it-was-opened-from.md +38 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/copy-inventory.md +255 -258
  5. package/docs/copy-registry.json +376 -414
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/builders.js +13 -64
  8. package/modules/hall-ui/public/dom-utils.js +2 -1
  9. package/modules/hall-ui/public/goals-page.js +4 -3
  10. package/modules/hall-ui/public/goals-render.js +5 -3
  11. package/modules/hall-ui/public/hall-render.js +10 -142
  12. package/modules/hall-ui/public/hall-widgets.js +6 -16
  13. package/modules/hall-ui/public/index.html +0 -3
  14. package/modules/hall-ui/public/index.states.json +3 -2
  15. package/modules/hall-ui/public/profile.js +1 -1
  16. package/modules/hall-ui/public/roster.html +63 -0
  17. package/modules/hall-ui/public/roster.js +227 -0
  18. package/modules/hall-ui/public/roster.states.json +100 -0
  19. package/modules/hall-ui/public/shell.js +28 -4
  20. package/modules/hall-ui/public/style.css +20 -18
  21. package/modules/hall-ui/public/task-detail.js +16 -4
  22. package/modules/hall-ui/public/task.html +5 -3
  23. package/modules/hall-ui/public/task.js +58 -4
  24. package/modules/hall-ui/public/task.states.json +1 -0
  25. package/modules/hall-ui/public/work.js +10 -11
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/module-api.js +1 -1
  29. package/tests/hall_board_world.mjs +69 -0
  30. package/tests/hall_nav.mjs +36 -1
  31. package/tests/hall_page_gate_map.mjs +8 -0
  32. package/tests/hall_record_world.mjs +83 -4
  33. package/tests/rank_tier_single_source.mjs +8 -1
@@ -1385,5 +1385,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1385
1385
  landed since 1.19.472 with no explicit bump. run 33646919877. (task 1002620)
1386
1386
  1.19.474 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1387
1387
  landed since 1.19.473 with no explicit bump. run 33650008858. (task 1002620)
1388
+ 1.19.475 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1389
+ landed since 1.19.474 with no explicit bump. run 33653632532. (task 1002620)
1390
+ 1.19.476 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1391
+ landed since 1.19.475 with no explicit bump. run 33655706909. (task 1002620)
1388
1392
  ---------------------------------------------------------------------------
1389
1393
  ```
@@ -230,7 +230,7 @@
230
230
 
231
231
  // task 1723: which container a widget mounts into. A widget may declare
232
232
  // `zone` (a fine-grained Home slot), `group` (a Standing rail section, task
233
- // 2174) or `view` (home/standing/leaderboard); all default to the Home main
233
+ // 2174) or `view` (home/standing); all default to the Home main
234
234
  // zone so pre-redesign module widgets still land somewhere visible. Every
235
235
  // container lives INSIDE <main>, so the `main .scroll` sweeps (newcomer gate,
236
236
  // landing hide-all) keep working.
@@ -910,26 +910,12 @@
910
910
  }
911
911
  }
912
912
 
913
- // ---- leaderboard ---------------------------------------------------------
914
-
915
- // Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
916
- // extra API round-trip per page load — the catalog won't shift in V2.
917
-
918
-
919
- // task 2174: OTBKit pager. 25/page is what task 1750 set for the top-100 Roll;
920
- // the per-page select now lets a builder widen it and the choice persists.
921
-
922
- // Stable sort. Server returns rows in drachmae-DESC order, which is also the
923
- // default view; for other modes (rank, karma, drachmae-ASC), re-sort here.
924
- // The original server order is preserved as a tie-breaker so equal-value rows
925
- // don't jitter between renders. Drachmae is the secondary tie-breaker when
926
- // sorting by karma (V3.R26 #246) so the karma toggle still reads sensibly
927
- // when many builders share the same karma value (e.g. zero).
928
-
929
- // Map a sortable column id → the lbState.sortBy key. Adding a new sortable
930
- // column means adding an entry here + a header in hall-widgets.js with id
931
- // `lb-sort-<key>`. The handler walks aria-sort across all sortable headers so
932
- // only one column shows its arrow at a time.
913
+ // ---- the Roll’s rows, for the Standing card -----------------------------
914
+ //
915
+ // task 1003441 moved the leaderboard TABLE to its own page (/roster,
916
+ // roster.js): the pager, the sort lenses, LB_SORT_HEADERS and the achievement
917
+ // laurels went with it. Only the place lookup below stays — it needs the same
918
+ // rows, and the Standing card that shows it is on THIS page.
933
919
 
934
920
  // #757: this builder's place on the Roll, ranked by drachmae (total_credits)
935
921
  // descending — the canonical leaderboard order, independent of whatever column
@@ -939,28 +925,6 @@
939
925
  // builder isn't on the roll). Matched by github_login (rows carry no id).
940
926
 
941
927
 
942
- // Cycle: pick column → desc → asc → back to default (drachmae desc).
943
- // For a click on the active column we just flip direction; for a click on
944
- // a different column we activate it at 'desc' (the natural "best first"
945
- // direction for all three columns). Clicking the same column a third time
946
- // returns to the default drachmae-desc — discoverable, no dead state.
947
- function onLbSortClick(key) {
948
- if (lbState.sortBy === key) {
949
- if (lbState.sortDir === 'desc') {
950
- lbState.sortDir = 'asc';
951
- } else {
952
- // Third click returns to the default view.
953
- lbState.sortBy = 'drachmae';
954
- lbState.sortDir = 'desc';
955
- }
956
- } else {
957
- lbState.sortBy = key;
958
- lbState.sortDir = 'desc';
959
- }
960
- lbPager.page = 1;
961
- renderLeaderboard();
962
- }
963
-
964
928
  // ---- onboarding live-tick poll (task 1444) -------------------------------
965
929
  //
966
930
  // The onboarding checklist is rendered once from the page-load /me snapshot.
@@ -1160,14 +1124,15 @@
1160
1124
 
1161
1125
  if (lb.status === 'fulfilled') {
1162
1126
  lbState.rows = lb.value.leaderboard || [];
1163
- lbPager.page = 1;
1164
- renderLeaderboard();
1165
1127
  // #757: the Standing card painted before the Roll loaded, so re-render it
1166
1128
  // now that lbState.rows exists — this is when its "#N on the Roll" stat
1167
1129
  // can resolve.
1168
1130
  if (lastMeState && lastMeState.builder) renderProfile(lastMeState.builder);
1169
1131
  } else {
1170
- $('#leaderboard tbody').innerHTML = emptyRow(5, 'The leaderboard could not be loaded.');
1132
+ // The Roll’s rows only feed the Standing card’s "#N" stat now — the table
1133
+ // itself is /roster (task 1003441) — so a failed fetch degrades that one
1134
+ // stat rather than blanking a panel this page no longer has.
1135
+ lbState.rows = [];
1171
1136
  }
1172
1137
  }
1173
1138
 
@@ -1241,25 +1206,9 @@
1241
1206
  // task 2174: the kit wires prev/next/per-page for both tables against the
1242
1207
  // #ships-*/#lb-* ids its own pagerBarHtml() stamped in hall-widgets.js.
1243
1208
  window.OTBKit.wirePager(shipsPager, renderRecentShips);
1244
- window.OTBKit.wirePager(lbPager, renderLeaderboard);
1209
+
1245
1210
  window.OTBKit.wirePager(acclaimPager, renderAcclaimRows);
1246
1211
 
1247
- // Sortable leaderboard headers — clickable + keyboard-operable. Each
1248
- // sortable column registers under LB_SORT_HEADERS; the click handler
1249
- // routes the column key into the shared cycle.
1250
- for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
1251
- const head = document.getElementById(headerId);
1252
- if (!head) continue;
1253
- head.setAttribute('tabindex', '0');
1254
- head.setAttribute('role', 'button');
1255
- head.addEventListener('click', () => onLbSortClick(key));
1256
- head.addEventListener('keydown', (e) => {
1257
- if (e.key === 'Enter' || e.key === ' ') {
1258
- e.preventDefault();
1259
- onLbSortClick(key);
1260
- }
1261
- });
1262
- }
1263
1212
 
1264
1213
  // Sign-out is wired centrally in shell.js (doLogout + a delegated click
1265
1214
  // listener on the sign-out anchors), so it runs the real POST -> cookie-clear
@@ -1365,7 +1314,7 @@
1365
1314
  // reads what this page holds NOW rather than what it held at boot. Everything
1366
1315
  // else is stable for the life of the page. This call sits AFTER every
1367
1316
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1368
- const { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1317
+ const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1369
1318
  API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1370
1319
  get instanceRepo() { return instanceRepo; },
1371
1320
  get lastCostState() { return lastCostState; },
@@ -153,7 +153,8 @@
153
153
  // third-party widget can't blank the hall (the structural id/label/mount
154
154
  // contract below still throws — that one is first-party programmer error).
155
155
  const HALL_ZONES = ['home-banner', 'home-stats', 'home-band', 'home-main'];
156
- const HALL_VIEWS = ['home', 'standing', 'leaderboard'];
156
+ // task 1003441: 'leaderboard' left — it is a page (/roster) now, not a view.
157
+ const HALL_VIEWS = ['home', 'standing'];
157
158
 
158
159
  const _hallWidgets = [];
159
160
  function contributeHallWidget(widget) {
@@ -1067,10 +1067,11 @@
1067
1067
  if (copyBtn) { copyPromptForTask(copyBtn.dataset.copyTask, copyBtn); return; }
1068
1068
  const releaseBtn = e.target.closest('[data-release-claim]');
1069
1069
  if (releaseBtn) { e.stopPropagation(); doGoalRelease(releaseBtn.dataset.releaseClaim, releaseBtn.dataset.releaseTask, releaseBtn); return; }
1070
- const openBtn = e.target.closest('.task__open');
1071
- if (openBtn && openBtn.dataset.taskId && window.OTBTaskDetail) { window.OTBTaskDetail.open(openBtn.dataset.taskId); return; }
1070
+ // task 1003468: the title is the way into the record — a real link the
1071
+ // browser follows (it carries where it was opened from); let it be.
1072
+ if (e.target.closest('.task__link')) return;
1072
1073
  // Click elsewhere: a ready card toggles its selection; a read-only (active /
1073
- // backlog) card opens the detail overlay (board affordance, work.js:1268-1271).
1074
+ // backlog) card opens the record (the board's affordance, work.js).
1074
1075
  const card = e.target.closest('.task');
1075
1076
  if (!card || !card.dataset.taskId) return;
1076
1077
  if (card.classList.contains('task--readonly')) {
@@ -47,6 +47,9 @@
47
47
  };
48
48
  const taskSettled = (t) => window.OTBBranding?.taskSettled?.(t) ?? (!!t && (t.status === 'confirmed' || t.status === 'shipped'));
49
49
  const creditVal = (t) => Number(window.OTBBranding?.taskCreditValue?.(t) ?? t?.credits_reward) || 0;
50
+ // task 1003468 — the record link a card's title carries; it names where it
51
+ // was opened from so the record can offer the way back (task-detail.js).
52
+ const taskHref = (id) => window.OTBTaskDetail.href(id);
50
53
  function statusBadge(status) {
51
54
  const cls = status === 'achieved' ? 'goal-badge--achieved'
52
55
  : (status === 'archived' ? 'goal-badge--archived' : 'goal-badge--open');
@@ -327,7 +330,7 @@
327
330
  ? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
328
331
  : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
329
332
  // Actions: ready → Claim + copy; mine-active → Release; other's active →
330
- // "claimed · @who"; anything else → nothing (open via the ⤢ button / card).
333
+ // "claimed · @who"; anything else → nothing (open via the title / card).
331
334
  let actions = '';
332
335
  if (isReady) {
333
336
  actions = `<button class="task__claim" type="button" data-claim-task="${escapeHtml(id)}" aria-label="Claim task ${escapeHtml(id)}">Claim</button>`
@@ -343,10 +346,9 @@
343
346
  <div class="task__id">
344
347
  ${lead}
345
348
  <span>#${escapeHtml(id)}</span>
346
- <button class="task__open" type="button" data-task-id="${escapeHtml(id)}" title="Open task details" aria-label="Open details for task ${escapeHtml(id)}">⤢</button>
347
349
  </div>
348
350
  <div class="task__body">
349
- <p class="task__title">${escapeHtml(t.title || '')}</p>
351
+ <p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(id))}">${escapeHtml(t.title || '')}</a></p>
350
352
  <div class="task__meta">
351
353
  <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
352
354
  <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
@@ -3,7 +3,7 @@
3
3
  // Carved out of builders.js by task 1003206 (goal 1000079, the R03 de-monolith
4
4
  // plan): that file was 2,558 lines, the largest first-party file left over the
5
5
  // 1,500-line ratchet. This holds the view painters — landing, profile, welcome,
6
- // onboarding, recent ships, the AI-dev curve, the leaderboard, the nav and hall
6
+ // onboarding, recent ships, the AI-dev curve, the nav and hall
7
7
  // widgets — plus the helpers and view state only they touch.
8
8
  //
9
9
  // WHY A FACTORY, and not a flat namespace like goals-lib.js. goals-lib.js can be
@@ -85,10 +85,13 @@
85
85
  `</span>` +
86
86
  `</span>`;
87
87
  }
88
+ // Mirrors dom-utils.js HALL_VIEWS. task 1003441 dropped the `leaderboard`
89
+ // key with the view: no widget can declare view:’leaderboard’ any more, so
90
+ // widgetHost() could never have read it, and index.html no longer carries the
91
+ // #zone-leaderboard mount it named.
88
92
  const VIEW_CONTAINERS = {
89
93
  home: 'zone-home-main',
90
94
  standing: 'zone-standing',
91
- leaderboard: 'zone-leaderboard',
92
95
  };
93
96
  const STANDING_GROUPS = [
94
97
  { key: 'work', label: 'Work' },
@@ -1099,99 +1102,10 @@
1099
1102
  kindsEl.innerHTML = '';
1100
1103
  }
1101
1104
  }
1102
- const ACH_EMOJI = {
1103
- 'first-ship': '🚢',
1104
- 'first-claim': '🪙',
1105
- 'first-parallel-claim': '⚓',
1106
- 'streak-3': '🔥',
1107
- 'streak-7': '🌋',
1108
- 'art-pipeline-shipper': '🎨',
1109
- 'gds-shipper': '⚙️',
1110
- 'breaking-the-100c-bar': '💯',
1111
- };
1112
- function renderAchievementRow(ids) {
1113
- if (!Array.isArray(ids) || ids.length === 0) return '';
1114
- const inner = ids
1115
- .map((id) => {
1116
- const emoji = ACH_EMOJI[id];
1117
- if (!emoji) return '';
1118
- // Title attribute is the achievement id (kebab) — terse enough that hover
1119
- // discloses the laurel without overstating the prose.
1120
- return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
1121
- })
1122
- .join('');
1123
- return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
1124
- }
1125
- const lbState = {
1126
- rows: [],
1127
- sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
1128
- sortDir: 'desc', // 'desc' | 'asc'
1129
- };
1130
- const lbPager = window.OTBKit.makePager('lb', 'otb.standing.leaderboardPerPage', { defaultPerPage: 25 });
1131
- function sortedLeaderboardRows() {
1132
- const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
1133
- const dir = lbState.sortDir === 'asc' ? 1 : -1;
1134
- if (lbState.sortBy === 'rank') {
1135
- return indexed
1136
- .sort((a, b) => {
1137
- const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
1138
- return diff !== 0 ? diff : a.idx - b.idx;
1139
- })
1140
- .map(({ row }) => row);
1141
- }
1142
- if (lbState.sortBy === 'karma') {
1143
- return indexed
1144
- .sort((a, b) => {
1145
- const ak = Number(a.row.karma) || 0;
1146
- const bk = Number(b.row.karma) || 0;
1147
- const diff = (ak - bk) * dir;
1148
- if (diff !== 0) return diff;
1149
- // Tie-break on drachmae DESC, then stable on server order.
1150
- const ac = Number(a.row.total_credits) || 0;
1151
- const bc = Number(b.row.total_credits) || 0;
1152
- if (ac !== bc) return bc - ac;
1153
- return a.idx - b.idx;
1154
- })
1155
- .map(({ row }) => row);
1156
- }
1157
- if (lbState.sortBy === 'tokens') {
1158
- // task 1473: sort by total tokens used. tokens_used arrives as a string
1159
- // (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
1160
- // matching the karma branch.
1161
- return indexed
1162
- .sort((a, b) => {
1163
- const at = Number(a.row.tokens_used) || 0;
1164
- const bt = Number(b.row.tokens_used) || 0;
1165
- const diff = (at - bt) * dir;
1166
- if (diff !== 0) return diff;
1167
- const ac = Number(a.row.total_credits) || 0;
1168
- const bc = Number(b.row.total_credits) || 0;
1169
- if (ac !== bc) return bc - ac;
1170
- return a.idx - b.idx;
1171
- })
1172
- .map(({ row }) => row);
1173
- }
1174
- if (lbState.sortBy === 'drachmae') {
1175
- // Server already DESCs by drachmae, so the default mode is a no-op
1176
- // copy. The ASC branch flips it.
1177
- if (lbState.sortDir === 'desc') return lbState.rows.slice();
1178
- return indexed
1179
- .sort((a, b) => {
1180
- const ac = Number(a.row.total_credits) || 0;
1181
- const bc = Number(b.row.total_credits) || 0;
1182
- const diff = (ac - bc) * dir;
1183
- return diff !== 0 ? diff : a.idx - b.idx;
1184
- })
1185
- .map(({ row }) => row);
1186
- }
1187
- return lbState.rows.slice();
1188
- }
1189
- const LB_SORT_HEADERS = {
1190
- rank: 'lb-sort-rank',
1191
- drachmae: 'lb-sort-drachmae',
1192
- karma: 'lb-sort-karma',
1193
- tokens: 'lb-sort-tokens',
1194
- };
1105
+ // The Roll’s rows, kept ONLY for leaderboardPlace() below — the Standing
1106
+ // card’s "#N on the leaderboard" stat. sortBy/sortDir went to /roster with
1107
+ // the table (task 1003441); nothing on this page sorts them any more.
1108
+ const lbState = { rows: [] };
1195
1109
  function leaderboardPlace(login) {
1196
1110
  const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
1197
1111
  if (!rows.length || !login) return null;
@@ -1201,53 +1115,7 @@
1201
1115
  const idx = ranked.findIndex((r) => r.github_login === login);
1202
1116
  return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1203
1117
  }
1204
- function renderLeaderboard() {
1205
- const rows = sortedLeaderboardRows();
1206
- // paginate() clamps the page, slices, and paints the whole #lb-* bar.
1207
- const slice = window.OTBKit.paginate(rows, lbPager);
1208
- const start = (lbPager.page - 1) * lbPager.perPage;
1209
- const tbody = $('#leaderboard tbody');
1210
-
1211
- if (!slice.length) {
1212
- tbody.innerHTML = emptyRow(6, 'No more builders to show.');
1213
- } else {
1214
- tbody.innerHTML = slice.map((b, i) => {
1215
- const place = start + i + 1;
1216
- const name = b.display_name || b.github_login || 'unknown builder';
1217
- const badges = renderAchievementRow(b.achievements);
1218
- const rank = renderRankBadge(normalizeRank(b.rank));
1219
- // task 1467: link the name to the builder's public profile page. Guarded
1220
- // on b.id (the /leaderboard row carries it) so a row without an id degrades
1221
- // to plain text rather than a broken /profile?id=undefined link.
1222
- const nameCell = b.id != null
1223
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
1224
- : escapeHtml(name);
1225
- return `
1226
- <tr>
1227
- <td class="ledger__num">${place}</td>
1228
- <td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
1229
- <td class="ledger__rank">${rank}</td>
1230
- <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
1231
- <td class="ledger__drachmae">${fmtNum(b.karma)}</td>
1232
- <td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
1233
- </tr>
1234
- `;
1235
- }).join('');
1236
- }
1237
-
1238
- // Reflect the active sort on every sortable header. Only one column
1239
- // shows its arrow at a time; the others reset to "none" — matches the
1240
- // ARIA-sort spec.
1241
- for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
1242
- const head = document.getElementById(headerId);
1243
- if (!head) continue;
1244
- const ariaSort = (lbState.sortBy === key && lbState.sortDir)
1245
- ? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
1246
- : 'none';
1247
- head.setAttribute('aria-sort', ariaSort);
1248
- }
1249
- }
1250
1118
 
1251
- return { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1119
+ return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1252
1120
  };
1253
1121
  })();
@@ -3,7 +3,7 @@
3
3
  // Every widget — core here, module-contributed from a module's own client script —
4
4
  // registers into the window.OTB hall-widget registry (dom-utils.js), and builders.js
5
5
  // mounts the registered set into the shell's view containers (task 1723 redesign):
6
- // view: 'home' | 'standing' | 'leaderboard' (default 'home')
6
+ // view: 'home' | 'standing' (default 'home')
7
7
  // zone: a finer mount target inside the Home view
8
8
  // ('home-banner' | 'home-stats' | 'home-band' | 'home-main')
9
9
  // group: a finer mount target inside Standing — which rail section this card
@@ -198,19 +198,9 @@
198
198
  '<div class="ach-grid" id="ach-grid"><div class="profile__placeholder">…</div></div>',
199
199
  { view: 'standing', group: 'achievements', labelledby: 'ach-h' });
200
200
 
201
- /* ---------------- Leaderboard ---------------- */
202
-
203
- // 8. Leaderboard.
204
- w('leaderboard-scroll', 'Leaderboard', 90,
205
- '<h2 id="lb-h" class="sr-only">Leaderboard</h2>' +
206
- '<table class="ledger" id="leaderboard"><thead><tr>' +
207
- '<th>Place</th><th>Builder</th>' +
208
- '<th class="ledger__sortable" id="lb-sort-rank" aria-sort="none" title="Sort by rank">Rank</th>' +
209
- '<th class="ledger__sortable" id="lb-sort-drachmae" aria-sort="descending" title="Sort by ' + curLabel() + ' (work shipped)" data-currency-label="caps">Credits</th>' +
210
- '<th class="ledger__sortable" id="lb-sort-karma" aria-sort="none" title="Sort by karma (peer recognition + approvals)">Karma</th>' +
211
- '<th class="ledger__sortable" id="lb-sort-tokens" aria-sort="none" title="Sort by tokens used across recorded sessions — efficiency is tokens ÷ credits (hover a row for tokens-per-drachma)">Tokens</th>' +
212
- '</tr></thead><tbody><tr><td colspan="6" class="ledger__empty">…</td></tr></tbody></table>' +
213
- // task 2174: OTBKit pager — same #lb-* id prefix as the bar it replaces.
214
- window.OTBKit.pagerBarHtml('lb', { label: 'builders' }),
215
- { view: 'leaderboard', labelledby: 'lb-h' });
201
+ // task 1003441: the Leaderboard widget is GONE from this page. It is a real
202
+ // page now (/roster — roster.html + roster.js), which is what the Community
203
+ // nav group needed: it used to read as one page plus a link into index.html.
204
+ // Nothing here declares view:'leaderboard' any more, and dom-utils.js's
205
+ // HALL_VIEWS no longer accepts it.
216
206
  })();
@@ -71,9 +71,6 @@
71
71
  </div>
72
72
  </section>
73
73
 
74
- <section class="view" id="view-leaderboard" data-view-title="Leaderboard" hidden>
75
- <div class="view-zone" id="zone-leaderboard"></div>
76
- </section>
77
74
  </main>
78
75
 
79
76
  <footer class="app-foot" id="app-foot"></footer>
@@ -7,10 +7,11 @@
7
7
  "_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
8
8
  "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"],
9
9
  "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
10
+ "_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
10
11
  "states": {
11
- "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing", "#view-leaderboard"] } },
12
+ "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
12
13
  "standing": { "auth": true, "url": "/builders/#standing", "actions": [["wait", 400]], "expect": { "visible": ["#view-standing"] } },
13
- "leaderboard": { "auth": true, "url": "/builders/#leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#view-leaderboard"] } },
14
+ "standing-karma": { "auth": true, "url": "/builders/#standing-karma", "actions": [["wait", 400]], "expect": { "visible": ["#view-standing"] } },
14
15
  "out": { "auth": false }
15
16
  },
16
17
  "reducedMotion": { "auth": true }
@@ -555,7 +555,7 @@
555
555
  head.innerHTML = `
556
556
  <div class="profile__placeholder">
557
557
  No builder selected. Open a builder from the
558
- <a href="/#leaderboard">leaderboard</a>, or
558
+ <a href="/roster">roster</a>, or
559
559
  <a href="${API}/auth/web/start">sign in</a> to see your own profile.
560
560
  </div>`;
561
561
  }
@@ -0,0 +1,63 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Builders — {{worldName}}</title>
7
+ <meta name="description" content="Who builds on this project: the roster, ranked by work shipped, with rank, credits, karma and tokens.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- task 1003441: the identity cell (avatar, name, login) is shared with the
13
+ cross-project directory — one definition, both rosters. -->
14
+ <link rel="stylesheet" href="/builders/people.css">
15
+ </head>
16
+ <body data-page="roster" data-page-title="Builders">
17
+
18
+ <div class="app">
19
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
20
+ <div class="app-scrim" id="app-scrim" hidden></div>
21
+ <div class="app-body">
22
+ <header class="app-topbar" id="app-topbar"></header>
23
+ <div class="app-content">
24
+ <!-- The board page head (task 1003307): the label voice. The sub is
25
+ rewritten by roster.js once the roster has loaded. -->
26
+ <div class="page-head page-head--board">
27
+ <h1 class="page-head__h">Builders</h1>
28
+ <p class="page-head__sub" id="roster-sub">Who builds on this project, ranked by work shipped. This project's own ledger — unlike the cross-project directory, it lists everyone who builds here.</p>
29
+ </div>
30
+
31
+ <main id="hall-main">
32
+ <!-- task 1003441: this was the #leaderboard HASH VIEW on the home page,
33
+ which is why the Community group used to read as one real page plus a
34
+ link into index.html. It is a page now; /#leaderboard redirects here. -->
35
+ <section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
36
+ <h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
37
+ <div id="roster-filters"></div>
38
+ <div id="roster-body">
39
+ <div class="profile__placeholder">Loading the roster…</div>
40
+ </div>
41
+ </section>
42
+ </main>
43
+
44
+ <footer class="app-foot" id="app-foot"></footer>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
50
+
51
+ <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
+ chrome exists before the page script runs. -->
53
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
54
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
+ <script src="/builders/palette.js" defer></script>
56
+ <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
57
+ branding pack's currency label, loaded BEFORE roster.js. -->
58
+ <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
59
+ <script src="/builders/brand-apply.js"></script>
60
+ <script src="/builders/hall-kit.js"></script>
61
+ <script src="/builders/roster.js"></script>
62
+ </body>
63
+ </html>