create-pmos 0.153.0 → 0.155.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.
@@ -1,12 +1,14 @@
1
1
  /* pm/c3-doc-sheet.js — the DOCUMENT SHEET of /c3.html (Initiatives › Documents, an initiative's Docs, Reports). The right-hand sheet from
2
2
  * c3-sheet.js, up to 780px, a bottom sheet on a phone. Initiative c3-doc-sheet-rich: the approved mockup (planning/prd/c3-doc-sheet-rich-prd.md).
3
3
  *
4
- * openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged, generated, secExtra }) doc = { path, name, ty, v, st, ini, iniName }
4
+ * openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged, generated, secExtra, library(), openGraph(doc) }) doc = { path, name, ty, v, st, ini, iniName }
5
5
  *
6
6
  * Six modes (the segment under the header; the keys are kept for the callers):
7
7
  * read Read the markdown rendered (pm/c3-md.js): the front matter as properties, each section with who changed it last (from the
8
- * revisions), "Show who wrote what", the Markdown source with a copy button, a Contents menu, the stale banner, and the
9
- * open proposals pinned to the sections they change, with "Show change" for the diff in place.
8
+ * revisions), "Show who wrote what", the Markdown source with a copy button, the stale banner, and the open proposals
9
+ * pinned to the sections they change, with "Show change" for the diff in place. The OUTLINE (v2) beside it: the headings
10
+ * nested by level, the section in view marked as the page scrolls, groups that fold, a filter past 12 headings, a dot on
11
+ * stale and proposal sections; Hide is kept in this browser (localStorage); under 900px it is a button over the text.
10
12
  * edit Edit a rich-text editor of the whole document (toolbar, Rich | Markdown over the same draft), the front matter as fields; a
11
13
  * changed section is marked; saving needs a reason (POST /api/doc-sheet/edit). A section nobody touched is written back
12
14
  * exactly as it was, so opening and saving without a change writes nothing.
@@ -18,13 +20,20 @@
18
20
  * change that has none (POST /api/doc-sheet/why).
19
21
  * health Health POST /api/doc-health/check: the checks, N of M; a failing Fresh check lists the sources that changed, and "Mark as still
20
22
  * current" records a reason (POST /api/doc-sheet/mark-current).
21
- * links Relations the document graph around this document, from the caller.
23
+ * links Relations the graph around this document (v2): its sources on the left, its initiative and goals on top, the documents that
24
+ * use it and its copies on the right, a drift link dashed; a node opens that document. It reads `library()` from the
25
+ * caller, else GET /api/docs/library; the caller's `relations` list, when given, is kept below the graph.
26
+ * COPIES (v2): GET /api/docs gives the document's Confluence and Google Doc links with their signal (lib/doc-sync.js): a chip per copy in
27
+ * the header, and the "Copies in other tools" panel: Check now (POST /api/docs/check), Open in the tool, Bring their change in as a
28
+ * proposal (the ask run reads the copy; PMOS keeps hashes, not the copy's text, so the proposal is the difference), Update the copy to
29
+ * this version (five seconds to undo, then POST /api/docs/sync with use: pmos), and Link a copy (POST /api/docs/link).
22
30
  * Nothing is invented: a part with no data says so. The demo (?demo=1) answers the routes from fixtures.
23
31
  *
24
32
  * Two options for a report, which is a document with no stored file yet (report-families-view.js):
25
33
  * generated = { text, why, health } Read shows the generated text; Edit and Proposals say why they need a stored document; Health shows `health`.
26
34
  * secExtra(name) a node under each section in Read: the report's claims and where they come from.
27
- * Pure parts (Node-testable): splitDoc, joinDoc, changedSections, wordDiffTokens, byName, dayName, parseFront, setFront, composeSections. */
35
+ * Pure parts (Node-testable): splitDoc, joinDoc, changedSections, wordDiffTokens, byName, dayName, parseFront, setFront, composeSections,
36
+ * copyKindOf, citesIn, outlineOf, hoodOf. */
28
37
  import { el } from './dom.js';
29
38
  import { pmGet, pmPost } from './http.js';
30
39
  import { c3ic } from './pm-work-view.js';
