@bongos/core 1.20.48 → 1.20.49

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.
@@ -0,0 +1,280 @@
1
+ // criterion-uat-ui.js — the UAT on a goal's criteria, in the hall (task 1004402,
2
+ // piece 2 of ADR 0351; piece 1, task 1004392, is the rule and the API).
3
+ //
4
+ // WHY. A criterion no longer closes because its linked tasks shipped. Once they
5
+ // have, it reads AWAITING UAT, and it closes only when someone who did not ship
6
+ // the work tries it on the live site and signs it off with a recording (or, for a
7
+ // criterion marked backend-only, a sign-off without one). Before this file that
8
+ // was reachable only from a Claude session (/goal-uat); this is the same act for
9
+ // a person in a browser.
10
+ //
11
+ // WHAT IT DRAWS, per criterion row (goals-render.js critListHtml asks it):
12
+ // - a STATE pill in the server's own words (Awaiting UAT, Satisfied (UAT), …)
13
+ // and a Backend-only tag — never re-derived here: `uat_state` and
14
+ // `backend_only` come from GET /goals/:id, and a server too old to send them
15
+ // falls back to the plain satisfied/open reading;
16
+ // - the ACTION: "Test and sign off" on an Awaiting UAT row, "See the UAT" on a
17
+ // row a UAT closed, and on an OPEN row in the full view the backend-only
18
+ // switch (the server refuses it once work has shipped, and says why).
19
+ // Opening a row loads GET /done-when/:id/uat into a panel under it: the three
20
+ // checks (Code, Live, UAT), the recording if there is one, and the sign-off form.
21
+ //
22
+ // THE SERVER IS THE WALL. Every refusal (you shipped this work, not live yet,
23
+ // too big, wrong file type, backend-only locked) is shown in the words the server
24
+ // sent; nothing here decides who may sign. A control shown to someone who may not
25
+ // use it gets an honest refusal, never a silent no-op.
26
+ //
27
+ // Loaded before goals-render.js (goals.html); pure browser code, no build step.
28
+ // The vocabulary helpers are exported for the page test (tests/criterion_uat_ui.mjs).
29
+ (function () {
30
+ 'use strict';
31
+
32
+ // The labels, identical to modules/lifecycle/criterion-uat.js stateLabel (the
33
+ // browser cannot require server code; tests/criterion_uat_ui.mjs holds the two
34
+ // equal).
35
+ const STATE_LABELS = {
36
+ open: 'Open',
37
+ awaiting_uat: 'Awaiting UAT',
38
+ satisfied_uat: 'Satisfied (UAT)',
39
+ satisfied_backend: 'Satisfied (backend sign-off)',
40
+ satisfied_override: 'Satisfied (override)',
41
+ closed_before_uat: 'Closed before UAT',
42
+ };
43
+ const MAX_BYTES = 100 * 1024 * 1024;
44
+ const TYPES = ['video/mp4', 'video/webm'];
45
+
46
+ function esc(s) {
47
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
48
+ }
49
+
50
+ // The state a row reads as. A server older than the UAT rule sends no
51
+ // uat_state; then satisfied/open is the whole truth and no UAT control shows.
52
+ function stateOf(c) {
53
+ if (c && c.uat_state && STATE_LABELS[c.uat_state]) return c.uat_state;
54
+ return c && c.satisfied ? 'legacy_satisfied' : 'open';
55
+ }
56
+ function isAwaiting(c) { return stateOf(c) === 'awaiting_uat'; }
57
+
58
+ // The pill + tag that follow the criterion text. Open rows carry no pill (the
59
+ // empty mark already says open), and a legacy server gets nothing new.
60
+ function badgesHtml(c) {
61
+ const s = stateOf(c);
62
+ const pill = (s === 'open' || s === 'legacy_satisfied') ? ''
63
+ : `<span class="crit-state crit-state--${esc(s)}">${esc(STATE_LABELS[s])}</span>`;
64
+ const tag = c && c.backend_only ? '<span class="crit-tag" title="No screen to record: this is signed off without a video">Backend-only</span>' : '';
65
+ return pill + tag;
66
+ }
67
+
68
+ // The trailing action. canReview = the viewer may sign off (Metic+; the server
69
+ // re-checks). full = the dedicated completion view, where the backend-only
70
+ // switch lives (it is an authoring act, not a glance).
71
+ function actionHtml(c, { canReview, full }) {
72
+ const id = esc(String(c.id));
73
+ const s = stateOf(c);
74
+ if (s === 'awaiting_uat') {
75
+ return canReview
76
+ ? `<button type="button" class="crit__confirm" data-uat-open="${id}" aria-expanded="false">Test and sign off</button>`
77
+ : '<span class="crit__flag" title="Its work has shipped; someone who did not build it needs to try it on the live site">Awaiting UAT</span>';
78
+ }
79
+ if (s === 'satisfied_uat' || s === 'satisfied_backend') {
80
+ return `<button type="button" class="crit__link" data-uat-open="${id}" aria-expanded="false">See the UAT</button>`;
81
+ }
82
+ if (s === 'open' && full && canReview && c.uat_state) {
83
+ return c.backend_only
84
+ ? `<button type="button" class="crit__link" data-uat-backend="${id}" data-on="0" title="It needs a recording again">Not backend-only</button>`
85
+ : `<button type="button" class="crit__link" data-uat-backend="${id}" data-on="1" title="For a criterion with nothing to see on screen: it will be signed off without a recording">Mark backend-only</button>`;
86
+ }
87
+ return '';
88
+ }
89
+
90
+ // ── the panel ──────────────────────────────────────────────────────────────
91
+
92
+ function fmtDate(v) {
93
+ const d = new Date(v);
94
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
95
+ }
96
+
97
+ function liveLine(live) {
98
+ if (live == null) return '';
99
+ if (!live.available) return 'This site cannot tell on its own. Confirm below that you tried it on the live site.';
100
+ if (live.live) return 'Running on the live site.';
101
+ const ids = (live.not_live || []).map((t) => `#${esc(t.task_id)}`).join(', ');
102
+ return `Shipped but not on the live site yet${ids ? ` (${ids})` : ''}. Sign off once it is deployed.`;
103
+ }
104
+
105
+ function checkRow(ok, label, text) {
106
+ return `<li class="uat-check${ok ? ' uat-check--ok' : ''}"><span class="uat-check__mark" aria-hidden="true">${ok ? '✓' : '·'}</span>`
107
+ + `<span class="uat-check__label">${label}</span><span class="uat-check__text">${text}</span></li>`;
108
+ }
109
+
110
+ function signoffText(s) {
111
+ if (!s) return '';
112
+ const who = esc(s.signed_off_by_login || `builder ${s.signed_off_by}`);
113
+ const kind = s.kind === 'uat' ? 'Tried on the live site' : 'Signed off (backend)';
114
+ return `${kind} by ${who} on ${esc(fmtDate(s.created_at))}${s.note ? ` — “${esc(s.note)}”` : ''}`;
115
+ }
116
+
117
+ function recordingHtml(s, apiBase) {
118
+ if (!s || !s.recording_file) return '';
119
+ const src = `${apiBase}/criterion-uats/recordings/${encodeURIComponent(s.recording_file)}`;
120
+ return `<video class="uat-video" controls preload="metadata" src="${esc(src)}">`
121
+ + `<a href="${esc(src)}">Open the recording</a></video>`;
122
+ }
123
+
124
+ // The panel body, from GET /done-when/:id/uat. Pure so the test can render it.
125
+ function panelHtml(view, { apiBase, canReview }) {
126
+ const c = view.checks || {};
127
+ const awaiting = view.uat_state === 'awaiting_uat';
128
+ const uatText = c.uat ? signoffText(c.uat)
129
+ : (c.stale_uat ? `An earlier sign-off (${signoffText(c.stale_uat)}) no longer counts: more work shipped after it.` : 'Not yet.');
130
+ const codeText = c.code ? 'Every linked task is done.' : 'Some linked work is still open.';
131
+ const checks = `<ul class="uat-checks">`
132
+ + checkRow(!!c.code, 'Code', esc(codeText))
133
+ + (awaiting ? checkRow(!!(c.live && c.live.available && c.live.live), 'Live', liveLine(c.live)) : '')
134
+ + checkRow(!!c.uat, view.backend_only ? 'Sign-off' : 'UAT', uatText)
135
+ + '</ul>';
136
+ const video = recordingHtml(c.uat || c.stale_uat, apiBase);
137
+ if (!awaiting || !canReview) return checks + video;
138
+
139
+ const id = esc(String(view.criterion.id));
140
+ const needsAttest = !(c.live && c.live.available);
141
+ const file = view.backend_only ? ''
142
+ : `<label class="uat-field"><span>Screen recording of you doing this on the live site (mp4 or webm, up to 100 MB)</span>`
143
+ + `<input type="file" accept="video/mp4,video/webm" data-uat-file="${id}"></label>`;
144
+ const attest = needsAttest
145
+ ? `<label class="uat-check-input"><input type="checkbox" data-uat-attest="${id}"> I tried this on the live site</label>` : '';
146
+ return checks + video + `<form class="uat-form" data-uat-form="${id}" novalidate>`
147
+ + (view.backend_only ? '<p class="uat-hint">Backend-only: nothing to record. Check it the way it can be checked (a log, a report, a test against the live site) and say how below.</p>' : '')
148
+ + file
149
+ + `<label class="uat-field"><span>${view.backend_only ? 'How you checked it' : 'What you checked (optional)'}</span>`
150
+ + `<textarea rows="2" maxlength="2000" data-uat-note="${id}"></textarea></label>`
151
+ + attest
152
+ + `<p class="uat-error" role="alert" data-uat-error="${id}" hidden></p>`
153
+ + `<button type="submit" class="crit__confirm">${view.backend_only ? 'Sign off' : 'Upload and sign off'}</button>`
154
+ + '</form>';
155
+ }
156
+
157
+ // The server's own sentence for a refusal, else a plain fallback. The envelope
158
+ // is { error: { code, message } }; the API client may also hand it back flat.
159
+ function refusalText(data, status) {
160
+ const e = data && data.error;
161
+ const msg = e && typeof e === 'object' ? e.message : null;
162
+ if (msg && msg !== (e && e.code)) return msg;
163
+ if (status === 413) return 'The recording is over the 100 MB cap. Record a shorter pass, or lower the resolution.';
164
+ if (status === 403) return 'Only a Metic or an Archon who did not ship this work can sign it off. Ask someone else on the goal to try it.';
165
+ return `That did not go through (${status || 'error'}). Try again.`;
166
+ }
167
+
168
+ // Screen a chosen file before sending 100 MB anywhere.
169
+ function checkFile(file) {
170
+ if (!file) return 'Choose the recording first.';
171
+ if (!TYPES.includes(file.type)) return 'A UAT recording must be an mp4 or webm video.';
172
+ if (file.size > MAX_BYTES) return 'The recording is over the 100 MB cap. Record a shorter pass, or lower the resolution.';
173
+ return null;
174
+ }
175
+
176
+ // wire(root, deps) — one delegated listener set on a criteria container.
177
+ // deps: { apiBase, getJSON, postJSON, putJSON, canReview(), toast, reload }
178
+ function wire(root, deps) {
179
+ if (!root || root.__uatWired) return;
180
+ root.__uatWired = true;
181
+
182
+ const showError = (id, text) => {
183
+ const el = root.querySelector(`[data-uat-error="${CSS.escape(id)}"]`);
184
+ if (el) { el.textContent = text; el.hidden = !text; }
185
+ };
186
+
187
+ async function openPanel(btn) {
188
+ const id = btn.dataset.uatOpen;
189
+ const row = btn.closest('.crit');
190
+ if (!row) return;
191
+ const existing = row.querySelector('.uat-panel');
192
+ if (existing) { existing.remove(); btn.setAttribute('aria-expanded', 'false'); return; }
193
+ const panel = document.createElement('div');
194
+ panel.className = 'uat-panel';
195
+ panel.textContent = 'Loading…';
196
+ row.appendChild(panel);
197
+ btn.setAttribute('aria-expanded', 'true');
198
+ try {
199
+ const view = await deps.getJSON(`${deps.apiBase}/done-when/${encodeURIComponent(id)}/uat`);
200
+ panel.innerHTML = panelHtml(view, { apiBase: deps.apiBase, canReview: deps.canReview() });
201
+ } catch (err) {
202
+ if (err && err.message === 'redirecting to auth') return;
203
+ panel.textContent = refusalText(err && err.data, err && err.status);
204
+ }
205
+ }
206
+
207
+ async function submit(form) {
208
+ const id = form.dataset.uatForm;
209
+ const btn = form.querySelector('button[type="submit"]');
210
+ const fileInput = form.querySelector('[data-uat-file]');
211
+ const note = (form.querySelector('[data-uat-note]') || {}).value || '';
212
+ const attestBox = form.querySelector('[data-uat-attest]');
213
+ showError(id, '');
214
+ const backend = !fileInput;
215
+ const file = fileInput && fileInput.files && fileInput.files[0];
216
+ if (!backend) { const bad = checkFile(file); if (bad) return showError(id, bad); }
217
+ if (backend && !note.trim()) return showError(id, 'Say how you checked it.');
218
+ if (attestBox && !attestBox.checked) return showError(id, 'Confirm that you tried it on the live site.');
219
+ const label = btn.textContent;
220
+ btn.disabled = true;
221
+ try {
222
+ let recording = null;
223
+ if (!backend) {
224
+ btn.textContent = 'Uploading…';
225
+ const res = await fetch(`${deps.apiBase}/done-when/${encodeURIComponent(id)}/uat/recording`, {
226
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': file.type }, body: file,
227
+ });
228
+ const data = await res.json().catch(() => ({}));
229
+ if (!res.ok) { showError(id, refusalText(data, res.status)); return; }
230
+ recording = data.recording;
231
+ }
232
+ btn.textContent = 'Signing off…';
233
+ const body = { kind: backend ? 'backend_signoff' : 'uat' };
234
+ if (recording) body.recording = recording;
235
+ if (note.trim()) body.note = note.trim();
236
+ if (attestBox && attestBox.checked) body.live_attested = true;
237
+ const out = await deps.postJSON(`${deps.apiBase}/done-when/${encodeURIComponent(id)}/uat`, body);
238
+ deps.toast(out && out.achieved_goals && out.achieved_goals.length
239
+ ? 'Signed off. That was the last criterion, so this goal is achieved.'
240
+ : (out && out.criterion_closed ? 'Signed off. The criterion is closed.' : 'Signed off.'));
241
+ await deps.reload();
242
+ } catch (err) {
243
+ if (err && err.message === 'redirecting to auth') return;
244
+ showError(id, refusalText(err && err.data, err && err.status));
245
+ } finally {
246
+ if (btn.isConnected) { btn.disabled = false; btn.textContent = label; }
247
+ }
248
+ }
249
+
250
+ async function toggleBackend(btn) {
251
+ const id = btn.dataset.uatBackend;
252
+ const on = btn.dataset.on === '1';
253
+ btn.disabled = true;
254
+ try {
255
+ await deps.putJSON(`${deps.apiBase}/done-when/${encodeURIComponent(id)}/backend-only`, { backend_only: on });
256
+ deps.toast(on ? 'Marked backend-only: it will be signed off without a recording.' : 'It needs a recording again.');
257
+ await deps.reload();
258
+ } catch (err) {
259
+ if (err && err.message === 'redirecting to auth') return;
260
+ deps.toast(refusalText(err && err.data, err && err.status));
261
+ btn.disabled = false;
262
+ }
263
+ }
264
+
265
+ root.addEventListener('click', (e) => {
266
+ const open = e.target.closest('[data-uat-open]');
267
+ if (open) { e.preventDefault(); openPanel(open); return; }
268
+ const flag = e.target.closest('[data-uat-backend]');
269
+ if (flag) { e.preventDefault(); toggleBackend(flag); }
270
+ });
271
+ root.addEventListener('submit', (e) => {
272
+ const form = e.target.closest('[data-uat-form]');
273
+ if (form) { e.preventDefault(); submit(form); }
274
+ });
275
+ }
276
+
277
+ const api = { STATE_LABELS, stateOf, isAwaiting, badgesHtml, actionHtml, panelHtml, refusalText, checkFile, wire };
278
+ if (typeof window !== 'undefined') window.OTBCriterionUat = api;
279
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
280
+ })();
@@ -126,11 +126,13 @@
126
126
  </footer>
