@bongos/core 1.19.332 → 1.19.333

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 (58) hide show
  1. package/.bongos-core.json +160 -40
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/box-infra.js +280 -0
  26. package/scripts/gds/box.js +1 -245
  27. package/scripts/gds/fitness-checks-identity.js +320 -0
  28. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  29. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  30. package/scripts/gds/fitness-lib.js +82 -0
  31. package/scripts/gds/fitness.js +55 -668
  32. package/scripts/gds/provision-config.js +204 -0
  33. package/scripts/gds/provision-net.js +221 -0
  34. package/scripts/gds/provision-repo.js +849 -0
  35. package/scripts/gds/provision-units.js +262 -0
  36. package/scripts/gds/provision.js +4 -1395
  37. package/src/bongos/auth-admission.js +264 -0
  38. package/src/bongos/auth-config.js +151 -0
  39. package/src/bongos/auth-discord.js +198 -0
  40. package/src/bongos/auth-github.js +336 -0
  41. package/src/bongos/auth-idp.js +330 -0
  42. package/src/bongos/auth.js +5 -1100
  43. package/src/module-api.js +3 -1
  44. package/tests/auth_admission_gate.mjs +3 -1
  45. package/tests/est_advisory.mjs +3 -1
  46. package/tests/goal_advisory.mjs +3 -2
  47. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  48. package/tests/goal_subtitle.mjs +5 -2
  49. package/tests/goals_card_view.mjs +10 -3
  50. package/tests/grader_generated_provenance.mjs +3 -1
  51. package/tests/hall_palette.mjs +3 -1
  52. package/tests/helpers.mjs +58 -25
  53. package/tests/lib_sh_resolution.mjs +3 -1
  54. package/tests/membership_kind_lockstep.mjs +3 -1
  55. package/tests/migration_allocation.mjs +5 -2
  56. package/tests/patch_gate_ordering.mjs +3 -1
  57. package/tests/rank_tier_single_source.mjs +16 -3
  58. package/tests/reward_gate.mjs +3 -2
@@ -147,69 +147,9 @@
147
147
  // job is the daily action surface, not the full permissions reference (that
148
148
  // lives at /builders/ranks). Sourced from docs/canonical-permissions.md.
149
149
  // Used by the rank-badge hover tooltip (opts.tip) and mirrored in ranks.js.
150
- const RANK_UNLOCKS = {
151
- xenos: [
152
- 'Read every public page.',
153
- 'Claim and ship tasks an Archon has flagged newcomer-friendly.',
154
- `Earn ${curLabel()} for shipped work and karma for ratified ideas.`,
155
- ],
156
- thetes: [
157
- 'Claim and ship any task in the general queue — no longer only newcomer-friendly work.',
158
- `Keep earning ${curLabel()} and karma for every clean ship.`,
159
- 'No triage, blockers, planning, or task-shaping yet — those levers open at Metic.',
160
- ],
161
- metic: [
162
- 'Claim and ship any task in the ready queue.',
163
- 'Triage the idea inbox; resolve blockers; run planning sessions.',
164
- 'Propose new version criteria for Archon ratification.',
165
- ],
166
- archon: [
167
- 'Create, promote, and edit tasks; reshape the dependency graph.',
168
- 'Grant or revoke ranks; close versions; confirm ships past the grader.',
169
- 'Adjudicate vulnerability reports and override requests.',
170
- ],
171
- };
172
150
 
173
151
  // Monotonic counter for unique tooltip ids (aria-describedby). Re-renders
174
152
  // replace innerHTML so duplicates would otherwise collide.
175
- let rankTipSeq = 0;
176
-
177
- function renderRankBadge(rank, opts = {}) {
178
- const label = escapeHtml(rankLabel(rank));
179
- const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
180
- const variant = RANK_ORDER[rank] ? `rank-badge--${rank}` : 'rank-badge--unknown';
181
-
182
- // Plain plaque (default — used in tables, roster, leaderboard).
183
- if (!opts.tip || !RANK_UNLOCKS[rank]) {
184
- const title = opts.title ? ` title="${escapeHtml(opts.title)}"` : '';
185
- return `<span class="rank-badge ${variant}" aria-label="${escapeHtml(ariaLabel)}"${title}>${label}</span>`;
186
- }
187
-
188
- // Tooltip variant — a hover/focus popover lists what this rank unlocks,
189
- // and a transparent "stretched link" overlay makes the whole plaque
190
- // navigate to the full ladder. We keep the badge a <span> (not an <a>) on
191
- // purpose: the hall's global a:hover rule repaints links terracotta, which
192
- // would otherwise bleed into the plaque AND the tip text. The overlay
193
- // anchor carries the navigation + keyboard focus without touching colour.
194
- // NB: this markup contains a <ul>, which is NOT valid inside a <p>. If a
195
- // caller drops this tipped badge into a <p>, the HTML parser auto-closes
196
- // the <p> at the <ul> and hoists the list out of .rank-tip — the unlocks
197
- // then render as a visible block instead of a hover popover (#530). Render
198
- // it inside a <div>/inline container, never a <p>.
199
- const tipId = `rank-tip-${rank}-${rankTipSeq++}`;
200
- const items = RANK_UNLOCKS[rank].map((u) => `<li>${escapeHtml(u)}</li>`).join('');
201
- return `<span class="rank-badge-wrap">` +
202
- `<span class="rank-badge ${variant} rank-badge--tipped" aria-hidden="true">${label}</span>` +
203
- `<a class="rank-badge-stretch" href="/ranks" ` +
204
- `aria-describedby="${tipId}" ` +
205
- `aria-label="${escapeHtml(ariaLabel)} — see all standings"></a>` +
206
- `<span class="rank-tip" id="${tipId}" role="tooltip">` +
207
- `<span class="rank-tip__label">What ${label} unlocks</span>` +
208
- `<ul class="rank-tip__list">${items}</ul>` +
209
- `<span class="rank-tip__more">Walk the full ladder →</span>` +
210
- `</span>` +
211
- `</span>`;
212
- }
213
153
 
214
154
  // ---- profile -------------------------------------------------------------
215
155
 
@@ -292,24 +232,12 @@
292
232
  // zone so pre-redesign module widgets still land somewhere visible. Every
293
233
  // container lives INSIDE <main>, so the `main .scroll` sweeps (newcomer gate,
294
234
  // landing hide-all) keep working.
295
- const VIEW_CONTAINERS = {
296
- home: 'zone-home-main',
297
- standing: 'zone-standing',
298
- leaderboard: 'zone-leaderboard',
299
- };
300
235
 
301
236
  // task 2174: Standing is an identity band + four rail sections grouped by the
302
237
  // question they answer. This array is the rail's order and its labels; each
303
238
  // entry gets a #standing-<key> anchor container inside #zone-standing that the
304
239
  // group's widgets mount into. The Profile card declares no group, so it stays
305
240
  // above them as the band.
306
- const STANDING_GROUPS = [
307
- { key: 'work', label: 'Work' },
308
- { key: 'karma', label: 'Karma' },
309
- { key: 'progress', label: 'Progress' },
310
- { key: 'achievements', label: 'Achievements' },
311
- ];
312
- const standingGroupId = (key) => `standing-${key}`;
313
241
 
314
242
  // Create ALL four group containers the first time any group is asked for, so
315
243
  // the rail's order is STANDING_GROUPS' — not whichever group's first widget
@@ -317,28 +245,7 @@
317
245
  // is what keeps the Profile band on top: an ungrouped Standing widget appends
318
246
  // straight to #zone-standing, so anything registered before the first grouped
319
247
  // widget lands above the groups. Idempotent.
320
- function ensureStandingGroups() {
321
- const zone = document.getElementById('zone-standing');
322
- if (!zone || document.getElementById(standingGroupId(STANDING_GROUPS[0].key))) return;
323
- for (const g of STANDING_GROUPS) {
324
- const el = document.createElement('div');
325
- el.id = standingGroupId(g.key);
326
- el.className = 'standing-group';
327
- zone.appendChild(el);
328
- }
329
- }
330
248
 
331
- function widgetHost(wgt) {
332
- const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
333
- if (zone) return zone;
334
- if (wgt.group) {
335
- ensureStandingGroups();
336
- const group = document.getElementById(standingGroupId(wgt.group));
337
- if (group) return group;
338
- }
339
- const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
340
- return document.getElementById(viewId) || document.querySelector('main');
341
- }
342
249
 
343
250
  // mountHallWidgets — stamp every registered widget's <section> into its view
344
251
  // container (falling back to <main>), in registry order, then render its
@@ -353,84 +260,12 @@
353
260
  // hidden overflow container and surfaces as a one-line link in a "More" row
