create-pmos 0.65.0 → 0.65.2

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.
@@ -1784,6 +1784,17 @@
1784
1784
  .in-derived {
1785
1785
  margin-bottom: 16px;
1786
1786
  }
1787
+ .in-files__bar {
1788
+ display: flex;
1789
+ flex-wrap: wrap;
1790
+ align-items: center;
1791
+ gap: 12px;
1792
+ margin-bottom: 12px;
1793
+ }
1794
+ .in-files__hint {
1795
+ font-size: 12px;
1796
+ color: var(--muted-foreground);
1797
+ }
1787
1798
  .in-files__title,
1788
1799
  .in-derived__title {
1789
1800
  margin: 0;
@@ -4503,6 +4514,302 @@
4503
4514
  }
4504
4515
  }
4505
4516
 
4517
+ /* Docs › All files (docs-library-graph): folding categories, one line per file, the match marked, Add copy on hover. */
4518
+ .rp-docs-folds {
4519
+ display: flex;
4520
+ flex-direction: column;
4521
+ }
4522
+ .rp-docs-hint {
4523
+ margin: 0;
4524
+ font-size: 12px;
4525
+ color: var(--muted-foreground);
4526
+ }
4527
+ .rp-docs-mark {
4528
+ background: var(--accent);
4529
+ color: var(--foreground);
4530
+ }
4531
+ .rp-docs-path {
4532
+ font-family: var(--mono);
4533
+ }
4534
+ .rp-docs-showall {
4535
+ align-self: flex-start;
4536
+ margin-top: 4px;
4537
+ }
4538
+ .rp-docs-links {
4539
+ grid-column: 1 / -1;
4540
+ padding: 4px 0 16px 24px;
4541
+ }
4542
+ .rp-docs-detail > .rp-docs-links {
4543
+ padding-left: 0;
4544
+ }
4545
+ /* Add copy shows on hover or focus where the pointer can hover; without hover (a touch screen) it always shows. */
4546
+ @media (hover: hover) and (pointer: fine) {
4547
+ .rp-docs-addcopy {
4548
+ opacity: 0;
4549
+ }
4550
+ .rp-docs-row:hover .rp-docs-addcopy,
4551
+ .rp-docs-row:focus-within .rp-docs-addcopy {
4552
+ opacity: 1;
4553
+ }
4554
+ }
4555
+
4556
+ /* The links of one file: who links here on the left, what it links to on the right, lines behind real buttons. */
4557
+ .rp-graph {
4558
+ display: flex;
4559
+ flex-direction: column;
4560
+ align-items: flex-start;
4561
+ gap: 8px;
4562
+ min-width: 0;
4563
+ }
4564
+ .rp-graph__head {
4565
+ display: flex;
4566
+ flex-wrap: wrap;
4567
+ align-items: center;
4568
+ gap: 8px;
4569
+ width: 100%;
4570
+ }
4571
+ .rp-graph__title {
4572
+ flex: 1 1 240px;
4573
+ min-width: 0;
4574
+ font-size: 14px;
4575
+ }
4576
+ .rp-graph__pic {
4577
+ position: relative;
4578
+ width: 100%;
4579
+ max-width: 820px;
4580
+ aspect-ratio: var(--rp-ar);
4581
+ margin-top: 16px;
4582
+ }
4583
+ .rp-graph__lines {
4584
+ position: absolute;
4585
+ inset: 0;
4586
+ width: 100%;
4587
+ height: 100%;
4588
+ overflow: visible;
4589
+ }
4590
+ .rp-graph__edge {
4591
+ fill: none;
4592
+ stroke: var(--muted-foreground);
4593
+ stroke-width: 1;
4594
+ }
4595
+ .rp-graph__edge--cites {
4596
+ stroke-dasharray: 4 3;
4597
+ }
4598
+ .rp-graph__node,
4599
+ .rp-graph__centre,
4600
+ .rp-graph__more {
4601
+ position: absolute;
4602
+ left: calc(var(--rp-x) * 1%);
4603
+ top: calc(var(--rp-y) * 1%);
4604
+ width: calc(var(--rp-w) * 1%);
4605
+ transform: translateY(-50%);
4606
+ }
4607
+ .rp-graph__node,
4608
+ .rp-graph__item,
4609
+ .rp-net__node {
4610
+ letter-spacing: normal;
4611
+ text-transform: none;
4612
+ }
4613
+ .rp-graph__node {
4614
+ justify-content: flex-start;
4615
+ background: var(--background);
4616
+ font-size: 12px;
4617
+ }
4618
+ .rp-graph__node--in {
4619
+ justify-content: flex-end;
4620
+ }
4621
+ .rp-graph__node--cites {
4622
+ border-style: dashed;
4623
+ }
4624
+ .rp-graph__node:focus-visible {
4625
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
4626
+ outline-offset: 2px;
4627
+ }
4628
+ .rp-graph__centre {
4629
+ padding: 6px 10px;
4630
+ border: 1px solid var(--foreground);
4631
+ background: var(--card);
4632
+ font-size: 12px;
4633
+ font-weight: 600;
4634
+ text-align: center;
4635
+ white-space: nowrap;
4636
+ overflow: hidden;
4637
+ text-overflow: ellipsis;
4638
+ }
4639
+ .rp-graph__more {
4640
+ font-size: 12px;
4641
+ color: var(--muted-foreground);
4642
+ }
4643
+ .rp-graph__more--in {
4644
+ text-align: right;
4645
+ }
4646
+ .rp-graph__cap {
4647
+ position: absolute;
4648
+ top: -20px;
4649
+ font-size: 11px;
4650
+ color: var(--muted-foreground);
4651
+ }
4652
+ .rp-graph__cap--out {
4653
+ right: 0;
4654
+ }
4655
+ .rp-graph__lists {
4656
+ display: grid;
4657
+ grid-template-columns: 1fr 1fr;
4658
+ gap: 16px;
4659
+ width: 100%;
4660
+ }
4661
+ .rp-graph__col,
4662
+ .rp-graph__group {
4663
+ display: flex;
4664
+ flex-direction: column;
4665
+ align-items: flex-start;
4666
+ gap: 2px;
4667
+ min-width: 0;
4668
+ }
4669
+ .rp-graph__col {
4670
+ gap: 12px;
4671
+ }
4672
+ .rp-graph__label {
4673
+ font-size: 12px;
4674
+ color: var(--muted-foreground);
4675
+ }
4676
+ .rp-graph__item {
4677
+ max-width: 100%;
4678
+ justify-content: flex-start;
4679
+ }
4680
+ .rp-graph__hint {
4681
+ margin: 0;
4682
+ font-size: 12px;
4683
+ color: var(--muted-foreground);
4684
+ }
4685
+ @media (max-width: 760px) {
4686
+ .rp-graph__lists {
4687
+ grid-template-columns: 1fr;
4688
+ }
4689
+ }
4690
+
4691
+ /* An initiative's files as a network: filled dots are its files, hollow dots the outside files they link to. */
4692
+ .rp-net {
4693
+ display: flex;
4694
+ flex-direction: column;
4695
+ gap: 8px;
4696
+ }
4697
+ .rp-net__pic {
4698
+ position: relative;
4699
+ width: 100%;
4700
+ aspect-ratio: var(--rp-ar);
4701
+ border: 1px solid var(--border);
4702
+ }
4703
+ .rp-net__lines {
4704
+ position: absolute;
4705
+ inset: 0;
4706
+ width: 100%;
4707
+ height: 100%;
4708
+ }
4709
+ .rp-net__edge {
4710
+ stroke: var(--border);
4711
+ stroke-width: 1;
4712
+ }
4713
+ .rp-net__edge--in {
4714
+ stroke: var(--muted-foreground);
4715
+ }
4716
+ .rp-net__edge--cites {
4717
+ stroke-dasharray: 4 3;
4718
+ }
4719
+ .rp-net__edge--hot {
4720
+ stroke: var(--foreground);
4721
+ stroke-width: 2;
4722
+ }
4723
+ .rp-net__node {
4724
+ position: absolute;
4725
+ left: calc(var(--rp-x) * 1%);
4726
+ top: calc(var(--rp-y) * 1%);
4727
+ height: auto;
4728
+ min-height: 0;
4729
+ padding: 2px 4px 2px 0;
4730
+ transform: translate(-5px, -50%);
4731
+ font-size: 11px;
4732
+ }
4733
+ .rp-net__node--in {
4734
+ transform: translate(-7px, -50%);
4735
+ }
4736
+ .rp-net__node:focus-visible {
4737
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
4738
+ outline-offset: 2px;
4739
+ }
4740
+ .rp-net__node--sel .rp-net__label {
4741
+ font-weight: 600;
4742
+ text-decoration: underline;
4743
+ text-underline-offset: 2px;
4744
+ }
4745
+ .rp-net__label {
4746
+ max-width: 140px;
4747
+ overflow: hidden;
4748
+ text-overflow: ellipsis;
4749
+ white-space: nowrap;
4750
+ color: var(--muted-foreground);
4751
+ }
4752
+ .rp-net__node--sel .rp-net__label,
4753
+ .rp-net__dot--in + .rp-net__label {
4754
+ color: var(--foreground);
4755
+ }
4756
+ .rp-net__dot {
4757
+ display: inline-block;
4758
+ flex: none;
4759
+ margin-right: 4px;
4760
+ vertical-align: middle;
4761
+ width: 10px;
4762
+ height: 10px;
4763
+ border: 1px solid var(--muted-foreground);
4764
+ border-radius: 50%;
4765
+ background: var(--background);
4766
+ }
4767
+ .rp-net__dot--in {
4768
+ width: 14px;
4769
+ height: 14px;
4770
+ border-color: var(--foreground);
4771
+ background: var(--foreground);
4772
+ }
4773
+ .rp-net__dash {
4774
+ display: inline-block;
4775
+ width: 18px;
4776
+ border-top: 1px dashed var(--muted-foreground);
4777
+ }
4778
+ .rp-net__legend {
4779
+ display: flex;
4780
+ flex-wrap: wrap;
4781
+ align-items: center;
4782
+ gap: 16px;
4783
+ font-size: 12px;
4784
+ color: var(--muted-foreground);
4785
+ }
4786
+ .rp-net__legend > span {
4787
+ display: inline-flex;
4788
+ align-items: center;
4789
+ gap: 6px;
4790
+ }
4791
+ .rp-net__sel {
4792
+ display: flex;
4793
+ flex-wrap: wrap;
4794
+ align-items: center;
4795
+ gap: 12px;
4796
+ padding: 8px 12px;
4797
+ border: 1px solid var(--border);
4798
+ }
4799
+ .rp-net__sel-text {
4800
+ display: flex;
4801
+ flex: 1 1 240px;
4802
+ flex-direction: column;
4803
+ min-width: 0;
4804
+ font-size: 14px;
4805
+ }
4806
+ .rp-net__path {
4807
+ font-family: var(--mono);
4808
+ font-size: 12px;
4809
+ color: var(--muted-foreground);
4810
+ overflow-wrap: anywhere;
4811
+ }
4812
+
4506
4813
  /* People (people-directory): what waits for the PM, the list beside the chosen person's page. */
