@bongos/core 1.24.42 → 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 (79) hide show
  1. package/.bongos-core.json +93 -83
  2. package/docs/copy-inventory.md +86 -104
  3. package/docs/copy-registry.json +159 -351
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/onboarding/primer.md +1 -1
  6. package/docs/page-inventory.json +3 -3
  7. package/docs/page-readings.json +386 -428
  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 +22 -505
  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 +23 -524
  24. package/modules/hall-ui/public/hall-widgets.js +11 -109
  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 +6 -19
  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/shell-trail.js +2 -1
  47. package/modules/hall-ui/public/shell.js +28 -25
  48. package/modules/hall-ui/public/street.html +4 -4
  49. package/modules/hall-ui/public/studio.html +4 -4
  50. package/modules/hall-ui/public/task.html +4 -4
  51. package/modules/hall-ui/public/thinking.html +4 -4
  52. package/modules/hall-ui/public/tweak-editor.html +4 -4
  53. package/modules/hall-ui/public/watch.html +4 -4
  54. package/modules/hall-ui/public/work.html +4 -4
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +6 -0
  58. package/src/module-api.js +1 -1
  59. package/tests/curve_controls_delegation.mjs +9 -8
  60. package/tests/curve_kind_overlay.mjs +5 -5
  61. package/tests/hall_acclaim_vote_control.mjs +5 -3
  62. package/tests/hall_audit.mjs +10 -6
  63. package/tests/hall_board_world.mjs +40 -26
  64. package/tests/hall_builders_page_pass.mjs +14 -5
  65. package/tests/hall_home_ships_bounded.mjs +10 -21
  66. package/tests/hall_kit_charts.mjs +3 -2
  67. package/tests/hall_loading.mjs +2 -1
  68. package/tests/hall_nav.mjs +36 -37
  69. package/tests/hall_numbers_page.mjs +100 -0
  70. package/tests/hall_palette.mjs +19 -24
  71. package/tests/hall_profile_world.mjs +7 -2
  72. package/tests/hall_reading_room.mjs +3 -2
  73. package/tests/helpers.mjs +8 -2
  74. package/tests/leaderboard_roles.mjs +1 -1
  75. package/tests/module_contributions.mjs +6 -4
  76. package/tests/nav_permission_atoms.mjs +3 -2
  77. package/tests/page_tweak_artist.mjs +2 -1
  78. package/tests/rank_tier_single_source.mjs +3 -0
  79. package/tests/task_visuals.mjs +3 -1
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // Carved out of builders.js by task 1003206 (goal 1000079, the R03 de-monolith
4
4
  // plan): that file was 2,558 lines, the largest first-party file left over the
5
- // 1,500-line ratchet. This holds the view painters — landing, profile, welcome,
6
- // onboarding, recent ships, the AI-dev curve, the nav and hall
7
- // widgets — plus the helpers and view state only they touch.
5
+ // 1,500-line ratchet. This holds the view painters — landing, the Standing
6
+ // chip, the shipped count, the nav and hall widgets — plus the helpers and view
7
+ // state only they touch. (The Accomplishments view and its curve, reviews and
8
+ // feeds moved to profile-own.js in task 1005050.)
8
9
  //
9
10
  // WHY A FACTORY, and not a flat namespace like goals-lib.js. goals-lib.js can be
10
11
  // flat because everything in it is pure. These renderers are not: they need the
@@ -22,7 +23,7 @@
22
23
  'use strict';
23
24
 
24
25
  window.OTBHallRender = function createHallRender(ctx) {
25
- const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd } = ctx;
26
+ const { API, RANK_ORDER, api, brandWorld, curLabel, normalizeRank, postJSON, rankLabel, rankWeight, toast } = ctx;
26
27
  const { escapeHtml, $, fmtNum, moduleOn } = window.OTB;
27
28
 
28
29
  const RANK_UNLOCKS = {
@@ -49,66 +50,6 @@
49
50
  };
50
51
  let rankTipSeq = 0;
51
52
 