354
261
  // at the end of home-main — clicking reveals that one section in place.
355
262
  // The budgets themselves become a CI fitness assertion in task 2177.
356
- const ZONE_BUDGETS = { 'home-stats': 1, 'home-band': 2, 'home-main': 3 };
357
-
358
- function mountHallWidgets() {
359
- const main = document.querySelector('main');
360
- if (!main || typeof window.OTB.hallWidgets !== 'function') return;
361
- const zoneCounts = {};
362
- const overflow = [];
363
- let overflowHost = null;
364
- for (const wgt of window.OTB.hallWidgets()) {
365
- try {
366
- let host = widgetHost(wgt) || main;
367
- const isHomeZone = (wgt.view || 'home') === 'home' && wgt.zone && ZONE_BUDGETS[wgt.zone] != null;
368
- if (isHomeZone) {
369
- zoneCounts[wgt.zone] = (zoneCounts[wgt.zone] || 0) + 1;
370
- if (zoneCounts[wgt.zone] > ZONE_BUDGETS[wgt.zone]) {
371
- if (!overflowHost) {
372
- overflowHost = document.createElement('div');
373
- overflowHost.id = 'zone-home-overflow';
374
- overflowHost.hidden = true;
375
- document.getElementById('zone-home-main')?.appendChild(overflowHost);
376
- }
377
- host = overflowHost || host;
378
- overflow.push({ id: wgt.id, label: wgt.label });
379
- }
380
- }
381
- let section = document.getElementById(wgt.id);
382
- if (!section) {
383
- section = document.createElement('section');
384
- section.id = wgt.id;
385
- host.appendChild(section);
386
- }
387
- section.className = wgt.sectionClass || 'scroll';
388
- if (wgt.labelledby) section.setAttribute('aria-labelledby', wgt.labelledby);
389
- // hiddenByDefault widgets (welcome/onboarding/karma/acclaim/curve) start
390
- // hidden exactly as the old inline markup did; their own render code
391
- // un-hides them when there's something to show.
392
- if (wgt.hiddenByDefault) section.hidden = true;
393
- wgt.mount(section);
394
- } catch (err) {
395
- console.error(`[builders] hall widget "${wgt && wgt.id}" failed to mount`, err);
396
- }
397
- }
398
- if (overflow.length) {
399
- const more = document.createElement('section');
400
- more.className = 'scroll scroll--bare';
401
- more.id = 'home-more-row';
402
- more.innerHTML = `<p class="home-more">More: ${overflow.map((o) =>
403
- `<a href="#" data-reveal="${o.id}">${o.label}</a>`).join(' · ')}</p>`;
404
- document.getElementById('zone-home-main')?.appendChild(more);
405
- more.addEventListener('click', (e) => {
406
- const a = e.target.closest('[data-reveal]');
407
- if (!a) return;
408
- e.preventDefault();
409
- const section = document.getElementById(a.dataset.reveal);
410
- if (section) {
411
- document.getElementById('zone-home-main')?.insertBefore(section, more);
412
- section.hidden = false;
413
- }
414
- a.remove();
415
- if (!more.querySelector('[data-reveal]')) more.remove();
416
- });
417
- }
418
- // brand-apply.js's currency-label pass ran at DOMContentLoaded, BEFORE these
419
- // skeletons existed — re-run it over the mounted widgets so [data-currency-
420
- // label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
421
- window.OTBBranding?.applyCurrencyLabels?.(main);
422
- }
263
+
423
264
 
424
265
  // task 2174: the Standing rail is an OTBKit sectionRail in scrollspy mode —
425
266
  // the kit owns the IntersectionObserver that used to live here as
426
267
  // setupNavObserver(). Built lazily on the first renderNav and refreshed after,
427
268
  // so the same instance survives re-renders.
428
- let standingRail = null;
429
-
430
- // task 1723: the old sticky chip strip is gone. renderNav now settles the
431
- // three nav surfaces the shell owns or hosts:
432
- // 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
433
- // the server enforces every page + endpoint regardless, ADR 0016);
434
269
  // 2. the Home re-open affordance for a dismissed welcome banner (the old
435
270
  // welcome chip's only job);
436
271
  // 3. the Standing view's in-page section rail (#standing-rail) — task 2174:
@@ -438,54 +273,6 @@
438
273
  // widget. A group with every member hidden (a newcomer, an empty karma
439
274
  // feed) hides itself and drops off the rail.
440
275
  // Keeps its (builder, onboarding) signature — all call sites pass them.
441
- function renderNav(builder, onboarding) {
442
- const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
443
- const graduated = !!(onboarding && onboarding.graduated);
444
- // #833: for a newcomer the Work entry surfaces only once the "ship three"
445
- // stage is current — before then the path reads one action at a time.
446
- const stages = Array.isArray(onboarding?.stages) ? onboarding.stages : [];
447
- const shipStepCurrent = stages.some((s) => s.id === 'ship_three' && s.is_current && !s.cleared);
448
- window.OTBShell?.applyAccess({
449
- authed: true,
450
- rank: normalizeRank(builder?.rank),
451
- graduated,
452
- showWork: shipStepCurrent,
453
- builder,
454
- });
455
-
456
- // Re-open affordance: pre-graduation, when the welcome banner is dismissed
457
- // (hidden), a small link brings it back (reopenWelcomeFromNav).
458
- const reopen = document.getElementById('welcome-reopen');
459
- if (reopen) {
460
- const welcomeEl = document.getElementById('welcome-scroll');
461
- reopen.hidden = !(welcomeEl && welcomeEl.hidden && !graduated);
462
- }
463
-
464
- // Standing section rail: one link per group with a visible member.
465
- const rail = document.getElementById('standing-rail');
466
- if (rail) {
467
- const shown = new Set(
468
- window.OTB.hallWidgets()
469
- .filter((wgt) => {
470
- if (!wgt.group) return false;
471
- if (wgt.archonOnly && !isArchon) return false;
472
- const el = document.getElementById(wgt.id);
473
- return !!el && !el.hidden;
474
- })
475
- .map((wgt) => wgt.group),
476
- );
477
- // An empty group would otherwise leave a gap in the panel column.
478
- for (const g of STANDING_GROUPS) {
479
- const el = document.getElementById(standingGroupId(g.key));
480
- if (el) el.hidden = !shown.has(g.key);
481
- }
482
- const items = STANDING_GROUPS
483
- .filter((g) => shown.has(g.key))
484
- .map((g) => ({ id: standingGroupId(g.key), label: g.label }));
485
- if (standingRail) standingRail.refresh(items);
486
- else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
487
- }
488
- }
489
276
 
490
277
  // #748: newcomer gate. A Xenos sees a deliberately spare hall — their
491
278
  // onboarding path and their Standing card — until they graduate to Thetes.
@@ -539,298 +326,13 @@
539
326
  if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
540
327
  }
541
328
 
542
- function renderLanding() {
543
- // task 1723: collapse the shell nav to the signed-out minimum (Home + a
544
- // Sign-in button). Cosmetic — the server gates every page regardless.
545
- window.OTBShell?.applyAccess({ authed: false });
546
-
547
- const welcome = $('#welcome-line');
548
- if (welcome) welcome.textContent = '';
549
-
550
- const landing = $('#landing-root');
551
- if (landing) {
552
- landing.hidden = false;
553
- landing.innerHTML = `
554
- <div class="scroll landing">
555
- <h2 class="scroll__h">Welcome</h2>
556
- <p class="landing__lede">
557
- This is the builders' hub for ${brandWorld()} — where builders claim
558
- work, earn ${curLabel()}, and track their standing. Access is
559
- invite-based: an Archon approves each new builder. To begin, request
560
- access below.
561
- </p>
562
-
563
- <div class="landing__request" id="landing-request">
564
- <h3 class="landing__request-h">New here — request access</h3>
565
- <p class="landing__request-lede">
566
- Leave your GitHub username and an Archon will let you in. No coding
567
- needed to start — once you're approved, sign in with GitHub and a
568
- step-by-step guide takes you the rest of the way.
569
- </p>
570
- <form class="landing__form" id="access-request-form" novalidate>
571
- <label class="landing__field">
572
- <span>GitHub username <span class="landing__req-mark" aria-hidden="true">*</span></span>
573
- <input type="text" id="ar-login" name="github_login" autocomplete="username"
574
- placeholder="e.g. octocat" maxlength="40" required>
575
- </label>
576
- <label class="landing__field">
577
- <span>Your name <span class="landing__opt">(optional)</span></span>
578
- <input type="text" id="ar-name" name="display_name" autocomplete="name"
579
- placeholder="So the Archon knows who you are" maxlength="100">
580
- </label>
581
- <label class="landing__field">
582
- <span>A word on why <span class="landing__opt">(optional)</span></span>
583
- <input type="text" id="ar-note" name="note"
584
- placeholder="How you got here, what you'd build" maxlength="500">
585
- </label>
586
- <p class="landing__form-hint">
587
- No GitHub account yet? Make a free one at
588
- <a href="https://github.com/signup" target="_blank" rel="noopener noreferrer">github.com</a>
589
- first — your username is all we need.
590
- </p>
591
- <button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
592
- <p class="landing__request-status" id="ar-status" aria-live="polite"></p>
593
- </form>
594
- </div>
595
-
596
- <p class="landing__signin-note">
597
- Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
598
- We store only your GitHub login, avatar, and the work you ship.
599
- </p>
600
- </div>
601
- `;
602
- wireAccessRequestForm();
603
- }
604
-
605
- // Hide every widget section — nothing to show until the visitor signs in.
606
- document.querySelectorAll('main .scroll').forEach((el) => {
607
- if (!landing || !landing.contains(el)) el.hidden = true;
608
- });
609
- }
610
329
 
