create-pmos 0.65.0 → 0.65.1

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.
@@ -6,13 +6,17 @@
6
6
  * only the body is rebuilt when its own data changed.
7
7
  *
8
8
  * What it shows, for the repository the tab names (or the open one):
9
- * - Docs, a search box, Needs you · All, and when the copies were last compared, with a control that compares again;
10
- * - the copies grouped by initiative, ONE line per copy: a status dot, the title, a link that opens the copy, one
9
+ * - Docs, a search box, Needs you · With a copy · All files, and when the copies were last compared, with a control
10
+ * that compares again;
11
+ * - the copies grouped by category, ONE line per copy: a status dot, the title, a link that opens the copy, one
11
12
  * sentence (which side is newer, and who edited it last) and at most one button (Use PMOS version, Use Confluence
12
13
  * version, or Choose… when both changed);
13
14
  * - a click on a line: both sides with where they live and their last edit, a Use … version button for each, the first
14
- * difference the check saw, Compare again and Stop tracking this copy;
15
- * - search: every markdown file that matches; a file without a copy offers Add copy, in place.
15
+ * difference the check saw, Compare again, Stop tracking this copy, and the file's links (doc-graph.js);
16
+ * - All files (docs-library-graph): every repository file under folding categories, each line naming its initiative;
17
+ * a category opens on its first 20 files. A press on a file shows the files it links to and the files that link to
18
+ * it. Add copy shows on hover or focus, and always on a touch screen;
19
+ * - search: every file whose title, path or initiative matches, grouped by category, with the match marked.
16
20
  * The signal is decided on the server (lib/doc-sync.js) from what a check session read. Nothing here reads or writes
17
21
  * Google Docs or Confluence. Use … version waits five seconds behind Undo, then asks the server to start ONE doc-sync
18
22
  * session for that copy, which writes one side with the PM's own connectors (the PM's ruling, amended 2026-10-06).
@@ -21,13 +25,20 @@
21
25
 
22
26
  import { el } from './dom.js';
23
27
  import { button, row, field, toast, empty, tabs, ensureComponentsCss } from './primitives.js';
24
- import { ensureSprite } from './icons.js';
28
+ import { ensureSprite, icon } from './icons.js';
25
29
  import { pmGet, pmPost } from './http.js';
26
30
  import { wsOf } from './data.js';
27
31
  import { registerCommand, openDestination } from './registry.js';
32
+ import { getLibrary, fileGraph } from './doc-graph.js';
33
+ import { openDoc } from './panel-initiatives.js';
28
34
 
29
35
  const LABEL = { google: 'Google Docs', confluence: 'Confluence' };
30
36
  const UNDO_MS = 5000;
37
+ const PAGE = 20;
38
+ /* the library's categories, in display order (lib/doc-library.js CATEGORIES) */
39
+ const CATS = [['spec', 'Specs'], ['ini', 'Initiatives'], ['dec', 'Decisions'], ['res', 'Research & plans'], ['exp', 'Explainers'],
40
+ ['eval', 'Evals'], ['know', 'Knowledge'], ['guide', 'Guides & rules'], ['proto', 'Prototypes']];
41
+ const CAT_NAME = Object.fromEntries(CATS);
31
42
 
32
43
  export const COPY = Object.freeze({
33
44
  title: 'Docs',
@@ -36,20 +47,25 @@ export const COPY = Object.freeze({
36
47
  loading: 'Reading…',
37
48
  readError: 'The documents could not be read.',
38
49
  find: 'Find a document',
50
+ findIn: (n) => `Find in ${n.toLocaleString('en-US')} files`,
39
51
  needs: 'Needs you',
40
- all: 'All',
52
+ copies: 'With a copy',
53
+ files: 'All files',
54
+ readingFiles: 'Reading the files…',
55
+ filesError: 'The file list could not be read.',
56
+ matches: (n, all) => `${n} of ${all.toLocaleString('en-US')} files match by title, path or initiative. Press Esc to clear.`,
57
+ showAll: (n, cat) => `Show all ${n} in ${cat}`,
58
+ needYou: (n) => `${n} need${n === 1 ? 's' : ''} you`,
41
59
  compared: (iso) => 'Compared ' + ago(iso),
42
60
  never: 'Not compared yet',
43
61
  comparing: 'Comparing…',
44
62
  compareAll: 'Compare every copy again',
45
- none: 'No document has a copy yet. Find a document above and press Add copy.',
63
+ none: 'No document has a copy yet. Open All files and press Add copy on a file.',
46
64
  nothingNeeds: 'Nothing needs you. Every copy matches PMOS.',
47
65
  noMatch: (q) => `No document matches “${q}”.`,
48
- other: 'Other',
49
66
  usePmos: 'Use PMOS version',
50
67
  useCopy: (label) => `Use ${label} version`,
51
68
  choose: 'Choose…',
52
- noCopy: 'No copy',
53
69
  addCopy: 'Add copy',
54
70
  add: 'Add',
55
71
  cancel: 'Cancel',
@@ -91,6 +107,23 @@ function when(iso) {
91
107
  if (days > 1 && days < 7) return t.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + hm;
92
108
  return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
93
109
  }
110
+ /** A file's last edit from its YYYY-MM-DD date: "Oct 6", or "Oct 6, 2025" in another year. */
111
+ function day(d) {
112
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(d || ''));
113
+ if (!m) return '';
114
+ const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
115
+ return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(t.getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}) });
116
+ }
117
+ /** The text with every match of q marked. */
118
+ function marked(text, q) {
119
+ const t = String(text || '');
120
+ if (!q) return t;
121
+ const out = []; const low = t.toLowerCase(); let at = 0;
122
+ for (let k = low.indexOf(q); k >= 0 && q; k = low.indexOf(q, at)) { out.push(t.slice(at, k), el('mark', { class: 'rp-docs-mark' }, t.slice(k, k + q.length))); at = k + q.length; }
123
+ out.push(t.slice(at));
124
+ return out;
125
+ }
126
+ const openFile = (p) => openDoc(null, p, 'FILE');
94
127
  const edit = (by, at) => [by, at ? when(at) : null].filter(Boolean).join(', ');
