@bongos/core 1.19.864 → 1.19.866

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.
@@ -2202,5 +2202,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2202
2202
  landed since 1.19.862 with no explicit bump. run 35292877780. (task 1002620)
2203
2203
  1.19.864 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2204
2204
  landed since 1.19.863 with no explicit bump. run 35294233113. (task 1002620)
2205
+ 1.19.865 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2206
+ landed since 1.19.864 with no explicit bump. run 35295856053. (task 1002620)
2207
+ 1.19.866 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2208
+ landed since 1.19.865 with no explicit bump. run 35355019469. (task 1002620)
2205
2209
  ---------------------------------------------------------------------------
2206
2210
  ```
@@ -29,9 +29,12 @@
29
29
  <main id="hall-main">
30
30
  <!-- task 1003455 (goal 1000089): the queue GET /blockers had no page for
31
31
  — the only door in used to be typing #/blocker/N into the address
32
- bar. This is the read half; filing/resolving/linking is task 1003456. -->
32
+ bar. task 1003456 added the write half: filing (any signed-in
33
+ builder) below the heading, and per-row Resolve/Link (blocker.review
34
+ — metic+archon) rendered by blockers.js when the viewer holds it. -->
33
35
  <section class="scroll" id="blockers-scroll" aria-labelledby="blockers-h">
34
36
  <h2 id="blockers-h" class="scroll__h">Open blockers<span class="count-pill" id="blockers-count" hidden></span></h2>
37
+ <div id="blockers-file-area"></div>
35
38
  <div id="blockers-body">
36
39
  <p class="profile__placeholder">Loading…</p>
37
40
  </div>
@@ -1,19 +1,35 @@
1
- // blockers.js — the Blockers page: the full open-blocker queue (task 1003455,
2
- // goal 1000089).
1
+ // blockers.js — the Blockers page: the full open-blocker queue, and now (task
2
+ // 1003456, goal 1000089) the write half — file, resolve, link.
3
3
  //
4
4
  // GET /blockers (modules/ideas/routes/blockers.js) is PUBLIC and had ZERO hall
5
- // consumers before this task — the only blocker a builder could ever see was the
6
- // #/blocker/N overlay task-detail.js already renders, reachable only by typing
7
- // the id into the address bar. Owner decision 2026-08-30: BOTH surfaces, from
8
- // ONE code path — this page is the full queue (a long list has nowhere else to
9
- // live), and goal-inbox.js's attention-inbox slice is the capped summary. Both
10
- // render from blockers-lib.js's blockerFacts(), so the two cannot drift apart
11
- // the way ~10 hand-rolled pagers did before the kit (hall-ui/CLAUDE.md).
5
+ // consumers before task 1003455 — the only blocker a builder could ever see was
6
+ // the #/blocker/N overlay task-detail.js already renders, reachable only by
7
+ // typing the id into the address bar. Owner decision 2026-08-30: BOTH surfaces,
8
+ // from ONE code path — this page is the full queue (a long list has nowhere
9
+ // else to live), and goal-inbox.js's attention-inbox slice is the capped
10
+ // summary. Both render from blockers-lib.js's blockerFacts(), so the two
11
+ // cannot drift apart the way ~10 hand-rolled pagers did before the kit.
12
+ //
13
+ // task 1003456 — the write half. POST /blockers, POST /blockers/:id/resolve
14
+ // and POST /blockers/:id/link already carry their own rank floors (any-builder
15
+ // for filing, blocker.review — metic+archon — for resolve/link; proven by the
16
+ // fitness route-rank-check gate, not re-proven here). AUTHORITY IS UNCHANGED:
17
+ // this file adds no floor of its own, only a COSMETIC mirror of the real one —
18
+ // the exact idea-triage-panel precedent in task-detail.js ("The panel only
19
+ // renders for metic/archon; the server enforces the same gate regardless").
20
+ // The one control open to EVERY signed-in viewer, including a non-reviewer, is
21
+ // filing — and the file form's own "blocking task #" field is deliberately
22
+ // NOT hidden from a non-reviewer, because a builder who is themselves stuck is
23
+ // exactly who most needs to say what it blocks, even though completing the
24
+ // LINK half needs a Metic+. That is the case "every API refusal renders as
25
+ // honest copy rather than a silent no-op" (the task's own done-when) exists
26
+ // for: filing still succeeds, and the honest message says a Metic+ has to
27
+ // finish the link — never a swallowed failure.
12
28
  //
13
- // Read-only: filing, resolving and linking a blocker to a task are task 1003456.
14
29
  // Deliberately UNGATED (tests/hall_page_gate_map.mjs) — its one data source is
15
30
  // public, the roster/drachmae precedent: a page must not be gated tighter than
16
- // the API it fronts.
31
+ // the API it fronts. A signed-out visitor sees the queue and the File button;
32
+ // clicking it redirects to sign-in, same as every other hall write.
17
33
  (function () {
18
34
  'use strict';
19
35
  var kit = window.OTBKit;
@@ -22,6 +38,45 @@
22
38
  var body = document.getElementById('blockers-body');
23
39
  var countPill = document.getElementById('blockers-count');
24
40
  var sub = document.getElementById('blockers-sub');
41
+ var fileArea = document.getElementById('blockers-file-area');
42
+
43
+ // The generated typed client (task-detail.js's own pattern), result-mode so
44
+ // the status-inspecting control flow below (401 → sign-in, 403 → honest
45
+ // copy) reads 1:1.
46
+ var api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
47
+ var toast = function (m) { if (window.OTB && window.OTB.toast) window.OTB.toast(m, { duration: 3200 }); };
48
+
49
+ async function writeJSON(method, path, payload) {
50
+ var res = await api.request(method, path, { body: payload || {} });
51
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start'); throw new Error('redirecting to auth'); }
52
+ var data = null;
53
+ try { data = res.data; } catch (_) { /* empty / non-JSON */ }
54
+ if (!res.ok) { var err = new Error(path + ' → ' + res.status); err.status = res.status; err.data = data || {}; throw err; }
55
+ return data;
56
+ }
57
+
58
+ // The R01/ADR-0116 envelope is `{ error: { code, message } }` — nested, the
59
+ // same trap task-detail.js's errMessage() guards against.
60
+ function errMessage(err) {
61
+ var d = err && err.data;
62
+ var e = d && d.error;
63
+ if (e && typeof e === 'object' && e.message) return e.message;
64
+ return (d && d.message) || '';
65
+ }
66
+ // Honest copy for a refusal — never a silent no-op (the done-when, verbatim).
67
+ // Generic: prefers the server's own message, falling back to the caller's.
68
+ // Filing has NO rank floor, so a 403 there is never "you need Metic+" — that
69
+ // reading belongs only to the two calls that actually gate on blocker.review.
70
+ function refusalMessage(err, fallback) {
71
+ return errMessage(err) || fallback;
72
+ }
73
+ // The reviewer-gated calls' version: a 403 on resolve/link IS the
74
+ // blocker.review floor, so it gets the specific message rather than
75
+ // whatever (or nothing) the server's envelope happened to say.
76
+ function reviewerRefusalMessage(err, fallback) {
77
+ if (err && err.status === 403) return 'You need Metic+ access to do that.';
78
+ return refusalMessage(err, fallback);
79
+ }
25
80
 
26
81
  var taskHref = function (id) {
27
82
  return window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : ('/task/' + encodeURIComponent(id));
@@ -29,6 +84,17 @@
29
84
  var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
30
85
 
31
86
  var all = [];
87
+ // Which rank a reviewer action (Resolve / Link) needs — mirrors the server's
88
+ // blocker.review floor (metic+archon), cosmetically only; the server holds
89
+ // the actual gate regardless of what this variable says.
90
+ var viewer = { rank: null };
91
+ var isReviewer = function () { return viewer.rank === 'metic' || viewer.rank === 'archon'; };
92
+
93
+ // Which row (if any) has an inline action form open, and which form.
94
+ // { id, mode: 'resolve' | 'link' } | null — a row's OWN form, not a global
95
+ // one, so opening a second row's form closes whichever was open (one at a
96
+ // time keeps the list from growing a form per row on a long queue).
97
+ var openRow = null;
32
98
 
33
99
  function rowHtml(b) {
34
100
  var facts = lib.blockerFacts(b, {
@@ -38,12 +104,46 @@
38
104
  blockerHref: blockerHref,
39
105
  taskHref: taskHref,
40
106
  });
107
+ var right = '';
108
+ if (isReviewer()) {
109
+ var id = String(b.id);
110
+ if (openRow && String(openRow.id) === id && openRow.mode === 'resolve') {
111
+ right = '<div class="ov-row__right"><form class="td-form" data-resolve-form="' + lib.esc(id) + '">'
112
+ + '<label class="td-field td-field--wide"><span>Resolution note (optional)</span>'
113
+ + '<input type="text" name="note" maxlength="1000" placeholder="what got un-stuck"></label>'
114
+ + '<div class="td-actions">'
115
+ + '<button type="submit" class="td-btn td-btn--primary" data-submit>Resolve</button>'
116
+ + '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
117
+ + '</div><p class="td-action-note" data-err></p></form></div>';
118
+ } else if (openRow && String(openRow.id) === id && openRow.mode === 'link') {
119
+ right = '<div class="ov-row__right"><form class="td-form" data-link-form="' + lib.esc(id) + '">'
120
+ + '<label class="td-field td-field--wide"><span>Task #</span>'
121
+ + '<input type="number" name="taskId" min="1" required placeholder="e.g. 1002980"></label>'
122
+ + '<div class="td-actions">'
123
+ + '<button type="submit" class="td-btn td-btn--primary" data-submit>Link</button>'
124
+ + '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
125
+ + '</div><p class="td-action-note" data-err></p></form></div>';
126
+ } else {
127
+ right = '<div class="ov-row__right"><div class="ov-row__actions">'
128
+ + '<button type="button" class="btn-ghost" data-resolve-open="' + lib.esc(id) + '">Resolve</button>'
129
+ + '<button type="button" class="btn-ghost" data-link-open="' + lib.esc(id) + '">Link a task</button>'
130
+ + '</div></div>';
131
+ }
132
+ }
133
+ // Plain .ov-row, never --lead: this row has no LEAD column (a face, as
134
+ // collab's rows have) — it is .ov-row__main + .ov-row__right, exactly the
135
+ // shape .ov-row's own grid (minmax(0,1fr) auto) already handles correctly,
136
+ // both at full width and under the family's ≤640px collapse. --lead's
137
+ // three-column grid (auto minmax(0,1fr) auto) would misread the actions
138
+ // column as the row's middle content and pin it beside the text on a
139
+ // phone instead of dropping it beneath.
41
140
  return '<li class="ov-row" data-id="' + lib.esc(String(b.id)) + '">'
42
141
  + '<div class="ov-row__main">'
43
142
  + facts.titleHtml
44
143
  + facts.noteHtml
45
144
  + '<div class="ov-row__facts">' + facts.factsHtml + '</div>'
46
145
  + '</div>'
146
+ + right
47
147
  + '</li>';
48
148
  }
49
149
 
@@ -57,6 +157,10 @@
57
157
  : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
58
158
  }
59
159
 
160
+ function reload() {
161
+ return fetchBlockers().catch(function () { toast('Couldn’t refresh the queue — try reloading the page.'); });
162
+ }
163
+
60
164
  function mountBlockers(rows) {
61
165
  all = Array.isArray(rows) ? rows : [];
62
166
  if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
@@ -78,11 +182,172 @@
78
182
  body.textContent = msg;
79
183
  }
80
184
 
81
- fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
82
- .then(function (r) {
83
- if (!r.ok) throw new Error('http ' + r.status);
84
- return r.json();
85
- })
86
- .then(function (data) { mountBlockers(data && data.blockers); })
87
- .catch(function () { notice('Couldn’t load the blocker queue right now.'); });
185
+ function fetchBlockers() {
186
+ return fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
187
+ .then(function (r) {
188
+ if (!r.ok) throw new Error('http ' + r.status);
189
+ return r.json();
190
+ })
191
+ .then(function (data) { mountBlockers(data && data.blockers); });
192
+ }
193
+
194
+ // ---- filing (any signed-in builder) ----------------------------------------
195
+
196
+ function fileFormHtml() {
197
+ return '<div class="td-actions" id="blockers-file-toggle">'
198
+ + '<button type="button" class="btn-ghost" id="blockers-file-open">+ File a blocker</button>'
199
+ + '</div>'
200
+ + '<form class="td-form" id="blockers-file-form" hidden>'
201
+ + '<label class="td-field td-field--wide"><span>Title</span>'
202
+ + '<input type="text" id="bf-title" maxlength="200" required placeholder="what needs the owner’s decision"></label>'
203
+ + '<label class="td-field td-field--wide"><span>What’s stuck (optional)</span>'
204
+ + '<input type="text" id="bf-note" maxlength="2000" placeholder="a sentence of context"></label>'
205
+ + '<label class="td-field"><span>Blocking task # (optional)</span>'
206
+ + '<input type="number" id="bf-task-id" min="1" placeholder="e.g. 1002980"></label>'
207
+ + '<div class="td-actions">'
208
+ + '<button type="submit" class="td-btn td-btn--primary" id="bf-submit">File it</button>'
209
+ + '<button type="button" class="td-btn" id="bf-cancel">Cancel</button>'
210
+ + '</div><p class="td-action-note" id="bf-err"></p>'
211
+ // The one control open to a non-reviewer that the review floor still
212
+ // touches: filing succeeds regardless, but linking needs blocker.review,
213
+ // and a viewer below it is told so BEFORE they submit — not left to
214
+ // discover it from a refusal.
215
+ + (isReviewer() ? '' : '<p class="td-form-hint">Filing is open to anyone. Linking to a task needs Metic+ — if you name one and aren’t, the blocker still files and a Metic+ can link it.</p>')
216
+ + '</form>';
217
+ }
218
+
219
+ function wireFileForm() {
220
+ var openBtn = document.getElementById('blockers-file-open');
221
+ var form = document.getElementById('blockers-file-form');
222
+ var cancelBtn = document.getElementById('bf-cancel');
223
+ var errEl = document.getElementById('bf-err');
224
+ if (!openBtn || !form) return;
225
+ openBtn.addEventListener('click', function () {
226
+ openBtn.parentElement.hidden = true;
227
+ form.hidden = false;
228
+ document.getElementById('bf-title').focus();
229
+ });
230
+ cancelBtn.addEventListener('click', function () {
231
+ form.hidden = true;
232
+ openBtn.parentElement.hidden = false;
233
+ form.reset();
234
+ errEl.textContent = '';
235
+ });
236
+ form.addEventListener('submit', async function (ev) {
237
+ ev.preventDefault();
238
+ errEl.textContent = '';
239
+ var title = document.getElementById('bf-title').value.trim();
240
+ var note = document.getElementById('bf-note').value.trim();
241
+ var taskIdRaw = document.getElementById('bf-task-id').value.trim();
242
+ if (!title) { errEl.textContent = 'A title is required.'; return; }
243
+ var submitBtn = document.getElementById('bf-submit');
244
+ submitBtn.disabled = true;
245
+ try {
246
+ var res = await writeJSON('POST', API + '/blockers', { title: title, body_md: note });
247
+ var blocker = res && res.blocker;
248
+ if (res && res.skipped) {
249
+ // A duplicate (source, source_ref) skip returns no blocker id at
250
+ // all, so a typed task # was NEVER attempted — say so rather than
251
+ // silently dropping half the request the builder asked for.
252
+ toast(taskIdRaw
253
+ ? 'That blocker was already filed. It was NOT linked to task #' + taskIdRaw + ' — open the existing blocker to link it.'
254
+ : 'That blocker was already filed.');
255
+ } else if (blocker && taskIdRaw) {
256
+ var taskId = Number(taskIdRaw);
257
+ try {
258
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(blocker.id) + '/link', { task_id: taskId });
259
+ toast('Blocker #' + blocker.id + ' filed and linked to task #' + taskId + '.');
260
+ } catch (linkErr) {
261
+ // Honest copy, not a silent no-op: the filing DID succeed, and the
262
+ // reader is told exactly what still needs a Metic+.
263
+ toast('Blocker #' + blocker.id + ' filed. ' + reviewerRefusalMessage(linkErr, 'Could not link it to task #' + taskId + ' — a Metic+ can do that from this page.'));
264
+ }
265
+ } else if (blocker) {
266
+ toast('Blocker #' + blocker.id + ' filed.');
267
+ } else {
268
+ toast('Filed.');
269
+ }
270
+ form.reset();
271
+ form.hidden = true;
272
+ document.getElementById('blockers-file-toggle').hidden = false;
273
+ await reload();
274
+ } catch (err) {
275
+ errEl.textContent = refusalMessage(err, 'Could not file that blocker. Try again.');
276
+ } finally {
277
+ submitBtn.disabled = false;
278
+ }
279
+ });
280
+ }
281
+
282
+ // ---- resolve / link (blocker.review — metic+archon; cosmetic mirror only) --
283
+
284
+ function closeRowForms() { openRow = null; rerender(); }
285
+
286
+ body.addEventListener('click', function (e) {
287
+ var resolveOpen = e.target.closest('[data-resolve-open]');
288
+ if (resolveOpen) { openRow = { id: resolveOpen.dataset.resolveOpen, mode: 'resolve' }; rerender(); return; }
289
+ var linkOpen = e.target.closest('[data-link-open]');
290
+ if (linkOpen) { openRow = { id: linkOpen.dataset.linkOpen, mode: 'link' }; rerender(); return; }
291
+ if (e.target.closest('[data-row-cancel]')) { closeRowForms(); }
292
+ });
293
+
294
+ body.addEventListener('submit', async function (e) {
295
+ var resolveForm = e.target.closest('[data-resolve-form]');
296
+ var linkForm = e.target.closest('[data-link-form]');
297
+ if (!resolveForm && !linkForm) return;
298
+ e.preventDefault();
299
+ var form = resolveForm || linkForm;
300
+ var id = (resolveForm || linkForm).getAttribute(resolveForm ? 'data-resolve-form' : 'data-link-form');
301
+ var errEl = form.querySelector('[data-err]');
302
+ var submitBtn = form.querySelector('[data-submit]');
303
+ submitBtn.disabled = true;
304
+ try {
305
+ if (resolveForm) {
306
+ var note = form.querySelector('[name="note"]').value.trim();
307
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/resolve', { resolution_note: note || null });
308
+ toast('Blocker #' + id + ' resolved.');
309
+ } else {
310
+ var taskId = Number(form.querySelector('[name="taskId"]').value);
311
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/link', { task_id: taskId });
312
+ toast('Blocker #' + id + ' linked to task #' + taskId + '.');
313
+ }
314
+ openRow = null;
315
+ await reload();
316
+ } catch (err) {
317
+ errEl.textContent = reviewerRefusalMessage(err, 'Could not do that. Try again.');
318
+ submitBtn.disabled = false;
319
+ }
320
+ });
321
+
322
+ // ---- boot -------------------------------------------------------------------
323
+
324
+ async function loadViewer() {
325
+ try {
326
+ var res = await api.request('GET', API + '/me');
327
+ if (res.ok && res.data && res.data.builder) {
328
+ viewer = { rank: String(res.data.builder.rank || '').toLowerCase() };
329
+ }
330
+ } catch (_) { /* signed out or offline — viewer stays { rank: null }, isReviewer() false */ }
331
+ }
332
+
333
+ loadViewer().then(function () {
334
+ if (fileArea) { fileArea.innerHTML = fileFormHtml(); wireFileForm(); }
335
+ // Boot race: GET /me and GET /blockers fire together, and rowHtml() reads
336
+ // isReviewer() at RENDER time. If the queue rendered first (the common
337
+ // case — /me is Metic+-gated work the server does more of), it rendered
338
+ // with isReviewer() still false and nothing re-draws it once the real
339
+ // rank lands — a Metic/Archon would see no Resolve/Link buttons until an
340
+ // unrelated re-render (a pager click, a file, an otb:blocker-resolved).
341
+ // Re-render now that the viewer is known, but only if the list actually
342
+ // exists yet (fetchBlockers may still be in flight — it will render
343
+ // correctly on its own the moment it resolves).
344
+ if (listEl) rerender();
345
+ });
346
+
347
+ fetchBlockers().catch(function () { notice('Couldn’t load the blocker queue right now.'); });
348
+
349
+ // A row's title opens the #/blocker/N overlay ON TOP of this page
350
+ // (task-detail.js), whose own Resolve panel fires this — refresh the queue
351
+ // in place rather than leaving a resolved row sitting there.
352
+ window.addEventListener('otb:blocker-resolved', reload);
88
353
  }());
@@ -790,7 +790,7 @@
790
790
  const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
791
791
  return `
