instar 1.3.810 → 1.3.811
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/dashboard/glance.js +533 -0
- package/dashboard/index.html +111 -97
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.811.md +53 -0
- package/upgrades/side-effects/glance-floors-f10-f11.md +225 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// Shared glance component — the three-layer template for the Dashboard UX Standard
|
|
2
|
+
// glance floors F10 (glance) + F11 (universal drill-down). Spec:
|
|
3
|
+
// docs/specs/dashboard-ux-standard.md ("The glance floors", topic 29836).
|
|
4
|
+
//
|
|
5
|
+
// Browser-native ESM (no build step; served at /dashboard/glance.js and imported by
|
|
6
|
+
// index.html on tab activation). The pure functions are exported so the three-tier
|
|
7
|
+
// jsdom tests exercise the SHIPPED code, not a copy.
|
|
8
|
+
//
|
|
9
|
+
// THE THREE LAYERS
|
|
10
|
+
// Layer 1 (glance) — one plain-English headline + ≤5 labeled tiles. 100%
|
|
11
|
+
// COMPONENT-AUTHORED: no agent/user free text ever reaches it.
|
|
12
|
+
// Layer 2 (list) — click a tile → the rows behind that number, in plain words.
|
|
13
|
+
// Layer 3 (record) — click a row → the full record (IDs, timestamps, raw detail).
|
|
14
|
+
//
|
|
15
|
+
// LOAD-BEARING SAFETY CONTRACT (mirrors dashboard/subscriptions.js): every dynamic
|
|
16
|
+
// value flows through sanitizeForDisplay before the DOM; ALL DOM writes are
|
|
17
|
+
// textContent only (never innerHTML); the only dynamic attributes are a fixed
|
|
18
|
+
// state→literal token and a numeric count. Agent/user free text lives at Layer 2/3
|
|
19
|
+
// where it is *displayed* through the sanitizer — it is never vocab-gated (F10's
|
|
20
|
+
// jargon check runs only over the component-authored Layer-1 strings).
|
|
21
|
+
//
|
|
22
|
+
// F9 COMPOSITION: while a drill interaction is open (the drill container carries
|
|
23
|
+
// data-interaction-open, or a field is focused/dirty) a background refresh MERGES
|
|
24
|
+
// live counts via patchGlanceCounts instead of rebuilding over the interaction —
|
|
25
|
+
// reusing the shipped hasOpenInteraction primitive.
|
|
26
|
+
|
|
27
|
+
import { sanitizeForDisplay, hasOpenInteraction } from './subscriptions.js';
|
|
28
|
+
|
|
29
|
+
export const GLANCE_MAX_TILES = 5;
|
|
30
|
+
export const GLANCE_WORD_BUDGET = 150; // words on the front page before interaction
|
|
31
|
+
export const GLANCE_MAX_TOKEN_LEN = 40; // a longer token is a glued-word budget dodge
|
|
32
|
+
|
|
33
|
+
// ── The glance-adopted / grandfathered registries (the ratchet) ──────────────
|
|
34
|
+
// A tab is ON the glance floor (F10/F11 apply) once it builds its glance through
|
|
35
|
+
// this component. GLANCE_ADOPTED_TABS grows as tabs migrate; GLANCE_GRANDFATHERED
|
|
36
|
+
// is every registered tab NOT yet on the floor, grandfathered against the survey
|
|
37
|
+
// scorecard (topic 29836). THE RATCHET: the grandfather list only shrinks — a tab
|
|
38
|
+
// leaves it only by adopting the floor (and passing F10/F11). Adding a tab here (or
|
|
39
|
+
// shipping a NEW tab grandfathered) requires raising GLANCE_GRANDFATHERED_CEILING,
|
|
40
|
+
// a visible committed change that needs a written justification + operator sign-off
|
|
41
|
+
// (same discipline as the F3 purpose-line exempt list). The completeness test
|
|
42
|
+
// asserts adopted ∪ grandfathered == every TAB_REGISTRY id, so a NEW tab in NEITHER
|
|
43
|
+
// set fails the build; the monotonicity test asserts the grandfather size ≤ ceiling.
|
|
44
|
+
export const GLANCE_ADOPTED_TABS = ['commitments'];
|
|
45
|
+
|
|
46
|
+
export const GLANCE_GRANDFATHERED = [
|
|
47
|
+
'insights', 'sessions', 'files', 'dropzone', 'jobs', 'features', 'systems',
|
|
48
|
+
'integrated-being', 'pr-pipeline', 'projects', 'initiatives', 'tokens',
|
|
49
|
+
'resources', 'llm-activity', 'routing-map', 'spend', 'threadline', 'evidence',
|
|
50
|
+
'process-health', 'subscriptions', 'preferences-learning', 'machines', 'mandates',
|
|
51
|
+
'blockers', 'secrets',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The committed ceiling on grandfathered-tab count. Only ever LOWER this (each
|
|
55
|
+
// lowering marks a tab retrofitted onto the floor). Never raise it without an
|
|
56
|
+
// operator-signed justification — raising it is how a NEW tab would silently ship
|
|
57
|
+
// below the floor, the exact regression the ratchet exists to prevent.
|
|
58
|
+
export const GLANCE_GRANDFATHERED_CEILING = 25;
|
|
59
|
+
|
|
60
|
+
// ── F10 — insider-vocab detection ────────────────────────────────────────────
|
|
61
|
+
// A readability floor, NOT a secret-redaction boundary (secret handling stays at
|
|
62
|
+
// the API/data layer). It scans ONLY component-authored Layer-1 strings (headline +
|
|
63
|
+
// tile labels + tile values), so it can never blank the glance on user free text.
|
|
64
|
+
|
|
65
|
+
// Concept-jargon the form heuristics can't catch (curated; extend as jargon is
|
|
66
|
+
// found). Matched case-insensitively as whole words/phrases over normalized text.
|
|
67
|
+
const INSIDER_TERM_DENYLIST = [
|
|
68
|
+
'atrisk', 'at risk', 'at-risk', 'suppressed', 'beacon', 'beacons',
|
|
69
|
+
'beaconenabled', 'beaconsuppressed', 'cadence', 'heartbeat', 'heartbeats',
|
|
70
|
+
'lane', 'reflow', 'ttl', 'slo', 'sla', 'mrr', 'paid door', 'money-gated',
|
|
71
|
+
'quiet-hours', 'quiet hours',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Internal IDs: a letter-run glued or hyphen/underscore-joined to 3+ digits
|
|
75
|
+
// (CMT-953, CMT_953, cmt953) — separator-agnostic, case-insensitive, NOT
|
|
76
|
+
// space-separated (so a quantity like "664 open promises" is never flagged).
|
|
77
|
+
const ID_RE = /[a-z]{2,}[-_]?\d{3,}/i;
|
|
78
|
+
// An all-caps prefix + optional space/sep + 3+ digits (CMT 953 / CMT-953) — safe
|
|
79
|
+
// because component-authored plain copy never writes an ALLCAPS token beside a number.
|
|
80
|
+
const ALLCAPS_ID_RE = /\b[A-Z]{2,6}[-_ ]?\d{3,}\b/;
|
|
81
|
+
// Machine/agent hex ids: m_<hex>, agent_<hex>, machine-<hex-with-digit>.
|
|
82
|
+
const HEX_ID_RE = /\b(?:[a-z]{1,}_[0-9a-f]{4,}|m_[0-9a-f]{4,}|[a-z]{2,}-[0-9a-f]*\d[0-9a-f]*)\b/i;
|
|
83
|
+
// Config keys: a camelCase transition (softDeadlineAt) or a snake_case token.
|
|
84
|
+
const CAMEL_RE = /\b[a-z][a-z0-9]*[A-Z][a-zA-Z0-9]*\b/;
|
|
85
|
+
const SNAKE_RE = /\b[a-z0-9]+_[a-z0-9]+\b/i;
|
|
86
|
+
// Machine-duration cadences: a bare number glued/spaced to a time unit — 1800s,
|
|
87
|
+
// 1800 s, 1800sec, 1800000ms, PT30M — EXCLUDING 4-digit year/decade prose ("1800s"
|
|
88
|
+
// meaning the 1800s decade is excluded via the decade guard below).
|
|
89
|
+
const CADENCE_RE = /\b\d{1,9}\s?(?:ms|milliseconds?|secs?|seconds?|s)\b|\bPT\d+[HMSD]\b/i;
|
|
90
|
+
const DECADE_RE = /^(?:1[5-9]\d0|20[0-4]\d)s$/i; // 1500s..1990s, 2000s..2049s
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the insider-vocabulary hits in a component-authored string. Empty array =
|
|
94
|
+
* clean. Each hit is { type, match }. NFKC-normalized + case-insensitive so
|
|
95
|
+
* look-alike glyphs and case tricks can't dodge the check.
|
|
96
|
+
*/
|
|
97
|
+
export function findInsiderVocab(text) {
|
|
98
|
+
const norm = String(text == null ? '' : text).normalize('NFKC');
|
|
99
|
+
const lower = norm.toLowerCase();
|
|
100
|
+
const hits = [];
|
|
101
|
+
|
|
102
|
+
const id = norm.match(ID_RE) || norm.match(ALLCAPS_ID_RE);
|
|
103
|
+
if (id) hits.push({ type: 'internal-id', match: id[0] });
|
|
104
|
+
const hex = norm.match(HEX_ID_RE);
|
|
105
|
+
if (hex) hits.push({ type: 'machine-id', match: hex[0] });
|
|
106
|
+
const camel = norm.match(CAMEL_RE);
|
|
107
|
+
if (camel) hits.push({ type: 'config-key', match: camel[0] });
|
|
108
|
+
const snake = norm.match(SNAKE_RE);
|
|
109
|
+
if (snake) hits.push({ type: 'config-key', match: snake[0] });
|
|
110
|
+
|
|
111
|
+
for (const m of lower.matchAll(new RegExp(CADENCE_RE, 'gi'))) {
|
|
112
|
+
const tok = m[0].replace(/\s+/g, '');
|
|
113
|
+
// "1800s" is ambiguous (1800 seconds vs the 1800s decade). Only treat it as a
|
|
114
|
+
// decade — and skip — when it reads as decade PROSE (preceded by "the"/"in").
|
|
115
|
+
if (DECADE_RE.test(tok)) {
|
|
116
|
+
const before = lower.slice(Math.max(0, m.index - 8), m.index);
|
|
117
|
+
if (/\b(?:the|in|early|late|mid)\s+$/.test(before)) continue;
|
|
118
|
+
}
|
|
119
|
+
hits.push({ type: 'cadence', match: m[0].trim() });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const term of INSIDER_TERM_DENYLIST) {
|
|
123
|
+
// Word/phrase boundary so "beacon" matches but "beaconing-signal-lantern" as a
|
|
124
|
+
// whole is still caught by the substring intent; keep it simple + robust.
|
|
125
|
+
const re = new RegExp(`(?:^|[^a-z0-9])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:$|[^a-z0-9])`, 'i');
|
|
126
|
+
if (re.test(lower)) hits.push({ type: 'insider-term', match: term });
|
|
127
|
+
}
|
|
128
|
+
return hits;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tokenize Layer-1 copy for the word budget: split on whitespace and structural
|
|
133
|
+
* punctuation (hyphen / underscore / slash / common separators) so a glued
|
|
134
|
+
* "carrying-664-open-cmt953" cannot pose as one word. A token longer than
|
|
135
|
+
* GLANCE_MAX_TOKEN_LEN is itself a budget dodge and is reported separately.
|
|
136
|
+
*/
|
|
137
|
+
export function tokenizeGlance(text) {
|
|
138
|
+
return String(text == null ? '' : text)
|
|
139
|
+
.normalize('NFKC')
|
|
140
|
+
.split(/[\s\-_/.,;:·|()[\]{}]+/)
|
|
141
|
+
.map((t) => t.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function countGlanceWords(text) {
|
|
146
|
+
return tokenizeGlance(text).length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The full component-authored Layer-1 text: headline + every tile label + value. */
|
|
150
|
+
export function glanceText(spec) {
|
|
151
|
+
const parts = [String(spec?.headline ?? '')];
|
|
152
|
+
for (const t of spec?.tiles ?? []) {
|
|
153
|
+
parts.push(String(t?.label ?? ''));
|
|
154
|
+
parts.push(String(t?.value ?? ''));
|
|
155
|
+
}
|
|
156
|
+
return parts.join(' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* F10 validator — the shared component refuses to build a glance that breaks the
|
|
161
|
+
* budget or carries jargon. Returns { ok, violations: [{code, detail}] }. Scans the
|
|
162
|
+
* concatenation of headline + every tile label + every tile value (all
|
|
163
|
+
* component-authored) — there is no free-text hole to hide jargon in.
|
|
164
|
+
*/
|
|
165
|
+
export function validateGlanceSpec(spec) {
|
|
166
|
+
const violations = [];
|
|
167
|
+
const tiles = Array.isArray(spec?.tiles) ? spec.tiles : [];
|
|
168
|
+
|
|
169
|
+
if (!spec || typeof spec.headline !== 'string' || spec.headline.trim() === '') {
|
|
170
|
+
violations.push({ code: 'no-headline', detail: 'a glance needs one plain-English headline sentence' });
|
|
171
|
+
}
|
|
172
|
+
if (tiles.length > GLANCE_MAX_TILES) {
|
|
173
|
+
violations.push({ code: 'too-many-tiles', detail: `${tiles.length} tiles > max ${GLANCE_MAX_TILES}` });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const text = glanceText(spec);
|
|
177
|
+
const words = countGlanceWords(text);
|
|
178
|
+
if (words > GLANCE_WORD_BUDGET) {
|
|
179
|
+
violations.push({ code: 'over-budget', detail: `${words} words > budget ${GLANCE_WORD_BUDGET}` });
|
|
180
|
+
}
|
|
181
|
+
for (const tok of tokenizeGlance(text)) {
|
|
182
|
+
if (tok.length > GLANCE_MAX_TOKEN_LEN) {
|
|
183
|
+
violations.push({ code: 'glued-token', detail: `"${tok.slice(0, 24)}…" (${tok.length} chars) evades the word count` });
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const jargon = findInsiderVocab(text);
|
|
189
|
+
for (const hit of jargon) {
|
|
190
|
+
violations.push({ code: 'insider-vocab', detail: `${hit.type}: "${hit.match}"` });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { ok: violations.length === 0, violations };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function el(doc, tag, cls, text) {
|
|
199
|
+
const node = doc.createElement(tag);
|
|
200
|
+
if (cls) node.className = cls;
|
|
201
|
+
if (text != null) node.textContent = sanitizeForDisplay(text, 'label');
|
|
202
|
+
return node;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Render the three-layer glance into `root` from `spec`:
|
|
207
|
+
* spec = { headline, tiles: [{ key, label, value, tone?, onActivate?(ctx) }] }
|
|
208
|
+
* Honors F9: if an interaction is open under `root`, MERGE live counts instead of
|
|
209
|
+
* rebuilding (patchGlanceCounts). On a spec that fails validation renders an HONEST
|
|
210
|
+
* DEGRADED glance (truncated headline + a "See details" drill) — NEVER a raw-record
|
|
211
|
+
* fallback. Returns a handle { root, headline, tiles, drilldown, spec }.
|
|
212
|
+
*/
|
|
213
|
+
export function renderGlance(doc, root, spec, opts = {}) {
|
|
214
|
+
if (!doc || !root) return null;
|
|
215
|
+
|
|
216
|
+
// F9 merge arm: an open drill / focused / dirty interaction holds the DOM.
|
|
217
|
+
if (root.querySelector('[data-glance-layer]') && hasOpenInteraction(doc, root)) {
|
|
218
|
+
patchGlanceCounts(doc, root, spec);
|
|
219
|
+
return { root, held: true, spec };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { ok, violations } = validateGlanceSpec(spec);
|
|
223
|
+
if (!ok && typeof console !== 'undefined' && console.warn) {
|
|
224
|
+
console.warn('[glance] spec failed F10 validation — rendering honest degraded glance:', violations);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Replace, don't append — repeated renders never leak detached DOM/listeners.
|
|
228
|
+
root.replaceChildren();
|
|
229
|
+
|
|
230
|
+
const layer = el(doc, 'div', 'glance-layer');
|
|
231
|
+
layer.setAttribute('data-glance-layer', '');
|
|
232
|
+
|
|
233
|
+
const headline = el(doc, 'div', 'glance-headline');
|
|
234
|
+
headline.setAttribute('data-glance-headline', '');
|
|
235
|
+
// Degraded mode: truncate to budget, never dump raw records.
|
|
236
|
+
const headlineText = ok
|
|
237
|
+
? String(spec?.headline ?? '')
|
|
238
|
+
: truncateToWords(String(spec?.headline ?? 'Details available'), GLANCE_WORD_BUDGET);
|
|
239
|
+
headline.textContent = sanitizeForDisplay(headlineText, 'summary');
|
|
240
|
+
layer.appendChild(headline);
|
|
241
|
+
|
|
242
|
+
const drilldown = doc.createElement('section');
|
|
243
|
+
drilldown.className = 'glance-drilldown';
|
|
244
|
+
drilldown.setAttribute('data-glance-drilldown', '');
|
|
245
|
+
drilldown.hidden = true;
|
|
246
|
+
|
|
247
|
+
const tilesWrap = el(doc, 'div', 'glance-tiles');
|
|
248
|
+
tilesWrap.setAttribute('data-glance-tiles', '');
|
|
249
|
+
const tiles = Array.isArray(spec?.tiles) ? spec.tiles : [];
|
|
250
|
+
const tileNodes = [];
|
|
251
|
+
const usableTiles = ok ? tiles.slice(0, GLANCE_MAX_TILES) : [];
|
|
252
|
+
for (const tile of usableTiles) {
|
|
253
|
+
const btn = doc.createElement('button');
|
|
254
|
+
btn.type = 'button';
|
|
255
|
+
btn.className = 'glance-tile';
|
|
256
|
+
btn.setAttribute('data-glance-tile', String(tile.key ?? tile.label ?? ''));
|
|
257
|
+
if (tile.tone) btn.setAttribute('data-tone', String(tile.tone));
|
|
258
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
259
|
+
|
|
260
|
+
const valEl = el(doc, 'span', 'glance-tile-value');
|
|
261
|
+
valEl.setAttribute('data-glance-count', '');
|
|
262
|
+
valEl.textContent = sanitizeForDisplay(String(tile.value ?? ''), 'label');
|
|
263
|
+
const labelEl = el(doc, 'span', 'glance-tile-label', String(tile.label ?? ''));
|
|
264
|
+
|
|
265
|
+
// Accessible label (F5) — never an icon-only/bare control.
|
|
266
|
+
btn.setAttribute('aria-label', `${sanitizeForDisplay(String(tile.label ?? ''), 'label')}: ${sanitizeForDisplay(String(tile.value ?? ''), 'label')}`);
|
|
267
|
+
btn.appendChild(valEl);
|
|
268
|
+
btn.appendChild(labelEl);
|
|
269
|
+
|
|
270
|
+
btn.addEventListener('click', () => openDrill(doc, root, drilldown, btn, tile, tileNodes));
|
|
271
|
+
tilesWrap.appendChild(btn);
|
|
272
|
+
tileNodes.push(btn);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Degraded fallback: one honest "See details" affordance, never the raw dump.
|
|
276
|
+
if (!ok) {
|
|
277
|
+
const btn = doc.createElement('button');
|
|
278
|
+
btn.type = 'button';
|
|
279
|
+
btn.className = 'glance-tile glance-tile-degraded';
|
|
280
|
+
btn.setAttribute('data-glance-tile', '__details__');
|
|
281
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
282
|
+
btn.appendChild(el(doc, 'span', 'glance-tile-label', 'See details'));
|
|
283
|
+
btn.addEventListener('click', () => openDrill(doc, root, drilldown, btn, {
|
|
284
|
+
key: '__details__',
|
|
285
|
+
onActivate: (ctx) => { ctx.drilldown.appendChild(el(doc, 'div', 'glance-empty', 'Details are being prepared.')); },
|
|
286
|
+
}, tileNodes));
|
|
287
|
+
tilesWrap.appendChild(btn);
|
|
288
|
+
tileNodes.push(btn);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
layer.appendChild(tilesWrap);
|
|
292
|
+
root.appendChild(layer);
|
|
293
|
+
root.appendChild(drilldown);
|
|
294
|
+
|
|
295
|
+
return { root, headline, tiles: tileNodes, drilldown, spec };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function truncateToWords(text, max) {
|
|
299
|
+
const toks = tokenizeGlance(text);
|
|
300
|
+
if (toks.length <= max) return text;
|
|
301
|
+
return toks.slice(0, max).join(' ') + '…';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Open a tile's drill (Layer 2). Replaces the drill container (never appends),
|
|
306
|
+
* calls the tile's onActivate to populate it, reveals it, and marks it
|
|
307
|
+
* data-interaction-open so a background refresh HOLDS it (F9). Clicking the same
|
|
308
|
+
* tile again (or the Back control) releases the hold. onActivate receives:
|
|
309
|
+
* { doc, drilldown, tile, openRecord } — openRecord(node) swaps in a Layer-3 record.
|
|
310
|
+
*/
|
|
311
|
+
function openDrill(doc, root, drilldown, btn, tile, allTiles) {
|
|
312
|
+
const alreadyOpen = drilldown.getAttribute('data-open-tile') === btn.getAttribute('data-glance-tile') && !drilldown.hidden;
|
|
313
|
+
// Collapse any open tile first.
|
|
314
|
+
for (const t of allTiles) t.setAttribute('aria-expanded', 'false');
|
|
315
|
+
drilldown.replaceChildren();
|
|
316
|
+
drilldown.removeAttribute('data-interaction-open');
|
|
317
|
+
|
|
318
|
+
if (alreadyOpen) {
|
|
319
|
+
drilldown.hidden = true;
|
|
320
|
+
drilldown.removeAttribute('data-open-tile');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const header = el(doc, 'div', 'glance-drill-header');
|
|
325
|
+
const back = doc.createElement('button');
|
|
326
|
+
back.type = 'button';
|
|
327
|
+
back.className = 'glance-drill-back';
|
|
328
|
+
back.setAttribute('aria-label', 'Back to the glance');
|
|
329
|
+
back.textContent = '← Back';
|
|
330
|
+
back.addEventListener('click', () => {
|
|
331
|
+
drilldown.replaceChildren();
|
|
332
|
+
drilldown.hidden = true;
|
|
333
|
+
drilldown.removeAttribute('data-interaction-open');
|
|
334
|
+
drilldown.removeAttribute('data-open-tile');
|
|
335
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
336
|
+
});
|
|
337
|
+
header.appendChild(back);
|
|
338
|
+
const title = el(doc, 'span', 'glance-drill-title', tile.label != null ? String(tile.label) : 'Details');
|
|
339
|
+
header.appendChild(title);
|
|
340
|
+
drilldown.appendChild(header);
|
|
341
|
+
|
|
342
|
+
const body = el(doc, 'div', 'glance-drill-body');
|
|
343
|
+
body.setAttribute('data-glance-drill-body', '');
|
|
344
|
+
drilldown.appendChild(body);
|
|
345
|
+
|
|
346
|
+
const openRecord = (node) => {
|
|
347
|
+
// Layer 3: swap the list for the full record, with a Back-to-list control.
|
|
348
|
+
const recWrap = el(doc, 'div', 'glance-record');
|
|
349
|
+
recWrap.setAttribute('data-glance-record', '');
|
|
350
|
+
const toList = doc.createElement('button');
|
|
351
|
+
toList.type = 'button';
|
|
352
|
+
toList.className = 'glance-drill-back';
|
|
353
|
+
toList.setAttribute('aria-label', 'Back to the list');
|
|
354
|
+
toList.textContent = '← Back to list';
|
|
355
|
+
const priorList = Array.from(body.childNodes);
|
|
356
|
+
toList.addEventListener('click', () => {
|
|
357
|
+
body.replaceChildren(...priorList);
|
|
358
|
+
});
|
|
359
|
+
recWrap.appendChild(toList);
|
|
360
|
+
if (node) recWrap.appendChild(node);
|
|
361
|
+
body.replaceChildren(recWrap);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
if (typeof tile.onActivate === 'function') {
|
|
366
|
+
tile.onActivate({ doc, drilldown: body, tile, openRecord });
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
// A drill builder that throws must not white-screen the tab: show an honest
|
|
370
|
+
// error state, never a raw dump. @silent-fallback-ok — degraded drill, logged.
|
|
371
|
+
body.replaceChildren(el(doc, 'div', 'glance-empty', 'Could not load these details right now.'));
|
|
372
|
+
if (typeof console !== 'undefined' && console.warn) console.warn('[glance] drill onActivate failed:', err);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// An honest F6 empty-state if the drill produced nothing (e.g. a zero-count tile).
|
|
376
|
+
if (body.childNodes.length === 0) {
|
|
377
|
+
body.appendChild(el(doc, 'div', 'glance-empty', 'Nothing here right now.'));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
drilldown.setAttribute('data-open-tile', btn.getAttribute('data-glance-tile') || '');
|
|
381
|
+
drilldown.setAttribute('data-interaction-open', 'glance-drill');
|
|
382
|
+
drilldown.hidden = false;
|
|
383
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* F9 merge arm — patch the live tile counts (and headline) from a fresh spec
|
|
388
|
+
* WITHOUT rebuilding the DOM, so an open drill interaction is never clobbered.
|
|
389
|
+
* Returns the number of tiles patched.
|
|
390
|
+
*/
|
|
391
|
+
export function patchGlanceCounts(doc, root, spec) {
|
|
392
|
+
if (!root || !spec) return 0;
|
|
393
|
+
let patched = 0;
|
|
394
|
+
const headline = root.querySelector('[data-glance-headline]');
|
|
395
|
+
if (headline && typeof spec.headline === 'string') {
|
|
396
|
+
headline.textContent = sanitizeForDisplay(spec.headline, 'summary');
|
|
397
|
+
}
|
|
398
|
+
for (const tile of spec.tiles ?? []) {
|
|
399
|
+
const key = String(tile.key ?? tile.label ?? '');
|
|
400
|
+
const btn = root.querySelector(`[data-glance-tile="${cssEscape(key)}"]`);
|
|
401
|
+
if (!btn) continue;
|
|
402
|
+
const val = btn.querySelector('[data-glance-count]');
|
|
403
|
+
if (val) { val.textContent = sanitizeForDisplay(String(tile.value ?? ''), 'label'); patched++; }
|
|
404
|
+
}
|
|
405
|
+
return patched;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cssEscape(s) {
|
|
409
|
+
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
|
|
410
|
+
return String(s).replace(/["\\\]]/g, '\\$&');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Commitments reference builder (the Phase-1 living example) ────────────────
|
|
414
|
+
// Pure: turns the /commitments open-promises list into a glance spec. Derives every
|
|
415
|
+
// tile + the headline from ONE population — the beacon-watched open promises
|
|
416
|
+
// (beaconEnabled && status==='pending'), the identical set the drill-down shows — so
|
|
417
|
+
// the headline count EQUALS the Layer-2 list length by construction, and each tile
|
|
418
|
+
// maps to an EXISTING server field (no client-side state re-derivation).
|
|
419
|
+
|
|
420
|
+
/** The single population: beacon-watched open promises. */
|
|
421
|
+
export function commitmentsOpenPopulation(commitments) {
|
|
422
|
+
return (Array.isArray(commitments) ? commitments : [])
|
|
423
|
+
.filter((c) => c && c.beaconEnabled && c.status === 'pending');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function buildCommitmentsGlance(commitments, now = Date.now()) {
|
|
427
|
+
const open = commitmentsOpenPopulation(commitments);
|
|
428
|
+
const dueSoon = open.filter((c) => c.atRisk === true);
|
|
429
|
+
const waiting = open.filter((c) => c.blockedOn === 'user-input' || c.blockedOn === 'user-authorization');
|
|
430
|
+
const quiet = open.filter((c) => c.beaconSuppressed === true);
|
|
431
|
+
const overdue = open.filter((c) => c.hardDeadlineAt && Date.parse(c.hardDeadlineAt) < now);
|
|
432
|
+
|
|
433
|
+
// Component-authored, jargon-free headline — honest to the one population.
|
|
434
|
+
let headline;
|
|
435
|
+
if (open.length === 0) {
|
|
436
|
+
headline = "You have no open promises right now.";
|
|
437
|
+
} else {
|
|
438
|
+
const soonClause = dueSoon.length > 0 ? `${dueSoon.length} need attention soon` : 'none need attention soon';
|
|
439
|
+
const overdueClause = overdue.length > 0 ? `${overdue.length} overdue` : 'none overdue';
|
|
440
|
+
const noun = open.length === 1 ? 'open promise' : 'open promises';
|
|
441
|
+
headline = `I'm carrying ${open.length} ${noun}; ${soonClause}, ${overdueClause}.`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const tiles = [
|
|
445
|
+
{ key: 'open', label: 'Open', value: String(open.length), tone: 'neutral', rows: open },
|
|
446
|
+
{ key: 'due-soon', label: 'Due soon', value: String(dueSoon.length), tone: dueSoon.length ? 'warn' : 'neutral', rows: dueSoon },
|
|
447
|
+
{ key: 'waiting', label: 'Waiting on you', value: String(waiting.length), tone: waiting.length ? 'warn' : 'neutral', rows: waiting },
|
|
448
|
+
{ key: 'quiet', label: 'Quiet', value: String(quiet.length), tone: 'muted', rows: quiet },
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
return { headline, tiles, population: open };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** One plain-word Layer-2 row for a commitment (no IDs/cadences — those are Layer 3). */
|
|
455
|
+
export function commitmentRowText(c) {
|
|
456
|
+
const summary = sanitizeForDisplay(c.agentResponse || c.userRequest || 'A promise', 'summary');
|
|
457
|
+
return summary;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function defaultFmtTs(iso) {
|
|
461
|
+
if (!iso) return '—';
|
|
462
|
+
try { return new Date(iso).toLocaleString(); } catch { return String(iso); }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Layer-3 full record for a commitment — the raw detail (IDs, cadence, deadlines)
|
|
467
|
+
* lives HERE, one click below the plain Layer-2 row. All values via textContent
|
|
468
|
+
* (XSS-safe); this is where insider fields legitimately appear. Optional onDeliver
|
|
469
|
+
* wires the existing "Mark delivered" action onto the record.
|
|
470
|
+
*/
|
|
471
|
+
export function commitmentRecordNode(doc, c, opts = {}) {
|
|
472
|
+
const fmtTs = opts.fmtTs || defaultFmtTs;
|
|
473
|
+
const wrap = el(doc, 'div', 'glance-record-fields');
|
|
474
|
+
const rows = [
|
|
475
|
+
['Promise', c.agentResponse || c.userRequest || '—'],
|
|
476
|
+
['id', c.id || '—'],
|
|
477
|
+
['topic', c.topicId != null ? String(c.topicId) : '—'],
|
|
478
|
+
['cadence', c.cadenceMs ? `${Math.round(c.cadenceMs / 1000)}s` : '—'],
|
|
479
|
+
['heartbeats', String(c.heartbeatCount ?? 0)],
|
|
480
|
+
['last heartbeat', fmtTs(c.lastHeartbeatAt)],
|
|
481
|
+
['soft deadline', fmtTs(c.softDeadlineAt)],
|
|
482
|
+
['hard deadline', fmtTs(c.hardDeadlineAt)],
|
|
483
|
+
];
|
|
484
|
+
for (const [k, v] of rows) {
|
|
485
|
+
const row = el(doc, 'div', 'glance-record-row');
|
|
486
|
+
row.appendChild(el(doc, 'span', 'glance-record-key', String(k)));
|
|
487
|
+
row.appendChild(el(doc, 'span', 'glance-record-val', String(v)));
|
|
488
|
+
wrap.appendChild(row);
|
|
489
|
+
}
|
|
490
|
+
if (typeof opts.onDeliver === 'function' && c.id) {
|
|
491
|
+
const btn = doc.createElement('button');
|
|
492
|
+
btn.type = 'button';
|
|
493
|
+
btn.className = 'glance-record-action';
|
|
494
|
+
btn.textContent = 'Mark delivered';
|
|
495
|
+
btn.addEventListener('click', () => { btn.disabled = true; opts.onDeliver(c.id); });
|
|
496
|
+
wrap.appendChild(btn);
|
|
497
|
+
}
|
|
498
|
+
return wrap;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Build the FULL Commitments glance spec with drill wiring — the reference
|
|
503
|
+
* implementation, importable by index.html AND the three test tiers. Each tile's
|
|
504
|
+
* onActivate renders the filtered open-promises as plain Layer-2 rows; each row
|
|
505
|
+
* opens the Layer-3 record. Population + counts come from buildCommitmentsGlance
|
|
506
|
+
* (one denominator, honest counts).
|
|
507
|
+
*/
|
|
508
|
+
export function commitmentsGlanceSpec(doc, commitments, opts = {}) {
|
|
509
|
+
const now = opts.now ?? Date.now();
|
|
510
|
+
const base = buildCommitmentsGlance(commitments, now);
|
|
511
|
+
const tiles = base.tiles.map((t) => ({
|
|
512
|
+
key: t.key,
|
|
513
|
+
label: t.label,
|
|
514
|
+
value: t.value,
|
|
515
|
+
tone: t.tone,
|
|
516
|
+
onActivate: ({ doc: d, drilldown, openRecord }) => {
|
|
517
|
+
const rows = t.rows || [];
|
|
518
|
+
if (rows.length === 0) return; // component renders the honest F6 empty-state
|
|
519
|
+
const list = el(d, 'div', 'glance-list');
|
|
520
|
+
for (const c of rows) {
|
|
521
|
+
const row = d.createElement('button');
|
|
522
|
+
row.type = 'button';
|
|
523
|
+
row.className = 'glance-list-row';
|
|
524
|
+
row.setAttribute('aria-label', 'Open the full record');
|
|
525
|
+
row.appendChild(el(d, 'span', 'glance-list-summary', commitmentRowText(c)));
|
|
526
|
+
row.addEventListener('click', () => openRecord(commitmentRecordNode(d, c, { onDeliver: opts.onDeliver })));
|
|
527
|
+
list.appendChild(row);
|
|
528
|
+
}
|
|
529
|
+
drilldown.appendChild(list);
|
|
530
|
+
},
|
|
531
|
+
}));
|
|
532
|
+
return { headline: base.headline, tiles, population: base.population };
|
|
533
|
+
}
|
package/dashboard/index.html
CHANGED
|
@@ -593,6 +593,71 @@
|
|
|
593
593
|
max-width: 900px;
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/* ── Glance component (Dashboard UX Standard F10/F11, topic 29836) ──
|
|
597
|
+
The shared three-layer template rendered by dashboard/glance.js: a
|
|
598
|
+
plain-English headline + ≤5 labeled tiles (Layer 1), each drilling into a
|
|
599
|
+
list (Layer 2) then a full record (Layer 3). Theme-consistent + mobile
|
|
600
|
+
responsive (tiles/records reflow; nothing scrolls the page sideways). */
|
|
601
|
+
.glance-root { display: flex; flex-direction: column; gap: 16px; }
|
|
602
|
+
.glance-layer { display: flex; flex-direction: column; gap: 16px; }
|
|
603
|
+
.glance-headline {
|
|
604
|
+
font-size: 20px; font-weight: 600; line-height: 1.4;
|
|
605
|
+
color: var(--text-bright); max-width: 900px;
|
|
606
|
+
}
|
|
607
|
+
.glance-tiles {
|
|
608
|
+
display: grid; gap: 12px;
|
|
609
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
610
|
+
}
|
|
611
|
+
.glance-tile {
|
|
612
|
+
display: flex; flex-direction: column; gap: 4px; align-items: flex-start;
|
|
613
|
+
padding: 16px; border: 1px solid var(--border); border-radius: 10px;
|
|
614
|
+
background: var(--bg-panel); color: var(--text); cursor: pointer;
|
|
615
|
+
text-align: left; min-width: 0;
|
|
616
|
+
transition: background .15s, border-color .15s;
|
|
617
|
+
}
|
|
618
|
+
.glance-tile:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
|
619
|
+
.glance-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
620
|
+
.glance-tile[aria-expanded="true"] { border-color: var(--accent); background: var(--bg-active); }
|
|
621
|
+
.glance-tile-value { font-size: 28px; font-weight: 700; color: var(--text-bright); }
|
|
622
|
+
.glance-tile-label { font-size: 13px; color: var(--text-dim); }
|
|
623
|
+
.glance-tile[data-tone="warn"] .glance-tile-value { color: var(--orange); }
|
|
624
|
+
.glance-tile[data-tone="muted"] .glance-tile-value { color: var(--text-dim); }
|
|
625
|
+
.glance-tile-degraded { justify-content: center; }
|
|
626
|
+
.glance-tile-degraded .glance-tile-label { color: var(--text); font-weight: 600; }
|
|
627
|
+
.glance-drilldown {
|
|
628
|
+
border: 1px solid var(--border); border-radius: 10px; background: var(--bg-panel);
|
|
629
|
+
padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
|
630
|
+
}
|
|
631
|
+
.glance-drilldown[hidden] { display: none; }
|
|
632
|
+
.glance-drill-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
633
|
+
.glance-drill-title { font-weight: 600; color: var(--text-bright); }
|
|
634
|
+
.glance-drill-back {
|
|
635
|
+
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
|
|
636
|
+
background: var(--bg-hover); color: var(--text); cursor: pointer; font-size: 12px;
|
|
637
|
+
}
|
|
638
|
+
.glance-drill-back:hover { border-color: var(--accent-dim); }
|
|
639
|
+
.glance-list { display: flex; flex-direction: column; gap: 8px; }
|
|
640
|
+
.glance-list-row {
|
|
641
|
+
text-align: left; padding: 12px 14px; border: 1px solid var(--border);
|
|
642
|
+
border-radius: 8px; background: var(--bg); color: var(--text); cursor: pointer;
|
|
643
|
+
line-height: 1.4; width: 100%;
|
|
644
|
+
}
|
|
645
|
+
.glance-list-row:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
|
646
|
+
.glance-empty { padding: 24px; text-align: center; color: var(--text-dim); }
|
|
647
|
+
.glance-record { display: flex; flex-direction: column; gap: 10px; }
|
|
648
|
+
.glance-record-fields { display: flex; flex-direction: column; gap: 6px; }
|
|
649
|
+
.glance-record-row {
|
|
650
|
+
display: grid; grid-template-columns: minmax(80px, 140px) 1fr; gap: 10px; font-size: 13px;
|
|
651
|
+
}
|
|
652
|
+
.glance-record-key { color: var(--text-dim); }
|
|
653
|
+
.glance-record-val { color: var(--text); word-break: break-word; overflow-wrap: anywhere; }
|
|
654
|
+
.glance-record-action {
|
|
655
|
+
align-self: flex-start; margin-top: 8px; padding: 6px 12px;
|
|
656
|
+
border: 1px solid var(--border); border-radius: 6px; background: var(--bg-hover);
|
|
657
|
+
color: var(--text); cursor: pointer;
|
|
658
|
+
}
|
|
659
|
+
.glance-record-action:hover { border-color: var(--accent-dim); }
|
|
660
|
+
|
|
596
661
|
.terminal-header {
|
|
597
662
|
display: flex;
|
|
598
663
|
align-items: center;
|
|
@@ -3312,15 +3377,11 @@
|
|
|
3312
3377
|
<button onclick="loadCommitments()" style="padding:6px 12px">Refresh</button>
|
|
3313
3378
|
</div>
|
|
3314
3379
|
<div class="tab-purpose">
|
|
3315
|
-
|
|
3316
|
-
<span style="color:#4a9a4a">pending</span>,
|
|
3317
|
-
<span style="color:#e27d3b">atRisk</span>,
|
|
3318
|
-
<span style="color:#888">suppressed</span>.
|
|
3319
|
-
</div>
|
|
3320
|
-
<div id="commitmentsEmpty" style="padding:40px;text-align:center;color:var(--text-dim);display:none">
|
|
3321
|
-
No open promises.
|
|
3380
|
+
Your open promises at a glance — the headline says where things stand; tap a tile to see which promises, tap one for the full record.
|
|
3322
3381
|
</div>
|
|
3323
|
-
|
|
3382
|
+
<!-- Glance floors F10/F11 (topic 29836): the shared component renders the
|
|
3383
|
+
headline + tiles + drill-down here. Reference implementation. -->
|
|
3384
|
+
<div id="commitmentsGlance" class="glance-root"></div>
|
|
3324
3385
|
</div>
|
|
3325
3386
|
|
|
3326
3387
|
<!-- Tokens Tab — read-only token-usage observability -->
|
|
@@ -8757,119 +8818,72 @@
|
|
|
8757
8818
|
}
|
|
8758
8819
|
}
|
|
8759
8820
|
|
|
8760
|
-
// ── Commitments Tab
|
|
8761
|
-
//
|
|
8762
|
-
//
|
|
8763
|
-
//
|
|
8821
|
+
// ── Commitments Tab — glance floors F10/F11 (topic 29836) ────
|
|
8822
|
+
// The reference implementation of the shared glance component. Fetches
|
|
8823
|
+
// /commitments?status=active and renders a plain-English headline + ≤5 tiles
|
|
8824
|
+
// (Layer 1); each tile drills into the filtered open-promises list (Layer 2);
|
|
8825
|
+
// each row opens the full record with IDs/cadence/deadlines (Layer 3). All
|
|
8826
|
+
// content goes through the component's sanitizer + textContent (XSS-safe).
|
|
8827
|
+
let __glanceModule = null;
|
|
8828
|
+
async function loadGlanceModule() {
|
|
8829
|
+
if (!__glanceModule) {
|
|
8830
|
+
// Same try/catch dynamic-import guard the other tabs use — a missing/failed
|
|
8831
|
+
// glance.js degrades the tab gracefully instead of white-screening.
|
|
8832
|
+
try { __glanceModule = await import('/dashboard/glance.js'); }
|
|
8833
|
+
catch (e) { console.error('[glance] module load failed', e); return null; }
|
|
8834
|
+
}
|
|
8835
|
+
return __glanceModule;
|
|
8836
|
+
}
|
|
8837
|
+
|
|
8764
8838
|
async function loadCommitments() {
|
|
8765
|
-
const
|
|
8766
|
-
const empty = document.getElementById('commitmentsEmpty');
|
|
8839
|
+
const glanceRoot = document.getElementById('commitmentsGlance');
|
|
8767
8840
|
const countBadge = document.getElementById('tabCommitmentCount');
|
|
8768
|
-
|
|
8769
|
-
|
|
8841
|
+
if (!glanceRoot) return;
|
|
8842
|
+
|
|
8843
|
+
const glance = await loadGlanceModule();
|
|
8844
|
+
if (!glance) {
|
|
8845
|
+
// Graceful degrade: the component couldn't load. Show an honest note, no crash.
|
|
8846
|
+
glanceRoot.textContent = 'Loading the glance view failed — refresh to retry.';
|
|
8847
|
+
return;
|
|
8848
|
+
}
|
|
8770
8849
|
|
|
8771
8850
|
let res = null;
|
|
8772
8851
|
try {
|
|
8773
8852
|
res = await apiFetch('/commitments?status=active');
|
|
8774
|
-
} catch { /* fall through */ }
|
|
8853
|
+
} catch { /* fall through to the disabled/empty glance @silent-fallback-ok — network hiccup renders the honest empty state */ }
|
|
8775
8854
|
|
|
8776
8855
|
if (!res || !res.enabled) {
|
|
8777
|
-
|
|
8778
|
-
|
|
8856
|
+
glanceRoot.textContent = '';
|
|
8857
|
+
const note = document.createElement('div');
|
|
8858
|
+
note.className = 'glance-empty';
|
|
8859
|
+
note.textContent = res && res.enabled === false
|
|
8860
|
+
? 'Promise tracking is not set up on this agent yet.'
|
|
8861
|
+
: 'Could not load your promises right now — refresh to retry.';
|
|
8862
|
+
glanceRoot.appendChild(note);
|
|
8779
8863
|
if (countBadge) countBadge.textContent = '0';
|
|
8780
8864
|
return;
|
|
8781
8865
|
}
|
|
8866
|
+
|
|
8782
8867
|
const items = Array.isArray(res.commitments) ? res.commitments : [];
|
|
8783
|
-
// Show only beacon-watched pending + atRisk.
|
|
8784
8868
|
const open = items.filter(c => c.beaconEnabled && c.status === 'pending');
|
|
8785
8869
|
if (countBadge) countBadge.textContent = String(open.length);
|
|
8786
|
-
if (open.length === 0) {
|
|
8787
|
-
empty.style.display = 'block';
|
|
8788
|
-
empty.textContent = 'No open promises.';
|
|
8789
|
-
return;
|
|
8790
|
-
}
|
|
8791
|
-
|
|
8792
|
-
const fmtTs = (iso) => {
|
|
8793
|
-
if (!iso) return '—';
|
|
8794
|
-
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
|
8795
|
-
};
|
|
8796
|
-
|
|
8797
|
-
for (const c of open) {
|
|
8798
|
-
const card = document.createElement('div');
|
|
8799
|
-
card.style.cssText = 'padding:14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-dim);display:flex;flex-direction:column;gap:8px';
|
|
8800
8870
|
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
summary.style.cssText = 'flex:1;font-weight:600;line-height:1.3';
|
|
8805
|
-
summary.textContent = (c.agentResponse || c.userRequest || '(no summary)').slice(0, 160);
|
|
8806
|
-
header.appendChild(summary);
|
|
8807
|
-
|
|
8808
|
-
// State badge.
|
|
8809
|
-
const badge = document.createElement('span');
|
|
8810
|
-
const atRisk = !!c.atRisk;
|
|
8811
|
-
const suppressed = !!c.beaconSuppressed;
|
|
8812
|
-
const [badgeText, badgeBg] = suppressed
|
|
8813
|
-
? [`suppressed: ${c.beaconSuppressionReason || '?'}`, '#555']
|
|
8814
|
-
: atRisk
|
|
8815
|
-
? ['atRisk', '#e27d3b']
|
|
8816
|
-
: ['pending', '#4a9a4a'];
|
|
8817
|
-
badge.textContent = badgeText;
|
|
8818
|
-
badge.style.cssText = `font-size:11px;padding:3px 8px;border-radius:4px;background:${badgeBg};color:#fff;white-space:nowrap`;
|
|
8819
|
-
header.appendChild(badge);
|
|
8820
|
-
card.appendChild(header);
|
|
8821
|
-
|
|
8822
|
-
const meta = document.createElement('div');
|
|
8823
|
-
meta.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px;font-size:12px;color:var(--text-dim)';
|
|
8824
|
-
const rows = [
|
|
8825
|
-
['id', c.id],
|
|
8826
|
-
['topic', c.topicId != null ? String(c.topicId) : '—'],
|
|
8827
|
-
['cadence', c.cadenceMs ? `${Math.round(c.cadenceMs / 1000)}s` : '—'],
|
|
8828
|
-
['heartbeats', String(c.heartbeatCount ?? 0)],
|
|
8829
|
-
['lastHeartbeat', fmtTs(c.lastHeartbeatAt)],
|
|
8830
|
-
['nextUpdateDue', fmtTs(c.nextUpdateDueAt)],
|
|
8831
|
-
['softDeadline', fmtTs(c.softDeadlineAt)],
|
|
8832
|
-
['hardDeadline', fmtTs(c.hardDeadlineAt)],
|
|
8833
|
-
];
|
|
8834
|
-
for (const [k, v] of rows) {
|
|
8835
|
-
const cell = document.createElement('div');
|
|
8836
|
-
const kEl = document.createElement('span');
|
|
8837
|
-
kEl.textContent = `${k}: `;
|
|
8838
|
-
kEl.style.opacity = '0.7';
|
|
8839
|
-
const vEl = document.createElement('span');
|
|
8840
|
-
vEl.textContent = v;
|
|
8841
|
-
vEl.style.color = 'var(--text)';
|
|
8842
|
-
cell.appendChild(kEl);
|
|
8843
|
-
cell.appendChild(vEl);
|
|
8844
|
-
meta.appendChild(cell);
|
|
8845
|
-
}
|
|
8846
|
-
card.appendChild(meta);
|
|
8847
|
-
|
|
8848
|
-
// Actions.
|
|
8849
|
-
const actions = document.createElement('div');
|
|
8850
|
-
actions.style.cssText = 'display:flex;gap:8px;align-items:center';
|
|
8851
|
-
const deliverBtn = document.createElement('button');
|
|
8852
|
-
deliverBtn.textContent = 'Mark delivered';
|
|
8853
|
-
deliverBtn.style.cssText = 'padding:6px 10px;cursor:pointer';
|
|
8854
|
-
deliverBtn.addEventListener('click', async () => {
|
|
8855
|
-
deliverBtn.disabled = true;
|
|
8871
|
+
const spec = glance.commitmentsGlanceSpec(document, items, {
|
|
8872
|
+
now: Date.now(),
|
|
8873
|
+
onDeliver: async (id) => {
|
|
8856
8874
|
try {
|
|
8857
|
-
await apiFetch(`/commitments/${encodeURIComponent(
|
|
8875
|
+
await apiFetch(`/commitments/${encodeURIComponent(id)}/deliver`, {
|
|
8858
8876
|
method: 'POST',
|
|
8859
8877
|
headers: { 'Content-Type': 'application/json' },
|
|
8860
8878
|
body: JSON.stringify({}),
|
|
8861
8879
|
});
|
|
8862
8880
|
await loadCommitments();
|
|
8863
8881
|
} catch (err) {
|
|
8864
|
-
deliverBtn.disabled = false;
|
|
8865
8882
|
alert('Deliver failed: ' + (err && err.message ? err.message : String(err)));
|
|
8866
8883
|
}
|
|
8867
|
-
}
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
list.appendChild(card);
|
|
8872
|
-
}
|
|
8884
|
+
},
|
|
8885
|
+
});
|
|
8886
|
+
glance.renderGlance(document, glanceRoot, spec);
|
|
8873
8887
|
}
|
|
8874
8888
|
|
|
8875
8889
|
// ── Secrets Tab ──────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-07-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-07-11T02:35:23.065Z",
|
|
5
|
+
"instarVersion": "1.3.811",
|
|
6
6
|
"entryCount": 202,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Added two new floors to the Dashboard UX Standard and shipped their enforcement plus
|
|
9
|
+
the first reference view:
|
|
10
|
+
|
|
11
|
+
- **F10 (glance floor):** every main view's front page is one plain-English headline +
|
|
12
|
+
at most 5 big labeled tiles, under 150 words, with no insider vocabulary (internal IDs,
|
|
13
|
+
state-machine names, config keys, seconds-cadences).
|
|
14
|
+
- **F11 (universal drill-down):** every tile/count/row is clickable and opens the next
|
|
15
|
+
detail layer — a filtered list, then the full record — with no dead-end summaries.
|
|
16
|
+
- A shared component `dashboard/glance.js` renders the three-layer template (headline +
|
|
17
|
+
tiles + drill-down) and *refuses* to build an over-budget or jargon-carrying glance, so
|
|
18
|
+
the rule is baked into the code, not left to memory.
|
|
19
|
+
- Enforcement across all three test tiers (unit F10 + F11, integration against the real
|
|
20
|
+
`/commitments` route, e2e feature-alive), with the 25 other tabs grandfathered against a
|
|
21
|
+
survey scorecard whose list can only shrink (a NEW tab can't ship below the floor).
|
|
22
|
+
- The **Commitments tab** is the reference implementation: its old wall of raw records
|
|
23
|
+
(IDs, `cadence: 1800s`, `atRisk`) is now a headline ("I'm carrying N open promises; K
|
|
24
|
+
need attention soon, none overdue.") over tiles (Open · Due soon · Waiting on you ·
|
|
25
|
+
Quiet); tap a tile to see which promises, tap one for the full record.
|
|
26
|
+
|
|
27
|
+
This is Phase 1 of the operator-approved four-phase rollout (topic 29836). Phases 2–4
|
|
28
|
+
retrofit the remaining tabs and are tracked in the spec.
|
|
29
|
+
|
|
30
|
+
## What to Tell Your User
|
|
31
|
+
|
|
32
|
+
Your dashboard's Commitments tab is now readable at a glance: instead of a wall of
|
|
33
|
+
technical records, you'll see a one-sentence summary of where your open promises stand
|
|
34
|
+
plus a few big tiles. Tap any tile to see exactly which promises are behind that number,
|
|
35
|
+
and tap one of those to see its full detail. Nothing is lost — the IDs and timestamps
|
|
36
|
+
just moved one or two taps down instead of crowding the front page. This is the first tab
|
|
37
|
+
brought to the new "glance" standard; the rest follow in later updates.
|
|
38
|
+
|
|
39
|
+
## Summary of New Capabilities
|
|
40
|
+
|
|
41
|
+
- The Commitments dashboard tab now leads with a plain-English headline + tiles, with
|
|
42
|
+
every number tappable down to the full record.
|
|
43
|
+
- A new dashboard-wide standard (F10/F11) that keeps future views readable at a glance and
|
|
44
|
+
fully drillable, enforced automatically in CI.
|
|
45
|
+
|
|
46
|
+
## Evidence
|
|
47
|
+
|
|
48
|
+
- Unit: `tests/unit/dashboard-glance-word-budget.test.ts` (31), `tests/unit/dashboard-glance-drilldown.test.ts` (8).
|
|
49
|
+
- Integration: `tests/integration/glance-commitments-tab.test.ts`.
|
|
50
|
+
- E2E: `tests/e2e/glance-commitments-tab-lifecycle.test.ts`.
|
|
51
|
+
- Live browser render (Playwright): headline + 4 tiles render; tile → filtered list → full
|
|
52
|
+
record (`id: CMT-953`, `cadence: 1800s` shown only at the record layer).
|
|
53
|
+
- Spec convergence: `docs/specs/reports/dashboard-ux-standard-convergence.md`.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Side-Effects Review — Dashboard glance floors F10/F11 (Phase 1)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `glance-floors-f10-f11`
|
|
4
|
+
**Date:** `2026-07-10`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required` (no block/allow, session-lifecycle, or gate/sentinel/guard surface — this is a display-only front-end component; see §5)
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Adds the two "glance floors" (F10 glance + F11 universal drill-down) to the Dashboard
|
|
11
|
+
UX Standard and ships their enforcement: one shared front-end component
|
|
12
|
+
`dashboard/glance.js` (a plain-English headline + ≤5 labeled tiles + a drill-down
|
|
13
|
+
container that opens a filtered list then a full record), a pure `validateGlanceSpec`
|
|
14
|
+
that refuses an over-budget or jargon-carrying glance, and three test tiers
|
|
15
|
+
(unit F10 + F11, integration against the real `/commitments` route, e2e feature-alive).
|
|
16
|
+
One view — the Commitments tab — is wired onto the component as the reference
|
|
17
|
+
implementation (glance layer → existing list as Layer 2 → full record as Layer 3).
|
|
18
|
+
Files: `docs/specs/dashboard-ux-standard.md` (+ `.eli16.md` + convergence report),
|
|
19
|
+
`docs/STANDARDS-REGISTRY.md`, `dashboard/glance.js` (new), `dashboard/index.html`
|
|
20
|
+
(Commitments panel markup + CSS + `loadCommitments` rewrite), and four test files.
|
|
21
|
+
**No `src/*.ts` change, no new server route, no config, no hooks.**
|
|
22
|
+
|
|
23
|
+
## Decision-point inventory
|
|
24
|
+
|
|
25
|
+
**No decision-point surface.** Nothing here gates information flow, blocks actions,
|
|
26
|
+
filters messages, or constrains agent behavior. `validateGlanceSpec` is a build-time
|
|
27
|
+
quality assertion over component-authored UI copy — it decides how a dashboard tab
|
|
28
|
+
*renders*, never what the agent may do. The one "refusal" (the component refusing to
|
|
29
|
+
render an over-budget/jargon glance) affects presentation only and falls back to an
|
|
30
|
+
honest degraded glance, never a raw dump — it holds no authority over any pipeline.
|
|
31
|
+
|
|
32
|
+
- `validateGlanceSpec` (dashboard/glance.js) — add — a pure UI-copy budget/jargon
|
|
33
|
+
check; presentation-only, no runtime authority.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 1. Over-block
|
|
38
|
+
|
|
39
|
+
**No block/allow surface — over-block not applicable.** The nearest analog is the
|
|
40
|
+
jargon check possibly rejecting legitimate glance copy. It is scoped to
|
|
41
|
+
*component-authored* Layer-1 strings only (headline + tile labels + values); agent/
|
|
42
|
+
user free text lives at Layer 2/3 and is displayed (sanitized), never vocab-gated — so
|
|
43
|
+
a user phrasing a promise with jargon can never blank the operator's glance. If the
|
|
44
|
+
check does reject a builder's copy, the component renders an honest degraded glance
|
|
45
|
+
(truncated headline + a drill), never a raw dump — no operator-visible data is lost.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. Under-block
|
|
50
|
+
|
|
51
|
+
**No block/allow surface — under-block not applicable.** As a readability floor the
|
|
52
|
+
jargon detector is deliberately heuristic (it is NOT a secret-redaction boundary —
|
|
53
|
+
secret handling stays at the API/data layer, untouched here). It can miss novel
|
|
54
|
+
concept-jargon expressed in ordinary words; that residual is a tracked later-phase
|
|
55
|
+
tightening, and the curated insider-TERM denylist + form heuristics cover the known
|
|
56
|
+
classes (internal IDs, machine ids, config keys, cadences, state-machine names) with
|
|
57
|
+
bypass-variant tests.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 3. Level-of-abstraction fit
|
|
62
|
+
|
|
63
|
+
Right layer. F10/F11 are enforced at the **component boundary** (a shared renderer +
|
|
64
|
+
a pure validator), not by scraping bespoke per-tab markup — because the glance content
|
|
65
|
+
is JS-rendered from live data, a static grep of `index.html` cannot see it, so the
|
|
66
|
+
component is the correct place to make the floor structural. The component REUSES the
|
|
67
|
+
existing lower-level primitives instead of re-implementing them: `sanitizeForDisplay`,
|
|
68
|
+
`hasOpenInteraction`, and `updateCountdowns` are imported from `dashboard/subscriptions.js`
|
|
69
|
+
(the shipped F9 safety + interaction-hold bar), so both surfaces share one contract.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 4. Signal vs authority compliance
|
|
74
|
+
|
|
75
|
+
**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
|
|
76
|
+
|
|
77
|
+
- [x] No — this change has no block/allow surface.
|
|
78
|
+
|
|
79
|
+
`validateGlanceSpec` is a build-time/presentation quality check with no blocking
|
|
80
|
+
authority over any runtime pipeline. It informs how a tab renders (and, in the tests,
|
|
81
|
+
fails CI on a NEW below-floor tab) — it never gates a message, action, session, or
|
|
82
|
+
information flow. There is no brittle detector holding runtime authority here.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 5. Interactions
|
|
87
|
+
|
|
88
|
+
- **Shadowing:** none. The glance component is additive; it renders into a new
|
|
89
|
+
`#commitmentsGlance` container. It does not run before/after any check.
|
|
90
|
+
- **Double-fire:** none. `loadCommitments` replaces (never appends) the glance root
|
|
91
|
+
and the drill container, so repeated renders/polls cannot accumulate DOM or listeners.
|
|
92
|
+
- **Races:** F9-composed. While a drill interaction is open (the drill container carries
|
|
93
|
+
`data-interaction-open`, or a field is focused/dirty), a re-render MERGES live counts
|
|
94
|
+
via `patchGlanceCounts` instead of rebuilding over the interaction — reusing the shipped
|
|
95
|
+
`hasOpenInteraction`. The Commitments tab has no background poll today, so this is
|
|
96
|
+
latent-but-correct (and unit-tested), and will already be right if a poll is added.
|
|
97
|
+
- **Feedback loops:** none — a pure renderer over data the tab already fetched.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 6. External surfaces
|
|
102
|
+
|
|
103
|
+
- **Other agents / users / external systems:** none. `dashboard/glance.js` is a
|
|
104
|
+
client-side ESM module served statically; it holds no secrets, opens no endpoint, and
|
|
105
|
+
makes no network call of its own (the Commitments reference reuses the EXISTING authed
|
|
106
|
+
`/commitments` GET and the EXISTING `/commitments/:id/deliver` POST).
|
|
107
|
+
- **Persistent state:** none touched.
|
|
108
|
+
- **Operator surface (Mobile-Complete):** the operator actions on this surface (view
|
|
109
|
+
promises, drill in, mark delivered) are all completable from the dashboard, which is
|
|
110
|
+
phone-reachable via the tunnel + PIN — no new laptop-bound step. The "Mark delivered"
|
|
111
|
+
action lives on the Layer-3 record and calls the existing authed route.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 6b. Operator-surface quality (Operator-Surface Quality standard)
|
|
116
|
+
|
|
117
|
+
This change touches an operator surface (`dashboard/glance.js`, `dashboard/index.html`).
|
|
118
|
+
The glance floors ARE the whole-view structural application of this standard.
|
|
119
|
+
|
|
120
|
+
1. **Leads with the primary action?** Yes. On arrival the Commitments tab shows the
|
|
121
|
+
headline answer ("I'm carrying N open promises; K need attention soon, none overdue.")
|
|
122
|
+
and the big labeled tiles — the state and the way in, visible immediately, no toggle,
|
|
123
|
+
no below-the-fold, no explanatory prose in front.
|
|
124
|
+
2. **Zero raw internals as primary content?** Yes — enforced by F10 itself. No internal
|
|
125
|
+
IDs, state-machine names, config keys, or seconds-cadences may appear at the glance
|
|
126
|
+
layer (a machine-checked rule). The raw detail (`CMT-953`, `cadence 1800s`, timestamps)
|
|
127
|
+
lives at Layer 3, one click down, where it belongs — verified in a real browser (tile →
|
|
128
|
+
list → record).
|
|
129
|
+
3. **Destructive actions de-emphasized?** N/a-leaning: the only action is the constructive
|
|
130
|
+
"Mark delivered", placed on the Layer-3 record (not above the glance). There is no
|
|
131
|
+
destructive control on this surface; nothing louder than the primary path.
|
|
132
|
+
4. **Plain language + phone width?** Yes. Copy reads the way a person would say it
|
|
133
|
+
("Waiting on you", "Due soon", "Quiet"). Tiles use an auto-fit grid that reflows at
|
|
134
|
+
phone width; the drill list/records stack; nothing scrolls the page sideways (records
|
|
135
|
+
use `word-break`/`overflow-wrap`). Verified visually at 900px and via the responsive
|
|
136
|
+
grid.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
141
|
+
|
|
142
|
+
**machine-local BY DESIGN → effectively `unified` by construction.** `dashboard/glance.js`
|
|
143
|
+
is a **stateless client-side renderer**: it persists nothing, reads no config, holds no
|
|
144
|
+
server state, and introduces no machine-divergent state. It renders whatever data the
|
|
145
|
+
adopting tab already fetched and inherits that endpoint's existing posture — the
|
|
146
|
+
Commitments reference drills into `GET /commitments`, whose pool-scope posture
|
|
147
|
+
(`?scope=mesh`) is unchanged by this PR. So there is no new machine-local surface to
|
|
148
|
+
justify and no `machine-local-justification` marker is required.
|
|
149
|
+
|
|
150
|
+
- **User-facing notices:** none emitted (a passive render surface — one-voice gating n/a).
|
|
151
|
+
- **Durable state:** none held (nothing strands on topic transfer).
|
|
152
|
+
- **Generated URLs:** none (no links minted that must survive a machine boundary).
|
|
153
|
+
|
|
154
|
+
**Migration Parity:** met by construction. The dashboard ships wholesale via
|
|
155
|
+
`express.static(dashboardDir)` from the installed package directory (`package.json`
|
|
156
|
+
`files` includes `dashboard/`; `AgentServer.resolveDashboardDir` resolves to the package
|
|
157
|
+
root, not the agent home). A package update replaces `dashboard/glance.js` + updated
|
|
158
|
+
`index.html` on the normal update path — exactly as `dashboard/subscriptions.js` shipped —
|
|
159
|
+
so already-deployed agents receive the glance with **no `PostUpdateMigrator` entry** and
|
|
160
|
+
no `init`-only templating. `loadCommitments` loads `glance.js` through the same `try/catch`
|
|
161
|
+
dynamic-import guard the other tabs use, so a missing/failed module degrades the tab
|
|
162
|
+
gracefully. **Agent Awareness:** n/a — this is an internal dashboard UX/dev standard (how
|
|
163
|
+
a view renders), not a new operator-invocable capability/route/config/hook; the awareness
|
|
164
|
+
surface is `STANDARDS-REGISTRY.md` (nine → eleven floors), already updated.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 8. Rollback cost
|
|
169
|
+
|
|
170
|
+
**Pure front-end change — revert and ship a patch.** No persistent state, no data
|
|
171
|
+
migration, no agent-state repair. `dashboard/glance.js` and the `index.html` edits are
|
|
172
|
+
replaced wholesale on the next package update; reverting the commit restores the prior
|
|
173
|
+
Commitments renderer. No user-visible regression during the rollback window (the tab
|
|
174
|
+
simply reverts to its previous look). The new tests would revert with the code.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Conclusion
|
|
179
|
+
|
|
180
|
+
This review produced no blocking concerns. The change is display-only with no
|
|
181
|
+
decision-point, block/allow, session-lifecycle, or gate/sentinel surface, so no
|
|
182
|
+
second-pass review is required. The design was materially hardened by /spec-converge
|
|
183
|
+
(a six-angle internal panel + the code-backed Standards-Conformance Gate + an external
|
|
184
|
+
Gemini pass across three rounds): the Layer-1-is-100%-component-authored invariant that
|
|
185
|
+
makes the jargon check both safe and complete; the XSS/display-safety contract reusing
|
|
186
|
+
the shipped `sanitizeForDisplay`; bypass-resistant vocab detection; the honest-degraded
|
|
187
|
+
(never raw-dump) failure mode; a strengthened F11 (non-vacuous, distinct, tile→list→record,
|
|
188
|
+
XSS + dead-end negatives); the structural grandfather ratchet (completeness + monotonic
|
|
189
|
+
ceiling); the one-population honest tile→server-field derivation with a count-truthfulness
|
|
190
|
+
test; and all three test tiers. Verified live in a real browser (Playwright): the
|
|
191
|
+
Commitments glance renders headline + tiles, and drilling opens the filtered list then the
|
|
192
|
+
full record with the raw IDs/cadence correctly one click down. Clear to ship.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Second-pass review (if required)
|
|
197
|
+
|
|
198
|
+
**Reviewer:** not required — no block/allow, messaging-dispatch, session-lifecycle,
|
|
199
|
+
compaction, coherence/idempotency/trust, or sentinel/guard/gate/watchdog surface (Phase-5
|
|
200
|
+
triggers). Display-only front-end component.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Evidence pointers
|
|
205
|
+
|
|
206
|
+
- Unit: `tests/unit/dashboard-glance-word-budget.test.ts` (31 tests — F10 budget/jargon +
|
|
207
|
+
bypass variants + adversarial-fixture conformance + count-truthfulness + the ratchet),
|
|
208
|
+
`tests/unit/dashboard-glance-drilldown.test.ts` (8 tests — F11 walk, Layer-2→3, negatives,
|
|
209
|
+
F9 hold, XSS).
|
|
210
|
+
- Integration: `tests/integration/glance-commitments-tab.test.ts` (glance built + walked
|
|
211
|
+
against a real `GET /commitments` HTTP response with a live `CommitmentTracker`).
|
|
212
|
+
- E2E: `tests/e2e/glance-commitments-tab-lifecycle.test.ts` (feature-alive: 200 not 503,
|
|
213
|
+
feature ON/OFF, full render, no `<script>` survives).
|
|
214
|
+
- Live: Playwright render of the reference glance — headline "I'm carrying 5 open
|
|
215
|
+
promises; 2 need attention soon, none overdue.", 4 tiles, tile→list (2 rows)→record
|
|
216
|
+
(`id: CMT-953`, `cadence: 1800s` at Layer 3 only).
|
|
217
|
+
- Spec convergence report: `docs/specs/reports/dashboard-ux-standard-convergence.md`.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Class-Closure Declaration (display-only mirror)
|
|
222
|
+
|
|
223
|
+
No agent-authored-artifact defect and no self-triggered controller (no loop / monitor /
|
|
224
|
+
sentinel / reaper / scheduler / recovery path that fires a restart / swap / respawn /
|
|
225
|
+
spawn / notify / retry / re-drive / kill) — **not applicable**.
|