@bongos/core 1.24.28 → 1.24.30

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 (63) hide show
  1. package/.bongos-core.json +100 -70
  2. package/.claude/skills/builder-claim/SKILL.md +1 -1
  3. package/.claude/skills/builder-ship/SKILL.md +1 -1
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +4 -0
  6. package/clients/bongos-client/index.cjs +4 -0
  7. package/clients/bongos-client/index.d.ts +4 -0
  8. package/clients/bongos-client/index.mjs +4 -0
  9. package/docs/api/openapi.json +36 -1
  10. package/docs/api-reference.md +7 -1
  11. package/docs/copy-inventory.md +247 -280
  12. package/docs/copy-registry.json +381 -691
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +5 -10
  15. package/docs/page-readings.json +327 -393
  16. package/modules/builder-settings/interaction-prefs.js +2 -2
  17. package/modules/government/db.js +29 -0
  18. package/modules/government/docket.js +61 -4
  19. package/modules/government/routes/government.js +2 -0
  20. package/modules/hall-ui/public/builders.js +3 -79
  21. package/modules/hall-ui/public/hall-render.js +6 -81
  22. package/modules/hall-ui/public/hall-widgets.js +4 -45
  23. package/modules/hall-ui/public/home-city-draw.js +275 -0
  24. package/modules/hall-ui/public/home-city.css +66 -0
  25. package/modules/hall-ui/public/home-city.js +223 -0
  26. package/modules/hall-ui/public/index.html +5 -6
  27. package/modules/hall-ui/public/index.states.json +73 -8
  28. package/modules/hall-ui/public/my-agents.js +35 -2
  29. package/modules/hall-ui/public/palette.js +8 -0
  30. package/modules/hall-ui/public/settings-moved.js +41 -0
  31. package/modules/hall-ui/public/settings.css +4 -28
  32. package/modules/hall-ui/public/settings.html +19 -107
  33. package/modules/hall-ui/public/settings.js +20 -215
  34. package/modules/hall-ui/public/settings.states.json +2 -4
  35. package/modules/hall-ui/records/home-city.md +66 -0
  36. package/modules/hall-ui/records/my-agents.md +3 -2
  37. package/modules/hall-ui/records/panel-family.md +2 -0
  38. package/modules/lifecycle/home-city.js +340 -0
  39. package/modules/lifecycle/module.json +1 -0
  40. package/modules/lifecycle/routes/home-city.js +29 -0
  41. package/modules/lifecycle/ship-card.js +2 -2
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +16 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/claim_from_task_and_home.mjs +8 -43
  47. package/tests/government_tidy_up.mjs +21 -24
  48. package/tests/hall_board_world.mjs +10 -12
  49. package/tests/hall_home_ships_bounded.mjs +4 -3
  50. package/tests/hall_my_agents.mjs +88 -2
  51. package/tests/hall_palette.mjs +12 -0
  52. package/tests/hall_settings_world.mjs +64 -100
  53. package/tests/helpers.mjs +6 -5
  54. package/tests/home_bounded_reads.mjs +16 -31
  55. package/tests/home_city.mjs +385 -0
  56. package/tests/home_city_widget.mjs +120 -0
  57. package/tests/interaction_posture.mjs +3 -1
  58. package/tests/leaderboard_roles.mjs +3 -2
  59. package/tests/speciality_offer_ui.mjs +6 -5
  60. package/tests/speciality_walkthrough_ui.mjs +7 -5
  61. package/modules/hall-ui/public/settings-interaction.js +0 -132
  62. package/modules/hall-ui/public/settings-law-locks.js +0 -53
  63. package/modules/hall-ui/public/settings-render.js +0 -138
@@ -174,7 +174,7 @@ function shouldInject(behaviorPrefs) {
174
174
  return contractFor(behaviorPrefs) !== '';
175
175
  }
176
176
 