@@ -183,10 +192,63 @@ const md = (text, extra) => el('div', { class: 'ds-md' + (extra ? ' ' + extra :
183
192
  const btn = (cls, attrs, kids, onClick) => c3btn(cls, attrs, kids, onClick);
184
193
  const headLevel = (s) => (/^(#+)/.exec(s.prefix || '') || ['', ''])[1].length;
185
194
 
186
- export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra }) {
195
+ /* ── v2 (c3-doc-sheet-v2): the copies in other tools, the outline, the graph ── */
196
+ /* a copy's state (lib/doc-sync.js signal): [label, icon, class, sentence, chip word] */
197
+ const SIG = {
198
+ same: ['In step', 'check', 'ds-good', 'Both have the same text.', 'in step'],
199
+ ahead: ['PMOS is ahead', 'up', 'ds-acc', 'PMOS has changes the copy does not have.', 'PMOS ahead'],
200
+ behind: ['Behind', 'alert', 'ds-warn', 'The copy has changes PMOS does not have.', 'behind'],
201
+ diverged: ['Both changed', 'alert', 'ds-bad', 'Both changed since they last matched.', 'both changed'],
202
+ unchecked: ['Not checked', 'info', '', 'No check has read this copy yet.', 'not checked'],
203
+ unreadable: ['Cannot open', 'alert', 'ds-bad', 'The last check could not open the copy.', 'cannot open'],
204
+ 'missing-remote': ['Not found', 'alert', 'ds-bad', 'The copy is not there any more.', 'not found'],
205
+ 'missing-repo': ['Not in PMOS', 'alert', 'ds-bad', 'This file is not in the repository any more.', 'not in PMOS'] };
206
+ const SYNCING = ['Updating', 'clock', 'ds-acc', 'A session is updating this copy.', 'updating'];
207
+ const KLABEL = { google: 'Google Doc', confluence: 'Confluence' };
208
+ const sigOf = (l) => (l.syncing ? SYNCING : SIG[l.signal] || SIG.unchecked);
209
+ const labelOf = (l) => KLABEL[l.kind] || l.label || l.kind;
210
+ /** Which tool a pasted address names (the server checks it again): 'confluence', 'google' or null. */
211
+ export function copyKindOf(target) {
212
+ const t = String(target || '');
213
+ if (/atlassian|confluence|\/wiki\/|pageId=/i.test(t)) return 'confluence';
214
+ if (/google|docs\.|drive\./i.test(t)) return 'google';
215
+ return null;
216
+ }
217
+ /** The names of the sections that cite a path (or its file name). */
218
+ export function citesIn(secs, p) {
219
+ const b = String(p).split('/').pop();
220
+ return secs.filter((s) => s.name !== 'Front matter' && (s.text.includes(p) || (b && s.text.includes(b)))).map((s) => s.name);
221
+ }
222
+ /** The outline: the named sections, nested from the top level found (1 to 3). A single title heading on top is left out. */
223
+ export function outlineOf(secs) {
224
+ let named = secs.filter((s) => s.prefix && headLevel(s) > 0);
225
+ if (named.length && headLevel(named[0]) === 1 && named.filter((s) => headLevel(s) === 1).length === 1) named = named.slice(1);
226
+ if (!named.length) return [];
227
+ const base = Math.min(...named.map(headLevel));
228
+ return named.map((s) => ({ s, lv: Math.min(3, headLevel(s) - base + 1) }));
229
+ }
230
+ /** The neighbourhood of a document in a library response: { me, src, out } with each edge's state (the Docs page's rule when it has none). */
231
+ export function hoodOf(lib, path) {
232
+ if (!lib || !Array.isArray(lib.files)) return null;
233
+ const me = lib.files.findIndex((f) => f.path === path);
234
+ const kinds = lib.kinds || []; const src = []; const out = [];
235
+ const st = (a, b, k, s) => s || (['link', 'cites', 'anchor'].includes(kinds[k]) && a.date && b.date && a.date > b.date ? 'drift' : 'ok');
236
+ if (me >= 0) for (const [a, b, k, s] of lib.edges || []) {
237
+ if (b === me && a !== me && lib.files[a]) src.push({ f: lib.files[a], kind: kinds[k] || '', st: st(lib.files[a], lib.files[me], k, s) });
238
+ else if (a === me && b !== me && lib.files[b]) out.push({ f: lib.files[b], kind: kinds[k] || '', st: st(lib.files[me], lib.files[b], k, s) });
239
+ }
240
+ return { me: me >= 0 ? lib.files[me] : null, src, out };
241
+ }
242
+ const OL_KEY = 'pmos.c3.docOutline';
243
+ const olGet = () => { try { return localStorage.getItem(OL_KEY) !== 'off'; } catch { return true; } };
244
+ const olSet = (on) => { try { localStorage.setItem(OL_KEY, on ? 'on' : 'off'); } catch { /* no storage here */ } };
245
+ const ESTATE = { ok: 'in step', drift: 'out of step', conflict: 'conflict', missing: 'missing', ahead: 'PMOS ahead' };
246
+
247
+ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra, library, openGraph }) {
187
248
  const S = { mode: MODE[mode] || mode, prompt, text: null, err: '', revs: null, texts: {}, health: null, hAsk: false, busy: false, changed: false,
188
249
  meta: null, metaFor: null, props: null, propsErr: '', log: null, logErr: '', asking: null, closed: false, who: false, src: false, preview: {},
189
- pick: {}, rejFor: null, rejWhy: '', whyFor: null, whyText: '', staleWhy: null, logF: 'all', cmp: null, cmpNode: null, flashRev: null, ed: null, leave: null };
250
+ pick: {}, rejFor: null, rejWhy: '', whyFor: null, whyText: '', staleWhy: null, logF: 'all', cmp: null, cmpNode: null, flashRev: null, ed: null, leave: null,
251
+ copies: null, copiesErr: '', syncOpen: false, linking: false, linkUrl: '', pend: {}, checkRun: null, lib: undefined, ol: olGet(), olOpen: false, olq: '', olShut: {}, cur: null };
190
252
  const demo = isDemo();
191
253
  const base = { ws, path: doc.path };
192
254
  let ui = null;
@@ -237,7 +299,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
237
299
  const reloadAll = async () => {
238
300
  S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false; S.log = null;
239
301
  await loadText(); draw();
240
- await Promise.all([loadRevs(), loadProps(), loadHealth()]); loadMeta(); if (S.mode === 'hist') { await loadLog(); } draw();
302
+ await Promise.all([loadRevs(), loadProps(), loadHealth(), loadCopies()]); loadMeta(); if (S.mode === 'hist') { await loadLog(); } draw();
241
303
  };
242
304
 
243
305
  /* ── drawing helpers ── */
@@ -266,7 +328,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
266
328
  return el('div', { class: 'chips2 ds-meta', 'data-doc-meta': '' },
267
329
  el('span', { class: 'chip2 ' + st[1] }, c3ic(st[2], 'sm'), st[0]), el('span', { class: 'chip2' }, doc.ty || 'Document'), doc.v ? el('span', { class: 'chip2 mono' }, doc.v) : null,
268
330
  h && h.total ? btn('chip2 lk ' + hcls(h), { 'data-doc-hchip': '' }, [c3ic('shield', 'sm'), h.passed + ' of ' + h.total + ' checks'], () => go('health')) : null,
269
- el('span', { class: 'chip2' }, c3ic('init', 'sm'), doc.iniName || doc.ini || 'Portfolio'),
331
+ el('span', { class: 'chip2' }, c3ic('init', 'sm'), doc.iniName || doc.ini || 'Portfolio'), copyChips(),
270
332
  last && !generated ? btn('chip2 lk', { 'data-doc-last': '' }, [c3ic(isAgentRev(last) ? 'spark' : 'user', 'sm'), 'Last change: ', el('b', null, whoOf(last) + ','), ' ' + dayName(last.at)], () => go('hist')) : null);
271
333
  }
272
334
  function seg() {
@@ -285,6 +347,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
285
347
  if (m === 'hist') { S.cmp = null; loadLog().then(() => { draw(); if (S.flashRev != null) { goEl('[data-log-rev="' + S.flashRev + '"]'); S.flashRev = null; } }); }
286
348
  if (m === 'ask' && S.props === null) loadProps().then(draw);
287
349
  if (m === 'read') loadMeta();
350
+ if (m === 'links') loadLib();
288
351
  draw();
289
352
  const sb = ui && ui.body.closest('.sb'); if (sb) sb.scrollTop = 0;
290
353
  }
@@ -294,7 +357,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
294
357
  const f = fresh(); if (!f || generated) return null;
295
358
  const secs = S.text ? splitDoc(S.text) : [];
296
359
  const srcs = Array.isArray(f.sources) ? f.sources : [];
297
- const cites = (p) => { const b = String(p).split('/').pop(); return secs.filter((s) => s.name !== 'Front matter' && (s.text.includes(p) || (b && s.text.includes(b)))).map((s) => s.name); };
360
+ const cites = (p) => citesIn(secs, p);
298
361
  const op = openProps();
299
362
  if (S.staleWhy !== null) {
300
363
  const inp = el('input', { type: 'text', id: 'dstalewhy', 'data-doc-stalewhy': '', placeholder: 'For example: the launch date is not agreed yet', 'aria-label': 'Why it is still current', value: S.staleWhy, onInput: (e) => { S.staleWhy = e.target.value; } });
@@ -318,6 +381,243 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
318
381
  await loadHealth(); draw(); say('Marked as current. The reason is in the edit log.');
319
382
  }
320
383
 
384
+ /* ── v2: the copies in other tools (GET /api/docs: the links and their signals, lib/doc-sync.js) ── */
385
+ const loadCopies = async () => {
386
+ if (generated) { S.copies = []; return; }
387
+ const j = await pmGet('/api/docs?' + q({ ws })).catch(() => null);
388
+ if (!j || !j.ok) { S.copies = S.copies || []; S.copiesErr = (j && j.error) || 'The copies could not be read.'; return; }
389
+ const d = (j.docs || []).find((x) => x.path === doc.path);
390
+ S.copies = d ? d.links || [] : []; S.copiesErr = ''; S.checkRun = j.session || j.open || null;
391
+ };
392
+ const copyChips = () => {
393
+ if (generated || S.copies == null) return null;
394
+ if (!S.copies.length) return btn('chip2 lk', { 'data-doc-copychip': '', 'aria-expanded': String(S.syncOpen) }, [c3ic('link', 'sm'), 'Link a Confluence or Google Doc copy'], () => toggleCopies());
395
+ return S.copies.map((l) => { const g = sigOf(l); return btn('chip2 lk ' + g[2], { 'data-doc-copychip': l.kind, 'aria-expanded': String(S.syncOpen), title: l.words || g[3] }, [c3ic(g[1], 'sm'), labelOf(l) + ': ' + g[4]], () => toggleCopies()); });
396
+ };
397
+ function toggleCopies() {
398
+ S.syncOpen = !S.syncOpen;
399
+ if (S.syncOpen && S.mode === 'edit') { go('read'); return; }
400
+ draw(); const sb = ui && ui.body.closest('.sb'); if (sb && S.syncOpen) sb.scrollTop = 0;
401
+ }
402
+ function copiesPanel() {
403
+ if (!S.syncOpen || generated) return null;
404
+ const L = S.copies || [];
405
+ const rows = L.map((l) => {
406
+ const g = sigOf(l); const name = labelOf(l); const pend = S.pend[l.kind];
407
+ const acts = [btn('ds-btn', { 'data-copy-check': l.kind, disabled: !!S.checkRun, 'aria-disabled': S.checkRun ? 'true' : null }, [c3ic(S.checkRun ? 'clock' : 'undo', 'sm'), S.checkRun ? 'Checking' : 'Check now'], () => checkNow()),
408
+ l.url ? el('a', { class: 'ds-btn', href: l.url, target: '_blank', rel: 'noopener', 'data-copy-open': l.kind }, c3ic('out', 'sm'), 'Open in ' + name) : null];
409
+ if (!l.syncing && (l.signal === 'behind' || l.signal === 'diverged')) acts.push(btn('ds-btn ag', { 'data-copy-take': l.kind }, [c3ic('spark', 'sm'), 'Bring their change in as a proposal'], () => takeCopy(l)));
410
+ if (!l.syncing && l.signal === 'ahead') acts.push(pend ? btn('ds-btn', { 'data-copy-undo': l.kind }, [c3ic('undo', 'sm'), 'Undo the update'], () => undoPush(l))
411
+ : btn('ds-btn pri', { 'data-copy-push': l.kind }, [c3ic('up', 'sm'), 'Update the ' + name + ' to this version'], () => pushCopy(l)));
412
+ const changed = l.remote_modified_at ? 'changed ' + whenOf(l.remote_modified_at) + (l.remote_modified_by ? ' by ' + l.remote_modified_by : '') : '';
413
+ return el('li', { 'data-copy': l.kind },
414
+ el('div', { class: 'l1' }, c3ic('doc', 'sm'), el('b', null, l.title || name), el('span', { class: 'tag ' + g[2], 'data-copy-state': l.syncing ? 'syncing' : l.signal || 'unchecked' }, c3ic(g[1], 'sm'), g[0])),
415
+ el('div', { class: 'ds-mono' }, [name, l.target, changed].filter(Boolean).join(' · ')),
416
+ el('p', null, l.words || g[3]),
417
+ el('div', { class: 'ds-note' }, l.checked_at ? 'Last checked ' + whenOf(l.checked_at) + (l.checked_by ? ' by ' + byName(l.checked_by) : '') + '.' : 'Not checked yet.'),
418
+ l.syncing ? el('div', { class: 'fold', role: 'status', 'data-copy-syncing': l.kind }, c3ic('clock', 'sm'), 'A session is updating ' + (l.syncing.use === 'copy' ? 'PMOS from the ' + name : 'the ' + name + ' to this version') + (l.syncing.started_at ? '. It started ' + whenOf(l.syncing.started_at) + '.' : '.')) : null,
419
+ l.sync_failed ? el('div', { class: 'fold w', role: 'alert', 'data-copy-failed': l.kind }, c3ic('alert', 'sm'), 'The last update did not finish' + (l.sync_failed.reason ? ': ' + l.sync_failed.reason : '.')) : null,
420
+ pend ? el('div', { class: 'fold', role: 'status', 'data-copy-pending': l.kind }, c3ic('clock', 'sm'), 'The update starts in five seconds. Undo stops it.') : null,
421
+ el('div', { class: 'ds-acts' }, acts));
422
+ });
423
+ const linkRow = S.linking ? el('div', { class: 'ds-whyadd ds-linkadd' },
424
+ el('input', { type: 'text', id: 'dcopyurl', 'data-copy-url': '', placeholder: 'Paste a Confluence page or Google Doc link', 'aria-label': 'Address of the copy', value: S.linkUrl, onInput: (e) => { S.linkUrl = e.target.value; } }),
425
+ btn('ds-btn pri', { 'data-copy-linkok': '' }, 'Link', () => linkCopy()), btn('ds-btn', {}, 'Cancel', () => { S.linking = false; draw(); })) : null;
426
+ return el('section', { class: 'ds-sync', 'aria-label': 'Copies in other tools', 'data-doc-copies': '' },
427
+ el('div', { class: 'hd' }, el('b', null, 'Copies in other tools'), el('span', { class: 'ds-acts' },
428
+ btn('ds-btn', { 'data-copy-link': '' }, [c3ic('link', 'sm'), L.length ? 'Link another copy' : 'Link a copy'], () => { S.linking = true; S.linkUrl = ''; draw(); const i = ui.body.querySelector('#dcopyurl'); if (i) i.focus(); }),
429
+ btn('ds-btn', { 'aria-label': 'Close the copies', 'data-copy-close': '' }, c3ic('x', 'sm'), () => { S.syncOpen = false; draw(); }))),
430
+ S.copiesErr ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.copiesErr) : null, linkRow,
431
+ L.length ? el('ul', null, rows) : el('p', { class: 'ds-note', 'data-copy-none': '' }, 'This document has no copy in Confluence or Google Docs yet. Link one to see whether it is in step.'),
432
+ el('p', { class: 'ds-note' }, 'PMOS never overwrites this document from a copy: a change in the copy comes in as a proposal you accept by section. Updating a copy starts one agent session, and you have five seconds to undo.'));
433
+ }
434
+ async function checkNow() {
435
+ const r = await pmPost('/api/docs/check', { ws }).catch(() => null);
436
+ if (!r || !r.ok) { say((r && r.error) || 'The check did not start.'); return; }
437
+ S.checkRun = { id: r.session }; draw();
438
+ say('Checking the copies. An agent session reads them with your connectors; the states change when it finishes.');
439
+ for (let i = 0; i < 120 && !S.closed && S.checkRun; i++) {
440
+ await new Promise((ok) => setTimeout(ok, 5000));
441
+ if (S.closed) return;
442
+ await loadCopies(); drawBg();
443
+ }
444
+ }
445
+ /* a change in the copy comes in as a proposal: the read-only ask run (#637) reads the copy and drafts it by section */
446
+ function takeCopy(l) {
447
+ S.prompt = 'Read the ' + labelOf(l) + ' copy of this document (' + l.target + ') and propose its changes here, section by section. Keep the PMOS text where only PMOS changed.';
448
+ S.syncOpen = false; go('ask');
449
+ if (demo) askDemo(); else askReal();
450
+ }
451
+ function pushCopy(l) {
452
+ const name = labelOf(l);
453
+ S.pend[l.kind] = setTimeout(async () => {
454
+ delete S.pend[l.kind];
455
+ const r = await pmPost('/api/docs/sync', { ws, path: doc.path, kind: l.kind, use: 'pmos' }).catch(() => null);
456
+ if (!r || !r.ok) say((r && r.error) || 'The update did not start.');
457
+ else say('Updating the ' + name + '. A session writes it with your connectors.');
458
+ await loadCopies(); drawBg();
459
+ }, 5000);
460
+ draw();
461
+ }
462
+ function undoPush(l) { clearTimeout(S.pend[l.kind]); delete S.pend[l.kind]; draw(); say('Stopped. The ' + labelOf(l) + ' is not updated.'); }
463
+ async function linkCopy() {
464
+ const t = String(S.linkUrl || '').trim(); const kind = copyKindOf(t);
465
+ if (!kind) { say('Paste a Confluence page or Google Doc link.'); const i = ui.body.querySelector('#dcopyurl'); if (i) i.focus(); return; }
466
+ const r = await pmPost('/api/docs/link', { ws, path: doc.path, kind, target: t }).catch(() => null);
467
+ if (!r || !r.ok) { say((r && r.error) || 'The copy was not linked.'); return; }
468
+ S.linking = false; S.linkUrl = ''; await loadCopies(); draw(); say('Linked the ' + KLABEL[kind] + ' copy. Check now reads it.');
469
+ }
470
+
471
+ /* ── v2: the outline (Read) ── */
472
+ const staleNames = () => {
473
+ const f = fresh(); if (!f || !S.text) return new Set();
474
+ const secs = splitDoc(S.text); const out = new Set();
475
+ (Array.isArray(f.sources) ? f.sources : []).forEach((x) => citesIn(secs, x.path).forEach((n) => out.add(n)));
476
+ return out;
477
+ };
478
+ function outlineNav(items) {
479
+ const qq = S.olq.trim().toLowerCase(); const stale = staleNames(); const prop = new Set(openProps().flatMap((p) => p.sections.map((c) => c.name)));
480
+ const rows = []; let hideUnder = null;
481
+ items.forEach((it, k) => {
482
+ const { s, lv } = it;
483
+ if (hideUnder !== null && lv > hideUnder) return;
484
+ hideUnder = null;
485
+ if (qq && !s.name.toLowerCase().includes(qq)) return;
486
+ let n = 0; for (let j = k + 1; j < items.length && items[j].lv > lv; j++) n++;
487
+ const shut = !qq && !!S.olShut[s.i]; if (shut) hideUnder = lv;
488
+ const on = S.cur === s.i;
489
+ rows.push(el('li', { class: 'l' + lv + (on ? ' on' : ''), 'data-ol-i': String(s.i) },
490
+ n && !qq ? btn('tw', { 'aria-label': (shut ? 'Show' : 'Hide') + ' the ' + plural(n, 'heading') + ' under ' + s.name, 'aria-expanded': String(!shut), 'data-ol-fold': String(s.i) }, c3ic('next', 'sm'), () => { S.olShut[s.i] = !shut; redrawOl(); }) : el('span', { class: 'tw' }),
491
+ btn('go', { 'data-ol-go': String(s.i), 'aria-current': on ? 'location' : null }, [el('span', null, s.name),
492
+ stale.has(s.name) ? el('i', { class: 'mdot w', title: 'A source changed after this section', 'data-ol-stale': '' }) : null,
493
+ prop.has(s.name) ? el('i', { class: 'mdot p', title: 'The agent proposes a change here', 'data-ol-prop': '' }) : null], () => jumpTo(s.i))));
494
+ });
495
+ return el('nav', { class: 'ds-ol', 'aria-label': 'Outline', 'data-doc-outline': '' },
496
+ el('div', { class: 'hd' }, el('b', null, 'Outline'), el('span', { class: 'ds-mono' }, plural(items.length, 'heading'))),
497
+ items.length > 12 ? el('input', { type: 'text', id: 'dolq', 'data-ol-find': '', placeholder: 'Find a heading', 'aria-label': 'Find a heading', value: S.olq, onInput: (e) => { S.olq = e.target.value; redrawOl(); } }) : null,
498
+ el('ul', null, rows.length ? rows : el('li', { class: 'none ds-note' }, 'No heading matches.')),
499
+ stale.size || prop.size ? el('div', { class: 'key ds-note' }, stale.size ? el('span', null, el('i', { class: 'mdot w' }), 'stale') : null, prop.size ? el('span', null, el('i', { class: 'mdot p' }), 'proposal') : null) : null);
500
+ }
501
+ function redrawOl() {
502
+ const n = ui && ui.body.querySelector('.ds-ol'); if (!n || S.text == null) return;
503
+ const f = document.activeElement && document.activeElement.id === 'dolq' ? document.activeElement.selectionStart : null;
504
+ n.replaceWith(outlineNav(outlineOf(splitDoc(S.text)))); placeOl();
505
+ if (f != null) { const i = ui.body.querySelector('#dolq'); if (i) { i.focus(); try { i.setSelectionRange(f, f); } catch { /* not a text field */ } } }
506
+ }
507
+ /* where the sticky header ends: a section scrolled to lands below it, and the outline sticks under it */
508
+ /* (a sticky element sticks inside the scroller's padding: its edge from the scroller's top adds the padding) */
509
+ const stickBottom = () => { const t = ui && ui.body.querySelector('.ds-top'); return t ? (parseFloat(getComputedStyle(t).top) || 0) + t.offsetHeight : 0; };
510
+ const topEdge = () => { const sb = ui && ui.body.closest('.sb'); return stickBottom() + (sb ? parseFloat(getComputedStyle(sb).paddingTop) || 0 : 0); };
511
+ function placeOl() {
512
+ const nav = ui && ui.body.querySelector('.ds-ol'); const sb = ui && ui.body.closest('.sb'); if (!nav || !sb) return;
513
+ const h = stickBottom() + 8;
514
+ nav.style.setProperty('--in-ds-oltop', h + 'px'); nav.style.setProperty('--in-ds-olmax', Math.max(160, sb.clientHeight - h - 16) + 'px');
515
+ }
516
+ function jumpTo(i) {
517
+ const sb = ui && ui.body.closest('.sb'); const n = ui && ui.body.querySelector('[data-dsec-i="' + i + '"]'); if (!sb || !n) return;
518
+ sb.scrollTop += n.getBoundingClientRect().top - sb.getBoundingClientRect().top - topEdge() - 8;
519
+ n.classList.remove('flash'); void n.offsetWidth; n.classList.add('flash');
520
+ S.cur = i; S.pinCur = i; S.olOpen = false; const lay = ui.body.querySelector('.ds-rlay'); if (lay) lay.classList.remove('olopen');
521
+ const b = ui.body.querySelector('[data-ol-btn]'); if (b) b.setAttribute('aria-expanded', 'false');
522
+ markCur();
523
+ }
524
+ function trackCur() {
525
+ if (S.mode !== 'read' || !ui) return;
526
+ const sb = ui.body.closest('.sb'); const nav = ui.body.querySelector('.ds-ol'); if (!sb || !nav) return;
527
+ const ids = new Set([...nav.querySelectorAll('[data-ol-i]')].map((x) => x.getAttribute('data-ol-i')));
528
+ const lim = sb.getBoundingClientRect().top + topEdge() + 24; let cur = null;
529
+ for (const s of ui.body.querySelectorAll('.ds-rmain [data-dsec-i]')) {
530
+ const k = s.getAttribute('data-dsec-i');
531
+ if (s.getBoundingClientRect().top <= lim) { if (ids.has(k)) cur = +k; } else break;
532
+ }
533
+ if (cur == null) { const f = nav.querySelector('[data-ol-i]'); cur = f ? +f.getAttribute('data-ol-i') : null; }
534
+ /* a section pressed near the end cannot scroll to the top: it stays the marked one while it is on screen */
535
+ if (S.pinCur != null) {
536
+ const p = ui.body.querySelector('[data-dsec-i="' + S.pinCur + '"]'); const r = p && p.getBoundingClientRect(); const b = sb.getBoundingClientRect();
537
+ if (r && r.top >= b.top + topEdge() - 8 && r.top < b.bottom - 40) cur = S.pinCur; else S.pinCur = null;
538
+ }
539
+ if (cur !== S.cur) { S.cur = cur; markCur(); }
540
+ }
541
+ function markCur() {
542
+ const nav = ui && ui.body.querySelector('.ds-ol'); if (!nav) return;
543
+ nav.querySelectorAll('[data-ol-i]').forEach((li) => { const on = +li.getAttribute('data-ol-i') === S.cur; li.classList.toggle('on', on); const g = li.querySelector('[data-ol-go]'); if (g) { if (on) g.setAttribute('aria-current', 'location'); else g.removeAttribute('aria-current'); } });
544
+ const secs = S.text ? splitDoc(S.text) : []; const c = secs.find((s) => s.i === S.cur);
545
+ const ob = ui.body.querySelector('[data-ol-btn] b'); if (ob) ob.textContent = c ? c.name : '';
546
+ const on = nav.querySelector('li.on');
547
+ if (on && nav.scrollHeight > nav.clientHeight) { const t = on.offsetTop - nav.offsetTop; if (t < nav.scrollTop || t > nav.scrollTop + nav.clientHeight - 30) nav.scrollTop = t - nav.clientHeight / 3; }
548
+ }
549
+
550
+ /* ── v2: Relations, the graph around this document (from the caller's library, else GET /api/docs/library) ── */
551
+ const loadLib = async () => {
552
+ if (S.lib !== undefined) return;
553
+ S.lib = null;
554
+ const j = await (library ? Promise.resolve().then(library) : pmGet('/api/docs/library?' + q({ ws }))).catch(() => null);
555
+ S.lib = j && j.ok ? j : { err: (j && j.error) || 'The document graph could not be read.' };
556
+ drawBg();
557
+ };
558
+ const catName = (c) => { const k = ((S.lib && S.lib.categories) || []).find((x) => x.key === c); return k ? k.name : c || 'Document'; };
559
+ const fileDoc = (f) => ({ path: f.path, name: f.title || f.path, ty: f.ty || catName(f.category), v: f.v || '', st: STATE[f.st] ? f.st : 'current', ini: f.initiative || '',
560
+ iniName: (S.lib && S.lib.initiatives && S.lib.initiatives[f.initiative] && (S.lib.initiatives[f.initiative].short || S.lib.initiatives[f.initiative].title)) || f.initiative || '' });
561
+ function graphNodes() {
562
+ const H = hoodOf(S.lib && !S.lib.err ? S.lib : null, doc.path) || { src: [], out: [] };
563
+ const isGoal = (f) => f.category === 'goal' || f.node === 'Goal';
564
+ const nd = (e, side) => { const f = e.f; const file = !f.node && f.st !== 'node' && f.st !== 'missing';
565
+ return { side, name: f.title || f.path, sub: [...new Set([e.kind, f.node || (f.date ? dayName(f.date) : '')].filter(Boolean).map((x) => x.toLowerCase() === String(e.kind).toLowerCase() ? e.kind : x))].join(' · '), st: e.st, path: f.path, open: file ? fileDoc(f) : null }; };
566
+ const top = H.src.filter((e) => isGoal(e.f)).map((e) => nd(e, 'top'));
567
+ if (doc.ini || doc.iniName) top.unshift({ side: 'top', name: doc.iniName || doc.ini, sub: 'initiative', st: 'ok', init: true });
568
+ const left = H.src.filter((e) => !isGoal(e.f)).map((e) => nd(e, 'left'));
569
+ const right = H.out.map((e) => nd(e, 'right')).concat((S.copies || []).map((l) => ({ side: 'right', name: labelOf(l) + ' copy', sub: sigOf(l)[4], url: l.url, kind: l.kind,
570
+ st: l.signal === 'behind' || l.signal === 'diverged' ? 'drift' : l.signal === 'ahead' ? 'ahead' : l.signal === 'same' ? 'ok' : 'missing' })));
571
+ return { top, left, right };
572
+ }
573
+ function graphEl(G) {
574
+ const CAP = 6;
575
+ const node = (n) => {
576
+ const kids = [el('b', null, n.name), el('small', null, n.sub)];
577
+ const a = { 'data-gn': n.side, 'data-gn-st': n.st, 'data-gn-copy': n.kind || null };
578
+ if (n.url) return el('a', { class: 'ds-gn ' + n.st, href: n.url, target: '_blank', rel: 'noopener', ...a }, ...kids);
579
+ if (n.open) return btn('ds-gn ' + n.st, { 'data-gn-path': n.path, title: 'Open ' + n.name, ...a }, kids, () => S.open(n.open));
580
+ return el('div', { class: 'ds-gn x ' + n.st, ...a }, ...kids);
581
+ };
582
+ const col = (list) => [list.slice(0, CAP).map(node), list.length > CAP ? el('div', { class: 'ds-note ds-gmore' }, '+' + (list.length - CAP) + ' more, listed below') : null];
583
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'ds-gsvg'); svg.setAttribute('aria-hidden', 'true');
584
+ const wrap = el('div', { class: 'ds-graph', role: 'group', 'aria-label': 'This document, its sources on the left, its initiative above, the documents that use it and its copies on the right', 'data-doc-graph': '' }, svg,
585
+ G.top.length ? el('div', { class: 'ds-gtop' }, col(G.top)) : null,
586
+ el('div', { class: 'ds-gmid' }, el('div', { class: 'ds-gcol' }, G.left.length ? col(G.left) : el('div', { class: 'ds-note' }, 'No source recorded')),
587
+ el('div', { class: 'ds-gn me', 'data-gn': 'me' }, el('b', null, doc.name), el('small', null, 'this document' + (doc.v ? ' · ' + doc.v : ''))),
588
+ el('div', { class: 'ds-gcol' }, G.right.length ? col(G.right) : el('div', { class: 'ds-note' }, 'Nothing uses it yet'))),
589
+ el('div', { class: 'key ds-note' }, el('span', null, el('i', { class: 'ln ok' }), 'in step'), el('span', null, el('i', { class: 'ln drift' }), 'out of step'), el('span', null, el('i', { class: 'ln ahead' }), 'PMOS ahead of the copy'),
590
+ openGraph ? btn('chip2 lk', { 'data-doc-fullgraph': '' }, [c3ic('tree', 'sm'), 'Open the full graph in Docs'], () => { ui.close(); openGraph(doc); }) : null));
591
+ if (typeof ResizeObserver === 'function') new ResizeObserver(() => drawEdges(wrap)).observe(wrap);
592
+ return wrap;
593
+ }
594
+ function drawEdges(wrap) {
595
+ const svg = wrap.querySelector('.ds-gsvg'); const me = wrap.querySelector('[data-gn="me"]'); if (!svg || !me || !wrap.isConnected) return;
596
+ const W = wrap.getBoundingClientRect(); const M = me.getBoundingClientRect();
597
+ svg.setAttribute('viewBox', '0 0 ' + Math.round(W.width) + ' ' + Math.round(W.height)); svg.setAttribute('width', String(Math.round(W.width))); svg.setAttribute('height', String(Math.round(W.height)));
598
+ const paths = [...wrap.querySelectorAll('[data-gn]:not([data-gn="me"])')].map((n) => {
599
+ const R = n.getBoundingClientRect(); const side = n.getAttribute('data-gn');
600
+ const [fx, fy, tx, ty] = side === 'left' ? [R.right, R.top + R.height / 2, M.left, M.top + M.height / 2] : side === 'right' ? [M.right, M.top + M.height / 2, R.left, R.top + R.height / 2] : [R.left + R.width / 2, R.bottom, M.left + M.width / 2, M.top];
601
+ const x1 = fx - W.left; const y1 = fy - W.top; const x2 = tx - W.left; const y2 = ty - W.top;
602
+ const d = side === 'top' ? 'M' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + (y1 + y2) / 2 + ', ' + x2 + ' ' + (y1 + y2) / 2 + ', ' + x2 + ' ' + y2 : 'M' + x1 + ' ' + y1 + ' C ' + (x1 + x2) / 2 + ' ' + y1 + ', ' + (x1 + x2) / 2 + ' ' + y2 + ', ' + x2 + ' ' + y2;
603
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', d); p.setAttribute('class', 'e ' + (n.getAttribute('data-gn-st') || 'ok')); return p;
604
+ });
605
+ svg.replaceChildren(...paths);
606
+ }
607
+ function linksBody() {
608
+ if (S.lib === undefined) loadLib();
609
+ if (S.lib == null) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the document graph.');
610
+ const G = graphNodes(); const any = G.left.length || G.right.length || G.top.some((n) => !n.init);
611
+ const rel = (h, list) => list.length ? [el('h3', { class: 'ds-h' }, h), el('ul', { class: 'ds-rel' }, list.map((n) => el('li', { 'data-rel': n.path || n.kind || n.name },
612
+ c3ic(n.url ? 'out' : n.init ? 'init' : 'doc', 'sm'),
613
+ n.open ? btn('ds-rlk', { 'data-rel-open': n.path }, [n.name, el('small', null, n.sub)], () => S.open(n.open)) : n.url ? el('a', { class: 'ds-rlk', href: n.url, target: '_blank', rel: 'noopener' }, n.name, el('small', null, n.sub)) : el('span', { class: 'ds-rlk' }, n.name, el('small', null, n.sub)),
614
+ n.st && n.st !== 'ok' ? el('span', { class: 'tag ' + (n.st === 'ahead' ? 'ds-acc' : n.st === 'drift' ? 'ds-warn' : 'ds-bad') }, ESTATE[n.st] || n.st) : el('span'))))] : null;
615
+ return el('div', { class: 'ds' },
616
+ S.lib.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.lib.err) : null,
617
+ any ? graphEl(G) : el('div', { class: 'fold', 'data-doc-norel': '' }, c3ic('info', 'sm'), 'No relation is recorded for this document.'),
618
+ relations ? relations(doc, (d) => { if (S.open) S.open(d); }) : [rel('Sources', G.left), rel('Goals and initiative', G.top.filter((n) => !n.init)), rel('Used by and copies', G.right)]);
619
+ }
620
+
321
621
  /* ── Read ── */