52
- // "Where you stand" (task 1005046): the personal half of the retired Ranks
53
- // page, now a card on Accomplishments. The ladder itself — every rank and what
54
- // it unlocks — is the Primer's rank-ladder chapter, written once; this card
55
- // says only what is true for the VIEWER: their rank, what it is, the rank
56
- // above, and how they reach it. Moved from ranks.js with the page.
57
- const RANK_TITLE = {
58
- xenos: 'The newcomer',
59
- thetes: 'The proven builder',
60
- metic: 'The trusted builder',
61
- archon: 'The steward',
62
- };
63
- const RANK_BLURB = {
64
- xenos: 'A newcomer with a deliberately small surface: onboarding, safe starter tasks, and your own standing.',
65
- thetes: 'A builder who has shipped their first work — the day-to-day surfaces are open.',
66
- metic: 'A trusted builder: triage, reviews, planning sessions, and security reporting are open.',
67
- archon: 'The steward of the whole system — approvals, the roster, budgets, and the Archon pages.',
68
- };
69
- // How a builder rises FROM their current rank. NB: these strings contain
70
- // markup (<strong>) and are injected via innerHTML without escaping, which is
71
- // safe only because they are author-controlled constants. Never source them
72
- // from server or user data without switching the render site to escapeHtml.
73
- const RISE_FROM = {
74
- xenos:
75
- `Claim and ship the newcomer-friendly tasks an Archon has opened to newcomers. Each clean ship earns ${curLabel()}; each ratified idea earns karma. On your <strong>third</strong> clean ship you are promoted automatically to <strong>Thetes</strong>, out of the newcomer sandbox with the whole queue open to you. No fee, no application; the third ship does it.`,
76
- thetes:
77
- 'You have the run of the queue. Promotion to <strong>Metic</strong> (triage, blockers, planning, the shaping of work) is granted by a standing Archon alone, to a builder whose judgement has earned the project’s trust. Keep shipping; rank is granted, never bought.',
78
- metic:
79
- 'The <strong>Archon</strong> rank is granted rarely, by a standing Archon alone, to a Metic who has shaped the project’s direction and earned its deepest trust. There is no checklist, only the weight of what you have built.',
80
- archon:
81
- 'You hold the highest rank. There is no rank above it, only the care of everything below.',
82
- };
83
- // The rank one rung up, read off the injected tier map rather than a second
84
- // copy of the ladder (tests/rank_tier_single_source.mjs).
85
- function nextRank(rank) {
86
- const want = (RANK_ORDER[rank] || 0) + 1;
87
- return Object.keys(RANK_ORDER).find((r) => RANK_ORDER[r] === want) || null;
88
- }
89
- // Intentionally not on the factory's return object: renderProfile is its one
90
- // caller, so it repaints whenever the band does, from the same /me payload.
91
- function renderRankStanding(builder) {
92
- const host = document.getElementById('rank-standing-body');
93
- if (!host) return;
94
- const rank = normalizeRank(builder && builder.rank);
95
- const next = nextRank(rank);
96
- // At the top there is no rung to name; RISE_FROM says so in its own words,
97
- // so the line is left out rather than said twice.
98
- const nextLine = next
99
- ? `<p class="ranks-standing__next">The rank above yours is ${renderRankBadge(next)}.</p>`
100
- : '';
101
- host.innerHTML = `
102
- <div class="ranks-standing">
103
- <div class="ranks-standing__badge">${renderRankBadge(rank)}</div>
104
- <p class="ranks-standing__title">${escapeHtml(RANK_TITLE[rank] || rankLabel(rank))}</p>
105
- <p class="ranks-standing__blurb">${escapeHtml(RANK_BLURB[rank] || '')}</p>
106
- ${nextLine}
107
- </div>
108
- <p class="ranks-rise__body">${RISE_FROM[rank] || RISE_FROM.xenos}</p>
109
- <p class="card-foot"><a href="/primer#the-rank-ladder">Every rank and what it unlocks, in the Primer →</a></p>`;
110
- }
111
-
112
53
  function renderRankBadge(rank, opts = {}) {
113
54
  const label = escapeHtml(rankLabel(rank));
114
55
  const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
@@ -151,33 +92,10 @@
151
92
  // #zone-leaderboard mount it named.
152
93
  const VIEW_CONTAINERS = {
153
94
  home: 'zone-home-main',
154
- standing: 'zone-standing',
155
95
  };
156
- const STANDING_GROUPS = [
157
- { key: 'work', label: 'Work' },
158
- { key: 'karma', label: 'Karma' },
159
- { key: 'progress', label: 'Progress' },
160
- { key: 'achievements', label: 'Achievements' },
161
- ];
162
- const standingGroupId = (key) => `standing-${key}`;
163
- function ensureStandingGroups() {
164
- const zone = document.getElementById('zone-standing');
165
- if (!zone || document.getElementById(standingGroupId(STANDING_GROUPS[0].key))) return;
166
- for (const g of STANDING_GROUPS) {
167
- const el = document.createElement('div');
168
- el.id = standingGroupId(g.key);
169
- el.className = 'standing-group';
170
- zone.appendChild(el);
171
- }
172
- }
173
96
  function widgetHost(wgt) {
174
97
  const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
175
98
  if (zone) return zone;
176
- if (wgt.group) {
177
- ensureStandingGroups();
178
- const group = document.getElementById(standingGroupId(wgt.group));
179
- if (group) return group;
180
- }
181
99
  const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
182
100
  return document.getElementById(viewId) || document.querySelector('main');
183
101
  }
@@ -247,14 +165,12 @@
247
165
  // label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
248
166
  window.OTBBranding?.applyCurrencyLabels?.(main);
249
167
  }