611
330
  // Wires the public landing's access-request form. Submits to the one public
612
331
  // GDS write (POST /access-requests). On success the form collapses into a
613
332
  // confirmation so the stranger knows the Archon has been pinged.
614
- function wireAccessRequestForm() {
615
- const form = $('#access-request-form');
616
- if (!form) return;
617
- form.addEventListener('submit', async (e) => {
618
- e.preventDefault();
619
- const loginEl = $('#ar-login');
620
- const nameEl = $('#ar-name');
621
- const noteEl = $('#ar-note');
622
- const submit = $('#ar-submit');
623
- const status = $('#ar-status');
624
- const login = (loginEl?.value || '').trim().replace(/^@/, '');
625
- if (!login) {
626
- if (status) { status.dataset.kind = 'err'; status.textContent = 'A GitHub username is needed.'; }
627
- if (loginEl) loginEl.focus();
628
- return;
629
- }
630
- if (submit) { submit.disabled = true; submit.textContent = 'Sending…'; }
631
- if (status) { status.dataset.kind = ''; status.textContent = ''; }
632
- try {
633
- const data = await postJSON(`${API}/access-requests`, {
634
- github_login: login,
635
- display_name: (nameEl?.value || '').trim() || null,
636
- note: (noteEl?.value || '').trim() || null,
637
- });
638
- const req = $('#landing-request');
639
- if (req) {
640
- req.innerHTML = `
641
- <h3 class="landing__request-h">Your request is sent ✓</h3>
642
- <p class="landing__request-lede">
643
- ${data && data.already_pending
644
- ? 'You were already in the queue — an Archon will approve you. Once approved, sign in with GitHub to get started.'
645
- : 'An Archon has been notified and will approve you. Once approved, sign in with GitHub to get started.'}
646
- </p>
647
- <p class="landing__request-lede landing__request-lede--soft">
648
- While you wait: install the Claude desktop app at
649
- <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">claude.com/download</a>.
650
- The full step-by-step guide appears here once you're in.
651
- </p>`;
652
- }
653
- } catch (err) {
654
- // Already known to the city — they don't need a request, they need to
655
- // sign in. Both the "already a builder" and "already approved" cases
656
- // collapse the form into a sign-in prompt rather than a red error.
657
- if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
658
- const req = $('#landing-request');
659
- if (req) {
660
- req.innerHTML = `
661
- <h3 class="landing__request-h">You already have access ✓</h3>
662
- <p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
663
- <p class="landing__cta">
664
- <a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
665
- </p>`;
666
- }
667
- return;
668
- }
669
- if (submit) { submit.disabled = false; submit.textContent = 'Request access'; }
670
- if (status) {
671
- status.dataset.kind = 'err';
672
- status.textContent = (err && err.status === 400)
673
- ? 'That does not look like a GitHub username — check it and try again.'
674
- : 'Could not send just now. Try again in a moment.';
675
- }
676
- }
677
- });
678
- }
679
333
 
680
334
  // Reads the module-level lastKarmaState / lastCostState directly — every caller
681
335
  // passed those same globals back in, so the params were redundant plumbing (C10).
