create-pmos 0.188.0 → 0.189.0

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/lib/headless.js +19 -5
  11. package/workspace/lib/pmos-verbs.js +2 -2
  12. package/workspace/lib/route-modules.js +1 -1
  13. package/workspace/lib/routes-boards.js +29 -12
  14. package/workspace/lib/routes-scan.js +224 -44
  15. package/workspace/lib/scan-cli.js +143 -45
  16. package/workspace/lib/scan-run.js +242 -87
  17. package/workspace/lib/scan.js +623 -79
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  21. package/workspace/public/pm/c3-initiatives.js +5 -4
  22. package/workspace/public/pm/c3-scan-api.js +167 -0
  23. package/workspace/public/pm/c3-scan-next.js +248 -0
  24. package/workspace/public/pm/c3-scan-setup.js +317 -0
  25. package/workspace/public/pm/c3-scan-words.js +34 -0
  26. package/workspace/public/pm/c3-scan.css +58 -9
  27. package/workspace/public/pm/c3-scan.js +36 -208
  28. package/workspace/public/pm/c3-toast.js +1 -0
  29. package/workspace/server.js +7 -4
  30. package/workspace/skills/horizon-scan-repo.skill +26 -10
  31. package/workspace/skills/horizon-scan-research.skill +44 -0
  32. package/workspace/skills/horizon-scan-web.skill +12 -5