322
622
  function propsBlock(secs, editing) {
323
623
  const s0 = secs.find((s) => s.prefix === ''); const fm = s0 && parseFront(s0.text);
@@ -334,11 +634,14 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
334
634
  if (S.text == null) return S.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.err) : el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the document.');
335
635
  const secs = splitDoc(S.text);
336
636
  if (!S.text.trim()) return el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'The document is empty.');
337
- const named = secs.filter((s) => s.prefix);
338
- const toc = el('select', { class: 'ds-sel', 'aria-label': 'Contents', 'data-doc-toc': '', onChange: (e) => { const v = e.target.value; e.target.value = ''; if (v !== '') goEl('[data-dsec-i="' + v + '"]'); } },
339
- el('option', { value: '' }, 'Contents'), named.map((s) => el('option', { value: String(s.i) }, (headLevel(s) > 2 ? ' ' : '') + s.name)));
637
+ const items = outlineOf(secs); const olOn = items.length >= 2 && !S.src;
638
+ if (S.cur == null && items.length) S.cur = items[0].s.i;
639
+ const curName = (items.find((x) => x.s.i === S.cur) || items[0] || { s: { name: '' } }).s.name;
640
+ const olCtl = olOn ? el('span', { class: 'ds-acts' },
641
+ btn('ds-btn ds-olbtn', { 'aria-expanded': String(S.olOpen), 'data-ol-btn': '' }, [c3ic('ul', 'sm'), 'Outline · ', el('b', null, curName)], () => { S.olOpen = !S.olOpen; const lay = ui.body.querySelector('.ds-rlay'); if (lay) lay.classList.toggle('olopen', S.olOpen); const b = ui.body.querySelector('[data-ol-btn]'); if (b) b.setAttribute('aria-expanded', String(S.olOpen)); }),
642
+ btn('ds-btn ds-olhide', { 'aria-pressed': String(!S.ol), 'data-ol-hide': '' }, [c3ic('ul', 'sm'), S.ol ? 'Hide the outline' : 'Show the outline'], () => { S.ol = !S.ol; olSet(S.ol); draw(); })) : el('span');
340
643
  const tg = (id, label, on, set) => el('label', { class: 'ds-tg' }, el('input', { type: 'checkbox', checked: on, 'data-doc-toggle': id, onChange: (e) => { set(e.target.checked); draw(); } }), label);
