diffninja 0.1.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.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/dist/calltree.d.ts +47 -0
- package/dist/calltree.js +296 -0
- package/dist/cli.d.ts +57 -0
- package/dist/cli.js +340 -0
- package/dist/diff.d.ts +7 -0
- package/dist/diff.js +114 -0
- package/dist/extract.d.ts +26 -0
- package/dist/extract.js +152 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.js +288 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/infer.d.ts +21 -0
- package/dist/infer.js +189 -0
- package/dist/languages/bash.d.ts +2 -0
- package/dist/languages/bash.js +208 -0
- package/dist/languages/c.d.ts +2 -0
- package/dist/languages/c.js +218 -0
- package/dist/languages/call-syntax.d.ts +125 -0
- package/dist/languages/call-syntax.js +997 -0
- package/dist/languages/cpp.d.ts +2 -0
- package/dist/languages/cpp.js +321 -0
- package/dist/languages/csharp.d.ts +2 -0
- package/dist/languages/csharp.js +324 -0
- package/dist/languages/elixir.d.ts +2 -0
- package/dist/languages/elixir.js +331 -0
- package/dist/languages/go.d.ts +2 -0
- package/dist/languages/go.js +299 -0
- package/dist/languages/grammars.d.ts +50 -0
- package/dist/languages/grammars.js +351 -0
- package/dist/languages/haskell.d.ts +2 -0
- package/dist/languages/haskell.js +250 -0
- package/dist/languages/java.d.ts +2 -0
- package/dist/languages/java.js +351 -0
- package/dist/languages/javascript.d.ts +4 -0
- package/dist/languages/javascript.js +648 -0
- package/dist/languages/kotlin.d.ts +2 -0
- package/dist/languages/kotlin.js +368 -0
- package/dist/languages/lua.d.ts +2 -0
- package/dist/languages/lua.js +212 -0
- package/dist/languages/ocaml.d.ts +2 -0
- package/dist/languages/ocaml.js +291 -0
- package/dist/languages/perl.d.ts +2 -0
- package/dist/languages/perl.js +418 -0
- package/dist/languages/php.d.ts +2 -0
- package/dist/languages/php.js +397 -0
- package/dist/languages/python.d.ts +2 -0
- package/dist/languages/python.js +376 -0
- package/dist/languages/registry.d.ts +7 -0
- package/dist/languages/registry.js +69 -0
- package/dist/languages/ruby.d.ts +2 -0
- package/dist/languages/ruby.js +391 -0
- package/dist/languages/rust.d.ts +2 -0
- package/dist/languages/rust.js +261 -0
- package/dist/languages/scala.d.ts +2 -0
- package/dist/languages/scala.js +307 -0
- package/dist/languages/solidity.d.ts +2 -0
- package/dist/languages/solidity.js +240 -0
- package/dist/languages/swift.d.ts +2 -0
- package/dist/languages/swift.js +268 -0
- package/dist/languages/types.d.ts +36 -0
- package/dist/languages/types.js +74 -0
- package/dist/languages/typescript-contracts.d.ts +57 -0
- package/dist/languages/typescript-contracts.js +528 -0
- package/dist/languages/typescript-dispatch.d.ts +68 -0
- package/dist/languages/typescript-dispatch.js +710 -0
- package/dist/languages/typescript.d.ts +4 -0
- package/dist/languages/typescript.js +722 -0
- package/dist/languages/zig.d.ts +2 -0
- package/dist/languages/zig.js +243 -0
- package/dist/loc.d.ts +17 -0
- package/dist/loc.js +34 -0
- package/dist/reach.d.ts +17 -0
- package/dist/reach.js +65 -0
- package/dist/render.d.ts +18 -0
- package/dist/render.js +83 -0
- package/dist/review/brand.d.ts +8 -0
- package/dist/review/brand.js +25 -0
- package/dist/review/call-context.d.ts +27 -0
- package/dist/review/call-context.js +446 -0
- package/dist/review/call-flow-html.d.ts +32 -0
- package/dist/review/call-flow-html.js +1870 -0
- package/dist/review/call-flow-nav.d.ts +151 -0
- package/dist/review/call-flow-nav.js +317 -0
- package/dist/review/call-flow.d.ts +47 -0
- package/dist/review/call-flow.js +229 -0
- package/dist/review/change-facts.d.ts +69 -0
- package/dist/review/change-facts.js +729 -0
- package/dist/review/cli.d.ts +2 -0
- package/dist/review/cli.js +50 -0
- package/dist/review/connected-analysis.d.ts +100 -0
- package/dist/review/connected-analysis.js +163 -0
- package/dist/review/connected-html.d.ts +17 -0
- package/dist/review/connected-html.js +2853 -0
- package/dist/review/connected.d.ts +23 -0
- package/dist/review/connected.js +141 -0
- package/dist/review/escape-html.d.ts +2 -0
- package/dist/review/escape-html.js +9 -0
- package/dist/review/evidence-html.d.ts +21 -0
- package/dist/review/evidence-html.js +521 -0
- package/dist/review/evidence-syntax.d.ts +132 -0
- package/dist/review/evidence-syntax.js +478 -0
- package/dist/review/evidence-types.d.ts +62 -0
- package/dist/review/evidence-types.js +1 -0
- package/dist/review/evidence.d.ts +31 -0
- package/dist/review/evidence.js +1603 -0
- package/dist/review/file-role.d.ts +9 -0
- package/dist/review/file-role.js +29 -0
- package/dist/review/github.d.ts +204 -0
- package/dist/review/github.js +1245 -0
- package/dist/review/history.d.ts +101 -0
- package/dist/review/history.js +412 -0
- package/dist/review/html.d.ts +34 -0
- package/dist/review/html.js +1104 -0
- package/dist/review/input.d.ts +10 -0
- package/dist/review/input.js +113 -0
- package/dist/review/intent.d.ts +4 -0
- package/dist/review/intent.js +75 -0
- package/dist/review/mcp-cli.d.ts +2 -0
- package/dist/review/mcp-cli.js +25 -0
- package/dist/review/mcp.d.ts +12 -0
- package/dist/review/mcp.js +414 -0
- package/dist/review/module-resolution.d.ts +2 -0
- package/dist/review/module-resolution.js +86 -0
- package/dist/review/palette.d.ts +7 -0
- package/dist/review/palette.js +104 -0
- package/dist/review/pipeline.d.ts +77 -0
- package/dist/review/pipeline.js +227 -0
- package/dist/review/pr-input.d.ts +19 -0
- package/dist/review/pr-input.js +130 -0
- package/dist/review/questions.d.ts +201 -0
- package/dist/review/questions.js +174 -0
- package/dist/review/reference-check.d.ts +7 -0
- package/dist/review/reference-check.js +733 -0
- package/dist/review/report-pages.d.ts +109 -0
- package/dist/review/report-pages.js +328 -0
- package/dist/review/service.d.ts +23 -0
- package/dist/review/service.js +198 -0
- package/dist/review/setup.d.ts +112 -0
- package/dist/review/setup.js +549 -0
- package/dist/review/source.d.ts +26 -0
- package/dist/review/source.js +276 -0
- package/dist/review/toml.d.ts +38 -0
- package/dist/review/toml.js +565 -0
- package/dist/review/types.d.ts +179 -0
- package/dist/review/types.js +1 -0
- package/dist/run.d.ts +49 -0
- package/dist/run.js +311 -0
- package/dist/types.d.ts +366 -0
- package/dist/types.js +83 -0
- package/package.json +88 -0
- package/scripts/ensure-native-grammar.mjs +188 -0
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
import { verdictOf } from "./questions.js";
|
|
2
|
+
import { factQuestionsFor } from "./change-facts.js";
|
|
3
|
+
import { renderCallFlows, CALL_FLOW_STYLES, CALL_FLOW_SCRIPT } from "./call-flow-html.js";
|
|
4
|
+
import { renderBrief, BRIEF_STYLES } from "./evidence-html.js";
|
|
5
|
+
import { BRAND_MARK, BRAND_MARK_STYLES } from "./brand.js";
|
|
6
|
+
import { PALETTE_STYLES } from "./palette.js";
|
|
7
|
+
import { escapeHtml } from "./escape-html.js";
|
|
8
|
+
import { testLikeFile } from "./file-role.js";
|
|
9
|
+
/**
|
|
10
|
+
* Render a review report as one self-contained HTML document.
|
|
11
|
+
*
|
|
12
|
+
* The document opens on the expected outcome: the pull request text, the intent
|
|
13
|
+
* cross-check, the checks that ran with their limits, and a short reading agenda
|
|
14
|
+
* whose top cards link the changed code, its direct caller or its contract. The
|
|
15
|
+
* call flow and the full diff are the other two views of the same document, so a
|
|
16
|
+
* reviewer reaches a hunk from the agenda in one click and every hunk stays
|
|
17
|
+
* reachable, including the ones no evaluation covers.
|
|
18
|
+
*
|
|
19
|
+
* The page is still a plain diff review underneath: file header, hunks with line
|
|
20
|
+
* numbers and green/red lines, ranking deciding order and severity reaching the
|
|
21
|
+
* reviewer as color. Ranking scores, model probabilities and the adapter's own
|
|
22
|
+
* rubric text stay in the JSON sidecar: the only model-derived content on the
|
|
23
|
+
* page is the hunk's own single-sample typed observations, printed from closed
|
|
24
|
+
* sets under a label that says what they are.
|
|
25
|
+
*
|
|
26
|
+
* Everything is server-rendered, so the report is readable with JavaScript
|
|
27
|
+
* disabled; without the script the three views stack as sections and every hunk
|
|
28
|
+
* is one disclosure away. The single inline script is progressive enhancement:
|
|
29
|
+
* view switching, expand and collapse, status filters that keep their fold
|
|
30
|
+
* state, focus mode, and a keyboard cursor. No report string is placed in the
|
|
31
|
+
* script; it reads labels from the DOM. Every string from a diff, path, pull
|
|
32
|
+
* request body or report field is HTML-escaped.
|
|
33
|
+
*/
|
|
34
|
+
export function renderReview(report) {
|
|
35
|
+
const counts = countStatuses(report.items);
|
|
36
|
+
const body = report.items.length === 0
|
|
37
|
+
? '<p class="empty">No hunks were reviewed in this diff.</p>'
|
|
38
|
+
: [
|
|
39
|
+
'<div class="toolbar" id="toolbar">',
|
|
40
|
+
renderControls(counts),
|
|
41
|
+
renderBreadcrumb(),
|
|
42
|
+
renderNav(report.items),
|
|
43
|
+
"</div>",
|
|
44
|
+
'<p class="empty" id="filter-empty" hidden>No hunks match the selected statuses.</p>',
|
|
45
|
+
renderItems(report.items, report.questions),
|
|
46
|
+
].join("\n");
|
|
47
|
+
return [
|
|
48
|
+
"<!doctype html>",
|
|
49
|
+
'<html lang="en">',
|
|
50
|
+
"<head>",
|
|
51
|
+
'<meta charset="utf-8">',
|
|
52
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
53
|
+
'<meta name="color-scheme" content="light dark">',
|
|
54
|
+
`<title>${escapeHtml(`diffninja review: ${report.title || "untitled diff"}`)}</title>`,
|
|
55
|
+
`<style>${STYLES}\n${BRIEF_STYLES}\n${CALL_FLOW_STYLES}</style>`,
|
|
56
|
+
"</head>",
|
|
57
|
+
`<body data-default-view="${report.evidence === undefined ? "call-flow" : "brief"}">`,
|
|
58
|
+
'<div class="wrap">',
|
|
59
|
+
renderHeader(report),
|
|
60
|
+
renderAgentOrder(report),
|
|
61
|
+
'<nav class="view-switch" aria-label="Report view">',
|
|
62
|
+
'<a href="#view-brief" data-view="brief">Outcome</a>',
|
|
63
|
+
'<a href="#view-call-flow" data-view="call-flow">Call flow</a>',
|
|
64
|
+
'<a href="#view-diff" data-view="diff">Diff</a>',
|
|
65
|
+
"</nav>",
|
|
66
|
+
`<section class="brief" id="view-brief" aria-label="Expected outcome and reading agenda">${renderBrief(report)}</section>`,
|
|
67
|
+
'<section id="view-call-flow" aria-label="Call flow">',
|
|
68
|
+
renderCallFlows(report),
|
|
69
|
+
"</section>",
|
|
70
|
+
'<section id="view-diff" aria-label="Diff">',
|
|
71
|
+
body,
|
|
72
|
+
"</section>",
|
|
73
|
+
renderFooter(report),
|
|
74
|
+
"</div>",
|
|
75
|
+
`<script>${SCRIPT}\n${CALL_FLOW_SCRIPT}</script>`,
|
|
76
|
+
"</body>",
|
|
77
|
+
"</html>",
|
|
78
|
+
"",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
/** Trims the report's call-flow view to live inside the pull request page's drawer. */
|
|
82
|
+
const EMBEDDED_FLOW_STYLES = `
|
|
83
|
+
body { background: var(--bg); font-family: var(--sans); }
|
|
84
|
+
.flow-embed { padding: 4px 16px 20px; }
|
|
85
|
+
.flow-embed .cf { margin-top: 8px; }
|
|
86
|
+
.cf-diff-link { display: none !important; }
|
|
87
|
+
.flow-single .cf-jump { display: none; }
|
|
88
|
+
`;
|
|
89
|
+
/**
|
|
90
|
+
* The call flows of one review as a page of their own, for the connected pull
|
|
91
|
+
* request page to show beside its diff: every changed file with call flows, or
|
|
92
|
+
* only `file` when given. Tree, Graph and Sequence work as in the report; links
|
|
93
|
+
* into the report's diff are dropped, since the page showing this has its own.
|
|
94
|
+
*/
|
|
95
|
+
export function renderCallFlowPage(report, file) {
|
|
96
|
+
const scoped = file === undefined ? report : { ...report, callFlows: report.callFlows.filter((entry) => entry.file === file) };
|
|
97
|
+
return [
|
|
98
|
+
"<!doctype html>",
|
|
99
|
+
'<html lang="en">',
|
|
100
|
+
"<head>",
|
|
101
|
+
'<meta charset="utf-8">',
|
|
102
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
103
|
+
'<meta name="color-scheme" content="light dark">',
|
|
104
|
+
`<title>${escapeHtml(file === undefined ? "Call flows" : `Call flow: ${file}`)}</title>`,
|
|
105
|
+
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
|
|
106
|
+
"</head>",
|
|
107
|
+
"<body>",
|
|
108
|
+
`<section id="view-call-flow" class="flow-embed${file === undefined ? "" : " flow-single"}" aria-label="Call flow">`,
|
|
109
|
+
renderCallFlows(scoped),
|
|
110
|
+
"</section>",
|
|
111
|
+
`<script>document.documentElement.classList.add('js');\n${CALL_FLOW_SCRIPT}</script>`,
|
|
112
|
+
"</body>",
|
|
113
|
+
"</html>",
|
|
114
|
+
"",
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|
|
117
|
+
const STATUS_ORDER = [
|
|
118
|
+
"attention",
|
|
119
|
+
"uncertain",
|
|
120
|
+
"low",
|
|
121
|
+
"passed",
|
|
122
|
+
];
|
|
123
|
+
const STATUS_LABEL = {
|
|
124
|
+
attention: "Attention",
|
|
125
|
+
uncertain: "Uncertain",
|
|
126
|
+
low: "Low",
|
|
127
|
+
passed: "Passed",
|
|
128
|
+
};
|
|
129
|
+
/** A compact legend is the only explanation of the severity colors. */
|
|
130
|
+
const STATUS_HINT = {
|
|
131
|
+
attention: "read first",
|
|
132
|
+
uncertain: "needs a human read",
|
|
133
|
+
low: "nothing notable found",
|
|
134
|
+
passed: "unchanged or blank-only",
|
|
135
|
+
};
|
|
136
|
+
function countStatuses(items) {
|
|
137
|
+
const counts = {
|
|
138
|
+
attention: 0,
|
|
139
|
+
uncertain: 0,
|
|
140
|
+
low: 0,
|
|
141
|
+
passed: 0,
|
|
142
|
+
};
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
counts[item.status] += 1;
|
|
145
|
+
}
|
|
146
|
+
return counts;
|
|
147
|
+
}
|
|
148
|
+
function renderHeader(report) {
|
|
149
|
+
const files = new Set(report.items.map((item) => item.file)).size;
|
|
150
|
+
const added = sum(report.items.map((item) => item.added));
|
|
151
|
+
const removed = sum(report.items.map((item) => item.removed));
|
|
152
|
+
const scope = report.items.length === 0
|
|
153
|
+
? ""
|
|
154
|
+
: `${report.items.length} hunks in ${files} files, +${formatInteger(added)} / -${formatInteger(removed)} lines, generated ${report.createdAt}`;
|
|
155
|
+
const mode = "Local analysis — nothing left your machine. Review changes before merging.";
|
|
156
|
+
return [
|
|
157
|
+
'<header class="masthead">',
|
|
158
|
+
`<p class="brand">${BRAND_MARK}diffninja</p>`,
|
|
159
|
+
`<h1>${escapeHtml(report.title || "Untitled diff")}</h1>`,
|
|
160
|
+
`<p class="meta">Files changed · <span class="mono">${escapeHtml(report.source)}</span></p>`,
|
|
161
|
+
scope === "" ? "" : `<p class="meta">${escapeHtml(scope)}</p>`,
|
|
162
|
+
`<p class="mode-note">${mode}</p>`,
|
|
163
|
+
renderLegend(),
|
|
164
|
+
"</header>",
|
|
165
|
+
]
|
|
166
|
+
.filter((part) => part !== "")
|
|
167
|
+
.join("\n");
|
|
168
|
+
}
|
|
169
|
+
/** One color line, no jargon. Counting is left to the filter chips. */
|
|
170
|
+
function renderLegend() {
|
|
171
|
+
const keys = STATUS_ORDER.map((status) => `<span class="key"><span class="dot dot-${status}" aria-hidden="true"></span>${escapeHtml(`${STATUS_LABEL[status]}: ${STATUS_HINT[status]}`)}</span>`).join("");
|
|
172
|
+
return `<p class="legend">${keys}</p>`;
|
|
173
|
+
}
|
|
174
|
+
function renderControls(counts) {
|
|
175
|
+
const chips = STATUS_ORDER.map((status) => `<button type="button" class="pill pill-${status}" data-filter="${status}" data-count="${counts[status]}" aria-pressed="true">` +
|
|
176
|
+
`<span class="dot dot-${status}" aria-hidden="true"></span>${escapeHtml(STATUS_LABEL[status])}` +
|
|
177
|
+
`<span class="pill-n mono">${counts[status]}</span></button>`).join("");
|
|
178
|
+
return [
|
|
179
|
+
'<div class="controls enhanced" role="group" aria-label="Report controls">',
|
|
180
|
+
'<button type="button" data-action="expand">Expand all</button>',
|
|
181
|
+
'<button type="button" data-action="collapse">Collapse all</button>',
|
|
182
|
+
`<div class="filters" role="group" aria-label="Show or hide hunks by status">${chips}</div>`,
|
|
183
|
+
'<span class="keyboard-help">j/k or arrows move · Enter folds · f focuses · Esc returns</span>',
|
|
184
|
+
"</div>",
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|
|
187
|
+
function renderBreadcrumb() {
|
|
188
|
+
return [
|
|
189
|
+
'<nav id="breadcrumb" aria-label="Focus breadcrumb" hidden>',
|
|
190
|
+
'<button type="button" data-action="back">Report</button>',
|
|
191
|
+
'<span class="crumb-sep" aria-hidden="true">/</span>',
|
|
192
|
+
'<span class="crumb-rank mono" id="focus-rank"></span>',
|
|
193
|
+
'<span class="crumb-path mono" id="focus-path"></span>',
|
|
194
|
+
"</nav>",
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Who ordered the hunks. Once the reviewing agent records an order, the items
|
|
199
|
+
* follow it, so every rank (#) on the page is the agent's; diffninja's own order
|
|
200
|
+
* stays one disclosure away, linking each hunk under its current rank.
|
|
201
|
+
*/
|
|
202
|
+
function renderAgentOrder(report) {
|
|
203
|
+
const order = report.agentOrder;
|
|
204
|
+
if (order === undefined)
|
|
205
|
+
return "";
|
|
206
|
+
const rankOf = new Map(report.items.map((item, index) => [item.id, index + 1]));
|
|
207
|
+
const links = order.diffninjaIds
|
|
208
|
+
.map((id) => {
|
|
209
|
+
const rank = rankOf.get(id);
|
|
210
|
+
const item = report.items[rank - 1];
|
|
211
|
+
return [
|
|
212
|
+
"<li>",
|
|
213
|
+
`<a class="toc-link" data-open-hunk href="#item-${rank}">`,
|
|
214
|
+
`<span class="toc-rank mono">#${rank}</span>`,
|
|
215
|
+
`<span class="dot dot-${escapeHtml(item.status)}" aria-hidden="true"></span>`,
|
|
216
|
+
`<span class="toc-path mono">${escapeHtml(`${item.file}:${item.newStart}`)}</span>`,
|
|
217
|
+
"</a></li>",
|
|
218
|
+
].join("");
|
|
219
|
+
})
|
|
220
|
+
.join("\n");
|
|
221
|
+
return [
|
|
222
|
+
'<section class="agent-order" aria-label="Reading order">',
|
|
223
|
+
`<p class="agent-order-by">Hunks are in the reading order recommended by ${escapeHtml(order.orderedBy)}, the agent that requested this review. It is that agent's reading, not a verdict; statuses come from diffninja.</p>`,
|
|
224
|
+
"<details>",
|
|
225
|
+
"<summary>diffninja's own order</summary>",
|
|
226
|
+
`<ol class="agent-order-list">${links}</ol>`,
|
|
227
|
+
"</details>",
|
|
228
|
+
"</section>",
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
function renderNav(items) {
|
|
232
|
+
if (items.length === 0) {
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
const links = items
|
|
236
|
+
.map((item, index) => {
|
|
237
|
+
const rank = index + 1;
|
|
238
|
+
const status = escapeHtml(item.status);
|
|
239
|
+
return [
|
|
240
|
+
"<li>",
|
|
241
|
+
`<a class="toc-link" data-open-hunk href="#item-${rank}">`,
|
|
242
|
+
`<span class="toc-rank mono">#${rank}</span>`,
|
|
243
|
+
`<span class="dot dot-${status}" aria-hidden="true"></span>`,
|
|
244
|
+
`<span class="toc-path mono">${escapeHtml(item.file)}</span>`,
|
|
245
|
+
`<span class="sr">${escapeHtml(STATUS_LABEL[item.status])}</span>`,
|
|
246
|
+
"</a></li>",
|
|
247
|
+
].join("");
|
|
248
|
+
})
|
|
249
|
+
.join("\n");
|
|
250
|
+
return [
|
|
251
|
+
'<nav class="toc" aria-label="Jump to a hunk">',
|
|
252
|
+
'<button type="button" class="jump-toggle enhanced" aria-expanded="false" aria-controls="jump-links">Jump to a hunk</button>',
|
|
253
|
+
`<ul class="toc-list" id="jump-links">${links}</ul>`,
|
|
254
|
+
"</nav>",
|
|
255
|
+
].join("\n");
|
|
256
|
+
}
|
|
257
|
+
function renderItems(items, questions) {
|
|
258
|
+
const cards = items
|
|
259
|
+
.map((item, index) => renderItem(item, index + 1, questions.filter((question) => question.unitIds[0] === item.id)))
|
|
260
|
+
.join("\n");
|
|
261
|
+
return `<section class="cards" aria-label="Hunks">${cards}</section>`;
|
|
262
|
+
}
|
|
263
|
+
/** Plain file header row: rank focus button, path, line growth. */
|
|
264
|
+
function renderItem(item, rank, questions) {
|
|
265
|
+
const status = escapeHtml(item.status);
|
|
266
|
+
return [
|
|
267
|
+
// Closed by default: a fresh report opens on the agenda, and the diff stays
|
|
268
|
+
// a short list of hunk headers until a reviewer opens what they came for.
|
|
269
|
+
`<details class="card card-${status}" data-status="${status}" id="item-${rank}">`,
|
|
270
|
+
"<summary>",
|
|
271
|
+
`<span class="rank fallback-rank mono">#${rank}</span>`,
|
|
272
|
+
`<button type="button" class="rank enhanced mono" data-focus aria-label="Focus hunk ${rank}">#${rank}</button>`,
|
|
273
|
+
`<span class="path mono">${escapeHtml(item.file)}</span>`,
|
|
274
|
+
`<span class="sr">${escapeHtml(STATUS_LABEL[item.status])}</span>`,
|
|
275
|
+
`<span class="growth mono"><span class="plus">+${formatInteger(item.added)}</span> <span class="minus">-${formatInteger(item.removed)}</span></span>`,
|
|
276
|
+
`<button type="button" class="focus-button enhanced" data-focus aria-label="Focus hunk ${rank}">Focus</button>`,
|
|
277
|
+
"</summary>",
|
|
278
|
+
renderItemBody(item, questions),
|
|
279
|
+
"</details>",
|
|
280
|
+
].join("\n");
|
|
281
|
+
}
|
|
282
|
+
function renderItemBody(item, questions) {
|
|
283
|
+
const special = item.special === undefined || item.special === ""
|
|
284
|
+
? ""
|
|
285
|
+
: `<p class="note">Manual review: ${escapeHtml(item.special)}</p>`;
|
|
286
|
+
return [
|
|
287
|
+
'<div class="body">',
|
|
288
|
+
special,
|
|
289
|
+
renderFacts(item),
|
|
290
|
+
renderHistory(item),
|
|
291
|
+
renderQuestions(questions),
|
|
292
|
+
renderDiff(item.diff),
|
|
293
|
+
"</div>",
|
|
294
|
+
]
|
|
295
|
+
.filter((part) => part !== "")
|
|
296
|
+
.join("\n");
|
|
297
|
+
}
|
|
298
|
+
/** The commits that last changed the lines this hunk removes, from the local repository. */
|
|
299
|
+
function renderHistory(item) {
|
|
300
|
+
const history = item.history;
|
|
301
|
+
if (history === undefined)
|
|
302
|
+
return "";
|
|
303
|
+
const rows = history.origins.map((origin) => `<li><span class="mono">${escapeHtml(origin.commit)}</span> ${escapeHtml(origin.date)} ${escapeHtml(origin.subject)} ` +
|
|
304
|
+
`<span class="obs-line">${origin.lines} line(s)${origin.notable ? ", names a fix or compatibility concern" : ""}</span></li>`);
|
|
305
|
+
if (history.unknownLines > 0)
|
|
306
|
+
rows.push(`<li class="obs-line">${history.unknownLines} line(s) of unknown origin (shallow or unreadable history)</li>`);
|
|
307
|
+
return `<div class="note">Removed lines last changed by:<ul>${rows.join("")}</ul></div>`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Questions this report asked the reviewing agent about this hunk, with the
|
|
311
|
+
* answer when one was recorded. An answer is printed only when it is one of the
|
|
312
|
+
* question's own options, attributed to the MCP client that recorded it; it is
|
|
313
|
+
* another reader's view, never a verdict, and it never reorders the report.
|
|
314
|
+
*/
|
|
315
|
+
function renderQuestions(questions) {
|
|
316
|
+
if (questions.length === 0)
|
|
317
|
+
return "";
|
|
318
|
+
const verdicts = questions.flatMap((question) => {
|
|
319
|
+
const verdict = question.answer === undefined ? undefined : verdictOf(question.kind, question.answer.choice);
|
|
320
|
+
return verdict === undefined ? [] : [{ question, verdict }];
|
|
321
|
+
});
|
|
322
|
+
const chips = verdicts
|
|
323
|
+
.map(({ question, verdict }) => `<span class="verdict verdict-${verdict.tone}" title="${escapeHtml(question.text)}">${escapeHtml(verdict.label)}</span>`)
|
|
324
|
+
.join("");
|
|
325
|
+
const answeredBy = verdicts[0]?.question.answer?.answeredBy;
|
|
326
|
+
const pending = questions.filter((question) => question.answer === undefined).length;
|
|
327
|
+
const rows = questions.map((question) => {
|
|
328
|
+
const answer = question.answer;
|
|
329
|
+
const shown = answer === undefined ? "not answered yet" : (verdictOf(question.kind, answer.choice)?.label ?? "unrecognized answer");
|
|
330
|
+
return `<dt>${escapeHtml(question.text)}</dt><dd>${escapeHtml(shown)}</dd>`;
|
|
331
|
+
});
|
|
332
|
+
return [
|
|
333
|
+
verdicts.length === 0
|
|
334
|
+
? ""
|
|
335
|
+
: `<p class="verdicts"><span class="verdicts-by">${escapeHtml(answeredBy ?? "Your agent")}:</span>${chips}</p>`,
|
|
336
|
+
'<details class="obs">',
|
|
337
|
+
`<summary>${pending === 0 ? "What the agent was asked" : `Questions for your agent (${pending} not answered yet)`}</summary>`,
|
|
338
|
+
'<div class="obs-body">',
|
|
339
|
+
'<p class="note">Answers are the agent's reading, not a verdict. They never change a status.</p>',
|
|
340
|
+
`<dl class="obs-list">${rows.join("")}</dl>`,
|
|
341
|
+
"</div>",
|
|
342
|
+
"</details>",
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
345
|
+
/** Human label per fact, in the order the report shows them. */
|
|
346
|
+
const FACT_LABEL = {
|
|
347
|
+
comparisonChanged: "Comparison changed",
|
|
348
|
+
limitChanged: "Limit, size, or offset changed",
|
|
349
|
+
validationChanged: "Input check changed",
|
|
350
|
+
failurePropagated: "Failure handed to the caller",
|
|
351
|
+
failureDeferred: "Failure deferred or retried",
|
|
352
|
+
failureDiscarded: "Failure discarded",
|
|
353
|
+
contractChanged: "Public contract or declaration changed",
|
|
354
|
+
dataChanged: "Schema or stored data changed",
|
|
355
|
+
queryChanged: "Database query changed",
|
|
356
|
+
instructionChanged: "Instruction to readers changed",
|
|
357
|
+
referenceChanged: "Link or reference changed",
|
|
358
|
+
gateWeakened: "CI gate weakened",
|
|
359
|
+
permissionChanged: "Permission or secret access changed",
|
|
360
|
+
pinChanged: "Version pin changed",
|
|
361
|
+
};
|
|
362
|
+
/**
|
|
363
|
+
* The local facts about this hunk's changed lines. Each `yes` shows the changed
|
|
364
|
+
* line it rests on, quoted from the diff; `no` speaks only about the lines the
|
|
365
|
+
* hunk shows, never about the rest of the code; a file type diffninja cannot read
|
|
366
|
+
* says so instead of listing answers. Nothing here reads `reasons`.
|
|
367
|
+
*/
|
|
368
|
+
function renderFacts(item) {
|
|
369
|
+
const facts = item.facts;
|
|
370
|
+
if (facts === undefined)
|
|
371
|
+
return "";
|
|
372
|
+
const notes = [];
|
|
373
|
+
let list = "";
|
|
374
|
+
if (facts.language === null) {
|
|
375
|
+
notes.push("diffninja does not read this file type, so no facts were established. Read this hunk yourself.");
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
list = factQuestionsFor(facts.language).map((question) => {
|
|
379
|
+
const evidence = facts.evidence[question];
|
|
380
|
+
const answer = facts.answers[question];
|
|
381
|
+
const shown = answer === "yes" && evidence !== undefined
|
|
382
|
+
? `yes <span class="obs-line">${escapeHtml(evidence.side)} line: <code>${escapeHtml(evidence.text)}</code></span>`
|
|
383
|
+
: escapeHtml(answer === "yes" ? "yes" : "no");
|
|
384
|
+
return `<dt>${escapeHtml(FACT_LABEL[question])}</dt><dd class="mono">${shown}</dd>`;
|
|
385
|
+
}).join("");
|
|
386
|
+
if (facts.inert) {
|
|
387
|
+
notes.push(facts.language === "prose"
|
|
388
|
+
? "Reflow only: the same words in the same order."
|
|
389
|
+
: "Formatting or comments only: the text is identical once comments and layout are ignored.");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// A path fact: explains why this hunk sits after the others.
|
|
393
|
+
if (facts.language !== null && testLikeFile(item.file)) {
|
|
394
|
+
notes.push("This path looks like a test file, so it is listed after the hunks outside test files, and it is marked for attention only when it changes a limit or discards a failure. A path convention, not coverage.");
|
|
395
|
+
}
|
|
396
|
+
return [
|
|
397
|
+
'<details class="obs">',
|
|
398
|
+
"<summary>Change facts</summary>",
|
|
399
|
+
'<div class="obs-body">',
|
|
400
|
+
'<p class="note">Found locally in the added and removed lines; no model was asked. A fact is a pointer to what to read, not a verdict, and not a merge approval.</p>',
|
|
401
|
+
list === "" ? "" : `<dl class="obs-list">${list}</dl>`,
|
|
402
|
+
...notes.map((note) => `<p class="note obs-note">${escapeHtml(note)}</p>`),
|
|
403
|
+
"</div>",
|
|
404
|
+
"</details>",
|
|
405
|
+
].filter((part) => part !== "").join("\n");
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Split a stored hunk into rows with gutter numbers.
|
|
409
|
+
*
|
|
410
|
+
* Classification is positional: the first line of a parsed hunk is the `@@`
|
|
411
|
+
* header and every later line starts with its diff marker. A deletion of a
|
|
412
|
+
* line whose text begins with `--` therefore stays a deletion instead of
|
|
413
|
+
* looking like a `--- file` header. Only a file-metadata unit, which stores
|
|
414
|
+
* `diff --git` / `---` / `+++` lines, is classified by prefix.
|
|
415
|
+
*/
|
|
416
|
+
function diffRows(diff) {
|
|
417
|
+
const text = diff.endsWith("\n") ? diff.slice(0, -1) : diff;
|
|
418
|
+
if (text === "") {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
const raw = text.split("\n");
|
|
422
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw[0]);
|
|
423
|
+
if (!header) {
|
|
424
|
+
return raw.map((line) => ({
|
|
425
|
+
kind: "meta",
|
|
426
|
+
text: line,
|
|
427
|
+
oldNo: null,
|
|
428
|
+
newNo: null,
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
let oldNo = Number(header[1]);
|
|
432
|
+
let newNo = Number(header[3]);
|
|
433
|
+
const rows = [
|
|
434
|
+
{ kind: "hunk", text: raw[0], oldNo: null, newNo: null },
|
|
435
|
+
];
|
|
436
|
+
for (let i = 1; i < raw.length; i++) {
|
|
437
|
+
const line = raw[i];
|
|
438
|
+
if (line.startsWith("\\")) {
|
|
439
|
+
// "" belongs to the line above, so no numbers.
|
|
440
|
+
rows.push({ kind: "note", text: line, oldNo: null, newNo: null });
|
|
441
|
+
}
|
|
442
|
+
else if (line.startsWith("+")) {
|
|
443
|
+
rows.push({ kind: "add", text: line, oldNo: null, newNo });
|
|
444
|
+
newNo += 1;
|
|
445
|
+
}
|
|
446
|
+
else if (line.startsWith("-")) {
|
|
447
|
+
rows.push({ kind: "del", text: line, oldNo, newNo: null });
|
|
448
|
+
oldNo += 1;
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
rows.push({ kind: "context", text: line, oldNo, newNo });
|
|
452
|
+
oldNo += 1;
|
|
453
|
+
newNo += 1;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return rows;
|
|
457
|
+
}
|
|
458
|
+
function renderDiff(diff) {
|
|
459
|
+
const rows = diffRows(diff);
|
|
460
|
+
if (rows.length === 0) {
|
|
461
|
+
return '<p class="note">No diff text was captured for this hunk.</p>';
|
|
462
|
+
}
|
|
463
|
+
// Joined without whitespace: the gutter and code spans are grid items, so the
|
|
464
|
+
// rows must not be separated by text nodes.
|
|
465
|
+
const body = rows
|
|
466
|
+
.map((row) => `<span class="ln ln-${row.kind}">` +
|
|
467
|
+
`<span class="old-no" aria-hidden="true">${row.oldNo === null ? "" : String(row.oldNo)}</span>` +
|
|
468
|
+
`<span class="new-no" aria-hidden="true">${row.newNo === null ? "" : String(row.newNo)}</span>` +
|
|
469
|
+
`<span class="code">${escapeHtml(row.text)}</span>` +
|
|
470
|
+
"</span>")
|
|
471
|
+
.join("");
|
|
472
|
+
return [
|
|
473
|
+
'<div class="diff-wrap">',
|
|
474
|
+
`<pre class="diff">${body}</pre>`,
|
|
475
|
+
"</div>",
|
|
476
|
+
].join("\n");
|
|
477
|
+
}
|
|
478
|
+
function renderFooter(report) {
|
|
479
|
+
return [
|
|
480
|
+
'<footer class="foot">',
|
|
481
|
+
`<p>Source: <span class="mono">${escapeHtml(report.source)}</span></p>`,
|
|
482
|
+
"<p>Offline report · All changes remain readable without JavaScript · Not a merge approval.</p>",
|
|
483
|
+
"</footer>",
|
|
484
|
+
].join("\n");
|
|
485
|
+
}
|
|
486
|
+
// One toolbar script. No report string is interpolated into it: it reads the
|
|
487
|
+
// labels it needs from the escaped DOM, so a crafted path, diff or pull request
|
|
488
|
+
// body cannot reach the inline script.
|
|
489
|
+
const SCRIPT = `
|
|
490
|
+
(function () {
|
|
491
|
+
'use strict';
|
|
492
|
+
document.documentElement.classList.add('js');
|
|
493
|
+
var briefView = document.getElementById('view-brief');
|
|
494
|
+
var diffView = document.getElementById('view-diff');
|
|
495
|
+
var flowView = document.getElementById('view-call-flow');
|
|
496
|
+
var viewLinks = Array.prototype.slice.call(document.querySelectorAll('[data-view]'));
|
|
497
|
+
var VIEWS = { brief: briefView, 'call-flow': flowView, diff: diffView };
|
|
498
|
+
function showView(name) {
|
|
499
|
+
for (var key in VIEWS) {
|
|
500
|
+
if (VIEWS[key]) VIEWS[key].hidden = key !== name;
|
|
501
|
+
}
|
|
502
|
+
viewLinks.forEach(function (link) {
|
|
503
|
+
if (link.getAttribute('data-view') === name) link.setAttribute('aria-current', 'page');
|
|
504
|
+
else link.removeAttribute('aria-current');
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// The hash may name a view or any element inside one, which is where the
|
|
508
|
+
// agenda and the call flow both point.
|
|
509
|
+
function viewForHash() {
|
|
510
|
+
var hash = location.hash;
|
|
511
|
+
if (hash === '') return null;
|
|
512
|
+
if (VIEWS[hash.slice(1)]) return hash.slice(1);
|
|
513
|
+
var target = document.getElementById(hash.slice(1));
|
|
514
|
+
if (!target) return null;
|
|
515
|
+
for (var key in VIEWS) {
|
|
516
|
+
if (VIEWS[key] && VIEWS[key].contains(target)) return key;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
// A new report carries an agenda, so that is what it opens on: the body names
|
|
521
|
+
// the opening view. A report without a cross-check opens on the call flow, its
|
|
522
|
+
// most useful overview, and an explicit hash always wins.
|
|
523
|
+
showView(viewForHash() || document.body.getAttribute('data-default-view') || 'call-flow');
|
|
524
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll('.card'));
|
|
525
|
+
var filters = Array.prototype.slice.call(document.querySelectorAll('[data-filter]'));
|
|
526
|
+
var toolbar = document.getElementById('toolbar');
|
|
527
|
+
var breadcrumb = document.getElementById('breadcrumb');
|
|
528
|
+
var focusRank = document.getElementById('focus-rank');
|
|
529
|
+
var focusPath = document.getElementById('focus-path');
|
|
530
|
+
var jump = document.querySelector('.jump-toggle');
|
|
531
|
+
var empty = document.getElementById('filter-empty');
|
|
532
|
+
var links = Array.prototype.slice.call(document.querySelectorAll('.toc-link'));
|
|
533
|
+
var cursor = 0;
|
|
534
|
+
var focusIndex = -1;
|
|
535
|
+
var focusWasOpen = true;
|
|
536
|
+
var returnControl = null;
|
|
537
|
+
var returnScroll = 0;
|
|
538
|
+
|
|
539
|
+
function statusesOn() {
|
|
540
|
+
return filters.filter(function (chip) {
|
|
541
|
+
return chip.getAttribute('aria-pressed') === 'true';
|
|
542
|
+
}).map(function (chip) { return chip.getAttribute('data-filter'); });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function visible() {
|
|
546
|
+
var list = [];
|
|
547
|
+
cards.forEach(function (card, index) { if (!card.hidden) list.push(index); });
|
|
548
|
+
return list;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function setCursor(index, move) {
|
|
552
|
+
if (index < 0 || index >= cards.length) return;
|
|
553
|
+
cursor = index;
|
|
554
|
+
cards.forEach(function (card, at) { card.classList.toggle('cursor', at === index); });
|
|
555
|
+
if (!move) return;
|
|
556
|
+
var card = cards[index];
|
|
557
|
+
var summary = card.querySelector('summary');
|
|
558
|
+
if (summary) summary.focus({ preventScroll: true });
|
|
559
|
+
measureToolbar();
|
|
560
|
+
card.scrollIntoView({ block: 'start' });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function apply() {
|
|
564
|
+
var on = statusesOn();
|
|
565
|
+
cards.forEach(function (card, index) {
|
|
566
|
+
card.hidden = focusIndex >= 0 ? index !== focusIndex : on.indexOf(card.getAttribute('data-status')) < 0;
|
|
567
|
+
});
|
|
568
|
+
links.forEach(function (link) {
|
|
569
|
+
var target = document.getElementById(link.hash.slice(1));
|
|
570
|
+
var item = link.parentElement;
|
|
571
|
+
if (target && item) item.hidden = target.hidden;
|
|
572
|
+
});
|
|
573
|
+
filters.forEach(function (chip) { chip.disabled = focusIndex >= 0; });
|
|
574
|
+
document.body.classList.toggle('focus-mode', focusIndex >= 0);
|
|
575
|
+
breadcrumb.hidden = focusIndex < 0;
|
|
576
|
+
var shown = visible();
|
|
577
|
+
if (empty) empty.hidden = shown.length > 0;
|
|
578
|
+
if (!cards[cursor] || cards[cursor].hidden) {
|
|
579
|
+
if (shown.length) setCursor(shown[0], false);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// One reveal for every link that points at a hunk: clear the view state that
|
|
584
|
+
// could hide it (a status filter, focus mode, another view) and open it.
|
|
585
|
+
function revealHunk(hash) {
|
|
586
|
+
var target = document.getElementById(hash.slice(1));
|
|
587
|
+
if (!target) return false;
|
|
588
|
+
if (focusIndex >= 0) exitFocus();
|
|
589
|
+
filters.forEach(function (chip) { chip.setAttribute('aria-pressed', 'true'); });
|
|
590
|
+
showView('diff');
|
|
591
|
+
apply();
|
|
592
|
+
target.open = true;
|
|
593
|
+
setJump(false);
|
|
594
|
+
setCursor(cards.indexOf(target), true);
|
|
595
|
+
history.replaceState(null, '', hash);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
document.addEventListener('click', function (event) {
|
|
599
|
+
if (!event.target.closest) return;
|
|
600
|
+
var link = event.target.closest('[data-view]');
|
|
601
|
+
if (!link) return;
|
|
602
|
+
event.preventDefault();
|
|
603
|
+
if (cards.length && focusIndex >= 0) exitFocus();
|
|
604
|
+
showView(link.getAttribute('data-view'));
|
|
605
|
+
history.replaceState(null, '', link.hash);
|
|
606
|
+
});
|
|
607
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll('.card'));
|
|
608
|
+
if (!cards.length) return;
|
|
609
|
+
var filters = Array.prototype.slice.call(document.querySelectorAll('[data-filter]'));
|
|
610
|
+
var toolbar = document.getElementById('toolbar');
|
|
611
|
+
var breadcrumb = document.getElementById('breadcrumb');
|
|
612
|
+
var focusRank = document.getElementById('focus-rank');
|
|
613
|
+
var focusPath = document.getElementById('focus-path');
|
|
614
|
+
var jump = document.querySelector('.jump-toggle');
|
|
615
|
+
var empty = document.getElementById('filter-empty');
|
|
616
|
+
var links = Array.prototype.slice.call(document.querySelectorAll('.toc-link'));
|
|
617
|
+
var cursor = 0;
|
|
618
|
+
var focusIndex = -1;
|
|
619
|
+
var focusWasOpen = true;
|
|
620
|
+
var returnControl = null;
|
|
621
|
+
var returnScroll = 0;
|
|
622
|
+
|
|
623
|
+
function statusesOn() {
|
|
624
|
+
return filters.filter(function (chip) {
|
|
625
|
+
return chip.getAttribute('aria-pressed') === 'true';
|
|
626
|
+
}).map(function (chip) { return chip.getAttribute('data-filter'); });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function visible() {
|
|
630
|
+
var list = [];
|
|
631
|
+
cards.forEach(function (card, index) { if (!card.hidden) list.push(index); });
|
|
632
|
+
return list;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function setCursor(index, move) {
|
|
636
|
+
if (index < 0 || index >= cards.length) return;
|
|
637
|
+
cursor = index;
|
|
638
|
+
cards.forEach(function (card, at) { card.classList.toggle('cursor', at === index); });
|
|
639
|
+
if (!move) return;
|
|
640
|
+
var card = cards[index];
|
|
641
|
+
var summary = card.querySelector('summary');
|
|
642
|
+
if (summary) summary.focus({ preventScroll: true });
|
|
643
|
+
measureToolbar();
|
|
644
|
+
card.scrollIntoView({ block: 'start' });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function apply() {
|
|
648
|
+
var on = statusesOn();
|
|
649
|
+
cards.forEach(function (card, index) {
|
|
650
|
+
card.hidden = focusIndex >= 0 ? index !== focusIndex : on.indexOf(card.getAttribute('data-status')) < 0;
|
|
651
|
+
});
|
|
652
|
+
links.forEach(function (link) {
|
|
653
|
+
var target = document.getElementById(link.hash.slice(1));
|
|
654
|
+
var item = link.parentElement;
|
|
655
|
+
if (target && item) item.hidden = target.hidden;
|
|
656
|
+
});
|
|
657
|
+
filters.forEach(function (chip) { chip.disabled = focusIndex >= 0; });
|
|
658
|
+
document.body.classList.toggle('focus-mode', focusIndex >= 0);
|
|
659
|
+
breadcrumb.hidden = focusIndex < 0;
|
|
660
|
+
var shown = visible();
|
|
661
|
+
if (empty) empty.hidden = shown.length > 0;
|
|
662
|
+
if (!cards[cursor] || cards[cursor].hidden) {
|
|
663
|
+
if (shown.length) setCursor(shown[0], false);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function enterFocus(index, control) {
|
|
668
|
+
if (index < 0 || index >= cards.length || focusIndex >= 0) return;
|
|
669
|
+
var card = cards[index];
|
|
670
|
+
focusIndex = index;
|
|
671
|
+
focusWasOpen = card.open;
|
|
672
|
+
returnControl = control && control.focus ? control : null;
|
|
673
|
+
returnScroll = window.scrollY;
|
|
674
|
+
card.open = true;
|
|
675
|
+
focusRank.textContent = '#' + (index + 1);
|
|
676
|
+
var path = card.querySelector('.path');
|
|
677
|
+
focusPath.textContent = path ? path.textContent : '';
|
|
678
|
+
apply();
|
|
679
|
+
setCursor(index, true);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function exitFocus() {
|
|
683
|
+
if (focusIndex < 0) return;
|
|
684
|
+
var index = focusIndex;
|
|
685
|
+
var card = cards[index];
|
|
686
|
+
var control = returnControl;
|
|
687
|
+
card.open = focusWasOpen;
|
|
688
|
+
focusIndex = -1;
|
|
689
|
+
returnControl = null;
|
|
690
|
+
apply();
|
|
691
|
+
setCursor(index, false);
|
|
692
|
+
window.scrollTo(0, returnScroll);
|
|
693
|
+
if (control && control.isConnected) control.focus({ preventScroll: true });
|
|
694
|
+
else {
|
|
695
|
+
var summary = card.querySelector('summary');
|
|
696
|
+
if (summary) summary.focus({ preventScroll: true });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function setJump(open) {
|
|
701
|
+
if (jump) jump.setAttribute('aria-expanded', String(open));
|
|
702
|
+
if (toolbar) toolbar.setAttribute('data-jump', open ? 'open' : 'closed');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
document.addEventListener('click', function (event) {
|
|
706
|
+
if (!event.target.closest) return;
|
|
707
|
+
var button = event.target.closest('button');
|
|
708
|
+
var card = event.target.closest('.card');
|
|
709
|
+
var at = card ? cards.indexOf(card) : -1;
|
|
710
|
+
if (at >= 0) setCursor(at, false);
|
|
711
|
+
if (button && button.hasAttribute('data-focus')) {
|
|
712
|
+
event.preventDefault();
|
|
713
|
+
if (at === focusIndex) exitFocus();
|
|
714
|
+
else enterFocus(at, button);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (button && button.getAttribute('data-action') === 'back') { exitFocus(); return; }
|
|
718
|
+
if (button && button.getAttribute('data-filter')) {
|
|
719
|
+
button.setAttribute('aria-pressed', String(button.getAttribute('aria-pressed') !== 'true'));
|
|
720
|
+
apply();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
var action = button && button.getAttribute('data-action');
|
|
724
|
+
if (action === 'expand' || action === 'collapse') {
|
|
725
|
+
visible().forEach(function (index) { cards[index].open = action === 'expand'; });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (button && button === jump) { setJump(jump.getAttribute('aria-expanded') !== 'true'); return; }
|
|
729
|
+
// Both the agenda's evidence links and the call flow's "View diff" links
|
|
730
|
+
// land on a hunk, whether or not the current filters would hide it.
|
|
731
|
+
var openHunk = event.target.closest('[data-open-hunk], .toc-link');
|
|
732
|
+
if (openHunk) {
|
|
733
|
+
event.preventDefault();
|
|
734
|
+
revealHunk(openHunk.hash);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
document.addEventListener('focusin', function (event) {
|
|
739
|
+
if (!event.target.closest) return;
|
|
740
|
+
var card = event.target.closest('.card');
|
|
741
|
+
if (card) setCursor(cards.indexOf(card), false);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
document.addEventListener('keydown', function (event) {
|
|
745
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
746
|
+
if (diffView.hidden) return;
|
|
747
|
+
if (!event.target.closest) return;
|
|
748
|
+
if (event.key === 'Escape') {
|
|
749
|
+
if (focusIndex >= 0) { event.preventDefault(); exitFocus(); }
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (event.target.closest('input, textarea, select, [contenteditable]')) return;
|
|
753
|
+
var list = visible();
|
|
754
|
+
if (!list.length) return;
|
|
755
|
+
// j/k and the arrow keys both move the review cursor, per the toolbar hint.
|
|
756
|
+
var step = 0;
|
|
757
|
+
if (!event.shiftKey && (event.key === 'j' || event.key === 'ArrowDown')) step = 1;
|
|
758
|
+
else if (!event.shiftKey && (event.key === 'k' || event.key === 'ArrowUp')) step = -1;
|
|
759
|
+
if (step !== 0) {
|
|
760
|
+
event.preventDefault();
|
|
761
|
+
var at = list.indexOf(cursor);
|
|
762
|
+
var next = at < 0 ? list[0] : list[Math.max(0, Math.min(list.length - 1, at + step))];
|
|
763
|
+
setCursor(next, true);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (event.key === 'Enter') {
|
|
767
|
+
// A focused control keeps its own native activation.
|
|
768
|
+
if (event.target.closest('summary, button, a')) return;
|
|
769
|
+
if (!cards[cursor]) return;
|
|
770
|
+
event.preventDefault();
|
|
771
|
+
cards[cursor].open = !cards[cursor].open;
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (event.key === 'f' && focusIndex < 0 && cards[cursor]) {
|
|
775
|
+
event.preventDefault();
|
|
776
|
+
enterFocus(cursor, cards[cursor].querySelector('[data-focus]'));
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
function measureToolbar() {
|
|
781
|
+
if (!toolbar) return;
|
|
782
|
+
document.documentElement.style.setProperty('--toolbar-height', toolbar.offsetHeight + 'px');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
apply();
|
|
786
|
+
setCursor(cursor, false);
|
|
787
|
+
measureToolbar();
|
|
788
|
+
// A link back into the report opens the hunk it names, in the view that shows
|
|
789
|
+
// it, instead of landing on a closed card in a hidden section.
|
|
790
|
+
if (location.hash && cards.length && !diffView.hidden) revealHunk(location.hash);
|
|
791
|
+
else if (location.hash) {
|
|
792
|
+
var initial = document.getElementById(location.hash.slice(1));
|
|
793
|
+
var initialAt = initial ? cards.indexOf(initial) : -1;
|
|
794
|
+
if (initialAt >= 0) setCursor(initialAt, true);
|
|
795
|
+
}
|
|
796
|
+
// Browser back and forward, and a hash typed or pasted into the address bar,
|
|
797
|
+
// arrive as a hash change rather than a load: route them the same way.
|
|
798
|
+
window.addEventListener('hashchange', function () {
|
|
799
|
+
var name = viewForHash();
|
|
800
|
+
if (name) showView(name);
|
|
801
|
+
if (cards.length && location.hash && !diffView.hidden) revealHunk(location.hash);
|
|
802
|
+
});
|
|
803
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(measureToolbar).observe(toolbar);
|
|
804
|
+
window.addEventListener('resize', measureToolbar);
|
|
805
|
+
}());
|
|
806
|
+
`;
|
|
807
|
+
const STYLES = `
|
|
808
|
+
:root {
|
|
809
|
+
color-scheme: light dark;
|
|
810
|
+
--bg: #ffffff;
|
|
811
|
+
--panel: #ffffff;
|
|
812
|
+
--sunken: #f6f8fa;
|
|
813
|
+
--ink: #1f2328;
|
|
814
|
+
--ink-soft: #59636e;
|
|
815
|
+
--line: #d1d9e0;
|
|
816
|
+
--line-strong: #afb8c1;
|
|
817
|
+
--accent: #59636e;
|
|
818
|
+
--teal: #276b64;
|
|
819
|
+
--teal-bg: #e8f2f1;
|
|
820
|
+
--warn: #805511;
|
|
821
|
+
--warn-bg: #fdf3e2;
|
|
822
|
+
--alarm: #c22e2e;
|
|
823
|
+
--alarm-bg: #fdecec;
|
|
824
|
+
--add: #116329;
|
|
825
|
+
--add-bg: #dafbe1;
|
|
826
|
+
--del: #82071e;
|
|
827
|
+
--del-bg: #ffebe9;
|
|
828
|
+
--cursor: #2f6fae;
|
|
829
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
830
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
831
|
+
}
|
|
832
|
+
@media (prefers-color-scheme: dark) {
|
|
833
|
+
:root {
|
|
834
|
+
--bg: #0d1117;
|
|
835
|
+
--panel: #0d1117;
|
|
836
|
+
--sunken: #161b22;
|
|
837
|
+
--ink: #e6edf3;
|
|
838
|
+
--ink-soft: #9198a1;
|
|
839
|
+
--line: #30363d;
|
|
840
|
+
--line-strong: #6e7681;
|
|
841
|
+
--accent: #9198a1;
|
|
842
|
+
--teal: #6cc5bd;
|
|
843
|
+
--teal-bg: #182827;
|
|
844
|
+
--warn: #e0b372;
|
|
845
|
+
--warn-bg: #2e2418;
|
|
846
|
+
--alarm: #ff8a8a;
|
|
847
|
+
--alarm-bg: #331f1e;
|
|
848
|
+
--add: #aff5b4;
|
|
849
|
+
--add-bg: #123821;
|
|
850
|
+
--del: #ffdcd7;
|
|
851
|
+
--del-bg: #3f1b22;
|
|
852
|
+
--cursor: #7aa7d8;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
856
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
857
|
+
body {
|
|
858
|
+
margin: 0;
|
|
859
|
+
background: var(--bg);
|
|
860
|
+
color: var(--ink);
|
|
861
|
+
font: 400 16px/1.55 var(--sans);
|
|
862
|
+
}
|
|
863
|
+
.wrap { max-width: 1280px; margin: 0 auto; padding: 26px 20px 64px; }
|
|
864
|
+
h1, h2, h3 { margin: 0; line-height: 1.25; }
|
|
865
|
+
p { margin: 0; }
|
|
866
|
+
a { color: var(--teal); }
|
|
867
|
+
.mono, code { font-family: var(--mono); }
|
|
868
|
+
.muted { color: var(--ink-soft); }
|
|
869
|
+
.sr {
|
|
870
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
871
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
872
|
+
}
|
|
873
|
+
.masthead {
|
|
874
|
+
display: flex;
|
|
875
|
+
flex-direction: column;
|
|
876
|
+
gap: 7px;
|
|
877
|
+
padding-bottom: 12px;
|
|
878
|
+
border-bottom: 2px solid var(--line-strong);
|
|
879
|
+
}
|
|
880
|
+
.brand {
|
|
881
|
+
display: flex;
|
|
882
|
+
align-items: center;
|
|
883
|
+
gap: 9px;
|
|
884
|
+
font-size: 12px;
|
|
885
|
+
font-weight: 700;
|
|
886
|
+
letter-spacing: 0.18em;
|
|
887
|
+
text-transform: uppercase;
|
|
888
|
+
color: var(--accent);
|
|
889
|
+
}
|
|
890
|
+
${BRAND_MARK_STYLES}
|
|
891
|
+
h1 { font-size: clamp(1.35rem, 1.05rem + 1.3vw, 1.9rem); overflow-wrap: anywhere; }
|
|
892
|
+
.meta { font-size: 13.5px; color: var(--ink-soft); overflow-wrap: anywhere; }
|
|
893
|
+
.mode-note { font-size: 12.5px; color: var(--ink-soft); }
|
|
894
|
+
.legend {
|
|
895
|
+
display: flex;
|
|
896
|
+
flex-wrap: wrap;
|
|
897
|
+
gap: 4px 16px;
|
|
898
|
+
font-size: 12px;
|
|
899
|
+
color: var(--ink-soft);
|
|
900
|
+
}
|
|
901
|
+
.key { display: inline-flex; align-items: center; gap: 6px; }
|
|
902
|
+
.dot {
|
|
903
|
+
width: 9px;
|
|
904
|
+
height: 9px;
|
|
905
|
+
border-radius: 999px;
|
|
906
|
+
flex: 0 0 auto;
|
|
907
|
+
border: 1px solid transparent;
|
|
908
|
+
background: var(--line-strong);
|
|
909
|
+
}
|
|
910
|
+
.dot-attention { background: var(--alarm); }
|
|
911
|
+
.dot-uncertain { background: var(--warn); }
|
|
912
|
+
.dot-low { background: var(--line-strong); }
|
|
913
|
+
.dot-passed { background: var(--teal); }
|
|
914
|
+
.view-switch { display: flex; gap: 4px; margin: 14px 0; border-bottom: 1px solid var(--line); }
|
|
915
|
+
.view-switch a { color: var(--ink-soft); padding: 8px 16px; text-decoration: none; border-bottom: 2px solid transparent; }
|
|
916
|
+
.view-switch a:hover, .view-switch a:focus-visible { color: var(--ink); }
|
|
917
|
+
.js .view-switch [aria-current] { color: var(--ink); border-bottom-color: var(--cursor); font-weight: 600; }
|
|
918
|
+
.toolbar {
|
|
919
|
+
position: sticky; top: 0; z-index: 5; background: var(--bg);
|
|
920
|
+
border-bottom: 1px solid var(--line); padding: 10px 0; margin-bottom: 14px;
|
|
921
|
+
}
|
|
922
|
+
[hidden], .enhanced { display: none !important; }
|
|
923
|
+
.js .enhanced { display: inline-flex !important; }
|
|
924
|
+
.js .fallback-rank { display: none; }
|
|
925
|
+
.js .jump-toggle { display: none !important; }
|
|
926
|
+
.controls, .filters { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
927
|
+
.controls { width: 100%; }
|
|
928
|
+
button { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 6px; padding: 5px 10px; cursor: pointer; }
|
|
929
|
+
button:hover { border-color: var(--teal); }
|
|
930
|
+
button:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--cursor); outline-offset: 2px; }
|
|
931
|
+
button:disabled { cursor: default; opacity: .65; }
|
|
932
|
+
.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 11px; font-size: 12px; }
|
|
933
|
+
.pill-n { font-size: 11px; color: var(--ink-soft); }
|
|
934
|
+
[data-filter][aria-pressed="false"] { opacity: .5; text-decoration: line-through; }
|
|
935
|
+
.keyboard-help { font-size: 12px; color: var(--ink-soft); }
|
|
936
|
+
#breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding-top: 6px; overflow-wrap: anywhere; }
|
|
937
|
+
.crumb-sep { color: var(--ink-soft); }
|
|
938
|
+
.crumb-path { overflow-wrap: anywhere; }
|
|
939
|
+
.toc { padding-top: 10px; }
|
|
940
|
+
.toc-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; max-height: 22vh; overflow-y: auto; }
|
|
941
|
+
.toc-list > li { min-width: 0; max-width: 100%; }
|
|
942
|
+
.agent-order { margin: 12px 0; border: 1px solid var(--line); border-radius: 8px; }
|
|
943
|
+
.agent-order-by { margin: 10px 14px; font-weight: 600; }
|
|
944
|
+
.agent-order summary { padding: 0 14px 10px; cursor: pointer; }
|
|
945
|
+
.agent-order-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 14px 12px; padding: 0; list-style: none; }
|
|
946
|
+
.agent-order-list > li { min-width: 0; max-width: 100%; }
|
|
947
|
+
.toc-link {
|
|
948
|
+
display: flex;
|
|
949
|
+
align-items: center;
|
|
950
|
+
gap: 8px;
|
|
951
|
+
max-width: 100%;
|
|
952
|
+
min-width: 0;
|
|
953
|
+
border: 1px solid var(--line-strong);
|
|
954
|
+
border-radius: 999px;
|
|
955
|
+
padding: 5px 11px 5px 9px;
|
|
956
|
+
text-decoration: none;
|
|
957
|
+
color: var(--ink);
|
|
958
|
+
background: var(--panel);
|
|
959
|
+
font-size: 12.5px;
|
|
960
|
+
}
|
|
961
|
+
.toc-link:hover, .toc-link:focus-visible { border-color: var(--cursor); }
|
|
962
|
+
.toc-rank { font-weight: 700; color: var(--ink-soft); }
|
|
963
|
+
.toc-path { min-width: 0; max-width: 26ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
964
|
+
.js .card.cursor { outline: 2px solid var(--cursor); outline-offset: 3px; }
|
|
965
|
+
.card { scroll-margin-top: calc(var(--toolbar-height, 0px) + 14px); }
|
|
966
|
+
.focus-mode .wrap { max-width: none; }
|
|
967
|
+
.focus-mode .masthead, .focus-mode .foot { display: none; }
|
|
968
|
+
.focus-mode .controls, .focus-mode .toc { display: none !important; }
|
|
969
|
+
.cards { margin: 0; }
|
|
970
|
+
.card {
|
|
971
|
+
background: var(--panel);
|
|
972
|
+
border: 1px solid var(--line);
|
|
973
|
+
border-left: 5px solid var(--line-strong);
|
|
974
|
+
border-radius: 6px;
|
|
975
|
+
margin: 0 0 12px;
|
|
976
|
+
overflow: hidden;
|
|
977
|
+
}
|
|
978
|
+
.card-attention { border-left-color: var(--alarm); }
|
|
979
|
+
.card-uncertain { border-left-color: var(--warn); }
|
|
980
|
+
.card-low { border-left-color: var(--line-strong); }
|
|
981
|
+
.card-passed { border-left-color: var(--teal); }
|
|
982
|
+
.card > summary {
|
|
983
|
+
cursor: pointer;
|
|
984
|
+
list-style: none;
|
|
985
|
+
display: flex;
|
|
986
|
+
align-items: center;
|
|
987
|
+
flex-wrap: wrap;
|
|
988
|
+
gap: 10px;
|
|
989
|
+
padding: 10px 14px;
|
|
990
|
+
background: var(--sunken);
|
|
991
|
+
}
|
|
992
|
+
.card > summary::-webkit-details-marker { display: none; }
|
|
993
|
+
.card > summary::after {
|
|
994
|
+
content: "\\25B8";
|
|
995
|
+
margin-left: auto;
|
|
996
|
+
font-size: 11px;
|
|
997
|
+
color: var(--ink-soft);
|
|
998
|
+
transition: transform 0.15s ease;
|
|
999
|
+
}
|
|
1000
|
+
.card[open] > summary::after { transform: rotate(90deg); }
|
|
1001
|
+
.card > summary:hover { filter: brightness(0.98); }
|
|
1002
|
+
.card > summary:focus-visible { outline: 2px solid var(--cursor); outline-offset: -2px; }
|
|
1003
|
+
.rank {
|
|
1004
|
+
font-size: 12px;
|
|
1005
|
+
font-weight: 700;
|
|
1006
|
+
color: var(--ink-soft);
|
|
1007
|
+
background: var(--panel);
|
|
1008
|
+
border: 1px solid var(--line);
|
|
1009
|
+
border-radius: 8px;
|
|
1010
|
+
padding: 2px 7px;
|
|
1011
|
+
}
|
|
1012
|
+
.path { flex: 1 1 240px; min-width: 0; font-size: 13.5px; font-weight: 600; overflow-wrap: anywhere; }
|
|
1013
|
+
.growth { font-size: 12.5px; color: var(--ink-soft); white-space: nowrap; }
|
|
1014
|
+
.growth .plus { color: var(--add); }
|
|
1015
|
+
.growth .minus { color: var(--del); }
|
|
1016
|
+
.focus-button { font-size: 12px; padding: 3px 8px; }
|
|
1017
|
+
.body { border-top: 1px solid var(--line); padding: 0 0 2px; }
|
|
1018
|
+
.note { margin: 12px 14px; font-size: 13px; color: var(--ink-soft); overflow-wrap: anywhere; }
|
|
1019
|
+
.obs { margin: 10px 14px; }
|
|
1020
|
+
.obs > summary { cursor: pointer; font-size: 12.5px; color: var(--ink-soft); }
|
|
1021
|
+
.obs-body { border: 1px dashed var(--line-strong); border-radius: 6px; padding: 8px 10px; margin-top: 6px; }
|
|
1022
|
+
.verdicts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 8px 16px 4px; }
|
|
1023
|
+
.verdicts-by { font-size: 12.5px; color: var(--ink-soft); margin-right: 2px; }
|
|
1024
|
+
.verdict { font-size: 12.5px; padding: 1px 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); }
|
|
1025
|
+
.verdict-ok { border-color: var(--teal); color: var(--teal); }
|
|
1026
|
+
.verdict-watch { border-color: var(--warn, #a2701f); color: var(--warn, #a2701f); font-weight: 600; }
|
|
1027
|
+
.verdict-unsure { border-style: dashed; color: var(--ink-soft); }
|
|
1028
|
+
.obs-body .note { margin: 0 0 6px; font-size: 12.5px; }
|
|
1029
|
+
.obs-note { font-style: italic; }
|
|
1030
|
+
.obs-line { font-family: var(--sans); color: var(--ink-soft); margin-left: 6px; }
|
|
1031
|
+
.obs-line code { font-family: var(--mono); color: var(--ink); }
|
|
1032
|
+
.obs-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 12px; margin: 0; }
|
|
1033
|
+
.obs-list dt { font-size: 12.5px; color: var(--ink-soft); }
|
|
1034
|
+
.obs-list dd { margin: 0; font-size: 12.5px; overflow-wrap: anywhere; }
|
|
1035
|
+
.diff-wrap {
|
|
1036
|
+
border-top: 1px solid var(--line);
|
|
1037
|
+
background: var(--panel);
|
|
1038
|
+
overflow-x: auto;
|
|
1039
|
+
overscroll-behavior-x: contain;
|
|
1040
|
+
}
|
|
1041
|
+
pre.diff {
|
|
1042
|
+
display: grid;
|
|
1043
|
+
grid-template-columns: max-content max-content minmax(min-content, 1fr);
|
|
1044
|
+
margin: 0;
|
|
1045
|
+
padding: 6px 0;
|
|
1046
|
+
min-width: max-content;
|
|
1047
|
+
font-family: var(--mono);
|
|
1048
|
+
font-size: 12.5px;
|
|
1049
|
+
line-height: 1.6;
|
|
1050
|
+
tab-size: 4;
|
|
1051
|
+
white-space: pre;
|
|
1052
|
+
}
|
|
1053
|
+
.ln { display: contents; }
|
|
1054
|
+
.old-no, .new-no {
|
|
1055
|
+
text-align: right;
|
|
1056
|
+
padding: 0 8px 0 10px;
|
|
1057
|
+
color: var(--ink-soft);
|
|
1058
|
+
background: var(--sunken);
|
|
1059
|
+
border-right: 1px solid var(--line);
|
|
1060
|
+
user-select: none;
|
|
1061
|
+
}
|
|
1062
|
+
.code { padding: 0 14px; }
|
|
1063
|
+
.ln-add > .code { color: var(--add); }
|
|
1064
|
+
.ln-del > .code { color: var(--del); }
|
|
1065
|
+
.ln-add > .code, .ln-add > .old-no, .ln-add > .new-no { background: var(--add-bg); }
|
|
1066
|
+
.ln-del > .code, .ln-del > .old-no, .ln-del > .new-no { background: var(--del-bg); }
|
|
1067
|
+
.ln-hunk > .code { color: var(--ink-soft); font-weight: 600; }
|
|
1068
|
+
.ln-hunk > span { background: var(--sunken); }
|
|
1069
|
+
.card-attention .ln-hunk > span { background: var(--alarm-bg); }
|
|
1070
|
+
.card-uncertain .ln-hunk > span { background: var(--warn-bg); }
|
|
1071
|
+
.card-passed .ln-hunk > span { background: var(--teal-bg); }
|
|
1072
|
+
.ln-meta > .code { color: var(--ink-soft); }
|
|
1073
|
+
.ln-note > .code { color: var(--ink-soft); font-style: italic; }
|
|
1074
|
+
.empty { padding: 18px 0; color: var(--ink-soft); }
|
|
1075
|
+
.foot { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--ink-soft); }
|
|
1076
|
+
.foot p { margin: 4px 0; overflow-wrap: anywhere; }
|
|
1077
|
+
@media (max-width: 680px) {
|
|
1078
|
+
.wrap { padding: 18px 13px 48px; }
|
|
1079
|
+
.js .jump-toggle { display: inline-flex !important; }
|
|
1080
|
+
.js .toc-list { display: none; }
|
|
1081
|
+
.js .toolbar[data-jump="open"] .toc-list { display: flex; }
|
|
1082
|
+
.toc-list { margin-top: 8px; max-height: 40vh; overflow-y: auto; }
|
|
1083
|
+
.toc-list > li { width: 100%; }
|
|
1084
|
+
.keyboard-help { display: none; }
|
|
1085
|
+
.toc-link { width: 100%; }
|
|
1086
|
+
.toc-path { max-width: none; flex: 1 1 auto; }
|
|
1087
|
+
.card > summary { gap: 8px; padding: 10px 12px; }
|
|
1088
|
+
.growth { flex: 0 0 auto; }
|
|
1089
|
+
pre.diff { font-size: 12px; }
|
|
1090
|
+
.code { padding: 0 12px; }
|
|
1091
|
+
}
|
|
1092
|
+
`;
|
|
1093
|
+
function sum(values) {
|
|
1094
|
+
let total = 0;
|
|
1095
|
+
for (const value of values) {
|
|
1096
|
+
if (Number.isFinite(value)) {
|
|
1097
|
+
total += value;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return total;
|
|
1101
|
+
}
|
|
1102
|
+
function formatInteger(value) {
|
|
1103
|
+
return Number.isFinite(value) ? String(Math.round(value)) : "n/a";
|
|
1104
|
+
}
|