@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.
- package/.bongos-core.json +45 -35
- package/docs/copy-inventory.md +121 -107
- package/docs/copy-registry.json +259 -133
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +7 -2
- package/docs/page-readings.json +196 -170
- package/modules/hall-ui/public/criterion-uat-ui.js +280 -0
- package/modules/hall-ui/public/goal-map.html +5 -3
- package/modules/hall-ui/public/goals-page.js +23 -44
- package/modules/hall-ui/public/goals-render.js +24 -15
- package/modules/hall-ui/public/goals.css +45 -0
- package/modules/hall-ui/public/goals.html +5 -2
- package/modules/hall-ui/public/goals.states.json +38 -0
- package/modules/hall-ui/public/sky-data.js +9 -1
- package/modules/hall-ui/public/sky-interaction.js +1 -1
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/hall-ui/public/thinking.html +5 -3
- package/modules/lifecycle/done-when.js +3 -1
- package/modules/lifecycle/sky.js +23 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/claim_action.mjs +6 -1
- package/tests/criterion_uat_ui.mjs +113 -0
- package/tests/goal_criterion_confirm_ui.mjs +188 -91
- package/tests/goal_map_page.mjs +2 -1
- package/tests/hall_sky.mjs +16 -0
- package/tests/helpers.mjs +3 -1
- package/tests/sky_read.mjs +7 -1
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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-
|
|
133
|
-
<script src="/builders/sky-panel.js?v=2026-09-
|
|
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
|
|
554
|
-
// (POST /
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
1086
|
-
//
|
|
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)
|
|
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
|
|
686
|
-
|
|
687
|
-
:
|
|
688
|
-
|
|
689
|
-
:
|
|
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">${
|
|
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">${
|
|
713
|
-
: `<span class="crit__text">${
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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>`; }
|