95
128
  const keyOf = (doc, link) => `${doc.path}|${link.kind}`;
96
129
  /** Which kind a pasted address names. The server checks it again; this only picks the kind. */
@@ -134,7 +167,8 @@ export function mountDestination(host, ctx) {
134
167
  if (!inst) {
135
168
  inst = { host, root: el('div', { class: 'rp-page rp-docs', 'data-docs': '' }), head: null, body: el('div', { class: 'rp-docs-body' }),
136
169
  ws: null, loaded: false, error: null, view: null, filter: 'needs', q: '', files: [], open: null, adding: null,
137
- pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null };
170
+ pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null,
171
+ lib: null, libError: null, openCats: new Set(), full: new Set(), graph: null };
138
172
  insts.set(host, inst);
139
173
  }
140
174
  ensureComponentsCss();
@@ -144,7 +178,8 @@ export function mountDestination(host, ctx) {
144
178
  const ws = repo ? wsOf(repo) : null;
145
179
  if (ws !== inst.ws) {
146
180
  stopPoll(inst);
147
- Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [] });
181
+ Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [], lib: null, libError: null, graph: null });
182
+ inst.openCats.clear(); inst.full.clear();
148
183
  if (ws) { ensureSprite(); refresh(inst); }
149
184
  draw(inst);
150
185
  return;
@@ -177,8 +212,9 @@ async function refresh(inst) {
177
212
  if (!ws) return;
178
213
  const seq = ++inst.seq;
179
214
  inst.readAt = Date.now();
180
- const v = await pmGet('/api/docs?ws=' + encodeURIComponent(ws));
215
+ const [v, lib] = await Promise.all([pmGet('/api/docs?ws=' + encodeURIComponent(ws)), getLibrary(ws)]);
181
216
  if (seq !== inst.seq || ws !== inst.ws) return;
217
+ if (lib) { inst.lib = lib; inst.libError = null; } else if (!inst.lib) inst.libError = COPY.filesError;
182
218
  inst.loaded = true;
183
219
  if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
184
220
  else { inst.error = null; inst.view = v; }
@@ -190,7 +226,7 @@ async function refresh(inst) {
190
226
  function searchFiles(inst) {
191
227
  if (inst.ftimer) clearTimeout(inst.ftimer);
192
228
  const q = inst.q.trim();
193
- if (!q) { inst.files = []; return; }
229
+ if (!q || inst.lib) { inst.files = []; return; }
194
230
  inst.ftimer = setTimeout(async () => {
195
231
  const seq = ++inst.fseq;
196
232
  const r = await pmGet('/api/docs/files?ws=' + encodeURIComponent(inst.ws) + '&q=' + encodeURIComponent(q));
@@ -252,7 +288,7 @@ async function addCopy(inst, path, target, err) {
252
288
 
253
289
  /* ── lines ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
254
290
 
255
- function copyLine(inst, doc, link) {
291
+ function copyLine(inst, doc, link, q = '') {
256
292
  const key = keyOf(doc, link);
257
293
  const L = link.label || LABEL[link.kind];
258
294
  const st = stateOf(inst, doc, link);
@@ -270,7 +306,7 @@ function copyLine(inst, doc, link) {
270
306
  ? el('a', { class: 'rp-docs-open', href: link.url, target: '_blank', rel: 'noopener noreferrer', 'data-docs-open': link.kind }, L + ' ↗')
271
307
  : el('span', { class: 'rp-docs-open' }, L);
272
308
  const line = row({
273
- title: doc.title,
309
+ title: marked(doc.title, q),
274
310
  meta: [st.strong, st.rest].filter(Boolean),
275
311
  lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--' + st.dot, 'aria-hidden': 'true' })],
276
312
  trail: [opener, act].filter(Boolean),
@@ -304,6 +340,7 @@ function details(inst, doc, link, st) {
304
340
  side('PMOS', doc.path, edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at), pNew, 'pmos'),
305
341
  side(L, link.target, edit(link.remote_modified_by, link.remote_modified_at), cNew, 'copy'),
306
342
  diff,
343
+ inst.lib && inst.lib.byPath.has(doc.path) ? el('div', { class: 'rp-docs-links' }, fileGraph(inst.lib, doc.path, { onOpen: openFile })) : null,
307
344
  el('div', { class: 'rp-docs-more' },
308
345
  button({ label: COPY.compareAgain, variant: 'ghost', size: 'sm', disabled: !!(inst.view && (inst.view.open || inst.view.session)) || inst.busy,
309
346
  attrs: { 'data-docs-act': 'compare-one' }, onClick: () => compareAll(inst) }),
@@ -311,18 +348,28 @@ function details(inst, doc, link, st) {
311
348
  onClick: () => stopTracking(inst, doc, link) })));
312
349
  }
313
350
 
314
- /** A file with no copy: "No copy" and Add copy, which opens a paste field in place. */
315
- function fileLine(inst, f) {
316
- const start = () => { inst.adding = f.path; draw(inst); };
351
+ /**
352
+ * A file with no copy: its title, its initiative's short name (or its path) and the date of its last edit. A press shows
353
+ * its links; Add copy opens a paste field in place.
354
+ */
355
+ function fileLine(inst, f, q = '') {
356
+ const start = () => { inst.adding = f.path; inst.graph = null; draw(inst); };
317
357
  const adding = inst.adding === f.path;
358
+ const open = inst.graph === f.path;
359
+ const toggle = () => { inst.graph = open ? null : f.path; draw(inst); };
360
+ const ini = inst.lib && f.initiative ? inst.lib.ini(f) : null;
318
361
  const line = row({
319
- title: f.title,
320
- meta: [COPY.noCopy],
362
+ title: marked(f.title, q),
363
+ meta: [ini ? el('span', { class: 'rp-docs-ini', title: ini.title }, marked(ini.short, q)) : el('span', { class: 'rp-docs-path' }, marked(f.path, q)), day(f.date)],
321
364
  lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--none', 'aria-hidden': 'true' })],
322
- trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
365
+ trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { class: 'rp-docs-addcopy', 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
366
+ onOpen: inst.lib ? toggle : null,
367
+ openLabel: `${f.title}: show the files it links to`,
368
+ openAttrs: { 'aria-expanded': open ? 'true' : 'false', title: f.path, 'data-docs-file-toggle': f.path },
323
369
  attrs: { 'data-docs-file-line': f.path, title: f.path },
324
370
  });
325
371
  const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-file': f.path }, line);
372
+ if (open && inst.lib) wrap.appendChild(el('div', { class: 'rp-docs-links' }, fileGraph(inst.lib, f.path, { onOpen: openFile })));
326
373
  if (!adding) return wrap;
327
374
  const err = el('span', { class: 'rp-docs-add__err', role: 'alert' });
328
375
  const inp = field({ label: `Copy of ${f.title}`, hideLabel: true, placeholder: COPY.paste, attrs: { 'data-docs-field': 'target' } });
@@ -344,57 +391,110 @@ function header(inst) {
344
391
  if (inst.head) return inst.head;
345
392
  const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
346
393
  const q = find.control;
347
- q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; searchFiles(inst); draw(inst); });
394
+ inst.findControl = q;
395
+ q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; inst.graph = null; inst.full.clear(); searchFiles(inst); draw(inst); });
348
396
  // The field restores its last committed text on Escape; here Escape clears the search instead.
349
- q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; searchFiles(inst); draw(inst); } });
397
+ q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; inst.graph = null; searchFiles(inst); draw(inst); } });
350
398
  inst.head = el('div', { class: 'rp-docs-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-docs-find' }, find));
