create-pmos 0.67.1 → 0.69.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 (37) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/command-palette.md +1 -1
  7. package/workspace/guide/initiatives-screen.md +1 -1
  8. package/workspace/guide/layout.md +35 -8
  9. package/workspace/guide/notes.md +12 -1
  10. package/workspace/guide/today.md +3 -1
  11. package/workspace/lib/notes-links.js +56 -0
  12. package/workspace/lib/notes.js +87 -5
  13. package/workspace/lib/routes-notes.js +47 -6
  14. package/workspace/package-lock.json +1 -1
  15. package/workspace/package.json +1 -1
  16. package/workspace/public/pm/components.css +697 -3
  17. package/workspace/public/pm/doc-editor.js +473 -0
  18. package/workspace/public/pm/doc-rail.js +161 -0
  19. package/workspace/public/pm/doc-remote.js +180 -0
  20. package/workspace/public/pm/dock-sessions.js +57 -13
  21. package/workspace/public/pm/frame-copy.js +2 -0
  22. package/workspace/public/pm/icons.js +1 -1
  23. package/workspace/public/pm/initiative-sheet.js +10 -2
  24. package/workspace/public/pm/initiatives-copy.js +1 -0
  25. package/workspace/public/pm/notes-links.js +110 -0
  26. package/workspace/public/pm/panel-tabs.js +85 -3
  27. package/workspace/public/pm/primitives.js +8 -4
  28. package/workspace/public/pm/state.js +3 -0
  29. package/workspace/public/pm/tab-doc.js +175 -170
  30. package/workspace/public/pm/today-agenda.js +6 -1
  31. package/workspace/public/pm/today-day.js +6 -0
  32. package/workspace/public/pm/view-notes.js +113 -6
  33. package/workspace/public/vendor/lucide-sprite.js +15 -2
  34. package/workspace/replaced-tests.json +43 -0
  35. package/workspace/server.js +30 -0
  36. package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
  37. package/workspace/third-party/README.md +1 -1
