@bongos/core 1.24.41 → 1.24.43

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 (90) hide show
  1. package/.bongos-core.json +117 -92
  2. package/docs/copy-inventory.md +98 -137
  3. package/docs/copy-registry.json +244 -644
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/onboarding/primer.md +1 -1
  6. package/docs/page-inventory.json +3 -3
  7. package/docs/page-readings.json +494 -535
  8. package/modules/hall-ui/public/ai-studio.html +4 -4
  9. package/modules/hall-ui/public/atlas.html +4 -4
  10. package/modules/hall-ui/public/blockers.html +4 -4
  11. package/modules/hall-ui/public/board-room.html +4 -4
  12. package/modules/hall-ui/public/builders.js +35 -763
  13. package/modules/hall-ui/public/city.html +4 -4
  14. package/modules/hall-ui/public/collab.html +4 -4
  15. package/modules/hall-ui/public/commands.html +4 -4
  16. package/modules/hall-ui/public/copy-desk.html +4 -4
  17. package/modules/hall-ui/public/craft-mode.js +1 -1
  18. package/modules/hall-ui/public/deploy.html +4 -4
  19. package/modules/hall-ui/public/dom-utils.js +2 -1
  20. package/modules/hall-ui/public/goal-map.html +4 -4
  21. package/modules/hall-ui/public/goals.html +4 -4
  22. package/modules/hall-ui/public/government.html +4 -4
  23. package/modules/hall-ui/public/hall-render.js +25 -771
  24. package/modules/hall-ui/public/hall-widgets.js +14 -123
  25. package/modules/hall-ui/public/idea.html +4 -4
  26. package/modules/hall-ui/public/ideas.html +4 -4
  27. package/modules/hall-ui/public/index.html +8 -22
  28. package/modules/hall-ui/public/index.states.json +0 -33
  29. package/modules/hall-ui/public/market-watch-item.html +4 -4
  30. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  31. package/modules/hall-ui/public/market-watch.html +4 -4
  32. package/modules/hall-ui/public/modules.html +4 -4
  33. package/modules/hall-ui/public/money.html +4 -4
  34. package/modules/hall-ui/public/my-agents.html +4 -4
  35. package/modules/hall-ui/public/palette.js +7 -6
  36. package/modules/hall-ui/public/primer.html +4 -4
  37. package/modules/hall-ui/public/profile-own.js +639 -0
  38. package/modules/hall-ui/public/profile.css +2 -1
  39. package/modules/hall-ui/public/profile.html +48 -5
  40. package/modules/hall-ui/public/profile.js +27 -2
  41. package/modules/hall-ui/public/profile.states.json +18 -0
  42. package/modules/hall-ui/public/project-settings.html +4 -4
  43. package/modules/hall-ui/public/roadmap.html +4 -4
  44. package/modules/hall-ui/public/roster.html +4 -4
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/setup-gate.js +156 -0
  47. package/modules/hall-ui/public/shell-trail.js +2 -1
  48. package/modules/hall-ui/public/shell.js +44 -26
  49. package/modules/hall-ui/public/street.html +4 -4
  50. package/modules/hall-ui/public/studio.html +4 -4
  51. package/modules/hall-ui/public/style.css +85 -117
  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 +4 -4
  57. package/modules/hall-ui/records/setup-gate.md +43 -0
  58. package/modules/lifecycle/routes/tasks.js +2 -2
  59. package/modules/onboarding/onboarding-state.js +41 -20
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +12 -0
  63. package/src/bongos/routes/me.js +2 -2
  64. package/src/module-api.js +1 -1
  65. package/tests/curve_controls_delegation.mjs +9 -8
  66. package/tests/curve_kind_overlay.mjs +5 -5
  67. package/tests/hall_acclaim_vote_control.mjs +5 -3
  68. package/tests/hall_audit.mjs +10 -6
  69. package/tests/hall_board_world.mjs +41 -27
  70. package/tests/hall_builders_page_pass.mjs +14 -5
  71. package/tests/hall_home_ships_bounded.mjs +10 -21
  72. package/tests/hall_kit_charts.mjs +3 -2
  73. package/tests/hall_landing_boot.mjs +2 -3
  74. package/tests/hall_loading.mjs +2 -1
  75. package/tests/hall_nav.mjs +36 -37
  76. package/tests/hall_numbers_page.mjs +100 -0
  77. package/tests/hall_onboarding_bongos_setup.mjs +14 -14
  78. package/tests/hall_palette.mjs +19 -24
  79. package/tests/hall_profile_world.mjs +7 -2
  80. package/tests/hall_reading_room.mjs +3 -2
  81. package/tests/helpers.mjs +8 -2
  82. package/tests/leaderboard_roles.mjs +1 -1
  83. package/tests/module_contributions.mjs +10 -6
  84. package/tests/nav_permission_atoms.mjs +4 -2
  85. package/tests/onboarding_state.mjs +33 -0
  86. package/tests/page_tweak_artist.mjs +2 -1
  87. package/tests/rank_tier_single_source.mjs +3 -0
  88. package/tests/setup_flow.mjs +242 -0
  89. package/tests/task_visuals.mjs +3 -1
  90. package/tests/welcome_boot.mjs +3 -2
@@ -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,36 +93,17 @@
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
- // ---- repo info (the clone URL the welcome walkthrough hands a newcomer) ---
99
+ // ---- repo info (the clone URL the setup screen hands a newcomer) ---
130
100
  //
131
101
  // The /builder-* commands live INSIDE the repo (.claude/skills/, shelling out
132
102
  // to scripts/bongos/*.js). The old repo-clone walkthrough (and the
133
103
  // /public/repo-info fetch that fed it) was removed with the setup-first
134
- // redesign (#777); the welcome panel's clone block now reads the instance's
135
- // repo coords instead. The /public/repo-info surface still exists for other
136
- // callers.
104
+ // redesign (#777); the setup screen's clone block (setup-gate.js, task 1005076)
105
+ // reads the instance's repo coords instead. The /public/repo-info surface
106
+ // still exists for other callers.
137
107
 
138
108
  // ---- rank badge (V3.R16 #240) -------------------------------------------
139
109
  // Three live ranks per ADR 0018: archon > metic > xenos. Missing rank
@@ -149,7 +119,7 @@
149
119
  // xenos — #692 / ADR 0034) to the entry rank. Reserved values still on the
150
120
  // citizen/divine ladder would otherwise render as a dashed "unknown" plaque
151
121
  // while the hall elsewhere shows them as xenos. Applied to the