792
792
  <tr data-acclaim-row="${tid}">
793
- <td>${title}</td>
793
+ <td>${title}${acclaimEvidenceHtml(t)}</td>
794
794
  <td>${by}</td>
795
795
  <td class="acclaim__net ${netCls}" data-acclaim-net="${tid}">${netText}</td>
796
796
  <td>${controls}</td>
@@ -1424,7 +1424,7 @@
1424
1424
  // reads what this page holds NOW rather than what it held at boot. Everything
1425
1425
  // else is stable for the life of the page. This call sits AFTER every
1426
1426
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1427
- const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1427
+ const { SETUP_CONFIG_KEY, acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1428
1428
  API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1429
1429
  get instanceRepo() { return instanceRepo; },
1430
1430
  get lastClaimableCount() { return lastClaimableCount; },
@@ -530,4 +530,10 @@
530
530
  id: 'attention-scroll', label: 'Needs your attention', order: 20, mount, core: true,
531
531
  labelledby: 'attention-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
532
532
  });
533
+
534
+ // task 1003456: a blocker resolved through the #/blocker/N overlay (which
535
+ // this slice's own row titles open into) should not sit in the inbox until
536
+ // the next full load — the otb:idea-triaged precedent, applied to the
537
+ // sibling event task-detail.js's blockerPanel fires.
538
+ window.addEventListener('otb:blocker-resolved', loadBlockers);
533
539
  })();