127
127
 
128
128
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
129
- <script src="/builders/sky-data.js?v=2026-09-27-split"></script>
129
+ <!-- task 1004402: the UAT state words the rings share with the goal page. -->
130
+ <script src="/builders/criterion-uat-ui.js?v=2026-09-30-uat1"></script>
131
+ <script src="/builders/sky-data.js?v=2026-09-30-uat1"></script>
130
132
  <script src="/builders/sky-layout.js?v=2026-09-27-split"></script>
131
133
  <script src="/builders/sky-draw.js?v=2026-09-27-split"></script>
132
- <script src="/builders/sky-interaction.js?v=2026-09-27-split"></script>
133
- <script src="/builders/sky-panel.js?v=2026-09-27-split"></script>
134
+ <script src="/builders/sky-interaction.js?v=2026-09-30-uat1"></script>
135
+ <script src="/builders/sky-panel.js?v=2026-09-30-uat1"></script>
134
136
  <script src="/builders/sky-composer.js?v=2026-09-27-split"></script>
135
137
  <script src="/builders/sky-tour.js?v=2026-09-27-split"></script>
136
138
  </body>
@@ -450,7 +450,7 @@
450
450
  // is passed straight through; workTabs is a GETTER because it is reassigned
451
451
  // after boot and a renderer must see the current controller, not the null it