@@ -25,6 +25,7 @@ export const SEL = Object.freeze({
25
25
  sprite: '.pmc-sprite',
26
26
  icon: 'svg.pmc-icon',
27
27
  btn: '.pmc-btn',
28
+ btnLabel: '.pmc-btn__label',
28
29
  primary: '.pmc-btn--primary',
29
30
  row: '.pmc-row',
30
31
  owed: '.pmc-row--owed',
@@ -256,16 +257,17 @@ function metaLine(cls, meta, signals) {
256
257
 
257
258
  /**
258
259
  * row({ title, titleIcon?, meta?: string[], signals?: Node[] (2 at most), lead?: Node[], trail?: Node[], onOpen?, openLabel?,
259
- * owed?: boolean, done?: boolean, attrs? })
260
+ * owed?: boolean, done?: boolean, compact?: boolean, attrs? })
261
+ * `compact` keeps the title and the meta line on one line each, cut with an ellipsis: for a narrow list such as the dock's.
260
262
  * One title, one meta line holding at most two signals. With `onOpen` the title and meta are one button (the row's
261
263
  * focus target); without it they are plain text.
262
264
  */
263
- export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, owed, done, proposed, attrs, openAttrs } = {}) {
265
+ export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, owed, done, proposed, compact, attrs, openAttrs } = {}) {
264
266
  const body = [titleLine('pmc-row__title', title, titleIcon), metaLine('pmc-row__meta', meta, signals)];
265
267
  const main = onOpen
266
268
  ? el('button', { type: 'button', class: 'pmc-row__open', 'aria-label': openLabel || null, onClick: onOpen, ...(openAttrs || {}) }, body)
267
269
  : el('span', { class: 'pmc-row__main' }, body);
268
- return el('div', withClass(cx('pmc-row', owed && 'pmc-row--owed', done && 'pmc-row--done', proposed && 'pmc-row--proposed'), attrs),
270
+ return el('div', withClass(cx('pmc-row', owed && 'pmc-row--owed', done && 'pmc-row--done', proposed && 'pmc-row--proposed', compact && 'pmc-row--compact'), attrs),
269
271
  lead && lead.length ? el('span', { class: 'pmc-row__lead' }, lead) : null,
270
272
  main,
271
273
  trail && trail.length ? el('span', { class: 'pmc-row__trail' }, trail) : null);
@@ -325,7 +327,8 @@ let openMenu = null;
325
327
  let lastMenuClose = { anchor: null, at: 0 };
326
328
 
327
329
  /**
328
- * menu({ anchor, items: [{ label, onSelect, danger?, sep? }], ariaLabel }): opens a popover menu under `anchor`.
330
+ * menu({ anchor, items: [{ label, onSelect, danger?, sep?, icon?, attrs? }], ariaLabel }): opens a popover menu under `anchor`.
331
+ * An item's `attrs` are data hooks for the suites (a class or a role is the menu's own).
329
332
  * Arrow keys move, Enter or Space chooses, Escape closes and returns focus to the anchor. Pressing the anchor again
330
333
  * closes it.
331
334
  */
@@ -349,6 +352,7 @@ export function menu({ anchor, items, ariaLabel } = {}) {
349
352
  for (const it of items || []) {
350
353
  if (it.sep) { node.appendChild(el('div', { class: 'pmc-menu__sep', role: 'separator' })); continue; }
351
354
  const b = el('button', {
355
+ ...(it.attrs || {}),
352
356
  type: 'button', role: 'menuitem', tabindex: '-1', class: cx('pmc-menu__item', it.danger && 'pmc-menu__item--danger'),
353
357
  onClick: (e) => { e.stopPropagation(); close(false); if (it.onSelect) it.onSelect(); },
354
358
  }, it.icon ? icon(it.icon) : null, el('span', null, it.label));
@@ -129,6 +129,7 @@ function initialBase() {
129
129
  newSessIsolate: null, newSessError: null, newSessBusy: false,
130
130
  newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
131
131
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
132
+ dsFolded: {}, // the dock's session groups the PM folded (true) or opened (false), by group key — persisted
132
133
  tabsVertical: false, // the cockpit's tabs as a vertical list instead of a strip — persisted, per browser
133
134
  /* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
134
135
  * the default; the PM can drag it, and the drag is clamped there rather than here — this store
@@ -238,6 +239,8 @@ const PERSIST = {
238
239
  scratchOpen: isBool,
239
240
  // the cockpit's tabs as a vertical list (true) or the horizontal strip (false, the default)
240
241
  tabsVertical: isBool,
242
+ // the dock's session groups, folded (true) or open (false) by the PM; a group with no entry takes its default
243
+ dsFolded: isFlagMap,
241
244
  /* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
242
245
  * hand-edited record must not be able to render a drawer with no textarea in it, or one taller
243
246
  * than any window it could be restored into. The upper bound here is deliberately generous (the
@@ -32,6 +32,12 @@
32
32
  * 4-second session poll included — took a half-read document back to the top. `scrollPane` keeps one
33
33
  * element per tab and mode; see the block comment above it.
34
34
  *
35
+ * ── In live mode, a markdown file the PM can edit is edited in place (workspace-tabs-declutter) ─────────────
36
+ * The four modes are the prototype's, and the fixture keeps them. In live mode a markdown file the PM can write is
37
+ * drawn by doc-editor.js: read and edited in one surface, block by block, in the layout it is read in. `edit` stays as
38
+ * the whole file as markdown (the Markdown toggle) and `diff` as the Changes toggle; `raw` is the Source toggle of a
39
+ * file that cannot be edited. Beside the document, doc-rail.js holds its outline and its Connections.
40
+ *
35
41
  * ── What `view` renders it through ──────────────────────────────────────────────────────────
36
42
  * `markdown.js`, not `fixture.js`'s `mdParse`. The latter is a transcription of the design
37
43
  * prototype's four-branch stand-in (`# `/`## `/`- `/paragraph) pinned byte-for-byte by
@@ -43,7 +49,10 @@ import { state, setState, FIXTURE } from './state.js';
43
49
  import * as FX from './fixture.js';
44
50
  import { mdRender } from './markdown.js';
45
51
  import { openDoc } from './panel-initiatives.js';
46
- import { button } from './primitives.js';
52
+ import { button, iconButton, menu } from './primitives.js';
53
+ import { editorNode, disposeEditor, headsOf, goToBlock, isEditing } from './doc-editor.js';
54
+ import { railNode, disposeRail, refreshGraph } from './doc-rail.js';
55
+ import { copyChip, refreshCopies } from './doc-remote.js';
47
56
 
48
57
  /** `{{ expr }}` — one interpolated value in its own span, as the prototype's runtime emits it. */
49
58
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
@@ -165,6 +174,8 @@ function save(tab, text) {
165
174
  const final = out && typeof out.text === 'string' ? out.text : text;
166
175
  disk.set(path, final);
167
176
  claims.delete(wsOf(tab)); // the consistency record may move with the file; read it again
177
+ refreshCopies(wsOf(tab)); // …and so may the copy's state, and the file's ties
178
+ refreshGraph(wsOf(tab), tab.path);
168
179
  commit(final);
169
180
  },
170
181
  (e) => { saveErr.set(path, (e && e.message) || 'could not reach the workspace server'); setState({}); },
@@ -388,35 +399,26 @@ function editBox(tab) {
388
399
  /** Release what a closed doc tab held. Called by panel-tabs.js when the tab goes away. */
389
400
  export function disposeDoc(id) {
390
401
  boxes.delete(id);
402
+ docBars.delete(id);
391
403
  normNote.delete(id);
392
404
  frames.delete(id);
393
405
  scrollers.delete(id);
394
406
  running.delete(id); // the grant belonged to this open document, and it is closed
395
- alignOpen.delete(id);
396
- alignSkip.delete(id);
397
- alignNodes.delete(id);
407
+ disposeEditor(id);
408
+ disposeRail(id);
409
+ places.delete(id);
410
+ railHidden.delete(id);
398
411
  }
399
412
 
400
413
 
401
- /* ── the Alignment drawer (workspace-cockpit-nav Run 3, ViewAlignment.dc.html) ──────────────────
414
+ /* ── the findings beside a document (was the Alignment drawer) ─────────────────────────────────────────────────────
402
415
  *
403
- * The board shows a drawer beside a document: the other documents with a CONCRETE tie to this one,
404
- * and what to do about each. Everything in it here is read from the consistency record the Validate
405
- * view already reads (`/api/claims`): which files a document is compared with, and which findings
406
- * name a source. Nothing is guessed from similarity, and nothing is drafted:
407
- * · a tie is drawn only from `compared_with` and a finding's `source` — never from wording;
408
- * · the board's "Apply" (a proposed edit to the other file, with its diff) has no backing — PMOS
409
- * drafts no edit to another file — so it is not drawn, and the drawer says so;
410
- * · a file the last check did not read is said to be unread, not shown as clean.
411
- * The drawer is closed by default, and closed it adds nothing to the pane: the pane is exactly
412
- * [mode bar, body] as before. Open, it is a third child laid over the right of the pane, and the
413
- * mode bar and body give it room with a margin. No node is re-parented, so a textarea keeps its
414
- * caret, a scroller its offset and a sandboxed frame its document. */
415
- const alignOpen = new Set(); // tab ids whose drawer is open
416
- const alignSkip = new Map(); // tab id -> Set of rels the PM skipped for this tab
416
+ * The Alignment drawer (workspace-cockpit-nav Run 3) listed the documents with a concrete tie to this one, from the
417
+ * consistency record the Validate view reads (`/api/claims`). The Connections rail (doc-rail.js) now draws every stated
418
+ * tie, so what the record adds is its FINDINGS: the ones in this file, and the ones in another file that name this
419
+ * file as their source. A tie is still never drawn from wording, and a file the last check did not read is not shown as
420
+ * clean: it has no findings to show, and the rail says only what was read. */
417
421
  const claims = new Map(); // ws -> { state: 'loading' | 'ok' | 'error', data?, error? }
418
- const ALIGN_W = 'min(420px, 50%)';
419
- const alignNodes = new Map(); // tab id -> the drawer element, kept so its scroll survives a repaint
420
422
 
421
423
  function readClaims(ws) {
422
424
  let c = claims.get(ws);
@@ -430,132 +432,24 @@ function readClaims(ws) {
430
432
  return c;
431
433
  }
432
434
 
433
- /** Whether a tab can have the drawer at all: a file in the checked-out tree, in real mode. */
434
- const canAlign = (tab) => !FIXTURE && !tab.guide && !tab.ref && !isReference(tab.path);
435
-
436
- const A_LBL = 'font-family:var(--mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
437
- const A_NOTE = 'font-size:12px;line-height:1.5;color:var(--muted-foreground)';
438
- 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)';
439
- const A_CARD = 'border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
440
- 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)';
441
- const STATUS_TONE = { contradicted: 'var(--destructive)', stale: 'var(--warning)', suspected: 'var(--muted-foreground)', undecided: 'var(--muted-foreground)' };
442
- const STATUS_WORD = { contradicted: 'contradicted', stale: 'stale', suspected: 'suspected by Jev', undecided: 'worth a look' };
443
-
444
- const statusBadge = (st) => el('span', {
445
- style: A_BADGE + ';border-color:transparent;color:' + (STATUS_TONE[st] || 'var(--muted-foreground)')
446
- + ';background:color-mix(in oklab, ' + (STATUS_TONE[st] || 'var(--muted-foreground)') + ' 14%, transparent)',
447
- }, iv(STATUS_WORD[st] || st));
448
-
449
- /**
450
- * The documents tied to `rel`, each with the ties that put it there. Three kinds, all read:
451
- * · this file is compared with it (this file's `compared_with`)
452
- * · it is compared with this file (its `compared_with` names this file)
453
- * · a finding in it names this file (a finding whose `source` is this file)
454
- */
455
- function tiesFor(rel, docs) {
456
- const out = new Map();
457
- const add = (r, tie, finding) => {
458
- if (!r || r === rel) return;
459
- let c = out.get(r);
460
- if (!c) { c = { rel: r, ties: new Set(), findings: [] }; out.set(r, c); }
461
- c.ties.add(tie);
462
- if (finding) c.findings.push(finding);
463
- };
464
- const self = docs.find((x) => x.rel === rel) || null;
465
- if (self) for (const r of self.compared_with || []) add(r, 'this file checks against it');
435
+ /** The findings that concern `rel`: its own, then each other file's finding whose source is `rel` (with that file). */
436
+ function findingsFor(ws, rel) {
437
+ const c = readClaims(ws);
438
+ if (c.state !== 'ok') return [];
439
+ const docs = Array.isArray(c.data && c.data.docs) ? c.data.docs : [];
440
+ const out = [];
441
+ const self = docs.find((x) => x.rel === rel);
442
+ if (self) for (const f of self.findings || []) out.push({ status: f.status, sentence: f.sentence });
466
443
  for (const x of docs) {
467
444
  if (x.rel === rel) continue;
468
- if ((x.compared_with || []).includes(rel)) add(x.rel, 'checks against this file');
469
- for (const f of x.findings || []) if (f && f.source === rel) add(x.rel, 'a finding names this file', f);
470
- }
471
- return { self, list: [...out.values()] };
472
- }
473
-
474
- function alignDrawer(tab) {
475
- const ws = wsOf(tab);
476
- const rel = String(tab.path || '');
477
- const close = () => { alignOpen.delete(tab.id); setState({}); };
478
- const kids = [
479
- el('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px' },
480
- el('span', { style: 'font-weight:600;font-size:15px;font-family:var(--sans)', 'data-r29': 'align-title' }, 'Alignment'),
481
- el('button', { onClick: close, 'data-r29': 'align-close', 'aria-label': 'close alignment', title: 'close the drawer',
482
- style: 'background:transparent;border:0;color:var(--muted-foreground);font-size:15px;padding:2px 6px', hover: 'color:var(--foreground)' }, '⟩')),
483
- el('div', { style: A_NOTE, 'data-r29': 'align-rule' }, 'Only documents with a concrete tie to this file are listed. No similarity guesses.'),
484
- ];
485
- const c = readClaims(ws);
486
- if (c.state === 'loading') {
487
- kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': 'loading' }, 'Reading the consistency record…'));
488
- } else if (c.state === 'error') {
489
- kids.push(el('div', { style: A_NOTE + ';color:var(--destructive)', 'data-r29': 'align-status', 'data-state': 'error' },
490
- iv('The consistency record could not be read: ' + c.error + '. Nothing is listed.')));
491
- } else {
492
- const d = c.data || {};
493
- const docs = Array.isArray(d.docs) ? d.docs : [];
494
- const openValidate = () => import('./panel-cockpit.js').then((m) => m.openView('validate')).catch(() => {});
495
- if (d.verdict === 'not-checked' || !docs.length) {
496
- kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': 'not-checked' },
497
- 'This repository has not been checked yet, so no tie is known. Open Validate and press “check now”.'));
498
- kids.push(el('div', null, el('button', { style: A_BTN, hover: 'border-color:var(--ring)', onClick: openValidate, 'data-r29': 'align-validate' }, 'open Validate')));
499
- } else {
500
- const { self, list } = tiesFor(rel, docs);
501
- const skipped = alignSkip.get(tab.id) || new Set();
502
- const shown = list.filter((x) => !skipped.has(x.rel));
503
- kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-status', 'data-state': self ? 'read' : 'unread' },
504
- iv(self
505
- ? 'This file: ' + (self.classified ? self.type : 'not classified') + ' · ' + self.verified + ' statement(s) verified'
506
- + (self.findings.length ? ' · ' + self.findings.length + ' finding(s)' : ' · no open findings')
507
- : 'The last check did not read this file, so its own findings are unknown. Ties from other files are still listed.')));
508
- if (self && self.findings.length) {
509
- kids.push(el('div', { style: A_LBL }, 'findings in this file'));
510
- kids.push(el('div', { style: 'display:flex;flex-direction:column;gap:6px', 'data-r29': 'align-self' },
511
- self.findings.map((f) => el('div', { 'data-r29': 'align-finding', 'data-status': f.status, style: 'display:flex;flex-direction:column;gap:3px' },
512
- el('span', null, statusBadge(f.status)),
513
- el('span', { style: 'font-size:12px;line-height:1.5;color:var(--foreground)' }, iv(f.sentence))))));
514
- }
515
- if (state.savedDocs[docKey(tab)] != null && list.length) {
516
- kids.push(el('div', { style: A_NOTE + ';color:var(--warning)', 'data-r29': 'align-saved' },
517
- iv('Saved. ' + list.length + ' other document' + (list.length === 1 ? '' : 's') + ' may now disagree with this change.')));
518
- }
519
- kids.push(el('div', { style: A_LBL }, iv('tied documents · ' + list.length)));
520
- if (!list.length) kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-none' }, 'No other document has a concrete tie to this file.'));
521
- for (const x of shown) {
522
- kids.push(el('div', { style: A_CARD, 'data-r29': 'align-cand', 'data-rel': x.rel },
523
- el('b', { style: 'font-family:var(--mono);font-size:12px;font-weight:600;overflow-wrap:anywhere' }, iv(x.rel)),
524
- 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)))),
525
- x.findings.map((f) => el('div', { style: 'display:flex;flex-direction:column;gap:3px' },
526
- el('span', null, statusBadge(f.status)),
527
- el('span', { style: A_NOTE }, iv(f.sentence)))),
528
- el('div', { style: 'display:flex;gap:6px' },
529
- 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'),
530
- el('button', { style: A_BTN, hover: 'border-color:var(--ring)', 'data-r29': 'align-skip',
531
- onClick: () => { const s2 = new Set(skipped); s2.add(x.rel); alignSkip.set(tab.id, s2); setState({}); } }, 'Skip'))));
532
- }
533
- if (skipped.size) {
534
- kids.push(el('button', { style: A_BTN + ';align-self:flex-start', hover: 'border-color:var(--ring)', 'data-r29': 'align-unskip',
535
- onClick: () => { alignSkip.delete(tab.id); setState({}); } }, iv('show ' + skipped.size + ' skipped')));
536
- }
537
- }
538
- kids.push(el('div', { style: 'display:flex;gap:6px' },
539
- el('button', { style: A_BTN, hover: 'border-color:var(--ring)', 'data-r29': 'align-reread', onClick: () => { claims.delete(ws); setState({}); } }, 're-read')));
445
+ for (const f of x.findings || []) if (f && f.source === rel) out.push({ status: f.status, sentence: f.sentence, rel: x.rel });
540
446
  }