682
- function renderProfile(builder) {
683
- const karma = lastKarmaState;
684
- const cost = lastCostState;
685
- const needs = lastNeedsState;
686
- const rebase = lastRebaseState;
687
- const avatar = builder.avatar_url
688
- ? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
689
- : `<div class="profile__avatar" aria-hidden="true"></div>`;
690
-
691
- const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
692
-
693
- const rankBadge = builder.rank
694
- ? renderRankBadge(normalizeRank(builder.rank), { tip: true })
695
- : renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
696
-
697
- // #757: this builder's place on the Roll. Null until the leaderboard rows
698
- // load (they're fetched async after the Standing card's first paint; the
699
- // card re-renders once they arrive) — when null the stat simply omits.
700
- const rollPlace = leaderboardPlace(builder.github_login);
701
-
702
- // V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
703
- // separate counters — drachmae = work shipped, karma = good citizenship
704
- // (peer recognition + Archon ratifications). The tooltip on each badge
705
- // spells the distinction out so a new builder can tell them apart.
706
- const karmaTotal = Number(builder.karma) || 0;
707
- const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
708
- const karmaTrendText = karma30d > 0
709
- ? `+${fmtNum(karma30d)} in 30d`
710
- : (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
711
- const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
712
- const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = work shipped. The two counters move independently — see the karma feed for the audit trail.`;
713
- const drachmaeTooltip = `${curLabel(true)} = total payout from shipped work. Karma is a separate counter for peer recognition and approvals.`;
714
-
715
- // V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
716
- // heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
717
- // are blocked until an Archon raises the budget. Nothing renders under 80%.
718
- const budgetBanner = (cost && (cost.level === 'warn' || cost.level === 'over'))
719
- ? `<div class="profile__budget profile__budget--${cost.level}" role="status">
720
- <strong>${cost.level === 'over' ? 'Monthly budget exceeded' : 'Approaching monthly budget'}</strong>
721
- — $${escapeHtml(String(cost.spend_mtd_usd))} of $${escapeHtml(String(cost.budget_mtd_usd))} this month (${escapeHtml(String(cost.pct_used))}%).
722
- ${cost.level === 'over'
723
- ? 'Shipping and art generation are paused until an Archon raises your budget.'
724
- : 'At 100% these actions pause until an Archon raises your budget.'}
725
- </div>`
726
- : '';
727
-
728
- // task 1013 / ADR 0073: the consistent "Action needed" / "we cover it"
729
- // banner. Renders the highest-priority `needs` item that isn't satisfied —
730
- // amber + a Settings button when the system needs an input (e.g. the image
731
- // key once free coverage ends), a calm green note while we cover it. Reads
732
- // the SAME /me `needs` data the CLI session nudge does (builder-needs.js).
733
- const need = (needs && Array.isArray(needs.items))
734
- ? needs.items.find((n) => n && n.state !== 'satisfied')
735
- : null;
736
- const needsBanner = need
737
- ? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
738
- <strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
739
- ${need.action && need.action.href
740
- ? ` <a class="profile__needs-act" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')} →</a>`
741
- : ''}
742
- </div>`
743
- : '';
744
-
745
- // ADR 0120 part 5: the "Rebase owed" banner. `needs_rebase_tasks` are un-landed
746
- // confirmed tasks the publish-reconciler flagged — they GATE new claims (part 3)
747
- // and their per-task reward is PENDING until they land on ship (part 1). Rendered
748
- // above the other banners since it blocks the queue; reads the SAME /me field the
749
- // CLI (/builder-start) does. `task N` form, never a bare #N (the ref rule).
750
- const rebaseTasks = Array.isArray(rebase) ? rebase.filter((t) => t && t.id != null) : [];
751
- const rebaseBanner = rebaseTasks.length
752
- ? `<div class="profile__rebase" role="status">
753
- <strong>Rebase owed — new claims paused</strong> — ${rebaseTasks.length} confirmed ${rebaseTasks.length === 1 ? 'task' : 'tasks'} can't land yet, so ${rebaseTasks.length === 1 ? 'its' : 'their'} reward is pending (it lands on ship). Rebase + re-ship or release ${rebaseTasks.length === 1 ? 'it' : 'them'}: ${rebaseTasks.map((t) => 'task ' + escapeHtml(String(t.id))).join(', ')}.
754
- </div>`
755
- : '';
756
-
757
- $('#profile-body').innerHTML = `
758
- ${rebaseBanner}
759
- ${needsBanner}
760
- ${budgetBanner}
761
- ${avatar}
762
- <div>
763
- <div class="profile__name-row">
764
- <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
765
- ${rankBadge}
766
- </div>
767
- <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
768
- <div class="profile__stats">
769
- <span>Member since ${fmtDate(builder.created_at)}</span>
770
- <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
771
- ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
772
- </div>
773
- <div class="profile__disciplines">
774
- <span class="profile__disc-label">Preferred work:</span>
775
- <span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : 'none chosen yet'}</span>
776
- <a class="profile__disc-edit" href="/settings">Change in Settings →</a>
777
- </div>
778
- </div>
779
- <div class="profile__credits">
780
- <span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
781
- <span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
782
- <span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
783
- <span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
784
- <span class="profile__karma-label">karma</span>
785
- </span>
786
- </div>
787
- `;
788
- const welcomeLine = $('#welcome-line');
789
- if (welcomeLine) {
790
- welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
791
- }
792
-
793
- // task 1723: the Home dashboard mirrors — quick-stat tiles + the standing
794
- // summary card — are fed from the same /me payload. All writes are guarded:
795
- // the widgets are registry-owned and a module could unregister them.
796
- const setText = (id, text) => {
797
- const el = document.getElementById(id);
798
- if (el) el.textContent = text;
799
- };
800
- setText('stat-credits', fmtNum(builder.total_credits));
801
- setText('stat-karma', fmtNum(karmaTotal));
802
- setText('stat-karma-sub', karmaTrendText);
803
- const statRank = document.getElementById('stat-rank');
804
- if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
805
- setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
806
- const statsSection = document.getElementById('home-stats');
807
- if (statsSection && !newcomerGated) statsSection.hidden = false;
808
-
809
- const homeStanding = document.getElementById('home-standing-body');
810
- if (homeStanding) {
811
- homeStanding.innerHTML = `
812
- ${rebaseBanner}
813
- ${needsBanner}
814
- ${budgetBanner}
815
- <div class="profile profile--compact">
816
- ${avatar}
817
- <div>
818
- <div class="profile__name-row">
819
- <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
820
- ${rankBadge}
821
- </div>
822
- <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
823
- <div class="profile__stats">
824
- <span>Member since ${fmtDate(builder.created_at)}</span>
825
- <span>${prefs.length ? escapeHtml(prefs.join(', ')) : 'no preferred work yet'}</span>
826
- </div>
827
- </div>
828
- </div>
829
- `;
830
- }
831
- const homeStandingSection = document.getElementById('home-standing');
832
- if (homeStandingSection && !newcomerGated) homeStandingSection.hidden = false;
833
- }
834
336
 
835
337
  // #731: renderCliTokenReissue / issueCliToken (V3.R91 #401) and
836
338
  // renderDiscordLink / unlinkDiscord (F7, ADR 0033) moved to
@@ -874,49 +376,13 @@
874
376
  // Canonical order of the onboarding stages, mirroring
875
377
  // src/bongos/onboarding-state.js STAGES. 'graduated' is the terminal stage;
876
378
  // when current === 'graduated' the panel hides entirely.
877
- const ONBOARDING_STAGE_ORDER = [
878
- 'auth_complete',
879
- 'pick_disciplines',
880
- 'first_start',
881
- 'first_claim',
882
- 'ship_three',
883
- 'graduated',
884
- ];
885
-
886
- function stageIndex(stageId) {
887
- const i = ONBOARDING_STAGE_ORDER.indexOf(stageId);
888
- return i === -1 ? 0 : i;
889
- }
890
379
 
891
- function welcomeDismissalKey(builderId) {
892
- return `builders:welcome-dismissed:${builderId}`;
893
- }
894
380
 
895
- function readDismissedStageIndex(builderId) {
896
- try {
897
- const raw = window.localStorage.getItem(welcomeDismissalKey(builderId));
898
- if (raw === null) return null;
899
- const n = Number(raw);
900
- return Number.isInteger(n) ? n : null;
901
- } catch (_) {
902
- // localStorage can throw in private-browsing modes. Treat as "never
903
- // dismissed" — the panel renders, which is the right fail-open
904
- // behaviour for an onboarding affordance.
905
- return null;
906
- }
907
- }
908
381
 
909
382
  // #785: remember which setup config the builder picked (Desktop app vs Claude
910
383
  // Online) so it survives a re-render / reload / tab switch — renderWelcome used
911
384
  // to hardcode 'desktop' every time. Device-level (not per-builder): it's "how I
912
385
  // connect from this machine". Fail-open to 'desktop' (the recommended default).
913
- const SETUP_CONFIG_KEY = 'builders:setup-config';
914
- function readSetupConfig() {
915
- try {
916
- const v = window.localStorage.getItem(SETUP_CONFIG_KEY);
917
- return v === 'online' ? 'online' : 'desktop';
918
- } catch (_) { return 'desktop'; }
919
- }
920
386
  function writeSetupConfig(v) {
921
387
  try { window.localStorage.setItem(SETUP_CONFIG_KEY, v === 'online' ? 'online' : 'desktop'); } catch (_) { /* private mode — won't persist, fine */ }
922
388
  }
@@ -934,151 +400,6 @@
934
400
  );
935
401
  }
936
402
 
937
- function renderWelcomeCore(builder, onboarding) {
938
- const scroll = $('#welcome-scroll');
939
- const body = $('#welcome-body');
940
- if (!scroll || !body) return;
941
-
942
- // No builder yet (unauth landing) → hide and exit.
943
- if (!builder || !builder.id) { scroll.hidden = true; return; }
944
-
945
- // Graduated → hide entirely. The checklist below will also hide itself.
946
- if (onboarding && onboarding.graduated) { scroll.hidden = true; return; }
947
-
948
- // Compute current stage index. When the /me payload didn't carry an
949
- // onboarding object (legacy), default to the first stage so the panel
950
- // still greets the builder rather than vanishing silently.
951
- const currentStageId = (onboarding && onboarding.current_stage) || ONBOARDING_STAGE_ORDER[0];
952
- const currentIdx = stageIndex(currentStageId);
953
-
954
- // Dismissal check. Reappears on stage regression (currentIdx < dismissed).
955
- const dismissedAt = readDismissedStageIndex(builder.id);
956
- if (dismissedAt !== null && currentIdx >= dismissedAt) {
957
- scroll.hidden = true;
958
- return;
959
- }
960
-
961
- const rankBadge = renderRankBadge(normalizeRank(builder.rank), { tip: true });
962
- const greeting = `Welcome, ${escapeHtml(builder.display_name || builder.github_login || 'builder')}.`;
963
-
964
- // #777: the welcome panel now shows ONLY setup — a two-config selector that
965
- // gets a zero-technical newcomer from "signed into the hall" to a working
966
- // `claude` session on their dev box. Both paths reach the SAME hosted dev
967
- // box (Xenos are box-only, ADR 0035): the Desktop app connects over an
968
- // automated SSH link (/builder-connect — no keys to copy), and Claude Online
969
- // is the in-browser ttyd terminal (ADR 0038 — no install, no SSH). Steps are
970
- // short, scannable, and plain-spoken (theme stays in the headings). The
971
- // "what to do next" arc lives in the Path checklist below — not here.
972
- // #785: restore the builder's last-chosen config (persisted in localStorage)
973
- // so a re-render / reload / tab switch doesn't snap back to Desktop.
974
- const setupCfg = readSetupConfig();
975
- // R60 (task 1199): the hosted dev-box onboarding below is the `dev-box`
976
- // feature module's UI. A vanilla Cloud Bongos instance has dev-box OFF, so
977
- // there is no box to host — show the bring-your-own-machine path instead.
978
- // OTB (dev-box ON) renders the existing flow verbatim, so prod is unchanged.
979
- const devBoxOn = moduleOn('dev-box');
980
- // The #support pointer assumes the Discord module; drop it when discord is off.
981
- const supportFoot = moduleOn('discord')
982
- ? `Stuck on any step? <a href="${API}/auth/discord/start">Join our Discord</a> and ask in <strong>#support</strong>.`
983
- : `Stuck on any step? See the project's README and docs.`;
984
- const setupPanes = devBoxOn ? `
985
- <p class="setup__intro">Your work runs on a dev box we host for you — there's almost nothing to install. Choose how to connect:</p>
986
- <div class="setup__toggle" role="tablist" aria-label="Choose a setup">
987
- <button type="button" class="setup__tab setup__tab--desktop" data-config="desktop" role="tab" aria-selected="${setupCfg === 'desktop'}">Claude Desktop app <span class="setup__tab-tag setup__tab-tag--rec">recommended</span></button>
988
- <button type="button" class="setup__tab setup__tab--online" data-config="online" role="tab" aria-selected="${setupCfg === 'online'}">Claude Online <span class="setup__tab-tag">browser only</span></button>
989
- </div>
990
-
991
- <div class="setup__pane setup__pane--desktop">
992
- <ol class="setup__steps">
993
- <li><strong>Download the Dev Box app:</strong>
994
- <a href="/downloads/devbox/mac-arm64">Mac</a> ·
995
- <a href="/downloads/devbox/mac-x64">Mac (Intel)</a> ·
996
- <a href="/downloads/devbox/win-x64">Windows</a> ·
997
- <a href="/downloads/devbox/win-arm64">Windows (ARM)</a>.
998
- <span class="setup__hint">First open: your computer warns about an unknown developer — that's
999
- expected (we haven't bought the signing certificates yet; the app is ours). Mac: System Settings
1000
- → Privacy &amp; Security → “Open Anyway”. Windows: “More info” → “Run anyway”.</span></li>
1001
- <li><strong>Sign in from the app.</strong> It opens this hall in your browser — click one
1002
- <em>Approve</em> button and you're in. No terminal, nothing to paste.</li>
1003
- <li><strong>Flip the switch.</strong> Your dev box builds itself in a minute or two and the app
1004
- chimes when it's ready. It lives in the menu bar on Mac and the system tray on Windows, and it
1005
- wires up Claude Desktop for you along the way.</li>
1006
- <li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong>
1007
- for Mac or Windows, sign in with your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a>
1008
- account, then open <strong>Code → Environment → “${brandWorld()} Dev Box”</strong>. You're inside —
1009
- build from <code>/workspace</code>.</li>
1010
- </ol>
1011
- </div>
1012
-
1013
- <div class="setup__pane setup__pane--online">
1014
- <ol class="setup__steps">
1015
- <li><strong>Create your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a> account.</strong></li>
1016
- <li><strong>Open your online environment.</strong> This is the environment you will build in — no SSH, nothing to install.
1017
- <div class="setup__box" data-box-panel></div>
1018
- </li>
1019
- <li><strong>Sign in when Claude prompts you.</strong> Claude starts automatically when you open the terminal.
1020
- <span class="setup__hint">To paste the sign-in code: <strong>Ctrl+Shift+V</strong> or right-click — plain Ctrl+V will not paste here. Claude takes over the terminal with its own interface once you're signed in. This guide stays open in its own tab. If Claude didn't start automatically, type <code>claude</code> and press Enter.</span></li>
1021
- <li><strong>Then drive it however you like:</strong>
1022
- <span class="setup__hint"><strong>Remote control (recommended)</strong> — type <code>/remote-control</code> for a link that runs this session in Claude's own web/app interface, the same UI you'd use on a computer. <strong>Or just stay in the online terminal</strong> and build right here in the browser — no remote control needed. Either way, reopening the online terminal later resumes your session right where you left it (closing the tab keeps it running).</span></li>
1023
- </ol>
1024
- </div>
1025
- ` : `
1026
- <p class="setup__intro">Build from your own machine — clone the repository, run the platform locally, and sign in from the terminal.</p>
1027
- <div class="setup__pane">
1028
- <ol class="setup__steps">
1029
- <li><strong>Clone the repository</strong> and install dependencies (<code>npm install</code>).</li>
1030
- <li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong> (or use the Claude Code CLI) and open the project.</li>
1031
- <li><strong>Run <code>/builder-setup</code></strong> to authenticate this machine, then <code>/builder-start</code> to claim your first task.</li>
1032
- </ol>
1033
- </div>
1034
- `;
1035
- // task 469: a "Set up your machine / get the code" door, available in BOTH
1036
- // the hosted dev-box flow and the bring-your-own-machine flow. The flows
1037
- // above get you a session running; this section answers the separate
1038
- // question a first-timer always has — "where does the code come from, and
1039
- // how do I get it onto my own laptop?". It's the hall mirror of the primer's
1040
- // "Step 0 — get the project onto your machine". Collapsed by default so it
1041
- // doesn't crowd the recommended path; the clone block is click-to-copy
1042
- // (copy-on-click.js picks up .welcome__paste). The commands mirror
1043
- // scripts/setup-builder.sh's own header verbatim so they can't drift.
1044
- // The clone commands come from the instance's own repo coords (loaded from
1045
- // GET /api/bongos/instance), not a hardcoded repo, so a fresh instance's newcomer
1046
- // clones ITS project (ADR 0062 §3, task 1705). Falls back to generic guidance
1047
- // until the coords load (or if they're unavailable).
1048
- const cloneBlock = instanceRepo
1049
- ? `git clone ${escapeHtml(instanceRepo.url)}.git\ncd ${escapeHtml(instanceRepo.repo)}\n./scripts/setup-builder.sh`
1050
- : `# clone your instance's repository, cd into it, then run:\n./scripts/setup-builder.sh`;
1051
- const getCode = `
1052
- <details class="setup__getcode">
1053
- <summary class="setup__getcode-summary">Set up your machine / get the code</summary>
1054
- <div class="setup__getcode-body">
1055
- <p class="setup__hint">First install <a href="https://git-scm.com/downloads" target="_blank" rel="noopener noreferrer">Git</a>
1056
- and <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Claude Code</a> — that's all the tooling you need up front.</p>
1057
- <p class="setup__getcode-lede">Then copy the project onto your machine and run the one-shot bootstrap (it checks your setup, installs dependencies, and signs you in):</p>
1058
- <code class="welcome__paste">${cloneBlock}</code>
1059
- <p class="setup__hint">On Windows, run <code>node scripts/setup-builder.js</code> instead of the last line. New to the steps? The
1060
- <a href="/primer">primer</a> walks the whole path in plain language.</p>
1061
- </div>
1062
- </details>`;
1063
- body.innerHTML = `
1064
- <div class="welcome__lede">${greeting} Your standing is ${rankBadge}.</div>
1065
- <div class="setup" data-config="${setupCfg}">
1066
- ${setupPanes}
1067
- ${getCode}
1068
- <p class="setup__foot">${supportFoot}</p>
1069
- </div>
1070
- `;
1071
- // Remember the stage we rendered for, so the dismiss handler can record it.
1072
- scroll.dataset.stageIdx = String(currentIdx);
1073
- scroll.hidden = false;
1074
- // #783/#807: mount the shared dev-box panel into the online pane and paint
1075
- // it from live server state. Browser-only onboarding hides SSH details
1076
- // (showSsh:false) — a newcomer sees only the online terminal. The panel runs
1077
- // its own poll while anything is in flight (survives tab flips / re-renders /
1078
- // reloads / background approvals); mount re-binds to the freshly-rendered DOM.
1079
- const boxMount = body.querySelector('[data-box-panel]');
1080
- if (boxMount) onboardingBox().mount(boxMount);
1081
- }
1082
403
 