452
452
  // held here at startup. This call sits AFTER every declaration it passes.
453
- const { GOAL_SORTS, WORK_SORT_HEADERS, bulk, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
453
+ const { GOAL_SORTS, WORK_SORT_HEADERS, bulk, canConfirmCriteria, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
454
454
  API, claimAction, copyText, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
455
455
  get workTabs() { return workTabs; },
456
456
  });
@@ -550,44 +550,26 @@
550
550
  // (withDetail=false) instead reveals detail via a per-criterion <details> expand
551
551
  // (BV1.R79) so the full text is one click away, not hover-only.
552
552
 
553
- // task 1002164 — confirm a pending-review criterion straight from the hall
554
- // (POST /done-when/:id/satisfy, Metic+). This is the in-hall replacement for
555
- // the /goal-review LLM skill: confirming the goal's LAST open criterion flips
556
- // it open→achieved server-side, which the forced detail reload then reflects.
557
- const pendingConfirms = new Set();
558
- async function confirmCriterion(critId, btn) {
559
- if (pendingConfirms.has(critId)) return;
560
- pendingConfirms.add(critId);
561
- const label = btn ? btn.textContent : '';
562
- if (btn) { btn.disabled = true; btn.textContent = 'Confirming…'; }
563
- try {
564
- const res = await postJSON(`${API}/done-when/${encodeURIComponent(critId)}/satisfy`, {});
565
- toast(res && res.goal_achieved ? 'Criterion confirmed — that was the last one, so this goal is achieved.' : 'Criterion confirmed.');
566
- if (state.current != null) {
567
- await loadDetail(state.current, { force: true });
568
- renderDetail(state.current);
569
- }
570
- } catch (err) {
571
- if (err && err.message === 'redirecting to auth') return;
572
- const code = err && err.data && err.data.error;
573
- // POST /done-when/:id/satisfy gates on criterion.satisfy since BV1.R105, so the
574
- // denial code is now 'permission_forbidden'; 'rank_forbidden' is kept for an
575
- // instance still on a pre-R105 core (the doorway is versioned, ADR 0083).
576
- const denial = code && (code.code || code);
577
- // task 1004392 (ADR 0351): a criterion closes on a UAT now, and a bare
578
- // confirm is refused as an override with no reason. Say that, rather than
579
- // "could not confirm (400)"; the UAT controls themselves are task 1004402.
580
- const reason = denial === 'override_reason_required'
581
- ? 'Criteria now close on a UAT: someone who did not ship the work tests it on the live site and signs it off with /goal-uat.'
582
- : (denial === 'permission_forbidden' || denial === 'rank_forbidden')
583
- ? 'Confirming a criterion needs Metic rank or higher.'
584
- : `Could not confirm that criterion (${(err && err.status) || 'error'}).`;
585
- toast(reason);
586
- if (btn) { btn.disabled = false; btn.textContent = label; }
587
- } finally {
588
- pendingConfirms.delete(critId);
589
- }
553
+ // task 1004402 (ADR 0351) — the UAT on a criterion, from the hall. The old
554
+ // Confirm button (a bare POST /satisfy) is gone: a criterion closes on a UAT
555
+ // sign-off now, and /satisfy is the recorded override. criterion-uat-ui.js
556
+ // owns the panel, the upload and the sign-off; this page hands it its I/O.
557
+ async function putJSON(path, body) {
558
+ const res = await api.request('PUT', path, { body: body || {} });
559
+ const data = res.data || {};
560
+ if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; e.data = data; throw e; }
561
+ return data;
590
562
  }
