create-pmos 0.46.0 → 0.48.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 (83) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +13 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  12. package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
  13. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  14. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  15. package/payload/planning/evals/eval-calibration.md +1 -1
  16. package/payload/planning/okrs/health-budgets.md +1 -1
  17. package/payload/scripts/pmos-log.sh +18 -8
  18. package/payload/scripts/pmos-model.js +233 -2
  19. package/payload/scripts/status-facts.js +204 -0
  20. package/payload/scripts/workflow-state.js +52 -0
  21. package/payload/skills/SKILL-FORMAT.md +1 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/workspace/README.md +3 -3
  24. package/workspace/bin/live-notes.js +133 -0
  25. package/workspace/design-parity.js +328 -21
  26. package/workspace/doc-category-bench.js +165 -0
  27. package/workspace/fixtures/doc-category-labelled.json +167 -0
  28. package/workspace/guide/initiatives-and-next-steps.md +16 -2
  29. package/workspace/guide/knowledge-graph.md +89 -2
  30. package/workspace/guide/layout.md +30 -2
  31. package/workspace/guide/proactive-engine.md +26 -4
  32. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  33. package/workspace/guide/statuses.md +1 -0
  34. package/workspace/guide/validate-with-typesafe.md +17 -1
  35. package/workspace/lib/claims.js +51 -2
  36. package/workspace/lib/doc-category-corrections.js +113 -0
  37. package/workspace/lib/doc-category-writeback.js +110 -0
  38. package/workspace/lib/doc-category.js +225 -0
  39. package/workspace/lib/engine/pmos-model.js +233 -2
  40. package/workspace/lib/engine/status-facts.js +204 -0
  41. package/workspace/lib/engine/workflow-state.js +52 -0
  42. package/workspace/lib/jev-categorize.js +399 -0
  43. package/workspace/lib/jev-claims.js +59 -15
  44. package/workspace/lib/jev-route.js +129 -0
  45. package/workspace/lib/kg-cli.js +5 -2
  46. package/workspace/lib/kg-dangling-baseline.json +1 -0
  47. package/workspace/lib/kg-dangling-rules.json +1 -0
  48. package/workspace/lib/kg.js +26 -9
  49. package/workspace/lib/learn.js +282 -0
  50. package/workspace/lib/live-notes.js +890 -0
  51. package/workspace/lib/proactive-cli.js +49 -1
  52. package/workspace/lib/proactive.js +8 -7
  53. package/workspace/lib/typesafe-settings.js +10 -2
  54. package/workspace/package-lock.json +1 -1
  55. package/workspace/package.json +5 -4
  56. package/workspace/public/pm/app.js +148 -79
  57. package/workspace/public/pm/data.js +58 -0
  58. package/workspace/public/pm/fixture.js +40 -2
  59. package/workspace/public/pm/modes-live.js +267 -21
  60. package/workspace/public/pm/panel-cockpit.js +636 -0
  61. package/workspace/public/pm/panel-detail.js +210 -8
  62. package/workspace/public/pm/panel-initiatives.js +9 -9
  63. package/workspace/public/pm/panel-newsess.js +145 -86
  64. package/workspace/public/pm/panel-repos.js +34 -17
  65. package/workspace/public/pm/panel-tabs.js +145 -27
  66. package/workspace/public/pm/state.js +45 -4
  67. package/workspace/public/pm/tab-doc.js +244 -16
  68. package/workspace/public/pm/tab-term.js +11 -1
  69. package/workspace/public/pm/tokens.css +4 -3
  70. package/workspace/public/pm/view-addrepo.js +207 -0
  71. package/workspace/public/pm/view-files.js +197 -0
  72. package/workspace/public/pm/view-guide.js +141 -0
  73. package/workspace/public/pm/view-knowledge.js +225 -0
  74. package/workspace/public/pm/view-memory.js +122 -0
  75. package/workspace/public/pm/view-portfolio.js +153 -0
  76. package/workspace/public/pm/view-reports.js +190 -0
  77. package/workspace/public/pm/view-scheduled.js +161 -0
  78. package/workspace/public/pm/view-skills.js +216 -0
  79. package/workspace/public/pm/view-today.js +168 -0
  80. package/workspace/public/pm/view-validate.js +171 -0
  81. package/workspace/server.js +394 -10
  82. package/workspace/skills/live-notes.skill +58 -0
  83. package/workspace/skills/morning-brief.skill +1 -0
@@ -7,7 +7,10 @@
7
7
  * `view` renders the markdown — EXCEPT for an HTML file, which is a page, not prose. Handing one to
8
8
  * the markdown parser prints its own markup back at the PM: the document read as its source code.
9
9
  * So an `.html` document renders, in `view`, as the page it is; `raw` is the one click that still
10
- * shows the code. See `htmlFrame` for why rendering it is not a new privilege.
10
+ * shows the code. See `htmlFrame` for why rendering it is not a new privilege — and for the one
11
+ * privilege that IS grantable, per document and only by the PM: `run scripts` lets a page execute
12
+ * its own inline code in a frame with no origin and no network, which is what an interactive
13
+ * explainer companion needs and all it needs.
11
14
  * The diff is COMPUTED, never narrated — a "2 sections differ" that nothing read is the watermelon
12
15
  * this repo has a word for, and the same rule that governs artifact sync governs this pane.
13
16
  *
@@ -160,6 +163,7 @@ function save(tab, text) {
160
163
  if (out && out.ok === false) { saveErr.set(path, out.error || 'refused'); setState({}); return; }
161
164
  const final = out && typeof out.text === 'string' ? out.text : text;
162
165
  disk.set(path, final);
166
+ claims.delete(wsOf(tab)); // the consistency record may move with the file; read it again
163
167
  commit(final);
164
168
  },
165
169
  (e) => { saveErr.set(path, (e && e.message) || 'could not reach the workspace server'); setState({}); },
