@cstart/coldstart 2.2.18 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/dist/cli.d.ts +9 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +44 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/graph/dump.d.ts +63 -0
  7. package/dist/graph/dump.d.ts.map +1 -0
  8. package/dist/graph/dump.js +183 -0
  9. package/dist/graph/dump.js.map +1 -0
  10. package/dist/graph/view-template.d.ts +10 -0
  11. package/dist/graph/view-template.d.ts.map +1 -0
  12. package/dist/graph/view-template.js +10 -0
  13. package/dist/graph/view-template.js.map +1 -0
  14. package/dist/graph/view.d.ts +20 -0
  15. package/dist/graph/view.d.ts.map +1 -0
  16. package/dist/graph/view.js +99 -0
  17. package/dist/graph/view.js.map +1 -0
  18. package/dist/index-manager.d.ts +16 -0
  19. package/dist/index-manager.d.ts.map +1 -1
  20. package/dist/index-manager.js +50 -0
  21. package/dist/index-manager.js.map +1 -1
  22. package/dist/index.js +8 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/indexer/cochange.d.ts +65 -0
  25. package/dist/indexer/cochange.d.ts.map +1 -0
  26. package/dist/indexer/cochange.js +237 -0
  27. package/dist/indexer/cochange.js.map +1 -0
  28. package/dist/kb/view-template.d.ts +1 -1
  29. package/dist/kb/view-template.d.ts.map +1 -1
  30. package/dist/kb/view-template.js +1 -1
  31. package/dist/kb/view-template.js.map +1 -1
  32. package/dist/kb/view.d.ts +1 -0
  33. package/dist/kb/view.d.ts.map +1 -1
  34. package/dist/kb/view.js +51 -1
  35. package/dist/kb/view.js.map +1 -1
  36. package/dist/server/mcp.d.ts.map +1 -1
  37. package/dist/server/mcp.js +10 -4
  38. package/dist/server/mcp.js.map +1 -1
  39. package/dist/server/tools.d.ts +6 -1
  40. package/dist/server/tools.d.ts.map +1 -1
  41. package/dist/server/tools.js +63 -3
  42. package/dist/server/tools.js.map +1 -1
  43. package/dist/status.d.ts.map +1 -1
  44. package/dist/status.js +8 -0
  45. package/dist/status.js.map +1 -1
  46. package/package.json +1 -1
  47. package/templates/coldstart.cli.md +2 -0
  48. package/templates/coldstart.mcp.md +2 -0