563
+ const uatDeps = {
564
+ apiBase: API, getJSON, postJSON, putJSON, toast,
565
+ // The one Metic+ gate the criteria rows already use (goals-render.js).
566
+ canReview: () => canConfirmCriteria(),
567
+ reload: async () => {
568
+ if (state.current == null) return;
569
+ await loadDetail(state.current, { force: true });
570
+ renderDetail(state.current);
571
+ },
572
+ };
591
573
 
592
574
  /* ----- Members (band column + #/goal/N/members full view) ----- */
593
575
 
@@ -1082,13 +1064,10 @@
1082
1064
  });
1083
1065
  // task 1002425 — the goal's bulk-action toolbar.
1084
1066
  bulk.wire();
1085
- // task 1002164 — Confirm on a pending-review criterion, in both the capped
1086
- // band column and the dedicated #/goal/N/completion view.
1067
+ // task 1004402 — the UAT controls, in both the capped band column and the
1068
+ // dedicated #/goal/N/completion view.
1087
1069
  ['#goal-completion-body', '#goal-completion-full'].forEach((sel) => {
1088
- $(sel)?.addEventListener('click', (e) => {
1089
- const btn = e.target.closest('[data-confirm-crit]');
1090
- if (btn) { e.preventDefault(); confirmCriterion(btn.dataset.confirmCrit, btn); }
1091
- });
1070
+ if (window.OTBCriterionUat) window.OTBCriterionUat.wire($(sel), uatDeps);
1092
1071
  });