250
- let standingRail = null;
251
168
 
252
169
  // task 1723: the old sticky chip strip is gone. renderNav now settles the
253
170
  // three nav surfaces the shell owns or hosts:
254
171
  // 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
255
172
  // the server enforces every page + endpoint regardless, ADR 0016);
256
173
  function renderNav(builder, onboarding) {
257
- const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
258
174
  const graduated = !!(onboarding && onboarding.graduated);
259
175
  // #833: for a newcomer the Work entry surfaces only once the "ship three"
260
176
  // stage is current — before then the path reads one action at a time.
@@ -291,31 +207,6 @@
291
207
  setup: (onboarding && onboarding.setup) || null,
292
208
  builder,
293
209
  });
294
-
295
- // Standing section rail: one link per group with a visible member.
296
- const rail = document.getElementById('standing-rail');
297
- if (rail) {
298
- const shown = new Set(
299
- window.OTB.hallWidgets()
300
- .filter((wgt) => {
301
- if (!wgt.group) return false;
302
- if (wgt.archonOnly && !isArchon) return false;
303
- const el = document.getElementById(wgt.id);
304
- return !!el && !el.hidden;
305
- })
306
- .map((wgt) => wgt.group),
307
- );
308
- // An empty group would otherwise leave a gap in the panel column.
309
- for (const g of STANDING_GROUPS) {
310
- const el = document.getElementById(standingGroupId(g.key));
311
- if (el) el.hidden = !shown.has(g.key);
312
- }
313
- const items = STANDING_GROUPS
314
- .filter((g) => shown.has(g.key))
315
- .map((g) => ({ id: standingGroupId(g.key), label: g.label }));
316
- if (standingRail) standingRail.refresh(items);
317
- else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
318
- }
319
210
  }
320
211
  // The project's door, as the platform pushed it into this instance's pack
321
212
  // (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
@@ -363,28 +254,6 @@
363
254
  </details>`;
364
255
  }
365
256
 
366
- // task 1002835: a karma vote on a peer's ship was a vote on a title alone.
367
- // The two facts the ship already recorded — what the grader scored it and
368
- // what it cost — turn it into an informed one. ABSENCE IS NOT ZERO: an
369
- // ungraded ship says so, and so does an unlogged cost, because "0.0" and
370
- // "$0.00" would read as measurements that were never taken.
371
- function acclaimEvidenceHtml(t) {
372
- const score = t.grade_score == null ? null : Number(t.grade_score);
373
- const grade = score == null || !Number.isFinite(score)
374
- ? '<span class="ledger__verdict--none">not graded</span>'
375
- : `<span class="${t.grade_passed ? 'ledger__verdict--ok' : 'ledger__verdict--danger'}">${score.toFixed(1)} / 10</span>`;
376
- // Three states, not two. The route OMITS cost_usd for a builder who may not
377
- // vote, and that is not the same fact as a logged cost of nothing: a reader
378
- // who is shown no figure is told nothing, never "no cost logged".
379
- const parts = [grade];
380
- if (Object.prototype.hasOwnProperty.call(t, 'cost_usd')) {
381
- const usd = t.cost_usd == null ? null : Number(t.cost_usd);
382
- parts.push(usd == null || !Number.isFinite(usd)
383
- ? '<span class="ledger__verdict--none">no cost logged</span>'
384
- : `<span class="acclaim__cost">$${usd.toFixed(2)}</span>`);
385
- }
386
- return `<span class="acclaim__evidence">${parts.join(' · ')}</span>`;
387
- }
388
257
  // The sign-in itself — the card's one primary control, in every mode, so a
389
258
  // returning builder never hunts for it below a form (task 1002851's intent).
390
259
  function returningDoorHtml() {
@@ -541,44 +410,24 @@
541
410
  }
542
411
  });
543
412
  }
413
+ // renderProfile(builder) — the Home surfaces fed by the /me payload: the
414
+ // Standing chip's card, the welcome line and the system notices. The full
415
+ // Accomplishments band it also painted went with the view in task 1005050:
416
+ // your numbers are the Profile page now.
544
417
  function renderProfile(builder) {
545
- const karma = ctx.lastKarmaState;
546
418
  const cost = ctx.lastCostState;
547
419
  const needs = ctx.lastNeedsState;
548
420
  const rebase = ctx.lastRebaseState;
549
- const avatar = builder.avatar_url
550
- ? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
551
- : `<div class="profile__avatar" aria-hidden="true"></div>`;
552
-
553
421
  const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
554
422
 
555
- const rankBadge = builder.rank
556
- ? renderRankBadge(normalizeRank(builder.rank), { tip: true })
557
- : renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
558
-
559
423
  // #757: this builder's place on the Roll. Null until the leaderboard rows
560
- // load (they're fetched async after the Standing card's first paint; the
561
- // card re-renders once they arrive) — when null the stat simply omits.
424
+ // load (they're fetched async after the card's first paint; the card
425
+ // re-renders once they arrive) — when null the stat simply omits.
562
426
  const rollPlace = leaderboardPlace(builder.github_login);
563
-
564
- // V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
565
- // separate counters — drachmae = work shipped, karma = good citizenship
566
- // (peer recognition + Archon ratifications). The tooltip on each badge
567
- // spells the distinction out so a new builder can tell them apart.
568
427
  const karmaTotal = Number(builder.karma) || 0;
569
- const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
570
- const karmaTrendText = karma30d > 0
571
- ? `+${fmtNum(karma30d)} in 30d`
572
- : (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
573
- const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
574
- const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = everything earned. The two counters move independently — see the karma feed for the audit trail.`;
575
- const drachmaeTooltip = `${curLabel(true)} = everything earned: shipped work, approved pages and ideas. Karma is a separate counter for peer recognition and approvals.`;
576
428
 