152
- // builder-facing standing surfaces — the welcome panel, the profile Standing
122
+ // builder-facing standing surfaces — the profile Standing
153
123
  // badge, and the public Roll — so they agree. NOT applied to the Archon-only
154
124
  // roster below, which intentionally shows the true stored rank so an Archon
155
125
  // can spot and fix a stray value. hall-render.js's "Where you stand" card
@@ -187,27 +157,10 @@
187
157
  // GATE new claims and whose reward is pending until they land on ship.
188
158
  let lastRebaseState = null;
189
159
 
190
- // Cached /me payload from the most recent loadAll() call. Used by the
191
- // welcome-chip reopen path (#408) so we can re-render the welcome panel
192
- // from cached state without a fresh /me round-trip. Distinct from
193
- // lastKarmaState: that one only stashes karma; this stashes the whole
194
- // payload so renderWelcome's builder + onboarding args are available.
160
+ // Cached /me payload from the most recent loadAll() call, for re-renders
161
+ // that need the builder without a fresh /me round-trip.
195
162
  let lastMeState = null;
196
163
 
197
- // Instance repo coords for the "get the code" clone block. The public instance
198
- // manifest (GET /api/bongos/instance) is the channel for cross-instance repo coords
199
- // — clientBranding deliberately omits `repo`, so window.__BRANDING__ can't carry
200
- // it. Cached; the welcome re-renders when it lands so a fresh instance shows ITS
201
- // OWN repo, never a hardcoded one (ADR 0062 §3, task 1705).
202
- let instanceRepo = null;
203
- async function loadInstanceRepo() {
204
- try {
205
- const inst = await getJSON(`${API}/instance`, { softAuth: true });
206
- instanceRepo = inst && inst.repo && !inst.repo.error ? inst.repo : null;
207
- if (instanceRepo && lastMeState) renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
208
- } catch (_) { /* best-effort — the clone block falls back to generic guidance */ }
209
- }
210
-
211
164
  // ---- hall section nav (#408) --------------------------------------------
212
165
  //
213
166
  // Single source of truth for what appears in the sticky strip at top of
@@ -218,9 +171,6 @@
218
171
  //
219
172
  // Filter rules at render time:
220
173
  // archonOnly: true → skip unless /me returns rank=archon.
221
- // welcomeChip:true → ALWAYS shown pre-graduation (even when the
222
- // panel is dismissed) so the builder can re-open
223
- // it. Hidden post-graduation just like the panel.
224
174
  // default → skip if the section's element is [hidden].
225
175
  //
226
176
  // Adding a new section: add a row here AND give the <section> a stable
@@ -242,31 +192,17 @@
242
192
  // per-widget gates (archonOnly) carried on the registry entry.
243
193
 
244
194
  // task 1723: which container a widget mounts into. A widget may declare
245
- // `zone` (a fine-grained Home slot), `group` (a Standing rail section, task
246
- // 2174) or `view` (home/standing); all default to the Home main
247
- // 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
248
198
  // container lives INSIDE <main>, so the `main .scroll` sweeps (newcomer gate,
249
199
  // landing hide-all) keep working.
250
200
 
251
- // task 2174: Standing is an identity band + four rail sections grouped by the
252
- // question they answer. This array is the rail's order and its labels; each
253
- // entry gets a #standing-<key> anchor container inside #zone-standing that the
254
- // group's widgets mount into. The Profile card declares no group, so it stays
255
- // above them as the band.
256
-
257
- // Create ALL four group containers the first time any group is asked for, so
258
- // the rail's order is STANDING_GROUPS' — not whichever group's first widget
259
- // happens to register earliest. Creating them on demand (rather than up front)
260
- // is what keeps the Profile band on top: an ungrouped Standing widget appends
261
- // straight to #zone-standing, so anything registered before the first grouped
262
- // widget lands above the groups. Idempotent.
263
-
264
-
265
201
  // mountHallWidgets — stamp every registered widget's <section> into its view
266
202
  // container (falling back to <main>), in registry order, then render its
267
203
  // skeleton via mount(host). Idempotent: a second call (defensive) re-uses an
268
204
  // existing <section>. Runs once at bootstrap BEFORE wireEvents() so the static
269
- // element ids the data-render + event-wiring code expects (#welcome-minimize,
205
+ // element ids the data-render + event-wiring code expects (#acclaim-feed,
270
206
  // #recent-ships, #lb-prev, …) are present. Each widget mount is
271
207
  // failure-isolated so one bad widget can't blank the hall.
272
208
  // task 2169: zone budgets — Home cannot grow unbounded again. Caps count
@@ -277,17 +213,9 @@
277
213
  // The budgets themselves become a CI fitness assertion in task 2177.
278
214
 
279
215
 
280
- // task 2174: the Standing rail is an OTBKit sectionRail in scrollspy mode —
281
- // the kit owns the IntersectionObserver that used to live here as
282
- // setupNavObserver(). Built lazily on the first renderNav and refreshed after,
283
- // so the same instance survives re-renders.
284
- // 2. the Home re-open affordance for a dismissed welcome banner (the old
285
- // welcome chip's only job);
286
- // 3. the Standing view's in-page section rail (#standing-rail) — task 2174:
287
- // one entry per GROUP that currently has something to show, not one per
288
- // widget. A group with every member hidden (a newcomer, an empty karma
289
- // feed) hides itself and drops off the rail.
290
- // 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.
291
219
 
292
220
  // #748: newcomer gate. A Xenos sees a deliberately spare hall — their
293
221
  // onboarding path and their Standing card — until they graduate to Thetes.
@@ -309,7 +237,7 @@
309
237
  // (which un-hide their own scroll on data) respect it; the section-hiding
310
238
  // sweep then runs before renderNav so the chip strip matches.
311
239
  const NEWCOMER_VISIBLE_SECTIONS = new Set([
312
- 'welcome-scroll', 'onboarding-scroll', 'profile-scroll',
240
+ 'profile-scroll',
313
241
  ]);
314
242
  let newcomerGated = false;
315
243
 
@@ -325,21 +253,6 @@
325
253
  // gates. This function keeps only the widget-section sweep.
326
254
  }
327
255
 
328
- function reopenWelcomeFromNav(e) {
329
- e.preventDefault();
330
- const id = window.__buildersSelfId;
331
- if (!id || !lastMeState) return;
332
- try {
333
- window.localStorage.removeItem(welcomeDismissalKey(id));
334
- } catch (_) { /* private mode — re-open in-memory only */ }
335
- // Re-render welcome from cached /me state and refresh the nav so the
336
- // chip drops its "dismissed" styling.
337
- renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
338
- renderNav(lastMeState.builder, lastMeState.onboarding || null);
339
- // Now scroll to the (newly-unhidden) welcome panel.
340
- const target = document.getElementById('welcome-scroll');
341
- if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
342
- }
343
256
 
