instar 1.3.809 → 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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +9 -4
- package/dist/commands/server.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +6 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
- package/dist/core/ProactiveSwapMonitor.js +5 -1
- package/dist/core/ProactiveSwapMonitor.js.map +1 -1
- package/dist/core/QuotaAwareScheduler.d.ts +6 -2
- package/dist/core/QuotaAwareScheduler.d.ts.map +1 -1
- package/dist/core/QuotaAwareScheduler.js +16 -3
- package/dist/core/QuotaAwareScheduler.js.map +1 -1
- package/dist/core/QuotaPoller.d.ts +9 -1
- package/dist/core/QuotaPoller.d.ts.map +1 -1
- package/dist/core/QuotaPoller.js +42 -4
- package/dist/core/QuotaPoller.js.map +1 -1
- package/dist/core/SubscriptionAccountMetaReplicatedStore.js +1 -1
- package/dist/core/SubscriptionAccountMetaReplicatedStore.js.map +1 -1
- package/dist/core/SubscriptionPool.d.ts +1 -1
- package/dist/core/SubscriptionPool.d.ts.map +1 -1
- package/dist/core/SwapAntiThrash.d.ts.map +1 -1
- package/dist/core/SwapAntiThrash.js +4 -1
- package/dist/core/SwapAntiThrash.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +1 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +19 -19
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.810.md +22 -0
- package/upgrades/1.3.811.md +53 -0
- package/upgrades/eli16/codex-quota-framework-safe.md +20 -0
- package/upgrades/side-effects/codex-quota-framework-safe.md +81 -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
|
+
}
|