577
- // task 1004826: the budget, rebase-owed and needs banners no longer stack on
578
- // the Accomplishments band — Home's attention cards carry all three
579
- // (renderSystemNotices, below), so here they only repeated it. `need`,
580
- // `actionNeeds` and `rebaseTasks` are still computed for that call.
581
- // The highest-priority `needs` item that isn't satisfied (ADR 0073).
429
+ // The highest-priority `needs` item that isn't satisfied (ADR 0073); Home's
430
+ // attention cards carry it (renderSystemNotices, below).
582
431
  const need = (needs && Array.isArray(needs.items))
583
432
  ? needs.items.find((n) => n && n.state !== 'satisfied')
584
433
  : null;
@@ -592,35 +441,6 @@
592
441
  : [];
593
442
  // ADR 0120 part 5: un-landed confirmed tasks (Home's "Rebase owed" line).
594
443
  const rebaseTasks = Array.isArray(rebase) ? rebase.filter((t) => t && t.id != null) : [];
595
- $('#profile-body').innerHTML = `
596
- ${avatar}
597
- <div>
598
- <div class="profile__name-row">
599
- <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
600
- ${rankBadge}
601
- </div>
602
- <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
603
- <div class="profile__stats">
604
- <span>Member since ${fmtDate(builder.created_at)}</span>
605
- <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
606
- ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
607
- ${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
608
- </div>
609
- <div class="profile__disciplines">
610
- <span class="profile__disc-label">Preferred work:</span>
611
- <span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</span>
612
- <a class="profile__disc-edit" href="/settings">Change in Settings →</a>
613
- </div>
614
- </div>
615
- <div class="profile__credits">
616
- <span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
617
- <span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
618
- <span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
619
- <span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
620
- <span class="profile__karma-label">karma</span>
621
- </span>
622
- </div>
623
- `;
624
444
  const welcomeLine = $('#welcome-line');