1083
404
  // #777/#783/#807: the dev-box management UI now lives in the shared OTBBoxPanel
1084
405
  // module (public-builders/box-panel.js) so it can be reused verbatim on the
@@ -1088,13 +409,6 @@
1088
409
  // created so it survives the very first renderWelcome regardless of which
1089
410
  // <script> finished loading first; a no-op shim keeps callers safe if the
1090
411
  // module somehow failed to load.
1091
- let _onboardingBox = null;
1092
- function onboardingBox() {
1093
- if (!_onboardingBox && window.OTBBoxPanel) {
1094
- _onboardingBox = window.OTBBoxPanel.create({ api: API, showSsh: false, toast });
1095
- }
1096
- return _onboardingBox || { mount() {}, refresh() {}, stop() {} };
1097
- }
1098
412
 
1099
413
  // ---- onboarding checklist (V3.R75 #294; reshaped #777) ------------------
1100
414
  // Renders the "Path into the City" checklist for the CURRENT logged-in
@@ -1112,133 +426,6 @@
1112
426
  // block ({ linked, configured }) — when Discord isn't configured server-side
1113
427
  // the Connect button is suppressed.
1114
428
 
1115
- function renderOnboarding(onboarding, discord) {
1116
- const scroll = $('#onboarding-scroll');
1117
- const list = $('#onboarding-list');
1118
- if (!scroll || !list) return;
1119
- if (!onboarding) {
1120
- scroll.hidden = true;
1121
- return;
1122
- }
1123
- // Hide the panel once the builder has graduated — no point lingering.
1124
- if (onboarding.graduated) {
1125
- scroll.hidden = true;
1126
- return;
1127
- }
1128
- // task 2169 (banner budget: one at a time): while the welcome panel is
1129
- // showing, the checklist waits its turn — it appears when the welcome is
1130
- // dismissed or outgrown. renderWelcome runs first in the same loadAll pass
1131
- // (and every SSE nudge re-runs both), so this stays current.
1132
- const welcomeEl = document.getElementById('welcome-scroll');
1133
- if (welcomeEl && !welcomeEl.hidden) {
1134
- scroll.hidden = true;
1135
- return;
1136
- }
1137
- scroll.hidden = false;
1138
-
1139
- const discordConfigured = !!(discord && discord.configured);
1140
- const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
1141
- if (stages.length === 0) {
1142
- list.innerHTML = `<li class="onboarding-item onboarding-item--placeholder">No onboarding steps recorded yet.</li>`;
1143
- return;
1144
- }
1145
-
1146
- // task 470: a static, INFORMATIONAL lead-in step that sits AHEAD of the
1147
- // data-driven steps — the genuine first action into the city is "obtain the
1148
- // repo" (clone it, or open the hosted dev box where it's already cloned).
1149
- // It mirrors onboarding-state.js PRE_AUTH_STEP. It is NOT one of the tracked
1150
- // checklist steps (it carries no engine stage, never gates graduation, and
1151
- // isn't counted in the "N of M steps cleared" status line) — it just makes
1152
- // the checklist a newcomer sees acknowledge the very first real action,
1153
- // which the auto-ticking steps below silently assumed already happened.
1154
- const preAuthLead = `
1155
- <li class="onboarding-item onboarding-item--informational" data-stage="obtain_repo">
1156
- <span class="onboarding-item__mark" aria-hidden="true">›</span>
1157
- <div class="onboarding-item__body">
1158
- <p class="onboarding-item__label">First — obtain the repo</p>
1159
- <p class="onboarding-item__hint">Clone the project to your machine, or open your hosted dev box (where it is already cloned). The build commands live inside the repo, so this comes first. The remaining steps below are tracked automatically.</p>
1160
- </div>
1161
- </li>`;
1162
-
1163
- list.innerHTML = preAuthLead + stages.map((s) => {
1164
- const status = s.cleared ? 'cleared' : (s.is_current ? 'current' : 'locked');
1165
- const mark = s.cleared ? '✓' : (s.is_current ? '◆' : '○');
1166
- const hint = s.hint ? `<p class="onboarding-item__hint">${escapeHtml(s.hint)}</p>` : '';
1167
- // #766: the terminal 'ship_three' stage carries a live "N of 3" counter
1168
- // in its `detail` field — render it as a small progress line so the
1169
- // builder watches themselves climb toward Thetes with each ship.
1170
- const detail = s.detail
1171
- ? `<p class="onboarding-item__detail">${escapeHtml(s.detail)}</p>`
1172
- : '';
1173
- // Per-step affordance, shown ONLY while the step is the current, uncleared
1174
- // one — every step now behaves uniformly (Lars, #833): locked/future steps
1175
- // and cleared steps carry no control, so the path reads as one action at a
1176
- // time. (The Discord step already did this; the craft picker and the
1177
- // See-the-works link used to render on locked/cleared steps too.) Every
1178
- // step auto-detects its own completion server-side (#777): join_discord
1179
- // clears on the Discord link, cli_setup once the GDS CLI is authenticated,
1180
- // ship_three at the third ship — no manual ticking.
1181
- let action = '';
1182
- if (s.is_current && !s.cleared) {
1183
- if (s.id === 'pick_disciplines') {
1184
- // V3 (2026-05-30): inline discipline picker → PATCH /me/disciplines.
1185
- // Multi-select: each button is a toggle reflecting the saved prefs, so
1186
- // a builder can pick engineer, artist, ideator, or any mix while the
1187
- // step is current. (Settings can revise crafts later for graduated citizens.)
1188
- const prefs = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
1189
- ? lastMeState.builder.preferred_disciplines : [];
1190
- const discBtn = (value, label) => {
1191
- const on = prefs.includes(value);
1192
- return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
1193
- };
1194
- // R59 (task 1198): 'artist' is offered only when the art-pipeline module
1195
- // is enabled — engineer + ideator are methodology core. moduleOn defaults
1196
- // to true if the flag is missing, so OTB (art on) is unchanged; a vanilla
1197
- // instance hides the button the server would reject anyway.
1198
- action = `
1199
- <div class="onboarding-item__action onboarding-item__disc-pick">
1200
- ${discBtn('engineer', 'Engineer')}
1201
- ${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
1202
- ${discBtn('ideator', 'Ideator')}
1203
- </div>`;
1204
- } else if (s.id === 'ship_three') {
1205
- action = `
1206
- <div class="onboarding-item__action">
1207
- <a class="btn-ghost" href="/work">See the work board →</a>
1208
- </div>`;
1209
- } else if (s.id === 'join_discord' && discordConfigured) {
1210
- // #777: the Connect button runs the existing OAuth flow (GET
1211
- // /auth/discord/start → auto-join + rank role). Full-page nav so the
1212
- // server can set the CSRF state cookie before redirecting to Discord.
1213
- // The step then clears itself once the link lands (discord.linked) —
1214
- // no manual mark. Same control as /builders/settings.
1215
- action = `
1216
- <div class="onboarding-item__action">
1217
- <a class="btn-ghost onboarding-item__discord" href="${API}/auth/discord/start">Connect Discord &amp; join the server</a>
1218
- </div>`;
1219
- }
1220
- }
1221
- return `
1222
- <li class="onboarding-item onboarding-item--${status}" data-stage="${escapeHtml(s.id)}">
1223
- <span class="onboarding-item__mark" aria-hidden="true">${mark}</span>
1224
- <div class="onboarding-item__body">
1225
- <p class="onboarding-item__label">${escapeHtml(s.label)}</p>
1226
- ${hint}
1227
- ${detail}
1228
- </div>
1229
- ${action}
1230
- </li>
1231
- `;
1232
- }).join('');
1233
-
1234
- const cleared = onboarding.progress?.cleared ?? 0;
1235
- const total = onboarding.progress?.total ?? stages.length;
1236
- const status = $('#onboarding-status');
1237
- if (status) {
1238
- status.hidden = false;
1239
- status.textContent = `${cleared} of ${total} steps cleared.`;
1240
- }
1241
- }
1242
429
 