341
- const bar = el('div', { class: 'ds-rbar' }, toc, el('span', { class: 'ds-acts' },
644
+ const bar = el('div', { class: 'ds-rbar' }, olCtl, el('span', { class: 'ds-acts' },
342
645
  generated ? null : tg('who', 'Show who wrote what', S.who, (v) => { S.who = v; }), tg('src', 'Markdown source', S.src, (v) => { S.src = v; })));
343
646
  const legend = S.who ? el('div', { class: 'ds-legend' }, el('span', null, el('i', { class: 'h' }), 'Written by a person'), el('span', null, el('i', { class: 'a' }), 'Written by the agent, accepted by a person')) : null;
344
647
  if (S.src) {
@@ -368,8 +671,9 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
368
671
  });
369
672
  const adds = openProps().flatMap((p) => p.sections.filter((c) => c.kind === 'added' && S.preview[p.id]).map((c) => el('div', { class: 'ds-sec a prev', 'data-dsec-new': c.name },
370
673
  el('div', { class: 'ds-pin' }, el('span', null, c3ic('spark', 'sm'), 'The proposal adds a section: ' + c.name)), el('div', { class: 'ds-sec-h' }, el('h2', null, c.name)), md(diffMd(null, c.after), 'ds-body'))));
371
- return el('div', { class: 'ds' + (S.who ? ' who' : '') }, bar, legend, generated ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null,
372
- staleBanner(), propsBlock(secs, false), cells, adds);
674
+ const main = [generated ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null, staleBanner(), propsBlock(secs, false), cells, adds];
675
+ return el('div', { class: 'ds' + (S.who ? ' who' : '') }, bar, legend,
676
+ olOn ? el('div', { class: 'ds-rlay' + (S.ol ? '' : ' nool') + (S.olOpen ? ' olopen' : '') }, outlineNav(items), el('div', { class: 'ds-rmain' }, main)) : el('div', { class: 'ds-rmain' }, main));
373
677
  }