1093
1072
  // task 1953 — Approve/Deny on the goal's own pending-requests panel.
1094
1073
  $('#goal-pending')?.addEventListener('click', (e) => {
@@ -30,6 +30,9 @@
30
30
  readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
31
31
  msOf, taskActivityMs, subtitleAttr, subtitleLine, applyGoalProse } = window.OTBGoalsLib;
32
32
  const forestRender = window.OTBGoalForestRender(ctx);
33
+ // task 1004402 — criterion-uat-ui.js. If that script failed to load, the page
34
+ // still draws its criteria (state by satisfied/open, no UAT controls).
35
+ const uatUi = window.OTBCriterionUat || { isAwaiting: () => false, badgesHtml: () => '', actionHtml: () => '' };
33
36
 
34
37
  const CONFLICT_CAP = 3;
35
38
  // task 1828 — the band's Completion / Members / Conflicts columns each cap
@@ -673,7 +676,10 @@
673
676
  </div>`;
674
677
  }
675
678
  function completionSummaryHtml(g, crit, sat, ringSize, criterionlessTaskCount, openTasks) {
679
+ // task 1004402: "met and ready to confirm" became Awaiting UAT (ADR 0351). A
680
+ // server too old to send uat_state still gets the old sentence.
676
681
  const pending = crit.filter(isPending).length;
682
+ const awaiting = crit.filter(uatUi.isAwaiting).length;
677
683
  // task 1002164 — an open goal used to punt confirmation to the /goal-review
678
684
  // CLI skill here; confirming now happens on this page, so the line reports
679
685
  // what is actually actionable instead of naming a command.
@@ -682,11 +688,14 @@
682
688
  // repeating the criterion sentence. Satisfied criteria plus an open goal is
683
689
  // precisely the state that reads as a bug when the page will not explain it.
684
690
  const held = openTasks && openTasks.total ? openTasks.total : 0;
685
- const openLine = pending
686
- ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
687
- : (held
688
- ? `Open — every criterion can be satisfied and the goal still will not close while work is unfinished.`
689
- : 'Open — a criterion becomes confirmable once every task linked to it has shipped.');
691
+ const uatMode = crit.some((c) => c.uat_state);
692
+ const openLine = awaiting
693
+ ? `${fmtNum(awaiting)} criteri${awaiting === 1 ? 'on is' : 'a are'} awaiting UAT: the work has shipped, and someone who did not build it now tries it on the live site.`
694
+ : (pending && !uatMode ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
695
+ : (held
696
+ ? `Open — every criterion can be satisfied and the goal still will not close while work is unfinished.`
697
+ : (uatMode ? 'Open — a criterion is ready for its UAT once every task linked to it has shipped.'
698
+ : 'Open — a criterion becomes confirmable once every task linked to it has shipped.')));
690
699
  const line = g.status === 'achieved' ? 'Goal achieved — every criterion confirmed.'
691
700
  : (g.status === 'archived' ? 'Archived.' : openLine);
692
701
  const unlinked = crit.length > 0 && criterionlessTaskCount > 0
@@ -706,18 +715,18 @@
706
715
  if (!list.length) return emptyStateHtml('No done-when criteria yet.');
707
716
  return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
708
717
  const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
718
+ // task 1004402 (ADR 0351): the state pill, the tag and the UAT action are
719
+ // criterion-uat-ui.js's, and the badges sit WITH the label (inside the
720
+ // summary, never after it, where a closed <details> would hide them).
721
+ const badges = uatUi.badgesHtml(c);
722
+ const label = escapeHtml(parts.label || '') + (badges ? ` <span class="crit__badges">${badges}</span>` : '');
709
723
  const body = withDetail
710
- ? `<div class="crit__col"><span class="crit__text">${escapeHtml(parts.label || '')}</span>${parts.detail ? `<span class="crit__detail">${escapeHtml(parts.detail)}</span>` : ''}</div>`
724
+ ? `<div class="crit__col"><span class="crit__text">${label}</span>${parts.detail ? `<span class="crit__detail">${escapeHtml(parts.detail)}</span>` : ''}</div>`
711
725
  : (parts.detail
712
- ? `<details class="crit-d"><summary class="crit__text">${escapeHtml(parts.label || '')}</summary><div class="crit__detail">${escapeHtml(parts.detail)}</div></details>`
713
- : `<span class="crit__text">${escapeHtml(parts.label || '')}</span>`);
726
+ ? `<details class="crit-d"><summary class="crit__text">${label}</summary><div class="crit__detail">${escapeHtml(parts.detail)}</div></details>`
727
+ : `<span class="crit__text">${label}</span>`);
714
728
  const pending = isPending(c);
715
- // A Xenos sees the state but not the control — the server enforces either way.
716
- const action = !pending ? ''
717
- : (canConfirm
718
- ? `<button type="button" class="crit__confirm" data-confirm-crit="${escapeHtml(String(c.id))}"
719
- title="Every task linked to this criterion has shipped — confirm it as satisfied">Confirm</button>`
720
- : '<span class="crit__flag" title="Every task linked to this criterion has shipped — a Metic can confirm it">Ready</span>');
729
+ const action = uatUi.actionHtml(c, { canReview: canConfirm, full: withDetail });
721
730
  const cls = c.satisfied ? ' crit--done' : (pending ? ' crit--pending' : '');
722
731
  return `<li class="crit${cls}"><span class="crit__mark">${c.satisfied ? '✓' : (pending ? '!' : '')}</span>${body}<span class="crit__act">${action}</span></li>`;
723
732
  }).join('')}</ul>`;
@@ -1476,6 +1485,6 @@
1476
1485
  return d;
1477
1486
  }