351
399
  return inst.head;
352
400
  }
353
401
 
354
- function grouped(inst, docs) {
355
- const by = new Map();
356
- for (const d of docs) {
357
- const k = d.initiative ? d.initiative.id : '';
358
- if (!by.has(k)) by.set(k, { title: d.initiative ? d.initiative.title : COPY.other, docs: [] });
359
- by.get(k).docs.push(d);
402
+ const needsYou = (inst, d, l) => l.signal !== 'same' || !!l.syncing || inst.pending.has(keyOf(d, l));
403
+
404
+ /**
405
+ * One category: its title (a fold when `foldable`), then its lines — files with a copy first, then newest first; the
406
+ * first 20, then Show all. `entries` are { f, doc }: f the file, doc its copies (null for a file with none).
407
+ */
408
+ function section(inst, key, entries, { q = '', foldable = false, page = true } = {}) {
409
+ const name = CAT_NAME[key] || key;
410
+ const open = !foldable || inst.openCats.has(key);
411
+ const copies = entries.reduce((n, e) => n + (e.doc ? e.doc.links.length : 0), 0);
412
+ const needs = entries.reduce((n, e) => n + (e.doc ? e.doc.links.filter((l) => needsYou(inst, e.doc, l)).length : 0), 0);
413
+ const toggle = () => { if (inst.openCats.has(key)) inst.openCats.delete(key); else inst.openCats.add(key); draw(inst); };
414
+ const head = foldable
415
+ ? row({ title: name, meta: [String(entries.length), needs ? COPY.needYou(needs) : null], lead: [icon(open ? 'chevron-down' : 'chevron-right')],
416
+ onOpen: toggle, openLabel: `${name}, ${entries.length} files${needs ? ', ' + COPY.needYou(needs) : ''}`,
417
+ openAttrs: { 'aria-expanded': open ? 'true' : 'false', 'data-docs-cat-toggle': key }, attrs: { class: 'rp-docs-cat', 'data-docs-cat': key, 'data-docs-count': String(entries.length) } })
418
+ : el('h3', { class: 'rp-docs-group__title' }, `${name} · ${entries.length}`);
419
+ const box = el('section', { class: 'rp-docs-group', 'data-docs-group': key }, head);
420
+ if (!open) return box;
421
+ const sorted = entries.slice().sort((a, b) => (!!b.doc - !!a.doc) || String(b.f.date || '').localeCompare(String(a.f.date || '')) || a.f.path.localeCompare(b.f.path));
422
+ const all = !page || inst.full.has(key);
423
+ const shown = all ? sorted : sorted.slice(0, PAGE);
424
+ const list = el('div', { role: 'list' }, shown.flatMap((e) => (e.doc ? e.doc.links.map((l) => copyLine(inst, e.doc, l, q)) : [fileLine(inst, e.f, q)])));
425
+ box.appendChild(list);
426
+ if (shown.length < sorted.length) {
427
+ box.appendChild(button({ label: COPY.showAll(sorted.length, name), variant: 'ghost', size: 'sm', attrs: { class: 'rp-docs-showall', 'data-docs-act': 'show-all', 'data-docs-key': key },
428
+ onClick: () => { inst.full.add(key); draw(inst); } }));
360
429
  }
361
- return [...by.entries()].sort((a, b) => (a[0] === '') - (b[0] === '')).map(([k, g]) =>
362
- el('section', { class: 'rp-docs-group', 'data-docs-group': k || 'other' },
363
- el('h3', { class: 'rp-docs-group__title' }, g.title),
364
- el('div', { role: 'list' }, g.docs.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))))));
430
+ return box;
431
+ }
432
+
433
+ /** Entries grouped by category, in the categories' order. */
434
+ function byCategory(entries) {
435
+ const by = new Map();
436
+ for (const e of entries) { const k = CAT_NAME[e.f.category] ? e.f.category : 'guide'; if (!by.has(k)) by.set(k, []); by.get(k).push(e); }
437
+ return CATS.map(([k]) => [k, by.get(k)]).filter(([, es]) => es && es.length);
438
+ }
439
+
440
+ /** Every file: the library's, with each one's copies, plus a file with a copy the library does not list yet. */
441
+ function allEntries(inst) {
442
+ const v = inst.view;
443
+ const docOf = new Map(v.docs.map((d) => [d.path, d]));
444
+ const out = inst.lib ? inst.lib.files.map((f) => ({ f, doc: docOf.get(f.path) || null })) : [];
445
+ const listed = new Set(out.map((e) => e.f.path));
446
+ for (const d of v.docs) if (!listed.has(d.path)) out.push({ f: { path: d.path, title: d.title, category: d.category, date: null, initiative: null }, doc: d });
447
+ return out;
365
448
  }