4507
4814
  .rp-people {
4508
4815
  display: flex;
@@ -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);
@@ -143,14 +143,14 @@ function build(ctx0) {
143
143
  const dayWs = dayRepo ? wsOf(dayRepo) : null;
144
144
  const prep = onPhone && !(window.pmDeck && window.pmDeck.phoneView('today') === 'day') ? prepCard(dayWs) : null;
145
145
  if (prep) { prep.setAttribute('role', 'listitem'); const none = list.querySelector('[data-today="empty"]'); if (none) none.setAttribute('data-today-id', 'empty'); list.prepend(prep); } // the empty note stays, as the deck's last card
146
- // the open day takes the list into its left column ("Also waiting for you"), so Today shows one layout; a folded day
147
- // leaves it below, as before
148
- const owed = { list, more, count: n };
146
+ // the open day takes the cards into its jobs (rows of Needs you; a pressed row shows its card), so Today shows one layout; a folded day
147
+ // leaves it below, as before. A phone does not draw the day, so its deck keeps every card
148
+ const owed = onPhone ? null : { list, more, count: n };
149
149
  // one day for every repository: the day is the same everywhere; the Show switch narrows it to the repository Today
150
150
  // was opened from (`here`) or shows all of it
151
151
  const here = sc.repo ? wsOf(sc.repo) : null;
152
152
  const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null, owed, here, all: sc.all });
153
- const inDay = !!(day && day.contains(list));
153
+ const inDay = !!(day && owed && owed.taken);
154
154
  return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner, onPhone, prep ? prep.outerHTML : null]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
155
155
  }
156
156