@@ -332,6 +332,46 @@
332
332
  </div>`;
333
333
  }
334
334
 
335
+ // task 1002835: a karma vote on a peer's ship was a vote on a title alone.
336
+ // The two facts the ship already recorded — what the grader scored it and
337
+ // what it cost — turn it into an informed one. ABSENCE IS NOT ZERO: an
338
+ // ungraded ship says so, and so does an unlogged cost, because "0.0" and
339
+ // "$0.00" would read as measurements that were never taken.
340
+ function acclaimEvidenceHtml(t) {
341
+ const score = t.grade_score == null ? null : Number(t.grade_score);
342
+ const grade = score == null || !Number.isFinite(score)
343
+ ? '<span class="ledger__verdict--none">not graded</span>'
344
+ : `<span class="${t.grade_passed ? 'ledger__verdict--ok' : 'ledger__verdict--danger'}">${score.toFixed(1)} / 10</span>`;
345
+ // Three states, not two. The route OMITS cost_usd for a builder who may not
346
+ // vote, and that is not the same fact as a logged cost of nothing: a reader
347
+ // who is shown no figure is told nothing, never "no cost logged".
348
+ const parts = [grade];
349
+ if (Object.prototype.hasOwnProperty.call(t, 'cost_usd')) {
350
+ const usd = t.cost_usd == null ? null : Number(t.cost_usd);
351
+ parts.push(usd == null || !Number.isFinite(usd)
352
+ ? '<span class="ledger__verdict--none">no cost logged</span>'
353
+ : `<span class="acclaim__cost">$${usd.toFixed(2)}</span>`);
354
+ }
355
+ return `<span class="acclaim__evidence">${parts.join(' · ')}</span>`;
356
+ }
357
+ // The RETURNING builder's door (task 1002851). `open` and `invite_only`
358
+ // already render a real .landing__btn sign-in inside the primary door, so a
359
+ // second button there would be a third scattered path rather than a clearer
360
+ // one — those modes keep the prose note alone. Only the `apply` door, whose
361
+ // primary control is the request FORM, left signing in as a footnote link.
362
+ // It gets a real button: the request button's geometry so it reads as a
363
+ // control, a quieter fill so the request path still leads for the strangers
364
+ // who are most of this page's traffic.
365
+ function returningDoorHtml(mode) {
366
+ if (mode !== 'apply') return '';
367
+ return `
368
+ <div class="landing__returning" id="landing-returning">
369
+ <h3 class="landing__request-h">Already a member?</h3>
370
+ <p class="landing__request-lede">You've been approved before — sign in and pick up where you left off.</p>
371
+ <p class="landing__cta"><a class="landing__btn landing__btn--quiet" href="${API}/auth/web/start">Sign in with GitHub</a></p>
372
+ </div>`;
373
+ }
374
+
335
375
  function renderLanding() {
336
376
  // task 1723: collapse the shell nav to the signed-out minimum (Home + a
337
377
  // Sign-in button). Cosmetic — the server gates every page regardless.
@@ -343,14 +383,20 @@
343
383
  const landing = $('#landing-root');
344
384
  if (landing) {
345
385
  landing.hidden = false;
386
+ const mode = joinabilityMode();
387
+ // On the apply door the sign-in is a real button above, so the note keeps
388
+ // only the privacy sentence — one sign-in path per landing, never two.
389
+ const signInNote = mode === 'apply'
390
+ ? ''
391
+ : `Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>. `;
346
392
  landing.innerHTML = `