366
449
 
367
450
  function body(inst) {
368
451
  const v = inst.view;
369
452
  const q = inst.q.trim().toLowerCase();
453
+ const lib = inst.lib;
454
+ if (inst.findControl) inst.findControl.placeholder = lib ? COPY.findIn(lib.files.length) : COPY.find;
370
455
  if (q) {
371
- const hits = v.docs.filter((d) => [d.title, d.path, d.initiative ? d.initiative.title : ''].join(' ').toLowerCase().includes(q));
372
- const rows = [...hits.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))), ...inst.files.map((f) => fileLine(inst, f))];
373
- return rows.length ? [el('div', { role: 'list', class: 'rp-docs-group', 'data-docs-results': '' }, rows)] : [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
456
+ if (!lib) {
457
+ const hits = v.docs.filter((d) => [d.title, d.path, d.initiative ? d.initiative.title : ''].join(' ').toLowerCase().includes(q));
458
+ const rows = [...hits.flatMap((d) => d.links.map((l) => copyLine(inst, d, l, q))), ...inst.files.map((f) => fileLine(inst, f, q))];
459
+ return rows.length ? [el('div', { role: 'list', class: 'rp-docs-group', 'data-docs-results': '' }, rows)] : [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
460
+ }
461
+ const hay = (e) => { const ini = e.f.initiative ? lib.ini(e.f) : null; return [e.f.title, e.f.path, ini ? ini.short + ' ' + ini.title : ''].join(' ').toLowerCase(); };
462
+ const hits = allEntries(inst).filter((e) => hay(e).includes(q));
463
+ if (!hits.length) return [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
464
+ return [el('p', { class: 'rp-docs-hint', 'data-docs-results': String(hits.length) }, COPY.matches(hits.length, lib.files.length)),
465
+ ...byCategory(hits).map(([k, es]) => section(inst, k, es, { q }))];
374
466
  }
375
467
  const all = v.docs.reduce((n, d) => n + d.links.length, 0);
376
- const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => l.signal !== 'same' || l.syncing).length, 0);
468
+ const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => needsYou(inst, d, l)).length, 0);
377
469
  const running = !!(v.open || v.session) || inst.busy;
378
470
  const out = [el('div', { class: 'rp-docs-views' },
379
- tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'all', label: COPY.all, count: all }],
380
- value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; draw(inst); } }),
471
+ tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'copies', label: COPY.copies, count: all },
472
+ { id: 'files', label: COPY.files, count: lib ? lib.files.length.toLocaleString('en-US') : null }],
473
+ value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; inst.graph = null; draw(inst); } }),
381
474
  el('span', { class: 'rp-docs-when', 'data-docs-summary': '' },
382
475
  running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never),
383
476
  button({ label: '↻', variant: 'ghost', size: 'sm', disabled: running || !all,
384
477
  attrs: { 'data-docs-act': 'check', 'aria-label': COPY.compareAll, title: COPY.compareAll }, onClick: () => compareAll(inst) })))];
478
+ if (inst.filter === 'files') {
479
+ if (!lib) { out.push(el('p', { class: 'rp-section__empty' + (inst.libError ? ' rp-section__empty--bad' : '') }, inst.libError || COPY.readingFiles)); return out; }
480
+ out.push(el('div', { class: 'rp-docs-folds' }, byCategory(allEntries(inst)).map(([k, es]) => section(inst, k, es, { foldable: true }))));
481
+ return out;
482
+ }
385
483
  if (!v.docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.none)); return out; }
386
- const shown = inst.filter === 'needs'
387
- ? v.docs.map((d) => ({ ...d, links: d.links.filter((l) => l.signal !== 'same' || l.syncing || inst.pending.has(keyOf(d, l))) })).filter((d) => d.links.length)
484
+ const docs = inst.filter === 'needs'
485
+ ? v.docs.map((d) => ({ ...d, links: d.links.filter((l) => needsYou(inst, d, l)) })).filter((d) => d.links.length)
388
486
  : v.docs;