@@ -0,0 +1,65 @@
1
+ export declare const CO_CHANGE_VERSION = 1;
2
+ /** Commits to walk. Measured: nothing improves past ~12k on any repo, and a
3
+ * full 15k-commit walk costs 817ms. */
4
+ export declare const CO_CHANGE_WINDOW = 12000;
5
+ /** Partners STORED per file. Storage is separated from display so the cheap
6
+ * part (the walk) doesn't have to be redone to retune the visible cut. */
7
+ export declare const CO_CHANGE_K = 8;
8
+ /** Partners DISPLAYED in `gs`. Precision falls steeply with K (K=3: 22-28%,
9
+ * K=12: 8-15%) while recall barely moves. */
10
+ export declare const CO_CHANGE_SHOW = 3;
11
+ /** A single shared commit is noise. */
12
+ export declare const CO_CHANGE_MIN_SUPPORT = 2;
13
+ /** Commits touching more than this are sweeps (reformat, license header, mass
14
+ * rename) — every file in them "moves with" every other, which is not a
15
+ * signal. Also the quadratic guard: 30 files = 435 pairs. */
16
+ export declare const CO_CHANGE_MAX_COMMIT_FILES = 30;
17
+ export interface CoChangeIndex {
18
+ v: typeof CO_CHANGE_VERSION;
19
+ builtAt: number;
20
+ /** HEAD the walk was taken at — the keeper re-derives when this moves. */
21
+ head: string;
22
+ /** Commits actually seen. Near-zero on a shallow clone (CI checkouts are
23
+ * shallow by default); `status` prints it so the silence is explainable. */
24
+ commitsScanned: number;
25
+ /** file → [partner, commits they shared][], desc, capped at CO_CHANGE_K. */
26
+ partners: Record<string, Array<[string, number]>>;
27
+ /** file → commits in the window that touched it. Denominator for
28
+ * "changed together in 14 of 17 commits", which is what makes the number
29
+ * readable: 14 shared out of 17 is strong, 14 out of 900 is a hub. */
30
+ touched: Record<string, number>;
31
+ }
32
+ /**
33
+ * Walk git history and count which indexed files change in the same commits.
34
+ *
35
+ * `isIndexed` filters to files the index actually knows, applied BEFORE pairing
36
+ * so a commit that touched 40 files but only 3 indexed ones still counts.
37
+ * Returns null when git can't answer (no repo, no git binary) — the caller
38
+ * writes nothing and every surface stays silent.
39
+ */
40
+ export declare function deriveCoChange(rootDir: string, isIndexed: (path: string) => boolean, head: string, window?: number): Promise<CoChangeIndex | null>;
41
+ export declare function coChangePath(rootDir: string, baseCacheDir?: string): string;
42
+ /** Atomic write (temp + rename) — a reader never sees a half-written file.
43
+ * Plain JSON, not .gz: `saveCachedIndex` sweeps *.gz on every save. */
44
+ export declare function saveCoChange(rootDir: string, data: CoChangeIndex, baseCacheDir?: string): Promise<void>;
45
+ /** Sync read for the query surfaces. null = absent/corrupt/wrong version, or
46
+ * no keeper has ever run here — callers omit the section entirely rather than
47
+ * claiming "no files move with this one". */
48
+ export declare function loadCoChange(rootDir: string, baseCacheDir?: string): CoChangeIndex | null;
49
+ export interface CoChangePartner {
50
+ path: string;
51
+ /** Commits in which both files changed. */
52
+ shared: number;
53
+ /** Commits in the window that touched the QUERIED file. */
54
+ outOf: number;
55
+ }
56
+ /**
57
+ * Partners to render for one file, strongest first.
58
+ *
59
+ * `exclude` drops files the caller is already showing (imports, importers) —
60
+ * repeating them spends the output budget without adding a file the agent
61
+ * didn't already have. What survives is exactly the part the graph missed,
62
+ * which is where the measured 10-14 point gain lives.
63
+ */
64
+ export declare function coChangePartnersFor(data: CoChangeIndex | null, file: string, exclude: Set<string>, limit?: number): CoChangePartner[];
65
+ //# sourceMappingURL=cochange.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cochange.d.ts","sourceRoot":"","sources":["../../src/indexer/cochange.ts"],"names":[],"mappings":"AAgCA,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAGnC;wCACwC;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAC;AACvC;2EAC2E;AAC3E,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B;8CAC8C;AAC9C,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,uCAAuC;AACvC,eAAO,MAAM,qBAAqB,IAAI,CAAC;AACvC;;8DAE8D;AAC9D,eAAO,MAAM,0BAA0B,KAAK,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,CAAC,EAAE,OAAO,iBAAiB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb;iFAC6E;IAC7E,cAAc,EAAE,MAAM,CAAC;IACvB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAClD;;2EAEuE;IACvE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAiED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,SAAmB,GACxB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAkE/B;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAE3E;AAED;wEACwE;AACxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM7G;AAED;;8CAE8C;AAC9C,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAUzF;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,GAAG,IAAI,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,EACpB,KAAK,SAAiB,GACrB,eAAe,EAAE,CAYnB"}
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Co-change — "which files move together", derived from git history.
3
+ *
4
+ * The code graph answers "what does this file reference". It cannot answer
5
+ * "what do people always edit alongside this file" when no import or call
6
+ * connects them: a JS widget and its Python test, two sibling importers that
7
+ * share an interface but never each other's code, a template and the view that
8
+ * renders it by naming convention. Measured on four repos (2026-08-22), pairs
9
+ * learned from older commits predict co-editing in the 300 most recent commits
10
+ * the model never saw, and ADD 14 points on django / 10.2 on arches on top of
11
+ * everything `gs`'s 1-hop graph already finds.
12
+ *
13
+ * It is a "these move together" claim, NOT a dependency claim — the renderer
14
+ * must say so, or an agent reads it as an import edge.
15
+ *
16
+ * Same discipline as the other synthetic relations: DERIVED, never stored as
17
+ * fact. The keeper recomputes it and drops it in a sidecar beside the cache;
18
+ * readers read that file or degrade silently to no section at all.
19
+ *
20
+ * Parameters below are measured, not guessed — see
21
+ * `~/coldstart-deep-analysis/cochange-and-reexport-2026-08-22/RESULTS.md`.
22
+ */
23
+ import { execFile } from 'node:child_process';
24
+ import { promisify } from 'node:util';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { writeFile, rename, mkdir } from 'node:fs/promises';
27
+ import { join, dirname } from 'node:path';
28
+ import { getCacheDir } from '../cache/disk-cache.js';
29
+ const execFileAsync = promisify(execFile);
30
+ const GIT_BUF = 256 * 1024 * 1024;
31
+ export const CO_CHANGE_VERSION = 1;
32
+ const FILE_NAME = 'cochange.json';
33
+ /** Commits to walk. Measured: nothing improves past ~12k on any repo, and a
34
+ * full 15k-commit walk costs 817ms. */
35
+ export const CO_CHANGE_WINDOW = 12_000;
36
+ /** Partners STORED per file. Storage is separated from display so the cheap
37
+ * part (the walk) doesn't have to be redone to retune the visible cut. */
38
+ export const CO_CHANGE_K = 8;
39
+ /** Partners DISPLAYED in `gs`. Precision falls steeply with K (K=3: 22-28%,
40
+ * K=12: 8-15%) while recall barely moves. */
41
+ export const CO_CHANGE_SHOW = 3;
42
+ /** A single shared commit is noise. */
43
+ export const CO_CHANGE_MIN_SUPPORT = 2;
44
+ /** Commits touching more than this are sweeps (reformat, license header, mass
45
+ * rename) — every file in them "moves with" every other, which is not a
46
+ * signal. Also the quadratic guard: 30 files = 435 pairs. */
47
+ export const CO_CHANGE_MAX_COMMIT_FILES = 30;
48
+ /** Newest-first, matching `git log`'s own order — which the rename walk below
49
+ * depends on. */
50
+ function parseCommits(raw) {
51
+ const out = [];
52
+ let cur = null;
53
+ for (const line of raw.split('\n')) {
54
+ if (line.startsWith('__C__')) {
55
+ if (cur && cur.paths.length)
56
+ out.push(cur);
57
+ cur = { paths: [], renames: [] };
58
+ continue;
59
+ }
60
+ if (!cur || !line.trim())
61
+ continue;
62
+ // `status \t path` — or, for a rename, `Rnnn \t old \t new`.
63
+ const parts = line.split('\t');
64
+ if (parts[0].startsWith('R') && parts[1] && parts[2]) {
65
+ cur.paths.push(parts[2]);
66
+ cur.renames.push([parts[1], parts[2]]);
67
+ }
68
+ else if (parts[1]) {
69
+ cur.paths.push(parts[1]);
70
+ }
71
+ }
72
+ if (cur && cur.paths.length)
73
+ out.push(cur);
74
+ return out;
75
+ }
76
+ /**
77
+ * Attribute a commit's paths to the names those files carry TODAY.
78
+ *
79
+ * `-M` only labels the rename commit itself; every older commit still records
80
+ * the old path, so a file renamed a year ago looks like two unrelated files
81
+ * with half its history each — and the older half is keyed to a path that no
82
+ * longer exists, so it can never be shown. Since `git log` walks newest-first,
83
+ * a rename `old → new` seen at commit i means every commit AFTER it in this
84
+ * stream (i.e. older) that says `old` means the file now called `new`.
85
+ *
86
+ * Chains resolve transitively (a → b → c collapses to c), and the alias is
87
+ * only recorded going backwards, so a path later REUSED by a different file
88
+ * keeps its own identity in the commits above the rename.
89
+ */
90
+ function canonicalizeRenames(commits) {
91
+ const alias = new Map();
92
+ const resolve = (p) => {
93
+ let cur = p;
94
+ for (let i = 0; i < 16 && alias.has(cur); i++)
95
+ cur = alias.get(cur);
96
+ return cur;
97
+ };
98
+ const out = [];
99
+ for (const c of commits) {
100
+ out.push([...new Set(c.paths.map(resolve))]);
101
+ for (const [from, to] of c.renames) {
102
+ if (from !== to && !alias.has(from))
103
+ alias.set(from, resolve(to));
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Walk git history and count which indexed files change in the same commits.
110
+ *
111
+ * `isIndexed` filters to files the index actually knows, applied BEFORE pairing
112
+ * so a commit that touched 40 files but only 3 indexed ones still counts.
113
+ * Returns null when git can't answer (no repo, no git binary) — the caller
114
+ * writes nothing and every surface stays silent.
115
+ */
116
+ export async function deriveCoChange(rootDir, isIndexed, head, window = CO_CHANGE_WINDOW) {
117
+ let raw;
118
+ try {
119
+ const { stdout } = await execFileAsync('git', ['log', '--no-merges', '--format=__C__%H', '--name-status', '-M', '-n', String(window)], { cwd: rootDir, maxBuffer: GIT_BUF, windowsHide: true });
120
+ raw = stdout;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ const commits = canonicalizeRenames(parseCommits(raw));
126
+ const pairCounts = new Map();
127
+ const touched = {};
128
+ for (const all of commits) {
129
+ const files = all.filter(isIndexed);
130
+ for (const f of files)
131
+ touched[f] = (touched[f] ?? 0) + 1;
132
+ if (files.length < 2 || files.length > CO_CHANGE_MAX_COMMIT_FILES)
133
+ continue;
134
+ files.sort();
135
+ for (let i = 0; i < files.length; i++) {
136
+ for (let j = i + 1; j < files.length; j++) {
137
+ const key = files[i] + '\0' + files[j];
138
+ pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
139
+ }
140
+ }
141
+ }
142
+ const acc = new Map();
143
+ const add = (a, b, n) => {
144
+ const list = acc.get(a);
145
+ if (list)
146
+ list.push([b, n]);
147
+ else
148
+ acc.set(a, [[b, n]]);
149
+ };
150
+ for (const [key, n] of pairCounts) {
151
+ if (n < CO_CHANGE_MIN_SUPPORT)
152
+ continue;
153
+ const sep = key.indexOf('\0');
154
+ const a = key.slice(0, sep), b = key.slice(sep + 1);
155
+ add(a, b, n);
156
+ add(b, a, n);
157
+ }
158
+ const partners = {};
159
+ const keep = new Set();
160
+ for (const [file, list] of acc) {
161
+ list.sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1));
162
+ const top = list.slice(0, CO_CHANGE_K);
163
+ partners[file] = top;
164
+ keep.add(file);
165
+ for (const [p] of top)
166
+ keep.add(p);
167
+ }
168
+ // `touched` is only ever read as a denominator for a rendered pair — keeping
169
+ // a count for every file the repo ever committed would dwarf the payload.
170
+ const trimmedTouched = {};
171
+ for (const f of keep)
172
+ if (touched[f])
173
+ trimmedTouched[f] = touched[f];
174
+ return {
175
+ v: CO_CHANGE_VERSION,
176
+ builtAt: Date.now(),
177
+ head,
178
+ commitsScanned: commits.length,
179
+ partners,
180
+ touched: trimmedTouched,
181
+ };
182
+ }
183
+ export function coChangePath(rootDir, baseCacheDir) {
184
+ return join(getCacheDir(rootDir, baseCacheDir), FILE_NAME);
185
+ }
186
+ /** Atomic write (temp + rename) — a reader never sees a half-written file.
187
+ * Plain JSON, not .gz: `saveCachedIndex` sweeps *.gz on every save. */
188
+ export async function saveCoChange(rootDir, data, baseCacheDir) {
189
+ const path = coChangePath(rootDir, baseCacheDir);
190
+ await mkdir(dirname(path), { recursive: true });
191
+ const tmp = path + '.tmp';
192
+ await writeFile(tmp, JSON.stringify(data));
193
+ await rename(tmp, path);
194
+ }
195
+ /** Sync read for the query surfaces. null = absent/corrupt/wrong version, or
196
+ * no keeper has ever run here — callers omit the section entirely rather than
197
+ * claiming "no files move with this one". */
198
+ export function loadCoChange(rootDir, baseCacheDir) {
199
+ try {
200
+ const p = coChangePath(rootDir, baseCacheDir);
201
+ if (!existsSync(p))
202
+ return null;
203
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
204
+ if (parsed?.v !== CO_CHANGE_VERSION)
205
+ return null;
206
+ return parsed;
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ }
212
+ /**
213
+ * Partners to render for one file, strongest first.
214
+ *
215
+ * `exclude` drops files the caller is already showing (imports, importers) —
216
+ * repeating them spends the output budget without adding a file the agent
217
+ * didn't already have. What survives is exactly the part the graph missed,
218
+ * which is where the measured 10-14 point gain lives.
219
+ */
220
+ export function coChangePartnersFor(data, file, exclude, limit = CO_CHANGE_SHOW) {
221
+ if (!data)
222
+ return [];
223
+ const list = data.partners[file];
224
+ if (!list?.length)
225
+ return [];
226
+ const outOf = data.touched[file] ?? 0;
227
+ const out = [];
228
+ for (const [path, shared] of list) {
229
+ if (exclude.has(path))
230
+ continue;
231
+ out.push({ path, shared, outOf });
232
+ if (out.length >= limit)
233
+ break;
234
+ }
235
+ return out;
236
+ }
237
+ //# sourceMappingURL=cochange.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cochange.js","sourceRoot":"","sources":["../../src/indexer/cochange.ts"],"names":[],"mappings":"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"}
@@ -5,5 +5,5 @@
5
5
  * `__DATA_JSON__` (the baked note data). Shipped as a string because tsc does
6
6
  * not copy non-.ts assets into dist.
7
7
  */
8
- export declare const VIEW_TEMPLATE = "<title>__TITLE__</title>\n<style>\n :root{\n --ground:#eaeef3; --surface:#ffffff; --rail:#f5f8fc; --sunk:#eef2f7;\n --ink:#161a22; --ink-soft:#39414f; --muted:#69748a; --faint:#9aa6b8;\n --line:#dce2ec; --line-soft:#e8edf3;\n --accent:#1f4fd8; --accent-soft:#e7ecfd; --accent-ink:#173bab;\n --fresh:#137a3b; --fresh-bg:#e4f4ea;\n --changed:#a85a06; --changed-bg:#fbeedd;\n --missing:#bb1436; --missing-bg:#fbe3e8;\n --unver:#5a6577; --unver-bg:#eceff4;\n --flow:#0b7285; --flow-bg:#dcf1f4;\n --file:#1f4fd8; --file-bg:#e7ecfd;\n --mono:ui-monospace,\"SF Mono\",SFMono-Regular,Menlo,Consolas,monospace;\n --sans:system-ui,-apple-system,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n --sh:0 1px 2px rgba(20,28,45,.05),0 4px 16px rgba(20,28,45,.06);\n --r:9px;\n }\n *{box-sizing:border-box}\n html,body{height:100%}\n body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);\n font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;\n text-rendering:optimizeLegibility;}\n button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}\n ::selection{background:var(--accent-soft)}\n\n .app{display:grid;grid-template-rows:auto 1fr;height:100vh;overflow:hidden}\n\n /* ---- top bar ---- */\n .top{display:flex;align-items:center;gap:20px;padding:12px 20px;\n background:var(--surface);border-bottom:1px solid var(--line);z-index:5}\n .brand{display:flex;flex-direction:column;justify-content:center;min-width:0;gap:1px}\n .brand .kicker{font-family:var(--mono);font-size:9.5px;letter-spacing:.11em;\n text-transform:uppercase;color:var(--faint);font-weight:600;line-height:1}\n .brand .repo{display:inline-flex;align-items:center;gap:6px;font-size:17px;\n font-weight:680;letter-spacing:-.02em;color:var(--ink);line-height:1.1}\n .brand .repo::before{content:\"\";width:7px;height:7px;border-radius:50%;\n background:var(--accent);flex-shrink:0}\n .top .spacer{flex:1}\n .stat{display:flex;flex-direction:column;line-height:1.15}\n .stat b{font-size:16px;font-variant-numeric:tabular-nums;letter-spacing:-.02em}\n .stat span{font-family:var(--mono);font-size:9.5px;letter-spacing:.09em;\n text-transform:uppercase;color:var(--faint)}\n .fresh-meter{display:flex;align-items:center;gap:8px}\n .meter-bar{width:120px;height:8px;border-radius:5px;background:var(--sunk);\n overflow:hidden;display:flex}\n .meter-bar i{display:block;height:100%}\n .segbtns{display:flex;background:var(--sunk);border-radius:8px;padding:3px}\n .segbtns button{padding:5px 13px;border-radius:6px;font-size:12.5px;\n font-weight:500;color:var(--muted)}\n .segbtns button[aria-pressed=true]{background:var(--surface);color:var(--ink);\n box-shadow:0 1px 2px rgba(20,28,45,.12);font-weight:600}\n\n /* ---- body split ---- */\n .split{display:grid;grid-template-columns:340px 1fr;min-height:0}\n .split.graph-mode{grid-template-columns:1fr}\n .split.graph-mode .rail{display:none}\n\n /* ---- rail ---- */\n .rail{background:var(--rail);border-right:1px solid var(--line);\n display:flex;flex-direction:column;min-height:0}\n .rail .controls{padding:12px 12px 0;display:flex;flex-direction:column;gap:10px}\n .search{position:relative}\n .search input{width:100%;padding:9px 12px 9px 32px;border:1px solid var(--line);\n border-radius:8px;background:var(--surface);font-size:13px;outline:none}\n .search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}\n .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);\n width:15px;height:15px;stroke:var(--faint);fill:none;stroke-width:2}\n\n /* lens tabs (Files / Flows) */\n .lenses{display:flex;gap:4px}\n .lens{flex:1;display:flex;align-items:center;justify-content:center;gap:7px;\n padding:9px 0;border-radius:9px;font-size:13px;font-weight:600;color:var(--muted);\n background:var(--sunk);border:1px solid transparent}\n .lens .ct{font-family:var(--mono);font-size:11px;font-weight:700;\n padding:1px 6px;border-radius:20px;background:rgba(105,116,138,.16);color:var(--muted);\n font-variant-numeric:tabular-nums}\n .lens[aria-pressed=true]{background:var(--surface);color:var(--ink);\n border-color:var(--line);box-shadow:var(--sh)}\n .lens.files[aria-pressed=true]{color:var(--file)} .lens.files[aria-pressed=true] .ct{background:var(--file-bg);color:var(--file)}\n .lens.flows[aria-pressed=true]{color:var(--flow)} .lens.flows[aria-pressed=true] .ct{background:var(--flow-bg);color:var(--flow)}\n .lens .ic{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.8}\n\n /* status / freshness filter row */\n .statusrow{display:flex;align-items:center;gap:8px;padding:2px}\n .fchip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:20px;\n font-size:12px;font-weight:600;border:1px solid var(--line);background:var(--surface);color:var(--muted)}\n .fchip .d{width:8px;height:8px;border-radius:50%;background:var(--fresh)}\n .fchip.has-stale{color:var(--changed);border-color:var(--changed);background:var(--changed-bg)}\n .fchip.has-stale .d{background:var(--changed)}\n .fchip[aria-pressed=true]{outline:2px solid var(--changed);outline-offset:1px}\n .fchip.all-fresh .d{background:var(--fresh)}\n .treetools{margin-left:auto;display:flex;gap:2px}\n .treetools button{font-family:var(--mono);font-size:10px;letter-spacing:.04em;\n text-transform:uppercase;color:var(--faint);padding:4px 7px;border-radius:6px}\n .treetools button:hover{color:var(--accent);background:var(--sunk)}\n\n .list{overflow-y:auto;padding:8px 8px 60px;flex:1}\n\n /* tree rows */\n .row{display:flex;align-items:center;gap:7px;width:100%;text-align:left;\n padding:5px 8px;border-radius:7px;min-height:30px}\n .row:hover{background:var(--sunk)}\n .row[aria-current=true]{background:var(--accent-soft)}\n .row[aria-current=true] .lbl{color:var(--accent-ink);font-weight:600}\n .chev{width:14px;height:14px;flex-shrink:0;stroke:var(--faint);fill:none;stroke-width:2;\n transition:transform .12s}\n .row.open>.chev{transform:rotate(90deg)}\n .spacer-ic{width:14px;flex-shrink:0}\n .ficon{width:15px;height:15px;flex-shrink:0;stroke:var(--faint);fill:none;stroke-width:1.6}\n .row.dir .ficon{stroke:var(--accent);fill:var(--accent-soft)}\n .lbl{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}\n .row.dir .lbl{font-weight:600;color:var(--ink-soft)}\n .row.dir .lbl .seg-dim{color:var(--faint);font-weight:500}\n .badge{font-family:var(--mono);font-size:10px;color:var(--faint);\n font-variant-numeric:tabular-nums;flex-shrink:0}\n .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .dot.fresh{background:var(--fresh)} .dot.changed{background:var(--changed)}\n .dot.missing{background:var(--missing)} .dot.unverified{background:var(--unver)}\n .char{font-family:var(--mono);font-size:8.5px;font-weight:700;color:var(--faint);\n border:1px solid var(--line);border-radius:3px;padding:0 3px;flex-shrink:0}\n\n /* flow cards */\n .flowcard{display:block;width:100%;text-align:left;padding:11px 12px;border-radius:9px;\n border:1px solid var(--line-soft);background:var(--surface);margin-bottom:7px}\n .flowcard:hover{border-color:var(--flow);box-shadow:var(--sh)}\n .flowcard[aria-current=true]{border-color:var(--flow);background:var(--flow-bg)}\n .fc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}\n .fc-top .dot{margin-top:0}\n .fc-title{font-size:13px;font-weight:600;line-height:1.35;flex:1;overflow-wrap:anywhere}\n .fc-span{display:flex;flex-wrap:wrap;gap:4px}\n .fc-file{font-family:var(--mono);font-size:10px;color:var(--muted);background:var(--sunk);\n padding:1px 6px;border-radius:5px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .fc-more{font-family:var(--mono);font-size:10px;color:var(--faint);padding:1px 3px}\n .empty-note{color:var(--faint);font-size:12px;font-style:italic;padding:20px 10px}\n\n /* ---- reader ---- */\n .reader{overflow-y:auto;padding:0}\n .reader-inner{max-width:760px;margin:0 auto;padding:38px 40px 120px}\n .eyebrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;\n font-family:var(--mono);font-size:11px;margin-bottom:14px}\n .eyebrow .id{color:var(--faint);overflow-wrap:anywhere}\n .eyebrow .sep{color:var(--line)}\n .kind{font-family:var(--mono);font-size:9px;letter-spacing:.06em;font-weight:700;\n text-transform:uppercase;padding:1px 5px;border-radius:4px}\n .kind.file{color:var(--file);background:var(--file-bg)}\n .kind.flow{color:var(--flow);background:var(--flow-bg)}\n h1.note-title{font-size:26px;line-height:1.22;letter-spacing:-.02em;\n font-weight:680;margin:0 0 16px;text-wrap:balance;overflow-wrap:anywhere}\n .metabar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;\n padding-bottom:18px;margin-bottom:22px;border-bottom:1px solid var(--line-soft)}\n .pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;\n font-weight:600;padding:3px 10px;border-radius:20px;font-family:var(--mono);\n letter-spacing:.02em}\n .pill .d{width:7px;height:7px;border-radius:50%}\n .pill.fresh{color:var(--fresh);background:var(--fresh-bg)} .pill.fresh .d{background:var(--fresh)}\n .pill.changed{color:var(--changed);background:var(--changed-bg)} .pill.changed .d{background:var(--changed)}\n .pill.missing{color:var(--missing);background:var(--missing-bg)} .pill.missing .d{background:var(--missing)}\n .pill.unverified{color:var(--unver);background:var(--unver-bg)} .pill.unverified .d{background:var(--unver)}\n .metabar .mi{font-size:12px;color:var(--muted)}\n .metabar .mi b{color:var(--ink-soft);font-variant-numeric:tabular-nums;font-weight:600}\n\n .sec-label{font-family:var(--mono);font-size:10px;letter-spacing:.09em;\n text-transform:uppercase;color:var(--faint);font-weight:600;margin:26px 0 10px}\n\n .anchor{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);\n padding:11px 13px;margin-bottom:8px}\n .anchor-top{display:flex;align-items:center;gap:9px}\n .apath{font-family:var(--mono);font-size:12px;color:var(--ink);font-weight:600;\n overflow-wrap:anywhere;flex:1}\n .apath .p{color:var(--faint)}\n .astate{font-family:var(--mono);font-size:9px;letter-spacing:.06em;font-weight:700;\n text-transform:uppercase;padding:2px 6px;border-radius:4px;white-space:nowrap}\n .astate.fresh{color:var(--fresh);background:var(--fresh-bg)}\n .astate.changed{color:var(--changed);background:var(--changed-bg)}\n .astate.missing{color:var(--missing);background:var(--missing-bg)}\n .astate.unverified{color:var(--unver);background:var(--unver-bg)}\n .copy{font-family:var(--mono);font-size:10px;color:var(--faint);padding:2px 6px;\n border-radius:4px;border:1px solid var(--line)}\n .copy:hover{color:var(--accent);border-color:var(--accent)}\n .syms{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px}\n .sym{font-family:var(--mono);font-size:11px;background:var(--sunk);\n color:var(--ink-soft);padding:2px 7px;border-radius:5px;cursor:pointer;\n transition:background .1s,color .1s;position:relative}\n .sym:hover{background:var(--accent-soft);color:var(--accent-ink)}\n .sym.copied{background:var(--accent);color:#fff}\n .sym.copied::after{content:\"copied\";position:absolute;left:50%;bottom:calc(100% + 5px);\n transform:translateX(-50%);background:var(--ink);color:#fff;font-size:9px;\n padding:2px 6px;border-radius:4px;white-space:nowrap;letter-spacing:.04em}\n\n .aliases{border:1px solid var(--line-soft);border-radius:var(--r);\n background:var(--rail);overflow:hidden}\n .aliases summary{list-style:none;cursor:pointer;padding:9px 13px;\n display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}\n .aliases summary::-webkit-details-marker{display:none}\n .aliases summary b{color:var(--ink-soft);font-weight:600}\n .aliases summary .chv{margin-left:auto;transition:transform .15s}\n .aliases[open] summary .chv{transform:rotate(90deg)}\n .alias-cloud{display:flex;flex-wrap:wrap;gap:6px;padding:2px 13px 13px}\n .alias-cloud span{font-family:var(--mono);font-size:11px;color:var(--ink-soft);\n background:var(--surface);border:1px solid var(--line);padding:2px 8px;border-radius:12px}\n\n .md{font-size:14.5px;line-height:1.68;color:var(--ink-soft)}\n .md h2{font-size:13px;font-family:var(--mono);letter-spacing:.05em;\n text-transform:uppercase;color:var(--ink);font-weight:700;margin:30px 0 12px;\n padding-bottom:7px;border-bottom:1px solid var(--line-soft)}\n .md h3{font-size:15px;margin:22px 0 8px;color:var(--ink)}\n .md p{margin:0 0 14px}\n .md ul,.md ol{margin:0 0 14px;padding-left:22px}\n .md li{margin:0 0 8px}\n .md li::marker{color:var(--faint)}\n .md code{font-family:var(--mono);font-size:12.5px;background:var(--sunk);\n color:var(--accent-ink);padding:1px 5px;border-radius:5px;overflow-wrap:anywhere}\n .md strong{color:var(--ink);font-weight:650}\n .wl{font-family:var(--mono);font-size:12px;color:var(--accent);font-weight:600;\n background:var(--accent-soft);padding:1px 6px;border-radius:5px;\n text-decoration:none;overflow-wrap:anywhere}\n .wl:hover{background:var(--accent);color:#fff}\n .wl.dead{color:var(--muted);background:var(--sunk);cursor:default}\n\n .links-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:34px;\n padding-top:22px;border-top:1px solid var(--line-soft)}\n @media(max-width:640px){.links-grid{grid-template-columns:1fr}}\n .linkcol .sec-label{margin-top:0}\n .linkbtn{display:flex;align-items:center;gap:8px;width:100%;text-align:left;\n padding:8px 10px;border-radius:7px;font-size:12px;margin-bottom:3px}\n .linkbtn:hover{background:var(--sunk)}\n .linkbtn .kind{flex-shrink:0}\n .linkbtn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n\n .placeholder{display:flex;align-items:center;justify-content:center;height:100%;\n color:var(--faint);font-size:13px}\n\n /* ---- graph ---- */\n .graph-wrap{position:relative;overflow:hidden;background:\n radial-gradient(circle at 30% 20%, #f2f5fa, var(--ground))}\n .graph-wrap canvas{display:block;width:100%;height:100%}\n .graph-legend{position:absolute;top:16px;left:16px;background:var(--surface);\n border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;\n box-shadow:var(--sh);font-size:12px;max-width:230px}\n .graph-legend .lg-t{font-family:var(--mono);font-size:10px;letter-spacing:.07em;\n text-transform:uppercase;color:var(--faint);margin-bottom:8px}\n .graph-legend .lg-row{display:flex;align-items:center;gap:8px;margin-bottom:5px}\n .graph-legend .lg-row i{width:12px;height:12px;border-radius:50%;flex-shrink:0}\n .graph-legend .hint{color:var(--muted);margin-top:8px;line-height:1.4}\n .graph-tip{position:absolute;pointer-events:none;background:var(--ink);color:#fff;\n font-size:11.5px;padding:6px 10px;border-radius:7px;max-width:280px;opacity:0;\n transform:translate(-50%,-140%);transition:opacity .1s;box-shadow:var(--sh);z-index:3}\n .graph-tip .tk{font-family:var(--mono);font-size:9px;letter-spacing:.06em;\n text-transform:uppercase;opacity:.7}\n\n @media(max-width:860px){\n .split{grid-template-columns:1fr}\n .rail{position:absolute;inset:57px 0 0 0;z-index:4;width:min(340px,86vw);\n box-shadow:var(--sh)}\n .split:not(.rail-open) .rail{display:none}\n .split.graph-mode .rail{display:none}\n .reader-inner{padding:26px 20px 100px}\n .menu-btn{display:inline-flex!important}\n }\n .menu-btn{display:none;align-items:center;gap:6px}\n</style>\n\n<div class=\"app\">\n <header class=\"top\">\n <button class=\"menu-btn segbtns\" id=\"menuBtn\" style=\"padding:6px 10px\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M2 4h12M2 8h12M2 12h12\"/></svg></button>\n <div class=\"brand\">\n <span class=\"kicker\">Coldstart&nbsp;Notebook</span>\n <span class=\"repo\" id=\"repoName\">repo</span>\n </div>\n <div class=\"spacer\"></div>\n <div class=\"stat\"><b id=\"sTotal\">0</b><span>notes</span></div>\n <div class=\"stat\"><b id=\"sLinks\">0</b><span>links</span></div>\n <div class=\"fresh-meter\">\n <div class=\"meter-bar\" id=\"meterBar\"></div>\n <div class=\"stat\"><b id=\"sFresh\">0%</b><span>fresh</span></div>\n </div>\n <div class=\"segbtns\" role=\"tablist\">\n <button id=\"tabNotes\" aria-pressed=\"true\">Notes</button>\n <button id=\"tabGraph\" aria-pressed=\"false\">Graph</button>\n </div>\n </header>\n\n <div class=\"split\" id=\"split\">\n <aside class=\"rail\" id=\"rail\">\n <div class=\"controls\">\n <div class=\"search\">\n <svg viewBox=\"0 0 16 16\"><circle cx=\"7\" cy=\"7\" r=\"5\"/><path d=\"M11 11l3 3\"/></svg>\n <input id=\"q\" placeholder=\"Search title, alias, path, symbol\u2026\" autocomplete=\"off\">\n </div>\n <div class=\"lenses\">\n <button class=\"lens files\" data-lens=\"files\" aria-pressed=\"true\">\n <svg class=\"ic\" viewBox=\"0 0 16 16\"><path d=\"M2 4.5h5l1.5 1.5H14v6.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z\"/></svg>\n Files <span class=\"ct\" id=\"ctFiles\">0</span>\n </button>\n <button class=\"lens flows\" data-lens=\"flows\" aria-pressed=\"false\">\n <svg class=\"ic\" viewBox=\"0 0 16 16\"><circle cx=\"3.5\" cy=\"3.5\" r=\"1.6\"/><circle cx=\"12.5\" cy=\"8\" r=\"1.6\"/><circle cx=\"3.5\" cy=\"12.5\" r=\"1.6\"/><path d=\"M5 3.9h4a2 2 0 0 1 2 2v.5M5 12.1h4a2 2 0 0 0 2-2v-.5\"/></svg>\n Flows <span class=\"ct\" id=\"ctFlows\">0</span>\n </button>\n </div>\n <div class=\"statusrow\">\n <button class=\"fchip all-fresh\" id=\"staleChip\" aria-pressed=\"false\"><span class=\"d\"></span><span id=\"staleLbl\">All fresh</span></button>\n <div class=\"treetools\" id=\"treeTools\">\n <button data-tt=\"expand\">Expand</button>\n <button data-tt=\"collapse\">Collapse</button>\n </div>\n </div>\n </div>\n <div class=\"list\" id=\"list\"></div>\n </aside>\n\n <main class=\"reader\" id=\"reader\">\n <div class=\"placeholder\">Select a note to read.</div>\n </main>\n\n <section class=\"graph-wrap\" id=\"graphWrap\" style=\"display:none\">\n <canvas id=\"graphCanvas\"></canvas>\n <div class=\"graph-legend\">\n <div class=\"lg-t\">Wikilink graph</div>\n <div class=\"lg-row\"><i style=\"background:var(--file)\"></i>file note</div>\n <div class=\"lg-row\"><i style=\"background:var(--flow)\"></i>flow note</div>\n <div class=\"hint\" id=\"graphHint\">Only linked notes shown. Click a node to open it.</div>\n </div>\n <div class=\"graph-tip\" id=\"graphTip\"></div>\n </section>\n </div>\n</div>\n\n<script>const DB = __DATA_JSON__;</script>\n<script>\n(function(){\n \"use strict\";\n const notes = DB.notes;\n const byId = new Map(notes.map(n=>[n.id,n]));\n const fileNotes = notes.filter(n=>n.type==='file');\n const flowNotes = notes.filter(n=>n.type==='flow');\n const $ = s=>document.querySelector(s);\n const esc = s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n // clipboard: navigator.clipboard is unreliable on file:// \u2014 fall back to execCommand\n function copyText(t){\n try{\n if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t).catch(()=>fallbackCopy(t));return;}\n }catch(e){}\n fallbackCopy(t);\n }\n function fallbackCopy(t){\n try{const ta=document.createElement('textarea');ta.value=t;ta.style.position='fixed';ta.style.opacity='0';\n document.body.appendChild(ta);ta.focus();ta.select();document.execCommand('copy');document.body.removeChild(ta);}catch(e){}\n }\n const RANK={fresh:0,unverified:1,changed:2,missing:3};\n const isStale=n=>n.freshness==='changed'||n.freshness==='missing';\n\n let state = { lens:'files', q:'', selected:null, view:'notes', staleOnly:false, open:new Set() };\n\n // ---- header stats ----\n $('#repoName').textContent = DB.summary.repo;\n $('#sTotal').textContent = DB.summary.total;\n $('#ctFiles').textContent = fileNotes.length;\n $('#ctFlows').textContent = flowNotes.length;\n const edgeCount = notes.reduce((s,n)=>s+n.outLinks.filter(l=>byId.has(l)).length,0);\n $('#sLinks').textContent = edgeCount;\n const fr = DB.summary.byFreshness;\n const freshPct = Math.round(100*(fr.fresh||0)/DB.summary.total);\n $('#sFresh').textContent = freshPct+'%';\n const mb = $('#meterBar');\n const morder=[['fresh','--fresh'],['changed','--changed'],['unverified','--unver'],['missing','--missing']];\n mb.innerHTML = morder.filter(([k])=>fr[k]).map(([k,v])=>\n `<i style=\"width:${100*fr[k]/DB.summary.total}%;background:var(${v})\"></i>`).join('');\n\n // stale chip\n const staleCount = notes.filter(isStale).length;\n const chip=$('#staleChip');\n if(staleCount){\n chip.classList.remove('all-fresh');chip.classList.add('has-stale');\n $('#staleLbl').textContent=`${staleCount} need review`;\n }else{\n $('#staleLbl').textContent='All fresh';\n }\n\n // ---- filter helpers ----\n function matches(n,q){\n if(!q) return true;\n const hay=(n.title+' '+n.id+' '+n.aliases.join(' ')+' '+n.anchors.map(a=>a.path+' '+(a.symbols||[]).join(' ')).join(' ')).toLowerCase();\n return q.toLowerCase().split(/\\s+/).every(t=>hay.includes(t));\n }\n function passFilter(n){\n if(state.staleOnly && !isStale(n)) return false;\n return matches(n,state.q);\n }\n\n // ================= FILE TREE =================\n function buildTree(){\n const root={name:'',path:'',children:new Map(),notes:[],dir:true};\n for(const n of fileNotes){\n const path=n.anchors[0]?n.anchors[0].path:n.title;\n const segs=path.split('/');\n let cur=root;\n for(let i=0;i<segs.length-1;i++){\n const s=segs[i], p=segs.slice(0,i+1).join('/');\n if(!cur.children.has(s)) cur.children.set(s,{name:s,path:p,children:new Map(),notes:[],dir:true});\n cur=cur.children.get(s);\n }\n cur.notes.push(Object.assign({leaf:segs[segs.length-1]},n));\n }\n // compact single-child dir chains (VSCode style), never the root\n const compact=(node,isRoot)=>{\n for(const c of node.children.values()) compact(c,false);\n if(!isRoot){\n while(node.children.size===1 && node.notes.length===0){\n const only=[...node.children.values()][0];\n node.name = node.name+'/'+only.name;\n node.path = only.path; node.notes = only.notes; node.children = only.children;\n }\n }\n };\n compact(root,true);\n // rollups\n const roll=(node)=>{\n let worst='fresh',cnt=node.notes.length;\n for(const n of node.notes) if(RANK[n.freshness]>RANK[worst]) worst=n.freshness;\n for(const c of node.children.values()){roll(c); if(RANK[c.roll]>RANK[worst]) worst=c.roll; cnt+=c.count;}\n node.roll=worst; node.count=cnt;\n };\n roll(root);\n return root;\n }\n const TREE=buildTree();\n\n function nodeHasMatch(node){\n if(node.notes.some(passFilter)) return true;\n for(const c of node.children.values()) if(nodeHasMatch(c)) return true;\n return false;\n }\n function ancestorsOf(id){\n // dir paths that contain the selected file note\n const n=byId.get(id); if(!n||n.type!=='file') return new Set();\n const path=n.anchors[0]?n.anchors[0].path:n.title;\n const set=new Set(); const segs=path.split('/');\n for(let i=1;i<segs.length;i++) set.add(segs.slice(0,i).join('/'));\n return set;\n }\n const FOLDER_SVG='<svg class=\"ficon\" viewBox=\"0 0 16 16\"><path d=\"M2 4.5h5l1.5 1.5H14v6.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z\"/></svg>';\n const FILE_SVG='<svg class=\"ficon\" viewBox=\"0 0 16 16\"><path d=\"M4 1.5h5L13 5v9a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V2a.5.5 0 0 1 1-.5z\"/><path d=\"M9 1.5V5h4\"/></svg>';\n\n function renderTreeNode(node,depth,active,out){\n const dirs=[...node.children.values()].filter(nodeHasMatch)\n .sort((a,b)=>a.name.localeCompare(b.name));\n const leaves=node.notes.filter(passFilter).sort((a,b)=>a.leaf.localeCompare(b.leaf));\n const pad=8+depth*15;\n for(const d of dirs){\n const open = active || state.open.has(d.path);\n const dimIdx=d.name.lastIndexOf('/');\n const nm = dimIdx>-1 ? `<span class=\"seg-dim\">${esc(d.name.slice(0,dimIdx+1))}</span>${esc(d.name.slice(dimIdx+1))}` : esc(d.name);\n out.push(`<button class=\"row dir ${open?'open':''}\" data-dir=\"${esc(d.path)}\" style=\"padding-left:${pad}px\">\n <svg class=\"chev\" viewBox=\"0 0 16 16\"><path d=\"M6 3l5 5-5 5\"/></svg>${FOLDER_SVG}\n <span class=\"lbl\">${nm}</span>\n <span class=\"badge\">${d.count}</span>\n ${d.roll!=='fresh'?`<span class=\"dot ${d.roll}\"></span>`:''}\n </button>`);\n if(open) renderTreeNode(d,depth+1,active,out);\n }\n for(const n of leaves){\n out.push(`<button class=\"row leaf\" data-id=\"${esc(n.id)}\" aria-current=\"${n.id===state.selected}\" style=\"padding-left:${pad+21}px\">\n ${FILE_SVG}\n <span class=\"lbl\">${esc(n.leaf)}</span>\n ${n.character?`<span class=\"char\" title=\"${esc(n.character)} note\">${n.character[0].toUpperCase()}</span>`:''}\n <span class=\"dot ${n.freshness}\"></span>\n </button>`);\n }\n }\n\n function renderFiles(){\n const active=!!(state.q||state.staleOnly);\n const out=[];\n renderTreeNode(TREE,0,active,out);\n $('#list').innerHTML = out.length?out.join(''):'<div class=\"empty-note\">No file notes match.</div>';\n }\n\n // ================= FLOWS =================\n function renderFlows(){\n const list=flowNotes.filter(passFilter)\n .sort((a,b)=>b.anchors.length-a.anchors.length||a.title.localeCompare(b.title));\n if(!list.length){$('#list').innerHTML='<div class=\"empty-note\">No flows match.</div>';return;}\n $('#list').innerHTML=list.map(n=>{\n const files=n.anchors.map(a=>a.path.split('/').pop());\n const shown=files.slice(0,4), extra=files.length-shown.length;\n return `<button class=\"flowcard\" data-id=\"${esc(n.id)}\" aria-current=\"${n.id===state.selected}\">\n <div class=\"fc-top\">\n <span class=\"dot ${n.freshness}\"></span>\n <span class=\"fc-title\">${esc(n.title)}</span>\n </div>\n <div class=\"fc-span\">\n ${shown.map(f=>`<span class=\"fc-file\">${esc(f)}</span>`).join('')}\n ${extra>0?`<span class=\"fc-more\">+${extra} more</span>`:''}\n </div>\n </button>`;\n }).join('');\n }\n\n function renderList(){\n $('#treeTools').style.visibility = state.lens==='files' ? 'visible':'hidden';\n if(state.lens==='files') renderFiles(); else renderFlows();\n }\n\n // ---- markdown mini-renderer ----\n function mdInline(t){\n t=esc(t);\n t=t.replace(/`([^`]+)`/g,(_,c)=>`<code>${c}</code>`);\n t=t.replace(/\\[\\[([^\\]]+)\\]\\]/g,(_,id)=>{\n const ok=byId.has(id);\n return `<a class=\"wl${ok?'':' dead'}\" ${ok?`data-goto=\"${esc(id)}\"`:''} title=\"${esc(id)}\">${ok?esc(byId.get(id).title):esc(id)}</a>`;\n });\n t=t.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>');\n return t;\n }\n function renderMd(src){\n const lines=src.split('\\n');\n let html='',i=0;\n const flush=(buf,tag)=>buf.length?`<${tag}>${buf.join('')}</${tag}>`:'';\n while(i<lines.length){\n let ln=lines[i];\n if(/^#\\s+/.test(ln)){i++;continue;}\n if(/^##\\s+/.test(ln)){html+=`<h2>${mdInline(ln.replace(/^##\\s+/,''))}</h2>`;i++;continue;}\n if(/^###\\s+/.test(ln)){html+=`<h3>${mdInline(ln.replace(/^###\\s+/,''))}</h3>`;i++;continue;}\n if(/^\\s*[-*]\\s+/.test(ln)){\n const buf=[];\n while(i<lines.length&&/^\\s*[-*]\\s+/.test(lines[i])){buf.push(`<li>${mdInline(lines[i].replace(/^\\s*[-*]\\s+/,''))}</li>`);i++;}\n html+=flush(buf,'ul');continue;\n }\n if(/^\\s*\\d+\\.\\s+/.test(ln)){\n const buf=[];\n while(i<lines.length&&/^\\s*\\d+\\.\\s+/.test(lines[i])){buf.push(`<li>${mdInline(lines[i].replace(/^\\s*\\d+\\.\\s+/,''))}</li>`);i++;}\n html+=flush(buf,'ol');continue;\n }\n if(!ln.trim()){i++;continue;}\n const buf=[];\n while(i<lines.length&&lines[i].trim()&&!/^(#{1,3}\\s|\\s*[-*]\\s|\\s*\\d+\\.\\s)/.test(lines[i])){buf.push(lines[i]);i++;}\n html+=`<p>${mdInline(buf.join(' '))}</p>`;\n }\n return html;\n }\n function anchorHtml(a){\n const parts=a.path.split('/'); const file=parts.pop();\n const dir=parts.length?parts.join('/')+'/':'';\n return `<div class=\"anchor\">\n <div class=\"anchor-top\">\n <span class=\"apath\"><span class=\"p\">${esc(dir)}</span>${esc(file)}</span>\n <span class=\"astate ${a.state}\">${a.state}</span>\n <button class=\"copy\" data-copy=\"${esc(a.path)}\">copy</button>\n </div>\n ${(a.symbols&&a.symbols.length)?`<div class=\"syms\">${a.symbols.map(s=>`<button class=\"sym\" data-copysym=\"${esc(s)}\" title=\"click to copy symbol\">${esc(s)}</button>`).join('')}</div>`:''}\n </div>`;\n }\n function linkBtn(id){\n const n=byId.get(id);\n return `<button class=\"linkbtn\" data-goto=\"${esc(id)}\"><span class=\"kind ${n.type}\">${n.type.toUpperCase()}</span><span>${esc(n.title)}</span></button>`;\n }\n function renderNote(id){\n const n=byId.get(id); if(!n) return;\n state.selected=id;\n // reveal once: persist the file's ancestor folders so they stay open\n // (and prior folders stay open too) \u2014 you can still collapse any by hand\n if(n.type==='file') for(const p of ancestorsOf(id)) state.open.add(p);\n const outs=n.outLinks.filter(l=>byId.has(l));\n const r=$('#reader');\n r.innerHTML=`<div class=\"reader-inner\">\n <div class=\"eyebrow\">\n <span class=\"kind ${n.type}\">${n.type.toUpperCase()}${n.character?' \u00B7 '+n.character:''}</span>\n <span class=\"sep\">/</span><span class=\"id\">${esc(n.id)}</span>\n </div>\n <h1 class=\"note-title\">${esc(n.title)}</h1>\n <div class=\"metabar\">\n <span class=\"pill ${n.freshness}\"><span class=\"d\"></span>${n.freshness}</span>\n <span class=\"mi\"><b>${n.anchors.length}</b> anchor${n.anchors.length===1?'':'s'}</span>\n <span class=\"mi\"><b>${n.edits}</b> edit${n.edits===1?'':'s'}</span>\n <span class=\"mi\">updated <b>${esc((n.updated||'').slice(0,10))}</b></span>\n </div>\n <div class=\"sec-label\">Anchors \u2014 verified code addresses</div>\n ${n.anchors.length?n.anchors.map(anchorHtml).join(''):'<div class=\"empty-note\">No anchors.</div>'}\n ${n.aliases.length?`<details class=\"aliases\" style=\"margin-top:14px\">\n <summary><b>${n.aliases.length}</b> search aliases <span style=\"color:var(--faint)\">\u2014 queries that should surface this note</span><span class=\"chv\">\u203A</span></summary>\n <div class=\"alias-cloud\">${n.aliases.map(a=>`<span>${esc(a)}</span>`).join('')}</div>\n </details>`:''}\n <div class=\"sec-label\">Note</div>\n <div class=\"md\">${renderMd(n.body)}</div>\n <div class=\"links-grid\">\n <div class=\"linkcol\"><div class=\"sec-label\">Links to (${outs.length})</div>\n ${outs.length?outs.map(linkBtn).join(''):'<div class=\"empty-note\">No outgoing links.</div>'}</div>\n <div class=\"linkcol\"><div class=\"sec-label\">Referenced by (${n.backlinks.length})</div>\n ${n.backlinks.length?n.backlinks.map(linkBtn).join(''):'<div class=\"empty-note\">Not referenced.</div>'}</div>\n </div>\n </div>`;\n r.scrollTop=0;\n // switch lens to match the note type so it's visible in the rail\n if(n.type==='flow'&&state.lens!=='flows') setLens('flows');\n else if(n.type==='file'&&state.lens!=='files') setLens('files');\n else renderList();\n }\n\n // ---- events ----\n $('#q').addEventListener('input',e=>{state.q=e.target.value;renderList();});\n $('.lenses').addEventListener('click',e=>{const b=e.target.closest('.lens');if(b)setLens(b.dataset.lens);});\n function setLens(l){\n state.lens=l;\n document.querySelectorAll('.lens').forEach(x=>x.setAttribute('aria-pressed',x.dataset.lens===l));\n renderList();\n }\n chip.addEventListener('click',()=>{state.staleOnly=!state.staleOnly;chip.setAttribute('aria-pressed',state.staleOnly);renderList();});\n $('#treeTools').addEventListener('click',e=>{\n const b=e.target.closest('button');if(!b)return;\n if(b.dataset.tt==='collapse'){state.open.clear();}\n else{ // expand all dirs\n const walk=nd=>{for(const c of nd.children.values()){state.open.add(c.path);walk(c);}};walk(TREE);\n }\n renderFiles();\n });\n document.addEventListener('click',e=>{\n const dir=e.target.closest('[data-dir]');\n if(dir){const p=dir.dataset.dir;state.open.has(p)?state.open.delete(p):state.open.add(p);renderFiles();return;}\n const row=e.target.closest('[data-id]');\n if(row){switchView('notes');renderNote(row.dataset.id);closeRail();return;}\n const goto=e.target.closest('[data-goto]');\n if(goto){switchView('notes');renderNote(goto.dataset.goto);return;}\n const cs=e.target.closest('[data-copysym]');\n if(cs){copyText(cs.dataset.copysym);cs.classList.add('copied');setTimeout(()=>cs.classList.remove('copied'),750);return;}\n const cp=e.target.closest('[data-copy]');\n if(cp){copyText(cp.dataset.copy);cp.textContent='copied';setTimeout(()=>cp.textContent='copy',900);return;}\n });\n\n // ---- view switching ----\n function switchView(v){\n state.view=v;\n $('#tabNotes').setAttribute('aria-pressed',v==='notes');\n $('#tabGraph').setAttribute('aria-pressed',v==='graph');\n $('#split').classList.toggle('graph-mode',v==='graph');\n $('#reader').style.display=v==='graph'?'none':'';\n $('#graphWrap').style.display=v==='graph'?'':'none';\n if(v==='graph') drawGraph();\n }\n $('#tabNotes').onclick=()=>switchView('notes');\n $('#tabGraph').onclick=()=>switchView('graph');\n function closeRail(){$('#split').classList.remove('rail-open');}\n $('#menuBtn').onclick=()=>$('#split').classList.toggle('rail-open');\n\n // ================= GRAPH =================\n let G=null;\n function buildGraph(){\n const linked=new Set(); const edges=[];\n for(const n of notes) for(const l of n.outLinks) if(byId.has(l)&&l!==n.id){linked.add(n.id);linked.add(l);edges.push([n.id,l]);}\n const ids=[...linked]; const idx=new Map(ids.map((id,i)=>[id,i])); const N=ids.length;\n const nodes=ids.map((id,i)=>{const a=2*Math.PI*i/N;\n return {id,x:Math.cos(a)*260,y:Math.sin(a)*260,vx:0,vy:0,deg:0,type:byId.get(id).type,title:byId.get(id).title};});\n const E=edges.map(([a,b])=>[idx.get(a),idx.get(b)]);\n for(const [a,b] of E){nodes[a].deg++;nodes[b].deg++;}\n for(let it=0;it<320;it++){\n const k=it<220?1:0.4;\n for(let i=0;i<N;i++){let fx=0,fy=0;\n for(let j=0;j<N;j++){if(i===j)continue;\n let dx=nodes[i].x-nodes[j].x,dy=nodes[i].y-nodes[j].y,d2=dx*dx+dy*dy+0.01,d=Math.sqrt(d2);\n const rep=1600/d2; fx+=dx/d*rep; fy+=dy/d*rep;}\n fx-=nodes[i].x*0.012; fy-=nodes[i].y*0.012;\n nodes[i].vx=(nodes[i].vx+fx)*0.82; nodes[i].vy=(nodes[i].vy+fy)*0.82;}\n for(const [a,b] of E){let dx=nodes[b].x-nodes[a].x,dy=nodes[b].y-nodes[a].y,d=Math.sqrt(dx*dx+dy*dy)+0.01;\n const spring=(d-90)*0.02,ux=dx/d*spring,uy=dy/d*spring;\n nodes[a].vx+=ux;nodes[a].vy+=uy;nodes[b].vx-=ux;nodes[b].vy-=uy;}\n for(const nd of nodes){nd.x+=nd.vx*k;nd.y+=nd.vy*k;}\n }\n G={nodes,E,idx};\n $('#graphHint').textContent=`${N} linked notes \u00B7 ${edges.length} links. Click a node to open it.`;\n }\n let cam={x:0,y:0,s:1},hover=-1;\n function drawGraph(){\n if(!G) buildGraph();\n const cv=$('#graphCanvas'),wrap=$('#graphWrap'),dpr=window.devicePixelRatio||1;\n const W=wrap.clientWidth,H=wrap.clientHeight;\n cv.width=W*dpr;cv.height=H*dpr;\n const ctx=cv.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);\n const xs=G.nodes.map(n=>n.x),ys=G.nodes.map(n=>n.y);\n const minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys),pad=70;\n const s=Math.min((W-pad*2)/(maxX-minX||1),(H-pad*2)/(maxY-minY||1));\n cam.s=s;cam.x=W/2-(minX+maxX)/2*s;cam.y=H/2-(minY+maxY)/2*s;\n const T=(x,y)=>[x*cam.s+cam.x,y*cam.s+cam.y];\n ctx.clearRect(0,0,W,H);\n for(const [a,b] of G.E){const A=G.nodes[a],B=G.nodes[b],[ax,ay]=T(A.x,A.y),[bx,by]=T(B.x,B.y);\n const on=hover>-1&&(a===hover||b===hover);\n ctx.strokeStyle=on?'rgba(31,79,216,.55)':'rgba(105,116,138,.20)';ctx.lineWidth=on?1.8:1;\n ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();}\n const cs=getComputedStyle(document.documentElement);\n const cFile=cs.getPropertyValue('--file').trim(),cFlow=cs.getPropertyValue('--flow').trim();\n G.nodes.forEach((n,i)=>{const[x,y]=T(n.x,n.y),r=Math.max(4,Math.min(13,4+n.deg*1.6)),isH=i===hover;\n ctx.beginPath();ctx.arc(x,y,r+(isH?2:0),0,7);ctx.fillStyle=n.type==='flow'?cFlow:cFile;\n ctx.globalAlpha=hover>-1&&!isH&&!G.E.some(([a,b])=>(a===hover&&b===i)||(b===hover&&a===i))?0.35:1;\n ctx.fill();if(isH){ctx.lineWidth=2;ctx.strokeStyle='#fff';ctx.stroke();}ctx.globalAlpha=1;});\n G._T=T;\n }\n const cv=$('#graphCanvas');\n function pick(mx,my){if(!G||!G._T)return -1;let best=-1,bd=18*18;\n G.nodes.forEach((n,i)=>{const[x,y]=G._T(n.x,n.y),d=(x-mx)**2+(y-my)**2,r=Math.max(4,Math.min(13,4+n.deg*1.6))+4;\n if(d<r*r&&d<bd){bd=d;best=i;}});return best;}\n cv.addEventListener('mousemove',e=>{const rect=cv.getBoundingClientRect(),mx=e.clientX-rect.left,my=e.clientY-rect.top,p=pick(mx,my),tip=$('#graphTip');\n if(p!==hover){hover=p;drawGraph();}\n if(p>-1){const n=G.nodes[p];tip.style.opacity=1;tip.style.left=mx+'px';tip.style.top=my+'px';\n tip.innerHTML=`<div class=\"tk\">${n.type} \u00B7 ${n.deg} link${n.deg===1?'':'s'}</div>${esc(n.title)}`;cv.style.cursor='pointer';}\n else{tip.style.opacity=0;cv.style.cursor='default';}});\n cv.addEventListener('mouseleave',()=>{hover=-1;$('#graphTip').style.opacity=0;drawGraph();});\n cv.addEventListener('click',e=>{const rect=cv.getBoundingClientRect(),p=pick(e.clientX-rect.left,e.clientY-rect.top);\n if(p>-1){switchView('notes');renderNote(G.nodes[p].id);}});\n window.addEventListener('resize',()=>{if(state.view==='graph')drawGraph();});\n\n // ---- boot ----\n // pre-open the top two directory levels so the Files tree opens usefully\n (function preopen(node,depth){for(const c of node.children.values()){if(depth<2)state.open.add(c.path);preopen(c,depth+1);}})(TREE,0);\n // land on the richest FILE note; the Flows tab (badged) is one click away\n const seed=[...fileNotes].sort((a,b)=>(b.outLinks.length+b.backlinks.length)-(a.outLinks.length+a.backlinks.length))[0]\n || notes[0];\n renderList();\n if(seed) renderNote(seed.id);\n})();\n</script>\n";
8
+ export declare const VIEW_TEMPLATE = "<title>__TITLE__</title>\n<style>\n :root{\n --ground:#eaeef3; --surface:#ffffff; --rail:#f5f8fc; --sunk:#eef2f7;\n --ink:#161a22; --ink-soft:#39414f; --muted:#69748a; --faint:#9aa6b8;\n --line:#dce2ec; --line-soft:#e8edf3;\n --accent:#1f4fd8; --accent-soft:#e7ecfd; --accent-ink:#173bab;\n --fresh:#137a3b; --fresh-bg:#e4f4ea;\n --changed:#a85a06; --changed-bg:#fbeedd;\n --missing:#bb1436; --missing-bg:#fbe3e8;\n --unver:#5a6577; --unver-bg:#eceff4;\n --flow:#0b7285; --flow-bg:#dcf1f4;\n --file:#1f4fd8; --file-bg:#e7ecfd;\n --mono:ui-monospace,\"SF Mono\",SFMono-Regular,Menlo,Consolas,monospace;\n --sans:system-ui,-apple-system,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n --sh:0 1px 2px rgba(20,28,45,.05),0 4px 16px rgba(20,28,45,.06);\n --r:9px;\n }\n *{box-sizing:border-box}\n html,body{height:100%}\n body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);\n font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;\n text-rendering:optimizeLegibility;}\n button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}\n ::selection{background:var(--accent-soft)}\n\n .app{display:grid;grid-template-rows:auto 1fr;height:100vh;overflow:hidden}\n\n /* ---- top bar ---- */\n .top{display:flex;align-items:center;gap:20px;padding:12px 20px;\n background:var(--surface);border-bottom:1px solid var(--line);z-index:5}\n .brand{display:flex;flex-direction:column;justify-content:center;min-width:0;gap:1px}\n .brand .kicker{font-family:var(--mono);font-size:9.5px;letter-spacing:.11em;\n text-transform:uppercase;color:var(--faint);font-weight:600;line-height:1}\n .brand .repo{display:inline-flex;align-items:center;gap:6px;font-size:17px;\n font-weight:680;letter-spacing:-.02em;color:var(--ink);line-height:1.1}\n .brand .repo::before{content:\"\";width:7px;height:7px;border-radius:50%;\n background:var(--accent);flex-shrink:0}\n .top .spacer{flex:1}\n .stat{display:flex;flex-direction:column;line-height:1.15}\n .stat b{font-size:16px;font-variant-numeric:tabular-nums;letter-spacing:-.02em}\n .stat span{font-family:var(--mono);font-size:9.5px;letter-spacing:.09em;\n text-transform:uppercase;color:var(--faint)}\n .fresh-meter{display:flex;align-items:center;gap:8px}\n .meter-bar{width:120px;height:8px;border-radius:5px;background:var(--sunk);\n overflow:hidden;display:flex}\n .meter-bar i{display:block;height:100%}\n .segbtns{display:flex;background:var(--sunk);border-radius:8px;padding:3px}\n .segbtns button{padding:5px 13px;border-radius:6px;font-size:12.5px;\n font-weight:500;color:var(--muted)}\n .segbtns button[aria-pressed=true]{background:var(--surface);color:var(--ink);\n box-shadow:0 1px 2px rgba(20,28,45,.12);font-weight:600}\n\n /* ---- body split ---- */\n .split{display:grid;grid-template-columns:340px 1fr;min-height:0}\n .split.graph-mode{grid-template-columns:1fr}\n .split.graph-mode .rail{display:none}\n\n /* ---- rail ---- */\n .rail{background:var(--rail);border-right:1px solid var(--line);\n display:flex;flex-direction:column;min-height:0}\n .rail .controls{padding:12px 12px 0;display:flex;flex-direction:column;gap:10px}\n .search{position:relative}\n .search input{width:100%;padding:9px 12px 9px 32px;border:1px solid var(--line);\n border-radius:8px;background:var(--surface);font-size:13px;outline:none}\n .search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}\n .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);\n width:15px;height:15px;stroke:var(--faint);fill:none;stroke-width:2}\n\n /* lens tabs (Files / Flows) */\n .lenses{display:flex;gap:4px}\n .lens{flex:1;display:flex;align-items:center;justify-content:center;gap:7px;\n padding:9px 0;border-radius:9px;font-size:13px;font-weight:600;color:var(--muted);\n background:var(--sunk);border:1px solid transparent}\n .lens .ct{font-family:var(--mono);font-size:11px;font-weight:700;\n padding:1px 6px;border-radius:20px;background:rgba(105,116,138,.16);color:var(--muted);\n font-variant-numeric:tabular-nums}\n .lens[aria-pressed=true]{background:var(--surface);color:var(--ink);\n border-color:var(--line);box-shadow:var(--sh)}\n .lens.files[aria-pressed=true]{color:var(--file)} .lens.files[aria-pressed=true] .ct{background:var(--file-bg);color:var(--file)}\n .lens.flows[aria-pressed=true]{color:var(--flow)} .lens.flows[aria-pressed=true] .ct{background:var(--flow-bg);color:var(--flow)}\n .lens .ic{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.8}\n\n /* status / freshness filter row */\n .statusrow{display:flex;align-items:center;gap:8px;padding:2px}\n .fchip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:20px;\n font-size:12px;font-weight:600;border:1px solid var(--line);background:var(--surface);color:var(--muted)}\n .fchip .d{width:8px;height:8px;border-radius:50%;background:var(--fresh)}\n .fchip.has-stale{color:var(--changed);border-color:var(--changed);background:var(--changed-bg)}\n .fchip.has-stale .d{background:var(--changed)}\n .fchip[aria-pressed=true]{outline:2px solid var(--changed);outline-offset:1px}\n .fchip.all-fresh .d{background:var(--fresh)}\n .treetools{margin-left:auto;display:flex;gap:2px}\n .treetools button{font-family:var(--mono);font-size:10px;letter-spacing:.04em;\n text-transform:uppercase;color:var(--faint);padding:4px 7px;border-radius:6px}\n .treetools button:hover{color:var(--accent);background:var(--sunk)}\n\n .list{overflow-y:auto;padding:8px 8px 60px;flex:1}\n\n /* tree rows */\n .row{display:flex;align-items:center;gap:7px;width:100%;text-align:left;\n padding:5px 8px;border-radius:7px;min-height:30px}\n .row:hover{background:var(--sunk)}\n .row[aria-current=true]{background:var(--accent-soft)}\n .row[aria-current=true] .lbl{color:var(--accent-ink);font-weight:600}\n .chev{width:14px;height:14px;flex-shrink:0;stroke:var(--faint);fill:none;stroke-width:2;\n transition:transform .12s}\n .row.open>.chev{transform:rotate(90deg)}\n .spacer-ic{width:14px;flex-shrink:0}\n .ficon{width:15px;height:15px;flex-shrink:0;stroke:var(--faint);fill:none;stroke-width:1.6}\n .row.dir .ficon{stroke:var(--accent);fill:var(--accent-soft)}\n .lbl{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}\n .row.dir .lbl{font-weight:600;color:var(--ink-soft)}\n .row.dir .lbl .seg-dim{color:var(--faint);font-weight:500}\n .badge{font-family:var(--mono);font-size:10px;color:var(--faint);\n font-variant-numeric:tabular-nums;flex-shrink:0}\n .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .dot.fresh{background:var(--fresh)} .dot.changed{background:var(--changed)}\n .dot.missing{background:var(--missing)} .dot.unverified{background:var(--unver)}\n .char{font-family:var(--mono);font-size:8.5px;font-weight:700;color:var(--faint);\n border:1px solid var(--line);border-radius:3px;padding:0 3px;flex-shrink:0}\n\n /* flow cards */\n .flowcard{display:block;width:100%;text-align:left;padding:11px 12px;border-radius:9px;\n border:1px solid var(--line-soft);background:var(--surface);margin-bottom:7px}\n .flowcard:hover{border-color:var(--flow);box-shadow:var(--sh)}\n .flowcard[aria-current=true]{border-color:var(--flow);background:var(--flow-bg)}\n .fc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}\n .fc-top .dot{margin-top:0}\n .fc-title{font-size:13px;font-weight:600;line-height:1.35;flex:1;overflow-wrap:anywhere}\n .fc-span{display:flex;flex-wrap:wrap;gap:4px}\n .fc-file{font-family:var(--mono);font-size:10px;color:var(--muted);background:var(--sunk);\n padding:1px 6px;border-radius:5px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .fc-more{font-family:var(--mono);font-size:10px;color:var(--faint);padding:1px 3px}\n .empty-note{color:var(--faint);font-size:12px;font-style:italic;padding:20px 10px}\n\n /* ---- reader ---- */\n .reader{overflow-y:auto;padding:0}\n .reader-inner{max-width:760px;margin:0 auto;padding:38px 40px 120px}\n .eyebrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;\n font-family:var(--mono);font-size:11px;margin-bottom:14px}\n .eyebrow .id{color:var(--faint);overflow-wrap:anywhere}\n .eyebrow .sep{color:var(--line)}\n .kind{font-family:var(--mono);font-size:9px;letter-spacing:.06em;font-weight:700;\n text-transform:uppercase;padding:1px 5px;border-radius:4px}\n .kind.file{color:var(--file);background:var(--file-bg)}\n .kind.flow{color:var(--flow);background:var(--flow-bg)}\n h1.note-title{font-size:26px;line-height:1.22;letter-spacing:-.02em;\n font-weight:680;margin:0 0 16px;text-wrap:balance;overflow-wrap:anywhere}\n .metabar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;\n padding-bottom:18px;margin-bottom:22px;border-bottom:1px solid var(--line-soft)}\n .pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;\n font-weight:600;padding:3px 10px;border-radius:20px;font-family:var(--mono);\n letter-spacing:.02em}\n .pill .d{width:7px;height:7px;border-radius:50%}\n .pill.fresh{color:var(--fresh);background:var(--fresh-bg)} .pill.fresh .d{background:var(--fresh)}\n .pill.changed{color:var(--changed);background:var(--changed-bg)} .pill.changed .d{background:var(--changed)}\n .pill.missing{color:var(--missing);background:var(--missing-bg)} .pill.missing .d{background:var(--missing)}\n .pill.unverified{color:var(--unver);background:var(--unver-bg)} .pill.unverified .d{background:var(--unver)}\n .metabar .mi{font-size:12px;color:var(--muted)}\n .metabar .mi b{color:var(--ink-soft);font-variant-numeric:tabular-nums;font-weight:600}\n\n .sec-label{font-family:var(--mono);font-size:10px;letter-spacing:.09em;\n text-transform:uppercase;color:var(--faint);font-weight:600;margin:26px 0 10px}\n\n .anchor{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);\n padding:11px 13px;margin-bottom:8px}\n .anchor-top{display:flex;align-items:center;gap:9px}\n .apath{font-family:var(--mono);font-size:12px;color:var(--ink);font-weight:600;\n overflow-wrap:anywhere;flex:1}\n .apath .p{color:var(--faint)}\n .astate{font-family:var(--mono);font-size:9px;letter-spacing:.06em;font-weight:700;\n text-transform:uppercase;padding:2px 6px;border-radius:4px;white-space:nowrap}\n .astate.fresh{color:var(--fresh);background:var(--fresh-bg)}\n .astate.changed{color:var(--changed);background:var(--changed-bg)}\n .astate.missing{color:var(--missing);background:var(--missing-bg)}\n .astate.unverified{color:var(--unver);background:var(--unver-bg)}\n .copy{font-family:var(--mono);font-size:10px;color:var(--faint);padding:2px 6px;\n border-radius:4px;border:1px solid var(--line)}\n .copy:hover{color:var(--accent);border-color:var(--accent)}\n .syms{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px}\n .sym{font-family:var(--mono);font-size:11px;background:var(--sunk);\n color:var(--ink-soft);padding:2px 7px;border-radius:5px;cursor:pointer;\n transition:background .1s,color .1s;position:relative}\n .sym:hover{background:var(--accent-soft);color:var(--accent-ink)}\n .sym.copied{background:var(--accent);color:#fff}\n .sym.copied::after{content:\"copied\";position:absolute;left:50%;bottom:calc(100% + 5px);\n transform:translateX(-50%);background:var(--ink);color:#fff;font-size:9px;\n padding:2px 6px;border-radius:4px;white-space:nowrap;letter-spacing:.04em}\n\n .aliases{border:1px solid var(--line-soft);border-radius:var(--r);\n background:var(--rail);overflow:hidden}\n .aliases summary{list-style:none;cursor:pointer;padding:9px 13px;\n display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}\n .aliases summary::-webkit-details-marker{display:none}\n .aliases summary b{color:var(--ink-soft);font-weight:600}\n .aliases summary .chv{margin-left:auto;transition:transform .15s}\n .aliases[open] summary .chv{transform:rotate(90deg)}\n .alias-cloud{display:flex;flex-wrap:wrap;gap:6px;padding:2px 13px 13px}\n .alias-cloud span{font-family:var(--mono);font-size:11px;color:var(--ink-soft);\n background:var(--surface);border:1px solid var(--line);padding:2px 8px;border-radius:12px}\n\n .md{font-size:14.5px;line-height:1.68;color:var(--ink-soft)}\n .md h2{font-size:13px;font-family:var(--mono);letter-spacing:.05em;\n text-transform:uppercase;color:var(--ink);font-weight:700;margin:30px 0 12px;\n padding-bottom:7px;border-bottom:1px solid var(--line-soft)}\n .md h3{font-size:15px;margin:22px 0 8px;color:var(--ink)}\n .md p{margin:0 0 14px}\n .md ul,.md ol{margin:0 0 14px;padding-left:22px}\n .md li{margin:0 0 8px}\n .md li::marker{color:var(--faint)}\n .md code{font-family:var(--mono);font-size:12.5px;background:var(--sunk);\n color:var(--accent-ink);padding:1px 5px;border-radius:5px;overflow-wrap:anywhere}\n .md strong{color:var(--ink);font-weight:650}\n .wl{font-family:var(--mono);font-size:12px;color:var(--accent);font-weight:600;\n background:var(--accent-soft);padding:1px 6px;border-radius:5px;\n text-decoration:none;overflow-wrap:anywhere}\n .wl:hover{background:var(--accent);color:#fff}\n .wl.dead{color:var(--muted);background:var(--sunk);cursor:default}\n\n .links-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:34px;\n padding-top:22px;border-top:1px solid var(--line-soft)}\n @media(max-width:640px){.links-grid{grid-template-columns:1fr}}\n .linkcol .sec-label{margin-top:0}\n .linkbtn{display:flex;align-items:center;gap:8px;width:100%;text-align:left;\n padding:8px 10px;border-radius:7px;font-size:12px;margin-bottom:3px}\n .linkbtn:hover{background:var(--sunk)}\n .linkbtn .kind{flex-shrink:0}\n .linkbtn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n\n .placeholder{display:flex;align-items:center;justify-content:center;height:100%;\n color:var(--faint);font-size:13px}\n\n /* ---- graph ---- */\n .graph-wrap{position:relative;overflow:hidden;background:\n radial-gradient(circle at 30% 20%, #f2f5fa, var(--ground))}\n .graph-wrap canvas{display:block;width:100%;height:100%}\n .graph-legend{position:absolute;top:16px;left:16px;background:var(--surface);\n border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;\n box-shadow:var(--sh);font-size:12px;max-width:230px}\n .graph-legend .lg-t{font-family:var(--mono);font-size:10px;letter-spacing:.07em;\n text-transform:uppercase;color:var(--faint);margin-bottom:8px}\n .graph-legend .lg-row{display:flex;align-items:center;gap:8px;margin-bottom:5px}\n .graph-legend .lg-row i{width:12px;height:12px;border-radius:50%;flex-shrink:0}\n .graph-legend .hint{color:var(--muted);margin-top:8px;line-height:1.4}\n .graph-tip{position:absolute;pointer-events:none;background:var(--ink);color:#fff;\n font-size:11.5px;padding:6px 10px;border-radius:7px;max-width:280px;opacity:0;\n transform:translate(-50%,-140%);transition:opacity .1s;box-shadow:var(--sh);z-index:3}\n .graph-tip .tk{font-family:var(--mono);font-size:9px;letter-spacing:.06em;\n text-transform:uppercase;opacity:.7}\n\n @media(max-width:860px){\n .split{grid-template-columns:1fr}\n .rail{position:absolute;inset:57px 0 0 0;z-index:4;width:min(340px,86vw);\n box-shadow:var(--sh)}\n .split:not(.rail-open) .rail{display:none}\n .split.graph-mode .rail{display:none}\n .reader-inner{padding:26px 20px 100px}\n .menu-btn{display:inline-flex!important}\n }\n .menu-btn{display:none;align-items:center;gap:6px}\n</style>\n\n<div class=\"app\">\n <header class=\"top\">\n <button class=\"menu-btn segbtns\" id=\"menuBtn\" style=\"padding:6px 10px\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M2 4h12M2 8h12M2 12h12\"/></svg></button>\n <div class=\"brand\">\n <span class=\"kicker\">Coldstart&nbsp;Notebook</span>\n <span class=\"repo\" id=\"repoName\">repo</span>\n </div>\n <div class=\"spacer\"></div>\n <div class=\"stat\"><b id=\"sTotal\">0</b><span>notes</span></div>\n <div class=\"stat\"><b id=\"sLinks\">0</b><span>links</span></div>\n <div class=\"fresh-meter\">\n <div class=\"meter-bar\" id=\"meterBar\"></div>\n <div class=\"stat\"><b id=\"sFresh\">0%</b><span>fresh</span></div>\n </div>\n <div class=\"segbtns\" role=\"tablist\">\n <button id=\"tabNotes\" aria-pressed=\"true\">Notes</button>\n <button id=\"tabGraph\" aria-pressed=\"false\">Graph</button>\n </div>\n </header>\n\n <div class=\"split\" id=\"split\">\n <aside class=\"rail\" id=\"rail\">\n <div class=\"controls\">\n <div class=\"search\">\n <svg viewBox=\"0 0 16 16\"><circle cx=\"7\" cy=\"7\" r=\"5\"/><path d=\"M11 11l3 3\"/></svg>\n <input id=\"q\" placeholder=\"Search title, alias, path, symbol\u2026\" autocomplete=\"off\">\n </div>\n <div class=\"lenses\">\n <button class=\"lens files\" data-lens=\"files\" aria-pressed=\"true\">\n <svg class=\"ic\" viewBox=\"0 0 16 16\"><path d=\"M2 4.5h5l1.5 1.5H14v6.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z\"/></svg>\n Files <span class=\"ct\" id=\"ctFiles\">0</span>\n </button>\n <button class=\"lens flows\" data-lens=\"flows\" aria-pressed=\"false\">\n <svg class=\"ic\" viewBox=\"0 0 16 16\"><circle cx=\"3.5\" cy=\"3.5\" r=\"1.6\"/><circle cx=\"12.5\" cy=\"8\" r=\"1.6\"/><circle cx=\"3.5\" cy=\"12.5\" r=\"1.6\"/><path d=\"M5 3.9h4a2 2 0 0 1 2 2v.5M5 12.1h4a2 2 0 0 0 2-2v-.5\"/></svg>\n Flows <span class=\"ct\" id=\"ctFlows\">0</span>\n </button>\n </div>\n <div class=\"statusrow\">\n <button class=\"fchip all-fresh\" id=\"staleChip\" aria-pressed=\"false\"><span class=\"d\"></span><span id=\"staleLbl\">All fresh</span></button>\n <div class=\"treetools\" id=\"treeTools\">\n <button data-tt=\"expand\">Expand</button>\n <button data-tt=\"collapse\">Collapse</button>\n </div>\n </div>\n </div>\n <div class=\"list\" id=\"list\"></div>\n </aside>\n\n <main class=\"reader\" id=\"reader\">\n <div class=\"placeholder\">Select a note to read.</div>\n </main>\n\n <section class=\"graph-wrap\" id=\"graphWrap\" style=\"display:none\">\n <canvas id=\"graphCanvas\"></canvas>\n <div class=\"graph-legend\">\n <div class=\"lg-t\">Note graph</div>\n <div class=\"lg-row\"><i style=\"background:var(--file)\"></i>file note</div>\n <div class=\"lg-row\"><i style=\"background:var(--flow)\"></i>flow note</div>\n <div class=\"hint\" id=\"graphHint\">Only linked notes shown. Click a node to open it.</div>\n </div>\n <div class=\"graph-tip\" id=\"graphTip\"></div>\n </section>\n </div>\n</div>\n\n<script>const DB = __DATA_JSON__;</script>\n<script>\n(function(){\n \"use strict\";\n const notes = DB.notes;\n const byId = new Map(notes.map(n=>[n.id,n]));\n const fileNotes = notes.filter(n=>n.type==='file');\n const flowNotes = notes.filter(n=>n.type==='flow');\n const $ = s=>document.querySelector(s);\n const esc = s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n // clipboard: navigator.clipboard is unreliable on file:// \u2014 fall back to execCommand\n function copyText(t){\n try{\n if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t).catch(()=>fallbackCopy(t));return;}\n }catch(e){}\n fallbackCopy(t);\n }\n function fallbackCopy(t){\n try{const ta=document.createElement('textarea');ta.value=t;ta.style.position='fixed';ta.style.opacity='0';\n document.body.appendChild(ta);ta.focus();ta.select();document.execCommand('copy');document.body.removeChild(ta);}catch(e){}\n }\n const RANK={fresh:0,unverified:1,changed:2,missing:3};\n const isStale=n=>n.freshness==='changed'||n.freshness==='missing';\n\n let state = { lens:'files', q:'', selected:null, view:'notes', staleOnly:false, open:new Set() };\n\n // ---- header stats ----\n $('#repoName').textContent = DB.summary.repo;\n $('#sTotal').textContent = DB.summary.total;\n $('#ctFiles').textContent = fileNotes.length;\n $('#ctFlows').textContent = flowNotes.length;\n // Both link kinds, deduped the same way buildGraph does, so the header\n // stat and the graph hint can never disagree.\n const edgeKeys = new Set();\n for(const n of notes) for(const l of [...n.outLinks,...(n.anchorLinks||[])])\n if(byId.has(l)&&l!==n.id) edgeKeys.add(n.id<l?n.id+'|'+l:l+'|'+n.id);\n const edgeCount = edgeKeys.size;\n $('#sLinks').textContent = edgeCount;\n const fr = DB.summary.byFreshness;\n const freshPct = Math.round(100*(fr.fresh||0)/DB.summary.total);\n $('#sFresh').textContent = freshPct+'%';\n const mb = $('#meterBar');\n const morder=[['fresh','--fresh'],['changed','--changed'],['unverified','--unver'],['missing','--missing']];\n mb.innerHTML = morder.filter(([k])=>fr[k]).map(([k,v])=>\n `<i style=\"width:${100*fr[k]/DB.summary.total}%;background:var(${v})\"></i>`).join('');\n\n // stale chip\n const staleCount = notes.filter(isStale).length;\n const chip=$('#staleChip');\n if(staleCount){\n chip.classList.remove('all-fresh');chip.classList.add('has-stale');\n $('#staleLbl').textContent=`${staleCount} need review`;\n }else{\n $('#staleLbl').textContent='All fresh';\n }\n\n // ---- filter helpers ----\n function matches(n,q){\n if(!q) return true;\n const hay=(n.title+' '+n.id+' '+n.aliases.join(' ')+' '+n.anchors.map(a=>a.path+' '+(a.symbols||[]).join(' ')).join(' ')).toLowerCase();\n return q.toLowerCase().split(/\\s+/).every(t=>hay.includes(t));\n }\n function passFilter(n){\n if(state.staleOnly && !isStale(n)) return false;\n return matches(n,state.q);\n }\n\n // ================= FILE TREE =================\n function buildTree(){\n const root={name:'',path:'',children:new Map(),notes:[],dir:true};\n for(const n of fileNotes){\n const path=n.anchors[0]?n.anchors[0].path:n.title;\n const segs=path.split('/');\n let cur=root;\n for(let i=0;i<segs.length-1;i++){\n const s=segs[i], p=segs.slice(0,i+1).join('/');\n if(!cur.children.has(s)) cur.children.set(s,{name:s,path:p,children:new Map(),notes:[],dir:true});\n cur=cur.children.get(s);\n }\n cur.notes.push(Object.assign({leaf:segs[segs.length-1]},n));\n }\n // compact single-child dir chains (VSCode style), never the root\n const compact=(node,isRoot)=>{\n for(const c of node.children.values()) compact(c,false);\n if(!isRoot){\n while(node.children.size===1 && node.notes.length===0){\n const only=[...node.children.values()][0];\n node.name = node.name+'/'+only.name;\n node.path = only.path; node.notes = only.notes; node.children = only.children;\n }\n }\n };\n compact(root,true);\n // rollups\n const roll=(node)=>{\n let worst='fresh',cnt=node.notes.length;\n for(const n of node.notes) if(RANK[n.freshness]>RANK[worst]) worst=n.freshness;\n for(const c of node.children.values()){roll(c); if(RANK[c.roll]>RANK[worst]) worst=c.roll; cnt+=c.count;}\n node.roll=worst; node.count=cnt;\n };\n roll(root);\n return root;\n }\n const TREE=buildTree();\n\n function nodeHasMatch(node){\n if(node.notes.some(passFilter)) return true;\n for(const c of node.children.values()) if(nodeHasMatch(c)) return true;\n return false;\n }\n function ancestorsOf(id){\n // dir paths that contain the selected file note\n const n=byId.get(id); if(!n||n.type!=='file') return new Set();\n const path=n.anchors[0]?n.anchors[0].path:n.title;\n const set=new Set(); const segs=path.split('/');\n for(let i=1;i<segs.length;i++) set.add(segs.slice(0,i).join('/'));\n return set;\n }\n const FOLDER_SVG='<svg class=\"ficon\" viewBox=\"0 0 16 16\"><path d=\"M2 4.5h5l1.5 1.5H14v6.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z\"/></svg>';\n const FILE_SVG='<svg class=\"ficon\" viewBox=\"0 0 16 16\"><path d=\"M4 1.5h5L13 5v9a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V2a.5.5 0 0 1 1-.5z\"/><path d=\"M9 1.5V5h4\"/></svg>';\n\n function renderTreeNode(node,depth,active,out){\n const dirs=[...node.children.values()].filter(nodeHasMatch)\n .sort((a,b)=>a.name.localeCompare(b.name));\n const leaves=node.notes.filter(passFilter).sort((a,b)=>a.leaf.localeCompare(b.leaf));\n const pad=8+depth*15;\n for(const d of dirs){\n const open = active || state.open.has(d.path);\n const dimIdx=d.name.lastIndexOf('/');\n const nm = dimIdx>-1 ? `<span class=\"seg-dim\">${esc(d.name.slice(0,dimIdx+1))}</span>${esc(d.name.slice(dimIdx+1))}` : esc(d.name);\n out.push(`<button class=\"row dir ${open?'open':''}\" data-dir=\"${esc(d.path)}\" style=\"padding-left:${pad}px\">\n <svg class=\"chev\" viewBox=\"0 0 16 16\"><path d=\"M6 3l5 5-5 5\"/></svg>${FOLDER_SVG}\n <span class=\"lbl\">${nm}</span>\n <span class=\"badge\">${d.count}</span>\n ${d.roll!=='fresh'?`<span class=\"dot ${d.roll}\"></span>`:''}\n </button>`);\n if(open) renderTreeNode(d,depth+1,active,out);\n }\n for(const n of leaves){\n out.push(`<button class=\"row leaf\" data-id=\"${esc(n.id)}\" aria-current=\"${n.id===state.selected}\" style=\"padding-left:${pad+21}px\">\n ${FILE_SVG}\n <span class=\"lbl\">${esc(n.leaf)}</span>\n ${n.character?`<span class=\"char\" title=\"${esc(n.character)} note\">${n.character[0].toUpperCase()}</span>`:''}\n <span class=\"dot ${n.freshness}\"></span>\n </button>`);\n }\n }\n\n function renderFiles(){\n const active=!!(state.q||state.staleOnly);\n const out=[];\n renderTreeNode(TREE,0,active,out);\n $('#list').innerHTML = out.length?out.join(''):'<div class=\"empty-note\">No file notes match.</div>';\n }\n\n // ================= FLOWS =================\n function renderFlows(){\n const list=flowNotes.filter(passFilter)\n .sort((a,b)=>b.anchors.length-a.anchors.length||a.title.localeCompare(b.title));\n if(!list.length){$('#list').innerHTML='<div class=\"empty-note\">No flows match.</div>';return;}\n $('#list').innerHTML=list.map(n=>{\n const files=n.anchors.map(a=>a.path.split('/').pop());\n const shown=files.slice(0,4), extra=files.length-shown.length;\n return `<button class=\"flowcard\" data-id=\"${esc(n.id)}\" aria-current=\"${n.id===state.selected}\">\n <div class=\"fc-top\">\n <span class=\"dot ${n.freshness}\"></span>\n <span class=\"fc-title\">${esc(n.title)}</span>\n </div>\n <div class=\"fc-span\">\n ${shown.map(f=>`<span class=\"fc-file\">${esc(f)}</span>`).join('')}\n ${extra>0?`<span class=\"fc-more\">+${extra} more</span>`:''}\n </div>\n </button>`;\n }).join('');\n }\n\n function renderList(){\n $('#treeTools').style.visibility = state.lens==='files' ? 'visible':'hidden';\n if(state.lens==='files') renderFiles(); else renderFlows();\n }\n\n // ---- markdown mini-renderer ----\n function mdInline(t){\n t=esc(t);\n t=t.replace(/`([^`]+)`/g,(_,c)=>`<code>${c}</code>`);\n t=t.replace(/\\[\\[([^\\]]+)\\]\\]/g,(_,id)=>{\n const ok=byId.has(id);\n return `<a class=\"wl${ok?'':' dead'}\" ${ok?`data-goto=\"${esc(id)}\"`:''} title=\"${esc(id)}\">${ok?esc(byId.get(id).title):esc(id)}</a>`;\n });\n t=t.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>');\n return t;\n }\n function renderMd(src){\n const lines=src.split('\\n');\n let html='',i=0;\n const flush=(buf,tag)=>buf.length?`<${tag}>${buf.join('')}</${tag}>`:'';\n while(i<lines.length){\n let ln=lines[i];\n if(/^#\\s+/.test(ln)){i++;continue;}\n if(/^##\\s+/.test(ln)){html+=`<h2>${mdInline(ln.replace(/^##\\s+/,''))}</h2>`;i++;continue;}\n if(/^###\\s+/.test(ln)){html+=`<h3>${mdInline(ln.replace(/^###\\s+/,''))}</h3>`;i++;continue;}\n if(/^\\s*[-*]\\s+/.test(ln)){\n const buf=[];\n while(i<lines.length&&/^\\s*[-*]\\s+/.test(lines[i])){buf.push(`<li>${mdInline(lines[i].replace(/^\\s*[-*]\\s+/,''))}</li>`);i++;}\n html+=flush(buf,'ul');continue;\n }\n if(/^\\s*\\d+\\.\\s+/.test(ln)){\n const buf=[];\n while(i<lines.length&&/^\\s*\\d+\\.\\s+/.test(lines[i])){buf.push(`<li>${mdInline(lines[i].replace(/^\\s*\\d+\\.\\s+/,''))}</li>`);i++;}\n html+=flush(buf,'ol');continue;\n }\n if(!ln.trim()){i++;continue;}\n const buf=[];\n while(i<lines.length&&lines[i].trim()&&!/^(#{1,3}\\s|\\s*[-*]\\s|\\s*\\d+\\.\\s)/.test(lines[i])){buf.push(lines[i]);i++;}\n html+=`<p>${mdInline(buf.join(' '))}</p>`;\n }\n return html;\n }\n function anchorHtml(a){\n const parts=a.path.split('/'); const file=parts.pop();\n const dir=parts.length?parts.join('/')+'/':'';\n return `<div class=\"anchor\">\n <div class=\"anchor-top\">\n <span class=\"apath\"><span class=\"p\">${esc(dir)}</span>${esc(file)}</span>\n <span class=\"astate ${a.state}\">${a.state}</span>\n <button class=\"copy\" data-copy=\"${esc(a.path)}\">copy</button>\n </div>\n ${(a.symbols&&a.symbols.length)?`<div class=\"syms\">${a.symbols.map(s=>`<button class=\"sym\" data-copysym=\"${esc(s)}\" title=\"click to copy symbol\">${esc(s)}</button>`).join('')}</div>`:''}\n </div>`;\n }\n function linkBtn(id){\n const n=byId.get(id);\n return `<button class=\"linkbtn\" data-goto=\"${esc(id)}\"><span class=\"kind ${n.type}\">${n.type.toUpperCase()}</span><span>${esc(n.title)}</span></button>`;\n }\n function renderNote(id){\n const n=byId.get(id); if(!n) return;\n state.selected=id;\n // reveal once: persist the file's ancestor folders so they stay open\n // (and prior folders stay open too) \u2014 you can still collapse any by hand\n if(n.type==='file') for(const p of ancestorsOf(id)) state.open.add(p);\n const outs=n.outLinks.filter(l=>byId.has(l));\n const r=$('#reader');\n r.innerHTML=`<div class=\"reader-inner\">\n <div class=\"eyebrow\">\n <span class=\"kind ${n.type}\">${n.type.toUpperCase()}${n.character?' \u00B7 '+n.character:''}</span>\n <span class=\"sep\">/</span><span class=\"id\">${esc(n.id)}</span>\n </div>\n <h1 class=\"note-title\">${esc(n.title)}</h1>\n <div class=\"metabar\">\n <span class=\"pill ${n.freshness}\"><span class=\"d\"></span>${n.freshness}</span>\n <span class=\"mi\"><b>${n.anchors.length}</b> anchor${n.anchors.length===1?'':'s'}</span>\n <span class=\"mi\"><b>${n.edits}</b> edit${n.edits===1?'':'s'}</span>\n <span class=\"mi\">updated <b>${esc((n.updated||'').slice(0,10))}</b></span>\n </div>\n <div class=\"sec-label\">Anchors \u2014 verified code addresses</div>\n ${n.anchors.length?n.anchors.map(anchorHtml).join(''):'<div class=\"empty-note\">No anchors.</div>'}\n ${n.aliases.length?`<details class=\"aliases\" style=\"margin-top:14px\">\n <summary><b>${n.aliases.length}</b> search aliases <span style=\"color:var(--faint)\">\u2014 queries that should surface this note</span><span class=\"chv\">\u203A</span></summary>\n <div class=\"alias-cloud\">${n.aliases.map(a=>`<span>${esc(a)}</span>`).join('')}</div>\n </details>`:''}\n <div class=\"sec-label\">Note</div>\n <div class=\"md\">${renderMd(n.body)}</div>\n <div class=\"links-grid\">\n <div class=\"linkcol\"><div class=\"sec-label\">Links to (${outs.length})</div>\n ${outs.length?outs.map(linkBtn).join(''):'<div class=\"empty-note\">No outgoing links.</div>'}</div>\n <div class=\"linkcol\"><div class=\"sec-label\">Referenced by (${n.backlinks.length})</div>\n ${n.backlinks.length?n.backlinks.map(linkBtn).join(''):'<div class=\"empty-note\">Not referenced.</div>'}</div>\n </div>\n </div>`;\n r.scrollTop=0;\n // switch lens to match the note type so it's visible in the rail\n if(n.type==='flow'&&state.lens!=='flows') setLens('flows');\n else if(n.type==='file'&&state.lens!=='files') setLens('files');\n else renderList();\n }\n\n // ---- events ----\n $('#q').addEventListener('input',e=>{state.q=e.target.value;renderList();});\n $('.lenses').addEventListener('click',e=>{const b=e.target.closest('.lens');if(b)setLens(b.dataset.lens);});\n function setLens(l){\n state.lens=l;\n document.querySelectorAll('.lens').forEach(x=>x.setAttribute('aria-pressed',x.dataset.lens===l));\n renderList();\n }\n chip.addEventListener('click',()=>{state.staleOnly=!state.staleOnly;chip.setAttribute('aria-pressed',state.staleOnly);renderList();});\n $('#treeTools').addEventListener('click',e=>{\n const b=e.target.closest('button');if(!b)return;\n if(b.dataset.tt==='collapse'){state.open.clear();}\n else{ // expand all dirs\n const walk=nd=>{for(const c of nd.children.values()){state.open.add(c.path);walk(c);}};walk(TREE);\n }\n renderFiles();\n });\n document.addEventListener('click',e=>{\n const dir=e.target.closest('[data-dir]');\n if(dir){const p=dir.dataset.dir;state.open.has(p)?state.open.delete(p):state.open.add(p);renderFiles();return;}\n const row=e.target.closest('[data-id]');\n if(row){switchView('notes');renderNote(row.dataset.id);closeRail();return;}\n const goto=e.target.closest('[data-goto]');\n if(goto){switchView('notes');renderNote(goto.dataset.goto);return;}\n const cs=e.target.closest('[data-copysym]');\n if(cs){copyText(cs.dataset.copysym);cs.classList.add('copied');setTimeout(()=>cs.classList.remove('copied'),750);return;}\n const cp=e.target.closest('[data-copy]');\n if(cp){copyText(cp.dataset.copy);cp.textContent='copied';setTimeout(()=>cp.textContent='copy',900);return;}\n });\n\n // ---- view switching ----\n function switchView(v){\n state.view=v;\n $('#tabNotes').setAttribute('aria-pressed',v==='notes');\n $('#tabGraph').setAttribute('aria-pressed',v==='graph');\n $('#split').classList.toggle('graph-mode',v==='graph');\n $('#reader').style.display=v==='graph'?'none':'';\n $('#graphWrap').style.display=v==='graph'?'':'none';\n if(v==='graph') drawGraph();\n }\n $('#tabNotes').onclick=()=>switchView('notes');\n $('#tabGraph').onclick=()=>switchView('graph');\n function closeRail(){$('#split').classList.remove('rail-open');}\n $('#menuBtn').onclick=()=>$('#split').classList.toggle('rail-open');\n\n // ================= GRAPH =================\n let G=null;\n function buildGraph(){\n const linked=new Set(); const edges=[];\n const seen=new Set();\n const addEdge=(a,b,kind)=>{const key=(a<b?a+'|'+b:b+'|'+a);if(seen.has(key))return;seen.add(key);\n linked.add(a);linked.add(b);edges.push([a,b,kind]);};\n // kind 0 = a hand-typed [[wikilink]]; kind 1 = the two notes anchor the\n // same file. Wikilinks are added first so an explicit link is never\n // downgraded to the derived kind by the dedup above.\n for(const n of notes) for(const l of n.outLinks) if(byId.has(l)&&l!==n.id) addEdge(n.id,l,0);\n for(const n of notes) for(const l of (n.anchorLinks||[])) if(byId.has(l)&&l!==n.id) addEdge(n.id,l,1);\n const ids=[...linked]; const idx=new Map(ids.map((id,i)=>[id,i])); const N=ids.length;\n const nodes=ids.map((id,i)=>{const a=2*Math.PI*i/N;\n return {id,x:Math.cos(a)*260,y:Math.sin(a)*260,vx:0,vy:0,deg:0,type:byId.get(id).type,title:byId.get(id).title};});\n const E=edges.map(([a,b,k])=>[idx.get(a),idx.get(b),k]);\n for(const [a,b] of E){nodes[a].deg++;nodes[b].deg++;}\n for(let it=0;it<320;it++){\n const k=it<220?1:0.4;\n for(let i=0;i<N;i++){let fx=0,fy=0;\n for(let j=0;j<N;j++){if(i===j)continue;\n let dx=nodes[i].x-nodes[j].x,dy=nodes[i].y-nodes[j].y,d2=dx*dx+dy*dy+0.01,d=Math.sqrt(d2);\n const rep=1600/d2; fx+=dx/d*rep; fy+=dy/d*rep;}\n fx-=nodes[i].x*0.012; fy-=nodes[i].y*0.012;\n nodes[i].vx=(nodes[i].vx+fx)*0.82; nodes[i].vy=(nodes[i].vy+fy)*0.82;}\n for(const [a,b] of E){let dx=nodes[b].x-nodes[a].x,dy=nodes[b].y-nodes[a].y,d=Math.sqrt(dx*dx+dy*dy)+0.01;\n const spring=(d-90)*0.02,ux=dx/d*spring,uy=dy/d*spring;\n nodes[a].vx+=ux;nodes[a].vy+=uy;nodes[b].vx-=ux;nodes[b].vy-=uy;}\n for(const nd of nodes){nd.x+=nd.vx*k;nd.y+=nd.vy*k;}\n }\n G={nodes,E,idx};\n const wl=edges.filter(e=>e[2]===0).length;\n $('#graphHint').textContent=`${N} linked notes \u00B7 ${edges.length} links `+\n `(${wl} written as [[links]], ${edges.length-wl} dashed: the notes share a file). Click a node to open it.`;\n }\n let cam={x:0,y:0,s:1},hover=-1;\n function drawGraph(){\n if(!G) buildGraph();\n const cv=$('#graphCanvas'),wrap=$('#graphWrap'),dpr=window.devicePixelRatio||1;\n const W=wrap.clientWidth,H=wrap.clientHeight;\n cv.width=W*dpr;cv.height=H*dpr;\n const ctx=cv.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);\n const xs=G.nodes.map(n=>n.x),ys=G.nodes.map(n=>n.y);\n const minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys),pad=70;\n const s=Math.min((W-pad*2)/(maxX-minX||1),(H-pad*2)/(maxY-minY||1));\n cam.s=s;cam.x=W/2-(minX+maxX)/2*s;cam.y=H/2-(minY+maxY)/2*s;\n const T=(x,y)=>[x*cam.s+cam.x,y*cam.s+cam.y];\n ctx.clearRect(0,0,W,H);\n for(const [a,b,k] of G.E){const A=G.nodes[a],B=G.nodes[b],[ax,ay]=T(A.x,A.y),[bx,by]=T(B.x,B.y);\n const on=hover>-1&&(a===hover||b===hover);\n ctx.strokeStyle=on?'rgba(31,79,216,.55)':'rgba(105,116,138,.20)';ctx.lineWidth=on?1.8:1;\n ctx.setLineDash(k?[4,4]:[]);\n ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();}\n ctx.setLineDash([]);\n const cs=getComputedStyle(document.documentElement);\n const cFile=cs.getPropertyValue('--file').trim(),cFlow=cs.getPropertyValue('--flow').trim();\n G.nodes.forEach((n,i)=>{const[x,y]=T(n.x,n.y),r=Math.max(4,Math.min(13,4+n.deg*1.6)),isH=i===hover;\n ctx.beginPath();ctx.arc(x,y,r+(isH?2:0),0,7);ctx.fillStyle=n.type==='flow'?cFlow:cFile;\n ctx.globalAlpha=hover>-1&&!isH&&!G.E.some(([a,b])=>(a===hover&&b===i)||(b===hover&&a===i))?0.35:1;\n ctx.fill();if(isH){ctx.lineWidth=2;ctx.strokeStyle='#fff';ctx.stroke();}ctx.globalAlpha=1;});\n G._T=T;\n }\n const cv=$('#graphCanvas');\n function pick(mx,my){if(!G||!G._T)return -1;let best=-1,bd=18*18;\n G.nodes.forEach((n,i)=>{const[x,y]=G._T(n.x,n.y),d=(x-mx)**2+(y-my)**2,r=Math.max(4,Math.min(13,4+n.deg*1.6))+4;\n if(d<r*r&&d<bd){bd=d;best=i;}});return best;}\n cv.addEventListener('mousemove',e=>{const rect=cv.getBoundingClientRect(),mx=e.clientX-rect.left,my=e.clientY-rect.top,p=pick(mx,my),tip=$('#graphTip');\n if(p!==hover){hover=p;drawGraph();}\n if(p>-1){const n=G.nodes[p];tip.style.opacity=1;tip.style.left=mx+'px';tip.style.top=my+'px';\n tip.innerHTML=`<div class=\"tk\">${n.type} \u00B7 ${n.deg} link${n.deg===1?'':'s'}</div>${esc(n.title)}`;cv.style.cursor='pointer';}\n else{tip.style.opacity=0;cv.style.cursor='default';}});\n cv.addEventListener('mouseleave',()=>{hover=-1;$('#graphTip').style.opacity=0;drawGraph();});\n cv.addEventListener('click',e=>{const rect=cv.getBoundingClientRect(),p=pick(e.clientX-rect.left,e.clientY-rect.top);\n if(p>-1){switchView('notes');renderNote(G.nodes[p].id);}});\n window.addEventListener('resize',()=>{if(state.view==='graph')drawGraph();});\n\n // ---- boot ----\n // pre-open the top two directory levels so the Files tree opens usefully\n (function preopen(node,depth){for(const c of node.children.values()){if(depth<2)state.open.add(c.path);preopen(c,depth+1);}})(TREE,0);\n // land on the richest FILE note; the Flows tab (badged) is one click away\n const seed=[...fileNotes].sort((a,b)=>(b.outLinks.length+b.backlinks.length)-(a.outLinks.length+a.backlinks.length))[0]\n || notes[0];\n renderList();\n if(seed) renderNote(seed.id);\n})();\n</script>\n";
9
9
  //# sourceMappingURL=view-template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"view-template.d.ts","sourceRoot":"","sources":["../../src/kb/view-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,i7sCAAo4sC,CAAC"}
1
+ {"version":3,"file":"view-template.d.ts","sourceRoot":"","sources":["../../src/kb/view-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,01uCAAy1uC,CAAC"}