374
678
  const short = (t) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); return s.length > 90 ? s.slice(0, 88) + '…' : s; };
375
679
 
@@ -709,8 +1013,6 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
709
1013
  el('p', { class: 'ds-note' }, 'Health answers one question: can I trust this document without rereading it? The checks run again every time the text changes.'));
710
1014
  }
711
1015
 
712
- const linksBody = () => (relations ? relations(doc, (d) => { if (S.open) S.open(d); }) : el('div', { class: 'fold' }, c3ic('info', 'sm'), 'No relation is recorded for this document.'));
713
-
714
1016
  /* a load that finished in the background must not redraw under a hand that is typing */
715
1017
  const drawBg = () => { const a = document.activeElement; if (ui && a && ui.body.contains(a) && a.closest('input,textarea,[contenteditable=true]')) { refreshTop(); return; } draw(); };
716
1018
  const top = () => el('div', { class: 'ds-top' }, head(), seg(), S.mode === 'edit' && S.ed ? toolbar() : null,
@@ -723,21 +1025,26 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
723
1025
  const body = S.mode === 'read' ? readBody() : S.mode === 'edit' ? editBody() : S.mode === 'ask' ? propsBody() : S.mode === 'hist' ? logBody() : S.mode === 'health' ? healthBody() : linksBody();
724
1026
  const keep = ui.body.querySelector('textarea:focus,input[type=text]:focus');
725
1027
  const had = keep ? { id: keep.id, s: keep.selectionStart } : null;
726
- ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), body));
1028
+ ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), copiesPanel(), body));
1029
+ const sh = ui.body.closest('.sheet'); if (sh) sh.classList.toggle('dwx', S.mode === 'read' && S.ol && !!ui.body.querySelector('.ds-ol'));
1030
+ const sb = ui.body.closest('.sb');
1031
+ if (sb && !sb.__dsTrack) { sb.__dsTrack = true; sb.addEventListener('scroll', trackCur, { passive: true }); }
1032
+ if (S.mode === 'read') { placeOl(); trackCur(); }
1033
+ if (S.mode === 'links') { const g = ui.body.querySelector('.ds-graph'); if (g) requestAnimationFrame(() => drawEdges(g)); }
727
1034
  if (had && had.id) { const n = ui.body.querySelector('#' + had.id); if (n) { n.focus(); try { n.setSelectionRange(had.s, had.s); } catch { /* not a text field */ } } }