625
445
  if (welcomeLine) {
626
446
  welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
@@ -658,7 +478,7 @@
658
478
  <div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
659
479
  <div><dt>Karma</dt><dd>${fmtNum(karmaTotal)}</dd></div>
660
480
  <div><dt>Shipped</dt><dd id="standing-shipped">${shipState.seen ? fmtNum(shipState.total) : '—'}</dd></div>
661
- <div><dt>Leaderboard place</dt><dd>${place}</dd></div>
481
+ <div><dt>Leaderboard place</dt><dd>${place}${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned."> · ${rolePlaceText(rollPlace)}</span>` : ''}</dd></div>
662
482
  </dl>
663
483
  <dl class="home-facts">
664
484
  <div><dt>Preferred work</dt><dd>${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</dd></div>
@@ -672,12 +492,10 @@
672
492
  // hover it widens left to show the name while .hall-standing__more drops down.
673
493
  const standingChip = document.getElementById('hall-standing');
674
494
  if (standingChip && !ctx.newcomerGated) standingChip.hidden = false;
675
- renderRankStanding(builder);
676
495
  renderSystemNotices({ rebaseTasks, need, actionNeeds, cost });
677
496
  }
678
497
 
679
- // The ONE string for "no preferred work chosen" — the mock's copy — on both
680
- // Standing surfaces (the Home card and the full view), so they cannot drift.
498
+ // The ONE string for "no preferred work chosen" — the mock's copy.
681
499
  const PREFS_EMPTY = 'Not set yet';
682
500
 
683
501
  const ordinal = (n) => {
@@ -689,8 +507,7 @@
689
507
  // task 1003464: the three system notices as inbox LINES on Home (the
690
508
  // placement rule in modules/hall-ui/CLAUDE.md: an action awaiting the builder
691
509
  // is a line item in Needs-attention, never a card). Only what awaits the
692
- // builder lands here — a "we cover it" note is information, and stays on the
693
- // full Standing view with the other two, where the banners still render.
510
+ // builder lands here — a "we cover it" note is information, not an action.
694
511
  function renderSystemNotices({ rebaseTasks, need, actionNeeds, cost }) {
695
512
  const box = document.getElementById('attention-system');
696
513
  if (!box) return;
@@ -749,340 +566,22 @@
749
566
  h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
750
567
  }
751
568
  }
752
- // task 1004854: Home shows the newest few ships and a count, so loadAll (on
753
- // every load AND every live refresh) asks for HOME_SHIPS_LIMIT rows plus the
754
- // server's `total`. Only the Accomplishments ledger — its window/version
755
- // filters, credits sort and pager work over the whole record — needs every
756
- // ship, so `all` is fetched when that view is open, and fetched again only
757
- // when the newest ships or the count have moved since (`recentSig`).
569
+ // task 1004854: Home asks for only the newest HOME_SHIPS_LIMIT ships plus the
570
+ // server's `total` — on every load AND every live refresh. The count rides the
571
+ // Standing chip's card. The whole ledger was Accomplishments', which folded into
572
+ // the Profile page in task 1005050 (its Works ledger reads its own record).
758
573
  const HOME_SHIPS_LIMIT = 5;
759
- const shipState = { recent: [], total: 0, all: null, versions: [], seen: false, recentSig: null, ledgerSig: null, ledgerLoading: null };
574
+ const shipState = { recent: [], total: 0, seen: false };
760
575
  function setRecentShips(payload) {
761
576
  shipState.recent = (payload && payload.ships) || [];
762
577
  shipState.total = Number.isFinite(payload && payload.total) ? payload.total : shipState.recent.length;
763
- shipState.recentSig = JSON.stringify([shipState.total,
764
- shipState.recent.map((s) => [s.id, s.title, s.value_summary, s.credits_awarded, s.visual_url])]);
765
- if (!shipState.seen) {
766
- // Opening Accomplishments later is what asks for the ledger.
767
- document.addEventListener('otbshell:view', (e) => {
768
- if (e.detail && e.detail.view === 'standing') loadShipLedger();
769
- });
770
- shipState.seen = true;
771
- }
578
+ shipState.seen = true;
772
579
  renderRecentShips();
773
- if (window.OTBShell && window.OTBShell.currentView && window.OTBShell.currentView() === 'standing') loadShipLedger();
774
- }
775
- function loadShipLedger() {
776
- // Never before loadAll has run (a signed-out page must not fetch), never twice at once.
777
- if (!shipState.seen || shipState.ledgerSig === shipState.recentSig) return Promise.resolve();
778
- if (shipState.ledgerLoading) return shipState.ledgerLoading;
779
- const sig = shipState.recentSig;
780
- shipState.ledgerLoading = (async () => {
781
- try {
782
- const data = await getJSON(`${API}/me/recent-ships?limit=all`);
783
- shipState.all = (data && data.ships) || [];
784
- shipState.ledgerSig = sig;
785
- populateShipVersionFilter();
786
- renderRecentShips();
787
- } catch (err) {
788
- console.warn('[builders] ship ledger failed', err && err.message ? err.message : err);
789
- const tbody = $('#recent-ships tbody');
790
- if (tbody) tbody.innerHTML = emptyRow(4, 'Your work history could not be loaded.');
791
- } finally {
792
- shipState.ledgerLoading = null;
793
- }
794
- })();
795
- return shipState.ledgerLoading;
796
- }
797
- // The ledger's version filter: the catalog versions this builder has shipped
798
- // to, so it never offers an empty option. Rebuilt (not appended to) on each
799
- // call, keeping the reader's pick, so a live refresh cannot duplicate options.
800
- function populateShipVersionFilter(versions) {
801
- if (versions) shipState.versions = versions;
802
- const sel = $('#f-ships-version');
803
- if (!sel || !shipState.all) return;
804
- const picked = sel.value;
805
- const seen = new Set(shipState.all.map((s) => s.version_id).filter(Boolean));
806
- while (sel.options.length > 1) sel.remove(1);
807
- for (const v of shipState.versions) {
808
- if (!seen.has(v.id)) continue;
809
- const opt = document.createElement('option');
810
- opt.value = v.id;
811
- opt.textContent = v.id;
812
- if (v.name && v.name !== v.id) opt.title = v.name;
813
- sel.appendChild(opt);
814
- }
815
- sel.value = [...sel.options].some((o) => o.value === picked) ? picked : '';
816
- }
817
- const shipsPager = window.OTBKit.makePager('ships', 'otb.standing.shipsPerPage', { defaultPerPage: 10 });
818
- function filterShips(ships) {
819
- const win = $('#f-ships-window')?.value || 'all';
820
- const ver = $('#f-ships-version')?.value || '';
821
- let cutoff = null;
822
- if (win !== 'all') {
823
- const days = Number(win);
824
- if (Number.isFinite(days) && days > 0) {
825
- cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
826
- }
827
- }
828
- return ships.filter((s) => {
829
- if (ver && s.version_id !== ver) return false;
830
- if (cutoff !== null) {
831
- const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
832
- if (!t || t < cutoff) return false;
833
- }
834
- return true;
835
- });
836
- }
837
- function shipCredit(s) {
838
- return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
839
- }
840
- function sortShips(ships, mode) {
841
- const copy = ships.slice();
842
- if (mode === 'oldest') {
843
- copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
844
- } else if (mode === 'drachmae-high') {
845
- copy.sort((a, b) => shipCredit(b) - shipCredit(a));
846
- } else if (mode === 'drachmae-low') {
847
- copy.sort((a, b) => shipCredit(a) - shipCredit(b));
848
- } else {
849
- // default: most recent first
850
- copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
851
- }
852
- return copy;
853
580
  }
854
581
  function renderRecentShips() {
855
- const tbody = $('#recent-ships tbody');
856
- const countEl = $('#ships-count');
857
- const pagerEl = $('#ships-pager');
858
-
859
582
  // task 1004774: the Standing hover card carries the shipped count.
860
583
  const standingShipped = document.getElementById('standing-shipped');
861
584
  if (standingShipped) standingShipped.textContent = fmtNum(shipState.total);
862
-
863
- // The ledger waits for its own fetch (loadShipLedger); until then it keeps its placeholder.
864
- const all = shipState.all;
865
- if (!all || !tbody) return;
866
- const filtered = filterShips(all);
867
- const mode = $('#f-ships-sort')?.value || 'recent';
868
- const sorted = sortShips(filtered, mode);
869
-
870
- if (countEl) {
871
- const total = all.length;
872
- const shown = sorted.length;
873
- countEl.textContent = total
874
- ? (shown === total
875
- ? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
876
- : `${fmtNum(shown)} of ${fmtNum(total)} in view`)
877
- : '';
878
- }
879
-
880
- if (!sorted.length) {
881
- tbody.innerHTML = all.length
882
- ? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
883
- : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
884
- if (pagerEl) pagerEl.hidden = true;
885
- return;
886
- }
887
-
888
- const slice = window.OTBKit.paginate(sorted, shipsPager);
889
-
890
- tbody.innerHTML = slice.map((s) => `
891
- <tr>
892
- <td class="ledger__num">#${s.id}</td>
893
- <td>
894
- <div>${escapeHtml(s.title)}</div>
895
- ${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
896
- ${s.visual_url && window.OTBVisual ? `<div style="margin-top:6px">${window.OTBVisual.shipVisualThumbHtml(s)}</div>` : ''}
897
- </td>
898
- <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
899
- <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
900
- </tr>
901
- `).join('');
902
- }
903
- // No `isArchon` here: the curve's scope was rank-gated until task 1004005, and
904
- // the field is gone rather than left write-only — a state flag nothing reads is
905
- // how a removed gate quietly grows back.
906
- //
907
- // `kind` is the active filter (null = all work). `kinds` is the picker's source
908
- // — the UNFILTERED fingerprint, held separately because a filtered payload's
909
- // top_kinds would otherwise shrink to the one kind selected and strand the
910
- // reader there with no way back.
911
- const analyticsState = { builderId: null, scope: 'self', kind: null, kinds: [], self: null, eco: null };
912
- // The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
913
- // of four chart implementations in this surface. This wrapper keeps the
914
- // Curve's own default colour, which is NOT the kit's: these four charts each
915
- // spend a different accent, and --accent-deep is the first one's.
916
- function sparkline(values, opts = {}) {
917
- return window.OTBKit.sparklineHtml(values, { color: 'var(--accent-deep)', ...opts });
918
- }
919
- function statCard(label, value, sub) {
920
- return `<div class="curve__stat">
921
- <div class="curve__stat-value">${escapeHtml(String(value))}</div>
922
- <div class="curve__stat-label">${escapeHtml(label)}</div>
923
- ${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
924
- </div>`;
925
- }
926
- // `overlay` is the ecosystem benchmark for this same chart, or null. When it is
927
- // present the card also carries a legend — two lines with no key cannot be
928
- // read. The legend is built here rather than inside sparklineHtml because the
929
- // NAMES are this page's ("You" / "All builders"), not the kit's.
930
- function chartCard(title, values, color, footer, overlay) {
931
- const has = Array.isArray(values) && values.length > 0;
932
- const hasOverlay = Array.isArray(overlay) && overlay.length > 0;
933
- const body = has
934
- ? sparkline(values, hasOverlay ? { color, overlay, overlayLabel: 'all builders' } : { color })
935
- : '';
936
- const legend = has && hasOverlay
937
- ? window.OTBKit.chartLegendHtml([
938
- { label: 'You', color },
939
- { label: 'All builders', color: 'var(--ink-soft)' },
940
- ])
941
- : '';
942
- return window.OTBKit.chartCardHtml(title, body ? body + legend : '', footer);
943
- }
944
- // The kind picker (task 1004005) — "how am I doing at THIS kind of work".
945
- //
946
- // Built from analyticsState.kinds, the UNFILTERED fingerprint captured on the
947
- // first load, NOT from the current payload's top_kinds. Under a filter the
948
- // payload would list only the selected kind, so a picker built from it would
949
- // lose every other option the moment one was chosen — the reader could narrow
950
- // once and never move again.
951
- //
952
- // A <select> rather than a row of chips: the kind list is open-ended (it is a
953
- // free-text column with an 'unspecified' bucket), so the control has to cope
954
- // with more options than fit on a line, and a native select is also the one
955
- // that arrives keyboard- and screen-reader-correct with no work.
956
- function kindFilterHtml() {
957
- const kinds = Array.isArray(analyticsState.kinds) ? analyticsState.kinds : [];
958
- if (kinds.length < 2) return ''; // one kind of work is not a choice
959
- const cur = analyticsState.kind;
960
- const opt = (value, label, n) => {
961
- const sel = (value === null ? cur === null : cur === value) ? ' selected' : '';
962
- const count = n === undefined ? '' : ` (${n})`;
963
- return `<option value="${escapeHtml(value === null ? '' : value)}"${sel}>${escapeHtml(label)}${escapeHtml(count)}</option>`;
964
- };
965
- return `<span class="curve__filter">`
966
- + `<label class="curve__filter-label" for="curve-kind">Kind of work</label>`
967
- + `<select id="curve-kind" class="curve__filter-select">`
968
- + opt(null, 'All work')
969
- + kinds.map((k) => opt(k.kind, k.kind, k.n)).join('')
970
- + `</select></span>`;
971
- }
972
-
973
- function renderCurveBody() {
974
- const headlineEl = $('#curve-headline');
975
- const gridEl = $('#curve-grid');
976
- const kindsEl = $('#curve-kinds');
977
- const emptyEl = $('#curve-empty');
978
- const noteEl = $('#curve-scope-note');
979
- if (!headlineEl || !gridEl || !kindsEl) return;
980
-
981
- const isEco = analyticsState.scope === 'eco';
982
- const data = isEco ? analyticsState.eco : analyticsState.self;
983
-
984
- // The controls — scope toggle and kind filter.
985
- //
986
- // WRITTEN BEFORE EVERY EARLY RETURN BELOW, and that ordering is the whole
987
- // reason they are here. A kind with nothing shipped in the window is an
988
- // ordinary, reachable state, and it takes the `totalShipped === 0` branch —
989
- // which blanks the headline, the grid and the bars. If the filter that
990
- // caused the empty view were drawn after that return, the reader would be
991
- // left looking at "no data" with no control on screen to undo it, and the
992
- // only way out would be a page reload.
993
- //
994
- // The scope toggle is no longer Archon-gated (task 1004005): the feed behind
995
- // "All builders" is a public, identity-stripped aggregate, so the gate
996
- // withheld the comparison rather than protecting anything.
997
- if (noteEl) {
998
- noteEl.innerHTML =
999
- `<span class="curve__scope" role="group" aria-label="Analytics scope">` +
1000
- `<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
1001
- `<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
1002
- `</span>` +
1003
- kindFilterHtml();
1004
- }
1005
-
1006
- if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
1007
-
1008
- // Under a kind filter the server cannot answer cost at all (cost_log has no
1009
- // kind), and says so in `unavailable` rather than sending a misleading zero.
1010
- const costUnavailable = Array.isArray(data.unavailable) && data.unavailable.includes('cost_trend');
1011
-
1012
- const totalShipped = data.headline?.total_shipped || 0;
1013
- if (totalShipped === 0) {
1014
- if (emptyEl) emptyEl.style.display = '';
1015
- headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
1016
- return;
1017
- }
1018
- if (emptyEl) emptyEl.style.display = 'none';
1019
-
1020
- // Headline stat cards.
1021
- const h = data.headline;
1022
- const cards = [];
1023
- cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
1024
- if (isEco) {
1025
- cards.push(statCard('Active builders', h.active_builders ?? 0));
1026
- cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1027
- } else {
1028
- // '—', never usd(null) → "$0.00". Zero is a measurement; this is the
1029
- // absence of one, and printing a price here would be a false statement.
1030
- cards.push(costUnavailable
1031
- ? statCard('Cost / work', '—', 'not tracked per kind')
1032
- : statCard('Cost / work', usd(h.cost_per_task)));
1033
- cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1034
- cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
1035
- cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
1036
- }
1037
- headlineEl.innerHTML = cards.join('');
1038
-
1039
- // Sparkline charts.
1040
- //
1041
- // THE OVERLAY ONLY EVER RIDES ON THE 'self' VIEW, and only where the
1042
- // ecosystem publishes the SAME series. Overlaying the benchmark on the
1043
- // benchmark would draw one line twice; and the ecosystem feed carries only
1044
- // ship-rate and duration (grade and cost have their own public feeds and are
1045
- // not part of that payload), so those two are the only charts that CAN
1046
- // compare. A chart with no counterpart simply draws one line, as before.
1047
- const eco = (!isEco && analyticsState.eco) ? analyticsState.eco : null;
1048
- const ecoOf = (rows, pick) => (eco && Array.isArray(eco[rows]) ? eco[rows].map(pick) : null);
1049
- const charts = [];
1050
- charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
1051
- `${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`,
1052
- ecoOf('ship_rate', (r) => r.n)));
1053
- charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
1054
- 'actual minutes per shipped work',
1055
- ecoOf('duration_trend', (r) => r.avg_actual)));
1056
- if (!isEco) {
1057
- // COST IS NOT ANSWERABLE PER KIND — cost_log has no kind and reaches tasks
1058
- // only through a mostly-NULL task_id, so the server returns cost_trend: []
1059
- // and names it in `unavailable` rather than plotting a near-zero line that
1060
- // would read as "this kind of work is cheap". Say so where the chart was.
1061
- if (costUnavailable) {
1062
- charts.push(window.OTBKit.chartCardHtml('Cost / work ($)', '',
1063
- '', 'not tracked per kind of work — clear the filter to see cost'));
1064
- } else {
1065
- charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1066
- `total ${usd(h.total_cost_usd)}`));
1067
- }
1068
- // n counts quality-graded rows only (task 1002652); a day whose grades
1069
- // were all outages has n=0 and must not plot as a 0% dip.
1070
- charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
1071
- 'share of work passing review'));
1072
- }
1073
- gridEl.innerHTML = charts.join('');
1074
-
1075
- // Top kinds — horizontal bars.
1076
- const kinds = (data.top_kinds || []).slice(0, 8);
1077
- if (kinds.length) {
1078
- kindsEl.innerHTML =
1079
- `<div class="kit-chart__title">Kinds of work shipped</div>` +
1080
- window.OTBKit.barSeriesHtml(
1081
- kinds.map((k) => ({ label: k.kind, value: k.n })),
1082
- );
1083
- } else {
1084
- kindsEl.innerHTML = '';
1085
- }
1086
585
  }
1087
586
  // The Roll’s rows, kept ONLY for leaderboardPlace() below — the Standing
1088
587
  // card’s "#N on the leaderboard" stat. sortBy/sortDir went to /roster with
@@ -1120,6 +619,6 @@
1120
619
  }
1121
620
 
1122
621
 
1123
- return { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderProfile, renderRecentShips, HOME_SHIPS_LIMIT, loadShipLedger, populateShipVersionFilter, setRecentShips, shipState, shipsPager };
622
+ return { lbState, mountHallWidgets, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderProfile, renderRecentShips, HOME_SHIPS_LIMIT, setRecentShips, shipState };
1124
623
  };
1125
624
  })();