1243
430
  // V3 (2026-05-30): clear the pick_disciplines onboarding stage from the path
1244
431
  // checklist. Saves the chosen craft via the same PATCH /me/disciplines route
@@ -1314,113 +501,14 @@
1314
501
  // filtered, sorted, and paged entirely client-side. Mirrors the status site's
1315
502
  // Lately Inscribed pattern (modules/status-ui/public/status.js renderShippedPage), with
1316
503
  // an additional version filter since a builder's history spans versions.
1317
- const shipState = { all: [] };
1318
504
  // task 2174: paging moved to the OTBKit pager (per-page select + persistence);
1319
505
  // 10 stays the default page size this table has always used.
1320
- const shipsPager = window.OTBKit.makePager('ships', 'otb.standing.shipsPerPage', { defaultPerPage: 10 });
1321
-
1322
- function filterShips(ships) {
1323
- const win = $('#f-ships-window')?.value || 'all';
1324
- const ver = $('#f-ships-version')?.value || '';
1325
- let cutoff = null;
1326
- if (win !== 'all') {
1327
- const days = Number(win);
1328
- if (Number.isFinite(days) && days > 0) {
1329
- cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
1330
- }
1331
- }
1332
- return ships.filter((s) => {
1333
- if (ver && s.version_id !== ver) return false;
1334
- if (cutoff !== null) {
1335
- const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
1336
- if (!t || t < cutoff) return false;
1337
- }
1338
- return true;
1339
- });
1340
- }
506
+
1341
507
 
1342
508
  // task 1957 — a shipped row's payout is the ACTUAL ledger figure (credits_awarded),
1343
509
  // not the creation-time estimate. taskCreditValue picks the right one by status.
1344
- function shipCredit(s) {
1345
- return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
1346
- }
1347
510
 
1348
- function sortShips(ships, mode) {
1349
- const copy = ships.slice();
1350
- if (mode === 'oldest') {
1351
- copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
1352
- } else if (mode === 'drachmae-high') {
1353
- copy.sort((a, b) => shipCredit(b) - shipCredit(a));
1354
- } else if (mode === 'drachmae-low') {
1355
- copy.sort((a, b) => shipCredit(a) - shipCredit(b));
1356
- } else {
1357
- // default: most recent first
1358
- copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
1359
- }
1360
- return copy;
1361
- }
1362
511
 
