@bongos/core 1.24.42 → 1.24.44

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 (92) hide show
  1. package/.bongos-core.json +108 -103
  2. package/docs/copy-inventory.md +115 -165
  3. package/docs/copy-registry.json +230 -781
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/onboarding/primer.md +1 -1
  7. package/docs/page-inventory.json +10 -38
  8. package/docs/page-readings.json +528 -627
  9. package/modules/hall-ui/public/ai-studio.html +4 -4
  10. package/modules/hall-ui/public/atlas.html +4 -4
  11. package/modules/hall-ui/public/blockers.html +4 -4
  12. package/modules/hall-ui/public/board-room.html +4 -4
  13. package/modules/hall-ui/public/builders.js +22 -505
  14. package/modules/hall-ui/public/city.html +4 -4
  15. package/modules/hall-ui/public/collab.html +4 -4
  16. package/modules/hall-ui/public/commands.html +4 -4
  17. package/modules/hall-ui/public/copy-desk.html +4 -4
  18. package/modules/hall-ui/public/craft-mode.js +2 -2
  19. package/modules/hall-ui/public/deploy.html +4 -4
  20. package/modules/hall-ui/public/dom-utils.js +2 -1
  21. package/modules/hall-ui/public/goal-map.html +4 -4
  22. package/modules/hall-ui/public/goals.html +16 -4
  23. package/modules/hall-ui/public/government.html +4 -4
  24. package/modules/hall-ui/public/hall-render.js +23 -524
  25. package/modules/hall-ui/public/hall-widgets.js +11 -109
  26. package/modules/hall-ui/public/idea.html +4 -4
  27. package/modules/hall-ui/public/ideas.html +4 -4
  28. package/modules/hall-ui/public/index.html +6 -19
  29. package/modules/hall-ui/public/index.states.json +0 -33
  30. package/modules/hall-ui/public/market-watch-item.html +4 -4
  31. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  32. package/modules/hall-ui/public/market-watch.html +4 -4
  33. package/modules/hall-ui/public/modules.html +4 -4
  34. package/modules/hall-ui/public/money.html +4 -4
  35. package/modules/hall-ui/public/my-agents.html +4 -4
  36. package/modules/hall-ui/public/palette.js +7 -6
  37. package/modules/hall-ui/public/primer.html +4 -4
  38. package/modules/hall-ui/public/profile-own.js +639 -0
  39. package/modules/hall-ui/public/profile.css +2 -1
  40. package/modules/hall-ui/public/profile.html +48 -5
  41. package/modules/hall-ui/public/profile.js +27 -2
  42. package/modules/hall-ui/public/profile.states.json +18 -0
  43. package/modules/hall-ui/public/project-settings.html +4 -4
  44. package/modules/hall-ui/public/roadmap.css +7 -138
  45. package/modules/hall-ui/public/roadmap.js +35 -293
  46. package/modules/hall-ui/public/roster.html +4 -4
  47. package/modules/hall-ui/public/settings.html +4 -4
  48. package/modules/hall-ui/public/shell-trail.js +2 -1
  49. package/modules/hall-ui/public/shell.js +31 -28
  50. package/modules/hall-ui/public/street.html +4 -4
  51. package/modules/hall-ui/public/studio.html +4 -4
  52. package/modules/hall-ui/public/task.html +4 -4
  53. package/modules/hall-ui/public/thinking.html +4 -4
  54. package/modules/hall-ui/public/tweak-editor.html +4 -4
  55. package/modules/hall-ui/public/watch.html +4 -4
  56. package/modules/hall-ui/public/work.html +5 -5
  57. package/modules/onboarding/founding-mode.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +12 -0
  61. package/scripts/bongos/gen-hall-sitemap.js +1 -0
  62. package/src/bongos/serve-internal.js +6 -6
  63. package/src/module-api.js +1 -1
  64. package/tests/craft_mode_nav.mjs +2 -1
  65. package/tests/curve_controls_delegation.mjs +9 -8
  66. package/tests/curve_kind_overlay.mjs +5 -5
  67. package/tests/founding_mode.mjs +5 -3
  68. package/tests/hall_acclaim_vote_control.mjs +5 -3
  69. package/tests/hall_audit.mjs +10 -6
  70. package/tests/hall_board_world.mjs +40 -26
  71. package/tests/hall_builders_page_pass.mjs +14 -5
  72. package/tests/hall_goals_band.mjs +88 -0
  73. package/tests/hall_home_ships_bounded.mjs +10 -21
  74. package/tests/hall_kit_charts.mjs +7 -5
  75. package/tests/hall_loading.mjs +2 -1
  76. package/tests/hall_nav.mjs +41 -39
  77. package/tests/hall_numbers_page.mjs +100 -0
  78. package/tests/hall_page_gate_map.mjs +5 -1
  79. package/tests/hall_palette.mjs +19 -24
  80. package/tests/hall_profile_world.mjs +7 -2
  81. package/tests/hall_reading_room.mjs +3 -2
  82. package/tests/hall_reading_world.mjs +17 -18
  83. package/tests/hall_sitemap.mjs +2 -1
  84. package/tests/helpers.mjs +8 -2
  85. package/tests/leaderboard_roles.mjs +1 -1
  86. package/tests/module_contributions.mjs +6 -4
  87. package/tests/nav_permission_atoms.mjs +4 -2
  88. package/tests/page_tweak_artist.mjs +2 -1
  89. package/tests/rank_tier_single_source.mjs +3 -0
  90. package/tests/task_visuals.mjs +3 -1
  91. package/modules/hall-ui/public/roadmap.html +0 -58
  92. package/modules/hall-ui/public/roadmap.states.json +0 -31