344
257
 
345
258
  // Wires the public landing's access-request form. Submits to the one public
@@ -353,445 +266,9 @@
353
266
  // renderDiscordLink / unlinkDiscord (F7, ADR 0033) moved to
354
267
  // public-builders/settings.js — these controls now live on /builders/settings.
355
268
 
356
- async function saveDisciplines() {
357
- const selected = Array.from(document.querySelectorAll('.disc-pref'))
358
- .filter((el) => el.checked)
359
- .map((el) => el.value);
360
- const btn = $('#disc-save');
361
- if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
362
- try {
363
- const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
364
- renderProfile(resp.builder);
365
- toast(selected.length ? `Preferred work: ${selected.join(', ')}` : 'Preference cleared.');
366
- } catch (err) {
367
- if (err && err.message === 'redirecting to auth') return;
368
- console.error('[builders] save disciplines failed', err);
369
- toast('Could not save your preference. Try again.');
370
- if (btn) { btn.disabled = false; btn.textContent = 'Save'; }
371
- }
372
- }
373
-
374
- // ---- first-visit welcome panel (V3.R77 #296) ----------------------------
375
- // Greets a not-yet-graduated builder with their rank, what their rank
376
- // unlocks, the next checklist step, and the two doors into the hall: the
377
- // orientation maps and the /builder-start command. Dismissable. Reappears
378
- // if the builder's onboarding stage regresses (rare — e.g. a stage row
379
- // wipe in dev or an Archon-driven reset).
380
- //
381
- // Persistence: localStorage key `builders:welcome-dismissed:<builder_id>`
382
- // stores the stage *index* (0-6) at the moment of dismissal. On every
383
- // render we compute the builder's current stage index; if it's strictly
384
- // less than the dismissed-at index, the panel reappears. Per-builder key
385
- // so a shared browser doesn't carry one builder's dismissal to another.
386
- //
387
- // The panel only renders for the LOGGED-IN viewer (uses /me). It is not
388
- // shown on profile pages of other builders — that's the read-only
389
- // onboarding-list panel below, which is roster-visible.
390
-
391
- // Canonical order of the onboarding stages, mirroring
392
- // src/bongos/onboarding-state.js STAGES. 'graduated' is the terminal stage;
393
- // when current === 'graduated' the panel hides entirely.
394
-
395
-
396
-
397
- // task 2169 (banner budget): exactly one banner panel at a time. The
398
- // checklist render runs before the welcome render in the boot pass, so after
399
- // the welcome panel's visibility settles we re-evaluate the checklist —
400
- // welcome visible → checklist waits; welcome dismissed/hidden → checklist
401
- // takes the banner slot (renderOnboarding carries the mirror check).
402
- function renderWelcome(builder, onboarding) {
403
- renderWelcomeCore(builder, onboarding);
404
- renderOnboarding(
405
- onboarding || (lastMeState && lastMeState.onboarding) || null,
406
- lastMeState && lastMeState.discord
407
- );
408
- }
409
-
410
-
411
- // ---- onboarding checklist (V3.R75 #294; reshaped #777) ------------------
412
- // Renders the "Path into the City" checklist for the CURRENT logged-in
413
- // builder. Hidden once graduated (current_stage === 'graduated') so it
414
- // doesn't clutter the hall for full citizens. The displayed list (#777) is a
415
- // short, action-only orientation path: Join Discord → Choose craft → Run
416
- // /builder-setup → Ship three (→ Thetes). Every step AUTO-detects server-side
417
- // (#777, per Lars: no manual ticks). Only two carry a control:
418
- // * pick_disciplines — the craft picker (PATCH /me/disciplines).
419
- // * join_discord — a Connect-Discord button (the existing OAuth flow,
420
- // auto-joins + assigns rank role); clears when linked.
421
- // cli_setup clears once the Bongos CLI is authenticated (db.hasCliSession) and
422
- // ship_three on the 3rd ship — both passive. The terminal ship_three step
423
- // shows the live "N of 3" counter (its `detail`). `discord` is the /me discord
424
- // block ({ linked, configured }) — when Discord isn't configured server-side
425
- // the Connect button is suppressed.
426
-
427
-
428
- // V3 (2026-05-30): clear the pick_disciplines onboarding stage from the path
429
- // checklist. Saves the chosen craft via the same PATCH /me/disciplines route
430
- // the profile uses, then re-renders the checklist (and the profile picker) so
431
- // both reflect the choice. The route echoes the updated onboarding state.
432
- async function pickDiscipline(value) {
433
- // Multi-select toggle: add the craft if not yet chosen, remove it if it is,
434
- // then save the full set (any mix of engineer, artist, ideator).
435
- const current = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
436
- ? lastMeState.builder.preferred_disciplines : [];
437
- const selected = current.includes(value)
438
- ? current.filter((d) => d !== value)
439
- : [...current, value];
440
- const buttons = document.querySelectorAll('.onboarding-item__disc');
441
- buttons.forEach((b) => { b.disabled = true; });
442
- try {
443
- const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
444
- if (resp.builder && lastMeState) {
445
- // Keep the cached /me state and the profile picker in sync.
446
- lastMeState.builder = resp.builder;
447
- lastMeState.onboarding = resp.onboarding || lastMeState.onboarding;
448
- }
449
- if (resp.onboarding) renderOnboarding(resp.onboarding, lastMeState && lastMeState.discord);
450
- if (resp.builder) renderProfile(resp.builder);
451
- const axis = window.OTB.roleAxisLabel();
452
- toast(selected.length ? `${axis}: ${selected.map((d) => window.OTB.roleLabel(d)).join(', ')}.` : `${axis} cleared.`);
453
- } catch (err) {
454
- if (err && err.message === 'redirecting to auth') return;
455
- console.error('[builders] pick discipline failed', err);
456
- toast('Could not save just now. Try again.');
457
- buttons.forEach((b) => { b.disabled = false; });
458
- }
459
- }
460
-
461
- // ---- achievements --------------------------------------------------------
462
-
463
- function renderAchievements(all, unlocked, lockedWithProgress) {
464
- const unlockedById = new Map(unlocked.map((u) => [u.achievement_id, u]));
465
- // Map achievement_id → progress_text for locked entries. Falsy/empty
466
- // progress just shows the credit-bonus line as before.
467
- const progressById = new Map(
468
- (lockedWithProgress || []).map((l) => [l.achievement_id, l.progress_text || '']),
469
- );
470
- const grid = $('#ach-grid');
471
- if (!all.length) {
472
- grid.innerHTML = `<div class="profile__placeholder">No achievements earned yet.</div>`;
473
- return;
474
- }
475
- grid.innerHTML = all.map((a) => {
476
- const got = unlockedById.get(a.id);
477
- const cls = got ? 'ach' : 'ach ach--locked';
478
- const bonus = a.credit_bonus ? `<p class="ach__bonus">+${fmtNum(a.credit_bonus)} ${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</p>` : '';
479
- const when = got ? `<p class="ach__bonus">Won ${fmtDate(got.unlocked_at)}</p>` : '';
480
- const progress = !got && progressById.has(a.id) && progressById.get(a.id)
481
- ? `<p class="ach__progress">${escapeHtml(progressById.get(a.id))}</p>`
482
- : '';
483
- return `
484
- <div class="${cls}" title="${escapeHtml(a.id)}">
485
- <div class="ach__icon" aria-hidden="true">${escapeHtml(a.icon || '✦')}</div>
486
- <div class="ach__body">
487
- <p class="ach__name">${escapeHtml(a.name)}</p>
488
- <p class="ach__desc">${escapeHtml(a.description || '')}</p>
489
- ${when || bonus}
490
- ${progress}
491
- </div>
492
- </div>
493
- `;
494
- }).join('');
495
- }
496
-
497
- // ---- recent ships --------------------------------------------------------
498
-
499
- // All shipped works for this builder, fetched once via ?limit=all then
500
- // filtered, sorted, and paged entirely client-side. Mirrors the status site's
501
- // Lately Inscribed pattern (modules/status-ui/public/status.js renderShippedPage), with
502
- // an additional version filter since a builder's history spans versions.
503
- // task 2174: paging moved to the OTBKit pager (per-page select + persistence);
504
- // 10 stays the default page size this table has always used.
505
-
506
-
507
- // task 1957 — a shipped row's payout is the ACTUAL ledger figure (credits_awarded),
508
- // not the creation-time estimate. taskCreditValue picks the right one by status.
509
-
510
-
511
-
512
- // Re-render when filters change. Reset to page 1 so the new filter starts at
513
- // the top — otherwise switching to a smaller window can leave you on page 9
514
- // of a 2-page result.
515
- function onShipsFilterChange() {
516
- shipsPager.page = 1;
517
- renderRecentShips();
518
- }
519
-
520
- // ---- recent grades (task 197) -------------------------------------------
521
-
522
- function renderRecentGrades(grades) {
523
- const tbody = $('#recent-grades tbody');
524
- if (!tbody) return;
525
- if (!grades || !grades.length) {
526
- tbody.innerHTML = emptyRow(5, 'No work reviewed yet.');
527
- return;
528
- }
529
- tbody.innerHTML = grades.map((g) => {
530
- // A grader OUTAGE row (panel_outcome 'unavailable') carries a placeholder
531
- // score of 0 — label it, never render it as a 0.0 fail (task 1002652).
532
- const isOutage = g.panel_outcome === 'unavailable';
533
- // A json-null gating axis (task 1003318 — the Quality worker never ran)
534
- // is likewise not a judgment: the score is the surviving axis, not a fail.
535
- const isLensOut = !isOutage && !!g.rubric_scores && g.rubric_scores.code_quality === null;
536
- const score = isOutage || isLensOut ? '—' : Number(g.score).toFixed(1);
537
- // A verdict is verification state: it wears the fixed vocabulary through
538
- // .ledger__verdict--* (style.css), never an inline colour. The literals
539
- // that stood here painted `passed` from a --olive that is not a token of
540
- // the world, so every hall ever rendered it as the fallback (task 1003495).
541
- const verdict = isOutage
542
- ? `<span class="ledger__verdict--none">not graded (grader outage)</span>`
543
- : isLensOut
544
- ? `<span class="ledger__verdict--none">not graded (quality lens unavailable)</span>`
545
- : g.passed
546
- ? `<span class="ledger__verdict--ok">passed</span>`
547
- : `<span class="ledger__verdict--danger">fell short</span>`;
548
- return `
549
- <tr>
550
- <td class="ledger__num">#${g.task_id}</td>
551
- <td>${escapeHtml(g.title || '')}</td>
552
- <td class="ledger__drachmae">${isOutage || isLensOut ? '—' : `${score} / 10`}</td>
553
- <td>${verdict}</td>
554
- <td class="ledger__date">${fmtDate(g.graded_at)}</td>
555
- </tr>
556
- `;
557
- }).join('');
558
- }
559
-
560
- function renderRollingNote(rolling) {
561
- const note = $('#grades-rolling-note');
562
- if (!note) return;
563
- if (!rolling || !rolling.n || Number(rolling.n) === 0) {
564
- note.hidden = true;
565
- return;
566
- }
567
- const avg = Number(rolling.avg_score).toFixed(1);
568
- const passed = Number(rolling.n_passed || 0);
569
- const n = Number(rolling.n);
570
- note.textContent = `Last 14 days: ${avg} / 10 average across ${n} graded work${n === 1 ? '' : 's'} (${passed} passed).`;
571
- note.hidden = false;
572
- }
573
-
574
- // ---- the dev curve (V3.R72 #291, C5) ------------------------------------
575
- //
576
- // Per-builder historical analytics: ship rate, cost-per-task, duration, and
577
- // grade pass-rate over time, plus a top-kinds fingerprint. Charts are inline
578
- // SVG sparklines — no chart library (the task spec's explicit constraint).
579
- // An Archon additionally gets a "You / All builders" scope toggle backed by
580
- // the ecosystem aggregate (individuals stripped), satisfying the
581
- // cross-builder view requirement without a per-builder roster on this page.
582
-
583
-
584
- // Build a tiny SVG line sparkline from an array of numeric y-values. Returns
585
- // a self-contained <svg> string. A flat/short series degrades to a baseline.
586
-
587
-
588
-
589
- const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
590
- const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
591
- const mins = (v) => (v == null ? '—' : `${Math.round(Number(v))}m`);
592
-
593
- // Render the curve for whichever scope is active. `data` is either the self
594
- // payload (/analytics/builder/:id) or the ecosystem payload, normalized below.
595
-
596
- // Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
597
- // the ecosystem aggregate) and render. A failed/empty feed simply leaves the
598
- // section hidden rather than holding up the rest of the hall.
599
- async function loadAnalytics(builder) {
600
- const scroll = $('#analytics-scroll');
601
- if (!scroll || !builder?.id) return;
602
- analyticsState.builderId = builder.id;
603
- analyticsState.scope = 'self';
604
- analyticsState.kind = null;
605
- try {
606
- const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
607
- analyticsState.self = self;
608
- // The picker's options come from THIS first, unfiltered load and are then
609
- // left alone — see kindFilterHtml. A later filtered payload's top_kinds is
610
- // deliberately not allowed to overwrite them.
611
- analyticsState.kinds = Array.isArray(self?.top_kinds) ? self.top_kinds : [];
612
- // A gated newcomer with nothing shipped: keep the section hidden.
613
- if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
614
- scroll.hidden = false;
615
- renderCurveBody();
616
- renderNav(builder, lastMeState?.onboarding || null); // re-show the Curve chip now that the section is visible
617
- // THE BENCHMARK IS FOR EVERY BUILDER, NOT JUST THE ARCHON (task 1004005).
618
- // This fetch used to sit behind `if (analyticsState.isArchon)`, so the
619
- // ecosystem average a builder is meant to measure themselves against was
620
- // fetched for exactly one of them. The route is `rank: public` — no auth
621
- // middleware, CORS '*', per-builder identity stripped server-side — and the
622
- // unauthenticated status page already reads it, so the gate protected
623
- // nothing and only withheld the comparison. Still fire-and-forget: the
624
- // ecosystem feed is optional context, never a reason to hold up the curve.
625
- getJSON(`${API}/public/analytics/ecosystem?days=90`)
626
- // ALWAYS re-render, not only in the 'eco' scope. That condition was
627
- // correct while the two scopes merely SWAPPED which single series was
628
- // drawn; now the ecosystem series is also the benchmark overlaid on the
629
- // 'self' view, so a self-scoped reader who arrived before this resolved
630
- // would sit with no overlay until they touched an unrelated control.
631
- .then((eco) => { analyticsState.eco = eco; renderCurveBody(); })
632
- .catch(() => { /* ecosystem optional */ });
633
- } catch (err) {
634
- console.error('[builders] analytics load failed', err);
635
- scroll.hidden = true;
636
- }
637
- }
638
-
639
- // Re-fetch BOTH series under a kind filter (task 1004005). Both, always: an
640
- // overlay that narrowed one side and not the other would compare one kind of
641
- // work against all work and still look like a like-for-like benchmark — the
642
- // failure this task's own text calls worse than no benchmark at all.
643
- //
644
- // `kinds` is deliberately not refreshed here; see kindFilterHtml.
645
- let curveKindSeq = 0;
646
- async function reloadCurveForKind(kind) {
647
- const id = analyticsState.builderId;
648
- if (!id) return;
649
- analyticsState.kind = kind;
650
- // A dropdown can be changed faster than a round trip returns, and the two
651
- // fetches can land out of order. Only the newest request may write state.
652
- const seq = ++curveKindSeq;
653
- const q = kind ? `&kind=${encodeURIComponent(kind)}` : '';
654
- renderCurveBody(); // reflect the new selection immediately
655
- try {
656
- const [self, eco] = await Promise.all([
657
- getJSON(`${API}/analytics/builder/${id}?days=90${q}`),
658
- getJSON(`${API}/public/analytics/ecosystem?days=90${q}`).catch(() => null),
659
- ]);
660
- if (seq !== curveKindSeq) return;
661
- analyticsState.self = self;
662
- if (eco) analyticsState.eco = eco;
663
- renderCurveBody();
664
- } catch (err) {
665
- if (seq !== curveKindSeq) return;
666
- console.error('[builders] curve kind filter failed', err);
667
- }
668
- }
669
-
670
- // ---- karma feed (V3.R26 #246) -------------------------------------------
671
- //
672
- // The recent karma_log entries for the current builder. The /me payload
673
- // already includes them under `karma.recent`. The scroll stays hidden when
674
- // the builder has no history yet (zero karma is a common starting state,
675
- // so we don't want to push an empty table at every new citizen).
676
-
677
- function renderKarmaFeed(entries) {
678
- const scroll = $('#karma-scroll');
679
- const tbody = $('#karma-feed tbody');
680
- if (!scroll || !tbody) return;
681
- if (!Array.isArray(entries) || entries.length === 0) {
682
- // Hide the scroll entirely if the builder has no karma history.
683
- // Keeps the page tidy for fresh citizens; reappears the moment a
684
- // karma_log row lands.
685
- scroll.hidden = true;
686
- return;
687
- }
688
- // #748: a gated newcomer never shows the karma feed, even if karma exists.
689
- scroll.hidden = newcomerGated;
690
- tbody.innerHTML = entries.map((k) => {
691
- const delta = Number(k.delta) || 0;
692
- const deltaCls = signClasses(delta, 'karma-feed__delta');
693
- const deltaText = delta > 0 ? `+${fmtNum(delta)}` : fmtNum(delta);
694
- const granted = k.granted_by
695
- ? `@${escapeHtml(k.granted_by_login || k.granted_by_display_name || `builder #${k.granted_by}`)}`
696
- : '<span style="opacity:0.6;">— all builders —</span>';
697
- return `
698
- <tr>
699
- <td class="ledger__date">${escapeHtml(fmtDate(k.recorded_at))}</td>
700
- <td class="karma-feed__delta ${deltaCls}">${deltaText}</td>
701
- <td>${escapeHtml(k.reason || '')}</td>
702
- <td>${granted}</td>
703
- </tr>
704
- `;
705
- }).join('');
706
- }
707
-
708
- // ---- peer acclaim feed (V3.R25 #245) ------------------------------------
709
- //
710
- // Other builders' recently confirmed/shipped work, each with a +/- vote
711
- // widget. Votes fold into the shipping builder's karma on a weekly tally,
712
- // lagged 7 days (server-enforced). Metic+ may vote; the endpoint returns
713
- // can_vote so Xenos see the tallies read-only. The endpoint already excludes
714
- // the caller's own ships (anti-self-vote), so there's nothing to vote on that
715
- // isn't a peer's work.
716
-
717
- let acclaimCanVote = false;
718
- // task 1002446: this was the hall's last unpaged list. The endpoint returns the
719
- // whole feed in one call, so the rows are held here and the kit's client-side
720
- // pager slices them — a page change re-renders without a refetch.
721
- const acclaimState = { tasks: [] };
722
- const acclaimPager = window.OTBKit.makePager('acclaim', 'otb.standing.acclaimPerPage', { defaultPerPage: 10 });
723
-
724
- function renderAcclaimFeed(data) {
725
- const scroll = $('#acclaim-scroll');
726
- if (!scroll) return;
727
- const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
728
- acclaimState.tasks = tasks;
729
- acclaimCanVote = !!(data && data.can_vote);
730
- if (tasks.length === 0) {
731
- // Nothing to acclaim yet (e.g. the only confirmed work so far is your
732
- // own). Hide rather than show an empty ledger.
733
- scroll.hidden = true;
734
- return;
735
- }
736
- // #748: a gated newcomer never shows the acclaim feed.
737
- scroll.hidden = newcomerGated;
738
- // task 2174: this feed lands after loadAll built the rail, and it can be the
739
- // only visible member of the Karma group (an empty karma feed hides itself).
740
- // Re-run renderNav so the group — and its rail entry — appear.
741
- if (lastMeState?.builder) renderNav(lastMeState.builder, lastMeState.onboarding || null);
742
- renderAcclaimRows();
743
- }
744
-
745
- function renderAcclaimRows() {
746
- const tbody = $('#acclaim-feed tbody');
747
- if (!tbody) return;
748
- const slice = window.OTBKit.paginate(acclaimState.tasks, acclaimPager);
749
- tbody.innerHTML = slice.map((t) => {
750
- // task 1004432: an approved page is its artist's; the applying session rides along as a helper.
751
- const helperName = t.helper_display_name || (t.helper_login ? `@${t.helper_login}` : '');
752
- 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>` : '');
753
- // task 1004833: the votes are THE shared list control (vote-control.js),
754
- // pre-painted from the feed's own net_votes/my_vote so it costs no extra
755
- // read. A viewer below the vote floor sees the net alone.
756
- const net = Number(t.net_votes) || 0;
757
- const votes = acclaimCanVote && window.OTBVote
758
- ? window.OTBVote.taskHtml(t)
759
- : `<span class="vote__net" title="Metic rank and above may vote">${net > 0 ? `+${net}` : `${net}`}</span>`;
760
- const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
761
- return `
762
- <tr>
763
- <td>${title}${acclaimEvidenceHtml(t)}</td>
764
- <td>${by}</td>
765
- <td>${votes}</td>
766
- </tr>
767
- `;
768
- }).join('');
769
- }
770
-
771
- async function loadAcclaimFeed() {
772
- try {
773
- const data = await getJSON(`${API}/tasks/peer-votes/feed?limit=25`);
774
- renderAcclaimFeed(data);
775
- } catch (err) {
776
- // Non-fatal: a feed failure shouldn't take down the hall.
777
- console.warn('[hall] acclaim feed failed:', err.message);
778
- const scroll = $('#acclaim-scroll');
779
- if (scroll) scroll.hidden = true;
780
- }
781
- }
782
-
783
- // task 1004833: the shared vote control casts the vote and repaints its own
784
- // row; it then raises 'otb-vote' with the server's answer. The feed is paged,
785
- // so the vote must land in the HELD rows too (task 1002446), or flipping to
786
- // page 2 and back re-renders the stale tally over the one just cast.
787
- function onAcclaimVote(ev) {
788
- const d = ev && ev.detail;
789
- if (!d || d.kind !== 'task') return;
790
- const taskIdNum = Number(d.id);
791
- acclaimState.tasks = acclaimState.tasks.map((t) => (Number(t.id) === taskIdNum
792
- ? { ...t, net_votes: d.net_votes, my_vote: d.my_vote }
793
- : t));
794
- }
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.)
795
272
 
796
273
  // ---- task browser (removed #750) -----------------------------------------
797
274
  // The claimable-works list now lives solely on the /work Work Board (#748);
@@ -888,79 +365,6 @@
888
365
  // builder isn't on the roll). Matched by github_login (rows carry no id).
889
366
 
890
367
 
891
- // ---- onboarding live-tick poll (task 1444) -------------------------------
892
- //
893
- // The onboarding checklist is rendered once from the page-load /me snapshot.
894
- // But two of its steps clear from LIVE server signals that flip WITHOUT any
895
- // local action or page reload while the hall sits open:
896
- // * cli_setup — stamped when the Bongos CLI is first authenticated;
897
- // * join_discord — flips on the OAuth link (usually a full redirect, but can
898
- // also land from another tab).
899
- // Without a poll, a newcomer who signs the CLI in AFTER opening the hall sees
900
- // the step stay unchecked until they manually reload — exactly the bug scootermack
901
- // hit. "The city watches" only worked on the next full reload. So while any
902
- // live-signal step is still uncleared, re-fetch /me on an interval and re-render
903
- // the checklist; stop once they all clear (or the builder graduates).
904
- let onboardingPollTimer = null;
905
- const ONBOARDING_POLL_MS = 15000;
906
-
907
- // True while at least one step that clears from a live signal (cli/discord)
908
- // is still uncleared and the builder hasn't graduated. The pick_disciplines and
909
- // ship_three steps flip via local action (PATCH re-renders) or shipping, so they
910
- // don't need a poll — scoping to the live-signal steps bounds polling to the
911
- // setup window rather than running forever for any non-graduated builder.
912
- function onboardingNeedsLivePoll(onboarding) {
913
- if (!onboarding || onboarding.graduated) return false;
914
- const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
915
- return stages.some((s) =>
916
- (s.source === 'cli' || s.source === 'discord') && !s.cleared);
917
- }
918
-
919
- function stopOnboardingPoll() {
920
- if (onboardingPollTimer) { clearTimeout(onboardingPollTimer); onboardingPollTimer = null; }
921
- }
922
-
923
- function scheduleOnboardingPoll(onboarding) {
924
- stopOnboardingPoll();
925
- if (!onboardingNeedsLivePoll(onboarding)) return;
926
- onboardingPollTimer = setTimeout(refreshOnboardingFromServer, ONBOARDING_POLL_MS);
927
- }
928
-
929
- async function refreshOnboardingFromServer() {
930
- onboardingPollTimer = null;
931
- // Don't poll a backgrounded tab — wait for it to come back (the
932
- // visibilitychange listener re-arms from the cached state).
933
- if (document.hidden) { scheduleOnboardingPoll(lastMeState && lastMeState.onboarding); return; }
934
- let me;
935
- try {
936
- me = await getJSON(`${API}/me`, { softAuth: true });
937
- } catch (err) {
938
- // Session expired → stop (a redirect/landing will take over on next action).
939
- if (err && err.code === AUTH_NEEDED) { stopOnboardingPoll(); return; }
940
- // Transient (network blip / 5xx) → try again next interval so a single
941
- // failure doesn't permanently kill the auto-tick.
942
- scheduleOnboardingPoll(lastMeState && lastMeState.onboarding);
943
- return;
944
- }
945
- if (!me || !me.builder) return;
946
- const wasGraduated = !!(lastMeState && lastMeState.onboarding && lastMeState.onboarding.graduated);
947
- lastMeState = me;
948
- lastKarmaState = me.karma || lastKarmaState;
949
- lastCostState = me.cost || lastCostState;
950
- lastNeedsState = me.needs || lastNeedsState;
951
- // ADR 0120 part 5: owed-rebase list is authoritative on every /me (an empty
952
- // array means "nothing owed now"), so take it verbatim when present.
953
- if (Array.isArray(me.needs_rebase_tasks)) lastRebaseState = me.needs_rebase_tasks;
954
- renderOnboarding(me.onboarding || null, me.discord);
955
- // If they graduated while watching, hide the welcome panel + rebuild the nav
956
- // (drops the Welcome/Path chips) — the same transition loadAll would render.
957
- if (me.onboarding && me.onboarding.graduated && !wasGraduated) {
958
- renderWelcome(me.builder, me.onboarding);
959
- renderNav(me.builder, me.onboarding || null);
960
- }
961
- scheduleOnboardingPoll(me.onboarding || null);
962
- }
963
-
964
368
  // ---- bootstrap -----------------------------------------------------------
965
369
 
966
370
  // `prefetchedMe` is boot()'s already-resolved /me — the SAME payload, handed in
@@ -986,52 +390,22 @@
986
390
  lastCostState = me.cost || null;
987
391
  lastNeedsState = me.needs || null; // task 1013 / ADR 0073: action-needed banner
988
392
  lastRebaseState = Array.isArray(me.needs_rebase_tasks) ? me.needs_rebase_tasks : null; // ADR 0120 part 5: owed-rebase banner
989
- // #408: stash the whole /me payload so reopenWelcomeFromNav can re-render
990
- // the welcome panel without a fresh round-trip.
991
393
  lastMeState = me;
992
394
  // #748: decide the newcomer gate up front so the async karma/acclaim feeds
993
395
  // (which un-hide their own scroll on data arrival) honour it too.
994
396
  newcomerGated = normalizeRank(me.builder?.rank) === 'xenos';
995
- // V3.R77 (#296): first-visit welcome panel. Renders above the profile and
996
- // hides itself once dismissed or once the builder graduates. Computed
997
- // before profile so the dismiss button is wired by the time the user
998
- // sees the page.
999
- // Cache self id on window so the dismiss-click handler can find it
1000
- // without re-fetching /me. Set BEFORE renderWelcome so the wired
1001
- // dismiss listener (attached once at bootstrap) can use it immediately.
397
+ // Cache self id on window for handlers wired once at bootstrap.
1002
398
  window.__buildersSelfId = me.builder?.id ?? null;
1003
- renderWelcome(me.builder, me.onboarding || null);
1004
399
  renderProfile(me.builder);
1005
400
  renderGreeting(); // task 1003464: "Good morning, <name>" — the h1 — as soon as /me answers
1006
401
  // #731: the CLI-token re-issue card (V3.R91 #401) and the Discord connect/
1007
402
  // link card (F7, ADR 0033) moved to /builders/settings — the hall's Standing
1008
403
  // card is identity-only now.
1009
- // V3.R75 (#294): auto-completing onboarding checklist. The /me payload
1010
- // carries the builder's own state directly so this is a render-only call.
1011
- renderOnboarding(me.onboarding || null, me.discord);
1012
- // task 1444: keep the checklist live — re-fetch /me on an interval while a
1013
- // cli/discord step is still uncleared, so it ticks itself without a manual
1014
- // reload the moment the CLI or Discord links.
1015
- scheduleOnboardingPoll(me.onboarding || null);
1016
- renderRecentGrades(me.grades?.recent || []);
1017
- renderRollingNote(me.grades?.rolling || null);
1018
- // V3.R72 (#291, C5): the dev curve. Loaded fire-and-forget — a slow or
1019
- // empty analytics feed must not hold up the rest of the hall, and the
1020
- // section un-hides itself (and re-renders the nav) only when there's data.
1021
- loadAnalytics(me.builder);
1022
- // V3.R26 (#246): karma feed — the recent karma_log entries powering the
1023
- // "why did my karma move" answer. The /me payload bundles this so the
1024
- // page doesn't need a follow-up call.
1025
- renderKarmaFeed(me.karma?.recent || []);
1026
- // V3.R25 (#245): peer acclaim feed — a separate endpoint (not bundled in
1027
- // /me because it spans all builders' work), loaded fire-and-forget so a
1028
- // slow/failed feed can't hold up the rest of the hall.
1029
- loadAcclaimFeed();
1030
404
  // #408: build the sticky nav now that every section has settled its
1031
- // hidden state (welcome → karma → onboarding → security/roster).
405
+ // hidden state (karma → security/roster).
1032
406
  // The renderNav call also wires the IntersectionObserver for active-
1033
- // section highlighting. Idempotent — safe to call again on dismiss/re-open.
1034
- // #748: collapse the hall to the newcomer view (onboarding + Standing) for
407
+ // section highlighting. Idempotent — safe to call again.
408
+ // #748: collapse the hall to the newcomer view (Standing) for
1035
409
  // a Xenos BEFORE building the nav, so the chip strip matches what's shown.
1036
410
  applyNewcomerGate();
1037
411
  renderNav(me.builder, me.onboarding || null);
@@ -1046,41 +420,15 @@
1046
420
  // down the page — log and show an inline placeholder.
1047
421
  // #726: the per-skill model picker (Voices) moved to /builders/settings,
1048
422
  // so the hall no longer fetches /me/skill-prefs here.
1049
- const [achAll, ships, versionsResp, lb] = await Promise.allSettled([
1050
- getJSON(`${API}/achievements`),
1051
- // task 1004854: Home's newest few + a count — never the whole history,
1052
- // 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).
1053
426
  getJSON(`${API}/me/recent-ships?limit=${HOME_SHIPS_LIMIT}`),
1054
- getJSON(`${API}/versions`),
1055
427
  getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1056
428
  ]);
1057
429
 
1058
- if (achAll.status === 'fulfilled') {
1059
- // Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
1060
- // for V2, so the slice is defensive.) Unlocked + locked-with-progress come
1061
- // straight from the enriched /me payload.
1062
- renderAchievements(
1063
- (achAll.value.achievements || []).slice(0, 10),
1064
- me.unlocked || [],
1065
- me.locked_with_progress || [],
1066
- );
1067
- } else {
1068
- $('#ach-grid').innerHTML = `<div class="profile__placeholder">Achievements could not be loaded.</div>`;
1069
- }
1070
-
1071
- if (ships.status === 'fulfilled') {
1072
- setRecentShips(ships.value);
1073
- } else {
1074
- $('#recent-ships tbody').innerHTML = emptyRow(4, 'Your work history could not be loaded.');
1075
- }
1076
-
1077
- if (versionsResp.status === 'fulfilled') {
1078
- const versions = versionsResp.value.versions || [];
1079
- // Populate the Works Wrought version filter — limited to versions this
1080
- // builder has actually shipped to, so the dropdown can't offer empty
1081
- // options. (The old claimable-works version filter moved to /work, #750.)
1082
- populateShipVersionFilter(versions);
1083
- }
430
+ // Only the Standing chip's shipped count reads this; a failure leaves its dash.
431
+ if (ships.status === 'fulfilled') setRecentShips(ships.value);
1084
432
 
1085
433
  if (lb.status === 'fulfilled') {
1086
434
  lbState.rows = lb.value.leaderboard || [];
@@ -1097,78 +445,6 @@
1097
445
  }
1098
446
 
1099
447
  function wireEvents() {
1100
- // #profile-body is re-rendered after load, so delegate the save click.
1101
- $('#profile-body').addEventListener('click', (e) => {
1102
- if (e.target.closest('#disc-save')) saveDisciplines();
1103
- });
1104
-
1105
- // V3.R72 (#291): the dev-curve scope toggle. Delegated because
1106
- // renderCurveBody replaces the toggle's innerHTML — and from the whole
1107
- // Progress section, NOT #curve-body: the controls render into
1108
- // #curve-scope-note in the section's lede, a sibling of #curve-body, so a
1109
- // listener there never heard them (task 1004079).
1110
- $('#analytics-scroll')?.addEventListener('click', (e) => {
1111
- const btn = e.target.closest('.curve__scope-btn[data-scope]');
1112
- if (!btn) return;
1113
- const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
1114
- if (scope === analyticsState.scope) return;
1115
- analyticsState.scope = scope;
1116
- renderCurveBody();
1117
- });
1118
-
1119
- // The kind filter (task 1004005). Delegated like the scope toggle, and for
1120
- // the same reason: renderCurveBody replaces the control's innerHTML, so a
1121
- // listener bound to the element itself would not survive the first render.
1122
- // 'change', not 'input' — a keyboard user arrowing through a native select
1123
- // fires input per option, and each one would be a pair of round trips.
1124
- $('#analytics-scroll')?.addEventListener('change', (e) => {
1125
- const sel = e.target.closest('#curve-kind');
1126
- if (!sel) return;
1127
- const kind = sel.value ? sel.value : null;
1128
- if (kind === analyticsState.kind) return;
1129
- reloadCurveForKind(kind);
1130
- });
1131
-
1132
- // task 1723: the Home "show the setup guide again" link re-opens a dismissed
1133
- // welcome banner (the old welcome chip's job) — clears the dismissal flag,
1134
- // re-renders the panel, refreshes the nav, then scrolls to it.
1135
- document.getElementById('welcome-reopen')?.addEventListener('click', (e) => {
1136
- reopenWelcomeFromNav(e);
1137
- });
1138
-
1139
- // V3.R75 (#294): the onboarding list re-renders on each /me load, so the
1140
- // inline craft picker is wired by delegation to the list container. (The
1141
- // primer-read ack button that also lived here was removed in #766.)
1142
- $('#onboarding-list')?.addEventListener('click', (e) => {
1143
- const disc = e.target.closest('.onboarding-item__disc[data-disc]');
1144
- if (disc) { pickDiscipline(disc.dataset.disc); return; }
1145
- // #777: the only other control is the Connect-Discord anchor, which is a
1146
- // real link (full-page nav into the OAuth flow) — nothing to handle here.
1147
- // Both new steps (join_discord, cli_setup) auto-detect server-side.
1148
- });
1149
-
1150
- // Welcome panel minimize toggle (replaces full dismiss).
1151
- $('#welcome-minimize')?.addEventListener('click', () => {
1152
- const scroll = $('#welcome-scroll');
1153
- const btn = $('#welcome-minimize');
1154
- if (!scroll) return;
1155
- const minimized = scroll.classList.toggle('scroll--welcome--minimized');
1156
- if (btn) btn.textContent = minimized ? '+' : '−';
1157
- });
1158
-
1159
- // task 1004833: the shared vote control reports each landed vote to the feed.
1160
- $('#acclaim-feed')?.addEventListener('otb-vote', onAcclaimVote);
1161
-
1162
- $('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
1163
- $('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
1164
- $('#f-ships-sort')?.addEventListener('change', renderRecentShips);
1165
- // task 2174: the kit wires prev/next/per-page for both tables against the
1166
- // #ships-*/#lb-* ids its own pagerBarHtml() stamped in hall-widgets.js.
1167
- window.OTBKit.wirePager(shipsPager, renderRecentShips);
1168
-
1169
- window.OTBKit.wirePager(acclaimPager, renderAcclaimRows);
1170
-
1171
-
1172
448
  // Sign-out is wired centrally in shell.js (doLogout + a delegated click
1173
449
  // listener on the sign-out anchors), so it runs the real POST -> cookie-clear
1174
450
  // -> bounce-home on EVERY hall page, not only this index. It used to live
@@ -1273,9 +549,8 @@
1273
549
  // reads what this page holds NOW rather than what it held at boot. Everything
1274
550
  // else is stable for the life of the page. This call sits AFTER every
1275
551
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1276
- const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1277
- API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1278
- get instanceRepo() { return instanceRepo; },
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,
1279
554
  get lastCostState() { return lastCostState; },
1280
555
  get lastKarmaState() { return lastKarmaState; },
1281
556
  get lastMeState() { return lastMeState; },
@@ -1318,9 +593,6 @@
1318
593
  // which only exist once the registry has mounted them.
1319
594
  mountHallWidgets();
1320
595
  wireEvents();
1321
- // Fetch the instance's repo coords (public) so the welcome "get the code"
1322
- // clone block shows this instance's own repository (ADR 0062 §3, task 1705).
1323
- loadInstanceRepo();
1324
596
  // #731: the Discord connect/link card moved to /builders/settings, and the
1325
597
  // OAuth callback now lands there — so the ?discord=joined|linked toast is
1326
598
  // handled by settings.js, not here.