728
1035
  if (S.mode === 'ask' && S.openProp) { const id = S.openProp; S.openProp = null; goEl('[data-doc-prop-card="' + id + '"]'); }
729
1036
  }
730
1037
 
731
1038
  document.addEventListener('selectionchange', keepRange);
732
1039
  ui = openSheet({ title: doc.name, icon: 'doc', dw: true, body: el('div', { class: 'dpn' }), onClose: () => { S.closed = true; document.removeEventListener('selectionchange', keepRange); if (S.changed && onChanged) onChanged(); } });
733
- S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); };
1040
+ S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); else openDocSheet({ ws, doc: d, library, openGraph }); };
734
1041
  ui.setOpener = (fn) => { S.openOther = fn; };
735
1042
  draw();
736
1043
  (async () => {
737
1044
  await loadText();
738
1045
  if (S.mode === 'edit') startEdit();
739
1046
  drawBg();
740
- await Promise.all([loadRevs(), loadProps(), loadHealth()]);
1047
+ await Promise.all([loadRevs(), loadProps(), loadHealth(), loadCopies()]);
741
1048
  if (S.mode === 'hist') await loadLog();
742
1049
  drawBg(); loadMeta();
743
1050
  })();
@@ -9,8 +9,8 @@
9
9
  * GET /api/runs the needs-you count only (a run that asks, holds a permission or needs the terminal); the cards are on the Terminal page