@@ -25,12 +25,11 @@
25
25
  const { moduleOn } = window.OTB;
26
26
 
27
27
  // ---- tiny helpers --------------------------------------------------------
28
- // escapeHtml / $ / fmtNum / fmtDate / rankLabel / toast now come from the
29
- // shared window.OTB toolkit (dom-utils.js, loaded first). Thin wrappers keep
30
- // this file's exact variants (date-only fmtDate; '—' rankLabel empty; a
31
- // 2200ms clearing toast). renderRankBadge + RANK_ORDER stay local below.
28
+ // escapeHtml / $ / fmtNum / rankLabel / toast now come from the shared
29
+ // window.OTB toolkit (dom-utils.js, loaded first). Thin wrappers keep this
30
+ // file's exact variants ('—' rankLabel empty; a 2200ms clearing toast).
31
+ // renderRankBadge + RANK_ORDER stay local below.
32
32
  const { escapeHtml, $, fmtNum } = window.OTB;
33
- const fmtDate = (iso) => window.OTB.fmtDate(iso);
34
33
  const rankLabel = (rank) => window.OTB.rankLabel(rank);
35
34
  const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
36
35
 
@@ -64,16 +63,6 @@
64
63
  return res.data;
65
64
  }
66
65
 
67
- async function patchJSON(path, body) {
68
- const res = await api.request('PATCH', path, { body });
69
- if (res.status === 401) {
70
- window.location.assign(`${API}/auth/web/start`);
71
- throw new Error('redirecting to auth');
72
- }
73
- if (!res.ok) throw new Error(`${path} → ${res.status}`);
74
- return res.data;
75
- }
76
-
77
66
  async function postJSON(path, body) {
78
67
  const res = await api.request('POST', path, { body });
79
68
  if (res.status === 401) {
@@ -104,27 +93,8 @@
104
93
  return res.data;
105
94
  }
106
95
 
107
- // ---- shared render helpers (C10) ----------------------------------------
108
-
109
- // emptyRow — the placeholder/error <tr> every ledger table renders when it has
110
- // nothing (or failed) to show. `message` is plain copy (escaped defensively
111
- // even though current callers pass static literals).
112
- function emptyRow(colspan, message) {
113
- return `<tr><td colspan="${colspan}" class="ledger__empty">${escapeHtml(message)}</td></tr>`;
114
- }
115
-
116
- // signClasses — the up/down/flat BEM modifier for a signed number. `prefix` is
117
- // the block, e.g. signClasses(delta, 'karma-feed__delta') → 'karma-feed__delta--up'.
118
- // The +/- TEXT differs per feed (fmtNum vs raw int vs '±0'), so callers format
119
- // the displayed value themselves.
120
- function signClasses(n, prefix) {
121
- const v = Number(n) || 0;
122
- return `${prefix}--${v > 0 ? 'up' : v < 0 ? 'down' : 'flat'}`;
123
- }
124
-
125
- // (task 2174: the hand-rolled renderPager the ships + roster tables shared is
126
- // gone — both now use the OTBKit pager, which clamps, persists per-page and
127
- // paints the bar from one implementation.)
96
+ // (task 1005050: emptyRow and signClasses went to profile-own.js with the
97
+ // ledgers and the karma feed that used them; nothing on Home draws one.)
128
98
 
129
99
  // ---- repo info (the clone URL the setup screen hands a newcomer) ---
130
100
  //
@@ -222,26 +192,12 @@
222
192
  // per-widget gates (archonOnly) carried on the registry entry.
223
193
 
224
194
  // task 1723: which container a widget mounts into. A widget may declare
225
- // `zone` (a fine-grained Home slot), `group` (a Standing rail section, task
226
- // 2174) or `view` (home/standing); all default to the Home main
227
- // zone so pre-redesign module widgets still land somewhere visible. Every
195
+ // `zone` (a fine-grained Home slot) or `view` (only 'home' since task 1005050
196
+ // folded the Standing view into the Profile page); both default to the Home
197
+ // main zone so pre-redesign module widgets still land somewhere visible. Every
228
198
  // container lives INSIDE <main>, so the `main .scroll` sweeps (newcomer gate,
229
199
  // landing hide-all) keep working.
230
200
 
231
- // task 2174: Standing is an identity band + four rail sections grouped by the
232
- // question they answer. This array is the rail's order and its labels; each
233
- // entry gets a #standing-<key> anchor container inside #zone-standing that the
234
- // group's widgets mount into. The Profile card declares no group, so it stays
235
- // above them as the band.
236
-
237
- // Create ALL four group containers the first time any group is asked for, so
238
- // the rail's order is STANDING_GROUPS' — not whichever group's first widget
239
- // happens to register earliest. Creating them on demand (rather than up front)
240
- // is what keeps the Profile band on top: an ungrouped Standing widget appends
241
- // straight to #zone-standing, so anything registered before the first grouped
242
- // widget lands above the groups. Idempotent.
243
-
244
-
245
201
  // mountHallWidgets — stamp every registered widget's <section> into its view
246
202
  // container (falling back to <main>), in registry order, then render its
247
203
  // skeleton via mount(host). Idempotent: a second call (defensive) re-uses an
@@ -257,14 +213,9 @@
257
213
  // The budgets themselves become a CI fitness assertion in task 2177.
258
214
 
259
215
 
260
- // task 2174: the Standing rail is an OTBKit sectionRail in scrollspy mode —
261
- // the kit owns the IntersectionObserver that used to live here as
262
- // setupNavObserver(). Built lazily on the first renderNav and refreshed after,
263
- // so the same instance survives re-renders.
264
- // The rail (#standing-rail) has one entry per GROUP that currently has
265
- // something to show, not one per widget. A group with every member hidden
266
- // (a newcomer, an empty karma feed) hides itself and drops off the rail.
267
- // Keeps its (builder, onboarding) signature — all call sites pass them.
216
+ // (The Standing view's section rail lived in renderNav until task 1005050
217
+ // folded the view into the Profile page.) renderNav keeps its (builder,
218
+ // onboarding) signature — all call sites pass them.
268
219
 
269
220
  // #748: newcomer gate. A Xenos sees a deliberately spare hall — their
270
221
  // onboarding path and their Standing card — until they graduate to Thetes.
@@ -315,358 +266,9 @@
315
266
  // renderDiscordLink / unlinkDiscord (F7, ADR 0033) moved to
316
267
  // public-builders/settings.js — these controls now live on /builders/settings.
317
268
 
318
- async function saveDisciplines() {
319
- const selected = Array.from(document.querySelectorAll('.disc-pref'))
320
- .filter((el) => el.checked)
321
- .map((el) => el.value);
322
- const btn = $('#disc-save');
323
- if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
324
- try {
325
- const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
326
- renderProfile(resp.builder);
327
- toast(selected.length ? `Preferred work: ${selected.join(', ')}` : 'Preference cleared.');
328
- } catch (err) {
329
- if (err && err.message === 'redirecting to auth') return;
330
- console.error('[builders] save disciplines failed', err);
331
- toast('Could not save your preference. Try again.');
332
- if (btn) { btn.disabled = false; btn.textContent = 'Save'; }
333
- }
334
- }
335
-
336
- // ---- achievements --------------------------------------------------------
337
-
338
- function renderAchievements(all, unlocked, lockedWithProgress) {
339
- const unlockedById = new Map(unlocked.map((u) => [u.achievement_id, u]));
340
- // Map achievement_id → progress_text for locked entries. Falsy/empty
341
- // progress just shows the credit-bonus line as before.
342
- const progressById = new Map(
343
- (lockedWithProgress || []).map((l) => [l.achievement_id, l.progress_text || '']),
344
- );
345
- const grid = $('#ach-grid');
346
- if (!all.length) {
347
- grid.innerHTML = `<div class="profile__placeholder">No achievements earned yet.</div>`;
348
- return;
349
- }
350
- grid.innerHTML = all.map((a) => {
351
- const got = unlockedById.get(a.id);
352
- const cls = got ? 'ach' : 'ach ach--locked';
353
- const bonus = a.credit_bonus ? `<p class="ach__bonus">+${fmtNum(a.credit_bonus)} ${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</p>` : '';
354
- const when = got ? `<p class="ach__bonus">Won ${fmtDate(got.unlocked_at)}</p>` : '';
355
- const progress = !got && progressById.has(a.id) && progressById.get(a.id)
356
- ? `<p class="ach__progress">${escapeHtml(progressById.get(a.id))}</p>`
357
- : '';
358
- return `
359
- <div class="${cls}" title="${escapeHtml(a.id)}">
360
- <div class="ach__icon" aria-hidden="true">${escapeHtml(a.icon || '✦')}</div>
361
- <div class="ach__body">
362
- <p class="ach__name">${escapeHtml(a.name)}</p>
363
- <p class="ach__desc">${escapeHtml(a.description || '')}</p>
364
- ${when || bonus}
365
- ${progress}
366
- </div>
367
- </div>
368
- `;
369
- }).join('');
370
- }
371
-
372
- // ---- recent ships --------------------------------------------------------
373
-
374
- // All shipped works for this builder, fetched once via ?limit=all then
375
- // filtered, sorted, and paged entirely client-side. Mirrors the status site's
376
- // Lately Inscribed pattern (modules/status-ui/public/status.js renderShippedPage), with
377
- // an additional version filter since a builder's history spans versions.
378
- // task 2174: paging moved to the OTBKit pager (per-page select + persistence);
379
- // 10 stays the default page size this table has always used.
380
-
381
-
382
- // task 1957 — a shipped row's payout is the ACTUAL ledger figure (credits_awarded),
383
- // not the creation-time estimate. taskCreditValue picks the right one by status.
384
-
385
-
386
-
387
- // Re-render when filters change. Reset to page 1 so the new filter starts at
388
- // the top — otherwise switching to a smaller window can leave you on page 9
389
- // of a 2-page result.
390
- function onShipsFilterChange() {
391
- shipsPager.page = 1;
392
- renderRecentShips();
393
- }
394
-
395
- // ---- recent grades (task 197) -------------------------------------------
396
-
397
- function renderRecentGrades(grades) {
398
- const tbody = $('#recent-grades tbody');
399
- if (!tbody) return;
400
- if (!grades || !grades.length) {
401
- tbody.innerHTML = emptyRow(5, 'No work reviewed yet.');
402
- return;
403
- }
404
- tbody.innerHTML = grades.map((g) => {
405
- // A grader OUTAGE row (panel_outcome 'unavailable') carries a placeholder
406
- // score of 0 — label it, never render it as a 0.0 fail (task 1002652).
407
- const isOutage = g.panel_outcome === 'unavailable';
408
- // A json-null gating axis (task 1003318 — the Quality worker never ran)
409
- // is likewise not a judgment: the score is the surviving axis, not a fail.
410
- const isLensOut = !isOutage && !!g.rubric_scores && g.rubric_scores.code_quality === null;
411
- const score = isOutage || isLensOut ? '—' : Number(g.score).toFixed(1);
412
- // A verdict is verification state: it wears the fixed vocabulary through
413
- // .ledger__verdict--* (style.css), never an inline colour. The literals
414
- // that stood here painted `passed` from a --olive that is not a token of
415
- // the world, so every hall ever rendered it as the fallback (task 1003495).
416
- const verdict = isOutage
417
- ? `<span class="ledger__verdict--none">not graded (grader outage)</span>`
418
- : isLensOut
419
- ? `<span class="ledger__verdict--none">not graded (quality lens unavailable)</span>`
420
- : g.passed
421
- ? `<span class="ledger__verdict--ok">passed</span>`
422
- : `<span class="ledger__verdict--danger">fell short</span>`;
423
- return `
424
- <tr>
425
- <td class="ledger__num">#${g.task_id}</td>
426
- <td>${escapeHtml(g.title || '')}</td>
427
- <td class="ledger__drachmae">${isOutage || isLensOut ? '—' : `${score} / 10`}</td>
428
- <td>${verdict}</td>
429
- <td class="ledger__date">${fmtDate(g.graded_at)}</td>
430
- </tr>
431
- `;
432
- }).join('');
433
- }
434
-
435
- function renderRollingNote(rolling) {
436
- const note = $('#grades-rolling-note');
437
- if (!note) return;
438
- if (!rolling || !rolling.n || Number(rolling.n) === 0) {
439
- note.hidden = true;
440
- return;
441
- }
442
- const avg = Number(rolling.avg_score).toFixed(1);
443
- const passed = Number(rolling.n_passed || 0);
444
- const n = Number(rolling.n);
445
- note.textContent = `Last 14 days: ${avg} / 10 average across ${n} graded work${n === 1 ? '' : 's'} (${passed} passed).`;
446
- note.hidden = false;
447
- }
448
-
449
- // ---- the dev curve (V3.R72 #291, C5) ------------------------------------
450
- //
451
- // Per-builder historical analytics: ship rate, cost-per-task, duration, and
452
- // grade pass-rate over time, plus a top-kinds fingerprint. Charts are inline
453
- // SVG sparklines — no chart library (the task spec's explicit constraint).
454
- // An Archon additionally gets a "You / All builders" scope toggle backed by
455
- // the ecosystem aggregate (individuals stripped), satisfying the
456
- // cross-builder view requirement without a per-builder roster on this page.
457
-
458
-
459
- // Build a tiny SVG line sparkline from an array of numeric y-values. Returns
460
- // a self-contained <svg> string. A flat/short series degrades to a baseline.
461
-
462
-
463
-
464
- const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
465
- const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
466
- const mins = (v) => (v == null ? '—' : `${Math.round(Number(v))}m`);
467
-
468
- // Render the curve for whichever scope is active. `data` is either the self
469
- // payload (/analytics/builder/:id) or the ecosystem payload, normalized below.
470
-
471
- // Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
472
- // the ecosystem aggregate) and render. A failed/empty feed simply leaves the
473
- // section hidden rather than holding up the rest of the hall.
474
- async function loadAnalytics(builder) {
475
- const scroll = $('#analytics-scroll');
476
- if (!scroll || !builder?.id) return;
477
- analyticsState.builderId = builder.id;
478
- analyticsState.scope = 'self';
479
- analyticsState.kind = null;
480
- try {
481
- const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
482
- analyticsState.self = self;
483
- // The picker's options come from THIS first, unfiltered load and are then
484
- // left alone — see kindFilterHtml. A later filtered payload's top_kinds is
485
- // deliberately not allowed to overwrite them.
486
- analyticsState.kinds = Array.isArray(self?.top_kinds) ? self.top_kinds : [];
487
- // A gated newcomer with nothing shipped: keep the section hidden.
488
- if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
489
- scroll.hidden = false;
490
- renderCurveBody();
491
- renderNav(builder, lastMeState?.onboarding || null); // re-show the Curve chip now that the section is visible
492
- // THE BENCHMARK IS FOR EVERY BUILDER, NOT JUST THE ARCHON (task 1004005).
493
- // This fetch used to sit behind `if (analyticsState.isArchon)`, so the
494
- // ecosystem average a builder is meant to measure themselves against was
495
- // fetched for exactly one of them. The route is `rank: public` — no auth
496
- // middleware, CORS '*', per-builder identity stripped server-side — and the
497
- // unauthenticated status page already reads it, so the gate protected
498
- // nothing and only withheld the comparison. Still fire-and-forget: the
499
- // ecosystem feed is optional context, never a reason to hold up the curve.
500
- getJSON(`${API}/public/analytics/ecosystem?days=90`)
501
- // ALWAYS re-render, not only in the 'eco' scope. That condition was
502
- // correct while the two scopes merely SWAPPED which single series was
503
- // drawn; now the ecosystem series is also the benchmark overlaid on the
504
- // 'self' view, so a self-scoped reader who arrived before this resolved
505
- // would sit with no overlay until they touched an unrelated control.
506
- .then((eco) => { analyticsState.eco = eco; renderCurveBody(); })
507
- .catch(() => { /* ecosystem optional */ });
508
- } catch (err) {
509
- console.error('[builders] analytics load failed', err);
510
- scroll.hidden = true;
511
- }
512
- }
513
-
514
- // Re-fetch BOTH series under a kind filter (task 1004005). Both, always: an
515
- // overlay that narrowed one side and not the other would compare one kind of
516
- // work against all work and still look like a like-for-like benchmark — the
517
- // failure this task's own text calls worse than no benchmark at all.
518
- //
519
- // `kinds` is deliberately not refreshed here; see kindFilterHtml.
520
- let curveKindSeq = 0;
521
- async function reloadCurveForKind(kind) {
522
- const id = analyticsState.builderId;
523
- if (!id) return;
524
- analyticsState.kind = kind;
525
- // A dropdown can be changed faster than a round trip returns, and the two
526
- // fetches can land out of order. Only the newest request may write state.
527
- const seq = ++curveKindSeq;
528
- const q = kind ? `&kind=${encodeURIComponent(kind)}` : '';
529
- renderCurveBody(); // reflect the new selection immediately
530
- try {
531
- const [self, eco] = await Promise.all([
532
- getJSON(`${API}/analytics/builder/${id}?days=90${q}`),
533
- getJSON(`${API}/public/analytics/ecosystem?days=90${q}`).catch(() => null),
534
- ]);
535
- if (seq !== curveKindSeq) return;
536
- analyticsState.self = self;
537
- if (eco) analyticsState.eco = eco;
538
- renderCurveBody();
539
- } catch (err) {
540
- if (seq !== curveKindSeq) return;
541
- console.error('[builders] curve kind filter failed', err);
542
- }
543
- }
544
-
545
- // ---- karma feed (V3.R26 #246) -------------------------------------------
546
- //
547
- // The recent karma_log entries for the current builder. The /me payload
548
- // already includes them under `karma.recent`. The scroll stays hidden when
549
- // the builder has no history yet (zero karma is a common starting state,
550
- // so we don't want to push an empty table at every new citizen).
551
-
552
- function renderKarmaFeed(entries) {
553
- const scroll = $('#karma-scroll');
554
- const tbody = $('#karma-feed tbody');
555
- if (!scroll || !tbody) return;
556
- if (!Array.isArray(entries) || entries.length === 0) {
557
- // Hide the scroll entirely if the builder has no karma history.
558
- // Keeps the page tidy for fresh citizens; reappears the moment a
559
- // karma_log row lands.
560
- scroll.hidden = true;
561
- return;
562
- }
563
- // #748: a gated newcomer never shows the karma feed, even if karma exists.
564
- scroll.hidden = newcomerGated;
565
- tbody.innerHTML = entries.map((k) => {
566
- const delta = Number(k.delta) || 0;
567
- const deltaCls = signClasses(delta, 'karma-feed__delta');
568
- const deltaText = delta > 0 ? `+${fmtNum(delta)}` : fmtNum(delta);
569
- const granted = k.granted_by
570
- ? `@${escapeHtml(k.granted_by_login || k.granted_by_display_name || `builder #${k.granted_by}`)}`
571
- : '<span style="opacity:0.6;">— all builders —</span>';
572
- return `
573
- <tr>
574
- <td class="ledger__date">${escapeHtml(fmtDate(k.recorded_at))}</td>
575
- <td class="karma-feed__delta ${deltaCls}">${deltaText}</td>
576
- <td>${escapeHtml(k.reason || '')}</td>
577
- <td>${granted}</td>
578
- </tr>
579
- `;
580
- }).join('');
581
- }
582
-
583
- // ---- peer acclaim feed (V3.R25 #245) ------------------------------------
584
- //
585
- // Other builders' recently confirmed/shipped work, each with a +/- vote
586
- // widget. Votes fold into the shipping builder's karma on a weekly tally,
587
- // lagged 7 days (server-enforced). Metic+ may vote; the endpoint returns
588
- // can_vote so Xenos see the tallies read-only. The endpoint already excludes
589
- // the caller's own ships (anti-self-vote), so there's nothing to vote on that
590
- // isn't a peer's work.
591
-
592
- let acclaimCanVote = false;
593
- // task 1002446: this was the hall's last unpaged list. The endpoint returns the
594
- // whole feed in one call, so the rows are held here and the kit's client-side
595
- // pager slices them — a page change re-renders without a refetch.
596
- const acclaimState = { tasks: [] };
597
- const acclaimPager = window.OTBKit.makePager('acclaim', 'otb.standing.acclaimPerPage', { defaultPerPage: 10 });
598
-
599
- function renderAcclaimFeed(data) {
600
- const scroll = $('#acclaim-scroll');
601
- if (!scroll) return;
602
- const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
603
- acclaimState.tasks = tasks;
604
- acclaimCanVote = !!(data && data.can_vote);
605
- if (tasks.length === 0) {
606
- // Nothing to acclaim yet (e.g. the only confirmed work so far is your
607
- // own). Hide rather than show an empty ledger.
608
- scroll.hidden = true;
609
- return;
610
- }
611
- // #748: a gated newcomer never shows the acclaim feed.
612
- scroll.hidden = newcomerGated;
613
- // task 2174: this feed lands after loadAll built the rail, and it can be the
614
- // only visible member of the Karma group (an empty karma feed hides itself).
615
- // Re-run renderNav so the group — and its rail entry — appear.
616
- if (lastMeState?.builder) renderNav(lastMeState.builder, lastMeState.onboarding || null);
617
- renderAcclaimRows();
618
- }
619
-
620
- function renderAcclaimRows() {
621
- const tbody = $('#acclaim-feed tbody');
622
- if (!tbody) return;
623
- const slice = window.OTBKit.paginate(acclaimState.tasks, acclaimPager);
624
- tbody.innerHTML = slice.map((t) => {
625
- // task 1004432: an approved page is its artist's; the applying session rides along as a helper.
626
- const helperName = t.helper_display_name || (t.helper_login ? `@${t.helper_login}` : '');
627
- const by = escapeHtml(t.display_name || (t.github_login ? `@${t.github_login}` : `builder #${t.shipped_by}`)) + (helperName ? ` <span class="acclaim__evidence">with help from ${escapeHtml(helperName)}</span>` : '');
628
- // task 1004833: the votes are THE shared list control (vote-control.js),
629
- // pre-painted from the feed's own net_votes/my_vote so it costs no extra
630
- // read. A viewer below the vote floor sees the net alone.
631
- const net = Number(t.net_votes) || 0;
632
- const votes = acclaimCanVote && window.OTBVote
633
- ? window.OTBVote.taskHtml(t)
634
- : `<span class="vote__net" title="Metic rank and above may vote">${net > 0 ? `+${net}` : `${net}`}</span>`;
635
- const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
636
- return `
637
- <tr>
638
- <td>${title}${acclaimEvidenceHtml(t)}</td>
639
- <td>${by}</td>
640
- <td>${votes}</td>
641
- </tr>
642
- `;
643
- }).join('');
644
- }
645
-
646
- async function loadAcclaimFeed() {
647
- try {
648
- const data = await getJSON(`${API}/tasks/peer-votes/feed?limit=25`);
649
- renderAcclaimFeed(data);
650
- } catch (err) {
651
- // Non-fatal: a feed failure shouldn't take down the hall.
652
- console.warn('[hall] acclaim feed failed:', err.message);
653
- const scroll = $('#acclaim-scroll');
654
- if (scroll) scroll.hidden = true;
655
- }
656
- }
657
-
658
- // task 1004833: the shared vote control casts the vote and repaints its own
659
- // row; it then raises 'otb-vote' with the server's answer. The feed is paged,
660
- // so the vote must land in the HELD rows too (task 1002446), or flipping to
661
- // page 2 and back re-renders the stale tally over the one just cast.
662
- function onAcclaimVote(ev) {
663
- const d = ev && ev.detail;
664
- if (!d || d.kind !== 'task') return;
665
- const taskIdNum = Number(d.id);
666
- acclaimState.tasks = acclaimState.tasks.map((t) => (Number(t.id) === taskIdNum
667
- ? { ...t, net_votes: d.net_votes, my_vote: d.my_vote }
668
- : t));
669
- }
269
+ // (task 1005050: the achievements grid, the ship ledger, reviews, the dev curve
270
+ // and the karma and peer-recognition feeds were the Accomplishments view, which
271
+ // folded into the Profile page: profile-own.js draws the private half there.)
670
272
 
671
273
  // ---- task browser (removed #750) -----------------------------------------
672
274
  // The claimable-works list now lives solely on the /work Work Board (#748);
@@ -799,20 +401,6 @@
799
401
  // #731: the CLI-token re-issue card (V3.R91 #401) and the Discord connect/
800
402
  // link card (F7, ADR 0033) moved to /builders/settings — the hall's Standing
801
403
  // card is identity-only now.
802
- renderRecentGrades(me.grades?.recent || []);
803
- renderRollingNote(me.grades?.rolling || null);
804
- // V3.R72 (#291, C5): the dev curve. Loaded fire-and-forget — a slow or
805
- // empty analytics feed must not hold up the rest of the hall, and the
806
- // section un-hides itself (and re-renders the nav) only when there's data.
807
- loadAnalytics(me.builder);
808
- // V3.R26 (#246): karma feed — the recent karma_log entries powering the
809
- // "why did my karma move" answer. The /me payload bundles this so the
810
- // page doesn't need a follow-up call.
811
- renderKarmaFeed(me.karma?.recent || []);
812
- // V3.R25 (#245): peer acclaim feed — a separate endpoint (not bundled in
813
- // /me because it spans all builders' work), loaded fire-and-forget so a
814
- // slow/failed feed can't hold up the rest of the hall.
815
- loadAcclaimFeed();
816
404
  // #408: build the sticky nav now that every section has settled its
817
405
  // hidden state (karma → security/roster).
818
406
  // The renderNav call also wires the IntersectionObserver for active-
@@ -832,41 +420,15 @@
832
420
  // down the page — log and show an inline placeholder.
833
421
  // #726: the per-skill model picker (Voices) moved to /builders/settings,
834
422
  // so the hall no longer fetches /me/skill-prefs here.
835
- const [achAll, ships, versionsResp, lb] = await Promise.allSettled([
836
- getJSON(`${API}/achievements`),
837
- // task 1004854: Home's newest few + a count — never the whole history,
838
- // which only the Accomplishments ledger fetches (hall-render.js loadShipLedger).
423
+ const [ships, lb] = await Promise.allSettled([
424
+ // task 1004854: Home's newest few + a count — never the whole history
425
+ // (the Profile page's Works ledger reads that, since task 1005050).
839
426
  getJSON(`${API}/me/recent-ships?limit=${HOME_SHIPS_LIMIT}`),
840
- getJSON(`${API}/versions`),
841
427
  getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
842
428
  ]);
843
429
 
844
- if (achAll.status === 'fulfilled') {
845
- // Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
846
- // for V2, so the slice is defensive.) Unlocked + locked-with-progress come
847
- // straight from the enriched /me payload.
848
- renderAchievements(
849
- (achAll.value.achievements || []).slice(0, 10),
850
- me.unlocked || [],
851
- me.locked_with_progress || [],
852
- );
853
- } else {
854
- $('#ach-grid').innerHTML = `<div class="profile__placeholder">Achievements could not be loaded.</div>`;
855
- }
856
-
857
- if (ships.status === 'fulfilled') {
858
- setRecentShips(ships.value);
859
- } else {
860
- $('#recent-ships tbody').innerHTML = emptyRow(4, 'Your work history could not be loaded.');
861
- }
862
-
863
- if (versionsResp.status === 'fulfilled') {
864
- const versions = versionsResp.value.versions || [];
865
- // Populate the Works Wrought version filter — limited to versions this
866
- // builder has actually shipped to, so the dropdown can't offer empty
867
- // options. (The old claimable-works version filter moved to /work, #750.)
868
- populateShipVersionFilter(versions);
869
- }
430
+ // Only the Standing chip's shipped count reads this; a failure leaves its dash.
431
+ if (ships.status === 'fulfilled') setRecentShips(ships.value);
870
432
 
871
433
  if (lb.status === 'fulfilled') {
872
434
  lbState.rows = lb.value.leaderboard || [];
@@ -883,51 +445,6 @@
883
445
  }
884
446
 
885
447
  function wireEvents() {
886
- // #profile-body is re-rendered after load, so delegate the save click.
887
- $('#profile-body').addEventListener('click', (e) => {
888
- if (e.target.closest('#disc-save')) saveDisciplines();
889
- });
890
-
891
- // V3.R72 (#291): the dev-curve scope toggle. Delegated because
892
- // renderCurveBody replaces the toggle's innerHTML — and from the whole
893
- // Progress section, NOT #curve-body: the controls render into
894
- // #curve-scope-note in the section's lede, a sibling of #curve-body, so a
895
- // listener there never heard them (task 1004079).
896
- $('#analytics-scroll')?.addEventListener('click', (e) => {
897
- const btn = e.target.closest('.curve__scope-btn[data-scope]');
898
- if (!btn) return;
899
- const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
900
- if (scope === analyticsState.scope) return;
901
- analyticsState.scope = scope;
902
- renderCurveBody();
903
- });
904
-
905
- // The kind filter (task 1004005). Delegated like the scope toggle, and for
906
- // the same reason: renderCurveBody replaces the control's innerHTML, so a
907
- // listener bound to the element itself would not survive the first render.
908
- // 'change', not 'input' — a keyboard user arrowing through a native select
909
- // fires input per option, and each one would be a pair of round trips.
910
- $('#analytics-scroll')?.addEventListener('change', (e) => {
911
- const sel = e.target.closest('#curve-kind');
912
- if (!sel) return;
913
- const kind = sel.value ? sel.value : null;
914
- if (kind === analyticsState.kind) return;
915
- reloadCurveForKind(kind);
916
- });
917
-
918
- // task 1004833: the shared vote control reports each landed vote to the feed.
919
- $('#acclaim-feed')?.addEventListener('otb-vote', onAcclaimVote);
920
-
921
- $('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
922
- $('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
923
- $('#f-ships-sort')?.addEventListener('change', renderRecentShips);
924
- // task 2174: the kit wires prev/next/per-page for both tables against the
925
- // #ships-*/#lb-* ids its own pagerBarHtml() stamped in hall-widgets.js.
926
- window.OTBKit.wirePager(shipsPager, renderRecentShips);
927
-
928
- window.OTBKit.wirePager(acclaimPager, renderAcclaimRows);
929
-
930
-
931
448
  // Sign-out is wired centrally in shell.js (doLogout + a delegated click
932
449
  // listener on the sign-out anchors), so it runs the real POST -> cookie-clear
933
450
  // -> bounce-home on EVERY hall page, not only this index. It used to live
@@ -1032,8 +549,8 @@
1032
549
  // reads what this page holds NOW rather than what it held at boot. Everything
1033
550
  // else is stable for the life of the page. This call sits AFTER every
1034
551
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1035
- const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderProfile, renderRecentShips, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager } = window.OTBHallRender({
1036
- API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
552
+ const { lbState, mountHallWidgets, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderProfile, HOME_SHIPS_LIMIT, setRecentShips } = window.OTBHallRender({
553
+ API, RANK_ORDER, api, brandWorld, curLabel, normalizeRank, postJSON, rankLabel, rankWeight, toast,
1037
554
  get lastCostState() { return lastCostState; },
1038
555
  get lastKarmaState() { return lastKarmaState; },
1039
556
  get lastMeState() { return lastMeState; },
@@ -105,10 +105,10 @@
105
105
  </div>
106
106
  </div>
107
107
 
108
- <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
109
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab" defer></script>
110
- <script src="/builders/craft-mode.js?v=2026-11-05-oversight" defer></script>
111
- <script src="/builders/shell.js?v=2026-11-05-setup-gate" defer></script>
108
+ <script src="/builders/dom-utils.js?v=2026-11-05-numbers"></script>
109
+ <script src="/builders/shell-trail.js?v=2026-11-05-numbers" defer></script>
110
+ <script src="/builders/craft-mode.js?v=2026-11-05-goals" defer></script>
111
+ <script src="/builders/shell.js?v=2026-11-05-goals" defer></script>
112
112
  <script src="/builders/palette.js" defer></script>
113
113
  <script src="/builders/gov-living.js?v=2026-10-09-positions1" defer></script>
114
114
  <script src="/builders/gov-feeds.js?v=2026-10-09-living3" defer></script>
@@ -159,10 +159,10 @@
159
159
 
160
160
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
161
161
 
162
- <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
163
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
164
- <script src="/builders/craft-mode.js?v=2026-11-05-oversight"></script>
165
- <script src="/builders/shell.js?v=2026-11-05-setup-gate"></script>
162
+ <script src="/builders/dom-utils.js?v=2026-11-05-numbers"></script>
163
+ <script src="/builders/shell-trail.js?v=2026-11-05-numbers"></script>
164
+ <script src="/builders/craft-mode.js?v=2026-11-05-goals"></script>
165
+ <script src="/builders/shell.js?v=2026-11-05-goals"></script>
166
166
  <script src="/builders/palette.js" defer></script>
167
167
  <script src="/builders/collab-lib.js?v=2026-09-24-archive"></script>
168
168
  <script src="/builders/collab.js?v=2026-09-24-archive"></script>
@@ -51,10 +51,10 @@
51
51
 
52
52
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
53
53
 
54
- <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
55
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
56
- <script src="/builders/craft-mode.js?v=2026-11-05-oversight"></script>
57
- <script src="/builders/shell.js?v=2026-11-05-setup-gate"></script>
54
+ <script src="/builders/dom-utils.js?v=2026-11-05-numbers"></script>
55
+ <script src="/builders/shell-trail.js?v=2026-11-05-numbers"></script>
56
+ <script src="/builders/craft-mode.js?v=2026-11-05-goals"></script>
57
+ <script src="/builders/shell.js?v=2026-11-05-goals"></script>
58
58
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
59
59
  <script src="/builders/palette.js" defer></script>
60
60
  <script src="/builders/hall-kit.js"></script>