@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.
- package/.bongos-core.json +43 -28
- package/docs/copy-inventory.md +131 -104
- package/docs/copy-registry.json +410 -122
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/blockers.html +4 -1
- package/modules/hall-ui/public/blockers.js +283 -18
- package/modules/hall-ui/public/builders.js +2 -2
- package/modules/hall-ui/public/goal-inbox.js +6 -0
- package/modules/hall-ui/public/hall-render.js +50 -4
- package/modules/hall-ui/public/people.css +9 -0
- package/modules/hall-ui/public/roster.js +11 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task-detail.js +92 -3
- package/modules/lifecycle/routes/tasks.js +33 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/acclaim_feed_evidence_db.mjs +186 -0
- package/tests/blockers_hall.mjs +92 -0
- package/tests/blockers_page_ui.mjs +325 -0
- package/tests/hall_builders_page_pass.mjs +324 -0
- package/tests/helpers.mjs +13 -0
- package/tests/joinability_invite_only.mjs +11 -1
- package/tests/task_detail_ui.mjs +79 -1
|
@@ -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.
|
|
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
|
|
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
|
|
6
|
-
// #/blocker/N overlay task-detail.js already renders, reachable only by
|
|
7
|
-
// the id into the address bar. Owner decision 2026-08-30: BOTH surfaces,
|
|
8
|
-
// ONE code path — this page is the full queue (a long list has nowhere
|
|
9
|
-
// live), and goal-inbox.js's attention-inbox slice is the capped
|
|
10
|
-
// render from blockers-lib.js's blockerFacts(), so the two
|
|
11
|
-
// the way ~10 hand-rolled pagers did before the kit
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
395
|
+
${landingDoorHtml(mode)}
|
|
396
|
+
${returningDoorHtml(mode)}
|
|
350
397
|
|
|
351
398
|
<p class="landing__signin-note">
|
|
352
|
-
|
|
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 ---------- */
|