10
10
  * GET and POST /api/scratchpad the session's pad
11
11
  * The Message box, Send and End are DISABLED (drawn as the mockup's): no page press route sends a prompt to a session, and none is invented here.
12
- * Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is the rail's
13
- * button (it opens this dock, the terminals of your branches) and every "Open the session" link. The rail draws one glyph button per session below it. */
12
+ * Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is every "Open the session" link.
13
+ * A folded dock leaves no rail (PM, 2026-10-12): Ctrl J and the More menu open it again. */
14
14
  import { el } from './dom.js';
15
15
  import { pmGet, pmPost } from './http.js';
16
16
  import { c3ic } from './pm-work-view.js';
@@ -53,12 +53,11 @@ export function createDock(opts) {
53
53
  el('a', { class: 'ib sm', href: '/', title: 'Start a branch and session in the main app. A session starts over the terminal connection; the page has no route for it.', 'aria-label': 'Start a branch and session in the main app' }, c3ic('plus', 'sm')),
54
54
  el('button', { type: 'button', class: 'ib sm', title: 'Fold the dock (Ctrl J)', 'aria-label': 'Fold the dock', onclick: () => o.setOpen && o.setOpen(false) }, c3ic('fold'))),
55
55
  body);
56
- const rail = el('div', { class: 'c3dock dock rail', 'data-c3pg': 'dock-rail' });
57
56
 
58
57
  const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
59
58
  const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
60
59
  const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
61
- const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); drawRail(); if (o.setOpen) o.setOpen(true); };
60
+ const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
62
61
 
63
62
  function row(s) {
64
63
  const g = smOf(s);
@@ -129,15 +128,9 @@ export function createDock(opts) {
129
128
  const r = await pmPost('/api/session/remove', { id: s.id });
130
129
  if (r && r.ok) { S.ended.add(s.id); toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 }); }
131
130
  else toast({ text: (r && r.error) ? 'Not ended: ' + r.error : NOEND, tone: 'error' });
132
- drawTree(); drawDetail(); drawRail(); refresh();
131
+ drawTree(); drawDetail(); refresh();
133
132
  }
134
133
 