389
- if (!shown.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.nothingNeeds)); return out; }
390
- out.push(...grouped(inst, shown));
487
+ if (!docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.nothingNeeds)); return out; }
488
+ const libFile = (d) => (lib && lib.byPath.has(d.path) ? lib.files[lib.byPath.get(d.path)] : { path: d.path, title: d.title, category: d.category, date: null });
489
+ out.push(...byCategory(docs.map((d) => ({ f: libFile(d), doc: d }))).map(([k, es]) => section(inst, k, es, { page: false })));
391
490
  return out;
392
491
  }
393
492
 
394
493
  function draw(inst) {
395
494
  const root = inst.root;
396
495
  if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
397
- const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()]]);
496
+ const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()],
497
+ inst.lib ? inst.lib.key : null, inst.libError, [...inst.openCats], [...inst.full], inst.graph]);
398
498
  if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
399
499
  inst.last = fp;
400
500
  const head = header(inst);
@@ -0,0 +1,272 @@
1
+ /* pm/doc-graph.js — the library of every repository file and the links the files write, drawn two ways (initiative
2
+ * docs-library-graph, PRD R-G1 to R-G5 and R-N1 to R-N5).
3
+ *
4
+ * getLibrary(ws) GET /api/docs/library once per workspace (kept for a short while), indexed
5
+ * fileGraph(lib, path, { onOpen }) one file in the middle: the files that link to it on the left, the files it
6
+ * links to on the right, at most 7 a side; a press on a file centres the graph on
7
+ * it, Back returns; List all N links lists every link by kind
8
+ * initiativeGraph(lib, paths, …) an initiative's files as filled dots and the outside files they link to as hollow
9
+ * dots (at most 14, the most-connected first); a press marks a dot's lines
10
+ *
11
+ * Every line is a link a file writes (lib/kg.js on the server): a markdown link, a path in the header, the initiative, the
12
+ * anchor, a decision number. Nothing is inferred. The layouts are computed from the files and links alone, so the same
13
+ * input draws the same picture. The pictures are an SVG of lines behind real buttons, placed with --rp-x and --rp-y. On a
14
+ * phone neither picture is drawn: the file graph shows its list, and the initiative sheet shows its own list.
15
+ */
16
+
17
+ import { el } from './dom.js';
18
+ import { button, narrowViewport } from './primitives.js';
19
+ import { pmGet } from './http.js';
20
+
21
+ const NS = 'http://www.w3.org/2000/svg';
22
+ const MAX_SIDE = 7;
23
+ const MAX_OUTSIDE = 14;
24
+ const LIST_CAP = 12;
25
+ const KEEP_MS = 30000;
26
+
27
+ export const GROUP_OUT = [['initiative', 'Its initiative'], ['header', 'Named in its header'], ['link', 'Links to'], ['anchor', 'Anchored to'], ['cites', 'Decisions it cites']];
28
+ export const GROUP_IN = [['initiative', 'Its initiative'], ['header', 'Named by'], ['link', 'Linked from'], ['anchor', 'Anchors'], ['cites', 'Cited by']];
29
+ const ROLE = { spec: 'PRD', ini: 'Initiative record', exp: 'Explainer', proto: 'Prototype' };
30
+
31
+ /* ── the library ─────────────────────────────────────────────────────────────────────────────────────────────────── */
32
+
33
+ const libs = new Map(); // ws -> { at, promise }
34
+
35
+ /** The workspace's library, indexed, or null when it could not be read. One request per workspace while it is fresh. */
36
+ export function getLibrary(ws, { fresh = false } = {}) {
37
+ const hit = libs.get(ws);
38
+ if (hit && !fresh && Date.now() - hit.at < KEEP_MS) return hit.promise;
39
+ const promise = pmGet('/api/docs/library?ws=' + encodeURIComponent(ws)).then((r) => (r && r.ok ? indexed(r) : null));
40
+ libs.set(ws, { at: Date.now(), promise });
41
+ promise.then((v) => { if (!v) libs.delete(ws); });
42
+ return promise;
43
+ }
44
+
45
+ /** The response with lookups: byPath, out and inn (per file: [[neighbour, kind]]), and each file's initiative. */
46
+ export function indexed(r) {
47
+ const byPath = new Map(r.files.map((f, i) => [f.path, i]));
48
+ const out = r.files.map(() => []);
49
+ const inn = r.files.map(() => []);
50
+ for (const [a, b, k] of r.edges) { out[a].push([b, r.kinds[k]]); inn[b].push([a, r.kinds[k]]); }
51
+ const ini = (f) => (f.initiative && r.initiatives[f.initiative]) ? { id: f.initiative, ...r.initiatives[f.initiative] } : null;
52
+ return { ...r, byPath, out, inn, ini };
53
+ }
54
+
55
+ const KIND_RANK = { initiative: 0, header: 1, link: 2, anchor: 3, cites: 4 };
56
+ /** One entry per neighbour (its strongest kind), cites last, then the same initiative first, then newest first. */
57
+ function neighbours(lib, i) {
58
+ const me = lib.files[i].initiative;
59
+ const uniq = (xs) => {
60
+ const m = new Map();
61
+ for (const x of xs) if (x[0] !== i && (!m.has(x[0]) || KIND_RANK[x[1]] < KIND_RANK[m.get(x[0])[1]])) m.set(x[0], x);
62
+ return [...m.values()].sort((a, b) => {
63
+ const A = lib.files[a[0]]; const B = lib.files[b[0]];
64
+ return (a[1] === 'cites') - (b[1] === 'cites')
65
+ || (B.initiative && B.initiative === me) - (A.initiative && A.initiative === me)
66
+ || String(B.date || '').localeCompare(String(A.date || ''))
67
+ || A.path.localeCompare(B.path);
68
+ });
69
+ };
70
+ return { out: uniq(lib.out[i]), inn: uniq(lib.inn[i]) };
71
+ }
72
+
73
+ const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '…' : t);
74
+ const svg = (tag, attrs = {}) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, String(v)); return n; };
75
+ const place = (node, vars) => { for (const [k, v] of Object.entries(vars)) node.style.setProperty('--rp-' + k, String(Math.round(v * 100) / 100)); return node; };
76
+
77
+ /* ── one file's links ────────────────────────────────────────────────────────────────────────────────────────────── */
78
+
79
+ /**
80
+ * The links of one file. `onOpen(path, title)` opens a file in the reader. Returns an element that redraws itself: a press
81
+ * on a neighbour centres the graph on it, Back returns. On a phone the list is shown in place of the picture.
82
+ */
83
+ export function fileGraph(lib, path, { onOpen } = {}) {
84
+ const start = lib.byPath.get(path);
85
+ const wrap = el('div', { class: 'rp-graph', 'data-graph': path });
86
+ if (start === undefined) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'This file is not in the library yet. It shows after its first commit.')); return wrap; }
87
+ const stack = [start];
88
+ const state = { list: false, more: new Set() };
89
+ const go = (j) => { stack.push(j); state.list = false; state.more.clear(); draw(); };
90
+ const draw = () => {
91
+ const i = stack[stack.length - 1];
92
+ const f = lib.files[i];
93
+ const { out, inn } = neighbours(lib, i);
94
+ const narrow = narrowViewport();
95
+ wrap.setAttribute('data-graph-focus', f.path);
96
+ const head = el('div', { class: 'rp-graph__head' },
97
+ stack.length > 1 ? button({ label: 'Back', icon: 'arrow-left', variant: 'ghost', size: 'sm', attrs: { 'data-graph-act': 'back' }, onClick: () => { stack.pop(); state.list = false; draw(); } }) : null,
98
+ el('span', { class: 'rp-graph__title' }, el('strong', null, clip(f.title, 80)), ` · ${inn.length === 1 ? '1 file links' : inn.length + ' files link'} here, it links to ${out.length}`),
99
+ onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-graph-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null);
100
+ const kids = [head];
101
+ if (!inn.length && !out.length) {
102
+ kids.push(el('p', { class: 'rp-graph__hint' }, 'No file links to this one, and it links to none. Only links written in the files are shown.'));
103
+ wrap.replaceChildren(...kids);
104
+ return;
105
+ }
106
+ if (!narrow) kids.push(picture(lib, i, inn, out, go));
107
+ const total = inn.length + out.length;
108
+ if (!narrow) {
109
+ kids.push(button({ label: state.list ? 'Hide the list' : `List all ${total} links`, variant: 'ghost', size: 'sm',
110
+ attrs: { 'data-graph-act': 'list', 'aria-expanded': state.list ? 'true' : 'false' }, onClick: () => { state.list = !state.list; draw(); } }));
111
+ }
112
+ if (narrow || state.list) kids.push(lists(lib, inn, out, go, state, draw));
113
+ kids.push(el('p', { class: 'rp-graph__hint' }, 'Only links written in the files count: a link, a path in the header, the initiative, or a decision number like D65. A dashed line is a decision it cites. Press a file to see its own links.'));
114
+ wrap.replaceChildren(...kids);
115
+ };
116
+ draw();
117
+ return wrap;
118
+ }
119
+
120
+ /** The picture: who links here (left), this file (middle), what it links to (right). */
121
+ function picture(lib, i, inn, out, go) {
122
+ const L = inn.slice(0, MAX_SIDE); const R = out.slice(0, MAX_SIDE);
123
+ const nL = L.length + (inn.length > MAX_SIDE ? 1 : 0); const nR = R.length + (out.length > MAX_SIDE ? 1 : 0);
124
+ const W = 820; const RH = 34; const NW = 250; const CW = 220;
125
+ const H = Math.max(nL, nR, 1) * RH + 16; const cx = W / 2; const cy = H / 2;
126
+ const yOf = (k, n) => cy + (k - (n - 1) / 2) * RH;
127
+ const pic = place(el('div', { class: 'rp-graph__pic', role: 'group', 'aria-label': `${lib.files[i].title}: ${inn.length} files link here, it links to ${out.length}` }), { ar: W / H });
128
+ const lines = svg('svg', { class: 'rp-graph__lines', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
129
+ const curve = (x1, y1, x2, y2, kind) => svg('path', { class: 'rp-graph__edge' + (kind === 'cites' ? ' rp-graph__edge--cites' : ''), d: `M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}` });
130
+ L.forEach(([, k], n) => lines.appendChild(curve(NW, yOf(n, nL), cx - CW / 2, cy, k)));
131
+ R.forEach(([, k], n) => lines.appendChild(curve(cx + CW / 2, cy, W - NW, yOf(n, nR), k)));
132
+ pic.appendChild(lines);
133
+ const node = (j, kind, x, y, side) => place(button({ label: clip(lib.files[j].title, 40), variant: 'outline', size: 'sm',
134
+ attrs: { class: 'rp-graph__node rp-graph__node--' + side + (kind === 'cites' ? ' rp-graph__node--cites' : ''), title: `${lib.files[j].title}\n${lib.files[j].path}`,
135
+ 'data-graph-node': lib.files[j].path, 'data-graph-side': side, 'data-graph-kind': kind }, onClick: () => go(j) }), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
136
+ L.forEach(([j, k], n) => pic.appendChild(node(j, k, 0, yOf(n, nL), 'in')));
137
+ R.forEach(([j, k], n) => pic.appendChild(node(j, k, W - NW, yOf(n, nR), 'out')));
138
+ const more = (n, x, y, side) => place(el('span', { class: 'rp-graph__more rp-graph__more--' + side }, `+ ${n} more in the list`), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
139
+ if (inn.length > MAX_SIDE) pic.appendChild(more(inn.length - MAX_SIDE, 0, yOf(L.length, nL), 'in'));
140
+ if (out.length > MAX_SIDE) pic.appendChild(more(out.length - MAX_SIDE, W - NW, yOf(R.length, nR), 'out'));
141
+ pic.appendChild(place(el('span', { class: 'rp-graph__centre', title: lib.files[i].path }, clip(lib.files[i].title, 34)), { x: ((cx - CW / 2) / W) * 100, y: (cy / H) * 100, w: (CW / W) * 100 }));
142
+ pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--in' }, 'Links here'));
143
+ pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--out' }, 'It links to'));
144
+ return pic;
145
+ }
146
+
147
+ /** Every link, both directions, grouped by kind. A group longer than 12 shows its first 12 and a Show all. */
148
+ function lists(lib, inn, out, go, state, draw) {
149
+ const col = (xs, groups, label, side) => el('div', { class: 'rp-graph__col', 'data-graph-list': side },
150
+ el('span', { class: 'rp-graph__label' }, label),
151
+ groups.map(([kind, name]) => {
152
+ const g = xs.filter((x) => x[1] === kind);
153
+ if (!g.length) return null;
154
+ const key = side + kind;
155
+ const shown = state.more.has(key) ? g : g.slice(0, LIST_CAP);
156
+ return el('div', { class: 'rp-graph__group' },
157
+ el('span', { class: 'rp-graph__label' }, `${name} · ${g.length}`),
158
+ shown.map(([j]) => button({ label: lib.files[j].title, variant: 'ghost', size: 'sm', attrs: { class: 'rp-graph__item', title: lib.files[j].path, 'data-graph-item': lib.files[j].path }, onClick: () => go(j) })),
159
+ g.length > shown.length ? button({ label: `Show all ${g.length}`, variant: 'ghost', size: 'sm', onClick: () => { state.more.add(key); draw(); } }) : null);
160
+ }));
161
+ return el('div', { class: 'rp-graph__lists' }, col(inn, GROUP_IN, 'Links here', 'in'), col(out, GROUP_OUT, 'It links to', 'out'));
162
+ }
163
+
164
+ /* ── an initiative's files ───────────────────────────────────────────────────────────────────────────────────────── */
165
+
166
+ /** The short label of a dot: a decision's number, the role of an initiative's file, or else its title, shortened. */
167
+ export function roleOf(f) {
168
+ const d = /^(D\d+)\b/.exec(f.title);
169
+ if (d) return d[1];
170
+ if (f.category === 'eval') return /rubric/i.test(f.title + ' ' + f.path) ? 'Rubric' : /verdict/i.test(f.path) ? 'Verdict' : 'Eval';
171
+ return ROLE[f.category] || clip(f.title.replace(/^[^:]{1,24}:\s+/, ''), 22);
172
+ }
173
+
174
+ /**
175
+ * The positions of an initiative's network: its files (inner) and the outside files they link to (outer, at most 14).
176
+ * Pure and deterministic: the same files and links give the same positions. Exported for the tests.
177
+ */
178
+ export function networkLayout(lib, paths, { W = 820, H = 460 } = {}) {
179
+ const inner = [...new Set(paths.map((p) => lib.byPath.get(p)).filter((x) => x !== undefined))].sort((a, b) => lib.files[a].path.localeCompare(lib.files[b].path));
180
+ const isIn = new Set(inner);
181
+ const score = new Map();
182
+ for (const i of inner) for (const [j] of [...lib.out[i], ...lib.inn[i]]) if (!isIn.has(j)) score.set(j, (score.get(j) || 0) + 1);
183
+ const outside = [...score.entries()].sort((a, b) => b[1] - a[1] || String(lib.files[b[0]].date || '').localeCompare(String(lib.files[a[0]].date || '')) || lib.files[a[0]].path.localeCompare(lib.files[b[0]].path));
184
+ const outer = outside.slice(0, MAX_OUTSIDE).map((x) => x[0]);
185
+ const ids = [...inner, ...outer];
186
+ const on = new Set(ids);
187
+ const cx = W / 2; const cy = H / 2;
188
+ const pos = new Map();
189
+ ids.forEach((i, k) => {
190
+ const mine = isIn.has(i); const n = mine ? inner.length : outer.length; const kk = mine ? k : k - inner.length;
191
+ const a = (kk / Math.max(n, 1)) * Math.PI * 2 + (mine ? 0 : 0.3); const r = mine ? 70 : 190;
192
+ pos.set(i, { x: cx + r * Math.cos(a) * 1.6, y: cy + r * Math.sin(a), vx: 0, vy: 0 });
193
+ });
194
+ const edges = [];
195
+ const seen = new Set();
196
+ for (const i of ids) for (const [j, kind] of lib.out[i]) {
197
+ if (!on.has(j) || i === j || !(isIn.has(i) || isIn.has(j))) continue;
198
+ const key = i + '>' + j;
199
+ if (seen.has(key)) continue;
200
+ seen.add(key); edges.push([i, j, kind]);
201
+ }
202
+ for (let it = 0; it < 500; it++) {
203
+ for (let a = 0; a < ids.length; a++) for (let b = a + 1; b < ids.length; b++) {
204
+ const A = pos.get(ids[a]); const B = pos.get(ids[b]);
205
+ let dx = A.x - B.x; let dy = (A.y - B.y) * 1.6;
206
+ const d2 = Math.max(dx * dx + dy * dy, 25); const f = 26000 / d2; const d = Math.sqrt(d2);
207
+ dx /= d; dy /= d; A.vx += dx * f; A.vy += dy * f; B.vx -= dx * f; B.vy -= dy * f;
208
+ }
209
+ for (const [i, j] of edges) {
210
+ const A = pos.get(i); const B = pos.get(j);
211
+ const dx = B.x - A.x; const dy = B.y - A.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - 150) * 0.02;
212
+ A.vx += (dx / d) * f; A.vy += (dy / d) * f; B.vx -= (dx / d) * f; B.vy -= (dy / d) * f;
213
+ }
214
+ for (const i of ids) {
215
+ const P = pos.get(i); const g = isIn.has(i) ? 0.02 : 0.005;
216
+ P.vx += (cx - P.x) * g; P.vy += (cy - P.y) * g * 1.4;
217
+ P.x += Math.max(-20, Math.min(20, P.vx * 0.5)); P.y += Math.max(-20, Math.min(20, P.vy * 0.5));
218
+ P.vx *= 0.5; P.vy *= 0.5;
219
+ P.x = Math.max(30, Math.min(W - 160, P.x)); P.y = Math.max(22, Math.min(H - 22, P.y));
220
+ }
221
+ }
222
+ const at = new Map(ids.map((i) => [i, { x: Math.round(pos.get(i).x * 10) / 10, y: Math.round(pos.get(i).y * 10) / 10 }]));
223
+ return { W, H, inner, outer, left: outside.length - outer.length, edges, at };
224
+ }
225
+
226
+ /**
227
+ * An initiative's files as a network. `onOpen(path, title)` opens a file. Returns null on a phone: the caller shows its
228
+ * list instead (R-N5).
229
+ */
230
+ export function initiativeGraph(lib, paths, { onOpen } = {}) {
231
+ if (narrowViewport()) return null;
232
+ const L = networkLayout(lib, paths);
233
+ const isIn = new Set(L.inner);
234
+ const wrap = el('div', { class: 'rp-net', 'data-net': '' });
235
+ if (!L.inner.length) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'None of this initiative’s files is in the library yet. They show after their first commit.')); return wrap; }
236
+ let sel = null;
237
+ const draw = () => {
238
+ const pic = place(el('div', { class: 'rp-net__pic', role: 'group', 'aria-label': `${L.inner.length} files of this initiative and ${L.outer.length} files outside it that they link to` }), { ar: L.W / L.H });
239
+ const lines = svg('svg', { class: 'rp-net__lines', viewBox: `0 0 ${L.W} ${L.H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
240
+ for (const [i, j, kind] of L.edges) {
241
+ const A = L.at.get(i); const B = L.at.get(j);
242
+ const cls = ['rp-net__edge', kind === 'cites' && 'rp-net__edge--cites', isIn.has(i) && isIn.has(j) && 'rp-net__edge--in', sel !== null && (sel === i || sel === j) && 'rp-net__edge--hot'].filter(Boolean).join(' ');
243
+ lines.appendChild(svg('line', { class: cls, x1: A.x, y1: A.y, x2: B.x, y2: B.y, 'data-net-edge': `${lib.files[i].path}>${lib.files[j].path}` }));
244
+ }
245
+ pic.appendChild(lines);
246
+ for (const i of [...L.inner, ...L.outer]) {
247
+ const f = lib.files[i]; const mine = isIn.has(i); const P = L.at.get(i);
248
+ const label = mine ? roleOf(f) : ((/^(D\d+)\b/.exec(f.title) || [])[1] || clip(f.title, 22));
249
+ pic.appendChild(place(button({ label: [el('span', { class: 'rp-net__dot' + (mine ? ' rp-net__dot--in' : ''), 'aria-hidden': 'true' }), el('span', { class: 'rp-net__label' }, label)],
250
+ variant: 'ghost', size: 'sm',
251
+ attrs: { class: 'rp-net__node' + (mine ? ' rp-net__node--in' : '') + (sel === i ? ' rp-net__node--sel' : ''), title: `${f.title}\n${f.path}`, 'aria-label': f.title, 'aria-pressed': sel === i ? 'true' : 'false',
252
+ 'data-net-node': f.path, 'data-net-side': mine ? 'in' : 'out' },
253
+ onClick: () => { sel = sel === i ? null : i; draw(); } }), { x: (P.x / L.W) * 100, y: (P.y / L.H) * 100 }));
254
+ }
255
+ const legend = el('div', { class: 'rp-net__legend' },
256
+ el('span', null, el('span', { class: 'rp-net__dot rp-net__dot--in', 'aria-hidden': 'true' }), 'This initiative'),
257
+ el('span', null, el('span', { class: 'rp-net__dot', 'aria-hidden': 'true' }), 'Outside it'),
258
+ el('span', null, el('span', { class: 'rp-net__dash', 'aria-hidden': 'true' }), 'Cites a decision'),
259
+ L.left ? el('span', { 'data-net-left': String(L.left) }, `${L.left} more outside files are left out. Open a file in Docs to see all its links.`) : null);
260
+ const kids = [pic, legend];
261
+ if (sel !== null) {
262
+ const f = lib.files[sel];
263
+ const ini = lib.ini(f);
264
+ kids.push(el('div', { class: 'rp-net__sel', 'data-net-sel': f.path },
265
+ el('span', { class: 'rp-net__sel-text' }, el('strong', null, f.title), el('span', { class: 'rp-net__path' }, f.path + (ini && !isIn.has(sel) ? ' · ' + ini.short : ''))),
266
+ onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-net-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null));
267
+ }
268
+ wrap.replaceChildren(...kids);
269
+ };
270
+ draw();
271
+ return wrap;
272
+ }