@@ -293,11 +297,26 @@ function rendersAsPage(tab) {
293
297
  * CSP is `default-src 'none'`, which blocks every network fetch the page could attempt. A poisoned
294
298
  * explainer therefore can neither execute nor beacon out, which is the same no-remote-fetch-on-view
295
299
  * invariant the markdown image rule holds (R4). Only inline styles, data: images and data: fonts
296
- * resolve — enough for a page to look like itself, not enough to reach anything. */
297
- const HTML_CSP = '<meta http-equiv="Content-Security-Policy" content="'
298
- + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:" + '">';
299
-
300
- const frames = new Map(); // tab id -> { text, node } — the frame currently showing that text
300
+ * resolve — enough for a page to look like itself, not enough to reach anything.
301
+ *
302
+ * ── …and why one privilege is nevertheless grantable, per document ──────────────────────────────
303
+ * That default left the surface short of what it is for: PMOS's own build explainers ship as
304
+ * INTERACTIVE HTML companions, so the one document class that most needs this reader was the class
305
+ * it could not run. `run scripts` grants exactly one thing and grants it per open document, on an
306
+ * act the PM performed: `allow-scripts` on the sandbox, `script-src 'unsafe-inline'` added to the
307
+ * same CSP. Read what is NOT in that list — `allow-same-origin` is absent, so the frame keeps an
308
+ * opaque origin and can never reach this app's document, storage or cookies; `default-src 'none'`
309
+ * is untouched, so `connect-src` is still `none` and fetch/XHR/WebSocket are refused; `script-src`
310
+ * names no host, so a remote `<script src>` is refused too; and forms, popups, top-level navigation
311
+ * and modals were never granted at all. The whole delta is: the page may run its own inline code,
312
+ * in a box with no origin and no network. Everything else the empty sandbox withheld stays withheld,
313
+ * and a page that needs a CDN does not work here by design. */
314
+ const CSP_BASE = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:";
315
+ const htmlCsp = (live) => '<meta http-equiv="Content-Security-Policy" content="'
316
+ + CSP_BASE + (live ? "; script-src 'unsafe-inline'" : '') + '">';
317
+
318
+ const running = new Set(); // tab ids the PM has granted script execution to, this window only
319
+ const frames = new Map(); // tab id -> { text, live, node } — the frame currently showing that
301
320
 
302
321
  /* THE FRAME IS NEVER RE-POINTED, IT IS REPLACED. Writing a new `srcdoc` onto a frame that is
303
322
  * already in the document does navigate it — but the replacement document does NOT get the meta
@@ -309,18 +328,24 @@ const frames = new Map(); // tab id -> { text, node } — the frame currentl
309
328
  * The cache is therefore keyed on the TEXT, not just the tab: a repaint that changed nothing hands
310
329
  * back the identical node — which is what `setChildren` needs to leave a live frame alone — while a
311
330
  * changed document gets a frame built from scratch. The cost is that saving an open HTML file
312
- * returns it to the top; the alternative is a page that renders contained only until it is edited. */
313
- function htmlFrame(tab, text) {
331
+ * returns it to the top; the alternative is a page that renders contained only until it is edited.
332
+ *
333
+ * The grant is keyed the same way and for the same reason. `sandbox` and the CSP are read when the
334
+ * frame loads its document, so re-writing them on a frame already showing a page changes an
335
+ * attribute and nothing else — the running document keeps whatever it was born with. Granting or
336
+ * revoking therefore builds a NEW frame too, which is also the honest behaviour: `stop` really
337
+ * stops, because the executing document is discarded rather than asked to behave. */
338
+ function htmlFrame(tab, text, live) {
314
339
  const cur = frames.get(tab.id);
315
- if (cur && cur.text === text) return cur.node;
340
+ if (cur && cur.text === text && cur.live === live) return cur.node;
316
341
  const node = el('iframe', {
317
342
  style: 'flex:1;min-height:0;width:100%;border:0;background:var(--background)',
318
- sandbox: '', // no allow-scripts, no allow-same-origin
343
+ sandbox: live ? 'allow-scripts' : '', // never allow-same-origin — that pair dissolves the box
319
344
  referrerpolicy: 'no-referrer',
320
345
  title: String(tab.path || 'document'),
321
- srcdoc: HTML_CSP + text, // set HERE, on a node not yet in the document — see above
346
+ srcdoc: htmlCsp(live) + text, // set HERE, on a node not yet in the document — see above
322
347
  });
323
- frames.set(tab.id, { text, node });
348
+ frames.set(tab.id, { text, live, node });
324
349
  return node;
325
350
  }
326
351
 
@@ -365,6 +390,170 @@ export function disposeDoc(id) {
365
390
  normNote.delete(id);
366
391
  frames.delete(id);
367
392
  scrollers.delete(id);
393
+ running.delete(id); // the grant belonged to this open document, and it is closed
394
+ alignOpen.delete(id);
395
+ alignSkip.delete(id);
396
+ alignNodes.delete(id);
397
+ }
398
+
399
+
400
+ /* ── the Alignment drawer (workspace-cockpit-nav Run 3, ViewAlignment.dc.html) ──────────────────
401
+ *
402
+ * The board shows a drawer beside a document: the other documents with a CONCRETE tie to this one,
403
+ * and what to do about each. Everything in it here is read from the consistency record the Validate
404
+ * view already reads (`/api/claims`): which files a document is compared with, and which findings
405
+ * name a source. Nothing is guessed from similarity, and nothing is drafted:
406
+ * · a tie is drawn only from `compared_with` and a finding's `source` — never from wording;
407
+ * · the board's "Apply" (a proposed edit to the other file, with its diff) has no backing — PMOS
408
+ * drafts no edit to another file — so it is not drawn, and the drawer says so;
409
+ * · a file the last check did not read is said to be unread, not shown as clean.
410
+ * The drawer is closed by default, and closed it adds nothing to the pane: the pane is exactly
411
+ * [mode bar, body] as before. Open, it is a third child laid over the right of the pane, and the
412
+ * mode bar and body give it room with a margin. No node is re-parented, so a textarea keeps its
413
+ * caret, a scroller its offset and a sandboxed frame its document. */
414
+ const alignOpen = new Set(); // tab ids whose drawer is open
415
+ const alignSkip = new Map(); // tab id -> Set of rels the PM skipped for this tab
416
+ const claims = new Map(); // ws -> { state: 'loading' | 'ok' | 'error', data?, error? }
417
+ const ALIGN_W = 'min(420px, 50%)';
418
+ const alignNodes = new Map(); // tab id -> the drawer element, kept so its scroll survives a repaint
419
+
420
+ function readClaims(ws) {
421
+ let c = claims.get(ws);
422
+ if (c) return c;
423
+ c = { state: 'loading' };
424
+ claims.set(ws, c);
425
+ fetch('/api/claims?ws=' + encodeURIComponent(ws))
426
+ .then((r) => r.json())
427
+ .then((d) => { claims.set(ws, d && d.ok !== false ? { state: 'ok', data: d } : { state: 'error', error: (d && d.error) || 'refused' }); setState({}); },
428
+ (e) => { claims.set(ws, { state: 'error', error: (e && e.message) || 'could not reach the workspace server' }); setState({}); });
429
+ return c;
430
+ }
431
+
432
+ /** Whether a tab can have the drawer at all: a file in the checked-out tree, in real mode. */
433
+ const canAlign = (tab) => !FIXTURE && !tab.guide && !tab.ref && !isReference(tab.path);
434
+
435
+ const A_LBL = 'font-family:var(--mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
436
+ const A_NOTE = 'font-size:12px;line-height:1.5;color:var(--muted-foreground)';
437
+ const A_BADGE = 'font-family:var(--sans);font-size:11px;font-weight:500;border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;color:var(--foreground)';
438
+ const A_CARD = 'border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
439
+ const A_BTN = 'font-family:var(--sans);font-size:12.5px;font-weight:500;padding:0 12px;min-height:30px;border-radius:8px;background:transparent;border:1px solid var(--input);color:var(--foreground)';
440
+ const STATUS_TONE = { contradicted: 'var(--destructive)', stale: 'var(--warning)', suspected: 'var(--muted-foreground)', undecided: 'var(--muted-foreground)' };
441
+ const STATUS_WORD = { contradicted: 'contradicted', stale: 'stale', suspected: 'suspected by Jev', undecided: 'worth a look' };
442
+
443
+ const statusBadge = (st) => el('span', {
444
+ style: A_BADGE + ';border-color:transparent;color:' + (STATUS_TONE[st] || 'var(--muted-foreground)')
445
+ + ';background:color-mix(in oklab, ' + (STATUS_TONE[st] || 'var(--muted-foreground)') + ' 14%, transparent)',
446
+ }, iv(STATUS_WORD[st] || st));
447
+
448
+ /**
449
+ * The documents tied to `rel`, each with the ties that put it there. Three kinds, all read:
450
+ * · this file is compared with it (this file's `compared_with`)
451
+ * · it is compared with this file (its `compared_with` names this file)
452
+ * · a finding in it names this file (a finding whose `source` is this file)
453
+ */
454
+ function tiesFor(rel, docs) {
455
+ const out = new Map();
456
+ const add = (r, tie, finding) => {
457
+ if (!r || r === rel) return;
458
+ let c = out.get(r);
459
+ if (!c) { c = { rel: r, ties: new Set(), findings: [] }; out.set(r, c); }
460
+ c.ties.add(tie);
461
+ if (finding) c.findings.push(finding);
462
+ };
463
+ const self = docs.find((x) => x.rel === rel) || null;
464
+ if (self) for (const r of self.compared_with || []) add(r, 'this file checks against it');
465
+ for (const x of docs) {
466
+ if (x.rel === rel) continue;
467
+ if ((x.compared_with || []).includes(rel)) add(x.rel, 'checks against this file');
468
+ for (const f of x.findings || []) if (f && f.source === rel) add(x.rel, 'a finding names this file', f);
469
+ }
470
+ return { self, list: [...out.values()] };
471
+ }
472
+
473
+ function alignDrawer(tab) {
474
+ const ws = wsOf(tab);
475
+ const rel = String(tab.path || '');
476
+ const close = () => { alignOpen.delete(tab.id); setState({}); };
477
+ const kids = [
478
+ el('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px' },
479
+ el('span', { style: 'font-weight:600;font-size:15px;font-family:var(--sans)', 'data-r29': 'align-title' }, 'Alignment'),
480
+ el('button', { onClick: close, 'data-r29': 'align-close', 'aria-label': 'close alignment', title: 'close the drawer',
481
+ style: 'background:transparent;border:0;color:var(--muted-foreground);font-size:15px;padding:2px 6px', hover: 'color:var(--foreground)' }, '⟩')),
482
+ el('div', { style: A_NOTE, 'data-r29': 'align-rule' }, 'Only documents with a concrete tie to this file are listed. No similarity guesses.'),
483
+ ];
484
+ const c = readClaims(ws);
485
+ if (c.state === 'loading') {
486
+ kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': 'loading' }, 'Reading the consistency record…'));
487
+ } else if (c.state === 'error') {
488
+ kids.push(el('div', { style: A_NOTE + ';color:var(--destructive)', 'data-r29': 'align-status', 'data-state': 'error' },
489
+ iv('The consistency record could not be read: ' + c.error + '. Nothing is listed.')));
490
+ } else {
491
+ const d = c.data || {};
492
+ const docs = Array.isArray(d.docs) ? d.docs : [];
493
+ const openValidate = () => import('./panel-cockpit.js').then((m) => m.openView('validate')).catch(() => {});
494
+ if (d.verdict === 'not-checked' || !docs.length) {
495
+ kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': 'not-checked' },
496
+ 'This repository has not been checked yet, so no tie is known. Open Validate and press “check now”.'));
497
+ kids.push(el('div', null, el('button', { style: A_BTN, hover: 'border-color:var(--ring)', onClick: openValidate, 'data-r29': 'align-validate' }, 'open Validate')));
498
+ } else {
499
+ const { self, list } = tiesFor(rel, docs);
500
+ const skipped = alignSkip.get(tab.id) || new Set();
501
+ const shown = list.filter((x) => !skipped.has(x.rel));
502
+ kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': self ? 'read' : 'unread' },
503
+ iv(self
504
+ ? 'This file: ' + (self.classified ? self.type : 'not classified') + ' · ' + self.verified + ' statement(s) verified'
505
+ + (self.findings.length ? ' · ' + self.findings.length + ' finding(s)' : ' · no open findings')
506
+ : 'The last check did not read this file, so its own findings are unknown. Ties from other files are still listed.')));
507
+ if (self && self.findings.length) {
508
+ kids.push(el('div', { style: A_LBL }, 'findings in this file'));
509
+ kids.push(el('div', { style: 'display:flex;flex-direction:column;gap:6px', 'data-r29': 'align-self' },
510
+ self.findings.map((f) => el('div', { 'data-r29': 'align-finding', 'data-status': f.status, style: 'display:flex;flex-direction:column;gap:3px' },
511
+ el('span', null, statusBadge(f.status)),
512
+ el('span', { style: 'font-size:12px;line-height:1.5;color:var(--foreground)' }, iv(f.sentence))))));
513
+ }
514
+ if (state.savedDocs[docKey(tab)] != null && list.length) {
515
+ kids.push(el('div', { style: A_NOTE + ';color:var(--warning)', 'data-r29': 'align-saved' },
516
+ iv('Saved. ' + list.length + ' other document' + (list.length === 1 ? '' : 's') + ' may now disagree with this change.')));
517
+ }
518
+ kids.push(el('div', { style: A_LBL }, iv('tied documents · ' + list.length)));
519
+ if (!list.length) kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-none' }, 'No other document has a concrete tie to this file.'));
520
+ for (const x of shown) {
521
+ kids.push(el('div', { style: A_CARD, 'data-r29': 'align-cand', 'data-rel': x.rel },
522
+ el('b', { style: 'font-family:var(--mono);font-size:12px;font-weight:600;overflow-wrap:anywhere' }, iv(x.rel)),
523
+ el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap' }, [...x.ties].map((t) => el('span', { style: A_BADGE, 'data-r29': 'align-tie' }, iv(t)))),
524
+ x.findings.map((f) => el('div', { style: 'display:flex;flex-direction:column;gap:3px' },
525
+ el('span', null, statusBadge(f.status)),
526
+ el('span', { style: A_NOTE }, iv(f.sentence)))),
527
+ el('div', { style: 'display:flex;gap:6px' },
528
+ el('button', { style: A_BTN, hover: 'border-color:var(--ring)', 'data-r29': 'align-open', onClick: () => openDoc(tab.slug, x.rel, 'FILE', tab.ws ? { ws: tab.ws, branch: tab.branch || null, linked: true } : undefined) }, 'Open to edit'),
529
+ el('button', { style: A_BTN, hover: 'border-color:var(--ring)', 'data-r29': 'align-skip',
530
+ onClick: () => { const s2 = new Set(skipped); s2.add(x.rel); alignSkip.set(tab.id, s2); setState({}); } }, 'Skip'))));
531
+ }
532
+ if (skipped.size) {
533
+ kids.push(el('button', { style: A_BTN + ';align-self:flex-start', hover: 'border-color:var(--ring)', 'data-r29': 'align-unskip',
534
+ onClick: () => { alignSkip.delete(tab.id); setState({}); } }, iv('show ' + skipped.size + ' skipped')));
535
+ }
536
+ }
537
+ kids.push(el('div', { style: 'display:flex;gap:6px' },
538
+ el('button', { style: A_BTN, hover: 'border-color:var(--ring)', 'data-r29': 'align-reread', onClick: () => { claims.delete(ws); setState({}); } }, 're-read')));
539
+ }
540
+ kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-foot' },
541
+ 'Ties come from the last consistency check. PMOS drafts no edit to another file: open it to change it. Git keeps the history.'));
542
+ /* One node per tab, kept like the scrollers above, so a repaint does not take the drawer's own
543
+ * scroll back to the top. */
544
+ let node = alignNodes.get(tab.id);
545
+ if (!node) { node = makeDrawerNode(); alignNodes.set(tab.id, node); }
546
+ setChildren(node, kids);
547
+ return node;
548
+ }
549
+
550
+ function makeDrawerNode() {
551
+ return el('div', {
552
+ 'data-r29': 'align-drawer', 'data-board': 'alignment',
553
+ style: 'position:absolute;top:0;right:0;bottom:0;width:' + ALIGN_W + ';box-sizing:border-box;overflow-y:auto;'
554
+ + 'border-left:1px solid var(--border);background:var(--sidebar);color:var(--foreground);padding:16px;'
555
+ + 'display:flex;flex-direction:column;gap:12px;font-family:var(--sans);font-size:13px;line-height:1.5',
556
+ });
368
557
  }