135
- function drawRail() {
136
- const all = S.sessions.filter((s) => !s.automatic); // the mockup's rail lists the first six sessions of every repository
137
- const dots = all.slice(0, 6).map((s) => el('button', { type: 'button', class: 'ib sm', 'data-c3-rail': s.id, title: s.name + ' \u00b7 ' + (s.label || s.shown), 'aria-label': 'Show the session ' + s.name + ' in the dock', onclick: () => pick(s.id) }, el('span', { class: 'mk ' + smOf(s)[1] }, smOf(s)[0])));
138
- /* the TERMINAL glyph: this button opens the dock (the terminals of your branches). The needs-you badge is on the agent button, not here. */
139
- rail.replaceChildren(el('button', { type: 'button', class: 'ib', title: TIPS.terminalRail, 'aria-label': 'Open the sessions dock', 'data-c3pg': 'rail-open', onclick: () => o.setOpen && o.setOpen(true) }, ic('term')), ...dots);
140
- }
141
134
 
142
135
  async function refresh() {
143
136
  const seq = ++S.seq; const ws = S.ws;
@@ -148,7 +141,7 @@ export function createDock(opts) {
148
141
  if (wt && wt.ok) { S.trees = wt.worktrees || []; S.err = null; } else if (!S.trees) S.trees = [];
149
142
  const m = mine();
150
143
  if (!m.some((x) => x.id === S.sel)) S.sel = (m.find((x) => x.shown !== 'exited') || m[0] || {}).id || null;
151
- drawTree(); drawRail();
144
+ drawTree();
152
145
  const cur = detail.getAttribute('data-sel');
153
146
  if (cur !== String(S.sel)) { detail.setAttribute('data-sel', String(S.sel)); drawDetail(); }
154
147
  if (o.onCount) { const all = S.sessions.filter((x) => !x.automatic); o.onCount(all.filter((x) => x.shown === 'working').length, S.need); }
@@ -157,7 +150,7 @@ export function createDock(opts) {
157
150
  /* the mockup's keys: Ctrl or Cmd J folds and opens the dock (title "Open sessions (Ctrl J)"); the arrows move along the tree */
158
151
  const onKey = (e) => {
159
152
  if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'j' || e.key === 'J')) {
160
- if (!side.isConnected && !rail.isConnected) return;
153
+ if (!side.isConnected) return;
161
154
  e.preventDefault(); if (o.setOpen) o.setOpen(side.hidden); return;
162
155
  }
163
156
  if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target.closest && e.target.closest('.dk-tree')) {
@@ -167,13 +160,13 @@ export function createDock(opts) {
167
160
  };
168
161
  let timer = null;
169
162
  const api = {
170
- side, rail,
163
+ side,
171
164
  setWorkspaces(list, ws) {
172
165
  S.ws = ws;
173
166
  sel.replaceChildren(...(list.length ? list : [{ id: ws, name: ws || 'workspace' }]).map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
174
167
  sel.onchange = (e) => { S.ws = e.target.value; S.trees = null; S.sel = null; detail.removeAttribute('data-sel'); if (o.onWs) o.onWs(S.ws); drawTree(); refresh(); };
175
168
  },
176
- start() { document.addEventListener('keydown', onKey); drawTree(); drawRail(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
169
+ start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
177
170
  stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
178
171
  refresh,
179
172
  };
@@ -58,7 +58,8 @@ export async function badge(ctx) {
58
58
  function openDoc(V, d, ask) {
59
59
  if (!d || d.st === 'missing' || d.node) return;
60
60
  const ui = openDocSheet({ ws: V.ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: d.v, st: d.st, ini: d.ini, iniName: iniName(V, d.ini) }, mode: ask ? 'ask' : 'read', prompt: ask || '',
61
- relations: (doc, openOther) => relationsOf(V, d, openOther), onChanged: () => { delete V.health[d.path]; V.asking = false; load(V); } });
61
+ relations: (doc, openOther) => relationsOf(V, d, openOther), library: () => V.lib, openGraph: () => { V.view = 'graph'; V.sel = d.i; V.render(); },
62
+ onChanged: () => { delete V.health[d.path]; V.asking = false; load(V); } });
62
63
  ui.setOpener((o) => openDoc(V, o));
63
64
  }
64
65
  const iniName = (V, id) => { const i = V.lib && V.lib.initiatives && V.lib.initiatives[id]; return i ? i.short || i.title : id || ''; };
@@ -30,7 +30,9 @@ export async function loadDocs(ctx, idSet) {
30
30
  async function openDoc(ctx, d, titles) {
31
31
  try {
32
32
  const { openDocSheet } = await import('./c3-doc-sheet.js');
33
- openDocSheet({ ws: ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: '', st: d.st, ini: d.ini, iniName: titles.get(d.ini) || d.ini } });
33
+ const ui = openDocSheet({ ws: ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: '', st: d.st, ini: d.ini, iniName: titles.get(d.ini) || d.ini },
34
+ library: () => ctx.api.get('/api/docs/library', { board: ctx.board.id }) });
35
+ ui.setOpener((o) => openDoc(ctx, { path: o.path, name: o.name, ty: o.ty, st: o.st, ini: o.ini }, titles));
34
36
  } catch { ctx.toast('The document sheet could not open. The file is ' + d.path + '.'); }
35
37
  }
36
38
 
@@ -102,8 +102,8 @@ function header() {
102
102
  S.ttl = el('h1', { 'data-c3pg': 'title' });
103
103
  S.sub = el('div', { class: 'sub', 'data-c3pg': 'date' }, dateLine());
104
104
  S.termBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'term-count' }, '0');
105
- /* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. The dock's own button is the TERMINAL glyph (c3-terminal-icons.js); Ctrl J and the More menu fold the dock. */
106
- S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), 'Sessions', S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
105
+ /* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. It is an icon only (PM, 2026-10-12: no "Sessions" label); its tooltip and aria-label name it. Ctrl J and the More menu fold and open the dock. */
106
+ S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
107
107
  attrs: { class: 'psess', title: 'Agent surfaces: sessions, results to accept, questions for you', 'aria-label': 'Agent surfaces: sessions, results to accept, questions for you', 'aria-haspopup': 'dialog', 'data-c3pg': 'agent-button' } }); // the mockup's header button (agent-surfaces.html #act)
108
108
  S.tools = el('div', { class: 'tools', 'data-c3pg': 'tools' });
109
109
  if (S.demoMod) S.tools.append(S.demoMod.demoPill());
@@ -423,15 +423,14 @@ function dock() {
423
423
  /* the needs-you badge on the agent button: runs that ask, hold a permission or need the terminal (not the running ones) */
424
424
  onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
425
425
  });
426
- S.dockEl = S.dock.side; S.rail = S.dock.rail;
426
+ S.dockEl = S.dock.side;
427
427
  // a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
428
428
  window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
429
- return [S.dock.side, S.dock.rail];
429
+ return [S.dock.side];
430
430
  }
431
- /* The mockup opens the dock on the Briefing and folds it to a rail on the other tabs (console3.html line 2017). */
431
+ /* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
432
432
  function syncDock() {
433
433
  S.dockEl.hidden = !S.dockOpen;
434
- S.rail.hidden = S.dockOpen || !S.wide;
435
434
  }
436
435
 
437
436
  /* ── the other tabs ── */