1478
1487
 
1479
- return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, bulk, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1488
+ return { GOAL_SORTS, WORK_SORT_HEADERS, canConfirmCriteria, errCode, errDetails, friendlyClaimError, bulk, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1480
1489
  };
1481
1490
  })();
@@ -178,6 +178,51 @@
178
178
  white-space: nowrap; padding: 2px 8px; border-radius: var(--radius-pill);
179
179
  background: var(--warn-weak); color: var(--warn); font-size: 10.5px; font-weight: 600;
180
180
  }
181
+ /* task 1004402 (ADR 0351) — the UAT on a criterion. The state pill and the
182
+ Backend-only tag sit with the label; the panel spans the whole row under it.
183
+ Status tones reuse the hall's measured --ok / --warn pairs (DESIGN.md §Reuse):
184
+ awaiting reads as the warn state the "!" mark already uses, a UAT close as ok,
185
+ and the quieter closes (override, closed before UAT) in plain ink. */
186
+ .crit__badges { display: inline-flex; flex-wrap: wrap; gap: 4px; margin-left: 4px; vertical-align: 1px; }
187
+ .crit-state, .crit-tag {
188
+ white-space: nowrap; padding: 1px 7px; border-radius: var(--radius-pill);
189
+ font-size: 10.5px; font-weight: 600; line-height: 1.5;
190
+ background: var(--rule-soft); color: var(--ink-soft);
191
+ }
192
+ .crit-state--awaiting_uat { background: var(--warn-weak); color: var(--warn); }
193
+ .crit-state--satisfied_uat, .crit-state--satisfied_backend { background: var(--ok-weak); color: var(--ok); }
194
+ .crit-tag { background: transparent; border: var(--line) solid var(--rule); }
195
+ .crit__link {
196
+ cursor: pointer; white-space: nowrap; min-height: 24px; padding: 3px 6px; margin: 0 -6px;
197
+ border: 0; background: transparent; color: var(--ink-soft); font: inherit; font-size: 11.5px;
198
+ text-decoration: underline; text-underline-offset: 3px;
199
+ }
200
+ .crit__link:hover:not(:disabled) { color: var(--ink); }
201
+ .crit__link:disabled { opacity: .6; cursor: default; }
202
+ .uat-panel {
203
+ grid-column: 1 / -1; margin: 6px 0 4px 32px; padding: 12px 14px;
204
+ border-radius: var(--radius); background: color-mix(in srgb, var(--ink) 5%, var(--bg-card));
205
+ font-size: 12.5px; color: var(--ink-soft); display: flex; flex-direction: column; gap: 10px;
206
+ }
207
+ .uat-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
208
+ .uat-check { display: grid; grid-template-columns: 16px 64px minmax(0,1fr); gap: 6px; align-items: baseline; }
209
+ .uat-check__mark { color: var(--ink-faint); text-align: center; }
210
+ .uat-check--ok .uat-check__mark { color: var(--ok); font-weight: 700; }
211
+ .uat-check__label { font-weight: 600; color: var(--ink); }
212
+ .uat-video { width: 100%; max-width: 560px; border-radius: var(--radius); background: var(--ink); }
213
+ .uat-form { display: flex; flex-direction: column; gap: 8px; }
214
+ .uat-field { display: flex; flex-direction: column; gap: 4px; }
215
+ .uat-field input[type="file"] { min-height: 24px; font: inherit; font-size: 12px; }
216
+ .uat-field textarea {
217
+ font: inherit; font-size: 12.5px; padding: 6px 8px; border-radius: var(--radius);
218
+ border: var(--line) solid var(--rule); background: var(--bg-card); color: var(--ink); resize: vertical;
219
+ }
220
+ .uat-check-input { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; cursor: pointer; }
221
+ .uat-check-input input { width: 16px; height: 16px; margin: 0; }
222
+ .uat-hint { margin: 0; }
223
+ .uat-error { margin: 0; color: var(--danger, var(--warn)); font-weight: 600; }
224
+ .uat-form .crit__confirm { align-self: flex-start; min-height: 32px; padding: 6px 14px; }
225
+
181
226
  /* BV1.R79: per-criterion expand in the capped band — short label, full text on
182
227
  click (matches the Roadmap page's .vbar__crit-d pattern). */