369
558
 
370
559
  /* ── mount ─────────────────────────────────────────────────────────────────────────────────── */
@@ -554,6 +743,27 @@ export function mountDoc(host, tab) {
554
743
  style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
555
744
  }, 'save') : null;
556
745
 
746
+ /* The one control that grants a privilege, and the only place the PM learns the page CAN run.
747
+ * Offered only where it means something — a document actually being rendered as a page, in the
748
+ * mode that renders it — so it never appears on markdown, on raw/edit/diff, or on a stub. Its two
749
+ * states differ in WORD, not only in colour: a page that is executing says so in as many words. */
750
+ const asPage = rendersAsPage(tab);
751
+ const live = running.has(tab.id);
752
+ const runBtn = (asPage && dm === 'view') ? el('button', {
753
+ onClick: () => {
754
+ if (live) running.delete(tab.id); else running.add(tab.id);
755
+ setState({});
756
+ },
757
+ title: live
758
+ ? 'This document is running its own scripts, in a frame with no origin and no network. Stop discards it.'
759
+ : "Run this document's own scripts. It stays sandboxed: no origin, no network, nothing loaded from anywhere.",
760
+ style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
761
+ + (live ? 'var(--secondary)' : 'transparent')
762
+ + ';color:' + (live ? 'var(--warning)' : 'var(--muted-foreground)')
763
+ + ';border:1px solid ' + (live ? 'var(--warning)' : 'var(--border)'),
764
+ hover: 'border-color:var(--ring)',
765
+ }, iv(live ? '● scripts running — stop' : 'run scripts')) : null;
766
+
557
767
  const revertBtn = dirty ? el('button', {
558
768
  onClick: () => revert(tab),
559
769
  style: 'font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)',
@@ -597,13 +807,25 @@ export function mountDoc(host, tab) {
597
807
  const modeBar = el('div', {
598
808
  style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
599
809
  },
600
- modeBtns, normBtn, saveBtn, revertBtn,
810
+ modeBtns, normBtn, saveBtn, revertBtn, runBtn,
601
811
  FIXTURE || dm !== 'view' ? null : el('button', {
602
812
  onClick: () => setState((s) => ({ docNarrow: { ...(s.docNarrow || {}), [tab.id]: !narrowOf(tab) } })),
603
813
  style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)',
604
814
  hover: 'color:var(--foreground)', 'data-r1': 'measure',
605
815
  title: narrowOf(tab) ? 'use the full width of the tab' : 'limit lines to a 72-character reading measure',
606
816
  }, iv(narrowOf(tab) ? '↔ full width' : '⇥ 72ch')),
817
+ canAlign(tab) ? el('button', {
818
+ onClick: () => {
819
+ if (alignOpen.has(tab.id)) alignOpen.delete(tab.id);
820
+ else { alignOpen.add(tab.id); claims.delete(wsOf(tab)); } // opening reads the record afresh
821
+ setState({});
822
+ },
823
+ style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
824
+ + (alignOpen.has(tab.id) ? 'var(--secondary)' : 'transparent') + ';border:1px solid '
825
+ + (alignOpen.has(tab.id) ? 'var(--brand)' : 'var(--border)') + ';color:var(--foreground)',
826
+ hover: 'border-color:var(--ring)', 'data-r29': 'align-toggle', 'data-open': alignOpen.has(tab.id) ? 'yes' : 'no',
827
+ title: 'Show the documents with a concrete tie to this one, from the last consistency check.',
828
+ }, 'alignment') : null,
607
829
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
608
830
  );
609
831
 
@@ -619,9 +841,15 @@ export function mountDoc(host, tab) {
619
841
  if (body.defaultValue !== text) body.defaultValue = text;
620
842
  if (body.value !== text) body.value = text;
621
843
  } else if (!FIXTURE && (extOfPath(tab.path) === 'csv' || extOfPath(tab.path) === 'tsv')) body = gridBody(tab, text);
622
- else body = rendersAsPage(tab) ? htmlFrame(tab, text) : (nonMd && !unread.has(docKey(tab)) ? sourceBody(tab, text) : viewBody(tab, text));
623
-
624
- setChildren(host, [modeBar, body]);
844
+ else body = asPage ? htmlFrame(tab, text, live) : (nonMd && !unread.has(docKey(tab)) ? sourceBody(tab, text) : viewBody(tab, text));
845
+
846
+ /* The drawer, when open, lies over the right of the pane; the bar and the body give it room. The
847
+ * margin is written on every paint so a closed drawer leaves no gap behind on a kept node. */
848
+ const drawerOn = canAlign(tab) && alignOpen.has(tab.id);
849
+ host.style.position = drawerOn ? 'relative' : '';
850
+ modeBar.style.marginRight = drawerOn ? ALIGN_W : '';
851
+ body.style.marginRight = drawerOn ? ALIGN_W : '';
852
+ setChildren(host, drawerOn ? [modeBar, body, alignDrawer(tab)] : [modeBar, body]);
625
853
  return host;
626
854
  }
627
855
 
@@ -913,8 +913,18 @@ export function disposeTerm(tab) {
913
913
  send({ t: 'detach', id: rec.sessionId });
914
914
  SOCK.attached.delete(rec.sessionId);
915
915
  }
916
- try { rec.term.dispose(); } catch { /* already gone */ }
917
916
  if (rec.host && rec.host.parentNode) rec.host.parentNode.removeChild(rec.host);
917
+ /* DISPOSE ONE FRAME LATER, NOT NOW. `attached` calls `term.reset()`, and xterm 5's Viewport.reset()
918
+ * queues a requestAnimationFrame that reads the renderer's dimensions — a callback dispose() does
919
+ * not cancel. A tab closed inside that frame (a fast close, or a `tabs` replace that drops a
920
+ * terminal whose session has just attached) disposed the renderer first, and the queued callback
921
+ * then threw "Cannot read properties of undefined (reading 'dimensions')" as an uncaught page
922
+ * error, about half the runs of pm-term-test.js. Queued after it, this dispose runs after every
923
+ * callback xterm has already queued, against a renderer that still exists. The pane is already
924
+ * out of the document and out of `panes`, so nothing can draw into it or re-attach it meanwhile. */
925
+ const term = rec.term;
926
+ const later = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 0);
927
+ later(() => { try { term.dispose(); } catch { /* already gone */ } });
918
928
  }
