@giamme/forge 0.0.0-stage → 0.9.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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,235 @@
1
+ /* forge-ui diff viewer: a task's diff (current, previous, committed, or exactly what one reviewer read), a final
2
+ review's combined diff or a Fractal node's candidate, with each review finding shown under the line it points
3
+ at. Defines window.ForgeDiff.render(host, ctx). All API values enter the DOM as text.
4
+ ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node, finding}, fetchJSON(path),
5
+ rerender(), href(spec) → hash, closeHref }. */
6
+ window.ForgeDiff = (() => {
7
+ 'use strict';
8
+ const list = v => Array.isArray(v) ? v : [];
9
+ const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
10
+ const BUDGET = 4000; // lines drawn before the rest of the files wait for a click
11
+ const cache = new Map(); // url → {data, error, pending, fresh}
12
+ const opened = new Set(); // files shown past the budget, by url + path
13
+ function el(tag, text, cls) {
14
+ const n = document.createElement(tag);
15
+ if (text !== undefined && text !== null) n.textContent = String(text);
16
+ if (cls) n.className = cls;
17
+ return n;
18
+ }
19
+ const chip = (text, st) => el('span', text, 'chip st-' + st);
20
+ const sevChip = (text, sev) => el('span', text, 'chip sev-chip sev-' + (/^P[0-3]$/.test(sev) ? sev : 'none'));
21
+ const enc = encodeURIComponent;
22
+ const TASK_FILES = new Set(['current', 'prev', 'committed', 'existing']);
23
+ function urlFor(run, spec) {
24
+ const base = `/api/runs/${enc(run.key)}`;
25
+ if (spec.kind === 'final') return `${base}/final-reviews/${enc(spec.name)}/diff`;
26
+ if (spec.kind === 'fractal') return `${base}/tasks/${enc(spec.task)}/fractal/nodes/${enc(spec.node)}/diff`;
27
+ const src = spec.source || 'current';
28
+ if (TASK_FILES.has(src)) return `${base}/tasks/${enc(spec.task)}/diff${src === 'current' ? '' : '/' + src}`;
29
+ return `${base}/executions/${enc(src)}/diff`;
30
+ }
31
+ function load(ctx, url) {
32
+ const c = cache.get(url), fresh = ctx.run.updated;
33
+ if (c && (c.fresh === fresh || c.pending)) return c;
34
+ const entry = { pending: true, fresh, data: c && c.data || null, error: null };
35
+ cache.set(url, entry);
36
+ ctx.fetchJSON(url).then(d => { entry.data = d; entry.error = null; entry.at = performance.now(); }, e => { entry.error = e; }).finally(() => { entry.pending = false; ctx.rerender(); });
37
+ return entry;
38
+ }
39
+ const pathOf = f => f.new || f.old || '';
40
+ const worst = fs => fs.reduce((w, f) => (w === null || RANK[f.severity] < RANK[w] ? f.severity : w), null);
41
+ // what a finding says after its "- [P1][CONFIRMED] path:line —" head
42
+ function said(f) {
43
+ const s = String(f.summary || f.text || '').split('\n')[0].replace(/^\s*-\s*\[P\d\]\s*(\[[A-Z]+\]\s*)?/, '');
44
+ const i = s.indexOf(' — '); return i >= 0 && i < 200 ? s.slice(i + 3) : s;
45
+ }
46
+
47
+ /* ---------- one finding, as a row under its line or in a list ---------- */
48
+ function findingBox(f, where) {
49
+ const box = el('div', undefined, 'diff-finding sev-' + (f.severity || 'P3'));
50
+ if (f.id) box.dataset.finding = f.id; // a mark on the stage opens the diff at this finding
51
+ const head = el('div', undefined, 'diff-finding-head');
52
+ head.append(sevChip(f.severity || '?', f.severity));
53
+ if (f.status) head.append(el('span', f.status.toLowerCase(), 'tag'));
54
+ const r = f.review || {};
55
+ head.append(el('span', `${r.kind || 'review'}${r.attempt != null ? ' · attempt ' + r.attempt : ''}${r.spec ? ' · ' + r.spec : ''}`, 'muted'));
56
+ if (where) head.append(el('span', where, 'mono muted'));
57
+ box.append(head, el('p', said(f)));
58
+ const more = String(f.text || '').trim();
59
+ if (more && more !== String(f.summary || '').trim()) { const d = el('details'); d.append(el('summary', 'Details'), el('pre', more)); box.append(d); }
60
+ return box;
61
+ }
62
+
63
+ /* ---------- a file: its header, hunks with line numbers, and pinned findings ---------- */
64
+ function fileBlock(ctx, D, f, fi, pins, state) {
65
+ const sec = el('section', undefined, 'diff-file'); sec.id = 'diff-file-' + fi; sec.dataset.path = pathOf(f);
66
+ const head = el('div', undefined, 'diff-file-head');
67
+ const st = { modified: 'running', added: 'merged', deleted: 'fail', renamed: 'nochanges', copied: 'nochanges' }[f.status] || 'unknown';
68
+ head.append(chip(f.status, st));
69
+ const name = el('a', f.status === 'renamed' || f.status === 'copied' ? `${f.old} → ${f.new}` : pathOf(f), 'mono diff-path');
70
+ name.href = ctx.href({ ...ctx.spec, finding: null, file: pathOf(f) });
71
+ head.append(name, el('span', `+${f.additions} −${f.deletions}`, 'diff-stat'));
72
+ if (f.binary) head.append(el('span', 'binary', 'tag'));
73
+ if (f.truncated) head.append(el('span', 'cut short', 'tag'));
74
+ sec.append(head);
75
+ const fileLevel = pins.filter(p => p.pin.hunk === null || p.pin.hunk === undefined);
76
+ if (fileLevel.length) {
77
+ const fl = el('div', undefined, 'diff-file-findings');
78
+ for (const p of fileLevel) fl.append(findingBox(p.f, p.f.off_diff === 'line outside the changed hunks' ? `line ${p.pin.line}, outside the changed lines` : 'this file'));
79
+ sec.append(fl);
80
+ }
81
+ if (f.binary) { sec.append(el('p', 'A binary file: no lines to show.', 'muted diff-note')); return sec; }
82
+ const lines = f.hunks.reduce((a, h) => a + h.lines.length, 0), key = D.__url + '\n' + pathOf(f);
83
+ if (state.drawn + lines > BUDGET && !opened.has(key) && state.drawn > 0) {
84
+ const b = el('button', `Show this file (${lines} lines)`, 'diff-more'); b.type = 'button';
85
+ b.addEventListener('click', () => { opened.add(key); ctx.rerender(); });
86
+ sec.append(b); return sec;
87
+ }
88
+ state.drawn += lines;
89
+ const at = new Map(); // "hunk:index" → findings
90
+ for (const p of pins) if (p.pin.hunk != null) { const k = p.pin.hunk + ':' + (p.pin.index ?? 'end'); if (!at.has(k)) at.set(k, []); at.get(k).push(p); }
91
+ const table = el('table', undefined, 'diff-table'); table.setAttribute('role', 'presentation');
92
+ const body = el('tbody');
93
+ f.hunks.forEach((h, hi) => {
94
+ const hr = el('tr', undefined, 'diff-hunk');
95
+ const hc = el('td', `@@ -${h.old_start},${h.old_lines} +${h.new_start},${h.new_lines} @@${h.section ? ' ' + h.section : ''}`); hc.colSpan = 4; hr.append(hc); body.append(hr);
96
+ let o = h.old_start, n = h.new_start;
97
+ h.lines.forEach((ln, li) => {
98
+ const m = ln[0], tr = el('tr', undefined, 'diff-line ' + (m === '+' ? 'add' : m === '-' ? 'del' : m === '\\' ? 'meta' : 'ctx'));
99
+ const on = m === '+' || m === '\\' ? '' : o++, nn = m === '-' || m === '\\' ? '' : n++;
100
+ tr.append(el('td', on, 'ln'), el('td', nn, 'ln'), el('td', m === '\\' ? '' : m, 'mk'), el('td', m === '\\' ? ln.slice(1).trim() : ln.slice(1), 'code'));
101
+ body.append(tr);
102
+ const here = at.get(hi + ':' + li);
103
+ if (here) for (const p of here) body.append(pinRow(p, p.pin.line));
104
+ });
105
+ const tail = at.get(hi + ':end');
106
+ if (tail) for (const p of tail) body.append(pinRow(p, p.pin.line));
107
+ });
108
+ table.append(body);
109
+ const scroll = el('div', undefined, 'diff-scroll'); scroll.append(table); sec.append(scroll);
110
+ return sec;
111
+ }
112
+ function pinRow(p, line) {
113
+ const tr = el('tr', undefined, 'diff-pin'), td = el('td'); td.colSpan = 4;
114
+ const box = findingBox(p.f, `line ${line}`); box.id = 'diff-finding-' + p.i; box.tabIndex = -1; td.append(box); tr.append(td);
115
+ return tr;
116
+ }
117
+
118
+ /* ---------- the viewer ---------- */
119
+ function render(host, ctx) {
120
+ const run = ctx.run, spec = ctx.spec, url = urlFor(run, spec), entry = load(ctx, url);
121
+ // a poll that brought nothing new leaves thousands of rows alone
122
+ const sig = [url, entry.at || 0, !!entry.error, spec.file, spec.finding, opened.size].join('|');
123
+ if (host.diffSig === sig) return;
124
+ host.diffSig = sig;
125
+ // the finding the keyboard is on keeps it through the rebuild (a poll, a fold)
126
+ const had = document.activeElement && host.contains(document.activeElement) && document.activeElement.closest('[data-finding]');
127
+ const keep = had ? had.dataset.finding : null;
128
+ host.replaceChildren();
129
+ const D = entry.data;
130
+ const top = el('div', undefined, 'diff-top');
131
+ const what = spec.kind === 'final' ? 'Final review' : spec.kind === 'fractal' ? `${spec.task} · Fractal node ${spec.node}` : spec.task === 'solo' && run.kind === 'solo' ? 'Solo run' : `Task ${spec.task}`;
132
+ const title = el('h2'); title.append(el('span', 'Diff · ', 'muted'), document.createTextNode(what)); top.append(title);
133
+ const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref;
134
+ if (window.ForgeIcons) close.append(window.ForgeIcons.icon('close'));
135
+ close.append(document.createTextNode('Close')); top.append(close);
136
+ host.append(top);
137
+ if (!D) {
138
+ host.append(el('p', entry.error ? (entry.error.snapshot ? 'This diff is not in this export.' : `Could not load this diff (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Loading the diff…', entry.error ? 'warn' : 'muted'));
139
+ return;
140
+ }
141
+ D.__url = url;
142
+ // which diff: the files of the task and the diffs each reviewer read
143
+ const sources = list(D.sources);
144
+ if (sources.length > 1) {
145
+ const bar = el('div', undefined, 'diff-sources'); bar.setAttribute('role', 'group'); bar.setAttribute('aria-label', 'Which diff');
146
+ const cur = spec.kind === 'final' ? spec.name : spec.kind === 'fractal' ? 'candidate' : spec.source || 'current';
147
+ for (const s of sources) {
148
+ const a = el('a', s.label || s.id, 'diff-source'); a.setAttribute('aria-current', String(s.id === cur));
149
+ a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null, finding: null });
150
+ if (s.verdict) a.append(document.createTextNode(' '), chip(s.verdict, String(s.verdict).toLowerCase()));
151
+ a.title = `${s.bytes != null ? s.bytes + ' bytes' : ''}${s.kind ? ' · ' + s.kind : ''}`;
152
+ bar.append(a);
153
+ }
154
+ host.append(bar);
155
+ }
156
+ const src = D.source || {};
157
+ const meta = el('p', undefined, 'diff-meta muted');
158
+ const files = list(D.files), adds = files.reduce((a, f) => a + (f.additions || 0), 0), dels = files.reduce((a, f) => a + (f.deletions || 0), 0);
159
+ const finds = list(D.findings).map((f, i) => ({ f, i, pin: f.pin || null }));
160
+ const pinned = finds.filter(p => p.pin && p.pin.file != null && p.pin.hunk != null && !p.f.off_diff);
161
+ meta.textContent = [src.file, src.attempt != null ? `attempt ${src.attempt}` : null, D.available ? `${files.length} file${files.length === 1 ? '' : 's'} · +${adds} −${dels}` : null,
162
+ finds.length ? `${finds.length} finding${finds.length === 1 ? '' : 's'}, ${pinned.length} on a changed line` : null].filter(Boolean).join(' · ');
163
+ host.append(meta);
164
+ if (D.truncated) host.append(el('p', 'This diff was cut short (over 8 MiB, 300 files or 20,000 lines): what is shown is its beginning.', 'warn'));
165
+ if (!D.available) { host.append(el('p', `No diff to show: ${D.reason || 'not available'}.`, 'muted diff-note')); }
166
+ const byFile = new Map();
167
+ for (const p of finds) if (p.pin && p.pin.file != null && (p.f.off_diff === null || p.f.off_diff === 'line outside the changed hunks')) { if (!byFile.has(p.pin.file)) byFile.set(p.pin.file, []); byFile.get(p.pin.file).push(p); }
168
+ const loose = finds.filter(p => !(p.pin && p.pin.file != null && (p.f.off_diff === null || p.f.off_diff === 'line outside the changed hunks')));
169
+
170
+ const body = el('div', undefined, 'diff-body');
171
+ // the file list, with what changed and how many findings point into each
172
+ const nav = el('nav', undefined, 'diff-files'); nav.setAttribute('aria-label', 'Files in this diff');
173
+ const ul = el('ul');
174
+ files.forEach((f, fi) => {
175
+ const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, finding: null, file: pathOf(f) }); a.dataset.fi = String(fi);
176
+ const p = pathOf(f), cut = p.lastIndexOf('/');
177
+ a.append(el('span', cut >= 0 ? p.slice(0, cut + 1) : '', 'diff-dir'), el('span', cut >= 0 ? p.slice(cut + 1) : p, 'diff-base'));
178
+ const stat = el('span', undefined, 'diff-stat'); stat.append(el('span', `+${f.additions}`, 'diff-add'), document.createTextNode(' '), el('span', `−${f.deletions}`, 'diff-del'));
179
+ a.append(stat);
180
+ const ps = byFile.get(fi) || [];
181
+ if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(sevChip(`${ps.length}`, w)); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
182
+ if (spec.file && spec.file === p) a.setAttribute('aria-current', 'true');
183
+ li.append(a); ul.append(li);
184
+ });
185
+ nav.append(ul);
186
+ if (loose.length) { const a = el('a', `Not on a changed line (${loose.length})`, 'diff-file-link diff-loose-link'); a.href = '#diff-loose'; a.addEventListener('click', e => { e.preventDefault(); host.querySelector('#diff-loose')?.scrollIntoView({ block: 'start' }); }); nav.append(a); }
187
+ const main = el('div', undefined, 'diff-main');
188
+ const state = { drawn: 0 };
189
+ files.forEach((f, fi) => main.append(fileBlock(ctx, D, f, fi, byFile.get(fi) || [], state)));
190
+ if (loose.length) {
191
+ const ls = el('section', undefined, 'diff-loose'); ls.id = 'diff-loose';
192
+ ls.append(el('h3', 'Findings not on a changed line of this diff'));
193
+ for (const p of loose) { const b = findingBox(p.f, `${p.f.location || 'no location'} · ${p.f.off_diff || 'not located'}`); b.id = 'diff-finding-' + p.i; b.tabIndex = -1; ls.append(b); }
194
+ main.append(ls);
195
+ }
196
+ if (list(D.warnings).length) { const d = el('details', undefined, 'diff-warnings'); d.append(el('summary', `Reader warnings (${D.warnings.length})`), el('pre', D.warnings.join('\n'))); main.append(d); }
197
+ if (files.length || loose.length) body.append(nav); else body.classList.add('no-files');
198
+ body.append(main); host.append(body);
199
+
200
+ // keys: n/p findings, j/k files; the hash keeps the file a link points at
201
+ const order = [...main.querySelectorAll('.diff-finding[id]')];
202
+ let fIdx = -1;
203
+ const fileEls = [...main.querySelectorAll('.diff-file')];
204
+ const visibleFile = () => { let i = 0; fileEls.forEach((s, k) => { if (s.getBoundingClientRect().top <= 90) i = k; }); return i; };
205
+ host.tabIndex = -1; host.setAttribute('aria-label', 'Diff: n and p move between findings, j and k between files');
206
+ host.onkeydown = e => {
207
+ if (e.altKey || e.ctrlKey || e.metaKey || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
208
+ if ((e.key === 'n' || e.key === 'p') && order.length) {
209
+ fIdx = (fIdx + (e.key === 'n' ? 1 : -1) + order.length) % order.length;
210
+ order[fIdx].scrollIntoView({ block: 'center' }); order[fIdx].classList.add('flash'); setTimeout(() => order[fIdx] && order[fIdx].classList.remove('flash'), 900); e.preventDefault();
211
+ } else if ((e.key === 'j' || e.key === 'k') && fileEls.length) {
212
+ const i = Math.max(0, Math.min(fileEls.length - 1, visibleFile() + (e.key === 'j' ? 1 : -1)));
213
+ fileEls[i].scrollIntoView({ block: 'start' }); e.preventDefault();
214
+ }
215
+ };
216
+ const flash = n => { n.classList.add('flash'); setTimeout(() => n.classList.remove('flash'), 1600); };
217
+ const back = keep && order.find(n => n.dataset.finding === keep);
218
+ if (back) back.focus({ preventScroll: true });
219
+ if (spec.finding) {
220
+ const target = order.find(n => n.dataset.finding === spec.finding);
221
+ if (target && host.dataset.scrolledTo !== url + '\n#' + spec.finding) {
222
+ host.dataset.scrolledTo = url + '\n#' + spec.finding; fIdx = order.indexOf(target);
223
+ requestAnimationFrame(() => {
224
+ const live = target.isConnected ? target : [...host.querySelectorAll('.diff-finding[id]')].find(n => n.dataset.finding === spec.finding);
225
+ if (!live) return;
226
+ live.scrollIntoView({ block: 'center' }); flash(live); live.focus({ preventScroll: true });
227
+ });
228
+ }
229
+ } else if (spec.file) {
230
+ const target = fileEls.find(s => s.dataset.path === spec.file);
231
+ if (target && host.dataset.scrolledTo !== url + '\n' + spec.file) { host.dataset.scrolledTo = url + '\n' + spec.file; requestAnimationFrame(() => target.scrollIntoView({ block: 'start' })); }
232
+ }
233
+ }
234
+ return { render, urlFor, cache };
235
+ })();
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1c222b"/><path d="M32 12 52 23v20L32 54 12 43V23Z" fill="#ff9d3c"/><path d="M32 12 52 23 32 34 12 23Z" fill="#ffc690"/><path d="M32 34v20L12 43V23Z" fill="#d9730d"/></svg>