177
- // The full catalog the /settings UI renders.
177
+ // The full catalog the My agents page renders (it left /settings, task 1005044).
178
178
  function fieldCatalog() {
179
179
  return FIELD_KEYS.map((f) => ({
180
180
  field: f,
@@ -185,7 +185,7 @@ function fieldCatalog() {
185
185
  }));
186
186
  }
187
187
 
188
- // Everything the /me payload + the /settings UI need in one object.
188
+ // Everything the /me payload + the My agents page need in one object.
189
189
  function describeForApi(behaviorPrefs) {
190
190
  return {
191
191
  fields: fieldCatalog(),
@@ -902,6 +902,34 @@ async function listAccessHolders() {
902
902
  return rows;
903
903
  }
904
904
 
905
+ // THE GOAL OF A DOCKET MATTER (task 1005056): which goal each goal-shaped matter
906
+ // belongs to, so Home's project city can put the docket's "yours" over the right
907
+ // tower. A blocker belongs to every goal whose tasks it holds (task_blockers →
908
+ // tasks.goal_id); a criterion and a goal join request carry their goal. The
909
+ // tables are core's; this is a read across the wall in SQL, never a require.
910
+ // → [{ matter_id: 'blockers:12', goal_id: '1000095' }, …]; ids are numeric or skipped.
911
+ async function goalsOfMatters({ blockers = [], criteria = [], joinRequests = [] } = {}) {
912
+ const ids = (list) => (Array.isArray(list) ? list : []).map(String).filter((v) => /^\d{1,18}$/.test(v));
913
+ const [b, c, j] = [ids(blockers), ids(criteria), ids(joinRequests)];
914
+ if (!b.length && !c.length && !j.length) return [];
915
+ const { rows } = await pool.query(
916
+ `SELECT 'blockers:' || tb.blocker_id AS matter_id, t.goal_id::text AS goal_id
917
+ FROM task_blockers tb
918
+ JOIN tasks t ON t.id = tb.task_id
919
+ WHERE tb.blocker_id = ANY($1::bigint[]) AND t.goal_id IS NOT NULL
920
+ UNION
921
+ SELECT 'criteria_uat:' || c.id, c.goal_id::text
922
+ FROM done_when_criteria c
923
+ WHERE c.id = ANY($2::bigint[]) AND c.goal_id IS NOT NULL
924
+ UNION
925
+ SELECT 'goal_join_requests:' || r.id, r.goal_id::text
926
+ FROM goal_membership_requests r
927
+ WHERE r.id = ANY($3::bigint[])`,
928
+ [b, c, j],
929
+ );
930
+ return rows;
931
+ }
932
+
905
933
  // Record that a builder holds an access. Idempotent: an existing row is kept as it
906
934
  // was (its original granted_at/by). → true when a row was added.
907
935
  async function grantAccess(accessKey, builderId, grantedBy) {
@@ -1342,6 +1370,7 @@ module.exports = {
1342
1370
  listLeads,
1343
1371
  listActiveArchons,
1344
1372
  listAccessHolders,
1373
+ goalsOfMatters,
1345
1374
  grantAccess,
1346
1375
  revokeAccess,
1347
1376
  getActiveBuilder,
@@ -932,14 +932,69 @@ async function blockersByPerson(builder, { contributions = registeredContributio
932
932
  return { available: false, reason: 'unavailable', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
933
933
  }
934
934
  await attachAsks(matters, asksDb.asksFor, timeoutMs);
935
+ const seats = await seatsFor(matters);
936
+ if (!seats) return { available: false, reason: 'unavailable', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
937
+ const grouped = waiting.groupByPerson(matters, seats);
938
+ return { available: true, ...grouped, yours: waiting.forViewer(grouped, builder.id) };
939
+ }
940
+
941
+ // The seats a set of matters routes against (loadSeats), reading a board's holders
942
+ // only when some matter names a board seat. null when the seats are unreadable.
943
+ async function seatsFor(matters) {
935
944
  const boardSeat = matters.some((m) => /^board:/.test(routing.seatOf(m)));
936
945
  const government = boardSeat
937
946
  ? await loadGovernment({ decisions: await board.decisionRulesView().catch(() => null) })
938
947
  : { holders: {} };
939
- const seats = await loadSeats(government);
940
- if (!seats) return { available: false, reason: 'unavailable', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
941
- const grouped = waiting.groupByPerson(matters, seats);
942
- return { available: true, ...grouped, yours: waiting.forViewer(grouped, builder.id) };
948
+ return loadSeats(government);
949
+ }
950
+
951
+ // WHAT IS YOURS, PER GOAL (task 1005056). Home's project city puts a salmon dot
952
+ // over a working area for each matter waiting on the viewer; this answers it the
953
+ // docket's way, so the city and the docket can never disagree. The goal-shaped
954
+ // sources are read exactly as buildDocket reads them (each under its own gate),
955
+ // carry their stored asks, and are routed against the seats with routing.js — a
956
+ // matter counts when its route is `yours`. Like docketFor, a viewer without the
957
+ // docket's own atom is routed nothing. Each matter is then placed on its goal(s)
958
+ // (db.goalsOfMatters). A source that fails is left out, as the docket leaves it
959
+ // unavailable; unreadable seats say `available: false` rather than "none".
960
+ // → { available: true, goals: { [goal_id]: count } } | { available: false, reason }
961
+ const GOAL_SOURCES = Object.freeze(['blockers', 'criteria_uat', 'goal_join_requests']);
962
+ async function yoursByGoal(builder, {
963
+ contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS,
964
+ permissions = null, readAsks = asksDb.asksFor, readSeats = seatsFor, goalsOf = db.goalsOfMatters,
965
+ } = {}) {
966
+ if (!builder || builder.id == null) return { available: true, goals: {} };
967
+ const held = new Set(permissions || await resolver.resolveBuilderPermissions(builder.id));
968
+ if (!held.has(GOVERNMENT_GATE.permission)) return { available: true, goals: {} };
969
+ const ctx = Object.freeze({ builderId: String(builder.id), rank: builder.rank || null, has: (p) => held.has(p) });
970
+ const matters = [];
971
+ await Promise.all(GOAL_SOURCES.map(async (source) => {
972
+ const c = contributions.sources.get(source);
973
+ if (!c || !gateAllows(c.gate, ctx)) return;
974
+ const decl = EXPECTED_SOURCES.find((d) => d.source === source);
975
+ try {
976
+ const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
977
+ matters.push(...(Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source, building: c.building || decl.building })).filter(Boolean));
978
+ } catch (err) {
979
+ log.warn({ source, err: err && err.message }, 'yours by goal: a source could not be read');
980
+ }
981
+ }));
982
+ const needs = matters.filter((m) => m.state === 'needs');
983
+ if (!needs.length) return { available: true, goals: {} };
984
+ await attachAsks(needs, readAsks, timeoutMs);
985
+ const seats = await readSeats(needs);
986
+ if (!seats) return { available: false, reason: 'unavailable' };
987
+ routing.routeAll(needs, ctx.builderId, seats, now);
988
+ const mine = needs.filter((m) => m.route && m.route.yours);
989
+ if (!mine.length) return { available: true, goals: {} };
990
+ const tail = (m) => m.id.slice(m.source.length + 1);
991
+ const of = (source) => mine.filter((m) => m.source === source).map(tail);
992
+ const rows = await goalsOf({ blockers: of('blockers'), criteria: of('criteria_uat'), joinRequests: of('goal_join_requests') });
993
+ const goals = {};
994
+ for (const r of rows || []) {
995
+ if (r && r.goal_id != null) goals[String(r.goal_id)] = (goals[String(r.goal_id)] || 0) + 1;
996
+ }
997
+ return { available: true, goals };
943
998
  }
944
999
 
945
1000
  // One matter with its stored ask and the seats, for passing it on to `toSeat`
@@ -1024,5 +1079,7 @@ module.exports = {
1024
1079
  findMatterFor,
1025
1080
  loadSeats,
1026
1081
  blockersByPerson,
1082
+ yoursByGoal,
1083
+ GOAL_SOURCES,
1027
1084
  matterWithSeats,
1028
1085
  };
@@ -171,6 +171,8 @@ const governmentPort = {
171
171
  askRequiredMessage: asks.ASK_REQUIRED_MESSAGE,
172
172
  saveAsk: (...a) => asksDb.saveAsk(...a),
173
173
  asksFor: (...a) => asksDb.asksFor(...a),
174
+ // task 1005056: per goal, the docket matters routed to this viewer (Home's city)
175
+ yoursByGoal: (viewer) => docket.yoursByGoal(viewer),
174
176
  };
175
177
 
176
178
  // An ask arrives bare, or wrapped as { ask } (the shape POST /blockers takes it in).
@@ -185,9 +185,6 @@
185
185
  // (same pattern as lastNeedsState). These are un-landed confirmed tasks that
186
186
  // GATE new claims and whose reward is pending until they land on ship.
187
187
  let lastRebaseState = null;
188
- // The public version progress, which the Home version tab draws (task 1004774).
189
- // Null until it lands.
190
- let lastProgressState = null;
191
188
 
192
189
  // Cached /me payload from the most recent loadAll() call. Used by the
193
190
  // welcome-chip reopen path (#408) so we can re-render the welcome panel
@@ -874,62 +871,6 @@
874
871
  if (btn) window.location.hash = `#/idea/${encodeURIComponent(btn.dataset.triageIdea)}`;
875
872
  });
876
873
 
877
- // ---- Home "Next up" slice (task 2184) -------------------------------------
878
-
879
- // Fill the top of the "Your work" card with the first five rows of the
880
- // claimable queue (server order: priority, then calibrated estimate) so Home
881
- // answers "what's my next action". Best-effort: a primer-gated 403, an auth
882
- // hiccup, or an empty queue hides the slice — Home never shows an error for
883
- // it. Re-runs with every loadAll (SSE nudges keep it current).
884
- async function loadNextUp() {
885
- const wrap = document.getElementById('home-next');
886
- const list = document.getElementById('home-next-list');
887
- const subhead = document.getElementById('home-recent-subh');
888
- if (!wrap || !list) return;
889
- try {
890
- // ?limit=2 — this card is TWO lines. It used to download the entire claimable
891
- // feed, every row with its full description, and `.slice(0, 2)` it: the same
892
- // shape goal 1000072's criterion names, and the one goal-rollup.js was carved to
893
- // fix on the Goals page (5,229,455 bytes to produce forty counters). The server
894
- // already orders by priority then calibrated estimate, so the first two rows ARE
895
- // the top two. `claimable_total` comes back so the foot can say how many are
896
- // waiting instead of leaving two rows to imply there are only two.
897
- // task 1004774: five rows, level with the five Recently shipped rows beside it.
898
- const resp = await getJSON(`${API}/tasks/claimable?limit=5`, { softAuth: true });
899
- // capped as well as asked for: a server that ignores ?limit must not grow the card.
900
- const rows = resp && Array.isArray(resp.claimable) ? resp.claimable.slice(0, 5) : [];
901
- if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
902
- const total = Number(resp.claimable_total);
903
- const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
904
- list.innerHTML = rows.map((t) => {
905
- const credits = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
906
- const mins = t.est_minutes ? ` · ~${escapeHtml(String(t.est_minutes))}m` : '';
907
- // task 1003459 — this row now claims directly (the extracted claim
908
- // action, task 1003458) rather than only linking to the record and
909
- // making the builder find the door there.
910
- return `<li class="next-up__item">
911
- <a class="next-up__title" href="/task/${encodeURIComponent(t.id)}">${escapeHtml(t.title || `task ${t.id}`)}</a>
912
- <span class="next-up__meta">#${escapeHtml(String(t.id))}${mins} · ${fmtNum(credits)} ${escapeHtml(cur)} est.</span>
913
- <button class="task__claim next-up__claim" type="button" data-task-id="${escapeHtml(String(t.id))}" aria-label="Claim task ${escapeHtml(String(t.id))}">Claim</button>
914
- </li>`;
915
- }).join('');
916
- // Name the size of the queue these two came off. Silent when the count is
917
- // missing or not bigger than what is shown — "See all 2" under two rows is
918
- // noise, and a made-up number is worse than none.
919
- const foot = document.getElementById('home-next-foot');
920
- if (foot) {
921
- foot.textContent = Number.isFinite(total) && total > rows.length
922
- ? `See all ${fmtNum(total)} ready work →`
923
- : 'See all ready work →';
924
- }
925
- wrap.hidden = false;
926
- if (subhead) subhead.hidden = false;
927
- } catch (_) {
928
- wrap.hidden = true;
929
- if (subhead) subhead.hidden = true;
930
- }
931
- }
932
-
933
874
  // ---- the Roll’s rows, for the Standing card -----------------------------
934
875
  //
935
876
  // task 1003441 moved the leaderboard TABLE to its own page (/roster,
@@ -1084,11 +1025,6 @@
1084
1025
  // /me because it spans all builders' work), loaded fire-and-forget so a
1085
1026
  // slow/failed feed can't hold up the rest of the hall.
1086
1027
  loadAcclaimFeed();
1087
- // task 2184: the Home "Next up" slice — top of the claimable queue, so the
1088
- // page answers "what's my next action" without a trip to the Work board.
1089
- // Fire-and-forget; hides itself when the feed is gated (primer 403) or empty.
1090
- loadNextUp();
1091
-
1092
1028
  // #408: build the sticky nav now that every section has settled its
1093
1029
  // hidden state (welcome → karma → onboarding → security/roster).
1094
1030
  // The renderNav call also wires the IntersectionObserver for active-
@@ -1108,21 +1044,15 @@
1108
1044
  // down the page — log and show an inline placeholder.
1109
1045
  // #726: the per-skill model picker (Voices) moved to /builders/settings,
1110
1046
  // so the hall no longer fetches /me/skill-prefs here.
1111
- const [achAll, ships, versionsResp, lb, progress] = await Promise.allSettled([
1047
+ const [achAll, ships, versionsResp, lb] = await Promise.allSettled([
1112
1048
  getJSON(`${API}/achievements`),
1113
1049
  // task 1004854: Home's newest few + a count — never the whole history,
1114
1050
  // which only the Accomplishments ledger fetches (hall-render.js loadShipLedger).
1115
1051
  getJSON(`${API}/me/recent-ships?limit=${HOME_SHIPS_LIMIT}`),
1116
1052
  getJSON(`${API}/versions`),
1117
1053
  getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1118
- getJSON(`${API}/public/progress`), // the Home version tab (task 1004774)
1119
1054
  ]);
1120
1055
 
1121
- if (progress.status === 'fulfilled') {
1122
- lastProgressState = progress.value;
1123
- renderGreeting();
1124
- }
1125
-
1126
1056
  if (achAll.status === 'fulfilled') {
1127
1057
  // Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
1128
1058
  // for V2, so the slice is defensive.) Unlocked + locked-with-progress come
@@ -1227,11 +1157,6 @@
1227
1157
  // task 1004833: the shared vote control reports each landed vote to the feed.
1228
1158
  $('#acclaim-feed')?.addEventListener('otb-vote', onAcclaimVote);
1229
1159
 
1230
- // task 1003459 — the Claim button on each Next-up row; the action + the
1231
- // delegated listener live in hall-render.js (builders.js is at the
1232
- // 1,500-line ratchet, hall-ui/CLAUDE.md's "growth goes in the lib/renderer").
1233
- wireNextUpClaim();
1234
-
1235
1160
  $('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
1236
1161
  $('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
1237
1162
  $('#f-ships-sort')?.addEventListener('change', renderRecentShips);
@@ -1346,14 +1271,13 @@
1346
1271
  // reads what this page holds NOW rather than what it held at boot. Everything
1347
1272
  // else is stable for the life of the page. This call sits AFTER every
1348
1273
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1349
- const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager, welcomeDismissalKey, wireNextUpClaim } = window.OTBHallRender({
1350
- API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, loadNextUp, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1274
+ const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1275
+ API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1351
1276
  get instanceRepo() { return instanceRepo; },
1352
1277
  get lastCostState() { return lastCostState; },
1353
1278
  get lastKarmaState() { return lastKarmaState; },
1354
1279
  get lastMeState() { return lastMeState; },
1355
1280
  get lastNeedsState() { return lastNeedsState; },
1356
- get lastProgressState() { return lastProgressState; },
1357
1281
  get lastRebaseState() { return lastRebaseState; },
1358
1282
  get newcomerGated() { return newcomerGated; },
1359
1283
  });
@@ -22,7 +22,7 @@
22
22
  'use strict';
23
23
 
24
24
  window.OTBHallRender = function createHallRender(ctx) {
25
- const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, loadNextUp, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd } = ctx;
25
+ const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd } = ctx;
26
26
  const { escapeHtml, $, fmtNum, moduleOn } = window.OTB;
27
27
 
28
28
  const RANK_UNLOCKS = {
@@ -603,14 +603,6 @@
603
603
  const el = document.getElementById(id);
604
604
  if (el) el.textContent = text;
605
605
  };
606
- setText('stat-credits', fmtNum(builder.total_credits));
607
- setText('stat-karma', fmtNum(karmaTotal));
608
- setText('stat-karma-sub', karmaTrendText);
609
- const statRank = document.getElementById('stat-rank');
610
- if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
611
- setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
612
- const statsSection = document.getElementById('home-stats');
613
- if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
614
606
 
615
607
  // task 1003464: the Home card as the mock draws it — an identity LINE and a
616
608
  // three-row fact ledger. The mock repeats Credits and the place even though
@@ -635,7 +627,7 @@
635
627
  <dl class="home-facts home-facts--grid">
636
628
  <div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
637
629
  <div><dt>Karma</dt><dd>${fmtNum(karmaTotal)}</dd></div>
638
- <div><dt>Shipped</dt><dd id="standing-shipped">${escapeHtml((document.getElementById('stat-shipped') || {}).textContent || '—')}</dd></div>
630
+ <div><dt>Shipped</dt><dd id="standing-shipped">${shipState.seen ? fmtNum(shipState.total) : '—'}</dd></div>
639
631
  <div><dt>Leaderboard place</dt><dd>${place}</dd></div>
640
632
  </dl>
641
633
  <dl class="home-facts">
@@ -716,39 +708,15 @@
716
708
  }
717
709
 
718
710
  // task 1003464: the greeting IS Home's one h1 (the mock opens with it).
719
- // task 1004780: it has no sub-line any more. The version tab under the banner
720
- // shows how far the version is and Next up shows the ready work, so a sentence
721
- // repeating both was redundant (owner, 2026-10-23).
711
+ // task 1004780: it has no sub-line any more (owner, 2026-10-23); since task
712
+ // 1005054 the city under it (home-city.js) is how far the project has come.
722
713
  const greetingWord = (hour) => (hour < 12 ? 'Good morning' : (hour < 18 ? 'Good afternoon' : 'Good evening'));
723
- // The version the tab is about: the one being built with the most work in it.
724
- function currentVersion(progress) {
725
- const rows = progress && Array.isArray(progress.progress) ? progress.progress : [];
726
- return rows.filter((r) => r && r.status === 'building')
727
- .sort((a, b) => (Number(b.total_count) || 0) - (Number(a.total_count) || 0))[0] || null;
728
- }
729
- // task 1004774: the version tab under the Home banner, drawn as a bar.
730
- // Hidden while no version is being built.
731
- function renderVersionTab(v) {
732
- const tab = document.getElementById('home-version');
733
- if (!tab) return;
734
- if (!v) { tab.hidden = true; return; }
735
- const pct = Math.max(0, Math.min(100, Math.round(Number(v.percent_complete) || 0)));
736
- const name = document.getElementById('home-version-name');
737
- const fill = document.getElementById('home-version-fill');
738
- const pctEl = document.getElementById('home-version-pct');
739
- if (name) name.textContent = v.version_id;
740
- if (fill) fill.style.width = `${pct}%`;
741
- if (pctEl) pctEl.textContent = `${pct}% complete`;
742
- tab.setAttribute('aria-label', `${v.version_id} is ${pct} percent complete`);
743
- tab.hidden = false;
744
- }
745
714
  function renderGreeting() {
746
715
  const h1 = document.getElementById('hall-h1');
747
716
  const builder = ctx.lastMeState && ctx.lastMeState.builder;
748
717
  if (h1 && builder) {
749
718
  h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
750
719
  }
751
- renderVersionTab(currentVersion(ctx.lastProgressState));
752
720
  }
753
721
  const ONBOARDING_STAGE_ORDER = [
754
722
  'auth_complete',
@@ -1096,28 +1064,9 @@
1096
1064
  const countEl = $('#ships-count');
1097
1065
  const pagerEl = $('#ships-pager');
1098
1066
 
1099
- // task 1723: Home mirrors — the shipped-count stat tile and the compact
1100
- // 5-row recent-work table are fed from the same feed. Guarded writes.
1101
- const statShipped = document.getElementById('stat-shipped');
1102
- if (statShipped) statShipped.textContent = fmtNum(shipState.total);
1103
- // task 1004774: the Standing hover card carries the same count.
1067
+ // task 1004774: the Standing hover card carries the shipped count.
1104
1068
  const standingShipped = document.getElementById('standing-shipped');
1105
1069
  if (standingShipped) standingShipped.textContent = fmtNum(shipState.total);
1106
- const homeRecent = document.querySelector('#home-recent-table tbody');
1107
- if (homeRecent) {
1108
- const latest = sortShips(shipState.recent, 'recent').slice(0, HOME_SHIPS_LIMIT);
1109
- homeRecent.innerHTML = latest.length
1110
- ? latest.map((s) => `
1111
- <tr>
1112
- <td class="ledger__num">#${s.id}</td>
1113
- <td>${escapeHtml(s.title)}</td>
1114
- <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
1115
- <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
1116
- </tr>`).join('')
1117
- : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
1118
- }
1119
- const homeRecentSection = document.getElementById('home-recent');
1120
- if (homeRecentSection && !ctx.newcomerGated) homeRecentSection.hidden = false;
1121
1070
 
1122
1071
  // The ledger waits for its own fetch (loadShipLedger); until then it keeps its placeholder.
1123
1072
  const all = shipState.all;
@@ -1378,31 +1327,7 @@
1378
1327
  return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
1379
1328
  }
1380
1329
 
1381
- // task 1003459 — the Claim button on each Home "Next up" row, through the
1382
- // hall's ONE claim action (task 1003458) rather than a third hand-rolled
1383
- // POST /claims. Lives here, not builders.js, because that file is at the
1384
- // 1,500-line ratchet (hall-ui/CLAUDE.md: "growth in the page half has to be
1385
- // paid for; new pure helper → the lib/renderer"). refresh re-fetches the
1386
- // two-row slice so a claimed task drops out of it immediately.
1387
- function wireNextUpClaim() {
1388
- const claimAction = window.OTBClaimAction.makeClaimAction({ postJSON, toast, API, refresh: loadNextUp });
1389
- $('#home-next-list')?.addEventListener('click', (e) => {
1390
- const btn = e.target.closest('.next-up__claim');
1391
- if (!btn || btn.disabled) return;
1392
- const taskId = btn.dataset.taskId;
1393
- btn.disabled = true;
1394
- btn.textContent = '…';
1395
- claimAction.claim(taskId).then((out) => {
1396
- // A redirect to sign-in is already navigating away; re-enabling here
1397
- // would just invite a click that can't land.
1398
- if (!out.ok && !out.redirected) {
1399
- btn.disabled = false;
1400
- btn.textContent = 'Claim';
1401
- }
1402
- });
1403
- });
1404
- }
1405
1330
 
1406
- return { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, loadShipLedger, populateShipVersionFilter, setRecentShips, shipState, shipsPager, welcomeDismissalKey, wireNextUpClaim };
1331
+ return { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, loadShipLedger, populateShipVersionFilter, setRecentShips, shipState, shipsPager, welcomeDismissalKey };
1407
1332
  };
1408
1333
  })();
@@ -62,29 +62,8 @@
62
62
  '<p class="ledger__note" id="onboarding-status" hidden></p>',
63
63
  { view: 'home', zone: 'home-banner', labelledby: 'onboarding-h', hiddenByDefault: true });
64
64
 
65
- // 0c. Quick stats — headline numbers for the Home dashboard (task 1723).
66
- // Filled by builders.js: credits + karma + rank from /me (renderProfile),
67
- // shipped count once the ships feed settles (renderRecentShips).
68
- // task 2169: each tile is a LINK — the stat row doubles as the fastest
69
- // navigation on the page (Credits → the economy doc, Shipped/Karma → the
70
- // Standing sections that explain them, Rank → the ladder).
71
- w('home-stats', 'Overview', 15,
72
- '<h2 id="home-stats-h" class="sr-only">Overview</h2>' +
73
- '<div class="stat-row" id="home-stats-row">' +
74
- '<a class="stat-tile" href="/drachmae"><span class="stat-tile__label" data-currency-label="caps">Credits</span><span class="stat-tile__num" id="stat-credits">—</span></a>' +
75
- '<a class="stat-tile" href="#accomplishments-work"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></a>' +
76
- '<a class="stat-tile" href="#accomplishments-karma"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></a>' +
77
- '<a class="stat-tile" href="/ranks"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></a>' +
78
- '</div>' +
79
- // task 1004774: the version tab under the banner — how far the version being
80
- // built has come. hall-render.js renderGreeting fills it from the same
81
- // progress read as the greeting's sub-line; hidden until there is a version.
82
- '<a class="version-tab" id="home-version" href="/roadmap" hidden>' +
83
- '<span class="version-tab__name" id="home-version-name"></span>' +
84
- '<span class="version-tab__bar" aria-hidden="true"><span class="version-tab__fill" id="home-version-fill"></span></span>' +
85
- '<span class="version-tab__pct" id="home-version-pct"></span>' +
86
- '</a>',
87
- { view: 'home', zone: 'home-stats', sectionClass: 'scroll scroll--bare', labelledby: 'home-stats-h', hiddenByDefault: true });
65
+ // (task 1005054: the quick-stats strip and the version tab left Home. The
66
+ // zone they held, home-stats, is the project city's — home-city.js.)
88
67
 
89
68
  // (task 1004774: the Standing summary is no longer a Home card. It is the
90
69
  // chip at the top right of the page head — index.html #hall-standing — whose
@@ -97,28 +76,8 @@
97
76
  // goal-inbox.js owns (#attention-ideas), the single landing place for every
98
77
  // actionable item. builders.js still gates the /inbox fetch to Metic+.)
99
78
 
100
- // 0e. Your work — "what's my next action" + the compact recent table
101
- // (task 1723; grown by task 2184). The Next-up slice surfaces the top of the
102
- // claimable queue (builders.js loadNextUp fills it; hidden when the feed is
103
- // gated or empty), then the 5-row recent-ships table below it.
104
- w('home-recent', 'Your work', 28,
105
- '<h2 id="home-recent-h" class="scroll__h">Your work</h2>' +
106
- // task 1004774: Next up and Recently shipped sit side by side (stacking
107
- // again on a phone); when Next up is hidden, shipped takes the full width.
108
- '<div class="home-split">' +
109
- '<div class="home-split__col" id="home-next" hidden>' +
110
- '<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
111
- '<ul class="next-up" id="home-next-list"></ul>' +
112
- '<p class="next-up__foot"><a href="/work" id="home-next-foot">See all ready work →</a></p>' +
113
- '</div>' +
114
- '<div class="home-split__col">' +
115
- '<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
116
- '<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
117
- '<tbody><tr><td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…</span></td></tr></tbody></table>' +
118
- '<p class="card-foot"><a href="#accomplishments-work">All your work →</a></p>' +
119
- '</div>' +
120
- '</div>',
121
- { view: 'home', zone: 'home-main', labelledby: 'home-recent-h', hiddenByDefault: true });
79
+ // (task 1005054: Your work — Next up and Recently shipped — left Home; ready
80
+ // work is the Work board's and your history is Accomplishments'.)
122
81
 
123
82
  // 0f. The founding wall (task 1004424) — everyone who joined while the project was
124
83
  // being founded, founder first, each with their mark. Hidden until the founding