919
929
 
920
930
  /**
@@ -72,6 +72,7 @@ a{color:oklch(0.623 0.214 259.815)}a:hover{color:oklch(0.72 0.19 259.8)}
72
72
  button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;text-align:left;padding:0}
73
73
  input,textarea{font:inherit;color:inherit;background:none;border:0;outline:none;padding:0;resize:none}
74
74
  /* overhaul-r1 R4: a collapsed-rail glyph draws its initials and kind badge from data attributes, so
75
- the rail's text stays `repositories ⟩` and the glyph names itself through aria-label */
76
- [data-r1="rail-glyph"]::before{content:attr(data-ini)}
77
- [data-r1="rail-glyph"]::after{content:attr(data-kind);position:absolute;right:-2px;bottom:-3px;font-size:8px;line-height:1;color:var(--muted-foreground)}
75
+ the rail's text stays `repositories ⟩` and the glyph names itself through aria-label. The cockpit rail's tiles
76
+ (`data-rail`) carry the same hooks for the suites but draw their initial as text, so the glyph rule skips them. */
77
+ [data-r1="rail-glyph"]:not([data-rail])::before{content:attr(data-ini)}
78
+ [data-r1="rail-glyph"]:not([data-rail])::after{content:attr(data-kind);position:absolute;right:-2px;bottom:-3px;font-size:8px;line-height:1;color:var(--muted-foreground)}
@@ -0,0 +1,207 @@
1
+ /* pm/view-addrepo.js — the Add repository view tab, drawn from planning/design/cockpit-mockup/ViewAddRepo.dc.html.
2
+ *
3
+ * PRD workspace-cockpit-nav, Run 3 (R10, done-criterion 3). The board is a header, three steps (Choose, Review the scan,
4
+ * Confirm), the scan's source line, a review table of the proposed initiatives, a note and the actions.
5
+ *
6
+ * It drives the existing add-repository flow and adds no new one:
7
+ * - Choose is the existing form (panel-repos.js `addProductBody`, the `addprod` detail body): the source switch, the GitHub
8
+ * reference or the local folder with its chooser, the "connect + ingest" and "add product" buttons and the ingest log. Its
9
+ * nodes are placed in the board's frame as they are, so every handler and the shared state keys (addSrc, addRepo, addPath,
10
+ * addLog) behave as in the detail. A local folder is registered at once and needs no review.
11
+ * - Review the scan is the server's repo ingest: GET /api/ingest lists the open proposals the clone-and-scan produced, and
12
+ * each row is decided one at a time with POST /api/ingest/confirm (with the anchor) or POST /api/ingest/dismiss. The
13
+ * server refuses a bulk confirm by design ("the tedium is the gate"), so the board's tick boxes and "Import 2 selected"
14
+ * are drawn as one Confirm per row. A guessed anchor is written only on an explicit "Accept the guess".
15
+ * - Confirm is on when every row of the proposal has been decided.
16
+ * The board's Lane column is not drawn: the scan does not read a lane. "Dismiss scan" has no route (only per-row dismiss), so it
17
+ * is not drawn. In the fixture the form runs its scripted ingest and the review is not read.
18
+ */
19
+ import { el } from './dom.js';
20
+ import { addProductBody } from './panel-repos.js';
21
+
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;white-space:nowrap';
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;white-space:nowrap';
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';
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: ';color:var(--destructive)',
29
+ };
30
+ const TH = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground);text-align:left;padding:10px 14px;border-bottom:1px solid var(--border)';
31
+ const TD = 'padding:10px 14px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle';
32
+ const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
33
+ const FRAME = 'border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--background)';
34
+ const INPUT = 'font:12px var(--mono);padding:0 9px;height:30px;min-height:30px;border:1px solid var(--input);border-radius:8px;background:transparent;color:var(--foreground);width:150px;box-sizing:border-box';
35
+
36
+ const badge = (text, tone, attrs) => el('span', { ...(attrs || {}), style: BADGE + (tone ? TONE[tone] : '') }, String(text));
37
+
38
+ /** Open the Add repository view. Used by the rail's add tile and by anything else that offers "add a repository". */
39
+ export async function openAddRepo() {
40
+ const m = await import('./panel-cockpit.js');
41
+ if (m.VIEWS && m.VIEWS.addrepo) { m.openView('addrepo'); return true; }
42
+ const r = await import('./panel-repos.js');
43
+ r.onAddProduct();
44
+ return false;
45
+ }
46
+
47
+ /* ── the ingest proposals: one read, read again after 15 seconds, or every 2.5 seconds while a scan runs ───────── */
48
+ let ing = { state: 'idle', data: null, error: null, at: 0 };
49
+ let pick = null; // the proposal id the PM chose, when there are several
50
+ let say = null; // { tone: 'ok'|'bad', text } — what the last row action did
51
+ let lastLog = -1; // the ingest log's length at the last draw: a new line means the form did something
52
+ let tick = null;
53
+
54
+ const running = (pr) => pr && (pr.stage === 'acquire' || pr.stage === 'scan');
55
+
56
+ function proposals(ctx) {
57
+ const ttl = ing.data && (ing.data.proposals || []).some(running) ? 2500 : 15000;
58
+ if (ing.state !== 'loading' && (ing.state === 'idle' || Date.now() - ing.at > ttl)) {
59
+ ing = { ...ing, state: 'loading' };
60
+ fetch('/api/ingest').then((r) => r.json()).then(
61
+ (d) => { ing = { state: d && d.ok === false ? 'error' : 'ok', data: d, error: d && d.error, at: Date.now() }; },
62
+ (e) => { ing = { state: 'error', data: ing.data, error: (e && e.message) || String(e), at: Date.now() }; },
63
+ ).finally(() => ctx.setState({}));
64
+ }
65
+ if (!tick && ing.data && (ing.data.proposals || []).some(running)) {
66
+ tick = setTimeout(() => { tick = null; ctx.setState({}); }, 2600);
67
+ }
68
+ return ing;
69
+ }
70
+
71
+ async function act(ctx, url, body, done) {
72
+ let out;
73
+ try { out = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json()); }
74
+ catch (e) { out = { ok: false, error: (e && e.message) || String(e) }; }
75
+ say = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'the server did not answer. Nothing was written.' } : { tone: 'ok', text: done(out) };
76
+ ing = { ...ing, state: 'idle' };
77
+ ctx.setState({});
78
+ }
79
+
80
+ const confirmRow = (ctx, pr, row, anchor) => {
81
+ const a = String(anchor || '').trim();
82
+ if (!a) { say = { tone: 'bad', text: 'Type an anchor first: a KR id or health:<id>.' }; ctx.setState({}); return; }
83
+ act(ctx, '/api/ingest/confirm', { id: pr.id, item: row.id, anchor: a },
84
+ (o) => row.id + ': parent_kr: ' + a + ' written into ' + o.path + '. ' + (o.resolver_note || ''));
85
+ };
86
+ const dismissRow = (ctx, pr, row) => act(ctx, '/api/ingest/dismiss', { id: pr.id, item: row.id }, () => row.id + ' dismissed. Nothing was written.');
87
+
88
+ function sourceBadge(row) {
89
+ if (row.disposition === 'confirmed') return badge('confirmed by you', 's', { 'data-board-badge': 'confirmed' });
90
+ if (row.disposition === 'dismissed') return badge('dismissed', null, { 'data-board-badge': 'dismissed' });
91
+ if (row.anchor_state === 'declared') return badge('declared by the repository', 's', { 'data-board-badge': 'declared' });
92
+ if (row.anchor_state === 'guessed') return badge('guessed from prose, not declared', 'w', { 'data-board-badge': 'guessed' });
93
+ return badge('will be rejected (D59)', 'd', { 'data-board-badge': 'none' });
94
+ }
95
+
96
+ function anchorCell(row) {
97
+ if (row.disposition === 'confirmed') return el('span', { style: 'font:12px var(--mono)' }, String(row.anchor_value || ''));
98
+ if (row.anchor_state === 'declared') return el('span', { style: 'font:12px var(--mono)' }, String(row.anchor_value));
99
+ if (row.anchor_state === 'guessed') return el('span', { style: 'font:12px var(--mono);color:var(--warning)' }, String(row.anchor_value) + ' ?');
100
+ return el('span', { style: 'color:var(--destructive)' }, 'no anchor');
101
+ }
102
+
103
+ function actionsCell(ctx, pr, row) {
104
+ if (row.disposition && row.disposition !== 'open') return null;
105
+ const parts = [];
106
+ if (row.anchor_state === 'declared') {
107
+ parts.push(el('button', { 'data-board-action': 'confirm', style: BTNP, title: 'write the declared anchor into the record', onClick: () => confirmRow(ctx, pr, row, row.anchor_value) }, 'Confirm'));
108
+ } else {
109
+ const input = el('input', { 'data-board-input': 'anchor', placeholder: 'KR id or health:<id>', style: INPUT, 'aria-label': 'anchor for ' + row.id });
110
+ parts.push(input);
111
+ parts.push(el('button', { 'data-board-action': 'confirm', style: BTN, hover: 'background:var(--accent)', title: 'write the anchor you typed into the record', onClick: () => confirmRow(ctx, pr, row, input.value) }, 'Confirm'));
112
+ if (row.anchor_state === 'guessed') {
113
+ parts.push(el('button', { 'data-board-action': 'accept-guess', style: BTN, hover: 'background:var(--accent)', title: 'you accept the guess: it is written as the anchor', onClick: () => confirmRow(ctx, pr, row, row.anchor_value) }, 'Accept the guess'));
114
+ }
115
+ }
116
+ parts.push(el('button', { 'data-board-action': 'dismiss', style: BTN, hover: 'background:var(--accent)', title: 'leave this record out; nothing is written', onClick: () => dismissRow(ctx, pr, row) }, 'Dismiss'));
117
+ return el('div', { style: 'display:flex;gap:6px;align-items:center;justify-content:flex-end;flex-wrap:wrap' }, parts);
118
+ }
119
+
120
+ /** The review for one proposal: the source line, the table and the note. */
121
+ function review(ctx, pr) {
122
+ const rows = pr.initiatives || [];
123
+ const adrs = pr.adrs || [];
124
+ const decided = rows.filter((r) => r.disposition && r.disposition !== 'open').length;
125
+ const confirmed = rows.filter((r) => r.disposition === 'confirmed').length;
126
+ let summary;
127
+ if (running(pr)) summary = (pr.stage === 'acquire' ? 'cloning' : 'scanning') + ' …';
128
+ else if (pr.ok === false) summary = 'the scan did not finish: ' + (pr.error || 'no reason given');
129
+ else summary = 'found ' + rows.length + (rows.length === 1 ? ' initiative record' : ' initiative records') + ' and ' + adrs.length + (adrs.length === 1 ? ' decision' : ' decisions') + '. '
130
+ + (confirmed ? confirmed + ' confirmed.' : 'Nothing imported yet.');
131
+ const line = el('div', { 'data-board-part': 'source', style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap' },
132
+ badge((pr.source && pr.source.kind) || 'github', null, { 'data-board-badge': 'source' }),
133
+ el('span', { style: 'font:13px var(--mono)' }, String((pr.source && pr.source.ref) || pr.id)),
134
+ el('span', { 'data-board-summary': '', style: 'color:' + (pr.ok === false && !running(pr) ? 'var(--destructive)' : 'var(--muted-foreground)') }, summary));
135
+ const out = [line];
136
+ if (rows.length) {
137
+ out.push(el('div', { 'data-board-section': 'review', style: FRAME + ';overflow-x:auto' },
138
+ el('table', { style: 'width:100%;border-collapse:collapse' },
139
+ el('thead', null, el('tr', null, ['Initiative', 'Anchor', 'Source of the anchor', ''].map((h) => el('th', { style: TH }, h)))),
140
+ el('tbody', null, rows.map((row) => el('tr', { 'data-board-row': row.id, 'data-disposition': row.disposition || 'open', 'data-anchor-state': row.anchor_state || 'none' },
141
+ el('td', { style: TD }, el('b', null, String(row.id)), el('div', { style: 'font:11px var(--mono);color:var(--muted-foreground);overflow-wrap:anywhere' }, String(row.path || ''))),
142
+ el('td', { style: TD }, anchorCell(row)),
143
+ el('td', { style: TD }, sourceBadge(row)),
144
+ el('td', { style: TD + ';text-align:right' }, actionsCell(ctx, pr, row))))))));
145
+ out.push(el('div', { 'data-board-part': 'review-note', style: NOTE },
146
+ 'Guessed anchors are never written as declared. Accept one only if you agree with the guess, or fix it in the repository first. '
147
+ + 'Each row is confirmed on its own. ' + decided + ' of ' + rows.length + ' decided.'
148
+ + (adrs.length ? ' ' + adrs.length + (adrs.length === 1 ? ' decision' : ' decisions') + ' found in the scan.' : '')));
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export function renderBoard(inner, ctx) {
154
+ const { FIXTURE, state } = ctx;
155
+
156
+ // 1 · Choose: the existing form, as it is. Its own title is the board's header now, so it is hidden, not removed.
157
+ const formNodes = addProductBody();
158
+ if (formNodes[0]) { formNodes[0].style.display = 'none'; formNodes[0].setAttribute('data-board-source', 'form-title'); }
159
+ const form = el('div', { 'data-board-section': 'choose', 'data-addprod': 'form', style: FRAME + ';display:flex;flex-direction:column;max-width:760px' }, formNodes);
160
+
161
+ // a new line in the ingest log means the form started something: read the proposals again
162
+ const logLen = ((state && state.addLog) || []).length;
163
+ if (logLen !== lastLog) { if (lastLog !== -1) ing = { ...ing, state: ing.state === 'loading' ? 'loading' : 'idle' }; lastLog = logLen; }
164
+
165
+ // 2 · Review the scan
166
+ let list = [];
167
+ let reviewParts = [];
168
+ if (FIXTURE) {
169
+ reviewParts = [el('div', { 'data-board-empty': '', style: NOTE }, 'The scan review is not read in the fixture. The form above runs its scripted ingest.')];
170
+ } else {
171
+ const r = proposals(ctx);
172
+ list = (r.data && r.data.proposals) || [];
173
+ if (r.state === 'error' && !list.length) reviewParts = [el('div', { style: NOTE + ';color:var(--destructive)' }, 'The scans could not be read: ' + (r.error || 'error'))];
174
+ else if (!r.data) reviewParts = [el('div', { style: NOTE }, 'Reading the scans …')];
175
+ else if (!list.length) reviewParts = [el('div', { 'data-board-empty': '', style: NOTE }, 'No scan is waiting for review. Give a GitHub repository above and press connect + ingest. A local folder is added at once.')];
176
+ else {
177
+ if (!list.some((p) => p.id === pick)) pick = list[list.length - 1].id;
178
+ if (list.length > 1) {
179
+ reviewParts.push(el('div', { 'data-board-part': 'scans', style: 'display:flex;gap:6px;flex-wrap:wrap' },
180
+ list.map((p) => el('button', { 'data-board-scan': p.id, 'aria-pressed': p.id === pick ? 'true' : 'false',
181
+ style: BTN + (p.id === pick ? ';background:var(--accent);border-color:var(--brand)' : ''), onClick: () => { pick = p.id; say = null; ctx.setState({}); } },
182
+ String((p.source && p.source.ref) || p.id)))));
183
+ }
184
+ reviewParts.push(...review(ctx, list.find((p) => p.id === pick)));
185
+ }
186
+ }
187
+ if (say) reviewParts.push(el('div', { 'data-board-part': 'action-note', style: NOTE + (say.tone === 'bad' ? ';color:var(--destructive)' : '') }, say.text));
188
+
189
+ // the steps: Choose until a scan is open, Review while a row is undecided, Confirm when every row is decided
190
+ const cur = list.find((p) => p.id === pick) || null;
191
+ const rows = (cur && cur.initiatives) || [];
192
+ const step = !cur ? 1 : rows.length && rows.every((r) => r.disposition && r.disposition !== 'open') ? 3 : 2;
193
+ const stepEl = (n, label) => el('span', { 'data-board-step': String(n), 'data-on': n === step ? '1' : '0', style: 'display:flex;align-items:center;gap:8px;color:' + (n === step ? 'var(--foreground)' : 'var(--muted-foreground)') },
194
+ el('b', { style: 'display:grid;place-items:center;width:22px;height:22px;border-radius:50%;font:500 11px var(--mono);border:1px solid ' + (n === step ? 'var(--brand);background:var(--brand);color:var(--background)' : 'var(--input)') }, String(n)),
195
+ label);
196
+
197
+ const header = el('div', { 'data-board-part': 'header' },
198
+ el('div', { 'data-board-title': '', style: 'font:600 22px var(--sans);line-height:1.45' }, 'Add repository'),
199
+ el('div', { 'data-board-subtitle': '', style: 'color:var(--muted-foreground)' }, 'The scan proposes. It never decides, and it writes nothing until you confirm.'));
200
+
201
+ inner.appendChild(el('div', { 'data-board': 'addrepo', style: 'display:flex;flex-direction:column;gap:14px;padding:4px 6px;font-size:13px;line-height:1.45;color:var(--foreground)' },
202
+ header,
203
+ el('div', { 'data-board-part': 'steps', style: 'display:flex;gap:24px;flex-wrap:wrap' }, stepEl(1, 'Choose'), stepEl(2, 'Review the scan'), stepEl(3, 'Confirm')),
204
+ form,
205
+ el('div', { style: 'font-weight:600' }, 'Review the scan'),
206
+ el('div', { 'data-board-part': 'review', style: 'display:flex;flex-direction:column;gap:12px' }, reviewParts)));
207
+ }