541
- kids.push(el('div', { style: A_NOTE, 'data-r29': 'align-foot' },
542
- 'Ties come from the last consistency check. PMOS drafts no edit to another file: open it to change it. Git keeps the history.'));
543
- /* One node per tab, kept like the scrollers above, so a repaint does not take the drawer's own
544
- * scroll back to the top. */
545
- let node = alignNodes.get(tab.id);
546
- if (!node) { node = makeDrawerNode(); alignNodes.set(tab.id, node); }
547
- setChildren(node, kids);
548
- return node;
447
+ return out;
549
448
  }
550
449
 
551
- function makeDrawerNode() {
552
- return el('div', {
553
- 'data-r29': 'align-drawer', 'data-board': 'alignment',
554
- style: 'position:absolute;top:0;right:0;bottom:0;width:' + ALIGN_W + ';box-sizing:border-box;overflow-y:auto;'
555
- + 'border-left:1px solid var(--border);background:var(--sidebar);color:var(--foreground);padding:16px;'
556
- + 'display:flex;flex-direction:column;gap:12px;font-family:var(--sans);font-size:13px;line-height:1.5',
557
- });
558
- }
450
+ /** Whether a tab can have the rail at all: a file in the checked-out tree, in real mode. */
451
+ const canRail = (tab) => !FIXTURE && !tab.guide && !tab.ref && !isReference(tab.path);
452
+ const railHidden = new Set(); // tab ids whose outline and connections the PM put away
559
453
 