347
393
  <div class="scroll landing">
348
394
  <h2 class="scroll__h">Welcome</h2>
349
- ${landingDoorHtml(joinabilityMode())}
395
+ ${landingDoorHtml(mode)}
396
+ ${returningDoorHtml(mode)}
350
397
 
351
398
  <p class="landing__signin-note">
352
- Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
353
- We store only your GitHub login, avatar, and the work you ship.
399
+ ${signInNote}We store only your GitHub login, avatar, and the work you ship.
354
400
  </p>
355
401
  </div>
356
402
  `;
@@ -1305,6 +1351,6 @@
1305
1351
  return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1306
1352
  }
1307
1353
 
1308
- return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1354
+ return { SETUP_CONFIG_KEY, acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1309
1355
  };
1310
1356
  })();
@@ -24,5 +24,14 @@
24
24
  .dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
25
25
  .dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
26
26
  .dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
27
+ /* The roster's place cell carries two facts: the place, and under it the
28
+ builder's number in the same mono/faint voice the login uses (task
29
+ 1002837). Block display so the id never wraps up beside the place. */
30
+ .ledger__place .ledger__id {
31
+ display: block;
32
+ font-family: var(--font-mono);
33
+ font-size: 11.5px;
34
+ color: var(--ink-faint);
35
+ }
27
36
  .dir-crafts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
28
37
  .dir-none { color: var(--ink-faint); }
@@ -148,9 +148,19 @@
148
148
  return fmtInt(Math.round((Number(b.tokens_used) || 0) / credits)) + ' tokens per ' + one;
149
149
  }
150
150
 
151
+ // task 1002837: a builder's place answers "who is ahead", their NUMBER
152
+ // answers "which builder is this" — the id every task row, claim and ship
153
+ // note already cites. It sits under the place in the same mono/faint voice
154
+ // the login uses, and only when the payload carries one, so an older
155
+ // /builders response degrades to the place alone.
156
+ function idHtml(b) {
157
+ if (b.id == null || b.id === '') return '';
158
+ return '<span class="ledger__id">#' + esc(String(b.id)) + '</span>';
159
+ }
160
+
151
161
  function rowHtml(b) {
152
162
  return '<tr>'
153
- + '<td class="ledger__num">' + esc(String(b.__place || '—')) + '</td>'
163
+ + '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
154
164
  + '<td>' + whoHtml(b) + achRowHtml(b.achievements) + '</td>'
155
165
  + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
156
166
  + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
@@ -1536,6 +1536,16 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1536
1536
  .ledger__verdict--ok { color: var(--ok); font-weight: 600; }
1537
1537
  .ledger__verdict--danger { color: var(--danger); font-weight: 600; }
1538
1538
  .ledger__verdict--none { color: var(--ink-soft); }
1539
+ /* The acclaim feed's evidence line (task 1002835): the grade and the cost the
1540
+ ship recorded, under the work it describes. Secondary to the title — this is
1541
+ what a voter checks, not what they read first. */
1542
+ .acclaim__evidence {
1543
+ display: block;
1544
+ margin-top: 3px;
1545
+ font-size: 12px;
1546
+ color: var(--ink-faint);
1547
+ }
1548
+ .acclaim__cost { font-variant-numeric: tabular-nums; }
1539
1549
  .ledger th.ledger__sortable { cursor: pointer; user-select: none; }
1540
1550
  .ledger th.ledger__sortable:hover { color: var(--ink); }
1541
1551
  .ledger th.ledger__sortable[aria-sort="ascending"]::after { content: " ↑"; }
@@ -2492,6 +2502,24 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2492
2502
  .landing__btn--request { align-self: flex-start; }
2493
2503
  .landing__request-status { font-size: 13px; margin: 0; color: var(--ok); }
2494
2504
  .landing__request-status[data-kind="err"] { color: var(--danger); }
2505
+ /* The returning builder's door, rendered only on the `apply` landing (task
2506
+ 1002851). Same panel as the request block and the same button geometry, so
2507
+ signing in reads as a real control rather than a footnote — but a quiet
2508
+ fill, because the request path is what most of this page's traffic needs. */
2509
+ .landing__returning {
2510
+ border: 0;
2511
+ border-radius: var(--radius);
2512
+ background: var(--surface-2);
2513
+ padding: 18px 20px;
2514
+ margin: 16px 0;
2515
+ }
2516
+ .landing__returning .landing__cta { margin: 0; }
2517
+ .landing__btn--quiet {
2518
+ background: transparent;
2519
+ color: var(--ink);
2520
+ box-shadow: inset 0 0 0 1px var(--border-strong);
2521
+ }
2522
+ .landing__btn--quiet:hover { background: var(--surface-2); color: var(--ink); }
2495
2523
  .landing__signin-note { font-size: 13px; color: var(--ink-soft); }
2496
2524
 
2497
2525
  /* ---------- goals ---------- */