@@ -0,0 +1,317 @@
1
+ /* pm/c3-scan-setup.js — the portfolio Horizon scan's own screens (initiative horizon-scan-portfolio, PRD sections 1 to 3; mockup 42-hscan.js).
2
+ *
3
+ * scanPortfolioSection({ ws, boardCount, showTab }) the "Horizon scan" section of Initiatives > Portfolio: the whole portfolio and its board
4
+ * count, a primary Scan now that is always there, the Horizon scan setup button, the
5
+ * latest scan's reports and the "Waiting for you: N proposals" line
6
+ * scanPointer(ws) the one line in Board settings: "Horizon scan runs for the whole portfolio"
7
+ * openScanSetup(ws, o) the setup sheet: last and next scan with Scan now, How often (Daily, Weekly, Off), the repository and the report
8
+ * types, the watch list (kind and board chips; Keep, Edit, Remove, Add; Keep all on first use)
9
+ * openScanPicker(ws, o) Scan now: what this scan reads and writes (items, repository, report types), then the running sheet
10
+ * openScanRunning(ws, o) the steps of the scan that is running, with Stop
11
+ * Reads and presses: c3-scan-api.js only. Each screen draws at once and fills itself from the server; every press posts, then reads again.
12
+ * Every part that draws the scan listens to onScanChange, so a press on one screen updates the others. Nothing is sent outward.
13
+ */
14
+ import { el } from './dom.js';
15
+ import { field } from './primitives.js';
16
+ import { icon, pbtn, lk, btn, chip, st as stc, sect, plural, put, nrow, list, geo } from './c3-ini-ui.js';
17
+ import { openSheet } from './c3-sheet.js';
18
+ import { toast } from './c3-toast.js';
19
+ import { TYPES, TYPE_KEYS, KINDS, typeName, getPortfolio, getBoards, getDismissed, postWatch, postSetting, postNow, postStop, scanNowWords, scanChanged, onScanChange } from './c3-scan-api.js';
20
+ import { openScanDoc } from './c3-scan-next.js';
21
+ import { pastLabel, nextLabel, scheduleLine, ensureScanCss } from './c3-scan-words.js';
22
+
23
+ const KIND_ICON = { product: 'org', domain: 'report', approach: 'bolt' };
24
+ const kindTag = (k) => el('span', { class: 'tag' + (k === 'product' ? ' acc' : '') }, k);
25
+ const errText = (r, what) => what + ': ' + ((r && r.error) || 'the workspace did not answer') + '.';
26
+ /** The proposals waiting: all of them (the server counts them), or those of one report (the report row says how many are open). */
27
+ const waitingOf = (d, report) => (report ? ((d.reports.find((r) => r.id === report) || {}).open || 0) : d.waiting);
28
+ /** Scan now may start: the server says so in `manual`. A scan already running is not a refusal here, since the button then opens the running sheet. */
29
+ const blocked = (d) => !!d && !d.manual.ok && d.manual.why !== 'running';
30
+ /** "2 of 3 manual scans left today", or the reason Scan now is off, in the server's words. */
31
+ const leftLine = (d) => (d.manual.ok || d.manual.why === 'running' ? d.manual.left + ' of ' + d.manual.limit + ' manual scans left today.' : scanNowWords({ why: d.manual.why, error: d.manual.error, limit: d.manual.limit }));
32
+ /** The reports of the latest scan (the ones that are not a research follow-up), newest first as the server lists them. */
33
+ export function latestReports(d) {
34
+ const top = d.reports.filter((r) => !r.parent && r.written);
35
+ const scan = (d.last && d.last.scan) || (top[0] && top[0].scan) || '';
36
+ return scan ? top.filter((r) => r.scan === scan) : top.slice(0, 4);
37
+ }
38
+ const SCHED_NOTE = scheduleLine;
39
+
40
+ /** A check box with a title and a line of help, drawn by the field primitive. attrs go on the input. */
41
+ function check(on, title, help, tag, attrs, onChange) {
42
+ return field({ kind: 'check', value: on, onCommit: onChange, attrs, label: el('span', { class: 'sc-chkl' }, el('b', null, title), tag || null, help ? [el('br'), el('span', { class: 'dim sc-sm' }, help)] : null) });
43
+ }
44
+ const boardChip = (boards, id) => { const b = boards.find((x) => x.id === id); return chip(icon('cols', 'sm'), b ? b.name : id); };
45
+
46
+ /* ══ the setup sheet ══ */
47
+ export function openScanSetup(ws, o = {}) {
48
+ ensureScanCss();
49
+ const V = { d: null, boards: [], dismissed: [], err: '', busy: false, edit: null, editValue: '', addName: '', addKind: 'product', addWhy: '', addBoard: '', addErr: '' };
50
+ const host = el('div', { class: 'sc-hs', 'data-scan-setup': '' });
51
+ const head = pbtn('primary', 'Scan now', 'bolt', { 'data-scan-act': 'scan', 'data-scan-head': '', onclick: () => scanNow() });
52
+ const ui = openSheet({ title: 'Horizon scan', icon: 'search', body: host, wide: true, dw: true, actions: [head], onClose: () => { off(); } });
53
+ const off = onScanChange(() => load());
54
+
55
+ async function load() {
56
+ const [d, b] = await Promise.all([getPortfolio(ws), V.boards.length ? V.boards : getBoards(ws)]);
57
+ V.boards = b;
58
+ if (d.ok) { V.d = d; V.err = ''; V.dismissed = await getDismissed(ws, d.reports); } else V.err = errText(d, 'The horizon scan could not be read');
59
+ draw();
60
+ }
61
+ async function press(fn, what, then) {
62
+ if (V.busy) return null;
63
+ V.busy = true; draw();
64
+ const r = await fn();
65
+ V.busy = false;
66
+ if (!r || r.ok === false) { toast({ text: errText(r, what + ' did not work'), tone: 'error', timeout: 6000 }); await load(); return null; }
67
+ if (then) then(r);
68
+ scanChanged();
69
+ return r;
70
+ }
71
+ const scanNow = () => { if (V.d && V.d.open) { openScanRunning(ws, o); toast('A scan is already running'); } else if (blocked(V.d)) toast({ text: leftLine(V.d), tone: 'error', timeout: 6000 }); else openScanPicker(ws, o); };
72
+ const setCadence = (c) => { if (V.d && V.d.setting.cadence === c) return null; return press(() => postSetting(ws, { cadence: c }), 'The change', () => toast(c === 'off' ? 'Horizon scan is off. Scan now still works.' : 'Horizon scan: ' + scheduleLine(c).toLowerCase())); };
73
+ const setRepo = (on) => press(() => postSetting(ws, { repo: on }), 'The change');
74
+ const setType = (k, on) => { const cur = V.d.setting.types; return press(() => postSetting(ws, { types: TYPE_KEYS.filter((x) => (x === k ? on : cur.includes(x))) }), 'The change'); };
75
+ const keep = (w) => press(() => postWatch(ws, { op: 'keep', id: w.id }), 'Keep');
76
+ const keepAll = () => press(async () => { const rs = await Promise.all(V.d.items.filter((w) => w.state === 'suggested').map((w) => postWatch(ws, { op: 'keep', id: w.id }))); return rs.find((r) => !r || r.ok === false) || { ok: true }; }, 'Keep all', () => toast('Kept the watch list'));
77
+ const remove = (w) => press(() => postWatch(ws, { op: 'remove', id: w.id }), 'Remove', () => toast({ text: 'Removed ' + w.name, onAction: () => press(() => postWatch(ws, { op: 'keep', id: w.id, restore: true }), 'Undo'), timeout: 7000 }));
78
+ const save = async (w) => {
79
+ const v = V.editValue.trim(); if (!v) return;
80
+ V.edit = null;
81
+ if (v !== w.name) await press(() => postWatch(ws, { op: 'edit', id: w.id, name: v }), 'Save');
82
+ else if (w.state === 'new') await keep(w); else draw();
83
+ };
84
+ const add = async () => {
85
+ const v = V.addName.trim();
86
+ if (!v) { V.addErr = 'Write what to watch first.'; draw(); const i = host.querySelector('[data-scan-add-in]'); if (i) i.focus(); return; }
87
+ V.addErr = '';
88
+ const r = await press(() => postWatch(ws, { op: 'add', name: v, kind: V.addKind, why: V.addWhy.trim(), boards: V.addBoard ? [V.addBoard] : [] }), 'Add',
89
+ () => { const b = V.addBoard; V.addName = ''; V.addWhy = ''; V.addBoard = ''; toast(b ? 'Added for ' + ((V.boards.find((x) => x.id === b) || {}).name || b) + '.' : 'Added for the whole portfolio.'); });
90
+ if (!r) draw();
91
+ };
92
+
93
+ function item(w, deciding) {
94
+ if (V.edit === w.id) {
95
+ const inp = el('input', { type: 'text', 'data-scan-edit-in': '', value: V.editValue, 'aria-label': 'Watch list item name', maxlength: '80', autocomplete: 'off', oninput: (e) => { V.editValue = e.target.value; },
96
+ onkeydown: (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); save(w); } else if (e.key === 'Escape') { e.stopPropagation(); V.edit = null; draw(); } } });
97
+ setTimeout(() => inp.focus(), 20);
98
+ return el('li', { class: 'sc-it', 'data-scan-item': w.id, 'data-scan-state': 'edit' }, icon('eye', 'sm'), el('div', { class: 'sc-main' }, el('div', { class: 'form' }, inp),
99
+ el('div', { class: 'sc-acts' }, pbtn('primary', 'Save', 'check', { 'data-scan-act': 'save', 'data-id': w.id, onclick: () => save(w) }), pbtn('ghost', 'Cancel', null, { 'data-scan-act': 'cancel', 'data-id': w.id, onclick: () => { V.edit = null; draw(); } }))));
100
+ }
101
+ const kept = w.state === 'kept';
102
+ return el('li', { class: 'sc-it', 'data-scan-item': w.id, 'data-scan-state': w.state, 'data-scan-kind': w.kind }, icon(KIND_ICON[w.kind] || 'eye', 'sm'),
103
+ el('div', { class: 'sc-main' },
104
+ el('div', { class: 'sc-name' }, el('b', null, w.name), kindTag(w.kind), kept ? stc('ok', 'check', 'Kept') : null),
105
+ (deciding || !kept || w.added) && w.why ? el('span', { class: 'dim sc-sm' }, w.why) : null,
106
+ el('div', { class: 'chips' }, el('span', { class: 'dim mono sc-xs' }, 'BOARDS'), ...(w.boards.length ? w.boards.map((b) => boardChip(V.boards, b)) : [chip('none yet')]), el('span', { class: 'dim sc-sm' }, w.added ? 'added by you' : w.ini ? 'from ' + w.ini : 'suggested by the agent')),
107
+ el('div', { class: 'sc-acts' }, !kept ? pbtn('outline', 'Keep', 'check', { 'data-scan-act': 'keep', 'data-id': w.id, disabled: V.busy, onclick: () => keep(w) }) : null,
108
+ pbtn('ghost', 'Edit', 'edit', { 'data-scan-act': 'edit', 'data-id': w.id, disabled: V.busy, onclick: () => { V.edit = w.id; V.editValue = w.name; draw(); } }),
109
+ pbtn('ghost', 'Remove', 'x', { 'data-scan-act': 'remove', 'data-id': w.id, disabled: V.busy, onclick: () => remove(w) }))));
110
+ }
111
+
112
+ function draw() {
113
+ const d = V.d; const sy = host.parentElement ? host.parentElement.scrollTop : 0;
114
+ if (!d) {
115
+ put(host, V.err ? el('div', { class: 'fold w', role: 'alert', 'data-scan': 'error' }, icon('alert', 'sm'), V.err, ' ', pbtn('outline', 'Try again', null, { onclick: load }))
116
+ : el('div', { class: 'dim', 'data-scan': 'loading' }, 'Reading the horizon scan'));
117
+ return;
118
+ }
119
+ const { cadence, repo, types } = d.setting;
120
+ const w = d.items; const n = w.length; const pend = w.filter((x) => x.state === 'suggested').length;
121
+ head.disabled = blocked(d);
122
+ const deciding = !d.last; const scanned = !!d.last;
123
+ const mine = latestReports(d); const wait = waitingOf(d);
124
+ const now = new Date();
125
+ const lastLine = d.open ? 'A scan is running now.' : d.last ? (d.last.ok === false ? 'Last scan did not finish' + (d.last.reason ? ': ' + d.last.reason : '') + '. ' + pastLabel(d.last.at, now) + '.' : 'Last scan: ' + pastLabel(d.last.at, now) + '. ' + plural(mine.length, 'report') + ' written.') : 'Last scan: none yet.';
126
+ const nextLine = cadence === 'off' ? 'Next scan: none while the scan is off. Scan now still works.'
127
+ : d.next && (scanned || w.some((x) => x.state === 'kept')) ? 'Next scan ' + nextLabel(d.next, now) + '. The agent skips what you dismissed or already saw.' : 'The first scheduled scan runs after you decide on the watch list.';
128
+ const status = sect({ ic: 'clock', title: 'Last scan and next scan', attrs: { 'data-scan': 'status' } },
129
+ el('div', { class: 'sc-meta' }, el('span', { 'data-scan': 'schedule' }, el('b', null, SCHED_NOTE(cadence) + '.')),
130
+ el('span', { class: 'dim', 'data-scan': 'last' }, lastLine, scanned && wait ? [' ', lk({ 'data-scan-act': 'reports', onclick: () => o.showTab ? o.showTab('rep', true) : null }, plural(wait, 'proposal') + ' waiting in Reports')] : null),
131
+ el('span', { class: 'dim', 'data-scan': 'next' }, nextLine)),
132
+ el('div', { class: 'chips2' }, pbtn('primary', 'Scan now', 'bolt', { 'data-scan-act': 'scan', disabled: blocked(d), onclick: scanNow }),
133
+ scanned && mine.length ? pbtn('outline', 'Open the reports', 'doc', { 'data-scan-act': 'reports', onclick: () => mine[0] && openScanDoc(ws, mine[0], { panel: wait > 0 }) }) : null,
134
+ el('span', { class: 'dim sc-sm', 'data-scan': 'manual', role: blocked(d) ? 'status' : null }, leftLine(d) + ' Scan now lets you choose what to scan.')));
135
+ const freq = sect({ ic: 'cal', title: 'How often', attrs: { 'data-scan': 'freq' } },
136
+ el('div', { class: 'chips2' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'How often the agent scans' },
137
+ ...[['daily', 'Daily'], ['weekly', 'Weekly'], ['off', 'Off']].map(([k, l]) => btn({ 'aria-pressed': String(cadence === k), 'data-scan-freq': k, disabled: V.busy, onclick: () => setCadence(k) }, l))), el('span', { class: 'dim sc-sm' }, 'Daily is the default.')),
138
+ el('div', { class: 'dim sc-sm' }, 'One scan for the whole portfolio, not one per board. It costs one agent run.'));
139
+ const fromAgent = w.some((x) => !x.added);
140
+ const banner = deciding && n && fromAgent ? el('div', { class: 'fold a', role: 'status', 'data-scan': 'banner' }, icon('spark', 'sm'),
141
+ ' The agent suggests ' + plural(n, 'thing') + ' to watch' + (V.boards.length ? (V.boards.length > 1 ? ', from the initiatives on all ' + V.boards.length + ' boards' : ', from the initiatives on the board') : '') + '. Keep, edit or remove each one.' + (pend ? ' ' + pend + ' still to decide.' : ' You decided on all of them.'),
142
+ pend ? lk({ 'data-scan-act': 'keepall', onclick: keepAll }, 'Keep all') : null) : null;
143
+ const kindSel = el('select', { 'data-scan-add-kind': '', 'aria-label': 'Kind', onchange: (e) => { V.addKind = e.target.value; } }, ...KINDS.map((k) => el('option', { value: k, selected: k === V.addKind }, k)));
144
+ const boardSel = el('select', { 'data-scan-add-board': '', 'aria-label': 'Boards it relates to', onchange: (e) => { V.addBoard = e.target.value; } },
145
+ el('option', { value: '', selected: !V.addBoard }, 'Whole portfolio'), ...V.boards.map((b) => el('option', { value: b.id, selected: b.id === V.addBoard }, b.name)));
146
+ const whyIn = el('input', { type: 'text', 'data-scan-add-why': '', placeholder: 'Why watch it (optional)', 'aria-label': 'Why watch it', maxlength: '200', autocomplete: 'off', value: V.addWhy,
147
+ oninput: (e) => { V.addWhy = e.target.value; }, onkeydown: (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); add(); } } });
148
+ const addIn = el('input', { type: 'text', 'data-scan-add-in': '', placeholder: 'Add something to watch', 'aria-label': 'Add to the watch list', maxlength: '80', autocomplete: 'off', value: V.addName,
149
+ oninput: (e) => { V.addName = e.target.value; }, onkeydown: (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); add(); } } });
150
+ const watch = sect({ ic: 'eye', title: 'Watch list', count: n, attrs: { 'data-scan': 'watch' } }, banner,
151
+ el('ul', { class: 'sc-list', 'data-scan': 'list' }, ...(n ? w.map((x) => item(x, deciding)) : [el('li', { class: 'dim', 'data-scan': 'empty' }, 'The watch list is empty. The web part of the scan has nothing to search for.')])),
152
+ el('div', { class: 'sc-add' }, addIn, kindSel, boardSel, whyIn, pbtn('outline', 'Add', 'plus', { 'data-scan-act': 'add', disabled: V.busy, onclick: add })),
153
+ V.addErr ? el('div', { class: 'fold w', role: 'alert', 'data-scan': 'add-error' }, icon('alert', 'sm'), V.addErr) : null);
154
+ const src = sect({ ic: 'repo', title: 'Sources', attrs: { 'data-scan': 'sources' } },
155
+ check(repo, 'Repository', 'Reads the initiatives on all boards, Jira themes, friction notes and delivery data. It stays on this machine.', null, { 'data-scan-repo': '' }, setRepo),
156
+ el('div', { class: 'sc-web' }, icon('out', 'sm'), el('span', null, el('b', null, 'Web'), ' ', el('span', { class: 'tag' }, 'topics only'), el('br'), el('span', { class: 'dim sc-sm' }, 'Searches and reads public pages for the watch list topics. It never sees private repository content.'))));
157
+ const typesS = sect({ ic: 'report', title: 'Report types', attrs: { 'data-scan': 'types' } },
158
+ ...TYPES.map((t) => check(types.includes(t[0]), t[1], t[2], t[3] ? [' ', el('span', { class: 'tag warn' }, 'repository only')] : null, { 'data-scan-type': t[0] }, (on) => setType(t[0], on))),
159
+ el('div', { class: 'fold' }, icon('plus', 'sm'), ' More report types come later.'));
160
+ const dismissed = V.dismissed;
161
+ const dis = dismissed.length ? sect({ ic: 'x', title: 'Dismissed', count: dismissed.length, attrs: { 'data-scan': 'dismissed' } },
162
+ el('ul', { class: 'chgl' }, ...dismissed.map((p) => el('li', null, icon('x', 'sm'), el('span', null, el('b', null, p.title), el('br'), el('span', { class: 'dim sc-sm' }, p.reason)), el('span', { class: 'tag' }, 'not raised again'))))) : null;
163
+ put(host, status, freq, watch, src, typesS, dis, el('div', { class: 'fold' }, icon('info', 'sm'), ' Nothing is sent outward. The agent proposes; you approve each step. A scan never creates an initiative by itself.'));
164
+ if (host.parentElement) host.parentElement.scrollTop = sy;
165
+ }
166
+ draw(); load();
167
+ return ui;
168
+ }
169
+
170
+ /* ══ Scan now: the picker ══ */
171
+ export function openScanPicker(ws, o = {}) {
172
+ ensureScanCss();
173
+ const P = { d: null, items: {}, repo: true, types: {}, err: '', busy: false, boards: [] };
174
+ const host = el('div', { class: 'sc-hs', 'data-scan-pick': '' });
175
+ const ui = openSheet({ title: 'Scan now', icon: 'search', body: host, wide: true });
176
+ Promise.all([getPortfolio(ws), getBoards(ws)]).then(([d, b]) => {
177
+ P.d = d; P.boards = b; P.repo = d.setting.repo;
178
+ d.items.filter((w) => w.state === 'kept').forEach((w) => { P.items[w.id] = true; });
179
+ TYPES.forEach((t) => { P.types[t[0]] = d.setting.types.includes(t[0]); });
180
+ draw();
181
+ });
182
+ async function start() {
183
+ const w = P.d.items.filter((x) => x.state === 'kept' && P.items[x.id]);
184
+ const sel = { items: w.map((x) => x.id), repo: P.repo, types: TYPES.map((t) => t[0]).filter((k) => P.types[k]) };
185
+ P.busy = true; P.err = ''; draw();
186
+ const r = await postNow(ws, sel);
187
+ P.busy = false;
188
+ if (!r || r.ok === false) { P.err = 'The scan did not start: ' + scanNowWords(r) + '.'; draw(); return; }
189
+ scanChanged();
190
+ openScanRunning(ws, { ...o, before: new Set(P.d.reports.map((x) => x.id)) });
191
+ }
192
+ function draw() {
193
+ const d = P.d;
194
+ if (!d) { put(host, el('div', { class: 'dim' }, 'Reading the horizon scan')); return; }
195
+ const live = d.items.filter((w) => w.state === 'kept'); const held = d.items.length - live.length; const nI = live.filter((w) => P.items[w.id]).length; const nT = TYPES.filter((t) => P.types[t[0]]).length;
196
+ const none = !nI && !P.repo; const webOnly = !none && !nI && P.repo && TYPES.some((t) => P.types[t[0]] && !t[3]);
197
+ const items = sect({ ic: 'eye', title: 'Watch items', count: nI },
198
+ el('ul', { class: 'sc-list' }, ...live.map((w) => el('li', { class: 'sc-it sc-pi' }, check(!!P.items[w.id], w.name, '', [' ', kindTag(w.kind)], { 'data-scan-pi': w.id }, (on) => { P.items[w.id] = on; draw(); }),
199
+ el('div', { class: 'chips' }, ...w.boards.map((b) => boardChip(P.boards, b)))))),
200
+ el('div', { class: 'chips2' }, lk({ 'data-scan-act': 'pall', onclick: () => { live.forEach((x) => { P.items[x.id] = true; }); draw(); } }, 'All'), lk({ 'data-scan-act': 'pnone', onclick: () => { live.forEach((x) => { P.items[x.id] = false; }); draw(); } }, 'None'),
201
+ el('span', { class: 'dim sc-sm' }, nI + ' of ' + live.length + ' chosen. The web part sees these topics only.' + (held ? ' ' + plural(held, 'suggestion') + ' not kept yet, so not listed.' : ''))));
202
+ const repo = sect({ ic: 'repo', title: 'Repository' }, check(P.repo, 'Read the repository', 'Initiatives on all boards, Jira themes, friction notes and delivery data.', null, { 'data-scan-prepo': '' }, (on) => { P.repo = on; draw(); }));
203
+ const types = sect({ ic: 'report', title: 'Report types', count: nT }, ...TYPES.map((t) => check(!!P.types[t[0]], t[1], t[2], t[3] ? [' ', el('span', { class: 'tag warn' }, 'repository only')] : null, { 'data-scan-pt': t[0] }, (on) => { P.types[t[0]] = on; draw(); })));
204
+ const warn = (t) => el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), t);
205
+ put(host, el('div', { class: 'fold' }, icon('info', 'sm'), ' Choose what this scan reads and writes. It does not change your schedule (' + SCHED_NOTE(d.setting.cadence).toLowerCase() + ').'),
206
+ items, repo, types,
207
+ blocked(d) ? warn(leftLine(d)) : null,
208
+ none ? warn('Choose at least one watch item or the repository.') : null, webOnly ? warn('No watch item is chosen, so the web reports have nothing to search for. Only the Productivity report can run.') : null,
209
+ nT === 0 ? warn('Choose at least one report type.') : null, P.err ? el('div', { class: 'fold w', role: 'alert', 'data-scan': 'start-error' }, icon('alert', 'sm'), P.err) : null,
210
+ el('div', { class: 'chips2' }, pbtn('primary', P.busy ? 'Starting' : 'Start the scan', 'bolt', { 'data-scan-act': 'start', disabled: none || !nT || P.busy || blocked(d), onclick: start }), el('span', { class: 'dim sc-sm', 'data-scan': 'left' }, d.manual.left + ' of ' + d.manual.limit + ' manual scans left today.'), pbtn('ghost', 'Cancel', null, { 'data-scan-act': 'cancel', onclick: () => ui.close() })));
211
+ }
212
+ draw();
213
+ return ui;
214
+ }
215
+
216
+ /* ══ the running sheet ══ */
217
+ /** The steps of a scan are web, repo and reports; each is pending | running | done | failed | skipped. */
218
+ const STEP_IC = (s) => (s === 'done' ? 'check' : s === 'failed' ? 'alert' : s === 'pending' || s === 'skipped' ? 'info' : 'clock');
219
+ const STEP_WORD = { done: 'done', running: 'now', failed: 'failed', skipped: 'skipped', pending: '' };
220
+ const settled = (s) => s.state === 'done' || s.state === 'failed' || s.state === 'skipped';
221
+ export function openScanRunning(ws, o = {}) {
222
+ ensureScanCss();
223
+ const R = { d: null, stopped: false, before: o.before || null, seen: false, timer: null, closed: false, stepAt: 0, total: 0, steps: [] };
224
+ const host = el('div', { class: 'sc-hs', 'data-scan-run': '' });
225
+ const ui = openSheet({ title: 'Scan running', icon: 'search', body: host, wide: true, onClose: () => { R.closed = true; clearTimeout(R.timer); } });
226
+ async function poll() {
227
+ if (R.closed) return;
228
+ const d = await getPortfolio(ws);
229
+ if (R.closed) return;
230
+ R.d = d;
231
+ if (d.open) { R.seen = true; R.steps = d.open.steps; R.total = d.open.steps.length; R.stepAt = Math.max(0, d.open.steps.findIndex((s) => !settled(s))); }
232
+ draw();
233
+ if (d.open || !R.seen) R.timer = setTimeout(poll, 1500);
234
+ if (!d.open && R.seen) scanChanged();
235
+ }
236
+ async function stop() {
237
+ const r = await postStop(ws);
238
+ if (!r || r.ok === false) { toast({ text: errText(r, 'Stop did not work'), tone: 'error', timeout: 6000 }); return; }
239
+ R.stopped = true; clearTimeout(R.timer); toast('Scan stopped. What was already filed is kept'); scanChanged(); draw();
240
+ }
241
+ function draw() {
242
+ const d = R.d;
243
+ if (!d) { put(host, el('div', { class: 'dim' }, 'Reading the scan')); return; }
244
+ if (R.stopped) { put(host, el('div', { class: 'fold w', role: 'status', 'data-scan': 'stopped' }, icon('x', 'sm'), ' Stopped at step ' + (R.stepAt + 1) + ' of ' + R.total + '. Nothing was sent. What was already filed is kept.'),
245
+ el('div', { class: 'chips2' }, pbtn('primary', 'Scan now again', 'bolt', { 'data-scan-act': 'scan', onclick: () => openScanPicker(ws, o) }), pbtn('ghost', 'Close', null, { onclick: () => ui.close() }))); return; }
246
+ if (d.open) {
247
+ const steps = d.open.steps;
248
+ put(host, el('div', { class: 'lh' }, el('span', { class: 'ki' }, icon('bolt')), el('div', { class: 'sc-lhm' }, el('b', null, 'Scanning the portfolio'), el('div', { class: 'dim' }, (d.open.stopping ? 'Stopping. ' : '') + 'Step ' + (R.stepAt + 1) + ' of ' + steps.length + '. You can close this sheet. The scan keeps running.'))),
249
+ el('div', { class: 'hbr ini-t-acc', 'data-scan': 'progress', 'aria-label': 'Scan progress' }, el('div', { class: 'tk' }, geo(el('u'), { '--in-w': Math.max(6, Math.min(100, Math.round(R.stepAt / Math.max(1, steps.length) * 100))) + '%' }))),
250
+ el('ul', { class: 'chgl sc-steps', 'data-scan': 'steps' }, ...steps.map((s, i) => el('li', { 'data-scan-step': s.id, 'data-scan-step-state': s.state }, icon(STEP_IC(s.state), 'sm'), el('span', { class: i > R.stepAt ? 'dim' : '' }, el('b', null, s.label)),
251
+ el('span', { class: 'dim' }, STEP_WORD[s.state] || '')))),
252
+ el('div', { class: 'chips2' }, pbtn('outline', 'Stop', 'x', { 'data-scan-act': 'stop', onclick: stop }), el('span', { class: 'dim sc-sm' }, 'Stopping keeps what was already filed. Nothing is sent.')));
253
+ return;
254
+ }
255
+ if (!R.seen && !R.before) { put(host, el('div', { class: 'fold', 'data-scan': 'idle' }, icon('info', 'sm'), ' No scan is running now.'), el('div', { class: 'chips2' }, pbtn('primary', 'Scan now', 'bolt', { 'data-scan-act': 'scan', onclick: () => openScanPicker(ws, o) }), pbtn('ghost', 'Close', null, { onclick: () => ui.close() }))); return; }
256
+ const fresh = d.reports.filter((r) => !r.parent && (!R.before || !R.before.has(r.id)));
257
+ const wait = waitingOf(d);
258
+ const failed = d.last && d.last.ok === false ? d.last : null;
259
+ put(host, failed ? el('div', { class: 'fold w', role: 'alert', 'data-scan': 'failed' }, icon('alert', 'sm'), failed.stopped ? ' The scan was stopped.' : ' The scan did not finish' + (failed.reason ? ': ' + failed.reason : '') + '.') : null,
260
+ el('div', { class: 'lh', 'data-scan': 'finished' }, el('span', { class: 'ki' }, icon('check')), el('div', { class: 'sc-lhm' }, el('b', null, fresh.length ? 'Scan finished: ' + plural(fresh.length, 'report') + ' written' : 'Scan finished: nothing new'),
261
+ el('div', { class: 'dim' }, fresh.length ? plural(wait, 'proposal') + ' wait for you.' : 'The agent raised nothing twice.'))),
262
+ fresh.length ? list(fresh.map((r) => nrow({ ic: 'search', title: r.title || typeName(r.type), onOpen: () => openScanDoc(ws, r, { panel: true }), attrs: { 'data-scan-report': r.id },
263
+ chips: [waitingOf(d, r.id) ? el('span', { class: 'tag acc' }, plural(waitingOf(d, r.id), 'proposal')) : chip('No proposals')], right: [pbtn('outline', 'Open', 'out', { 'data-scan-act': 'opendoc', 'data-id': r.id, onclick: () => openScanDoc(ws, r, { panel: true }) })] }))) : null,
264
+ el('div', { class: 'fold' }, icon('info', 'sm'), ' Nothing was sent. Public pages were read for topics only.'),
265
+ el('div', { class: 'chips2' }, fresh.length && o.showTab ? pbtn('primary', 'Open Documents', 'doc', { 'data-scan-act': 'reports', onclick: () => { ui.close(); o.showTab('rep', true); } }) : null, pbtn('ghost', 'Close', null, { onclick: () => ui.close() })));
266
+ }
267
+ draw(); poll();
268
+ return ui;
269
+ }
270
+
271
+ /* ══ the Portfolio section ══ */
272
+ export function scanPortfolioSection({ ws, boardCount, showTab }) {
273
+ ensureScanCss();
274
+ const S = { d: null, err: '' };
275
+ const body = el('div', { class: 'sc-pf' });
276
+ const right = el('span', { class: 'dim mono sc-xs', 'data-scan': 'cadence' });
277
+ const o = { showTab };
278
+ const node = sect({ ic: 'search', title: 'Horizon scan', right, attrs: { 'data-scan-section': 'portfolio', 'data-c3pg': 'hs-portfolio', 'aria-label': 'Horizon scan' } }, body);
279
+ const scanNow = () => { if (S.d && S.d.open) { openScanRunning(ws, o); toast('A scan is already running'); } else if (blocked(S.d)) toast({ text: leftLine(S.d), tone: 'error', timeout: 6000 }); else openScanPicker(ws, o); };
280
+ async function load() { const d = await getPortfolio(ws); if (d.ok) { S.d = d; S.err = ''; } else S.err = errText(d, 'The horizon scan could not be read'); draw(); }
281
+ function draw() {
282
+ const d = S.d;
283
+ const mine = d ? latestReports(d) : []; const wait = d ? waitingOf(d) : 0; const cad = d ? d.setting.cadence : 'daily';
284
+ const now = new Date();
285
+ const kept = d ? d.items.filter((w) => w.state === 'kept').length : 0;
286
+ let state = 'Reading the horizon scan.';
287
+ if (S.err && !d) state = S.err;
288
+ else if (d) {
289
+ state = !kept && !d.last ? (d.items.length ? 'The agent suggests ' + plural(d.items.length, 'thing') + ' to watch. Decide on the list, or run a scan now and choose what to scan.' : 'No watch list yet. Open the setup to add what to watch, or run a scan now and choose what to scan.')
290
+ : cad === 'off' ? 'The scan is off. Scan now still works.'
291
+ : (cad === 'daily' ? 'Daily' : 'Weekly') + '. ' + (d.last ? 'Last scan ' + pastLabel(d.last.at, now) + '. ' : '') + (d.next ? 'Next scan ' + nextLabel(d.next, now) + '.' : '');
292
+ }
293
+ right.textContent = d ? cad.toUpperCase() : '';
294
+ const byDoc = d ? mine.filter((r) => waitingOf(d, r.id)) : [];
295
+ put(body,
296
+ el('div', { class: 'chips2' }, chip(icon('cols', 'sm'), 'Whole portfolio: ' + plural(boardCount == null ? 0 : boardCount, 'board')),
297
+ d && mine.length ? chip(icon('doc', 'sm'), plural(mine.length, 'report') + ' from the last scan') : null),
298
+ el('div', { class: 'dim sc-sm', 'data-scan': 'state', role: S.err && !d ? 'alert' : null }, state),
299
+ d ? el('div', { class: 'dim sc-sm', 'data-scan': 'manual', role: blocked(d) ? 'status' : null }, leftLine(d)) : null,
300
+ d && wait ? el('div', { class: 'fold a sc-strip', role: 'status', 'data-scan': 'waiting' }, icon('spark', 'sm'), el('b', null, 'Waiting for you: ' + plural(wait, 'proposal')), el('span', { class: 'dim' }, 'from the Horizon scan'),
301
+ ...byDoc.map((r) => lk({ 'data-scan-act': 'opennext', 'data-id': r.id, onclick: () => openScanDoc(ws, r, { panel: true }) }, (r.title || typeName(r.type)).split(':')[0] + ' ' + waitingOf(d, r.id)))) : null,
302
+ d && mine.length ? list(mine.map((r) => nrow({ ic: 'search', title: r.title || typeName(r.type), onOpen: () => openScanDoc(ws, r, { panel: waitingOf(d, r.id) > 0 }), attrs: { 'data-scan-report': r.id },
303
+ chips: [waitingOf(d, r.id) ? el('span', { class: 'tag acc' }, plural(waitingOf(d, r.id), 'proposal')) : chip('No proposals waiting')] })), { 'data-scan': 'reports' }) : null,
304
+ el('div', { class: 'chips2' }, pbtn('primary', 'Scan now', 'bolt', { 'data-scan-act': 'scan', disabled: blocked(d), onclick: scanNow }), pbtn('outline', 'Horizon scan setup', 'settings', { 'data-scan-act': 'setup', onclick: () => openScanSetup(ws, o) }),
305
+ d && mine.length && showTab ? pbtn('ghost', 'Open the reports', 'doc', { 'data-scan-act': 'reports', onclick: () => showTab('rep', true) }) : null));
306
+ }
307
+ const off = onScanChange(() => { if (!node.isConnected) { off(); return; } load(); });
308
+ draw(); load();
309
+ return node;
310
+ }
311
+
312
+ /* ══ Board settings: the pointer ══ */
313
+ export function scanPointer(ws) {
314
+ ensureScanCss();
315
+ return el('div', { class: 'sc-ptr', 'data-scan-ptr': '' }, icon('search', 'sm'), el('span', null, el('b', null, 'Horizon scan runs for the whole portfolio.'), ' ', el('span', { class: 'dim' }, 'It is not set per board.')),
316
+ lk({ 'data-scan-act': 'setup', onclick: () => openScanSetup(ws) }, 'Open the Horizon scan setup'));
317
+ }
@@ -0,0 +1,34 @@
1
+ /* pm/c3-scan-words.js — the words and dates the Horizon scan screens share (c3-scan.js, c3-scan-setup.js, c3-scan-next.js), and the one
2
+ * stylesheet link. Pure, except ensureScanCss. */
3
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
4
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
5
+ const p2 = (n) => String(n).padStart(2, '0');
6
+ const clock = (d) => p2(d.getHours()) + ':' + p2(d.getMinutes());
7
+ const sameDay = (a, b) => a.toDateString() === b.toDateString();
8
+ const WHEN_SHORT = (d) => WD[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()] + ', ' + clock(d);
9
+
10
+ /** Link c3-scan.css once, from this module's own folder (the way c3-driver.js links its stylesheet). */
11
+ export function ensureScanCss() {
12
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-scan]')) return;
13
+ const l = document.createElement('link');
14
+ l.rel = 'stylesheet'; l.href = new URL('./c3-scan.css', import.meta.url).href; l.setAttribute('data-c3-scan', '');
15
+ document.head.appendChild(l);
16
+ }
17
+
18
+ /** 'today, 09:00', 'yesterday, 09:00' or 'Mon 12 Oct, 09:00', for a time in the past. */
19
+ export function pastLabel(iso, now = new Date()) {
20
+ const d = new Date(iso);
21
+ if (!Number.isFinite(d.getTime())) return '';
22
+ const y = new Date(now); y.setDate(y.getDate() - 1);
23
+ return sameDay(d, now) ? 'today, ' + clock(d) : sameDay(d, y) ? 'yesterday, ' + clock(d) : WHEN_SHORT(d);
24
+ }
25
+ /** 'within a minute', 'today, 09:00', 'tomorrow, 09:00' or 'Mon 19 Oct, 09:00', for the next scan. */
26
+ export function nextLabel(iso, now = new Date()) {
27
+ const d = new Date(iso);
28
+ if (!Number.isFinite(d.getTime())) return '';
29
+ if (d.getTime() - now.getTime() < 90000) return 'within a minute';
30
+ const t = new Date(now); t.setDate(t.getDate() + 1);
31
+ return sameDay(d, now) ? 'today, ' + clock(d) : sameDay(d, t) ? 'tomorrow, ' + clock(d) : WHEN_SHORT(d);
32
+ }
33
+ export const scheduleLine = (c) => (c === 'daily' ? 'Every working day, 09:00' : c === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs by itself');
34
+ export const sameDayAs = sameDay;
@@ -1,25 +1,74 @@
1
- /* pm/c3-scan.css — the few rules the Horizon scan needs beyond console3.css (c3-scan.js). The mockup's 40-scan.css under the `sc-` prefix: the
2
- * Board settings block, the scan asks in Triage and the Briefing card. Held to the R28 rules: tokens only, no inline style, corners 0, 6px or 8px,
1
+ /* pm/c3-scan.css — the few rules the portfolio Horizon scan needs beyond console3.css (c3-scan-setup.js, c3-scan-next.js, c3-scan.js). The mockup's
2
+ * 42-hscan.css under the `sc-` prefix: the Portfolio section, the setup, picker and running sheets, the proposals panel in the document sheet, the
3
+ * Board settings pointer, the Briefing card and the scan asks in Triage. Held to the R28 rules: tokens only, no inline style, corners 0, 6px or 8px,
3
4
  * font sizes from the --fs tokens. */
4
- .c3 .sc-set { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); padding-top: var(--sp-3); border-top: 1px solid var(--border); }
5
- .c3 .sc-set > * { min-width: 0; overflow-wrap: anywhere; }
6
- .c3 .sc-set .seg { max-width: 100%; overflow-x: auto; }
5
+ .c3 .sc-hs { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-3); min-width: 0; }
6
+ .c3 .sc-hs > * { min-width: 0; overflow-wrap: anywhere; }
7
+ .c3 .sc-hs .seg { max-width: 100%; overflow-x: auto; }
8
+ .c3 .sc-lhm { min-width: 0; }
9
+ .c3 .sc-sm { font-size: var(--fs-2); }
10
+ .c3 .sc-xs { font-size: var(--fs-1); }
7
11
  .c3 .sc-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