560
454
  /* ── mount ─────────────────────────────────────────────────────────────────────────────────── */
561
455
 
@@ -724,7 +618,10 @@ export function mountDoc(host, tab) {
724
618
  const raw = docText(tab);
725
619
  const text = state.docEdits[docKey(tab)] ?? raw;
726
620
  const nonMd = !FIXTURE && isSourcePath(tab.path); // file-viewer-any-text: read-only, shown as source
727
- const dm = ((tab.readonly || nonMd) && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
621
+ const writable = canWrite(tab, nonMd);
622
+ let dm = ((tab.readonly || nonMd) && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
623
+ if (writable && dm === 'raw') dm = 'view'; // in place, the file you can write IS its source: no read-only copy of it
624
+ if (!FIXTURE && !writable && (dm === 'edit' || dm === 'diff')) dm = 'view'; // only a markdown file can be saved
728
625
  const dirty = text !== raw;
729
626
  const err = saveErr.get(docKey(tab));
730
627
 
@@ -780,14 +677,7 @@ export function mountDoc(host, tab) {
780
677
  const canNormalize = !!(norm && typeof norm.normalize === 'function')
781
678
  && !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(docKey(tab)) && !nonMd;
782
679
  const normBtn = canNormalize ? el('button', {
783
- onClick: () => {
784
- const r = norm.normalize(text);
785
- normNote.set(tab.id, r.changed
786
- ? r.joins + ' line(s) joined' + (r.blanks ? ', ' + r.blanks + ' blank inserted' : '')
787
- : 'already normalized — every paragraph is one line');
788
- if (!r.changed) { setState({}); return; }
789
- setState((s) => ({ docEdits: { ...s.docEdits, [docKey(tab)]: r.text } }));
790
- },
680
+ onClick: () => normalizeBuffer(tab, norm),
791
681
  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)',
792
682
  hover: 'border-color:var(--ring)',
793
683
  title: 'Join hard-wrapped paragraphs into one line each — what a Jira issue, a Google Doc or any'
@@ -812,21 +702,19 @@ export function mountDoc(host, tab) {
812
702
  hover: 'color:var(--foreground)', 'data-r1': 'measure',
813
703
  title: narrowOf(tab) ? 'use the full width of the tab' : 'limit lines to a 72-character reading measure',
814
704
  }, iv(narrowOf(tab) ? '↔ full width' : '⇥ 72ch')),
815
- canAlign(tab) ? el('button', {
816
- onClick: () => {
817
- if (alignOpen.has(tab.id)) alignOpen.delete(tab.id);
818
- else { alignOpen.add(tab.id); claims.delete(wsOf(tab)); } // opening reads the record afresh
819
- setState({});
820
- },
821
- style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
822
- + (alignOpen.has(tab.id) ? 'var(--secondary)' : 'transparent') + ';border:1px solid '
823
- + (alignOpen.has(tab.id) ? 'var(--brand)' : 'var(--border)') + ';color:var(--foreground)',
824
- hover: 'border-color:var(--ring)', 'data-r29': 'align-toggle', 'data-open': alignOpen.has(tab.id) ? 'yes' : 'no',
825
- title: 'Show the documents with a concrete tie to this one, from the last consistency check.',
826
- }, 'alignment') : null,
827
705
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
828
706
  );
829
707
 
708
+ /* Live mode draws the bar from the design system (workspace-tabs-declutter): the four modes as one segmented control,
709
+ * the save state beside them, save and revert, and one ⋯ menu for what is used now and then (the reading width,
710
+ * normalize, alignment). Run scripts stays on the bar while a page can run: it grants a privilege, so its state is
711
+ * never one click away. The fixture keeps the prototype's bar above, which the parity suites pin. */
712
+ const bar = FIXTURE ? modeBar : docBar(tab, {
713
+ dm, readonly, writable, dirty, err, text, asPage, live, canNormalize, norm,
714
+ statusText: dirtyLabel,
715
+ tone: err ? 'error' : (dirty ? 'dirty' : 'clean'),
716
+ });
717
+
830
718
  let body;
831
719
  if (dm === 'raw') body = rawBody(tab, text);
832
720
  else if (dm === 'diff') body = diffBody(tab, raw, text, dirty);
@@ -839,16 +727,133 @@ export function mountDoc(host, tab) {
839
727
  if (body.defaultValue !== text) body.defaultValue = text;
840
728
  if (body.value !== text) body.value = text;
841
729
  } else if (!FIXTURE && (extOfPath(tab.path) === 'csv' || extOfPath(tab.path) === 'tsv')) body = gridBody(tab, text);
730
+ else if (writable) body = inPlace(tab, text, raw);
842
731
  else body = asPage ? htmlFrame(tab, text, live) : (nonMd && !unread.has(docKey(tab)) ? sourceBody(tab, text) : viewBody(tab, text));
843
732
 
844
- /* The drawer, when open, lies over the right of the pane; the bar and the body give it room. The
845
- * margin is written on every paint so a closed drawer leaves no gap behind on a kept node. */
846
- const drawerOn = canAlign(tab) && alignOpen.has(tab.id);
847
- host.style.position = drawerOn ? 'relative' : '';
848
- modeBar.style.marginRight = drawerOn ? ALIGN_W : '';
849
- body.style.marginRight = drawerOn ? ALIGN_W : '';
850
- setChildren(host, drawerOn ? [modeBar, body, alignDrawer(tab)] : [modeBar, body]);
733
+ setChildren(host, [bar, body]);
851
734
  return host;
852
735
  }
853
736
 
737
+ /* A markdown file the PM can write, in live mode: edited in place. A guide page ships with the app, a file from git is
738
+ * evidence, a reference names someone else's record, and a file that could not be read is a stub: none of those. */
739
+ function canWrite(tab, nonMd) {
740
+ if (FIXTURE || tab.readonly || tab.guide || tab.ref || nonMd || isReference(tab.path) || isHtmlDoc(tab.path)) return false;
741
+ const PK = typeof window !== 'undefined' ? window.PreviewKind : null;
742
+ if (!PK || typeof PK.previewKind !== 'function' || PK.previewKind(String(tab.path || '')) !== 'markdown') return false;
743
+ const k = docKey(tab);
744
+ return state.savedDocs[k] != null || (disk.has(k) && !unread.has(k));
745
+ }
746
+
747
+ /* The in-place surface: the editor and, beside it, the rail. One scrolling node per tab, kept like the scrollers above,
748
+ * so neither a repaint nor a keystroke moves the page. */
749
+ const places = new Map(); // tab id -> { node, grid }
750
+ function inPlace(tab, text, raw) {
751
+ let pl = places.get(tab.id);
752
+ if (!pl) {
753
+ const grid = el('div', { class: 'fr-ed__grid' });
754
+ pl = { node: el('div', { class: 'fr-ed', 'data-ed': 'scroll' }, grid), grid };
755
+ places.set(tab.id, pl);
756
+ }
757
+ const ws = wsOf(tab);
758
+ const main = editorNode(tab, {
759
+ text, ctx: linkCtx(tab),
760
+ onChange: (t) => { saveErr.delete(docKey(tab)); setState((s) => { const de = { ...s.docEdits }; if (t === raw) delete de[docKey(tab)]; else de[docKey(tab)] = t; return { docEdits: de }; }); },
761
+ onSave: () => { const cur = state.docEdits[docKey(tab)]; if (cur != null && cur !== raw) save(tab, cur); },
762
+ });
763
+ const showRail = canRail(tab) && !railHidden.has(tab.id);
764
+ const rail = showRail ? railNode(tab, {
765
+ ws, rel: String(tab.path || ''), heads: headsOf(tab.id), findings: findingsFor(ws, String(tab.path || '')),
766
+ onGoto: (i) => goToBlock(tab.id, i),
767
+ onOpen: (rel) => openDoc(tab.slug, rel, 'FILE', tab.ws ? { ws: tab.ws, branch: tab.branch || null, linked: true } : undefined),
768
+ }) : null;
769
+ pl.grid.setAttribute('data-measure', narrowOf(tab) ? '72ch' : 'full');
770
+ pl.grid.toggleAttribute('data-rail', !!rail);
771
+ setChildren(pl.grid, [main, rail]);
772
+ if (isEditing(tab.id)) pl.node.setAttribute('data-editing', ''); else pl.node.removeAttribute('data-editing');
773
+ return pl.node;
774
+ }
775
+
776
+ /** normalize: the buffer's paragraphs joined into one line each. The buffer, never the file; a clean document says so. */
777
+ function normalizeBuffer(tab, norm) {
778
+ const raw = docText(tab);
779
+ const text = state.docEdits[docKey(tab)] ?? raw;
780
+ const r = norm.normalize(text);
781
+ normNote.set(tab.id, r.changed
782
+ ? r.joins + ' line(s) joined' + (r.blanks ? ', ' + r.blanks + ' blank inserted' : '')
783
+ : 'already normalized — every paragraph is one line');
784
+ if (!r.changed) { setState({}); return; }
785
+ setState((s) => ({ docEdits: { ...s.docEdits, [docKey(tab)]: r.text } }));
786
+ }
787
+
788
+ /* ── the document bar in live mode ──────────────────────────────────────────────────────────────────────────────────
789
+ * One bar per tab, kept so the ⋯ control keeps its focus across a repaint; its children are rebuilt. The bar says whether
790
+ * the file is saved and what its copy holds, and carries what changes the view: Changes while there is something to
791
+ * compare, the whole file as markdown (or, for a file that cannot be edited, its source), and one ⋯ menu for what is used
792
+ * now and then. Run scripts stays on the bar while a page can run: it grants a privilege, so its state is never one
793
+ * click away. Every action is the prototype's: the same state keys, the same buffer, the same save path. */
794
+ const docBars = new Map(); // tab id -> { node, more }
795
+
796
+ function docBar(tab, o) {
797
+ let kept = docBars.get(tab.id);
798
+ if (!kept) {
799
+ const more = iconButton({ icon: 'ellipsis', ariaLabel: 'Document options', size: 'sm', attrs: { 'data-doc': 'more', class: 'fr-doc-bar__more' },
800
+ onClick: (e) => menu({ anchor: e.currentTarget, ariaLabel: 'Document options', items: docMenu(tab) }) });
801
+ kept = { node: el('div', { class: 'fr-doc-bar', 'data-doc': 'bar' }), more };
802
+ docBars.set(tab.id, kept);
803
+ }
804
+ const setMode = (m) => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: m } }));
805
+ const status = el('span', { class: 'fr-doc-bar__status', 'data-doc': 'status', 'data-tone': o.tone }, o.statusText);
806
+ const copy = canRail(tab) && !o.readonly ? copyChip(wsOf(tab), String(tab.path || '')) : null;
807
+ const inDiff = o.dm === 'diff';
808
+ const changes = o.writable && (o.dirty || inDiff) ? button({
809
+ label: 'changes', variant: inDiff ? 'outline' : 'ghost', size: 'sm', icon: 'git-compare',
810
+ attrs: { 'data-doc': 'changes', 'aria-pressed': inDiff ? 'true' : 'false', title: 'What saving would change on disk, line by line' },
811
+ onClick: () => setMode(inDiff ? 'view' : 'diff'),
812
+ }) : null;
813
+ const srcMode = o.writable ? 'edit' : 'raw';
814
+ const inSrc = o.dm === srcMode;
815
+ const source = button({
816
+ label: o.writable ? 'markdown' : 'source', variant: inSrc ? 'outline' : 'ghost', size: 'sm', icon: 'file-code',
817
+ attrs: { 'data-doc': 'source', 'aria-pressed': inSrc ? 'true' : 'false',
818
+ title: o.writable ? 'Edit the whole file as markdown' : 'Show the file as it is written' },
819
+ onClick: () => setMode(inSrc ? 'view' : srcMode),
820
+ });
821
+ const saveB = o.dirty ? button({ label: 'save', variant: 'primary', size: 'sm', attrs: { 'data-doc': 'save', title: 'Save (Ctrl or ⌘ S)' }, onClick: () => save(tab, o.text) }) : null;
822
+ const revertB = o.dirty ? button({ label: 'revert', variant: 'ghost', size: 'sm', attrs: { 'data-doc': 'revert' }, onClick: () => revert(tab) }) : null;
823
+ const run = o.asPage && o.dm === 'view' ? button({
824
+ label: o.live ? 'scripts running — stop' : 'run scripts', variant: o.live ? 'danger' : 'outline', size: 'sm',
825
+ onClick: () => { if (o.live) running.delete(tab.id); else running.add(tab.id); setState({}); },
826
+ attrs: { 'data-doc': 'run', 'aria-pressed': o.live ? 'true' : 'false',
827
+ title: o.live
828
+ ? 'This document is running its own scripts, in a frame with no origin and no network. Stop discards it.'
829
+ : "Run this document's own scripts. It stays sandboxed: no origin, no network, nothing loaded from anywhere." },
830
+ }) : null;
831
+ const hasMenu = docMenu(tab).length > 0;
832
+ setChildren(kept.node, [status, copy, el('span', { class: 'fr-doc-bar__end' }, run, changes, revertB, saveB, source, hasMenu ? kept.more : null)]);
833
+ return kept.node;
834
+ }
835
+
836
+ /** The ⋯ menu's items, read afresh when it opens, so each one says what choosing it will do now. */
837
+ function docMenu(tab) {
838
+ const items = [];
839
+ const dm = state.docModes[tab.id] ?? 'view';
840
+ if (dm === 'view' || dm === 'raw') {
841
+ const narrow = narrowOf(tab);
842
+ items.push({ label: narrow ? 'Use the full width' : 'Limit lines to 72 characters', icon: 'arrow-left-right', attrs: { 'data-r1': 'measure' },
843
+ onSelect: () => setState((s) => ({ docNarrow: { ...(s.docNarrow || {}), [tab.id]: !narrowOf(tab) } })) });
844
+ }
845
+ const norm = typeof window !== 'undefined' ? window.MdNormalize : null;
846
+ const nonMd = isSourcePath(tab.path);
847
+ if (norm && typeof norm.normalize === 'function' && !tab.readonly && !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(docKey(tab)) && !nonMd) {
848
+ items.push({ label: 'Normalize line breaks', icon: 'refresh-cw', attrs: { 'data-doc': 'normalize', title: 'Join hard-wrapped paragraphs into one line each. Writes the buffer, not the file: read it in Changes, then save.' },
849
+ onSelect: () => normalizeBuffer(tab, norm) });
850
+ }
851
+ if (canRail(tab) && canWrite(tab, nonMd)) {
852
+ const hidden = railHidden.has(tab.id);
853
+ items.push({ label: hidden ? 'Show the outline and connections' : 'Hide the outline and connections', icon: 'share-2', attrs: { 'data-doc': 'rail' },
854
+ onSelect: () => { if (hidden) railHidden.delete(tab.id); else railHidden.add(tab.id); setState({}); } });
855
+ }
856
+ return items;
857
+ }
858
+
854
859
  export default mountDoc;