1363
- function renderRecentShips() {
1364
- const tbody = $('#recent-ships tbody');
1365
- const countEl = $('#ships-count');
1366
- const pagerEl = $('#ships-pager');
1367
-
1368
- const filtered = filterShips(shipState.all);
1369
- const mode = $('#f-ships-sort')?.value || 'recent';
1370
- const sorted = sortShips(filtered, mode);
1371
-
1372
- // task 1723: Home mirrors — the shipped-count stat tile and the compact
1373
- // 5-row recent-work table are fed from the same feed. Guarded writes.
1374
- const statShipped = document.getElementById('stat-shipped');
1375
- if (statShipped) statShipped.textContent = fmtNum(shipState.all.length);
1376
- const homeRecent = document.querySelector('#home-recent-table tbody');
1377
- if (homeRecent) {
1378
- const latest = sortShips(shipState.all, 'recent').slice(0, 5);
1379
- homeRecent.innerHTML = latest.length
1380
- ? latest.map((s) => `
1381
- <tr>
1382
- <td class="ledger__num">#${s.id}</td>
1383
- <td>${escapeHtml(s.title)}</td>
1384
- <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
1385
- <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
1386
- </tr>`).join('')
1387
- : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
1388
- }
1389
- const homeRecentSection = document.getElementById('home-recent');
1390
- if (homeRecentSection && !newcomerGated) homeRecentSection.hidden = false;
1391
-
1392
- if (countEl) {
1393
- const total = shipState.all.length;
1394
- const shown = sorted.length;
1395
- countEl.textContent = total
1396
- ? (shown === total
1397
- ? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
1398
- : `${fmtNum(shown)} of ${fmtNum(total)} in view`)
1399
- : '';
1400
- }
1401
-
1402
- if (!sorted.length) {
1403
- tbody.innerHTML = shipState.all.length
1404
- ? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
1405
- : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
1406
- if (pagerEl) pagerEl.hidden = true;
1407
- return;
1408
- }
1409
-
1410
- const slice = window.OTBKit.paginate(sorted, shipsPager);
1411
-
1412
- tbody.innerHTML = slice.map((s) => `
1413
- <tr>
1414
- <td class="ledger__num">#${s.id}</td>
1415
- <td>
1416
- <div>${escapeHtml(s.title)}</div>
1417
- ${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
1418
- </td>
1419
- <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
1420
- <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
1421
- </tr>
1422
- `).join('');
1423
- }
1424
512
 
1425
513
  // Re-render when filters change. Reset to page 1 so the new filter starts at
1426
514
  // the top — otherwise switching to a smaller window can leave you on page 9
@@ -1501,52 +589,11 @@
1501
589
  // the ecosystem aggregate (individuals stripped), satisfying the
1502
590
  // cross-builder view requirement without a per-builder roster on this page.
1503
591
 
1504
- const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
1505
592
 
1506
593
  // Build a tiny SVG line sparkline from an array of numeric y-values. Returns
1507
594
  // a self-contained <svg> string. A flat/short series degrades to a baseline.