8
12
  .c3 .sc-it { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 8px; align-items: start; padding: var(--sp-2) 0; border-top: 1px solid var(--border); }
13
+ .c3 .sc-it:first-child { border-top: 0; }
9
14
  .c3 .sc-it > .sc-main { min-width: 0; display: grid; gap: 4px; }
10
15
  .c3 .sc-it .sc-name { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; overflow-wrap: anywhere; }
11
- .c3 .sc-it .sc-why { font-size: var(--fs-2); }
12
16
  .c3 .sc-it .sc-acts { display: flex; gap: 6px; flex-wrap: wrap; }
13
17
  .c3 .sc-it .form { margin: 0; }
18
+ .c3 .sc-it.sc-pi { grid-template-columns: minmax(0, 1fr); gap: 4px; padding: 4px 0; }
19
+ .c3 .sc-it.sc-pi .chips { margin-left: 26px; }
20
+ .c3 .sc-hs .chips, .c3 .sc-next .chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
14
21
  .c3 .sc-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
15
22
  .c3 .sc-add input[type="text"] { flex: 1 1 160px; min-width: 0; }
16
23
  .c3 .sc-meta { display: grid; gap: 4px; font-size: var(--fs-2); }
17
- .c3 .sc-foot { font-size: var(--fs-2); }
24
+ .c3 .sc-chkl { min-width: 0; overflow-wrap: anywhere; }
25
+ .c3 .sc-web { display: flex; gap: 8px; align-items: flex-start; padding-top: var(--sp-2); }
26
+ .c3 .sc-web > span { min-width: 0; }
27
+ .c3 .sc-pf { display: grid; gap: var(--sp-2); }
28
+ .c3 .sc-pf ul.list { margin: 0; }
29
+ .c3 .sc-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
30
+ .c3 .sc-steps li.ok { color: var(--foreground); }
31
+ .c3 .sc-ptr { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); border-radius: 8px; background: var(--card); }
32
+ .c3 .sc-ptr > span { flex: 1 1 200px; min-width: 0; }
33
+ /* Keep the board settings sheet body inside the sheet when the pointer line is present. */
34
+ .c3 #sheet .sb:has(.sc-ptr) { grid-template-columns: minmax(0, 1fr); }
35
+ .c3 .sheet .sb:has(.sc-ptr) { min-width: 0; }
36
+ .c3 .sheet .sb .sbb:has(.sc-ptr) { min-width: 0; max-width: 100%; grid-template-columns: minmax(0, 1fr); }
37
+
38
+ /* The "Proposed next steps" panel, under the document sheet's header and tabs */
39
+ .c3 .sc-next { display: grid; gap: var(--sp-2); padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); min-width: 0; }
40
+ .c3 .sc-next[hidden] { display: none; }
41
+ .c3 .sc-next .hd { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
42
+ .c3 .sc-next .sc-tg { display: inline-flex; gap: 8px; align-items: center; flex-wrap: wrap; background: transparent; border: 0; padding: 0; font: inherit; color: var(--foreground); cursor: pointer; text-align: left; min-width: 0; }
43
+ .c3 .sc-next .sc-tg { max-width: 100%; }
44
+ .c3 .sc-next .sc-tg .dim { font-size: var(--fs-2); min-width: 0; overflow-wrap: anywhere; }
45
+ .c3 .sc-next .sc-tg .ic { transition: transform .15s; }
46
+ .c3 .sc-next .sc-tg[aria-expanded="true"] .ic { transform: rotate(90deg); }
47
+ .c3 .sc-next .tag { display: inline-flex; gap: 4px; align-items: center; }
48
+ .c3 .sc-next ul.sc-pl { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
49
+ .c3 .sc-next .sc-p { display: grid; gap: 8px; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--background); }
50
+ .c3 .sc-next .sc-p p { margin: 0; font-size: var(--fs-2); line-height: 1.5; }
51
+ .c3 .sc-next .sc-p .l1 { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
52
+ .c3 .sc-next .sc-p .l1 .tag { margin-left: auto; }
53
+ .c3 .sc-next .sc-p .chip2 { max-width: 100%; }
54
+ .c3 .sc-next .sc-p .chip2 .mono { overflow-wrap: anywhere; }
55
+ .c3 .sc-next .sc-p .ini-kv { margin: 0; font-size: var(--fs-2); }
56
+ .c3 .sc-next .sc-p .ini-kv dt { text-transform: uppercase; font-size: var(--fs-1); }
57
+ .c3 .sc-next .sc-link { text-decoration: none; }
58
+ .c3 .sc-next .sc-p .fold { align-items: center; }
59
+ .c3 .sc-next .sc-form { display: grid; gap: 8px; }
60
+ .c3 .sc-next .sc-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }
61
+ .c3 .sc-next .sc-f { display: grid; gap: 4px; min-width: 0; font-size: var(--fs-1); }
62
+ .c3 .sc-next .sc-f input, .c3 .sc-next .sc-f select, .c3 .sc-next .sc-f textarea { width: 100%; max-width: 100%; box-sizing: border-box; }
63
+ .c3 .sc-next .sc-f input.bad { border-color: var(--destructive); }
64
+ .c3 .sc-next .sc-run { display: grid; gap: 8px; }
65
+ .c3 .sheet .flash { background: color-mix(in oklab, var(--acc) 10%, transparent); }
66
+
67
+ /* Triage rows for an ask an earlier scan found, and the Briefing card */
18
68
  .c3 .nrow .sc-src { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; align-items: center; }
19
69
  .c3 .nrow .sc-src .chip2 { max-width: 100%; }
20
70
  .c3 .sc-link { text-decoration: none; }
21
71
  .c3 .sc-card { display: grid; gap: var(--sp-2); }
22
72
  .c3 .sc-card ul.chgl li { flex-wrap: wrap; }
23
-
24
73
  .c3 .sc-card .sc-find { display: flex; flex-direction: column; min-width: 0; }
25
- .c3 .sc-card .sc-find .sc-sub { display: block; }
74
+ .c3 .sc-card .sc-find .sc-sm { display: block; }