@@ -12,6 +12,8 @@
12
12
  * picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
13
13
  * The calendar memory changes only through the morning-brief session's brief. The one write here is the PM's key-meeting
14
14
  * mark (POST /api/proactive/agenda/mark): this time or every time, key or not key, over what the calendar says.
15
+ * A meeting's detail shows its meeting notes, and an event row a notes chip, from pm/notes-links.js (meeting-notes Run 4);
16
+ * Take notes writes there.
15
17
  */
16
18
 
17
19
  import { peopleLinks } from './people-link.js';
@@ -19,6 +21,7 @@ import { el } from './dom.js';
19
21
  import { button, iconButton, badge, tabs, toast } from './primitives.js';
20
22
  import { pmGet, pmPost } from './http.js';
21
23
  import { COPY } from './today-copy.js';
24
+ import { notesBlock, notesFor } from './notes-links.js';
22
25
 
23
26
  const A = COPY.day.ag;
24
27
  const DAY0 = 9 * 60;
@@ -203,7 +206,8 @@ function eventRow(h, e) {
203
206
  const chips = el('span', { class: 'td-day__chips' },
204
207
  e.imp ? badge({ text: A.star, tone: 'outline', attrs: { title: A.means } }) : null,
205
208
  e.prep ? el('span', { class: 'td-ag__ready', 'data-ag-ready': e.ready || 'ok' }, A.readyBadge(e.prep_done || 0, e.prep.length)) : null,
206
- e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
209
+ notesFor(e).length ? badge({ text: 'notes', tone: 'outline', attrs: { 'data-ag-notes': e.id, title: 'Meeting notes are linked to this event' } })
210
+ : e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
207
211
  return h.rowBtn(type, e.id, { lead: e.time, title: e.title, meta: A.kindWord[e.kind] + (e.attendees ? ' · ' + A.people(e.attendees) : ''), trail: chips },
208
212
  { 'data-past': e.ended ? 'true' : null, 'data-ag-event': e.id });
209
213
  }
@@ -327,5 +331,6 @@ export function meetingDetail(ctx, id, h) {
327
331
  e.ended ? el('div', { class: 'td-day__phase', 'data-ag': 'notes' }, el('h4', null, A.afterNotes),
328
332
  e.notes ? el('p', null, e.notes) : el('p', { class: 'td-day__note' }, A.noNotesYet),
329
333
  e.open && e.open.length ? [el('h4', null, A.openLines), el('ul', { class: 'td-day__chk' }, ...e.open.map((x) => el('li', null, x)))] : null) : null,
334
+ notesBlock(e, { ws: ctx && ctx.ws }),
330
335
  ];
331
336
  }