183
228
  .crit-d { min-width: 0; }
@@ -257,7 +257,10 @@
257
257
  began — goals-lib took the pure helpers, this takes the view painters.
258
258
  Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
259
259
  <script src="/builders/bulk-toolbar.js"></script>
260
- <script src="/builders/goals-render.js?v=2026-09-30-bulktoolbar"></script>
261
- <script src="/builders/goals-page.js?v=2026-09-30-bulktoolbar"></script>
260
+ <!-- task 1004402: the criterion UAT pills, panel and sign-off (ADR 0351). Loaded
261
+ BEFORE goals-render.js, which reads window.OTBCriterionUat at factory time. -->
262
+ <script src="/builders/criterion-uat-ui.js?v=2026-09-30-uat2"></script>
263
+ <script src="/builders/goals-render.js?v=2026-09-30-uat2"></script>
264
+ <script src="/builders/goals-page.js?v=2026-09-30-uat2"></script>
262
265
  </body>
263
266
  </html>
@@ -128,6 +128,44 @@
128
128
  },
129
129
  "out": {
130
130
  "auth": false
131
+ },
132
+ "uat": {
133
+ "auth": true,
134
+ "url": "/builders/goals#/goal/1000111/completion",
135
+ "actions": [
136
+ [
137
+ "wait",
138
+ 800
139
+ ]
140
+ ],
141
+ "expect": {
142
+ "visible": [
143
+ "#goal-completion-full"
144
+ ]
145
+ }
146
+ },
147
+ "uat-panel": {
148
+ "auth": true,
149
+ "url": "/builders/goals#/goal/1000111/completion",
150
+ "actions": [
151
+ [
152
+ "wait",
153
+ 800
154
+ ],
155
+ [
156
+ "click",
157
+ "#goal-completion-full [data-uat-open=\"990001\"]"
158
+ ],
159
+ [
160
+ "wait",
161
+ 500
162
+ ]
163
+ ],
164
+ "expect": {
165
+ "visible": [
166
+ "#goal-completion-full .uat-form"
167
+ ]
168
+ }
131
169
  }
132
170
  },
133
171
  "reducedMotion": {
@@ -60,6 +60,14 @@
60
60
  const GOLDEN = 137.50776405003785 * Math.PI / 180;
61
61
  const reduce = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
62
62
  const NOW = Date.now();
63
+ // task 1004402 (ADR 0351): a ring's UAT state, in the SAME words the goal page
64
+ // uses — read from criterion-uat-ui.js, which both sky pages load first. An
65
+ // open ring carries no label (its open ring already says so), and a missing
66
+ // script or an older server yields none rather than a guessed word.
67
+ function uatLabel(state) {
68
+ const labels = window.OTBCriterionUat && window.OTBCriterionUat.STATE_LABELS;
69
+ return labels && state && state !== 'open' ? labels[state] || null : null;
70
+ }
63
71
  function hash(s) { let h = 2166136261; s = String(s); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967296; }
64
72
  function rng(seed) { let t = Math.floor(hash(seed) * 1e9) + 1; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; }
65
73
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
@@ -138,7 +146,7 @@
138
146
  return {
139
147
  id: String(g.goal_id), title: g.title || 'A private goal', subtitle: g.subtitle || null, version: g.version_id, status: g.status,
140
148
  taskTotal: Number(g.task_total) || 0, taskDone: Number(g.task_done) || 0, createdAt: g.created_at || null,
141
- crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true })),
149
+ crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true, uat: c.uat_state || null, uatLabel: uatLabel(c.uat_state), backendOnly: c.backend_only === true })),
142
150
  tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
143
151
  };
144
152
  }
@@ -31,7 +31,7 @@
31
31
  if (!h || h.kind === 'shuttle') { card.classList.remove('on'); card.setAttribute('aria-hidden', 'true'); return; }
32
32
  const { goalById } = S; let html = '';
33
33
  if (h.kind === 'idea') { const i = h.ref; const o = OBJ[i.st] || { noun: i.st, word: i.st }; const wellG = i.well && goalById.get(i.well); const r = reach(i).length; html = `<div class="k">${glyphHtml(i)}<span class="lbl${i.st === 'star' ? ' hot' : ''}">${o.noun} · ${o.word}${i.origin === 'claude' ? ' · filed by a session' : ''}</span></div><div class="t">${esc(i.title)}</div><div class="m">${i.shaped ? `<b>${i.shaped.kind === 'goal' ? 'became this goal' : 'became a ring'}</b> · ` : ''}${wellG ? `caught by <b>${esc(short(wellG.title))}</b>` : `open space · ${Math.round(i.age)} days adrift`} · ${esc(became(i))}${i.size && i.size.credits ? ` · size <b>${i.size.credits}</b>` : ''}${i.lit < 1 ? ` · <b>${Math.round(i.lit * 100)}%</b> alive` : ''}${r ? ' · its reach is drawn' : ''}</div>`; }
