create-pmos 0.47.0 → 0.49.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.
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +14 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +214 -0
- package/payload/scripts/workflow-state.js +59 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -2
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +17 -2
- package/workspace/guide/knowledge-graph.md +94 -1
- package/workspace/guide/layout.md +31 -2
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +31 -2
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +214 -0
- package/workspace/lib/engine/workflow-state.js +59 -2
- package/workspace/lib/jev-categorize.js +433 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +139 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +1035 -0
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +78 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +279 -18
- package/workspace/public/pm/panel-cockpit.js +638 -0
- package/workspace/public/pm/panel-detail.js +212 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +249 -29
- package/workspace/public/pm/state.js +48 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-livenotes.js +397 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +349 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* pm/view-skills.js — the Skills view tab, drawn from planning/design/cockpit-mockup/ViewSkills.dc.html.
|
|
2
|
+
*
|
|
3
|
+
* PRD workspace-cockpit-nav, Run 3 (R4). The board is two panes. On the left, a 340px column with the title, a switch
|
|
4
|
+
* (All, Built-in, Yours, Discovered) and the skills. On the right, the skill that waits for the person: a quarantined
|
|
5
|
+
* candidate, its text, and Approve and Reject.
|
|
6
|
+
*
|
|
7
|
+
* Both panes are the view's existing list (modes-live.js `skillsLive`, or the fixture's skills), drawn once and split: the
|
|
8
|
+
* skill rows go left, the discovery block and the candidates go right. No node is removed, so every `data-r4="skill"`,
|
|
9
|
+
* `data-r13` and `data-r7` hook and every live behaviour stays as it was: a row still opens its skill as a tab, and a
|
|
10
|
+
* candidate is still read, approved, rejected or retired by its own buttons. Only the frame is new.
|
|
11
|
+
*
|
|
12
|
+
* The switch hides the rows of the other sources. It reads each row's own source label, so it never disagrees with the list.
|
|
13
|
+
* The board's "+ New skill" has no form in this app (the server's POST /api/skills is reached from the CLI), so it is not
|
|
14
|
+
* drawn. The board's "Always produces" and "Procedure" parts belong to a skill's own tab, which a row opens. In the fixture
|
|
15
|
+
* the discovery is not read, and the right pane says so.
|
|
16
|
+
*/
|
|
17
|
+
import { el } from './dom.js';
|
|
18
|
+
|
|
19
|
+
let seg = 'all'; // the left switch: 'all' · 'builtin' · 'user' · 'discovered'
|
|
20
|
+
|
|
21
|
+
const SEGS = [['all', 'All'], ['builtin', 'Built-in'], ['user', 'Yours'], ['discovered', 'Discovered']];
|
|
22
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
23
|
+
const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
24
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;display:inline-block;flex:none';
|
|
25
|
+
const TONE = {
|
|
26
|
+
s: ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
27
|
+
w: ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
28
|
+
d: ';border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
|
|
29
|
+
};
|
|
30
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
31
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5;padding:0';
|
|
32
|
+
const ITEM = 'display:flex;flex-direction:column;gap:2px;width:100%;box-sizing:border-box;text-align:left;padding:8px 12px;min-height:40px;border:0;border-radius:8px;background:transparent;color:var(--foreground);cursor:pointer';
|
|
33
|
+
const CARD = 'display:flex;flex-direction:column;gap:8px;width:100%;box-sizing:border-box;text-align:left;border:1px solid var(--border);border-radius:10px;padding:14px 16px;background:var(--background);min-height:0';
|
|
34
|
+
const PROC = 'white-space:pre-wrap;overflow-wrap:anywhere;margin:0;border:1px solid var(--border);border-radius:10px;background:var(--card);padding:12px 14px;font:12px/1.6 var(--mono);color:var(--muted-foreground);max-height:320px;overflow:auto';
|
|
35
|
+
|
|
36
|
+
const badge = (text, tone, attrs) => el('span', { ...(attrs || {}), style: BADGE + (tone ? TONE[tone] : '') }, String(text));
|
|
37
|
+
|
|
38
|
+
/** A row's source, read off the row itself: the live row shows it on the right of its first line. A fixture row has none. */
|
|
39
|
+
function sourceOf(row) {
|
|
40
|
+
const top = row.children[0];
|
|
41
|
+
const src = top && top.children.length > 1 ? (top.children[1].textContent || '').trim() : '';
|
|
42
|
+
return src || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Restyle one skill row in place, as the board's item: the name in bold, the source as a badge, the description below. */
|
|
46
|
+
function restyleRow(row) {
|
|
47
|
+
const active = /background:var\(--accent\)/.test(row.getAttribute('style') || '');
|
|
48
|
+
row.setAttribute('style', ITEM + (active ? ';background:var(--accent)' : ''));
|
|
49
|
+
const [top, desc] = row.children;
|
|
50
|
+
if (top) {
|
|
51
|
+
top.setAttribute('style', 'display:flex;align-items:center;gap:8px;min-width:0');
|
|
52
|
+
const [name, src] = top.children.length > 1 ? top.children : [top, null];
|
|
53
|
+
if (name && name !== top) name.setAttribute('style', 'font:600 13px var(--sans);color:var(--foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
|
|
54
|
+
if (name === top) top.setAttribute('style', 'display:block;font:600 13px var(--sans);color:var(--foreground)');
|
|
55
|
+
if (src) {
|
|
56
|
+
const s = (src.textContent || '').trim();
|
|
57
|
+
src.setAttribute('style', BADGE + ';margin-left:auto' + (s === 'discovered' ? TONE.s : ''));
|
|
58
|
+
src.setAttribute('data-board-badge', 'source');
|
|
59
|
+
if (s === 'discovered') src.setAttribute('title', 'discovered, and approved on this machine');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (desc) desc.setAttribute('style', 'display:block;font-size:12.5px;line-height:1.45;color:var(--muted-foreground)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Restyle one candidate as a card: its name and state as the board's title and badge, its text as the procedure box. */
|
|
66
|
+
function restyleCand(card) {
|
|
67
|
+
card.setAttribute('style', CARD);
|
|
68
|
+
card.setAttribute('data-board-card', 'candidate');
|
|
69
|
+
const st = card.getAttribute('data-state') || '';
|
|
70
|
+
const kids = [...card.children];
|
|
71
|
+
const [top] = kids;
|
|
72
|
+
if (top) {
|
|
73
|
+
top.setAttribute('style', 'display:flex;align-items:center;gap:10px;flex-wrap:wrap');
|
|
74
|
+
const [name, state] = top.children;
|
|
75
|
+
if (name) name.setAttribute('style', 'font:600 16px var(--sans);color:var(--foreground)');
|
|
76
|
+
if (state) {
|
|
77
|
+
const t = state.textContent || '';
|
|
78
|
+
const tone = /running|approved here/.test(t) ? 's' : st === 'quarantined' ? 'w' : st === 'rejected' ? 'd' : null;
|
|
79
|
+
state.setAttribute('style', BADGE + (tone ? TONE[tone] : ''));
|
|
80
|
+
state.setAttribute('data-board-badge', 'state');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const k of kids.slice(1)) {
|
|
84
|
+
if (k.tagName === 'PRE') { k.setAttribute('style', PROC); continue; }
|
|
85
|
+
const btns = k.querySelectorAll('button');
|
|
86
|
+
if (btns.length) {
|
|
87
|
+
k.setAttribute('style', 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:2px');
|
|
88
|
+
for (const b of btns) {
|
|
89
|
+
const h = b.getAttribute('data-r13');
|
|
90
|
+
b.setAttribute('style', (h === 'approve' ? BTNP : BTN) + (b.disabled ? ';opacity:.45;cursor:not-allowed' : ''));
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const tone = /var\(--destructive\)/.test(k.getAttribute('style') || '') ? ';color:var(--destructive)' : '';
|
|
95
|
+
const mono = /var\(--mono\)/.test(k.getAttribute('style') || '');
|
|
96
|
+
k.setAttribute('style', 'display:block;' + (mono ? 'font:11px var(--mono);overflow-wrap:anywhere;' : 'font-size:13px;line-height:1.5;') + 'color:var(--muted-foreground)' + tone);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The right pane's parts, restyled in place. `head` is the list's discovery block, in its own order. */
|
|
101
|
+
function restyleReview(nodes) {
|
|
102
|
+
for (const n of nodes) {
|
|
103
|
+
if (!n || n.nodeType !== 1) continue;
|
|
104
|
+
const h = n.getAttribute('data-r13') || n.getAttribute('data-r4') || '';
|
|
105
|
+
if (h === 'cand') { restyleCand(n); continue; }
|
|
106
|
+
if (h === 'discovery-label' || h === 'cands-label') { n.setAttribute('style', LBL + ';padding:6px 0 0'); continue; }
|
|
107
|
+
if (h === 'run-note' || h === 'cand-note') { n.setAttribute('style', NOTE + (/destructive/.test(n.getAttribute('style') || '') ? ';color:var(--destructive)' : '')); continue; }
|
|
108
|
+
if (n.querySelector('button')) {
|
|
109
|
+
// the discovery actions: "find skills now" with its next run, or the one-press weekly set-up
|
|
110
|
+
n.setAttribute('style', 'display:flex;gap:10px;align-items:center;flex-wrap:wrap');
|
|
111
|
+
for (const b of n.querySelectorAll('button')) b.setAttribute('style', b.getAttribute('data-r13') === 'discover-now' ? BTNP : BTN);
|
|
112
|
+
const seed = n.querySelector('[data-r7]');
|
|
113
|
+
if (seed) {
|
|
114
|
+
seed.setAttribute('style', 'display:flex;flex-direction:column;gap:6px;align-items:flex-start');
|
|
115
|
+
for (const c of seed.children) if (c.tagName === 'SPAN') c.setAttribute('style', 'font-size:12px;color:var(--muted-foreground)');
|
|
116
|
+
}
|
|
117
|
+
for (const s of n.children) if (s.tagName === 'SPAN') s.setAttribute('style', 'font:11px var(--mono);color:var(--muted-foreground)');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const bad = /var\(--destructive\)/.test(n.getAttribute('style') || '');
|
|
121
|
+
n.setAttribute('style', NOTE + (bad ? ';color:var(--destructive)' : ''));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function renderBoard(inner, ctx) {
|
|
126
|
+
const { FIXTURE } = ctx;
|
|
127
|
+
|
|
128
|
+
// The board fills the tab from edge to edge, as the mockup does: the pane's own padding is dropped for this view only.
|
|
129
|
+
const pane = inner.parentElement;
|
|
130
|
+
if (pane) { pane.style.padding = '0'; pane.style.overflow = 'hidden'; pane.style.display = 'flex'; pane.style.flexDirection = 'column'; }
|
|
131
|
+
inner.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;flex-direction:row;gap:0;max-width:none;height:100%';
|
|
132
|
+
inner.setAttribute('data-board', 'skills');
|
|
133
|
+
|
|
134
|
+
// the existing list, drawn once, then split between the two panes
|
|
135
|
+
const drawn = el('div', null);
|
|
136
|
+
ctx.renderMode(drawn);
|
|
137
|
+
const wrap = !FIXTURE && drawn.children.length === 1 && !drawn.firstElementChild.hasAttribute('data-r4') ? drawn.firstElementChild : null;
|
|
138
|
+
const review = [];
|
|
139
|
+
if (wrap) {
|
|
140
|
+
const kids = [...wrap.children];
|
|
141
|
+
// the list's own label "skills · N · click one to read it" starts the skills; everything above it is the discovery block
|
|
142
|
+
let cut = kids.findIndex((k) => !k.hasAttribute('data-r13') && !k.hasAttribute('data-r4') && /^skills · \d+/.test((k.textContent || '').trim()));
|
|
143
|
+
const counted = cut >= 0;
|
|
144
|
+
if (!counted) cut = kids.length - 1; // a read in progress or failed: its status line stays with the list
|
|
145
|
+
for (const k of kids.slice(0, Math.max(0, cut))) review.push(k);
|
|
146
|
+
for (const k of review) k.remove();
|
|
147
|
+
wrap.setAttribute('style', 'display:flex;flex-direction:column;gap:2px');
|
|
148
|
+
const label = kids[cut];
|
|
149
|
+
if (counted && label) {
|
|
150
|
+
label.setAttribute('data-board-part', 'count');
|
|
151
|
+
label.setAttribute('style', LBL + ';padding:6px 12px 4px');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const rows = [...drawn.querySelectorAll(FIXTURE ? 'button' : '[data-r4="skill"]')];
|
|
155
|
+
for (const row of rows) restyleRow(row);
|
|
156
|
+
for (const n of wrap ? wrap.children : []) {
|
|
157
|
+
if (n.hasAttribute('data-r4') || n.hasAttribute('data-board-part')) continue;
|
|
158
|
+
n.setAttribute('style', NOTE + ';padding:6px 12px' + (/destructive/.test(n.getAttribute('style') || '') ? ';color:var(--destructive)' : ''));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// the switch: it hides the rows of the other sources, and never removes one
|
|
162
|
+
const waiting = review.filter((n) => n.getAttribute && n.getAttribute('data-r13') === 'cand'
|
|
163
|
+
&& n.getAttribute('data-state') === 'quarantined' && !/approved here/.test(n.textContent || '')).length;
|
|
164
|
+
if (!SEGS.some(([k]) => k === seg)) seg = 'all';
|
|
165
|
+
let shown = 0;
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
const src = sourceOf(row);
|
|
168
|
+
const hide = seg !== 'all' && src !== seg;
|
|
169
|
+
row.style.display = hide ? 'none' : '';
|
|
170
|
+
if (!hide) shown++;
|
|
171
|
+
}
|
|
172
|
+
const noMatch = rows.length && !shown ? el('div', { 'data-board-empty': seg, style: NOTE + ';padding:6px 12px' },
|
|
173
|
+
seg === 'user' ? 'You have no skills of your own here. Open a skill and make your own copy to edit it.'
|
|
174
|
+
: seg === 'discovered' ? 'No discovered skill is approved here yet. Candidates wait on the right.'
|
|
175
|
+
: 'No skills of this kind.') : null;
|
|
176
|
+
const segBtn = ([k, label]) => {
|
|
177
|
+
const on = seg === k;
|
|
178
|
+
return el('button', {
|
|
179
|
+
'data-board-seg': k, 'aria-pressed': on ? 'true' : 'false',
|
|
180
|
+
onClick: () => { seg = k; ctx.setState({}); },
|
|
181
|
+
style: 'flex:1 1 0;text-align:center;border:0;border-radius:6px;padding:3px 0;font:' + (on ? '500' : '400') + ' 12.5px var(--sans);cursor:pointer;'
|
|
182
|
+
+ (on ? 'background:var(--background);color:var(--foreground)' : 'background:transparent;color:var(--muted-foreground)'),
|
|
183
|
+
}, label, k === 'discovered' && waiting ? el('span', { 'data-board-waiting': String(waiting), style: 'color:var(--warning);margin-left:4px' }, String(waiting)) : null);
|
|
184
|
+
};
|
|
185
|
+
const toolbar = FIXTURE ? null : el('div', { 'data-board-part': 'toolbar', role: 'group', 'aria-label': 'Show skills by source',
|
|
186
|
+
style: 'display:flex;gap:2px;background:var(--accent);border-radius:8px;padding:3px' }, SEGS.map(segBtn));
|
|
187
|
+
|
|
188
|
+
const left = el('div', { 'data-board-part': 'list',
|
|
189
|
+
style: 'width:340px;flex:none;box-sizing:border-box;border-right:1px solid var(--border);background:var(--sidebar);padding:14px;display:flex;flex-direction:column;gap:10px;min-height:0;overflow-y:auto' },
|
|
190
|
+
el('div', { 'data-board-part': 'header', style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:30px' },
|
|
191
|
+
el('span', { 'data-board-title': '', style: 'font:600 15px var(--sans);color:var(--foreground)' }, 'Skills')),
|
|
192
|
+
toolbar,
|
|
193
|
+
el('div', { 'data-board-section': 'skills', style: 'display:flex;flex-direction:column;gap:2px;margin:0 -2px' }, [...drawn.childNodes], noMatch));
|
|
194
|
+
|
|
195
|
+
// the right pane: what waits for the person
|
|
196
|
+
restyleReview(review);
|
|
197
|
+
let right;
|
|
198
|
+
if (FIXTURE) {
|
|
199
|
+
right = [el('div', { 'data-board-part': 'review-title', style: 'font:600 22px var(--sans);color:var(--foreground)' }, 'Skill discovery'),
|
|
200
|
+
el('div', { 'data-board-part': 'note', style: NOTE }, 'Skill discovery is not read in the fixture. Choose a skill on the left to see it in the detail.')];
|
|
201
|
+
} else {
|
|
202
|
+
right = [
|
|
203
|
+
el('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap' },
|
|
204
|
+
el('span', { 'data-board-part': 'review-title', style: 'font:600 22px var(--sans);color:var(--foreground)' }, 'Skill discovery'),
|
|
205
|
+
waiting ? badge(waiting + (waiting === 1 ? ' waits for you' : ' wait for you'), 'w', { 'data-board-badge': 'waiting' }) : null),
|
|
206
|
+
el('div', { 'data-board-section': 'review', style: 'display:flex;flex-direction:column;gap:12px' }, review),
|
|
207
|
+
review.length ? el('div', { 'data-board-part': 'note', style: NOTE + ';font-size:12px' },
|
|
208
|
+
'Approval is a press on this machine, after you read the full text. It applies to this machine only.') : null,
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
const rightPane = el('div', { 'data-board-part': 'detail',
|
|
212
|
+
style: 'flex:1 1 0;min-width:0;box-sizing:border-box;padding:22px 28px;display:flex;flex-direction:column;gap:12px;overflow-y:auto;background:var(--card);color:var(--foreground);font-size:13px;line-height:1.45' }, right);
|
|
213
|
+
|
|
214
|
+
inner.appendChild(left);
|
|
215
|
+
inner.appendChild(rightPane);
|
|
216
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* pm/view-today.js — the Today view tab, drawn from planning/design/cockpit-mockup/ViewToday.dc.html (PRD workspace-cockpit-nav, Run 3).
|
|
2
|
+
*
|
|
3
|
+
* The board, top to bottom: a greeting with the date and the count line, the Repository / All toggle on the right, a row of
|
|
4
|
+
* source badges, then two columns (Needs you on the left; Prepared for you and Today's events in a 360px column on the right).
|
|
5
|
+
* Under the board sits the live proactive-engine list (`ctx.renderMode`), unchanged, with every data-r22/r23/r26 hook.
|
|
6
|
+
*
|
|
7
|
+
* Every fact comes from a read that exists: derived status (`state.statuses`, the same as Run 2's board) and the morning
|
|
8
|
+
* brief (`/api/proactive/brief`, the route the engine list reads). The mockup's mail, Jira and Confluence badges and its
|
|
9
|
+
* calendar events show only when a brief reports them. Without a brief the board says that nothing was read.
|
|
10
|
+
* The Run 2 hooks are kept: data-today = board, greeting, count, sources, needs, prepared, events, card, open, dismiss, and
|
|
11
|
+
* data-today-scope on the toggle.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { currentProduct } from './board-read.js';
|
|
16
|
+
import { repoTuples, selectRepo } from './panel-repos.js';
|
|
17
|
+
import { needsCards } from './panel-cockpit.js';
|
|
18
|
+
|
|
19
|
+
const DISMISS_REASONS = ['not now', 'wrong priority', 'already handled'];
|
|
20
|
+
const NEEDS_SHOWN = 5; // the mockup shows a few cards and then "+ N more"
|
|
21
|
+
let needsAll = false; // the only module state: has the person asked for every card
|
|
22
|
+
|
|
23
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
24
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;color:var(--foreground);white-space:nowrap;flex:none';
|
|
25
|
+
const TONE = {
|
|
26
|
+
d: 'border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
|
|
27
|
+
w: 'border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
28
|
+
s: 'border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
29
|
+
};
|
|
30
|
+
const badge = (text, tone, extra) => el('span', { style: BADGE + (tone ? ';' + TONE[tone] : '') + (extra ? ';' + extra : '') }, text);
|
|
31
|
+
const CARD = 'border:1px solid var(--border);background:var(--card);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
|
|
32
|
+
const BTN_P = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;display:inline-grid;place-items:center;cursor:pointer';
|
|
33
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;display:inline-grid;place-items:center;cursor:pointer';
|
|
34
|
+
const GHOST = 'font:12px var(--sans);color:var(--muted-foreground);padding:0 8px;height:28px;display:inline-grid;place-items:center;cursor:pointer;background:transparent;border:0';
|
|
35
|
+
const MUTED = 'color:var(--muted-foreground)';
|
|
36
|
+
|
|
37
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
38
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
39
|
+
|
|
40
|
+
/* ── the morning brief: the same route the engine list reads, kept for 15 seconds ─────────────────── */
|
|
41
|
+
let brief = { state: 'idle', data: null, at: 0 };
|
|
42
|
+
function readBrief(ctx) {
|
|
43
|
+
if (ctx.FIXTURE) return null;
|
|
44
|
+
const fresh = brief.state === 'ok' && Date.now() - brief.at < 15000;
|
|
45
|
+
if (brief.state === 'loading' || brief.state === 'error' && Date.now() - brief.at < 15000 || fresh) return brief.data;
|
|
46
|
+
brief = { state: 'loading', data: brief.data, at: brief.at };
|
|
47
|
+
fetch('/api/proactive/brief').then((r) => r.json()).then(
|
|
48
|
+
(d) => { brief = { state: d && d.ok !== false ? 'ok' : 'error', data: d && d.ok !== false ? d : null, at: Date.now() }; ctx.setState({}); },
|
|
49
|
+
() => { brief = { state: 'error', data: null, at: Date.now() }; ctx.setState({}); },
|
|
50
|
+
);
|
|
51
|
+
return brief.data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const keyOf = (repo, slug, state) => {
|
|
55
|
+
const sg = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
|
|
56
|
+
return sg ? sg.keyOf(((state.statuses || {})[repo] || {})[slug]) : 'unknown';
|
|
57
|
+
};
|
|
58
|
+
const meaning = (key) => {
|
|
59
|
+
const sg = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
|
|
60
|
+
return (sg && sg.BY_KEY && sg.BY_KEY[key] && sg.BY_KEY[key].meaning) || '';
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function renderBoard(inner, ctx) {
|
|
64
|
+
const { state, setState } = ctx;
|
|
65
|
+
const all = state.todayScope === 'all';
|
|
66
|
+
const repo = currentProduct();
|
|
67
|
+
const cards = needsCards(all);
|
|
68
|
+
const repos = repoTuples();
|
|
69
|
+
const prepared = [];
|
|
70
|
+
for (const t of repos) {
|
|
71
|
+
if (!all && t[0] !== repo) continue;
|
|
72
|
+
for (const slug of Object.keys((state.statuses && state.statuses[t[0]]) || {})) {
|
|
73
|
+
if (keyOf(t[0], slug, state) === 'in-progress') prepared.push({ repo: t[0], slug });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const b = readBrief(ctx);
|
|
77
|
+
const br = b && b.brief && !b.stale ? b.brief : null;
|
|
78
|
+
const now = new Date();
|
|
79
|
+
const hour = now.getHours();
|
|
80
|
+
const greet = b && b.greeting ? String(b.greeting).replace(/\.$/, '') : (hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening');
|
|
81
|
+
const open = (c) => { selectRepo(c.repo); setState({ sel: c.slug, detail: { kind: 'init' }, col2: true }); };
|
|
82
|
+
|
|
83
|
+
/* header: greeting and count line on the left, the scope toggle on the right */
|
|
84
|
+
const toggle = el('div', { 'data-today': 'scope', style: 'display:flex;gap:2px;background:var(--accent);border-radius:8px;padding:3px;font-size:12.5px' },
|
|
85
|
+
...[['repo', 'Repository'], ['all', 'All']].map(([k, label]) => el('button', {
|
|
86
|
+
'data-today-scope': k, 'aria-pressed': (k === 'all') === all ? 'true' : 'false',
|
|
87
|
+
title: k === 'all' ? 'Show every repository' : 'Show ' + (repo || 'this repository') + ' only',
|
|
88
|
+
onClick: () => setState({ todayScope: k === 'all' ? 'all' : 'repo' }),
|
|
89
|
+
style: 'font:12.5px var(--sans);padding:3px 12px;border-radius:6px;border:0;cursor:pointer;' + ((k === 'all') === all ? 'background:var(--background);font-weight:500;color:var(--foreground)' : 'background:transparent;color:var(--muted-foreground)'),
|
|
90
|
+
}, label)));
|
|
91
|
+
const date = DAYS[now.getDay()] + ' ' + now.getDate() + ' ' + MONTHS[now.getMonth()];
|
|
92
|
+
const count = cards.length + (cards.length === 1 ? ' needs you' : ' need you') + (all ? ' across ' + repos.length + ' repositories' : ' in ' + (repo || 'this repository'))
|
|
93
|
+
+ ' · ' + prepared.length + ' in progress';
|
|
94
|
+
const header = el('div', { 'data-board-part': 'header', style: 'display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap' },
|
|
95
|
+
el('div', { style: 'min-width:0' },
|
|
96
|
+
el('div', { 'data-today': 'greeting', style: 'font:600 22px var(--sans)' }, greet),
|
|
97
|
+
el('div', { 'data-today': 'count', style: MUTED }, date + ' · ' + count)),
|
|
98
|
+
el('div', { style: 'display:flex;align-items:center;gap:10px' }, toggle));
|
|
99
|
+
|
|
100
|
+
/* the source badges: what was read, and nothing else */
|
|
101
|
+
const srcBadges = [badge('records · read', 's')];
|
|
102
|
+
if (br) {
|
|
103
|
+
for (const [k, v] of Object.entries(br.sources || {})) {
|
|
104
|
+
srcBadges.push(v === 'read' ? badge(k + ' · read', 's') : v === 'off' ? badge(k + ' · off', null, 'opacity:.6') : badge(k + ' · not reached'));
|
|
105
|
+
}
|
|
106
|
+
} else srcBadges.push(badge(b && b.stale ? 'brief · out of date' : ctx.FIXTURE ? 'brief · not read here' : 'brief · none yet', b && b.stale ? 'w' : null));
|
|
107
|
+
const sources = el('div', { 'data-today': 'sources', style: 'display:flex;gap:6px;flex-wrap:wrap;align-items:center' },
|
|
108
|
+
...srcBadges,
|
|
109
|
+
el('span', { style: MUTED + ';font-size:11px' }, br
|
|
110
|
+
? 'Read from the derived status of ' + Object.keys(state.statuses || {}).length + ' repositories and the brief for ' + br.date + '.'
|
|
111
|
+
: 'Read from the derived status of ' + Object.keys(state.statuses || {}).length + ' repositories. No mail or calendar is read here.'));
|
|
112
|
+
|
|
113
|
+
/* Needs you */
|
|
114
|
+
const card = (c) => {
|
|
115
|
+
const needs = c.key === 'needs-you';
|
|
116
|
+
return el('div', { 'data-today': 'card', 'data-slug': c.slug, 'data-repo': c.repo, style: CARD },
|
|
117
|
+
el('div', { style: 'display:flex;align-items:center;gap:8px;min-width:0' },
|
|
118
|
+
badge(c.repo),
|
|
119
|
+
el('b', { style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, c.slug),
|
|
120
|
+
badge(needs ? 'needs you' : 'acceptance', needs ? 'd' : 'w', 'margin-left:auto')),
|
|
121
|
+
meaning(c.key) ? el('div', { style: MUTED }, meaning(c.key)) : null,
|
|
122
|
+
el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
|
|
123
|
+
el('button', { 'data-today': 'open', onClick: () => open(c), style: BTN_P }, needs ? 'Unblock' : 'Review'),
|
|
124
|
+
...DISMISS_REASONS.map((r, i) => el('button', { 'data-today': 'dismiss', 'data-reason': r, title: 'Dismiss: ' + r,
|
|
125
|
+
onClick: () => setState((s) => ({ todayDismissed: { ...(s.todayDismissed || {}), [c.repo + '/' + c.slug]: r } })),
|
|
126
|
+
style: GHOST + (i === 0 ? ';margin-left:auto' : ''), hover: 'color:var(--foreground)' }, r.charAt(0).toUpperCase() + r.slice(1)))));
|
|
127
|
+
};
|
|
128
|
+
const shown = needsAll ? cards : cards.slice(0, NEEDS_SHOWN);
|
|
129
|
+
const needsCol = el('div', { 'data-today': 'needs', style: 'flex:1 1 320px;min-width:0;display:flex;flex-direction:column;gap:10px' },
|
|
130
|
+
el('div', { style: LBL }, 'Needs you · only a person can close these'),
|
|
131
|
+
...(cards.length ? shown.map(card) : [el('div', { style: MUTED }, 'Nothing is waiting on you.')]),
|
|
132
|
+
cards.length > NEEDS_SHOWN ? el('button', { 'data-today': 'more', onClick: () => { needsAll = !needsAll; setState({}); },
|
|
133
|
+
style: 'text-align:left;background:transparent;border:0;padding:0;cursor:pointer;' + MUTED, hover: 'color:var(--foreground)' },
|
|
134
|
+
needsAll ? 'Show fewer' : '+ ' + (cards.length - NEEDS_SHOWN) + ' more') : null);
|
|
135
|
+
|
|
136
|
+
/* Prepared for you, then today's events */
|
|
137
|
+
const prep = (c) => el('div', { 'data-today': 'prepared', 'data-slug': c.slug, 'data-repo': c.repo, style: CARD },
|
|
138
|
+
el('b', { style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, c.slug),
|
|
139
|
+
el('div', { style: MUTED }, meaning('in-progress') || 'In progress.'),
|
|
140
|
+
el('div', { style: 'display:flex;gap:6px' }, el('button', { 'data-today': 'prepared-open', onClick: () => open(c), style: BTN, hover: 'background:var(--accent)' }, 'Open')));
|
|
141
|
+
let events;
|
|
142
|
+
if (!br) events = [el('div', { style: MUTED }, b && b.stale ? 'The brief is out of date, so no events are shown.' : 'No calendar was read. Events come from the morning brief.')];
|
|
143
|
+
else if (br.sources && br.sources.calendar !== 'read') events = [el('div', { style: MUTED }, 'The brief did not read the calendar, so this says nothing about your day.')];
|
|
144
|
+
else if (!(br.calendar || []).length) events = [el('div', { style: MUTED }, 'No events today.')];
|
|
145
|
+
else events = [el('div', { style: 'display:flex;flex-direction:column;gap:6px' }, ...br.calendar.map((e) => el('div', { 'data-today': 'event', style: 'display:flex;gap:10px' },
|
|
146
|
+
el('span', { style: 'font:12px var(--mono);' + MUTED }, new Date(e.start).toTimeString().slice(0, 5)), e.title + (e.with ? ' · ' + e.with : ''))))];
|
|
147
|
+
const side = el('div', { style: 'flex:0 1 360px;min-width:260px;display:flex;flex-direction:column;gap:10px' },
|
|
148
|
+
el('div', { 'data-today': 'prepared', style: 'display:flex;flex-direction:column;gap:10px' },
|
|
149
|
+
el('div', { style: LBL }, 'Prepared for you · in progress'),
|
|
150
|
+
...(prepared.length ? prepared.map(prep) : [el('div', { style: MUTED }, 'Nothing is being prepared.')])),
|
|
151
|
+
el('div', { 'data-today': 'events', style: 'display:flex;flex-direction:column;gap:10px;margin-top:6px' },
|
|
152
|
+
el('div', { style: LBL }, "Today's events"), ...events));
|
|
153
|
+
|
|
154
|
+
const board = el('div', { 'data-today': 'board', 'data-board': 'today', style: 'display:flex;flex-direction:column;gap:16px;font-size:13px;line-height:1.45' },
|
|
155
|
+
header, sources,
|
|
156
|
+
el('div', { 'data-board-part': 'columns', style: 'display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start;min-height:0' }, needsCol, side));
|
|
157
|
+
|
|
158
|
+
/* the live list: the proactive engine, the brief, background prepare, learning and settings */
|
|
159
|
+
const host = el('div', { 'data-today': 'engine', style: 'display:flex;flex-direction:column;gap:2px;border:1px solid var(--border);border-radius:10px;background:var(--background);padding:4px 0' });
|
|
160
|
+
ctx.renderMode(host);
|
|
161
|
+
if (!host.firstChild) host.appendChild(el('div', { style: MUTED + ';padding:8px 14px;font-size:12px' }, ctx.ready ? 'This list has nothing to show.' : 'Reading the list …'));
|
|
162
|
+
const lower = el('div', { 'data-board-part': 'engine', style: 'display:flex;flex-direction:column;gap:10px;padding-top:16px;margin-top:6px;border-top:1px solid var(--border)' },
|
|
163
|
+
el('div', { style: LBL }, 'From the records · scanned on this computer'), host);
|
|
164
|
+
|
|
165
|
+
inner.style.gap = '16px';
|
|
166
|
+
inner.appendChild(board);
|
|
167
|
+
inner.appendChild(lower);
|
|
168
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* pm/view-validate.js — the Validate view, drawn from planning/design/cockpit-mockup/ViewValidate.dc.html.
|
|
2
|
+
*
|
|
3
|
+
* PRD workspace-cockpit-nav, Run 3 (R4). The board is a header with two actions on the right, a row of five stat cards, a
|
|
4
|
+
* table of findings and a closing note. The content is the view's existing list (modes-live.js `validateList`): this module
|
|
5
|
+
* draws it into a host and arranges its parts into the board. No node is removed and no text is changed, so every `data-r19`,
|
|
6
|
+
* `data-r22` and `data-r24` hook and every live behaviour stays as it was. Only the frame is new.
|
|
7
|
+
*
|
|
8
|
+
* Where each part comes from:
|
|
9
|
+
* - The two header actions are the list's own buttons, moved: "check now" (`data-r22="check"`, the local consistency check)
|
|
10
|
+
* and "show what would be sent" (`data-r19="plan"`, the first step of a TypeSafe run). Their labels are kept because the
|
|
11
|
+
* verdict line and the guide page name them.
|
|
12
|
+
* - The stat cards count the findings of the last TypeSafe run by rule (`broken-link`, `stale-after-passed`,
|
|
13
|
+
* `workflow-sweep`, `withheld-by-secret-scan`), read off the rows the list drew, so a card and the table always agree.
|
|
14
|
+
* The consent card reads `data-r19="consent-state"`. When no run exists, or the read has not answered, a card says so.
|
|
15
|
+
* - The table is the run's finding rows (`data-r19="finding"`), restyled as a grid. The board's "Status" column has no
|
|
16
|
+
* backing (a finding has no open or closed state), so the table shows the finding's severity instead.
|
|
17
|
+
* - The local consistency check and the TypeSafe settings have no place on the board. They are kept below the table as
|
|
18
|
+
* two cards, unchanged inside.
|
|
19
|
+
* In the fixture the list is empty, and the board says that validation is not read there.
|
|
20
|
+
*/
|
|
21
|
+
import { el } from './dom.js';
|
|
22
|
+
|
|
23
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
24
|
+
const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
25
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;display:inline-block';
|
|
26
|
+
const TONE = {
|
|
27
|
+
s: ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
28
|
+
w: ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
29
|
+
d: ';border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
|
|
30
|
+
};
|
|
31
|
+
const STAT = 'flex:1 1 150px;min-width:0;border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px';
|
|
32
|
+
const TH = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground);text-align:left';
|
|
33
|
+
const COLS = 'display:grid;grid-template-columns:minmax(0,2.4fr) minmax(0,1fr) 92px minmax(0,1.3fr);gap:0 14px;align-items:center';
|
|
34
|
+
const CARD = 'border:1px solid var(--border);border-radius:10px;background:var(--background);padding:4px 0 8px;overflow:hidden';
|
|
35
|
+
const CARD_HEAD = 'padding:12px 14px 2px;font:600 15px var(--sans);color:var(--foreground)';
|
|
36
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
37
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
|
|
38
|
+
|
|
39
|
+
const badge = (text, tone) => el('span', { style: BADGE + (tone ? TONE[tone] : '') }, String(text));
|
|
40
|
+
|
|
41
|
+
/** The board's stat cards. `kind` is what the card counts; the counts come from the rows the list drew. */
|
|
42
|
+
const STATS = [
|
|
43
|
+
{ key: 'broken', label: 'Broken links', rule: 'broken-link', tone: 'var(--destructive)' },
|
|
44
|
+
{ key: 'stale', label: 'Stale documents', rule: 'stale-after-passed', tone: 'var(--warning)' },
|
|
45
|
+
{ key: 'workflow', label: 'Workflow defects', rule: 'workflow-sweep', tone: 'var(--warning)' },
|
|
46
|
+
{ key: 'withheld', label: 'Withheld (credential shape)', rule: 'withheld-by-secret-scan', tone: null },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** What the list says about the last run: 'run' (the coverage line is drawn), 'none' (no run), or 'unread'. */
|
|
50
|
+
function runState(wrap) {
|
|
51
|
+
if (!wrap) return 'unread';
|
|
52
|
+
if (wrap.querySelector('[data-r19="coverage"]')) return 'run';
|
|
53
|
+
if (wrap.querySelector('[data-r19="no-run"]')) return 'none';
|
|
54
|
+
return 'unread';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stats(wrap) {
|
|
58
|
+
const run = runState(wrap);
|
|
59
|
+
const rows = wrap ? [...wrap.querySelectorAll('[data-r19="finding"]')] : [];
|
|
60
|
+
const cards = STATS.map((s) => {
|
|
61
|
+
const n = rows.filter((r) => r.getAttribute('data-rule') === s.rule).length;
|
|
62
|
+
const value = run === 'run'
|
|
63
|
+
? el('div', { 'data-board-value': String(n), style: 'font:600 24px var(--sans);line-height:1.3;color:' + (n > 0 && s.tone ? s.tone : 'var(--foreground)') }, String(n))
|
|
64
|
+
: el('div', { 'data-board-value': run, style: 'margin-top:6px' }, badge(run === 'none' ? 'no run yet' : 'not read'));
|
|
65
|
+
return el('div', { 'data-board-stat': s.key, style: STAT }, el('div', { style: 'color:var(--muted-foreground)' }, s.label), value);
|
|
66
|
+
});
|
|
67
|
+
const cs = wrap ? wrap.querySelector('[data-r19="consent-state"]') : null;
|
|
68
|
+
const txt = cs ? cs.textContent || '' : '';
|
|
69
|
+
const consent = !cs ? badge('not read') : /\bON\b/.test(txt) ? badge('given', 's') : badge('not given', 'w');
|
|
70
|
+
cards.push(el('div', { 'data-board-stat': 'consent', 'data-board-value': !cs ? 'unread' : /\bON\b/.test(txt) ? 'given' : 'not-given', style: STAT },
|
|
71
|
+
el('div', { style: 'color:var(--muted-foreground)' }, 'Jev consent'), el('div', { style: 'margin-top:6px' }, consent)));
|
|
72
|
+
return el('div', { 'data-board-part': 'stats', style: 'display:flex;gap:14px;flex-wrap:wrap' }, cards);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Split the list's children at its section labels. The labels are fixed text in modes-live.js. */
|
|
76
|
+
function sections(wrap) {
|
|
77
|
+
const kids = [...wrap.children];
|
|
78
|
+
const at = (re) => kids.findIndex((k) => re.test((k.textContent || '').trim()));
|
|
79
|
+
const iT = at(/^typesafe \(jev\)/i);
|
|
80
|
+
const iF = at(/^4 · findings/);
|
|
81
|
+
const end = (i, fallback) => (i < 0 ? fallback : i);
|
|
82
|
+
return {
|
|
83
|
+
consistency: kids.slice(0, end(iT, end(iF, kids.length))),
|
|
84
|
+
typesafe: iT < 0 ? [] : kids.slice(iT, end(iF, kids.length)),
|
|
85
|
+
findings: iF < 0 ? [] : kids.slice(iF),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Restyle one finding row as a table row. Only styles change: the row keeps its nodes, text, hooks and click. */
|
|
90
|
+
function asTableRow(row) {
|
|
91
|
+
row.setAttribute('style', COLS + ';width:100%;box-sizing:border-box;padding:10px 14px;text-align:left;border-bottom:1px solid var(--border);font-size:13px;color:var(--foreground);cursor:pointer;min-height:40px');
|
|
92
|
+
const [head, files] = row.children;
|
|
93
|
+
if (files) files.setAttribute('style', 'order:0;font:12px var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--foreground)');
|
|
94
|
+
if (head) {
|
|
95
|
+
head.setAttribute('style', 'display:contents');
|
|
96
|
+
const [sev, rule, meta] = head.children;
|
|
97
|
+
if (rule) rule.setAttribute('style', 'order:1;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
|
|
98
|
+
if (sev) {
|
|
99
|
+
const v = (sev.textContent || '').trim();
|
|
100
|
+
const tone = v === 'high' ? TONE.d : v === 'medium' ? TONE.w : '';
|
|
101
|
+
sev.setAttribute('style', 'order:2;justify-self:start;' + BADGE + tone);
|
|
102
|
+
}
|
|
103
|
+
if (meta) meta.setAttribute('style', 'order:3;font:11px var(--mono);color:var(--muted-foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
|
|
104
|
+
}
|
|
105
|
+
row.setAttribute('data-board-row', 'finding');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findingsSection(parts) {
|
|
109
|
+
const [label, ...rest] = parts;
|
|
110
|
+
if (label) label.setAttribute('style', LBL + ';padding:0 2px');
|
|
111
|
+
const rows = rest.filter((n) => n.getAttribute && n.getAttribute('data-r19') === 'finding');
|
|
112
|
+
const notes = rest.filter((n) => !rows.includes(n));
|
|
113
|
+
for (const n of notes) {
|
|
114
|
+
const tone = /color:var\(--destructive\)/.test(n.getAttribute('style') || '') ? ';color:var(--destructive)' : '';
|
|
115
|
+
n.setAttribute('style', NOTE + tone + ';padding:0 2px');
|
|
116
|
+
}
|
|
117
|
+
rows.forEach(asTableRow);
|
|
118
|
+
const table = rows.length
|
|
119
|
+
? el('div', { 'data-board-part': 'table', style: 'border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--background)' },
|
|
120
|
+
el('div', { 'data-board-part': 'table-head', style: COLS + ';padding:10px 14px;border-bottom:1px solid var(--border)' },
|
|
121
|
+
el('span', { style: TH }, 'File and finding'), el('span', { style: TH }, 'Rule'), el('span', { style: TH }, 'Severity'), el('span', { style: TH }, 'Checked by')),
|
|
122
|
+
rows)
|
|
123
|
+
: null;
|
|
124
|
+
return el('div', { 'data-board-section': 'findings', style: 'display:flex;flex-direction:column;gap:8px' }, label || null, notes, table);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function card(key, title, parts) {
|
|
128
|
+
if (!parts.length) return null;
|
|
129
|
+
return el('div', { 'data-board-section': key, style: CARD }, el('div', { style: CARD_HEAD }, title), parts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderBoard(inner, ctx) {
|
|
133
|
+
const { FIXTURE, state } = ctx;
|
|
134
|
+
const repo = state.product || null;
|
|
135
|
+
|
|
136
|
+
const toolbar = el('div', { 'data-board-part': 'toolbar', style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center' });
|
|
137
|
+
const header = el('div', { 'data-board-part': 'header', style: 'display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap' },
|
|
138
|
+
el('div', null,
|
|
139
|
+
el('div', { 'data-board-title': '', style: 'font:600 22px var(--sans);line-height:1.45' }, 'Validate documents'),
|
|
140
|
+
el('div', { 'data-board-subtitle': '', style: 'color:var(--muted-foreground)' },
|
|
141
|
+
'Local checks first. Nothing is sent anywhere until you consent for ' + (repo ? repo : 'this repository') + '.')),
|
|
142
|
+
toolbar);
|
|
143
|
+
|
|
144
|
+
const host = el('div', { 'data-board-section': 'list', style: 'display:flex;flex-direction:column;gap:14px' });
|
|
145
|
+
ctx.renderMode(host);
|
|
146
|
+
const wrap = host.querySelector('[data-r19="validate"]');
|
|
147
|
+
|
|
148
|
+
let statRow = null;
|
|
149
|
+
if (FIXTURE) {
|
|
150
|
+
statRow = el('div', { 'data-board-part': 'stats', style: NOTE }, 'Validation is not read in the fixture.');
|
|
151
|
+
} else if (wrap) {
|
|
152
|
+
statRow = stats(wrap);
|
|
153
|
+
// the two header actions are the list's own buttons, moved, so their hooks and presses stay as they were
|
|
154
|
+
const check = wrap.querySelector('[data-r22="check"]');
|
|
155
|
+
const plan = wrap.querySelector('[data-r19="plan"]');
|
|
156
|
+
if (check) { check.setAttribute('style', BTN); toolbar.appendChild(check); }
|
|
157
|
+
if (plan) { plan.setAttribute('style', BTNP); toolbar.appendChild(plan); }
|
|
158
|
+
const s = sections(wrap);
|
|
159
|
+
wrap.setAttribute('style', 'display:flex;flex-direction:column;gap:14px;padding:0');
|
|
160
|
+
const findings = s.findings.length ? findingsSection(s.findings) : null;
|
|
161
|
+
const foot = el('div', { 'data-board-part': 'note', style: NOTE }, 'Findings never edit a file. You decide what to fix.');
|
|
162
|
+
const cons = card('consistency', 'Consistency of this repository', s.consistency);
|
|
163
|
+
const ts = card('typesafe', 'TypeSafe (Jev)', s.typesafe);
|
|
164
|
+
// the consistency card keeps its own label, which the card head now says
|
|
165
|
+
if (s.consistency[0] && /^consistency of this repository/.test((s.consistency[0].textContent || '').trim())) s.consistency[0].style.display = 'none';
|
|
166
|
+
for (const n of [findings, foot, cons, ts]) if (n) wrap.appendChild(n);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
inner.appendChild(el('div', { 'data-board': 'validate', style: 'display:flex;flex-direction:column;gap:14px;padding:4px 6px;font-size:13px;line-height:1.45;color:var(--foreground)' },
|
|
170
|
+
header, statRow, host));
|
|
171
|
+
}
|