@@ -10,6 +10,8 @@
10
10
  * - on the left, the calendar (with a line at the current time), the jobs (needs you, agent working, done), the goals
11
11
  * and the mentions with the watched channels, projects and spaces;
12
12
  * - on the right, the detail of what is selected: a meeting, a job, a goal, a mention, a step, the sources or a place.
13
+ * A meeting's detail ends with its meeting notes: the summary and Open notes, or Take notes (pm/notes-links.js,
14
+ * meeting-notes Run 4).
13
15
  * The presses go to the routes that own them: the goals route (add, done, drop, reopen, remove), /goals/plan (Prepare my
14
16
  * day), /day/decide (approve, revise, run again), /day/check (Check now) and the settings route (sources, levels, the
15
17
  * daytime check, websites). Nothing here sends anything outward: the agent sessions do that, after the PM approves.
@@ -30,6 +32,7 @@ import { COPY } from './today-copy.js';
30
32
  import { goalRows, goalAdd, goalDetail, addGoal, goalsSig } from './today-goals.js';
31
33
  import { peopleLinks } from './people-link.js';
32
34
  import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
35
+ import { notesBlock, onNotes } from './notes-links.js';
33
36
 
34
37
  const D = COPY.day;
35
38
  const SLOW_MS = 30000;
@@ -57,6 +60,7 @@ const ui = {
57
60
  revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
58
61
  };
59
62
  let rerender = () => {};
63
+ let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
60
64
  let timer = null;
61
65
 
62
66
  /* ── the read ─────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -566,6 +570,7 @@ function eventDetail(ctx, e) {
566
570
  memoryBlock(e.id),
567
571
  !e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
568
572
  after,
573
+ notesBlock(e, { ws: ctx.ws, initiative: ini }),
569
574
  !e.ended ? [list(D.phases.before, (steps.before || []).map(stepItem)), list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
570
575
  Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
571
576
  ];
@@ -856,6 +861,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
856
861
  // cause a whole-page render under whatever the PM is doing elsewhere
857
862
  if (setState) rerender = () => { if (document.querySelector('[data-today="day"]')) setState({}); };
858
863
  onAgenda(() => rerender());
864
+ if (!notesSub) notesSub = onNotes(() => rerender());
859
865
  ensure(ws);
860
866
  const c = cache.get(ws) || {};
861
867
  const m = model(c.data, { here: here || null, all: !!all });