34
- else if (h.kind === 'ring') { const c = h.ref; const g = h.goal; html = `<div class="k"><span class="mk well" style="${c.satisfied ? 'border-style:solid;border-color:rgba(255,255,255,.7)' : ''}"></span><span class="lbl">ring ${h.index + 1} of ${g.crit.length} · ${c.satisfied ? 'closed' : 'open'}</span></div><div class="t">${esc(SKY.plain(c.text).slice(0, 180))}${SKY.plain(c.text).length > 180 ? '…' : ''}</div><div class="m">${c.tasks.length ? `<b>${c.tasks.length}</b> task${c.tasks.length === 1 ? '' : 's'} on this ring · <b>${c.credits}</b> credits` : 'no task gates it yet'}${c.pendingReview ? ' · awaiting review' : ''}${c.bornOf ? ` · <b style="color:var(--accent)">born of a thought</b>: ${esc(ideaTitle(c.bornOf))}` : ''}</div>`; }
34
+ else if (h.kind === 'ring') { const c = h.ref; const g = h.goal; html = `<div class="k"><span class="mk well" style="${c.satisfied ? 'border-style:solid;border-color:rgba(255,255,255,.7)' : ''}"></span><span class="lbl">ring ${h.index + 1} of ${g.crit.length} · ${c.satisfied ? 'closed' : 'open'}</span></div><div class="t">${esc(SKY.plain(c.text).slice(0, 180))}${SKY.plain(c.text).length > 180 ? '…' : ''}</div><div class="m">${c.tasks.length ? `<b>${c.tasks.length}</b> task${c.tasks.length === 1 ? '' : 's'} on this ring · <b>${c.credits}</b> credits` : 'no task gates it yet'}${c.uatLabel ? ` · ${esc(c.uatLabel)}` : (c.pendingReview && !c.uat ? ' · awaiting review' : '')}${c.backendOnly ? ' · backend-only' : ''}${c.bornOf ? ` · <b style="color:var(--accent)">born of a thought</b>: ${esc(ideaTitle(c.bornOf))}` : ''}</div>`; }
35
35
  else if (h.kind === 'goal') { const g = h.ref; if (!seen(g)) html = `<div class="k"><span class="mk unseen"></span><span class="lbl">unexplored</span></div><div class="t">Something is here.</div><div class="m">Follow a route from a goal you know to reach it — or turn explore off to see everything.</div>`; else { const met = g.crit.filter((c) => c.satisfied).length; const state = g.status === 'open' ? (g.live > 0 ? 'gravity well · alive' : 'gravity well') : g.status === 'achieved' ? 'settled system' : 'collapsed'; html = `<div class="k"><span class="mk ${g.status === 'open' ? (g.live > 0 ? 'alive' : 'well') : g.status === 'achieved' ? 'settled' : 'collapsed'}"></span><span class="lbl${g.live > 0 ? ' hot' : ''}">${state} · ${esc(g.band.label)}</span></div><div class="t">${esc(g.title)}</div><div class="m"><b>${g.taskDone}</b> of <b>${g.taskTotal}</b> tasks${g.credits ? ` · <b>${g.credits}</b> credits` : ''} · ${g.crit.length ? `<b>${met}</b> of <b>${g.crit.length}</b> rings closed` : 'no rings written'}${g.live > 0 ? ` · <b>${g.active}</b> held now, <b>${g.recent}</b> landed this week` : ''}${g.thoughts.filter(inLayer).length ? ` · <b>${g.thoughts.filter(inLayer).length}</b> of your thoughts` : ''}${g.bornOf ? ` · <b style="color:var(--accent)">born of a thought</b>` : ''}${edgesOf(g).length ? ` · <b>${edgesOf(g).length}</b> route${edgesOf(g).length === 1 ? '' : 's'}` : ''}</div>`; } }
36
36
  else if (h.kind === 'task' || h.kind === 'ltask') { const t = h.ref; const kin = t.goal && t.kin ? t.goal.tasks.filter((x) => x.kin === t.kin && x !== t).length : 0; html = `<div class="k"><span class="mk task${t.status === 'active' ? ' live' : ''}"></span><span class="lbl${t.status === 'active' ? ' hot' : ''}">task · ${esc(t.status)}</span></div><div class="t">${esc(t.title)}</div><div class="m">${t.credits != null ? `<b>${t.credits}</b> credits · ` : ''}${t.ci >= 0 ? `ring ${t.ci + 1}` : 'outer orbit'}${t.sourceRef ? ' · born of a thought' : ''}${kin ? ` · lines up with <b>${kin}</b> kin` : ''}${t.drift ? ' · in motion' : ''}</div>`; }
37
37
  else if (h.kind === 'stub') { const e = h.edge; const waits = e.a === h.goal; html = `<div class="k"><span class="mk route"></span><span class="lbl chart">route · ${waits ? 'waits on' : 'waited on by'}</span></div><div class="t">${seen(h.other) ? esc(h.other.title) : 'an unexplored goal'}</div><div class="m"><b>${e.count}</b> task dependenc${e.count === 1 ? 'y' : 'ies'} · click to chart it · double-click to chart every route from here</div>`; }