1508
- function sparkline(values, opts = {}) {
1509
- const W = opts.width || 260;
1510
- const H = opts.height || 46;
1511
- const pad = 3;
1512
- const color = opts.color || 'var(--accent-deep)';
1513
- const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
1514
- if (nums.length === 0) {
1515
- return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
1516
- }
1517
- const min = Math.min(...nums);
1518
- const max = Math.max(...nums);
1519
- const span = max - min || 1;
1520
- const n = nums.length;
1521
- const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
1522
- const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
1523
- const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
1524
- const line = pts.join(' ');
1525
- const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
1526
- const last = nums[n - 1];
1527
- return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
1528
- <polyline class="spark__area" points="${area}" fill="${color}" />
1529
- <polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
1530
- ${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
1531
- </svg>`;
1532
- }
1533
595
 
1534
- function statCard(label, value, sub) {
1535
- return `<div class="curve__stat">
1536
- <div class="curve__stat-value">${escapeHtml(String(value))}</div>
1537
- <div class="curve__stat-label">${escapeHtml(label)}</div>
1538
- ${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
1539
- </div>`;
1540
- }
1541
596
 
1542
- function chartCard(title, values, color, footer) {
1543
- const has = Array.isArray(values) && values.length > 0;
1544
- return `<div class="curve__chart">
1545
- <div class="curve__chart-title">${escapeHtml(title)}</div>
1546
- ${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
1547
- ${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
1548
- </div>`;
1549
- }
1550
597
 
1551
598
  const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
1552
599
  const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
@@ -1554,85 +601,6 @@
1554
601
 
1555
602
  // Render the curve for whichever scope is active. `data` is either the self
1556
603
  // payload (/analytics/builder/:id) or the ecosystem payload, normalized below.
1557
- function renderCurveBody() {
1558
- const headlineEl = $('#curve-headline');
1559
- const gridEl = $('#curve-grid');
1560
- const kindsEl = $('#curve-kinds');
1561
- const emptyEl = $('#curve-empty');
1562
- const noteEl = $('#curve-scope-note');
1563
- if (!headlineEl || !gridEl || !kindsEl) return;
1564
-
1565
- const isEco = analyticsState.scope === 'eco';
1566
- const data = isEco ? analyticsState.eco : analyticsState.self;
1567
-
1568
- // Scope toggle (Archon only).
1569
- if (analyticsState.isArchon && noteEl) {
1570
- noteEl.innerHTML =
1571
- `<span class="curve__scope" role="group" aria-label="Analytics scope">` +
1572
- `<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
1573
- `<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
1574
- `</span>`;
1575
- }
1576
-
1577
- if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
1578
-
1579
- const totalShipped = data.headline?.total_shipped || 0;
1580
- if (totalShipped === 0) {
1581
- if (emptyEl) emptyEl.style.display = '';
1582
- headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
1583
- return;
1584
- }
1585
- if (emptyEl) emptyEl.style.display = 'none';
1586
-
1587
- // Headline stat cards.
1588
- const h = data.headline;
1589
- const cards = [];
1590
- cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
1591
- if (isEco) {
1592
- cards.push(statCard('Active builders', h.active_builders ?? 0));
1593
- cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1594
- } else {
1595
- cards.push(statCard('Cost / work', usd(h.cost_per_task)));
1596
- cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1597
- cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
1598
- cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
1599
- }
1600
- headlineEl.innerHTML = cards.join('');
1601
-
1602
- // Sparkline charts.
1603
- const charts = [];
1604
- charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
1605
- `${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`));
1606
- charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
1607
- 'actual minutes per shipped work'));
1608
- if (!isEco) {
1609
- charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1610
- `total ${usd(h.total_cost_usd)}`));
1611
- // n counts quality-graded rows only (task 1002652); a day whose grades
1612
- // were all outages has n=0 and must not plot as a 0% dip.
1613
- charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
1614
- 'share of work passing review'));
1615
- }
1616
- gridEl.innerHTML = charts.join('');
1617
-
1618
- // Top kinds — horizontal bars.
1619
- const kinds = (data.top_kinds || []).slice(0, 8);
1620
- if (kinds.length) {
1621
- const maxN = Math.max(...kinds.map((k) => Number(k.n)));
1622
- kindsEl.innerHTML =
1623
- `<div class="curve__chart-title">Kinds of work shipped</div>` +
1624
- kinds.map((k) => {
1625
- const w = maxN ? Math.round((Number(k.n) / maxN) * 100) : 0;
1626
- return `<div class="curve__kind">
1627
- <span class="curve__kind-label">${escapeHtml(k.kind)}</span>
1628
- <span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
1629
- <span class="curve__kind-n">${k.n}</span>
1630
- </div>`;
1631
- }).join('');
1632
- } else {
1633
- kindsEl.innerHTML = '';
1634
- }
1635
- }
1636
604
 
1637
605
  // Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
1638
606
  // the ecosystem aggregate) and render. A failed/empty feed simply leaves the
@@ -1939,39 +907,10 @@
1939
907
 
1940
908
  // Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
1941
909
  // extra API round-trip per page load — the catalog won't shift in V2.
1942
- const ACH_EMOJI = {
1943
- 'first-ship': '🚢',
1944
- 'first-claim': '🪙',
1945
- 'first-parallel-claim': '⚓',
1946
- 'streak-3': '🔥',
1947
- 'streak-7': '🌋',
1948
- 'art-pipeline-shipper': '🎨',
1949
- 'gds-shipper': '⚙️',
1950
- 'breaking-the-100c-bar': '💯',
1951
- };
1952
910
 
1953
- function renderAchievementRow(ids) {
1954
- if (!Array.isArray(ids) || ids.length === 0) return '';
1955
- const inner = ids
1956
- .map((id) => {
1957
- const emoji = ACH_EMOJI[id];
1958
- if (!emoji) return '';
1959
- // Title attribute is the achievement id (kebab) — terse enough that hover
1960
- // discloses the laurel without overstating the prose.
1961
- return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
1962
- })
1963
- .join('');
1964
- return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
1965
- }
1966
911
 
1967
- const lbState = {
1968
- rows: [],
1969
- sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
1970
- sortDir: 'desc', // 'desc' | 'asc'
1971
- };
1972
912
  // task 2174: OTBKit pager. 25/page is what task 1750 set for the top-100 Roll;
1973
913
  // the per-page select now lets a builder widen it and the choice persists.
1974
- const lbPager = window.OTBKit.makePager('lb', 'otb.standing.leaderboardPerPage', { defaultPerPage: 25 });
1975
914
 
1976
915
  // Stable sort. Server returns rows in drachmae-DESC order, which is also the
1977
916
  // default view; for other modes (rank, karma, drachmae-ASC), re-sort here.
@@ -1979,75 +918,11 @@
1979
918
  // don't jitter between renders. Drachmae is the secondary tie-breaker when
1980
919
  // sorting by karma (V3.R26 #246) so the karma toggle still reads sensibly
1981
920
  // when many builders share the same karma value (e.g. zero).
1982
- function sortedLeaderboardRows() {
1983
- const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
1984
- const dir = lbState.sortDir === 'asc' ? 1 : -1;
1985
- if (lbState.sortBy === 'rank') {
1986
- return indexed
1987
- .sort((a, b) => {
1988
- const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
1989
- return diff !== 0 ? diff : a.idx - b.idx;
1990
- })
1991
- .map(({ row }) => row);
1992
- }
1993
- if (lbState.sortBy === 'karma') {
1994
- return indexed
1995
- .sort((a, b) => {
1996
- const ak = Number(a.row.karma) || 0;
1997
- const bk = Number(b.row.karma) || 0;
1998
- const diff = (ak - bk) * dir;
1999
- if (diff !== 0) return diff;
2000
- // Tie-break on drachmae DESC, then stable on server order.
2001
- const ac = Number(a.row.total_credits) || 0;
2002
- const bc = Number(b.row.total_credits) || 0;
2003
- if (ac !== bc) return bc - ac;
2004
- return a.idx - b.idx;
2005
- })
2006
- .map(({ row }) => row);
2007
- }
2008
- if (lbState.sortBy === 'tokens') {
2009
- // task 1473: sort by total tokens used. tokens_used arrives as a string
2010
- // (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
2011
- // matching the karma branch.
2012
- return indexed
2013
- .sort((a, b) => {
2014
- const at = Number(a.row.tokens_used) || 0;
2015
- const bt = Number(b.row.tokens_used) || 0;
2016
- const diff = (at - bt) * dir;
2017
- if (diff !== 0) return diff;
2018
- const ac = Number(a.row.total_credits) || 0;
2019
- const bc = Number(b.row.total_credits) || 0;
2020
- if (ac !== bc) return bc - ac;
2021
- return a.idx - b.idx;
2022
- })
2023
- .map(({ row }) => row);
2024
- }
2025
- if (lbState.sortBy === 'drachmae') {
2026
- // Server already DESCs by drachmae, so the default mode is a no-op
2027
- // copy. The ASC branch flips it.
2028
- if (lbState.sortDir === 'desc') return lbState.rows.slice();
2029
- return indexed
2030
- .sort((a, b) => {
2031
- const ac = Number(a.row.total_credits) || 0;
2032
- const bc = Number(b.row.total_credits) || 0;
2033
- const diff = (ac - bc) * dir;
2034
- return diff !== 0 ? diff : a.idx - b.idx;
2035
- })
2036
- .map(({ row }) => row);
2037
- }
2038
- return lbState.rows.slice();
2039
- }
2040
921
 
2041
922
  // Map a sortable column id → the lbState.sortBy key. Adding a new sortable
2042
923
  // column means adding an entry here + a header in hall-widgets.js with id
2043
924
  // `lb-sort-<key>`. The handler walks aria-sort across all sortable headers so
2044
925
  // only one column shows its arrow at a time.
2045
- const LB_SORT_HEADERS = {
2046
- rank: 'lb-sort-rank',
2047
- drachmae: 'lb-sort-drachmae',
2048
- karma: 'lb-sort-karma',
2049
- tokens: 'lb-sort-tokens',
2050
- };
2051
926
 
2052
927
  // #757: this builder's place on the Roll, ranked by drachmae (total_credits)
2053
928
  // descending — the canonical leaderboard order, independent of whatever column
@@ -2055,62 +930,7 @@
2055
930
  // already fetched for the Roll, so the Standing card shows it with no extra
2056
931
  // round-trip. Returns { place, total } or null (rows not loaded yet, or this
2057
932
  // builder isn't on the roll). Matched by github_login (rows carry no id).
2058
- function leaderboardPlace(login) {
2059
- const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
2060
- if (!rows.length || !login) return null;
2061
- const ranked = [...rows].sort(
2062
- (a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
2063
- );
2064
- const idx = ranked.findIndex((r) => r.github_login === login);
2065
- return idx === -1 ? null : { place: idx + 1, total: ranked.length };
2066
- }
2067
-
2068
- function renderLeaderboard() {
2069
- const rows = sortedLeaderboardRows();
2070
- // paginate() clamps the page, slices, and paints the whole #lb-* bar.
2071
- const slice = window.OTBKit.paginate(rows, lbPager);
2072
- const start = (lbPager.page - 1) * lbPager.perPage;
2073
- const tbody = $('#leaderboard tbody');
2074
-
2075
- if (!slice.length) {
2076
- tbody.innerHTML = emptyRow(6, 'No more builders to show.');
2077
- } else {
2078
- tbody.innerHTML = slice.map((b, i) => {
2079
- const place = start + i + 1;
2080
- const name = b.display_name || b.github_login || 'unknown builder';
2081
- const badges = renderAchievementRow(b.achievements);
2082
- const rank = renderRankBadge(normalizeRank(b.rank));
2083
- // task 1467: link the name to the builder's public profile page. Guarded
2084
- // on b.id (the /leaderboard row carries it) so a row without an id degrades
2085
- // to plain text rather than a broken /profile?id=undefined link.
2086
- const nameCell = b.id != null
2087
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
2088
- : escapeHtml(name);
2089
- return `
2090
- <tr>
2091
- <td class="ledger__num">${place}</td>
2092
- <td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
2093
- <td class="ledger__rank">${rank}</td>
2094
- <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
2095
- <td class="ledger__drachmae">${fmtNum(b.karma)}</td>
2096
- <td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
2097
- </tr>
2098
- `;
2099
- }).join('');
2100
- }
2101
933
 
2102
- // Reflect the active sort on every sortable header. Only one column
2103
- // shows its arrow at a time; the others reset to "none" — matches the
2104
- // ARIA-sort spec.
2105
- for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
2106
- const head = document.getElementById(headerId);
2107
- if (!head) continue;
2108
- const ariaSort = (lbState.sortBy === key && lbState.sortDir)
2109
- ? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
2110
- : 'none';
2111
- head.setAttribute('aria-sort', ariaSort);
2112
- }
2113
- }
2114
934
 
2115
935
  // Cycle: pick column → desc → asc → back to default (drachmae desc).
2116
936
  // For a click on the active column we just flip direction; for a click on
@@ -2532,6 +1352,23 @@
2532
1352
 
2533
1353
  // The #NNN deep-link record overlay moved to task-detail.js (task 1753) —
2534
1354
 
1355
+ // ---- renderers (task 1003206) -------------------------------------------
1356
+ // The view painters live in hall-render.js. They are handed a ctx, not copies:
1357
+ // the seven live fields below are GETTERS, so a renderer called at any point
1358
+ // reads what this page holds NOW rather than what it held at boot. Everything
1359
+ // else is stable for the life of the page. This call sits AFTER every
1360
+ // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1361
+ const { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1362
+ API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1363
+ get instanceRepo() { return instanceRepo; },
1364
+ get lastCostState() { return lastCostState; },
1365
+ get lastKarmaState() { return lastKarmaState; },
1366
+ get lastMeState() { return lastMeState; },
1367
+ get lastNeedsState() { return lastNeedsState; },
1368
+ get lastRebaseState() { return lastRebaseState; },
1369
+ get newcomerGated() { return newcomerGated; },
1370
+ });
1371
+
2535
1372
  document.addEventListener('DOMContentLoaded', () => {
2536
1373
  // BV1.R75: stamp the registry's <section>s into <main> BEFORE wiring events —
2537
1374
  // wireEvents() + the data-render code address the widgets by their static ids,