@analitikisler/ai-tag-debugger 0.15.0 → 0.17.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/src/report.js CHANGED
@@ -1,13 +1,17 @@
1
- import { consentTable } from "./consent.js";
1
+ import { AD_PLATFORMS, consentTable } from "./consent.js";
2
2
  import { OWNERS, ownerOf, verifyLinks } from "./verify.js";
3
3
  import { createProvider } from "./providers.js";
4
- import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
4
+ import { canonicalEvent, groupFindings, hitMatchesEvent, summarize } from "./checks.js";
5
5
  import { messages } from "./i18n.js";
6
6
  import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
7
7
  import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
8
8
  import { buildInventory } from "./inventory.js";
9
9
  import { LOGOS } from "./brand.js";
10
+ import { brandCss } from "./whitelabel.js";
11
+ import { computeMetrics } from "./metrics.js";
12
+ import { historySummaryLines, sparkline } from "./history.js";
10
13
  import { readFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
11
15
 
12
16
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
17
 
@@ -17,7 +21,7 @@ For each finding with severity broken, risk or warning, give:
17
21
  - impact: one sentence on what this does to the data, the reports or ad spend;
18
22
  - likely_cause: the most likely cause, in plain language a marketing manager can follow and an analyst can act on;
19
23
  - fix_steps: the fix as short, ordered steps.
20
- When a GTM container is provided, name the specific tags, triggers or variables involved. Only state causes the evidence supports; when the cause is uncertain, say what to check.
24
+ When a GTM container is provided, name the specific tags, triggers or variables involved. Only state causes the evidence supports; when the cause is uncertain, say what to check. When a finding's gtm[].evaluation.why is present, it is a deterministic evaluation of that tag's triggers and consent against the captured dataLayer: rely on it for the likely cause instead of guessing.
21
25
 
22
26
  Then write the summary:
23
27
  - headline: one sentence naming what matters most for data quality and ad spend;
@@ -95,7 +99,7 @@ function hitsByStep(run) {
95
99
  * deterministic report is still written.
96
100
  */
97
101
  export async function explainFindings({ findings, plan, runs, gtmContainer, provider, providerName, client, model, lang = "en", log = () => {} }) {
98
- const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity));
102
+ const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity) && !f.accepted);
99
103
  if (actionable.length === 0) return { summary: { headline: messages(lang).ui.allGood, points: [], also_check: [] }, explanations: [] };
100
104
  try {
101
105
  const input = {
@@ -127,7 +131,11 @@ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "
127
131
 
128
132
  function attachExplanations(findings, explained) {
129
133
  const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
130
- return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i) }));
134
+ // A finding a baseline accepted carries `accepted: true`; the grouped card is accepted when every place it covers is.
135
+ const key = (f) => [f.severity, f.check, f.platform, f.event, f.detail].join("|");
136
+ const accepted = new Map();
137
+ for (const f of findings) if (f.severity !== "ok") accepted.set(key(f), (accepted.get(key(f)) ?? true) && f.accepted === true);
138
+ return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i), ...(accepted.get(key(f)) ? { accepted: true } : {}) }));
131
139
  }
132
140
 
133
141
  const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
@@ -146,7 +154,6 @@ function hitSeverity(findings, run, step, hit) {
146
154
 
147
155
  const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
148
156
  const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
149
- const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) => JSON.stringify(v)).join(", ")})` : JSON.stringify(value));
150
157
 
151
158
  // Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
152
159
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -154,62 +161,369 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
154
161
 
155
162
  const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
156
163
 
157
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
158
- const m = messages(lang);
159
- const rows = attachExplanations(findings, explained);
160
- const counts = summarize(rows);
161
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), ...(planChanges ? [mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : [])] : []), ...(changes?.routes?.length ? [mdText(m.ui.routeChanged(routeList(changes))), ""] : []), `**${m.ui.counts(counts)}**`, ""];
162
- if (changes) lines.push(...changesMd(m, changes));
163
- if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
164
- lines.push(...inventoryMd(m, runs));
165
- const sum = summaryParts(explained?.summary);
166
- if (sum) {
167
- lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
168
- if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
169
- if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
164
+ // ---------- The Markdown report (docs/design/md-report-spec.md) ----------
165
+ // A verdict first, evidence second, never a dump: the file goes into pull request comments, Slack
166
+ // previews and mails, so it stays around 120 lines and the per-hit timeline lives in timeline.csv.
167
+
168
+ const MD_BLOCKS = 5; // full finding blocks; findings beyond that are one line each
169
+ const MD_EVIDENCE = 200; // characters of raw evidence per finding
170
+ const MD_INVENTORY_ROWS = 10;
171
+ const MD_FLAGGED = 5;
172
+ const MD_PLACES = 3;
173
+
174
+ /** A finding's detail with quoted names as code spans, as the HTML shows it. */
175
+ const mdDetail = (s) => mdText(s).replace(/"([^"]+?)"/g, (_, x) => `\`${x.replace(/`/g, "'")}\``);
176
+ const mdClip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
177
+ /** The path of a page address; the host is the site's and would repeat on every line. */
178
+ const mdPath = (url) => {
179
+ try {
180
+ const u = new URL(url);
181
+ return u.pathname + u.search;
182
+ } catch {
183
+ return String(url ?? "");
170
184
  }
171
- if (consentScenarios) {
172
- const { cols, rows: tagRows } = consentTable(consentScenarios);
173
- lines.push(`## ${m.ui.scenarios}`, "", mdText(m.ui.scenariosLead(consentScenarios.cmp)), "");
174
- lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
175
- for (const r of tagRows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => (c.count === null ? "–" : c.problem ? `**${c.count}** ⚠` : String(c.count))).join(" | ")} |`);
176
- lines.push("");
185
+ };
186
+ const isProblem = (f) => f.severity in SEVERITY_RANK;
187
+ const runFor = (runs, w) => runs.find((r) => r.journeyId === w.journey && r.viewport === w.viewport) ?? null;
188
+ const placeKey = (w) => `${w.journey}|${w.viewport}`;
189
+ const uniqPlaces = (where) => [...new Map(where.map((w) => [placeKey(w), w])).values()];
190
+ const mdPlace = (w) => `${mdText(w.journey)} · ${mdText(w.viewport)}`;
191
+ /** "GA4 `purchase`", or the check name for a finding without an event. */
192
+ const mdTitle = (f) => [f.platform ? mdText(platformLabel(f.platform)) : "", f.event ? `\`${mdCode(f.event)}\`` : ""].filter(Boolean).join(" ") || mdText(f.check);
193
+ const mdAnchor = (f) => ["f", f.platform, f.event, f.check].filter(Boolean).map(safeKey).join("-");
194
+
195
+ /**
196
+ * A text from one of the per-check tables (consequence, fix, verdict headline) that other parts of
197
+ * i18n.js provide: a string as is, a function called with the grouped finding (or the verdict
198
+ * context), nothing when the table or the entry is not there. Never throws, so a missing table only
199
+ * costs the line.
200
+ */
201
+ function mdTemplate(table, key, arg) {
202
+ const v = table?.[key];
203
+ if (typeof v === "string") return v;
204
+ if (typeof v !== "function") return null;
205
+ try {
206
+ const out = v(arg);
207
+ return typeof out === "string" && out ? out : null;
208
+ } catch {
209
+ return null;
177
210
  }
178
- lines.push(`## ${m.ui.findings}`, "");
179
- for (const f of rows.filter((r) => r.severity !== "ok")) {
180
- lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
181
- lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
182
- lines.push(`- ${m.ui.owners[ownerOf(f)]}`);
183
- const vl = verifyLinks(f);
184
- if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
185
- if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
186
- if (f.explanation) {
187
- if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
188
- lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
189
- }
211
+ }
212
+
213
+ /** "checkout · mobile", "all 4 journeys", or the first three places and "+n more". */
214
+ function mdPlaces(m, runs, where) {
215
+ const places = uniqPlaces(where);
216
+ if (runs.length > 1 && places.length === runs.length) return m.ui.md.allJourneys(runs.length);
217
+ const shown = places.slice(0, MD_PLACES).map(mdPlace);
218
+ return places.length > MD_PLACES ? `${shown.join(", ")}, ${m.ui.md.more(places.length - MD_PLACES)}` : shown.join(", ");
219
+ }
220
+
221
+ /** The step a finding points at, from the first place that names one: { index, label, path, at }. */
222
+ function mdStep(runs, where) {
223
+ for (const w of where) {
224
+ if (!w.step) continue;
225
+ const run = runFor(runs, w);
226
+ const step = run?.steps?.find((s) => s.label === w.step);
227
+ if (!step) return { index: null, label: w.step, path: null, at: null };
228
+ return { index: step.index, label: step.label, path: step.pagePath ? mdPath(step.pagePath) : null, at: typeof run.startedAt === "number" && typeof step.t === "number" ? seconds(run, step.t) : null };
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /** "checkout · mobile · step 11 "Place the order" · /thanks.html · +6.7s"; the short form stops at the step number. */
234
+ function mdWhere(m, runs, where, { full = true } = {}) {
235
+ const parts = [mdPlaces(m, runs, where)];
236
+ const step = mdStep(runs, where);
237
+ if (step) {
238
+ parts.push(step.index === null ? `"${mdText(step.label)}"` : `${m.ui.md.step(step.index)}${full ? ` "${mdText(step.label)}"` : ""}`);
239
+ if (full && step.path) parts.push(mdText(step.path));
240
+ if (full && step.at) parts.push(step.at);
241
+ }
242
+ return parts.join(" · ");
243
+ }
244
+
245
+ /** Completed runs where the same event fired and this check did not flag it: the contrast a reader needs ("desktop sent both"). */
246
+ function mdAlsoFineOn(m, runs, f) {
247
+ if (!f.event || !f.platform) return "";
248
+ const flagged = new Set(f.where.map(placeKey));
249
+ const fine = runs.filter((r) => r.completed && !flagged.has(`${r.journeyId}|${r.viewport}`) && (r.hits ?? []).some((h) => h.platform === f.platform && canonicalEvent(h.name) === canonicalEvent(f.event)));
250
+ return fine.length ? mdPlaces(m, runs, fine.map((r) => ({ journey: r.journeyId, viewport: r.viewport }))) : "";
251
+ }
252
+
253
+ /** The problem the verdict is about: the one that costs the score most, else the most severe and widespread. */
254
+ function mdLead(rows, metrics) {
255
+ const top = metrics.score?.deductions?.[0];
256
+ const problems = rows.filter(isProblem);
257
+ return (top && problems.find((f) => f.check === top.check && f.severity === top.severity && (f.platform ?? null) === top.platform && (f.event ?? null) === top.event)) ?? problems[0] ?? null;
258
+ }
259
+
260
+ /** " on mobile" when the lead problem is on one viewport of several, " on checkout" for one journey of several, " on every journey" when it is everywhere. */
261
+ function mdScope(m, runs, lead) {
262
+ if (!lead) return "";
263
+ const places = uniqPlaces(lead.where);
264
+ if (runs.length > 1 && places.length === runs.length) return m.ui.md.everywhere;
265
+ const viewports = new Set(places.map((w) => w.viewport));
266
+ const journeys = new Set(places.map((w) => w.journey));
267
+ if (viewports.size === 1 && new Set(runs.map((r) => r.viewport)).size > 1) return m.ui.md.onViewport(String([...viewports][0]));
268
+ if (journeys.size === 1 && new Set(runs.map((r) => r.journeyId)).size > 1) return m.ui.md.onJourney(String([...journeys][0]));
269
+ return "";
270
+ }
271
+
272
+ /**
273
+ * The verdict sentence for the title, with no model, keyed the same way as metrics.verdict.headline. It names
274
+ * the lead problem and where it is ("purchase tracking is broken on mobile"): the counts sit next to the
275
+ * headline on every surface, so the count-based sentences in `verdict.headline` are left to the HTML verdict block.
276
+ */
277
+ function mdHeadline(m, runs, rows, metrics) {
278
+ const v = metrics.verdict;
279
+ const lead = mdLead(rows, metrics);
280
+ const what = lead ? (lead.event ? m.ui.md.tracking(String(lead.event)) : m.ui.md.setup(String(lead.check).replace(/_/g, " "))) : "";
281
+ const scope = mdScope(m, runs, lead);
282
+ const incomplete = runs.find((r) => !r.completed);
283
+ const ctx = { key: v.headline, what, scope, journey: incomplete ? String(incomplete.journeyId) : "", events: metrics.coverage?.checkable ?? 0, journeys: runs.length, counts: v.counts, lead };
284
+ const fallback = m.ui.md.headline[v.headline] ?? m.ui.md.headline.attention_nothing_tested;
285
+ if (v.headline === "fail_incomplete") return fallback(ctx.journey);
286
+ if (v.headline === "pass") return fallback(ctx.events, ctx.journeys);
287
+ if (v.headline === "attention_no_hits" || v.headline === "attention_nothing_tested") return fallback();
288
+ return fallback(what, scope);
289
+ }
290
+
291
+ /** "Changed since last run" in six lines at most: the headline, fixed, new, still open, and the route changes. */
292
+ function mdChanges(m, ch, rows) {
293
+ if (!ch?.previous) return [];
294
+ const lines = [`## ${m.ui.changesTitle}`, "", `**${mdText(m.ui.changesLine(ch))}** · ${mdText(m.ui.changesLead(ch.previous, ch.mode))}`, ""];
295
+ const why = (c, bucket) => (ch.mode === "findings" ? mdDetail(c.detail) : mdText(m.ui.bucket[bucket]));
296
+ if (ch.fixed.length) lines.push(`- ${m.ui.changeFixed}: ${ch.fixed.map((c) => `${mdText(changeLabel(c))} (${m.ui.history.fixedWas}: ${why(c, c.was)})`).join("; ")}`);
297
+ if (ch.new.length) lines.push(`- ${m.ui.changeNew}: ${ch.new.map((c) => `${mdText(changeLabel(c))} (${why(c, c.bucket)})`).join("; ")}`);
298
+ // Still open: this run's problems that the diff did not list as new.
299
+ const key = (c) => (ch.mode === "plan" ? `${c.platform ?? ""}|${c.event ?? ""}` : `${c.check ?? ""}|${c.platform ?? ""}|${c.event ?? ""}`);
300
+ const fresh = new Set(ch.new.map(key));
301
+ const open = rows.filter((f) => isProblem(f) && !fresh.has(key(f)));
302
+ if (open.length) lines.push(`- ${m.ui.md.stillOpen}: ${open.slice(0, MD_PLACES).map(mdTitle).join(", ")}${open.length > MD_PLACES ? `, ${m.ui.md.more(open.length - MD_PLACES)}` : ""}`);
303
+ if (ch.routes?.length) lines.push(`- ${mdText(m.ui.routeChanged(routeList(ch)))}`);
304
+ lines.push("");
305
+ return lines;
306
+ }
307
+
308
+ /** The to-do list per owner, the owner that recovers the most points first; owners with nothing get one line. */
309
+ function mdPriorities(m, runs, metrics) {
310
+ const lines = [`## ${m.ui.md.priorities}`, ""];
311
+ const idle = [];
312
+ for (const owner of Object.keys(m.ui.owners)) {
313
+ const g = metrics.priorities.find((p) => p.owner === owner);
314
+ if (!g) idle.push(owner);
315
+ }
316
+ for (const g of metrics.priorities) {
317
+ lines.push(`### ${m.ui.owners[g.owner] ?? mdText(g.owner)}`);
318
+ g.top.forEach((f, i) => lines.push(`${i + 1}. **${m.severity[f.severity]}** ${mdDetail(f.detail).replace(/\.$/, "")} · ${mdWhere(m, runs, f.where, { full: false })}`));
319
+ if (g.total > g.top.length) lines.push(`${m.ui.md.more(g.total - g.top.length)}`);
190
320
  lines.push("");
191
321
  }
192
- for (const f of rows.filter((r) => r.severity === "ok")) lines.push(`- ${m.severity.ok}, ${mdText(f.where[0].journey)} (${mdText(f.where[0].viewport)}): ${mdText(f.detail)}`);
193
- lines.push("", `## ${m.ui.timeline}`, "");
322
+ if (idle.length) lines.push(...idle.map((o) => m.ui.md.nothingToDo(m.ui.owners[o])), "");
323
+ return lines;
324
+ }
325
+
326
+ /** One finding as the fixed field list; the AI's impact, cause and fix steps replace the per-check templates when it ran. */
327
+ function mdFinding(m, runs, f) {
328
+ const e = f.explanation;
329
+ const lines = [`### ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${mdDetail(f.detail)}`, `- ${m.ui.where}: ${mdWhere(m, runs, f.where)}`];
330
+ const impact = e?.impact ?? mdTemplate(m.consequence, f.check, f);
331
+ if (impact) lines.push(`- ${m.ui.impact}: ${mdText(impact)}`);
332
+ if (e?.likely_cause) lines.push(`- ${m.ui.likelyCause}: ${mdText(e.likely_cause)}`);
333
+ // Evidence is the hit's parameters as JSON; "{}" is a hit that carried none, and no evidence at all means there was no hit.
334
+ if (f.evidence === "{}") lines.push(`- ${m.ui.evidence}: ${mdText(m.ui.md.noParams)}`);
335
+ else if (f.evidence) lines.push(`- ${m.ui.evidence}: \`${mdClip(mdCode(f.evidence), MD_EVIDENCE)}\``);
336
+ const fine = mdAlsoFineOn(m, runs, f);
337
+ if (fine) lines.push(`- ${m.ui.md.alsoFineOn}: ${fine}`);
338
+ lines.push(`- ${m.ui.md.owner}: ${m.ui.owners[ownerOf(f)]}`);
339
+ if (f.gtm) lines.push(`- ${m.ui.gtmTags}: ${f.gtm.length ? f.gtm.map((t) => `${mdText(t.name)}${t.paused ? ` (${m.ui.gtmPaused})` : ""}, ${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map((x) => mdText(x.name)).join(", ")}` : m.ui.gtmNoTrigger}`).join("; ") : m.ui.gtmNoTag}`);
340
+ for (const t of (f.gtm ?? []).filter((x) => x.evaluation)) lines.push(`- ${mdText(m.ui.gtmWhy(t.name, m.ui.gtmVerdict[t.evaluation.fired], t.evaluation.why))}`);
341
+ const steps = fixSteps(e);
342
+ const fix = steps.length ? steps.map((t, i) => `${i + 1}. ${mdText(t)}`).join("; ") : mdText(mdTemplate(m.fix, f.check, f) ?? "");
343
+ if (fix) lines.push(`- ${m.ui.fix}: ${fix}`);
344
+ const vl = verifyLinks(f);
345
+ if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
346
+ lines.push(`- ${m.ui.md.link}: report.html#${mdAnchor(f)}`, "");
347
+ return lines;
348
+ }
349
+
350
+ /** One consent-scenario cell with its reason: the count, ✓ or ✗, and why ("cookieless", "before consent", "should not send"). */
351
+ function mdCell(m, col, cell, raw, adPlatform) {
352
+ const c = m.ui.md.cell;
353
+ if (cell.count === null) return c.notRun;
354
+ const n = cell.count;
355
+ if (cell.problem) return `**${n}** ${c.bad} ${c.shouldNotSend}`;
356
+ if (!n) return "0";
357
+ if (col === "before") return raw.cookieless >= n ? `${n} · ${c.cookieless} ${c.ok}` : `${n} ${c.bad} ${c.beforeConsent}`;
358
+ if (col === "reject") return `${n} · ${c.cookieless} ${c.ok}`;
359
+ if (col === "analytics" && adPlatform && raw.adDenied >= n) return `${n} · ${c.adDenied} ${c.ok}`;
360
+ return `${n} ${c.ok}`;
361
+ }
362
+
363
+ /** The consent section: the Consent Mode timing sentence, then the scenario table with a reason in every cell. */
364
+ function mdConsent(m, metrics, scenarios) {
365
+ const lines = [`## ${m.ui.md.consent}`, ""];
366
+ const cm = metrics.consent?.consentMode;
367
+ const tested = Boolean(cm?.runs?.length);
368
+ if (tested) {
369
+ const present = cm.runs.filter((d) => d.present).length;
370
+ lines.push(present ? m.ui.md.consentMode(present, cm.runs.length, cm.defaultOnTime, cm.v2Signals.present) : m.ui.md.consentModeNone, "");
371
+ }
372
+ if (scenarios) {
373
+ const { cols, rows } = consentTable(scenarios);
374
+ const rawOf = (col, platform, event) => (col === "before" ? scenarios.before?.[platform]?.[event] : scenarios.states?.[col]?.after?.[platform]?.[event]) ?? { n: 0, cookieless: 0, adDenied: 0 };
375
+ lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
376
+ for (const r of rows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => mdCell(m, c.state, c, rawOf(c.state, r.platform, r.event), AD_PLATFORMS.has(r.platform))).join(" | ")} |`);
377
+ lines.push("", m.ui.md.consentLegend, "");
378
+ } else if (!tested) lines.push(m.ui.md.consentNotChecked, "");
379
+ return lines;
380
+ }
381
+
382
+ /** What was verified, not only what failed: the ok checks, plan coverage, the purchases that pass, and personal data. */
383
+ function mdPassed(m, runs, findings, metrics, planCheck) {
384
+ const items = [];
385
+ const ok = new Map();
386
+ for (const f of findings.filter((x) => x.severity === "ok")) {
387
+ if (!ok.has(f.check)) ok.set(f.check, { detail: f.detail, where: [] });
388
+ ok.get(f.check).where.push({ journey: f.journey, viewport: f.viewport });
389
+ }
390
+ for (const o of ok.values()) items.push(`${mdDetail(o.detail).replace(/\.$/, "")} · ${mdPlaces(m, runs, o.where)}`);
391
+ if (metrics.coverage && !planCheck) items.push(m.ui.md.coverage(metrics.coverage.counts.implemented, metrics.coverage.checkable, metrics.coverage.percent.implemented));
392
+ for (const r of (metrics.conversions?.rows ?? []).filter((x) => x.pass).slice(0, 3)) items.push(m.ui.md.purchaseOk(mdText(platformLabel(r.platform)), mdPlace({ journey: r.journey, viewport: r.viewport }), mdText(r.value), mdText(r.currency), r.transactionId ? mdText(r.transactionId) : ""));
393
+ for (const p of metrics.parity ?? []) {
394
+ // Two platforms "agree" only when both reported a value; two purchases without one agree on nothing.
395
+ const purchase = p.shared.find((s) => s.event === "purchase" && !s.mismatch.length && Object.values(s.platforms).every((x) => x.value !== null));
396
+ if (purchase) items.push(m.ui.md.parityOk(Object.keys(purchase.platforms).map((k) => mdText(platformLabel(k))).join(" & "), mdPlace({ journey: p.journey, viewport: p.viewport })));
397
+ }
398
+ if (runs.some((r) => r.hits?.length) && !findings.some((f) => String(f.check).startsWith("pii"))) items.push(m.ui.md.noPii);
399
+ return [`## ${m.ui.md.whatPassed}`, "", ...(items.length ? items.slice(0, 10).map((i) => `- ${i}`) : [m.ui.nothing]), ""];
400
+ }
401
+
402
+ /** One table row per run, then the steps that carry a problem; the full timeline is timeline.csv. */
403
+ function mdJourneys(m, runs, rows) {
404
+ const lines = [`## ${m.ui.tabJourneys}`, ""];
405
+ if (!runs.length) return [...lines, m.ui.nothing, ""];
406
+ const problems = rows.filter(isProblem);
407
+ lines.push(`| ${m.ui.journey} | ${m.ui.md.viewport} | ${m.ui.completed} | ${m.ui.stepsCol} | ${m.ui.md.hits} | ${m.ui.findings} |`, "|---|---|---|---|---|---|");
194
408
  for (const r of runs) {
195
- lines.push(`### ${mdText(r.journeyId)} (${mdText(r.viewport)}): ${r.completed ? m.ui.completed : m.ui.notCompleted}`, "", mdText(r.note), "");
196
- for (const step of r.steps.filter((s) => s.index > 0)) {
197
- lines.push(`${step.index}. **${mdText(step.label)}** (${seconds(r, step.t)})${step.forced ? ` _${m.ui.clickedThrough}_` : ""}`);
198
- for (const h of r.hits.filter((x) => x.step === step.index)) {
199
- const flag = hitSeverity(findings, r, step, h);
200
- const params = Object.entries(h.params).map(([k, v]) => `${k}=${v}`).join(", ");
201
- lines.push(` - ${flag ? `**${m.severity[flag]}** ` : ""}${mdText(`${h.platform}:${h.name}${params ? ` (${params})` : ""}`)}`);
202
- }
203
- for (const e of (r.dataLayerLog ?? []).filter((x) => x.step === step.index)) {
204
- lines.push(` - dataLayer: \`${mdCode(isConsentEntry(e.value) ? formatPush(e.value) : JSON.stringify(e.value))}\``);
205
- }
409
+ const n = problems.filter((f) => f.where.some((w) => w.journey === r.journeyId && w.viewport === r.viewport)).length;
410
+ const done = r.completed ? m.ui.md.yes : `${m.ui.md.no}${r.note ? ` · ${mdClip(mdText(r.note), 60)}` : ""}`;
411
+ lines.push(`| ${mdText(r.journeyId)} | ${mdText(r.viewport)} | ${done.replace(/\|/g, "\\|")} | ${(r.steps ?? []).filter((s) => s.index > 0).length} | ${r.hits?.length ?? 0} | ${n} |`);
412
+ }
413
+ lines.push("");
414
+ // The same step flagged on several journeys is one line with its places, as the findings are grouped.
415
+ const flagged = new Map();
416
+ for (const f of problems) {
417
+ for (const w of f.where) {
418
+ if (!w.step) continue;
419
+ if (!flagged.has(w.step)) flagged.set(w.step, { where: [], titles: new Set() });
420
+ flagged.get(w.step).where.push(w);
421
+ flagged.get(w.step).titles.add(mdTitle(f));
206
422
  }
423
+ }
424
+ if (flagged.size) {
425
+ const shown = [...flagged.values()].slice(0, MD_FLAGGED);
426
+ lines.push(`**${m.ui.md.flaggedSteps}**`, ...shown.map(({ where, titles }) => `- ${mdWhere(m, runs, where)}: ${[...titles].join(", ")}`));
427
+ if (flagged.size > shown.length) lines.push(`- ${m.ui.md.moreIn(flagged.size - shown.length, "timeline.csv")}`);
207
428
  lines.push("");
208
429
  }
209
- if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
430
+ lines.push(m.ui.md.timelineNote, "");
431
+ return lines;
432
+ }
433
+
434
+ /** What this run could not judge, each with the config key that enables it; omitted when nothing is missing. */
435
+ function mdNotChecked(m, runs, rows, plan, scenarios) {
436
+ const items = [];
437
+ if (!plan?.events?.length) items.push(m.ui.md.noPlan);
438
+ const withEvent = rows.filter((f) => isProblem(f) && f.event);
439
+ if (withEvent.length && !withEvent.some((f) => f.gtm)) items.push(m.ui.md.noGtm);
440
+ if (scenarios) {
441
+ if (!scenarios.states?.analytics?.after) items.push(m.ui.md.noAnalyticsOnly);
442
+ } else if (runs.length) items.push(m.ui.md.noScenarios);
443
+ const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
444
+ const seen = new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)));
445
+ const silent = sortPlatforms([...chosen].filter((p) => !seen.has(p)));
446
+ if (silent.length) items.push(m.ui.md.platformsSilent(silent.map((p) => mdText(platformLabel(p))).join(silent.length === 2 ? " & " : ", "), silent.length));
447
+ return items.length ? [`## ${m.ui.md.notChecked}`, "", ...items.map((i) => `- ${i}`), ""] : [];
448
+ }
449
+
450
+ /** The vendor table, capped; the HTML report has the rest. Omitted when nothing was recorded. */
451
+ function mdInventory(m, runs) {
452
+ const inv = buildInventory(runs);
453
+ if (!inv.vendors.length) return [];
454
+ const lines = [`## ${m.ui.inventoryTitle}`, "", `| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|"];
455
+ for (const v of inv.vendors.slice(0, MD_INVENTORY_ROWS)) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", ")) || "–"} |`);
456
+ if (inv.vendors.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(inv.vendors.length - MD_INVENTORY_ROWS, "report.html"));
457
+ if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
458
+ lines.push("");
459
+ return lines;
460
+ }
461
+
462
+ /** The trend in up to five lines: the text sparkline with the direction, then the open-longest and flapping lines. */
463
+ function mdTrend(m, trend, lang) {
464
+ if (!trend || trend.runs < 2) return [];
465
+ const [headline, ...rest] = historySummaryLines(trend, lang);
466
+ return [`## ${m.ui.md.trend}`, "", `\`${sparkline(trend)}\` ${mdText(headline)}`, ...rest.map(mdText), ""];
467
+ }
468
+
469
+ /**
470
+ * The verdict as the CLI, the alert subject and the Markdown title say it: { status, statusText, headline,
471
+ * score }, in the report's language and with no Markdown in it. `metrics` is computeMetrics' result.
472
+ */
473
+ export function verdictHeadline({ findings = [], runs = [], metrics, lang = "en" }) {
474
+ const m = messages(lang);
475
+ const rows = groupFindings(findings).sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
476
+ return { status: metrics.verdict.status, statusText: m.verdict.status[metrics.verdict.status], headline: mdHeadline(m, runs, rows, metrics), score: metrics.score?.score ?? null };
477
+ }
478
+
479
+ /**
480
+ * The Markdown report: the verdict in the title, the meta and counts, what changed, the priorities by
481
+ * owner, up to five full finding blocks, consent, what passed, the journeys with their flagged steps,
482
+ * what was not checked, the inventory and the trend. `metrics` comes from computeMetrics (computed
483
+ * here when the caller has none) and `trend` from history.js (null without a saved history).
484
+ */
485
+ export function renderMarkdown({ title, site = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null, metrics = null, trend = null }) {
486
+ const m = messages(lang);
487
+ const rows = attachExplanations(findings, explained).sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
488
+ const counts = summarize(rows);
489
+ const accepted = rows.filter((f) => f.accepted).length;
490
+ metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
491
+ const platformsSeen = sortPlatforms([...new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)))]);
492
+ const meta = [m.ui.md.meta(mdText(generatedAt), runs.length, m.ui.md.platformsSeen(platformsSeen.length, platformsSeen.map((p) => mdText(platformLabel(p))).join(", ")), VERSION), `[${m.ui.md.htmlReport}](report.html)`, ...(against ? [mdText(m.ui.checkedAgainst(against))] : [])];
493
+ const status = `**${m.verdict.status[metrics.verdict.status]}**${metrics.score?.score === null || metrics.score?.score === undefined ? "" : ` · ${m.ui.md.score(metrics.score.score)}`}`;
494
+ const lines = [`# ${mdText(m.ui.md.title(site ?? title, mdHeadline(m, runs, rows, metrics)))}`, "", meta.join(" · "), status, `**${m.ui.counts(counts)}**${changes && !changes.previous ? ` · ${m.ui.noPreviousRun}` : ""}`, ""];
495
+ if (accepted) lines.push(mdText(m.ui.acceptedLine(accepted)), "");
496
+ if (planChanges) lines.push(mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : []));
497
+ const sum = summaryParts(explained?.summary);
498
+ if (sum) {
499
+ lines.push(`${m.ui.md.aiSummary}: ${sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`}`);
500
+ lines.push(...sum.points.slice(0, 3).map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`));
501
+ if (sum.also_check.length) lines.push(`- ${m.ui.alsoCheck}: ${sum.also_check.slice(0, 2).map(mdText).join("; ")}`);
502
+ lines.push("");
503
+ }
504
+ lines.push(...mdChanges(m, changes, rows));
505
+ lines.push(...mdTrend(m, trend, lang));
506
+ if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
507
+ lines.push(...mdPriorities(m, runs, metrics));
508
+ lines.push(`## ${m.ui.findings}`, "");
509
+ const problems = rows.filter((f) => f.severity !== "ok");
510
+ if (!problems.length) lines.push(m.ui.noProblems, "");
511
+ for (const f of problems.slice(0, MD_BLOCKS)) lines.push(...mdFinding(m, runs, f));
512
+ const rest = problems.slice(MD_BLOCKS);
513
+ if (rest.length) lines.push(`### ${m.ui.md.also}`, ...rest.map((f) => `- **${m.severity[f.severity]}** ${mdDetail(f.detail).replace(/\.$/, "")} · ${mdWhere(m, runs, f.where, { full: false })}`), "");
514
+ lines.push(...mdConsent(m, metrics, consentScenarios));
515
+ lines.push(...mdPassed(m, runs, findings, metrics, planCheck));
516
+ lines.push(...mdJourneys(m, runs, rows));
517
+ lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
518
+ lines.push(...mdInventory(m, runs));
519
+ // A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
520
+ if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
521
+ else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
210
522
  return lines.join("\n");
211
523
  }
212
524
 
525
+ const SEVERITY_ORDER_MD = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
526
+
213
527
  const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
214
528
  export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
215
529
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
@@ -266,11 +580,73 @@ function callsForHit(run, hit) {
266
580
  }
267
581
 
268
582
 
269
- const icon = {
270
- chevron: '<svg class="chev" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>',
583
+ /* ---------- icons: one inline SVG sprite, referenced with <use> ---------- */
584
+
585
+ const STROKE = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
586
+ // Severity glyphs (shape and mark, so they survive greyscale) and the interface icons (Lucide).
587
+ const UI_SYMBOLS = {
588
+ "sev-broken": '<path d="M8.2 2.5h7.6l5.7 5.7v7.6l-5.7 5.7H8.2l-5.7-5.7V8.2z"/><path d="m9.5 9.5 5 5m0-5-5 5"/>',
589
+ "sev-risk": '<path d="M12 2.5 21.5 12 12 21.5 2.5 12z"/><path d="M12 8v5"/><path d="M12 16.5h.01"/>',
590
+ "sev-warning": '<path d="m12 3.5 9.5 16.5h-19z"/><path d="M12 10v4.5"/><path d="M12 17.5h.01"/>',
591
+ "sev-info": '<circle cx="12" cy="12" r="9.5"/><path d="M12 11v5.5"/><path d="M12 7.5h.01"/>',
592
+ "sev-ok": '<circle cx="12" cy="12" r="9.5"/><path d="m7.5 12.5 3 3 6-6.5"/>',
593
+ "ui-chevron": '<path d="m9 18 6-6-6-6"/>',
594
+ "ui-impact": '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
595
+ "ui-cause": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
596
+ "ui-evidence": '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/>',
597
+ "ui-fix": '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
598
+ "ui-where": '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
599
+ "ui-verify": '<path d="M9 12l2 2 4-4"/><path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"/>',
600
+ "ui-external": '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
601
+ "ui-theme": '<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"/>',
602
+ "ui-gtm": '<path d="M4 4h6v6H4z"/><path d="M14 4h6v6h-6z"/><path d="M4 14h6v6H4z"/><path d="M14 14h6v6h-6z"/>',
603
+ };
604
+ // Platform artwork, the same drawings the report used as data URIs before, as symbols. Dark glyphs (TikTok, X) sit on a plate that follows the card colour.
605
+ const PLATFORM_ART = {
606
+ ga4: '<rect x="15" y="2" width="6" height="20" rx="3" fill="#F9AB00"/><rect x="9" y="9" width="6" height="13" rx="3" fill="#E37400"/><circle cx="6" cy="19" r="3" fill="#E37400"/>',
607
+ google_ads: '<rect x="9.5" y="1.5" width="6" height="21" rx="3" transform="rotate(30 12.5 12)" fill="#FBBC04"/><rect x="8.5" y="1.5" width="6" height="21" rx="3" transform="rotate(-30 11.5 12)" fill="#4285F4"/><circle cx="5.5" cy="18.5" r="3" fill="#34A853"/>',
608
+ meta: '<path d="M2.5 15.5c0-4.4 2.3-9 5-9 3.4 0 6 11 9.5 11 2.2 0 4.5-2.6 4.5-6.5S19.8 6.5 17.5 6.5c-3.4 0-6 11-9.8 11-2.9 0-5.2-.7-5.2-2z" fill="none" stroke="#0866FF" stroke-width="2.4" stroke-linejoin="round"/>',
609
+ tiktok: '<rect class="plate" x="0" y="0" width="24" height="24" rx="5"/><g fill="none" stroke-width="2.6" stroke-linecap="round"><path d="M13 3v11.5a3.5 3.5 0 1 1-3-3.46M13 3c.5 2.5 2.5 4 5 4" stroke="#25F4EE" transform="translate(-1 -1)"/><path d="M13 3v11.5a3.5 3.5 0 1 1-3-3.46M13 3c.5 2.5 2.5 4 5 4" stroke="#FE2C55" transform="translate(1 1)"/><path d="M13 3v11.5a3.5 3.5 0 1 1-3-3.46M13 3c.5 2.5 2.5 4 5 4" stroke="#111"/></g>',
610
+ x: '<rect class="plate" x="0" y="0" width="24" height="24" rx="5"/><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" fill="#000" transform="translate(3 3) scale(.75)"/>',
611
+ linkedin: '<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" fill="#0A66C2"/>',
612
+ microsoft_ads: '<rect x="1" y="1" width="10.5" height="10.5" fill="#F25022"/><rect x="12.5" y="1" width="10.5" height="10.5" fill="#7FBA00"/><rect x="1" y="12.5" width="10.5" height="10.5" fill="#00A4EF"/><rect x="12.5" y="12.5" width="10.5" height="10.5" fill="#FFB900"/>',
613
+ pinterest: '<path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z" fill="#BD081C"/>',
614
+ snapchat: '<rect width="24" height="24" rx="5" fill="#FFFC00"/><path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" transform="translate(3 3) scale(.75)" fill="#fff" stroke="#000" stroke-width="1.3"/>',
615
+ criteo: '<circle cx="12" cy="12" r="11" fill="#FE5000"/><path d="M16.2 8.6a5 5 0 1 0 0 6.8" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>',
616
+ hotjar: '<path d="M10.119 9.814C12.899 8.27 16.704 6.155 16.704 0h-4.609c0 3.444-1.676 4.375-4.214 5.786C5.1 7.33 1.295 9.444 1.295 15.6h4.61c0-3.444 1.676-4.376 4.214-5.786zM18.096 8.4c0 3.444-1.677 4.376-4.215 5.785-2.778 1.544-6.585 3.66-6.585 9.815h4.609c0-3.444 1.676-4.376 4.214-5.786 2.78-1.544 6.586-3.658 6.586-9.814h-4.609z" fill="#FF3C00"/>',
617
+ adobe: '<path d="M0 2h8.5L0 22zM24 2h-8.5L24 22zM12 9l5.5 13h-3.6l-1.7-4.2H8.9z" fill="#EB1000"/>',
618
+ matomo: '<path d="M6 18l6-9 6 9" fill="none" stroke="#3152A0" stroke-width="2"/><circle cx="6" cy="18" r="3" fill="#95C748"/><circle cx="12" cy="9" r="3" fill="#3152A0"/><circle cx="18" cy="18" r="3" fill="#3152A0"/>',
619
+ piwik_pro: '<rect width="24" height="24" rx="5" fill="#0C2D5B"/><path d="M8 18V6h5a3.5 3.5 0 010 7H8" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
620
+ clarity: '<rect width="24" height="24" rx="5" fill="#0078D4"/><path d="M12 5a7 7 0 100 14M12 9a3 3 0 100 6" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>',
271
621
  };
272
622
 
273
- const kvTable = (rows) => `<table class="ptable"><tbody>${rows.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</tbody></table>`;
623
+ /** An icon from the sprite. Decorative: the text next to it names the thing. */
624
+ const use = (id, size = 16, cls = "") => `<svg class="ic${cls ? ` ${cls}` : ""}" width="${size}" height="${size}" aria-hidden="true"><use href="#${id}"/></svg>`;
625
+
626
+ /** The sprite with only the symbols the page references, so a report never carries artwork it does not show. */
627
+ function sprite(html) {
628
+ const used = new Set([...html.matchAll(/href="#((?:sev|ui)-[a-z]+|p-[a-z0-9_]+)"/g)].map((x) => x[1]));
629
+ const symbols = [...used].sort().map((id) => {
630
+ if (id.startsWith("p-")) return `<symbol id="${id}" viewBox="0 0 24 24">${PLATFORM_ART[id.slice(2)]}</symbol>`;
631
+ return `<symbol id="${id}" ${STROKE}>${UI_SYMBOLS[id]}</symbol>`;
632
+ });
633
+ return `<svg hidden style="display:none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">${symbols.join("")}</svg>`;
634
+ }
635
+
636
+ /** The platform's icon, or for a platform without artwork its initials on a neutral square. Screen readers get the name as text. */
637
+ function platIcon(key, size = 16, label = platformLabel(key)) {
638
+ const k = safeKey(key);
639
+ const art = PLATFORM_ART[k] ? use(`p-${k}`, size) : `<span class="plat-fb s${size}" aria-hidden="true">${escapeHtml(label.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase())}</span>`;
640
+ return `${art}<span class="sr">${escapeHtml(label)} </span>`;
641
+ }
642
+ /** Icon and name of a platform. */
643
+ function plat(key, size = 16, label = platformLabel(key)) {
644
+ const k = safeKey(key);
645
+ const art = PLATFORM_ART[k] ? use(`p-${k}`, size) : `<span class="plat-fb s${size}" aria-hidden="true">${escapeHtml(label.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase())}</span>`;
646
+ return `<span class="plat">${art}<span>${escapeHtml(label)}</span></span>`;
647
+ }
648
+
649
+ const kvTable = (label, rows) => `<table class="ptable" aria-label="${escapeHtml(label)}"><tbody>${rows.map(([k, v]) => `<tr><th scope="row">${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</tbody></table>`;
274
650
  // Platform keys go into class names, ids and attribute selectors.
275
651
  const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
276
652
  const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
@@ -305,9 +681,9 @@ function itemCards(m, items) {
305
681
  })
306
682
  .join("");
307
683
  }
308
- const itemsBlock = (m, items) => (items.length ? `<h5>${m.ui.items} <span class="n">${items.length}</span></h5>${itemCards(m, items)}` : "");
684
+ const itemsBlock = (m, items) => (items.length ? `<h6>${m.ui.items} <span class="n">${items.length}</span></h6>${itemCards(m, items)}` : "");
309
685
 
310
- /** The most severe finding that points at this hit, as { severity, detail }, or null. */
686
+ /** The most severe finding that points at this hit, or null. */
311
687
  function hitFlag(findings, run, step, hit) {
312
688
  let worst = null;
313
689
  for (const f of findings) {
@@ -316,7 +692,7 @@ function hitFlag(findings, run, step, hit) {
316
692
  if (f.platform !== hit.platform || f.event !== hit.name) continue;
317
693
  if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
318
694
  }
319
- return worst && { severity: worst.severity, detail: worst.detail };
695
+ return worst;
320
696
  }
321
697
 
322
698
  const hostPath = (url) => {
@@ -328,19 +704,13 @@ const hostPath = (url) => {
328
704
  }
329
705
  };
330
706
 
331
- /** Radio buttons that filter the list after them by data-p. Only sources present are offered. */
332
- function filterBar(m, name, rows) {
333
- const counts = new Map();
334
- for (const r of rows) counts.set(r.key, { label: r.label, n: (counts.get(r.key)?.n ?? 0) + 1 });
335
- if (counts.size < 2) return "";
336
- const keys = [...counts.keys()].sort((a, b) => platformRank(a) - platformRank(b) || counts.get(a).label.localeCompare(counts.get(b).label));
337
- return `<input type="radio" name="${name}" id="${name}-all" class="f-all" checked><label for="${name}-all">${m.ui.all} <span class="n">${rows.length}</span></label>${keys
338
- .map((k) => `<input type="radio" name="${name}" id="${name}-${k}" class="f-${k}"><label for="${name}-${k}"><span class="dot p-${k}"></span>${escapeHtml(counts.get(k).label)} <span class="n">${counts.get(k).n}</span></label>`)
339
- .join("")}`;
340
- }
341
- const filtered = (m, name, rows, listHtml) => `<div class="filter" role="group" aria-label="${m.ui.filterBy}">${filterBar(m, name, rows)}<ul class="evlist">${listHtml}</ul></div>`;
707
+ /** The card of the grouped finding a raw finding belongs to, by the same key groupFindings uses. */
708
+ const cardOf = (ids, f) => {
709
+ for (const [row, id] of ids) if (row.severity === f.severity && row.check === f.check && row.platform === f.platform && row.event === f.event && row.detail === f.detail) return id;
710
+ return null;
711
+ };
342
712
 
343
- function eventRow(m, findings, run, step, hit) {
713
+ function eventRow(m, findings, run, step, hit, ids = new Map()) {
344
714
  const flag = hitFlag(findings, run, step, hit);
345
715
  const pushes = pushesForHit(run, hit);
346
716
  const calls = callsForHit(run, hit);
@@ -351,16 +721,17 @@ function eventRow(m, findings, run, step, hit) {
351
721
  if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
352
722
  if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
353
723
  if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
354
- const consent = hit.consent ? `<h5>${m.ui.consent}</h5><div class="consents">${Object.entries(hit.consent).map(([k, v]) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)}: ${escapeHtml(v)}</span>`).join("")}</div>` : "";
724
+ const consent = hit.consent ? `<p class="xs muted consents">${m.html.consentOnHit} ${Object.entries(hit.consent).map(([k, v]) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)} ${escapeHtml(v)}</span>`).join(" ")}</p>` : "";
355
725
  const pushLine = pushes.length ? pushes.map((e) => `<span class="mono">${escapeHtml(pushEvent(e.value) ?? "")} · ${escapeHtml(seconds(run, e.t))}</span>`).join("") : `<span class="muted">${m.ui.noMatchingPush}</span>`;
356
726
  const reqText = calls.length
357
- ? calls.map((c) => `<span class="mono"><span class="method">${escapeHtml(c.method)}</span> ${escapeHtml(hostPath(c.url))} <span class="status">${escapeHtml(c.status ?? "")}</span>${c.blocked ? ` <span class="muted">${m.ui.blockedNote}</span>` : ""}</span>`).join("")
727
+ ? calls.map((c) => `<span class="mono"><span class="method">${escapeHtml(c.method)}</span> ${escapeHtml(hostPath(c.url))} <span class="status">${escapeHtml(c.status ?? "")}</span>${c.blocked ? ` <span class="muted">${escapeHtml(m.html.blockedBy(platformLabel(hit.platform)))}</span>` : ""}</span>`).join("")
358
728
  : `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
359
- return `<li data-p="${safeKey(hit.platform)}"><details class="event${flag ? ` flag-${flag.severity}` : ""}"${flag ? " open" : ""}>
360
- <summary>${icon.chevron}<span class="ptag p-${safeKey(hit.platform)}">${escapeHtml(platformLabel(hit.platform))}</span><code class="ev">${escapeHtml(hit.name)}</code>${flag ? `<span class="tag ${flag.severity}">${m.severity[flag.severity]}</span>` : ""}<span class="meta">${escapeHtml(bits.join(" · ") || `${params.length} ${m.ui.paramsShort}`)}</span><span class="time">${escapeHtml(seconds(run, hit.t))}</span></summary>
361
- <div class="event-body">${flag ? `<p class="flagnote ${flag.severity}">${escapeHtml(flag.detail)}</p>` : ""}
362
- <div class="ebgrid"><section><h5>${m.ui.parameters} <span class="n">${params.length}</span></h5>${params.length ? kvTable(params) : `<p class="muted small">${m.ui.noParameters}</p>`}${consent}</section>
363
- <aside><h5>${m.ui.source}</h5><ul class="srclist"><li><span class="lbl">dataLayer</span>${pushLine}</li><li><span class="lbl">${m.ui.request}</span>${reqText}</li></ul></aside></div>
729
+ const card = flag ? cardOf(ids, flag) : null;
730
+ return `<li class="event${flag ? ` flag-${flag.severity} sev-${flag.severity}` : ""}"><details${flag ? " open" : ""}>
731
+ <summary>${plat(hit.platform, 16)}<code class="ev">${escapeHtml(hit.name)}</code>${flag ? `<span class="tag badge sev-${flag.severity}">${use(`sev-${flag.severity}`, 12)}${m.severity[flag.severity]}</span>` : ""}<span class="meta">${escapeHtml(bits.join(" · ") || `${params.length} ${m.ui.paramsShort}`)}</span><span class="time">${escapeHtml(seconds(run, hit.t))}</span>${use("ui-chevron", 14, "chev")}</summary>
732
+ <div class="event-body">${flag ? `<p class="flagnote sev-${flag.severity}">${detailHtml(flag.detail)}${card ? ` <a href="#${card}">${m.html.findingLink}</a>` : ""}</p>` : ""}${consent}
733
+ <div class="ebgrid"><div>${params.length ? kvTable(m.ui.parameters, params) : `<p class="muted xs">${m.ui.noParameters}</p>`}</div>
734
+ <aside><h6>${m.ui.source}</h6><ul class="srclist"><li><span class="lbl">dataLayer</span>${pushLine}</li><li><span class="lbl">${m.ui.request}</span>${reqText}</li></ul></aside></div>
364
735
  ${itemsBlock(m, items)}</div></details></li>`;
365
736
  }
366
737
 
@@ -371,8 +742,8 @@ function pushRow(m, run, e) {
371
742
  const items = objectItems(pushItems(e.value));
372
743
  const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
373
744
  const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
374
- return `<li><details class="event"><summary>${icon.chevron}<code class="ev">${escapeHtml(name)}</code>${bits ? `<span class="meta">${escapeHtml(bits)}</span>` : ""}<span class="time">${escapeHtml(seconds(run, e.t))}</span></summary>
375
- <div class="event-body">${itemsBlock(m, items)}${items.length ? `<details class="raw"><summary>${icon.chevron} ${m.ui.rawJson}</summary><pre>${escapeHtml(pushText(e.value))}</pre></details>` : `<pre>${escapeHtml(pushText(e.value))}</pre>`}</div></details></li>`;
745
+ return `<li class="event"><details><summary><code class="ev">${escapeHtml(name)}</code>${bits ? `<span class="meta">${escapeHtml(bits)}</span>` : ""}<span class="time">${escapeHtml(seconds(run, e.t))}</span>${use("ui-chevron", 14, "chev")}</summary>
746
+ <div class="event-body">${itemsBlock(m, items)}${items.length ? `<details class="raw"><summary>${use("ui-chevron", 14, "chev")} ${m.ui.rawJson}</summary><pre>${escapeHtml(pushText(e.value))}</pre></details>` : `<pre>${escapeHtml(pushText(e.value))}</pre>`}</div></details></li>`;
376
747
  }
377
748
 
378
749
  /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
@@ -415,12 +786,13 @@ function paramSets(call) {
415
786
  return [query];
416
787
  }
417
788
 
789
+ /** Parameter rows with a glossary tip: the "?" shows the tip on hover and takes no focus; the tip text is read by screen readers from the hidden span. */
418
790
  function paramRows(m, lang, entries) {
419
791
  return entries
420
792
  .map(([k, v]) => {
421
793
  const tip = paramTip(k, lang);
422
794
  const isItem = /^pr\d+$/.test(k);
423
- return `<tr${isItem ? ' class="pr"' : ""}><th><span class="pk">${escapeHtml(k)}</span>${tip ? `<span class="q" tabindex="0" role="note" aria-label="${escapeHtml(tip)}" data-tip="${escapeHtml(tip)}">?</span>` : ""}</th><td>${isItem ? `<span class="muted small">${escapeHtml(paramTip(k, lang))}</span>` : escapeHtml(v)}</td></tr>`;
795
+ return `<tr${isItem ? ' class="pr"' : ""}><th scope="row"><span class="pk">${escapeHtml(k)}</span>${tip ? `<span class="q" aria-hidden="true" data-tip="${escapeHtml(tip)}">?</span><span class="sr">${escapeHtml(tip)}</span>` : ""}</th><td>${isItem ? `<span class="muted small">${escapeHtml(paramTip(k, lang))}</span>` : escapeHtml(v)}</td></tr>`;
424
796
  })
425
797
  .join("");
426
798
  }
@@ -432,17 +804,18 @@ function requestRows(m, lang, run, step) {
432
804
  const sets = paramSets(c);
433
805
  const hits = parseTrackingRequest(c.url, c.postData ?? null) ?? [];
434
806
  const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
807
+ const label = PROVIDERS[key] ?? c.vendor ?? key;
435
808
  sets.forEach((entries, i) => {
436
809
  const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
437
810
  const eventEntries = entries.filter(([k]) => isEventParam(k));
438
811
  const context = entries.filter(([k]) => !isEventParam(k));
439
812
  const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
440
813
  const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
441
- const html = `<li data-p="${key}"><details class="event"><summary>${icon.chevron}<span class="ptag p-${key}">${escapeHtml(c.vendor)}</span><span class="method">${escapeHtml(c.method)}</span>${name ? `<code class="ev">${escapeHtml(name)}</code>` : ""}<span class="meta mono">${escapeHtml(hostPath(c.url))}${sets.length > 1 ? ` · ${escapeHtml(m.ui.inBatch(i + 1, sets.length))}` : ""}</span><span class="status mono">${escapeHtml(c.status ?? "")}</span><span class="time">${escapeHtml(seconds(run, c.t))}</span></summary>
442
- <div class="event-body">${c.blocked ? `<p class="muted small">${m.ui.blockedNote}</p>` : ""}${c.bodyDropped ? `<p class="muted small">${m.ui.bodyDropped}</p>` : ""}${eventEntries.length ? `<h5>${m.ui.eventParams}</h5><table class="ptable glossary"><tbody>${paramRows(m, lang, eventEntries)}</tbody></table>` : ""}
443
- ${context.length ? `<details class="raw ctx"${eventEntries.length ? "" : " open"}><summary>${icon.chevron} ${m.ui.contextParams} <span class="n">${context.length}</span></summary><table class="ptable glossary"><tbody>${paramRows(m, lang, context)}</tbody></table></details>` : ""}
444
- ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
445
- rows.push({ key, label: PROVIDERS[key] ?? c.vendor ?? key, html });
814
+ const html = `<li class="event"><details><summary>${plat(key, 16, label)}<span class="method">${escapeHtml(c.method)}</span>${name ? `<code class="ev">${escapeHtml(name)}</code>` : ""}<span class="meta mono">${escapeHtml(hostPath(c.url))}${sets.length > 1 ? ` · ${escapeHtml(m.ui.inBatch(i + 1, sets.length))}` : ""}</span><span class="status mono">${escapeHtml(c.status ?? "")}</span><span class="time">${escapeHtml(seconds(run, c.t))}</span>${use("ui-chevron", 14, "chev")}</summary>
815
+ <div class="event-body">${c.blocked ? `<p class="muted small">${escapeHtml(m.html.blockedBy(label))}</p>` : ""}${c.bodyDropped ? `<p class="muted small">${m.ui.bodyDropped}</p>` : ""}${eventEntries.length ? `<h6>${m.ui.eventParams}</h6><table class="ptable glossary" aria-label="${m.ui.eventParams}"><tbody>${paramRows(m, lang, eventEntries)}</tbody></table>` : ""}
816
+ ${context.length ? `<details class="raw ctx"${eventEntries.length ? "" : " open"}><summary>${use("ui-chevron", 14, "chev")} ${m.ui.contextParams} <span class="n">${context.length}</span></summary><table class="ptable glossary" aria-label="${m.ui.contextParams}"><tbody>${paramRows(m, lang, context)}</tbody></table></details>` : ""}
817
+ ${itemsBlock(m, items)}<details class="raw"><summary>${use("ui-chevron", 14, "chev")} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
818
+ rows.push({ key, label, html });
446
819
  });
447
820
  }
448
821
  return rows;
@@ -451,30 +824,374 @@ ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawR
451
824
  const stepAnchor = (ri, si) => `j${ri}-s${si}`;
452
825
  const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
453
826
  const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
454
- const problemCount = (findings, run) => findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === run.journeyId && f.viewport === run.viewport).length;
455
827
 
456
828
  // AI text marks numbers with **x** and names with `x`. Only those two marks become HTML; everything else is escaped.
457
829
  const fmt = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
458
830
  // Finding texts quote event and parameter names ("add_to_cart"); show those as code.
459
831
  const detailHtml = (s) => escapeHtml(s).replace(/&quot;([^&]+?)&quot;/g, "<code>$1</code>");
460
832
 
461
- // Lucide icons.
462
- const ic = (paths, size = 16) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
463
- const ICON = {
464
- broken: '<path d="m15 9-6 6"/><path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"/><path d="m9 9 6 6"/>',
465
- warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
466
- info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
467
- impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
468
- cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
469
- evidence: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
470
- fix: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
471
- where: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
472
- };
473
- const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "info" : "warning"];
474
- const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
475
- const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
476
- /** A logo with its light and dark versions, switched by the viewer's color scheme. */
477
- export const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
833
+ const sec = (name, label) => `<h5 class="sec-h">${use(`ui-${name}`, 14)}<span>${label}</span></h5>`;
834
+ /** A severity badge: glyph and word, never colour alone. A badge without a severity is a plain grey chip. */
835
+ const badge = (sev, text) => (UI_SYMBOLS[`sev-${sev}`] ? `<span class="badge sev-${sev}">${use(`sev-${sev}`, 14)}${escapeHtml(text)}</span>` : `<span class="badge">${escapeHtml(text)}</span>`);
836
+
837
+ // The dark palette, written twice: once for the OS preference (unless the page is forced light) and once for the forced dark theme.
838
+ const DARK_TOKENS = `--page:#0B0F19;--card:#141927;--well:#1B2235;--fg:#EEF2FF;--muted:#9AA4BD;--border:#263050;
839
+ --accent:#4F8EF7;--accent-fg:#8CBBFF;--accent-bg:#1A2F5C;
840
+ --sev-broken:#F04438;--sev-broken-fg:#FDA29B;--sev-broken-bg:#3D1D1D;
841
+ --sev-risk:#D78AD6;--sev-risk-fg:#E3A6E0;--sev-risk-bg:#34203A;
842
+ --sev-warning:#E8B44A;--sev-warning-fg:#F5C451;--sev-warning-bg:#352B10;
843
+ --sev-info:#8191B8;--sev-info-fg:#B4C0DC;--sev-info-bg:#1E2536;
844
+ --sev-ok:#3FB98A;--sev-ok-fg:#5BD39A;--sev-ok-bg:#113128;
845
+ --shadow-sm:none;--muted-bg:var(--well);--bg:var(--card);--warn:var(--sev-warning-fg);--warn-bg:var(--sev-warning-bg);--danger:var(--sev-broken-fg);--ok:var(--sev-ok-fg)`;
846
+
847
+ export const CSS = `
848
+ :root{
849
+ color-scheme:light dark;
850
+ --page:#F6F7F9;--card:#FFFFFF;--well:#EEF1F6;--fg:#111827;--muted:#4B5567;--border:#D9DEE8;
851
+ --accent:#2E6CDB;--accent-fg:#2459C0;--accent-bg:#EBF1FE;
852
+ --sev-broken:#D92D20;--sev-broken-fg:#B42318;--sev-broken-bg:#FEE4E2;
853
+ --sev-risk:#9B3B9A;--sev-risk-fg:#86338A;--sev-risk-bg:#F7E8F7;
854
+ --sev-warning:#B7791F;--sev-warning-fg:#7A5A00;--sev-warning-bg:#FBF0D0;
855
+ --sev-info:#5B6B8C;--sev-info-fg:#3F4D6B;--sev-info-bg:#E9EDF5;
856
+ --sev-ok:#17805A;--sev-ok-fg:#167A55;--sev-ok-bg:#E1F3EA;
857
+ --s1:4px;--s2:8px;--s3:12px;--s4:16px;--s5:24px;--s6:32px;--s7:48px;
858
+ --r-sm:4px;--r-md:8px;--radius:var(--r-md);
859
+ --shadow-sm:0 1px 2px rgb(16 24 40 / 6%);
860
+ --font-sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;
861
+ --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
862
+ --muted-bg:var(--well);--bg:var(--card);--warn:var(--sev-warning-fg);--warn-bg:var(--sev-warning-bg);--danger:var(--sev-broken-fg);--ok:var(--sev-ok-fg)}
863
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${DARK_TOKENS}}}
864
+ :root[data-theme="dark"]{${DARK_TOKENS}}
865
+ *{box-sizing:border-box}
866
+ html{scroll-padding-top:72px;scrollbar-gutter:stable}
867
+ body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--page);color:var(--fg);font:400 14px/1.55 var(--font-sans);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
868
+ a{color:var(--accent-fg);text-decoration:none}
869
+ a:hover{text-decoration:underline}
870
+ h1,h2,h3,h4,h5,h6{margin:0;line-height:1.3}
871
+ h1{font-size:24px;font-weight:700;line-height:1.2;overflow-wrap:anywhere}
872
+ h2{font-size:17px;font-weight:600;margin:0 0 var(--s4)}
873
+ h3{font-size:14px;font-weight:600}
874
+ h4,h5,h6{font-size:12.5px;font-weight:600}
875
+ .card-h h4{font-size:14px}
876
+ h4.ftitle{font-size:14px;font-weight:400;line-height:1.55}
877
+ p{margin:0}
878
+ strong{font-weight:600}
879
+ code,.mono,.time,pre{font-family:var(--font-mono)}
880
+ code{font-size:12.5px;background:var(--well);padding:1px 5px;border-radius:var(--r-sm);overflow-wrap:anywhere}
881
+ .muted{color:var(--muted)}.xs,.small{font-size:12.5px}
882
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
883
+ .skip{position:absolute;left:var(--s4);top:-48px;z-index:20;padding:var(--s2) var(--s3);background:var(--card);border:1px solid var(--border);border-radius:var(--r-sm);color:var(--fg)}
884
+ .skip:focus{top:var(--s2)}
885
+ .wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 var(--s4)}
886
+ .n{font:500 12px/1.4 var(--font-mono);background:var(--well);color:var(--muted);padding:0 6px;border-radius:var(--r-sm)}
887
+ .ic{flex:none;vertical-align:-3px}
888
+ svg[hidden]{display:none}
889
+ .plate{fill:var(--card);stroke:var(--border);stroke-width:1}
890
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
891
+ .js-only{display:none}html.js .js-only{display:inline-flex}html.js .tabs.js-only{display:flex}
892
+ .topbar{background:var(--card);border-bottom:1px solid var(--border)}
893
+ .topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:var(--s4);min-height:56px}
894
+ .product img,.brand-name{display:block}
895
+ .brand-name{font:600 16px var(--font-sans);color:var(--fg)}
896
+ .logo{display:inline-flex}
897
+ .logo img{display:block;width:auto}
898
+ .logo .l-dark{display:none}
899
+ @media (prefers-color-scheme:dark){html:not([data-theme="light"]) .logo .l-light{display:none}html:not([data-theme="light"]) .logo .l-dark{display:block}}
900
+ html[data-theme="dark"] .logo .l-light{display:none}html[data-theme="dark"] .logo .l-dark{display:block}
901
+ .actions{display:flex;align-items:center;gap:var(--s3)}
902
+ .byline{display:inline-flex;align-items:center;gap:var(--s2);min-height:24px;font-size:12px;color:var(--muted)}
903
+ .btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border:1px solid var(--border);border-radius:var(--r-sm);background:var(--card);color:var(--fg);font:500 12.5px var(--font-sans);cursor:pointer}
904
+ .btn:hover{background:var(--well)}
905
+ .verdict{background:var(--card);border-bottom:1px solid var(--border);padding:var(--s5) 0}
906
+ .eyebrow{font:500 11px/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:var(--s1)}
907
+ .verdict-line{display:flex;gap:var(--s3);align-items:flex-start;font-size:20px;line-height:1.3;letter-spacing:-.01em;margin-top:var(--s3);max-width:760px}
908
+ .verdict-line strong{font-weight:600}
909
+ .verdict .meta{margin-top:var(--s3);font-size:12.5px}
910
+ .vmark{margin-top:3px;color:var(--sev-fg)}
911
+ .aisum{margin-top:var(--s3);max-width:760px;display:grid;gap:var(--s2)}
912
+ .ai-l{font:500 11px/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px}
913
+ .summary-list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
914
+ .summary-list li{display:flex;gap:8px;align-items:baseline}
915
+ .also{margin:0;padding-left:18px;display:grid;gap:4px;font-size:13px}
916
+ .counts{list-style:none;margin:var(--s5) 0 0;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:var(--s2)}
917
+ .count a{display:grid;grid-template-columns:auto 1fr;gap:2px var(--s2);align-items:center;padding:10px 12px;border:1px solid var(--border);border-top:3px solid var(--sev);border-radius:var(--r-md);color:var(--fg);background:var(--card)}
918
+ .count a:hover{text-decoration:none;background:var(--well)}
919
+ .count .ic{color:var(--sev-fg);grid-row:1/3}
920
+ .count .k{font-size:12px;color:var(--muted)}
921
+ .count .v{font:600 24px/1 var(--font-mono)}
922
+ .count.zero a{border-top-color:var(--border);color:var(--muted)}.count.zero .ic{color:var(--muted)}
923
+ .sev-broken{--sev:var(--sev-broken);--sev-fg:var(--sev-broken-fg);--sev-bg:var(--sev-broken-bg)}
924
+ .sev-risk{--sev:var(--sev-risk);--sev-fg:var(--sev-risk-fg);--sev-bg:var(--sev-risk-bg)}
925
+ .sev-warning{--sev:var(--sev-warning);--sev-fg:var(--sev-warning-fg);--sev-bg:var(--sev-warning-bg)}
926
+ .sev-info{--sev:var(--sev-info);--sev-fg:var(--sev-info-fg);--sev-bg:var(--sev-info-bg)}
927
+ .sev-ok{--sev:var(--sev-ok);--sev-fg:var(--sev-ok-fg);--sev-bg:var(--sev-ok-bg)}
928
+ .facts{margin:var(--s5) 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s3) var(--s5);font-size:13px}
929
+ .facts div{display:grid;gap:2px;min-width:0}
930
+ .facts dt{font-size:12px;color:var(--muted)}
931
+ .facts dd{margin:0;overflow-wrap:anywhere}
932
+ .plats{display:inline-flex;flex-wrap:wrap;gap:var(--s1) var(--s3)}
933
+ .plat{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
934
+ .plat-fb{display:inline-grid;place-items:center;border-radius:var(--r-sm);background:var(--well);color:var(--muted);font:500 10px var(--font-mono);flex:none}
935
+ .plat-fb.s12{width:12px;height:12px;font-size:8px}.plat-fb.s14{width:14px;height:14px;font-size:9px}.plat-fb.s16{width:16px;height:16px}.plat-fb.s20{width:20px;height:20px;font-size:11px}
936
+ .tabbar{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border)}
937
+ html:not(.js) .tabbar{display:none}
938
+ .tabs{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none}
939
+ .tabs [role=tab]{appearance:none;background:none;border:0;border-bottom:2px solid transparent;margin-bottom:-1px;padding:12px 10px;color:var(--muted);font:500 14px var(--font-sans);white-space:nowrap;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
940
+ .tabs [role=tab]:hover{color:var(--fg)}
941
+ .tabs [role=tab][aria-selected=true]{color:var(--fg);border-bottom-color:var(--accent)}
942
+ .panel{padding:var(--s5) 0 var(--s6)}
943
+ html.js .panel:not(.active){display:none}
944
+ html.js .panel>h2{display:none}
945
+ .panel:focus{outline:none}
946
+ .stack{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--s4)}
947
+ .ov-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--s4);align-items:start}
948
+ .card{background:var(--card);border:1px solid var(--border);border-radius:var(--r-md);box-shadow:var(--shadow-sm);min-width:0}
949
+ .card-h{padding:var(--s4) var(--s4) 0}.card-c{padding:var(--s3) var(--s4) var(--s4)}
950
+ .card-h+.tscroll{margin-top:var(--s3)}
951
+ .card-d{font-size:12.5px;color:var(--muted);margin-top:2px}
952
+ .card-t{font-weight:600}
953
+ .badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px 2px 6px;border-radius:var(--r-sm);font:500 12px/16px var(--font-sans);white-space:nowrap;background:var(--well);color:var(--muted)}
954
+ .badge[class*=sev-]{background:var(--sev-bg);color:var(--sev-fg)}
955
+ .chip{display:inline-flex;align-items:center;gap:8px;padding:3px 10px 3px 3px;border:1px solid var(--border);border-radius:var(--r-sm);color:var(--fg);font-size:12px;background:var(--card)}
956
+ .chip.owner{padding:1px 8px;color:var(--muted)}
957
+ span.chip:not(.owner){padding-left:10px}
958
+ a.chip:hover{border-color:var(--accent);text-decoration:none}
959
+ .chip-step{display:inline-grid;place-items:center;min-width:20px;height:20px;border-radius:var(--r-sm);background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
960
+ .sidx{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:var(--r-sm);background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
961
+ .ogroup+.ogroup{margin-top:var(--s4)}
962
+ .ogroup-h{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--s2);margin:0 0 var(--s2);font-size:14px}
963
+ .ogroup-h .pts{margin-left:auto}
964
+ .fixlist{list-style:none;margin:0;padding:0;display:grid;gap:var(--s2)}
965
+ .fixlist a,.fixlist .fl-row{display:grid;grid-template-columns:auto 1fr;gap:2px var(--s3);align-items:start;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--sev);border-radius:var(--r-sm);color:var(--fg)}
966
+ .fixlist a:hover{text-decoration:none;background:var(--well)}
967
+ .fl-ic{color:var(--sev-fg);margin-top:2px;grid-row:1/3}
968
+ .fl-t{overflow-wrap:anywhere}
969
+ .fl-m{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;font-size:12px;color:var(--muted)}
970
+ .oklist{list-style:none;margin:0;padding:0;display:grid;gap:var(--s2)}
971
+ .oklist li{display:grid;grid-template-columns:auto 1fr;gap:var(--s3);align-items:start;font-size:13px}
972
+ .empty{padding:var(--s1) 0;color:var(--muted)}
973
+ .score-v{display:flex;align-items:baseline;gap:4px}
974
+ .score-v .v{font:600 32px/1 var(--font-mono)}
975
+ .deductions{list-style:none;margin:var(--s3) 0 0;padding:0;display:grid;gap:6px;font-size:13px}
976
+ .deductions li{display:grid;grid-template-columns:44px 1fr;gap:var(--s2);align-items:baseline}
977
+ .deductions .num{color:var(--sev-broken-fg);font-weight:600;text-align:right}
978
+ .spark{display:block;max-width:100%;height:auto}
979
+ .spark polyline{fill:none;stroke:var(--sev);stroke-width:1.75;stroke-linejoin:round}
980
+ .spark circle{fill:var(--sev);stroke:var(--card);stroke-width:1}
981
+ .spark circle.hollow{fill:var(--card);stroke:var(--sev);stroke-width:1.5}
982
+ .spark-legend{display:flex;flex-wrap:wrap;gap:4px 12px}
983
+ .spark-legend i{display:inline-block;width:10px;height:3px;background:var(--sev);margin-right:5px;vertical-align:middle;border-radius:2px}
984
+ .changes{list-style:none;margin:var(--s2) 0 0;padding:0;display:grid;gap:6px;font-size:13px}
985
+ .changes li{overflow-wrap:anywhere}
986
+ .run{margin:0;display:grid;gap:var(--s3) var(--s5);font-size:13px}
987
+ .run div{display:grid;grid-template-columns:120px minmax(0,1fr);gap:var(--s3);min-width:0}
988
+ .run dt{color:var(--muted)}.run dd{margin:0;overflow-wrap:anywhere}
989
+ /* position:relative keeps the hidden screen-reader spans inside the scroll box; without it they sit at their static x and widen the page. */
990
+ .tscroll{overflow-x:auto;position:relative}
991
+ table.dt{width:100%;border-collapse:collapse;font-size:13px}
992
+ table.dt th,table.dt td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:middle;overflow-wrap:anywhere}
993
+ table.dt thead th{font-weight:500;color:var(--muted);font-size:12px;background:var(--well);white-space:nowrap}
994
+ table.dt th:first-child,table.dt td:first-child{padding-left:var(--s4)}
995
+ table.dt tbody tr:last-child td{border-bottom:0}
996
+ table.dt .num{text-align:right;font-family:var(--font-mono);white-space:nowrap}
997
+ table.dt td.bad{color:var(--sev-broken-fg);font-weight:600}
998
+ table.dt td.bad .xs{font-weight:400;white-space:normal}
999
+ table.dt td.bad .xs a{display:inline-flex;align-items:center;min-height:24px}
1000
+ table.dt a{color:var(--fg)}
1001
+ .cov td.c,.cov th.c,table.dt td.c,table.dt th.c{text-align:center;font-family:var(--font-mono);white-space:nowrap}
1002
+ .y{color:var(--sev-ok-fg)}.x{color:var(--sev-broken-fg);font-weight:600}.o{color:var(--muted)}
1003
+ .plat-v{display:inline-flex;align-items:center;gap:4px;margin-right:8px;white-space:nowrap}
1004
+ .sevbar{display:flex;height:6px;border-radius:3px;overflow:hidden;gap:2px;margin:var(--s1) 0 var(--s2)}
1005
+ .sevbar span{display:block;background:var(--sev)}
1006
+ .lead{margin-bottom:var(--s4)}
1007
+ .flist{list-style:none;margin:0;padding:0;display:grid;gap:var(--s2)}
1008
+ .finding{background:var(--card);border:1px solid var(--border);border-left:3px solid var(--sev);border-radius:var(--r-md);box-shadow:var(--shadow-sm);scroll-margin-top:72px}
1009
+ summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
1010
+ .chev{color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
1011
+ .finding>details>summary{display:flex;align-items:center;gap:var(--s3);padding:14px var(--s4)}
1012
+ .fmark{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:var(--r-sm);background:var(--sev-bg);color:var(--sev-fg);flex:none}
1013
+ .fhead{flex:1;min-width:0;display:grid;gap:4px}
1014
+ .ftitle{overflow-wrap:anywhere}
1015
+ .fmeta{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;font-size:12px;color:var(--muted)}
1016
+ .fbody{padding:0 var(--s4) var(--s4);border-top:1px solid var(--border)}
1017
+ .note{margin-top:var(--s3);padding:6px 10px;border:1px dashed var(--border);border-radius:var(--r-sm);color:var(--muted)}
1018
+ .impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;margin-top:var(--s3);background:var(--sev-bg);border-radius:var(--r-sm)}
1019
+ .impact .ic{color:var(--sev-fg);margin-top:3px}
1020
+ .impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
1021
+ .fcols{display:grid;grid-template-columns:minmax(0,1fr);gap:0 var(--s6)}
1022
+ .fcol{min-width:0}
1023
+ .sec-h{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:500;color:var(--muted);margin:var(--s4) 0 var(--s2)}
1024
+ .sec-h .ic{color:var(--accent-fg)}
1025
+ .fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:var(--s2);counter-reset:fx}
1026
+ .fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
1027
+ .fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:var(--r-sm);background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
1028
+ .where{display:flex;flex-wrap:wrap;gap:6px}
1029
+ .also-fine{margin-top:var(--s2)}
1030
+ .verify{display:flex;flex-wrap:wrap;gap:6px 14px;margin:0;padding:0;list-style:none;font-size:13px}
1031
+ .verify a{display:inline-flex;align-items:center;gap:4px;min-height:24px}
1032
+ .ptable{width:100%;border-collapse:collapse;font:12.5px/1.5 var(--font-mono);border:1px solid var(--border);border-radius:var(--r-sm)}
1033
+ .ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
1034
+ .ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px;background:var(--well)}
1035
+ .glossary th{min-width:170px}
1036
+ .ptable td{overflow-wrap:anywhere}
1037
+ .ptable tr:last-child>*{border-bottom:0}
1038
+ .gtm-why{margin-top:4px}
1039
+ .missing{display:inline-block;padding:0 6px;border-radius:var(--r-sm);background:var(--sev-broken-bg);color:var(--sev-broken-fg);font-weight:500}
1040
+ .jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--well);border-radius:var(--r-md);margin:var(--s4) 0}
1041
+ html:not(.js) .jtabs{display:none}
1042
+ .jtabs [role=tab]{appearance:none;border:0;background:none;display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:var(--r-sm);color:var(--muted);font:500 13px var(--font-sans);cursor:pointer}
1043
+ .jtabs [role=tab]:hover{color:var(--fg)}
1044
+ .jtabs [role=tab][aria-selected=true]{background:var(--card);color:var(--fg);box-shadow:var(--shadow-sm)}
1045
+ .jpanel{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--s4);scroll-margin-top:72px}
1046
+ html:not(.js) .jpanel{margin-top:var(--s5)}
1047
+ html.js .jpanel:not(.active){display:none}
1048
+ .jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s3);flex-wrap:wrap}
1049
+ .jhead h3{font-size:16px}
1050
+ .jbadges{display:flex;gap:6px;flex-wrap:wrap}
1051
+ .ctl{list-style:none;margin:var(--s2) 0 0;padding:0;display:grid;gap:var(--s2)}
1052
+ .ctl>li{border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;background:var(--page)}
1053
+ .ctl .ch{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;font-size:13px;margin-bottom:6px}
1054
+ .ctl .ch b{font-weight:600}
1055
+ .sigs{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:6px}
1056
+ .sig{display:flex;justify-content:space-between;gap:8px;align-items:center;border:1px solid var(--border);border-radius:var(--r-sm);padding:4px 10px;font:12px var(--font-mono);background:var(--card)}
1057
+ .sig .granted{color:var(--sev-ok-fg)}.sig .denied{color:var(--sev-warning-fg)}
1058
+ .consent-card .muted.xs{margin-top:var(--s2)}
1059
+ table.steps{width:100%;border-collapse:collapse;font-size:13px}
1060
+ table.steps th,table.steps td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top}
1061
+ table.steps thead th{font-weight:500;color:var(--muted);font-size:12px;background:var(--well);white-space:nowrap}
1062
+ table.steps tbody th{padding-left:var(--s3)}
1063
+ table.steps tbody tr:last-child>*{border-bottom:0}
1064
+ table.steps tr.flagged>th{box-shadow:inset 3px 0 0 var(--sev)}
1065
+ table.steps .slabel{min-width:180px;overflow-wrap:anywhere}
1066
+ .smeta{display:block;margin-top:2px}
1067
+ table.steps .time{font-size:12px;color:var(--muted);white-space:nowrap}
1068
+ table.steps .num{text-align:right;font-family:var(--font-mono)}
1069
+ table.steps .hits{width:100%}
1070
+ .hits-wrap{display:flex;flex-wrap:wrap;gap:4px}
1071
+ table.steps tr.has-d>*{border-bottom:0}
1072
+ table.steps .drow td{padding:0 var(--s3) var(--s2) 42px}
1073
+ table.steps .drow:last-child td{border-bottom:0}
1074
+ .drow>td>details>summary{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--muted);padding:4px 0;min-height:24px}
1075
+ .drow>td>details>summary:hover{color:var(--fg)}
1076
+ .hit{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 4px;border:1px solid var(--border);border-radius:var(--r-sm);background:var(--card)}
1077
+ .hit code{background:none;padding:0;font-size:12px}
1078
+ .hit.flag{border-color:var(--sev);background:var(--sev-bg)}
1079
+ .hit.flag .ic:last-of-type{color:var(--sev-fg)}
1080
+ .sdetail h5{font-size:12px;font-weight:500;color:var(--muted);margin:var(--s3) 0 6px}
1081
+ .netblock{margin-top:var(--s3)}
1082
+ .netblock>summary{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;color:var(--muted);padding:4px 0;min-height:24px}
1083
+ .netblock>summary:hover{color:var(--fg)}
1084
+ .netblock>.evlist{margin-top:6px}
1085
+ .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--r-sm);background:var(--card)}
1086
+ .evlist>li+li{border-top:1px solid var(--border)}
1087
+ .event>details>summary{display:flex;align-items:center;gap:10px;padding:7px 10px;flex-wrap:wrap}
1088
+ .event>details>summary:hover{background:var(--well)}
1089
+ .event>details>summary .chev{margin-left:auto}
1090
+ .event .meta{font-size:12px;color:var(--muted)}
1091
+ .event .time{font-size:12px;color:var(--muted)}
1092
+ .ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
1093
+ .event-body{padding:4px 10px 12px 36px}
1094
+ .flagnote{margin:4px 0 10px;padding:6px 10px;border-radius:var(--r-sm);background:var(--sev-bg);color:var(--sev-fg);font-size:13px}
1095
+ .flagnote a{color:inherit;text-decoration:underline}
1096
+ .consents{margin:0 0 var(--s2)}
1097
+ .consent{font-size:12px;padding:1px 6px;border-radius:var(--r-sm);font-family:var(--font-mono);white-space:nowrap}
1098
+ .consent.granted{background:var(--sev-ok-bg);color:var(--sev-ok-fg)}.consent.denied{background:var(--sev-warning-bg);color:var(--sev-warning-fg)}
1099
+ .ebgrid{display:grid;gap:var(--s3);margin-top:var(--s2)}
1100
+ .ebgrid>*{min-width:0}
1101
+ .ebgrid aside{background:var(--well);border-radius:var(--r-sm);padding:8px 12px;align-self:start}
1102
+ .ebgrid aside h6{margin:0 0 6px}
1103
+ h6{margin:var(--s3) 0 6px;font-size:12px;font-weight:500;color:var(--muted)}
1104
+ .srclist{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12px}
1105
+ .srclist li{display:grid;gap:1px;overflow-wrap:anywhere}.srclist .lbl{color:var(--muted)}
1106
+ .method{font:500 12px var(--font-mono);padding:0 5px;border-radius:var(--r-sm);background:var(--accent-bg);color:var(--accent-fg)}
1107
+ .status{color:var(--sev-ok-fg);font-family:var(--font-mono);font-size:12px}
1108
+ .item{border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;margin-bottom:8px}
1109
+ .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:8px}
1110
+ .item-head b{font-weight:500}
1111
+ .item-idx{font:12px var(--font-mono);color:var(--muted)}
1112
+ .item-price{margin-left:auto;font-size:12px}
1113
+ .grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
1114
+ .grid4 dt{font:12px var(--font-mono);color:var(--muted);overflow-wrap:anywhere}.grid4 dd{margin:0;font:12px var(--font-mono);overflow-wrap:anywhere}
1115
+ .pk{margin-right:4px}
1116
+ .q{position:relative;display:inline-grid;place-items:center;width:15px;height:15px;border:1px solid var(--muted);border-radius:50%;font:500 10px var(--font-sans);color:var(--muted);cursor:help;vertical-align:1px}
1117
+ .q:hover{border-color:var(--accent);color:var(--accent-fg)}
1118
+ .q:hover::after{content:attr(data-tip);position:absolute;left:0;top:calc(100% + 6px);z-index:5;width:260px;padding:8px 10px;background:var(--fg);color:var(--page);font:400 12px/1.45 var(--font-sans);border-radius:var(--r-sm);white-space:normal;text-align:left}
1119
+ tr.pr td{color:var(--muted)}
1120
+ details.raw{margin-top:10px}details.raw>summary{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);min-height:24px}
1121
+ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--well);border-radius:var(--r-sm);padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
1122
+ .scope-note{margin-top:10px}
1123
+ main{flex:1 0 auto;width:100%}
1124
+ .footer{border-top:1px solid var(--border);background:var(--card);margin-top:var(--s6)}
1125
+ .footer .wrap{display:grid;gap:var(--s4);padding:var(--s6) var(--s4)}
1126
+ .footer p{font-size:12.5px;color:var(--muted)}
1127
+ .footer a{color:var(--fg)}
1128
+ .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
1129
+ .f-meta{font:12px var(--font-mono);color:var(--muted)}
1130
+ .logo-link{display:inline-flex}
1131
+ .f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
1132
+ .title{margin-top:20px;display:grid;gap:4px}.title .meta{font-size:12px;color:var(--muted)}.product-logo{display:inline-flex}.card-c.tight{padding:8px}.ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
1133
+ @media (min-width:768px){
1134
+ .wrap{padding:0 var(--s5)}
1135
+ .card-h{padding:var(--s4) var(--s5) 0}.card-c{padding:var(--s3) var(--s5) var(--s4)}
1136
+ table.dt th:first-child,table.dt td:first-child{padding-left:var(--s5)}
1137
+ .facts{grid-template-columns:repeat(3,minmax(0,1fr))}
1138
+ .ov-grid{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}
1139
+ .fcols{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
1140
+ .ebgrid{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}
1141
+ .grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
1142
+ .footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:var(--s6) var(--s5)}
1143
+ .footer .wrap.plain{grid-template-columns:1fr auto}
1144
+ .verdict{padding:var(--s6) 0}
1145
+ }
1146
+ @media (max-width:480px){.logo-atd img{height:22px}.byline span{display:none}.logo-h img{height:14px}.topbar .wrap{gap:var(--s2)}}
1147
+ @media (max-width:640px){
1148
+ .counts{grid-template-columns:repeat(3,1fr)}
1149
+ .count a{padding:8px 10px}
1150
+ .count .v{font-size:20px}
1151
+ .run div{grid-template-columns:1fr}
1152
+ table.steps thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
1153
+ table.steps tr{display:grid;grid-template-columns:auto 1fr auto;grid-template-areas:"idx label time" "hits hits hits";gap:4px 8px;padding:8px var(--s3);border-bottom:1px solid var(--border)}
1154
+ table.steps tr>*{border-bottom:0!important;padding:0!important}
1155
+ table.steps tr.flagged{box-shadow:inset 3px 0 0 var(--sev)}
1156
+ table.steps tr.flagged>th{box-shadow:none}
1157
+ table.steps tbody th{grid-area:idx}table.steps .slabel{grid-area:label;min-width:0}table.steps .time{grid-area:time}table.steps .hits{grid-area:hits}
1158
+ table.steps .num{display:none}
1159
+ table.steps .drow{display:block;padding:0 var(--s3) var(--s2) 42px}
1160
+ table.steps .drow td{display:block}
1161
+ .event-body{padding-left:10px}
1162
+ .finding>details>summary{flex-wrap:wrap;align-items:flex-start}
1163
+ .finding>details>summary .badge{margin-left:44px}
1164
+ .finding>details>summary .chev{display:none}
1165
+ .finding>details>summary .fhead{flex-basis:calc(100% - 44px)}
1166
+ }
1167
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
1168
+ @media print{
1169
+ :root{--page:#fff;--card:#fff;--well:#f2f2f2;--shadow-sm:none}
1170
+ body{font-size:12.5px;background:#fff}
1171
+ .topbar .actions,.tabbar,.jtabs,.btn,.skip,.print-skip{display:none!important}
1172
+ .panel,.jpanel{display:block!important}
1173
+ .panel>h2{display:block!important}
1174
+ .jpanel+.jpanel{margin-top:var(--s5)}
1175
+ .panel{break-before:page;padding:0}
1176
+ #overview{break-before:auto}
1177
+ h2,h3{break-after:avoid}
1178
+ .finding,.ptable,table.steps tr,table.dt tr,.ctl>li,.count a,.fixlist a,.oklist li,.event,.card{break-inside:avoid}
1179
+ .tabbar,.finding,.jpanel{position:static}
1180
+ .finding>details>*,.drow[class*=sev-]>td>details>*,.event[class*=flag-]>details>*{display:block}
1181
+ .chev,.q{display:none}
1182
+ .badge,.chip,.missing,.consent{background:none!important;border:1px solid currentColor}
1183
+ .impact,.flagnote,.hit.flag{background:none!important;border:1px solid currentColor}
1184
+ .fmark{background:none;border:1px solid currentColor}
1185
+ table.dt thead th,table.steps thead th,.ptable th,.ebgrid aside{background:#f2f2f2;-webkit-print-color-adjust:exact;print-color-adjust:exact}
1186
+ .sevbar span,.count a,.finding,.fixlist a,table.steps tr.flagged>th,.spark{-webkit-print-color-adjust:exact;print-color-adjust:exact}
1187
+ a{color:inherit}
1188
+ .verdict{padding:0 0 var(--s4);border:0}
1189
+ .footer{margin-top:var(--s5)}
1190
+ }
1191
+ `;
1192
+
1193
+ /** A logo with its light and dark versions as two images; the CSS shows the one that fits the colour scheme or the forced theme. */
1194
+ export const logo = (key, alt, height, cls) => `<span class="logo ${cls}"><img class="l-light" src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"><img class="l-dark" src="${LOGOS[`${key}-dark`]}" alt="${alt}" height="${height}"></span>`;
478
1195
 
479
1196
  /** The journey and step a finding's place points at. Steps are named by label; when labels repeat, prefer the step where the finding's event fired. */
480
1197
  function whereTarget(runs, w, f) {
@@ -485,184 +1202,196 @@ function whereTarget(runs, w, f) {
485
1202
  return { ri, step };
486
1203
  }
487
1204
 
488
- /** The push a hit came from: the latest matching push at or before the hit, else the first match. */
489
- function sourcePush(run, hit) {
490
- const pushes = pushesForHit(run, hit);
491
- return pushes.filter((e) => e.t <= hit.t).at(-1) ?? pushes[0] ?? null;
492
- }
493
-
494
- /** The step in order: each dataLayer push, the GTM event it raised, then the platform hits it caused. Hits with no push come last. */
495
- function timelinePanel(m, findings, run, step, pushes, hits) {
496
- const bySource = new Map();
497
- const orphans = [];
498
- for (const h of [...hits].sort((a, b) => a.t - b.t)) {
499
- const src = sourcePush(run, h);
500
- if (src && src.step === step.index) {
501
- if (!bySource.has(src)) bySource.set(src, []);
502
- bySource.get(src).push(h);
503
- } else orphans.push(h);
1205
+ const pathOf = (url) => {
1206
+ try {
1207
+ return new URL(url).pathname || "/";
1208
+ } catch {
1209
+ return String(url ?? "");
504
1210
  }
505
- const hitList = (list) => (list.length ? `<ul class="evlist tl-hits">${list.map((h) => eventRow(m, findings, run, step, h)).join("")}</ul>` : `<p class="muted xs tl-none">${m.ui.timelineNoHits}</p>`);
506
- const nodes = [...pushes].sort((a, b) => a.t - b.t).map((e) => {
507
- const name = pushEvent(e.value);
508
- const gtm = isConsentEntry(e.value)
509
- ? `<span class="tl-k">${m.ui.timelineConsent}</span><code>${escapeHtml(`consent ${e.value[1] ?? ""}`.trim())}</code>`
510
- : typeof name === "string" && name
511
- ? `<span class="tl-k">${m.ui.timelineGtmEvent}</span><code>${escapeHtml(name)}</code>`
512
- : `<span class="tl-k muted">${m.ui.timelineNoEvent}</span>`;
513
- return `<li class="tl-node"><div class="tl-lbl">dataLayer</div><ul class="evlist">${pushRow(m, run, e)}</ul><div class="tl-gtm">${gtm}</div>${hitList(bySource.get(e) ?? [])}</li>`;
514
- });
515
- if (orphans.length) nodes.push(`<li class="tl-node tl-orphan"><div class="tl-lbl">${m.ui.timelineOther}</div>${hitList(orphans)}</li>`);
516
- return nodes.length ? `<ol class="timeline">${nodes.join("")}</ol>` : `<p class="empty">${m.ui.noHits}</p>`;
517
- }
1211
+ };
518
1212
 
519
- function stepDetail(m, lang, findings, run, ri, step, isDefault) {
520
- const hits = run.hits.filter((h) => h.step === step.index);
521
- const id = `s${ri}-${step.index}`;
522
- const sortedHits = [...hits].sort((a, b) => platformRank(a.platform) - platformRank(b.platform) || a.t - b.t);
523
- const eventRows = sortedHits.map((h) => ({ key: safeKey(h.platform), label: platformLabel(h.platform), html: eventRow(m, findings, run, step, h) }));
524
- const pushes = (run.dataLayerLog ?? []).filter((e) => e.step === step.index);
525
- const reqRows = requestRows(m, lang, run, step);
526
- const skipped = skippedText(run.skipped?.[step.index]);
527
- const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
528
- const tab = (n, label, count) => `<input type="radio" name="${id}" id="${id}-${n}"${n === 1 ? " checked" : ""}><label for="${id}-${n}">${label} <span class="n">${count}</span></label>`;
529
- return `<section class="sdetail${isDefault ? " default" : ""}" id="${stepAnchor(ri, step.index)}" aria-label="${escapeHtml(step.label)}">
530
- <header class="shead"><span class="sidx">${step.index}</span><div><h3>${escapeHtml(step.label)}</h3><div class="muted xs">${escapeHtml(m.ui.stepMeta(seconds(run, step.t), hits.length, pushes.length, skippedN))}${step.forced ? ` · ${m.ui.clickedThrough}` : ""}</div></div></header>
531
- <div class="tabs seg">${tab(1, m.ui.tabTimeline, pushes.length + hits.length)}${tab(2, m.ui.tabEvents, eventRows.length)}${tab(3, "dataLayer", pushes.length)}${tab(4, m.ui.tabNetwork, reqRows.length)}
532
- <div class="panel">${timelinePanel(m, findings, run, step, pushes, hits)}</div>
533
- <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
534
- <div class="panel">${pushes.length ? `<ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : `<p class="empty">${m.ui.noPushes}</p>`}</div>
535
- <div class="panel">${reqRows.length ? filtered(m, `${id}n`, reqRows, reqRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noRequests}</p>`}${skippedN ? `<p class="muted xs scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</div>
536
- </div></section>`;
1213
+ /** The same problem's identity across the grouped rows, the metrics and the history: severity, check, platform and event. */
1214
+ const problemKey = (f) => [f.severity, f.check, f.platform ?? "", f.event ?? ""].join("|");
1215
+ /** The grouped finding's full identity, as groupFindings keys it, so a metrics entry finds its card. */
1216
+ const rowKey = (f) => `${problemKey(f)}|${f.detail ?? ""}`;
1217
+
1218
+ /** Stable card ids (f-<platform>-<event>-<check>, with a counter when two cards would share one) for the problem rows. */
1219
+ function cardIds(rows) {
1220
+ const taken = new Map();
1221
+ const ids = new Map();
1222
+ for (const f of rows) {
1223
+ if (f.severity === "ok") continue;
1224
+ const base = ["f", safeKey(f.platform ?? "site"), f.event ? safeKey(f.event) : "", safeKey(f.check)].filter(Boolean).join("-");
1225
+ const n = (taken.get(base) ?? 0) + 1;
1226
+ taken.set(base, n);
1227
+ ids.set(f, n === 1 ? base : `${base}-${n}`);
1228
+ }
1229
+ return ids;
537
1230
  }
538
1231
 
539
- /** One journey: a sticky step list on the left and the selected step on the right. It opens on the first step with a problem. */
540
- /** Every Consent Mode command of a journey (default, then updates), one tile per signal. */
541
- function consentCard(m, run) {
542
- const cmds = run.dataLayerLog.filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
543
- const head = `<div class="card-h"><div class="card-t">${m.ui.consent}</div><div class="card-d">${cmds.length ? m.ui.consentLead : m.ui.consentNone}</div></div>`;
544
- if (!cmds.length) return `<div class="card jconsent">${head}<div class="card-c"></div></div>`;
545
- const items = cmds.map((e) => {
546
- const step = run.steps.find((s) => s.index === e.step);
547
- const sigs = Object.entries(e.value[2] ?? {}).filter(([, v]) => typeof v === "string");
548
- return `<li><div class="ch"><b>${e.value[1] === "default" ? m.ui.consentDefault : m.ui.consentUpdate}</b><span class="muted xs">${step ? `${step.index} · ${escapeHtml(step.label)}` : ""}</span></div><div class="sigs">${sigs.map(([k, v]) => `<span class="sig"><span>${escapeHtml(k)}</span><span class="${v === "granted" ? "granted" : "denied"}">${escapeHtml(v)}</span></span>`).join("")}</div></li>`;
549
- });
550
- return `<div class="card jconsent">${head}<div class="card-c"><ol class="ctl">${items.join("")}</ol></div></div>`;
1232
+ /** Runs where the finding's event fired but the finding did not: the "desktop sent both" contrast. */
1233
+ function alsoFineOn(runs, f) {
1234
+ if (!f.event || !f.platform) return [];
1235
+ const flagged = new Set(f.where.map((w) => `${w.journey}|${w.viewport}`));
1236
+ return runs.filter((r) => !flagged.has(`${r.journeyId}|${r.viewport}`) && r.hits.some((h) => h.platform === f.platform && h.name === f.event));
551
1237
  }
552
1238
 
553
- function journeyPanel(m, lang, findings, run, ri, flagged) {
554
- const steps = run.steps.filter((s) => s.index > 0);
555
- const def = steps.find((s) => flagged.has(stepAnchor(ri, s.index))) ?? steps[0];
556
- const nav = steps
557
- .map((s) => {
558
- const hits = run.hits.filter((h) => h.step === s.index);
559
- const counts = sortPlatforms(new Set(hits.map((h) => h.platform))).map((p) => `<span class="dot p-${safeKey(p)}"></span>${hits.filter((h) => h.platform === p).length}`);
560
- const a = stepAnchor(ri, s.index);
561
- return `<li><a href="#${a}" data-s="${a}"${s === def ? ' class="def"' : ""}><span class="sidx">${s.index}</span><span class="sl">${escapeHtml(s.label)}</span>${flagged.has(a) ? `<span class="dot-flag" title="${m.ui.hasProblems}"></span>` : "<span></span>"}<span class="sc">${counts.join(" ") || '<span class="muted">–</span>'}</span></a></li>`;
562
- })
563
- .join("");
564
- const body = steps.length
565
- ? `<div class="jgrid2"><nav class="stepnav" aria-label="${m.ui.stepsNav}"><ol>${nav}</ol></nav><div class="sdetails">${steps.map((s) => stepDetail(m, lang, findings, run, ri, s, s === def)).join("\n")}</div></div>`
566
- : `<p class="empty">${m.ui.noSteps}</p>`;
567
- return `<article class="jpanel" id="j${ri}"><header class="jhead"><div><h2>${escapeHtml(run.journeyId)} <span class="muted">· ${escapeHtml(run.viewport)}</span></h2>${run.note ? `<p class="muted xs">${escapeHtml(run.note)}</p>` : ""}</div>${run.completed ? badge("ok", m.ui.completed) : badge("warning", m.ui.notCompleted)}</header>
568
- ${consentCard(m, run)}${body}</article>`;
1239
+ /** The plan's required parameters the finding's event lacks, as evidence rows marked "missing". */
1240
+ function missingParamRows(plan, f, evidence) {
1241
+ if (f.check !== "missing_params" || !f.event) return [];
1242
+ const ev = (plan?.events ?? []).find((e) => String(e.platform ?? "").toLowerCase() === f.platform && hitMatchesEvent({ ...e, platform: f.platform }, { platform: f.platform, name: f.event }));
1243
+ return (ev?.required_params ?? []).filter((p) => !(p in evidence));
569
1244
  }
570
1245
 
571
- /** Consent scenarios: one row per tag, one column per consent choice, hit counts after the choice. */
572
- function scenariosCard(m, scenarios) {
573
- if (!scenarios) return "";
574
- const { cols, rows } = consentTable(scenarios);
575
- const head = `<tr><th>${m.ui.scenarioTag}</th>${cols.map((c) => `<th>${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
576
- const body = rows.map((r) => `<tr><td><span class="ptag p-${safeKey(r.platform)}">${escapeHtml(platformLabel(r.platform))}</span> <code>${escapeHtml(r.event)}</code></td>${r.cells.map((c) => (c.count === null ? `<td class="muted xs">–</td>` : `<td class="num${c.problem ? " bad" : ""}">${c.count}</td>`)).join("")}</tr>`).join("");
577
- const notRun = cols.filter((c) => c !== "before" && !scenarios.states[c]?.via).map((c) => m.ui.scenarioCols[c]);
578
- return `<div class="card jconsent scenarios"><div class="card-h"><div class="card-t">${m.ui.scenarios}</div><div class="card-d">${escapeHtml(m.ui.scenariosLead(scenarios.cmp))}${notRun.length ? ` ${escapeHtml(m.ui.scenarioNotRun)}: ${escapeHtml(notRun.join(", "))}.` : ""}</div></div><div class="card-c tight"><table class="dt"><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
1246
+ /** The captured hit a finding points at, for evidence when the finding carries none. */
1247
+ function hitFor(runs, f) {
1248
+ for (const w of f.where) {
1249
+ const t = whereTarget(runs, w, f);
1250
+ if (!t?.step) continue;
1251
+ const hit = runs[t.ri].hits.find((h) => h.step === t.step.index && h.platform === f.platform && h.name === f.event);
1252
+ if (hit) return { run: runs[t.ri], hit };
1253
+ }
1254
+ return null;
579
1255
  }
580
1256
 
581
- function journeysTab(m, lang, findings, runs, flagged, scenarios = null) {
582
- if (!runs.length) return `${scenariosCard(m, scenarios)}<p class="empty">${m.ui.noSteps}</p>`;
583
- const tabs = runs.map((r, ri) => `<a href="#j${ri}">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span><span class="n">${problemCount(findings, r)}</span></a>`).join("");
584
- return `${scenariosCard(m, scenarios)}<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
1257
+ const missingCell = (m) => `<span class="missing">${m.ui.missing}</span>`;
1258
+
1259
+ /** Evidence table: the finding's captured parameters (plus the plan's missing ones), the hit it points at, or the missing request. */
1260
+ function evidenceHtml(m, runs, plan, f) {
1261
+ let captured = null;
1262
+ if (f.evidence) {
1263
+ try {
1264
+ const v = JSON.parse(f.evidence);
1265
+ if (v && typeof v === "object" && !Array.isArray(v)) captured = Object.entries(v).map(([k, x]) => [k, escapeHtml(cell(x))]);
1266
+ } catch { /* plain text */ }
1267
+ if (!captured) return `<pre>${escapeHtml(f.evidence)}</pre>`;
1268
+ }
1269
+ const rows = captured ?? [];
1270
+ if (!captured) {
1271
+ const found = hitFor(runs, f);
1272
+ if (found) {
1273
+ rows.push(...Object.entries(found.hit.params ?? {}).map(([k, v]) => [k, escapeHtml(cell(v))]));
1274
+ rows.push([m.ui.request, `<span class="mono">${escapeHtml(hostPath(found.hit.url))}</span> · ${escapeHtml(seconds(found.run, found.hit.t))}`]);
1275
+ if (found.hit.consent) rows.push([m.ui.consent, Object.entries(found.hit.consent).map(([k, v]) => consentChip(k, v)).join(" ")]);
1276
+ } else if (f.check === "missing_event" && f.platform) rows.push([m.ui.requestTo(platformLabel(f.platform)), missingCell(m)]);
1277
+ }
1278
+ for (const p of missingParamRows(plan, f, Object.fromEntries(rows))) rows.push([p, missingCell(m)]);
1279
+ if (!rows.length) return "";
1280
+ return `<table class="ptable" aria-label="${m.ui.evidence}"><tbody>${rows.map(([k, v]) => `<tr><th scope="row">${escapeHtml(k)}</th><td>${v}</td></tr>`).join("")}</tbody></table>`;
585
1281
  }
586
1282
 
587
- /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
588
- function evidenceHtml(evidence) {
589
- try {
590
- const v = JSON.parse(evidence);
591
- if (v && typeof v === "object" && !Array.isArray(v)) return `<table class="ptable ev-table"><tbody>${Object.entries(v).map(([k, x]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(cell(x))}</td></tr>`).join("")}</tbody></table>`;
592
- } catch { /* plain text */ }
593
- return `<pre>${escapeHtml(evidence)}</pre>`;
1283
+ /** The tags in the GTM container export that send this finding's event, with their triggers. */
1284
+ function gtmHtml(m, tags) {
1285
+ if (!tags) return "";
1286
+ if (!tags.length) return `${sec("gtm", m.ui.gtmTags)}<p class="muted">${m.ui.gtmNoTag}</p>`;
1287
+ const trig = (t) => `${escapeHtml(t.name)}${t.event ? ` <span class="muted">(${escapeHtml(t.event)})</span>` : ""}`;
1288
+ const rows = tags.map((t) => {
1289
+ const notes = [t.paused ? m.ui.gtmPaused : "", t.consent.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
1290
+ const why = t.evaluation ? `<div class="xs gtm-why">${escapeHtml(m.ui.gtmWhy(t.name, m.ui.gtmVerdict[t.evaluation.fired], t.evaluation.why))}</div>` : "";
1291
+ return `<tr><th scope="row">${escapeHtml(t.name)}${notes.length ? `<div class="muted xs">${escapeHtml(notes.join(" · "))}</div>` : ""}</th><td>${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map(trig).join(", ")}` : `<span class="missing">${m.ui.gtmNoTrigger}</span>`}${t.blocking.length ? `<div class="muted xs">${m.ui.gtmBlockedBy} ${t.blocking.map(trig).join(", ")}</div>` : ""}${why}</td></tr>`;
1292
+ });
1293
+ return `${sec("gtm", m.ui.gtmTags)}<table class="ptable gtm-tags" aria-label="${m.ui.gtmTags}"><tbody>${rows.join("")}</tbody></table>`;
594
1294
  }
595
1295
 
1296
+ const externalLink = (m, url, name) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(name)}${use("ui-external", 12)}<span class="sr"> (${m.html.newTab})</span></a>`;
1297
+
596
1298
  function verifyHtml(m, f) {
597
1299
  const links = verifyLinks(f);
598
1300
  if (!links.length) return "";
599
- return `${sec("cause", m.ui.verify)}<ul class="verify">${links.map((l) => `<li><a href="${l.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(l.name)}</a></li>`).join("")}</ul>`;
1301
+ return `${sec("verify", m.ui.verify)}<ul class="verify">${links.map((l) => `<li>${externalLink(m, l.url, l.name)}</li>`).join("")}</ul>`;
1302
+ }
1303
+
1304
+ /** The check's own consequence or fix text from i18n, when a language has it; a function gets the finding. */
1305
+ const checkText = (block, f) => {
1306
+ const v = block?.[f.check];
1307
+ return typeof v === "function" ? v(f) : typeof v === "string" ? v : "";
1308
+ };
1309
+
1310
+ const placeText = (w) => `${w.journey} · ${w.viewport}`;
1311
+
1312
+ /** Where a problem was seen, in short: every run, the one or two places, or a count. */
1313
+ function spreadText(m, runs, f) {
1314
+ const places = [...new Set(f.where.map((w) => `${w.journey}|${w.viewport}`))];
1315
+ if (runs.length > 1 && places.length === runs.length) return m.html.allRuns(runs.length);
1316
+ if (places.length <= 2) return m.html.onlyOn(places.map((p) => p.replace("|", " · ")));
1317
+ return m.ui.spread(places.length, f.where.filter((w) => w.step).length);
600
1318
  }
601
1319
 
602
- function findingCard(m, runs, f, open, aiRan) {
1320
+ function whereChips(m, runs, f) {
1321
+ const chips = f.where.map((w) => {
1322
+ const t = whereTarget(runs, w, f);
1323
+ const page = t?.step?.pagePath ? pathOf(t.step.pagePath) : null;
1324
+ const inner = `${t?.step ? `<span class="chip-step">${t.step.index}</span>` : ""}<span>${escapeHtml(placeText(w))}${w.step ? ` <span class="muted">· ${escapeHtml(w.step)}</span>` : ""} <span class="muted">· ${page ? `<code>${escapeHtml(page)}</code>` : m.html.pageUnknown}</span></span>`;
1325
+ return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
1326
+ });
1327
+ const fine = alsoFineOn(runs, f);
1328
+ const also = fine.length ? `<p class="xs muted also-fine">${m.html.alsoFineOn} ${fine.map((r) => `<a href="#j${runs.indexOf(r)}">${escapeHtml(`${r.journeyId} · ${r.viewport}`)}</a>`).join(", ")}.</p>` : "";
1329
+ return `<div class="where">${chips.join("")}</div>${also}`;
1330
+ }
1331
+
1332
+ function findingCard(m, runs, plan, f, id, open) {
603
1333
  const e = f.explanation;
604
1334
  const steps = fixSteps(e);
605
- const journeysN = new Set(f.where.map((w) => `${w.journey}|${w.viewport}`)).size;
606
- const stepsN = f.where.filter((w) => w.step).length;
607
- const evidence = f.evidence
608
- ? evidenceHtml(f.evidence)
609
- : f.check === "missing_event" && f.platform
610
- ? `<table class="ptable ev-table"><tbody><tr><th>${escapeHtml(m.ui.requestTo(platformLabel(f.platform)))}</th><td><span class="missing">${m.ui.missing}</span></td></tr></tbody></table>`
611
- : "";
612
- const chips = f.where
613
- .map((w) => {
614
- const t = whereTarget(runs, w, f);
615
- const inner = `${t?.step ? `<span class="chip-step">${t.step.index}</span>` : ""}${escapeHtml(w.journey)} · ${escapeHtml(w.viewport)}${w.step ? ` <span class="muted">· ${escapeHtml(w.step)}</span>` : ""}`;
616
- return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
617
- })
618
- .join("");
619
- return `<li class="finding sev-${f.severity}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
620
- <span class="sev-icon">${ic(sevIcon(f.severity), 18)}</span>
621
- <span class="fhead"><span class="ftitle">${detailHtml(f.detail)}</span><span class="fmeta">${f.platform ? `<span class="ptag p-${safeKey(f.platform)}">${escapeHtml(platformLabel(f.platform))}</span>` : ""}<span>${escapeHtml(m.ui.spread(journeysN, stepsN))}</span></span></span>
622
- ${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
623
- <div class="fbody">${e?.impact ? `<div class="impact">${ic(ICON.impact, 16)}<div><span class="impact-l">${m.ui.impact}</span>${fmt(e.impact)}</div></div>` : ""}
624
- <div class="fcols"><div class="fcol">${e?.likely_cause ? `${sec("cause", m.ui.likelyCause)}<p>${fmt(e.likely_cause)}</p>` : aiRan || f.severity === "info" ? "" : `${sec("cause", m.ui.likelyCause)}<p class="muted">${m.ui.noExplanation}</p>`}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}</div>
625
- <div class="fcol">${steps.length ? `${sec("fix", m.ui.fixSteps)}<ol class="fix-steps">${steps.map((t) => `<li><span>${fmt(t)}</span></li>`).join("")}</ol>` : ""}${sec("where", m.ui.where)}<div class="where">${chips}</div>${verifyHtml(m, f)}</div></div></div></details></li>`;
1335
+ const impact = e?.impact ? fmt(e.impact) : escapeHtml(checkText(m.consequence, f));
1336
+ const cause = e?.likely_cause ? `<p>${fmt(e.likely_cause)}</p>` : f.severity === "info" ? "" : `<p class="muted">${m.html.noModel}</p>`;
1337
+ const fixText = checkText(m.fix, f);
1338
+ const fix = steps.length ? `<ol class="fix-steps">${steps.map((t) => `<li><span>${fmt(t)}</span></li>`).join("")}</ol>` : fixText ? `<p>${escapeHtml(fixText)}</p>` : "";
1339
+ const evidence = evidenceHtml(m, runs, plan, f);
1340
+ const accepted = f.accepted === true ? `<p class="note xs"><span class="badge">${use("sev-ok", 14)}${m.html.accepted}</span> ${m.html.acceptedNote}</p>` : "";
1341
+ return `<li class="finding sev-${f.severity}" id="${id}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
1342
+ <span class="fmark">${use(`sev-${f.severity}`, 18)}</span>
1343
+ <span class="fhead"><h4 class="ftitle">${detailHtml(f.detail)}</h4><span class="fmeta">${f.platform ? plat(f.platform, 16) : ""}${f.event ? `<code>${escapeHtml(f.event)}</code>` : ""}<span class="chip owner">${m.ui.owners[ownerOf(f)]}</span><span>${escapeHtml(spreadText(m, runs, f))}</span>${f.accepted === true ? `<span class="badge">${m.html.accepted}</span>` : ""}</span></span>
1344
+ ${badge(f.severity, m.severity[f.severity])}${use("ui-chevron", 16, "chev")}</summary>
1345
+ <div class="fbody">${accepted}${impact ? `<div class="impact">${use("ui-impact", 16)}<div><span class="impact-l">${m.ui.impact}</span>${impact}</div></div>` : ""}
1346
+ <div class="fcols"><div class="fcol">${cause ? `${sec("cause", m.ui.likelyCause)}${cause}` : ""}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}${gtmHtml(m, f.gtm)}</div>
1347
+ <div class="fcol">${fix ? `${sec("fix", m.ui.fixSteps)}${fix}` : ""}${sec("where", m.ui.where)}${whereChips(m, runs, f)}${verifyHtml(m, f)}</div></div></div></details></li>`;
626
1348
  }
627
1349
 
628
1350
  const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
629
- const SEV_COLOR = { broken: "var(--danger-dot)", risk: "var(--risk)", warning: "var(--warn-dot)", info: "var(--accent)" };
630
1351
 
631
- function findingsTab(m, runs, rows, aiRan) {
1352
+ /** OK findings as one line per distinct result, with how many runs saw it. */
1353
+ function okLines(m, runs, rows) {
1354
+ const seen = new Map();
1355
+ for (const f of rows.filter((r) => r.severity === "ok")) {
1356
+ const k = f.detail;
1357
+ if (!seen.has(k)) seen.set(k, { detail: f.detail, places: new Set() });
1358
+ for (const w of f.where) seen.get(k).places.add(`${w.journey}|${w.viewport}`);
1359
+ }
1360
+ return [...seen.values()].map((o) => `<li>${badge("ok", m.severity.ok)}<span>${detailHtml(o.detail)} <span class="muted">· ${escapeHtml(o.places.size === runs.length && runs.length > 1 ? m.html.allRuns(runs.length) : [...o.places].map((p) => p.replace("|", " · ")).join(", "))}</span></span></li>`);
1361
+ }
1362
+
1363
+ function findingsPanel(m, runs, plan, rows, ids, counts) {
632
1364
  const problems = rows.filter((f) => f.severity !== "ok");
633
- if (!problems.length) return `<p class="empty">${m.ui.noProblems}</p>`;
634
- const n = Object.fromEntries(FINDING_SEVERITIES.map((s) => [s, problems.filter((f) => f.severity === s).length]));
635
- const present = FINDING_SEVERITIES.filter((s) => n[s]);
636
- const radios = ["all", ...present].map((s) => `<input type="radio" name="fs" id="fs-${s}" class="fsr"${s === "all" ? " checked" : ""}>`).join("");
637
- const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
638
- const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
639
- const labels = [`<label for="fs-all">${m.ui.all} <span class="n">${problems.length}</span></label>`, ...present.map((s) => `<label for="fs-${s}">${m.severity[s]} <span class="n">${n[s]}</span></label>`)].join("");
640
- let first = true;
1365
+ const okCount = rows.length - problems.length;
1366
+ if (!problems.length) {
1367
+ const ok = okLines(m, runs, rows);
1368
+ return `<div class="card"><div class="card-h"><h3>${m.html.whatWasChecked}</h3><p class="card-d">${m.ui.noProblems}</p></div><div class="card-c">${ok.length ? `<ul class="oklist">${ok.join("")}</ul>` : `<p class="muted">${m.html.nothingVerified}</p>`}</div></div>`;
1369
+ }
1370
+ const present = FINDING_SEVERITIES.filter((s) => counts[s]);
1371
+ const bar = `<div class="sevbar" role="img" aria-label="${escapeHtml(present.map((s) => `${counts[s]} ${m.severity[s]}`).join(", "))}">${present.map((s) => `<span class="sev-${s}" style="flex:${counts[s]}"></span>`).join("")}</div>`;
641
1372
  const groups = OWNERS.map((o) => {
642
1373
  const mine = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s && ownerOf(f) === o));
643
1374
  if (!mine.length) return "";
644
- const cards = mine.map((f) => {
645
- const open = first && f.severity !== "info";
646
- if (open) first = false;
647
- return findingCard(m, runs, f, open, aiRan);
648
- }).join("");
649
- return `<section class="ogroup" data-owner="${o}"><h3 class="ogroup-h">${m.ui.owners[o]} <span class="n">${mine.length}</span><span class="muted xs">${m.ui.ownerHint[o]}</span></h3><ul class="flist">${cards}</ul></section>`;
1375
+ // The first card of each owner group is open, so each owner lands on their worst problem with its evidence showing.
1376
+ const cards = mine.map((f, i) => findingCard(m, runs, plan, f, ids.get(f), i === 0 && f.severity !== "info")).join("");
1377
+ return `<section class="ogroup" data-owner="${o}"><h3 class="ogroup-h"><span>${m.ui.owners[o]}</span><span class="n">${mine.length}</span><span class="muted xs">${m.ui.ownerHint[o]}</span></h3><ul class="flist">${cards}</ul></section>`;
650
1378
  }).join("");
651
- return `<div class="filter-host">${radios}
652
- <div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
653
- <div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
654
- ${groups}</div>`;
1379
+ return `${bar}<p class="muted xs lead">${m.html.findingsLead}${okCount ? ` ${escapeHtml(m.html.okInOverview(okCount))}` : ""}</p>${groups}`;
655
1380
  }
656
1381
 
657
- function summaryCard(m, explained, problems) {
1382
+ /** The glyph of a metrics verdict. */
1383
+ const verdictGlyph = (v) => ({ fail_broken: "broken", fail_incomplete: "warning", attention_risk: "risk", attention_warning: "warning", attention_no_hits: "info", attention_nothing_tested: "info", pass: "ok" })[v.headline] ?? "info";
1384
+ /** The owner split starts a sentence here, so it gets a capital; the locale keeps Turkish "i" as "İ". */
1385
+ const sentence = (s, lang) => (s ? s[0].toLocaleUpperCase(lang) + s.slice(1) : s);
1386
+
1387
+ /** "2 for the developer, 2 for Tag Manager." from the priorities, or nothing. */
1388
+ const ownerSplit = (m, priorities) => (priorities.length ? m.html.ownerSplit(priorities.map((p) => m.html.forOwner[p.owner](p.total))) : "");
1389
+
1390
+ function aiSummary(m, explained) {
658
1391
  const s = summaryParts(explained?.summary);
659
- let body;
660
- if (!s) body = `<p class="muted">${problems ? m.ui.noSummary : m.ui.noProblems}</p>`;
661
- else if (s.text) body = `<p class="summary-text">${fmt(s.text)}</p>`;
662
- else {
663
- body = `${s.headline ? `<p class="headline">${fmt(s.headline)}</p>` : ""}${s.points.length ? `<ul class="summary-list">${s.points.map((p) => `<li>${badge(p.severity, m.severity[p.severity])}<span>${fmt(p.text)}</span></li>`).join("")}</ul>` : ""}${s.also_check.length ? `<div><div class="label">${m.ui.alsoCheck}</div><ul class="also">${s.also_check.map((t) => `<li>${fmt(t)}</li>`).join("")}</ul></div>` : ""}`;
664
- }
665
- return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.summary}</div><div class="card-d">${m.ui.summaryLead}</div></div><div class="card-c stack">${body}${problems ? `<p><a href="#findings">${m.ui.seeAllFindings}</a></p>` : ""}</div></div>`;
1392
+ if (!s) return "";
1393
+ const head = s.text ? fmt(s.text) : fmt(s.headline);
1394
+ return `<div class="aisum"><p><span class="ai-l">${m.html.aiSummary}</span> ${head}</p>${s.points?.length ? `<ul class="summary-list">${s.points.map((p) => `<li>${badge(p.severity, m.severity[p.severity])}<span>${fmt(p.text)}</span></li>`).join("")}</ul>` : ""}${s.also_check?.length ? `<p class="xs muted">${m.ui.alsoCheck}</p><ul class="also">${s.also_check.map((t) => `<li>${fmt(t)}</li>`).join("")}</ul>` : ""}</div>`;
666
1395
  }
667
1396
 
668
1397
  /** The providers that were checked: from the runs, or for older captures the platforms seen. */
@@ -679,43 +1408,279 @@ function skippedTotals(runs) {
679
1408
  }
680
1409
 
681
1410
  const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
1411
+ const uniqList = (xs) => [...new Set(xs)];
682
1412
 
683
- /** The run's settings: platforms checked, AI model, plan, journeys, viewports, language and version. */
684
- function runCard(m, runs, provs, { ai, plan, lang }) {
685
- const uniq = (xs) => [...new Set(xs)];
686
- const ptags = provs.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("");
687
- const rows = [
688
- [m.ui.runPlatforms, ptags || '<span class="muted">–</span>'],
689
- [m.ui.runAi, escapeHtml(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)],
690
- [m.ui.runPlan, escapeHtml(plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan)],
691
- [m.ui.runJourneys, escapeHtml(uniq(runs.map((r) => r.journeyId)).join(", ") || "–")],
692
- [m.ui.runViewports, escapeHtml(uniq(runs.map((r) => r.viewport)).join(", ") || "–")],
693
- [m.ui.runLanguage, escapeHtml(messages(lang).name)],
694
- [m.ui.runVersion, `<code>${escapeHtml(VERSION)}</code>`],
1413
+ /** The run facts: journeys, platforms, plan, consent, campaign check and the tool itself. */
1414
+ function runFacts(m, runs, metrics, { ai, plan, lang, against, planChanges, consentScenarios }) {
1415
+ const provs = checkedProviders(runs);
1416
+ const completed = runs.filter((r) => r.completed).length;
1417
+ const journeys = runs.length ? `${escapeHtml(uniqList(runs.map((r) => r.journeyId)).join(", "))} · ${escapeHtml(uniqList(runs.map((r) => r.viewport)).join(", "))} · <span class="muted">${escapeHtml(completed === runs.length ? m.html.runsAllCompleted(runs.length) : m.html.runsCompleted(completed, runs.length))}</span>` : "–";
1418
+ const planBits = [plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan, plan?.consent_mode ? m.html.consentModeOn : "", against ? m.ui.checkedAgainst(against) : "", planChanges ? m.ui.planChangesLine(planChanges) : ""].filter(Boolean);
1419
+ const states = consentScenarios ? ["before", ...Object.keys(consentScenarios.states ?? {}).filter((s) => consentScenarios.states[s]?.after)] : [];
1420
+ const consent = consentScenarios ? `${escapeHtml(consentScenarios.cmp === "banner" ? m.html.cmpBanner : String(consentScenarios.cmp ?? ""))} · <span class="muted">${escapeHtml(m.html.scenariosRun(states.length))}</span>` : metrics.consent.consentMode.defaultPresent ? m.html.consentModeSeen : "–";
1421
+ const campaign = runs.some((r) => r.landing) ? m.html.campaignChecked : "–";
1422
+ const facts = [
1423
+ [m.ui.runJourneys, journeys],
1424
+ [m.html.platformsChecked, provs.length ? `<span class="plats">${provs.map((k) => plat(k, 16)).join("")}</span>` : "–"],
1425
+ [m.ui.runPlan, escapeHtml(planBits.join(" · "))],
1426
+ [m.ui.consent, consent],
1427
+ [m.html.campaignCheck, escapeHtml(campaign)],
1428
+ [m.ui.runAi, `${escapeHtml(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)} · <span class="muted">ai-tag-debugger ${escapeHtml(VERSION)}, ${escapeHtml(messages(lang).name)}</span>`],
695
1429
  ];
696
- return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.runCard}</div></div><div class="card-c"><dl class="run">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl></div></div>`;
1430
+ return `<dl class="facts">${facts.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join("")}</dl>`;
697
1431
  }
698
1432
 
699
- function overviewTab(m, findings, rows, counts, explained, runs, info) {
700
- const problems = rows.filter((f) => f.severity !== "ok").length;
701
- const metric = (sev) => `<div class="card metric ${sev}"><div class="k">${m.severity[sev]}</div><div class="v">${counts[sev]}</div></div>`;
702
- const journeyRows = runs.map((r, ri) => {
703
- const n = problemCount(findings, r);
704
- return `<tr><td><a href="#j${ri}">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></a></td><td class="num">${r.steps.filter((s) => s.index > 0).length}</td><td class="num${n ? " bad" : ""}">${n}</td></tr>`;
1433
+ function verdictBlock(m, { site, title, generatedAt, findings, runs, lang, metrics, explained, counts, accepted, runInfo, changes }) {
1434
+ const v = metrics.verdict;
1435
+ const sev = verdictGlyph(v);
1436
+ // The same status word and headline as the CLI, the alerts and the Markdown title ("Fail: purchase tracking is broken on
1437
+ // mobile."); the count-and-owner sentence follows, except on a pass, where it would only repeat the headline.
1438
+ const head = verdictHeadline({ findings, runs, metrics, lang });
1439
+ const headline = [`${head.headline}.`, v.headline === "pass" ? "" : m.verdict.headline[v.headline]?.(v) ?? "", sentence(ownerSplit(m, metrics.priorities), lang)].filter(Boolean).join(" ");
1440
+ const tiles = ["broken", "risk", "warning", "info", "ok"].map((s) => `<li class="count sev-${s}${counts[s] ? "" : " zero"}"><a href="#findings">${use(`sev-${s}`, 16)}<span class="k">${m.severity[s]}</span><span class="v">${counts[s]}</span></a></li>`).join("");
1441
+ return `<section class="verdict" id="verdict" aria-labelledby="site"><div class="wrap">
1442
+ <p class="eyebrow">${m.html.eyebrow} · ${escapeHtml(generatedAt)}</p>
1443
+ <h1 id="site">${escapeHtml(site ?? title)}</h1>
1444
+ <p class="verdict-line">${use(`sev-${sev}`, 22, `vmark sev-${sev}`)}<span><strong>${escapeHtml(head.statusText)}:</strong> ${escapeHtml(headline)}</span></p>
1445
+ ${accepted ? `<p class="meta accepted-line">${escapeHtml(m.ui.acceptedLine(accepted))}</p>\n` : ""}${aiSummary(m, explained)}
1446
+ <ul class="counts" aria-label="${m.html.countsLabel}">${tiles}</ul>
1447
+ ${runFacts(m, runs, metrics, runInfo)}${changes?.routes?.length ? `\n<p class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></p>` : ""}
1448
+ </div></section>`;
1449
+ }
1450
+
1451
+ /** The problem lines of one owner on the overview, each linking to its card. */
1452
+ function fixFirst(m, runs, rows, ids, metrics) {
1453
+ const byKey = new Map(rows.map((f) => [rowKey(f), f]));
1454
+ const groups = OWNERS.map((o) => {
1455
+ const p = metrics.priorities.find((x) => x.owner === o);
1456
+ const head = `<h4 class="ogroup-h"><span>${m.ui.owners[o]}</span><span class="n">${p?.total ?? 0}</span><span class="muted xs">${m.ui.ownerHint[o]}</span>${p?.points ? `<span class="muted xs pts">${escapeHtml(m.html.pointsToRecover(p.points))}</span>` : ""}</h4>`;
1457
+ if (!p) return `<section class="ogroup">${head}<p class="muted xs empty">${escapeHtml(m.html.nothingFor(m.ui.owners[o]))}</p></section>`;
1458
+ const items = p.top.map((d) => {
1459
+ const f = byKey.get(rowKey(d)) ?? d;
1460
+ const id = ids.get(f);
1461
+ const inner = `${use(`sev-${f.severity}`, 16, "fl-ic")}<span class="fl-t">${detailHtml(f.detail)}</span><span class="fl-m">${f.platform ? plat(f.platform, 14) : ""}${f.event ? `<code>${escapeHtml(f.event)}</code>` : ""}<span>${escapeHtml(spreadText(m, runs, f))}</span></span>`;
1462
+ return `<li class="sev-${f.severity}">${id ? `<a href="#${id}">${inner}</a>` : `<span class="fl-row">${inner}</span>`}</li>`;
1463
+ });
1464
+ const more = p.total > p.top.length ? `<p class="xs"><a href="#findings">${escapeHtml(m.html.moreInFindings(p.total - p.top.length))}</a></p>` : "";
1465
+ return `<section class="ogroup">${head}<ol class="fixlist">${items.join("")}</ol>${more}</section>`;
705
1466
  });
706
- const skipped = skippedTotals(runs);
707
- const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
708
- const provs = checkedProviders(runs);
709
- return `<div class="stack">
710
- <div class="metrics">${["broken", "risk", "warning", "ok"].map(metric).join("")}</div>
711
- <div class="ov-grid">${summaryCard(m, explained, problems)}
712
- <div class="stack">
713
- ${runs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.ui.tabJourneys}</div></div><div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.journey}</th><th>${m.ui.stepsCol}</th><th>${m.ui.problemsCol}</th></tr></thead><tbody>${journeyRows.join("")}</tbody></table></div></div></div>` : ""}
714
- ${runCard(m, runs, provs, info)}${skippedN ? `<p class="muted xs scope-skipped">${escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</p>` : ""}
715
- </div></div></div>`;
1467
+ return `<div class="card"><div class="card-h"><h3>${m.html.fixFirst}</h3><p class="card-d">${m.html.fixFirstLead}</p></div><div class="card-c">${groups.join("")}</div></div>`;
1468
+ }
1469
+
1470
+ function workingCard(m, runs, rows, metrics) {
1471
+ const lines = okLines(m, runs, rows);
1472
+ const c = metrics.coverage;
1473
+ if (c) lines.unshift(`<li>${badge("ok", m.severity.ok)}<span>${escapeHtml(m.html.coverageVerified(c.counts.implemented, c.checkable, c.percent.implemented))} <a href="#scope">${m.ui.planCoverage}</a></span></li>`);
1474
+ if (!lines.length) return "";
1475
+ return `<div class="card"><div class="card-h"><h3>${m.html.working}</h3></div><div class="card-c"><ul class="oklist">${lines.join("")}</ul></div></div>`;
1476
+ }
1477
+
1478
+ /** Problem cards that point at this run. */
1479
+ const problemsIn = (rows, run) => rows.filter((f) => f.severity in SEVERITY_RANK && f.where.some((w) => w.journey === run.journeyId && w.viewport === run.viewport)).length;
1480
+
1481
+ function journeysTable(m, runs, rows) {
1482
+ if (!runs.length) return "";
1483
+ const body = runs.map((r, ri) => {
1484
+ const n = problemsIn(rows, r);
1485
+ return `<tr><td><a href="#j${ri}">${escapeHtml(r.journeyId)}</a></td><td>${escapeHtml(r.viewport)}</td><td class="num">${r.steps.filter((s) => s.index > 0).length}</td><td class="num">${r.hits.length}</td><td class="num${n ? " bad" : ""}">${n ? `${use("sev-broken", 12)} ${n}` : "0"}</td></tr>`;
1486
+ }).join("");
1487
+ return `<div class="card"><div class="card-h"><h3>${m.ui.tabJourneys}</h3><p class="card-d">${m.html.journeysLead}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.tabJourneys}</caption><thead><tr><th scope="col">${m.ui.journey}</th><th scope="col">${m.html.viewport}</th><th scope="col" class="num">${m.ui.stepsCol}</th><th scope="col" class="num">${m.html.hitsCol}</th><th scope="col" class="num">${m.ui.problemsCol}</th></tr></thead><tbody>${body}</tbody></table></div></div>`;
1488
+ }
1489
+
1490
+ const SPARK_SERIES = ["broken", "risk", "warning"];
1491
+
1492
+ /** Three count lines over the saved runs, evenly spaced by run; incomplete runs are hollow points. */
1493
+ function sparklineSvg(m, t) {
1494
+ const s = t.series;
1495
+ const W = 280, H = 64, P = 6;
1496
+ const max = Math.max(3, ...s.flatMap((p) => SPARK_SERIES.map((k) => p[k])));
1497
+ const x = (i) => P + (i * (W - 2 * P)) / (s.length - 1);
1498
+ const y = (v) => H - P - (v * (H - 2 * P)) / max;
1499
+ const lines = SPARK_SERIES.map((k) => `<polyline class="sev-${k}" points="${s.map((p, i) => `${x(i).toFixed(1)},${y(p[k]).toFixed(1)}`).join(" ")}"/>`).join("");
1500
+ const points = s.flatMap((p, i) => SPARK_SERIES.map((k) => `<circle class="sev-${k}${p.complete ? "" : " hollow"}" cx="${x(i).toFixed(1)}" cy="${y(p[k]).toFixed(1)}" r="${i === s.length - 1 ? 3.5 : 2.5}"><title>${escapeHtml(`${p.date ?? p.id} · ${p[k]} ${m.severity[k]}${p.complete ? "" : ` · ${m.html.runIncomplete}`}`)}</title></circle>`)).join("");
1501
+ const label = SPARK_SERIES.map((k) => `${m.severity[k]}: ${s.map((p) => p[k]).join(", ")}`).join("; ");
1502
+ return `<svg class="spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${escapeHtml(label)}">${lines}${points}</svg>`;
1503
+ }
1504
+
1505
+ const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
1506
+ const changeItems = (m, ch) => [
1507
+ ...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
1508
+ ...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
1509
+ ];
1510
+ const routeLines = (ch) => (ch?.routes ?? []).map((r) => `<li>${escapeHtml(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}</li>`).join("");
1511
+
1512
+ /** "Changed since last run" when there is no history: the headline, what was fixed and what is new. */
1513
+ function changesCard(m, ch) {
1514
+ const head = `<div class="card-h"><h3>${m.ui.changesTitle}</h3>${ch.previous ? `<p class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</p>` : ""}</div>`;
1515
+ if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
1516
+ const items = changeItems(m, ch);
1517
+ return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${ch.routes?.length ? `<ul class="changes">${routeLines(ch)}</ul>` : ""}</div></div>`;
1518
+ }
1519
+
1520
+ /** One problem from the history as a line: the current card's title when it is still open, else platform, event and check. */
1521
+ function historyLine(m, rows, ids, e) {
1522
+ const f = rows.find((r) => problemKey(r) === problemKey(e));
1523
+ const text = f ? detailHtml(f.detail) : `${e.platform ? `${escapeHtml(platformLabel(e.platform))} ` : ""}${e.event ? `<code>${escapeHtml(e.event)}</code> ` : ""}${escapeHtml(e.check)}`;
1524
+ return f && ids.get(f) ? `<a href="#${ids.get(f)}">${text}</a>` : text;
1525
+ }
1526
+
1527
+ const dateOnly = (id) => String(id ?? "").slice(0, 10);
1528
+
1529
+ /** The trend over the saved runs: a sparkline, the diff with the last run, flapping problems and the ones open longest. */
1530
+ function trendCard(m, t, rows, ids, changes) {
1531
+ const direction = `<p><strong>${escapeHtml(m.html.trendDirection[t.direction] ?? t.direction)}</strong> <span class="muted">${escapeHtml(m.html.trendSpan(t.runs, dateOnly(t.first), dateOnly(t.last)))}</span></p>`;
1532
+ const diff = changes?.previous ? `<div id="changes"><p class="xs"><strong>${escapeHtml(m.ui.changesLine(changes))}</strong> <span class="muted">${escapeHtml(m.ui.changesLead(changes.previous, changes.mode))}</span></p>${changeItems(m, changes).length ? `<ul class="changes">${changeItems(m, changes).map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${changes.routes?.length ? `<ul class="changes">${routeLines(changes)}</ul>` : ""}</div>` : "";
1533
+ const flapping = t.flapping.length ? `<h4 class="sec-h">${m.html.flapping}</h4><ul class="changes">${t.flapping.map((e) => `<li>${badge(e.severity, m.severity[e.severity])} ${historyLine(m, rows, ids, e)} <span class="muted">· ${escapeHtml(m.html.seenIn(e.seenIn, t.runs))}</span></li>`).join("")}</ul>` : "";
1534
+ const open = t.openLongest.length ? `<h4 class="sec-h">${m.html.openLongest}</h4><ul class="changes">${t.openLongest.map((e) => `<li>${badge(e.severity, m.severity[e.severity])} ${historyLine(m, rows, ids, e)} <span class="muted">· ${escapeHtml(m.html.openSince(dateOnly(e.openSince), e.runsOpen, e.places.length))}</span></li>`).join("")}</ul>` : "";
1535
+ const legend = `<p class="xs muted spark-legend">${SPARK_SERIES.map((k) => `<span class="sev-${k}"><i></i>${m.severity[k]}</span>`).join(" ")}</p>`;
1536
+ return `<div class="card" id="trend"><div class="card-h"><h3>${m.html.trend}</h3><p class="card-d">${escapeHtml(m.html.trendLead)}</p></div><div class="card-c stack">${sparklineSvg(m, t)}${legend}${direction}${diff}${flapping}${open}</div></div>`;
1537
+ }
1538
+
1539
+ function scoreCard(m, metrics, rows, ids) {
1540
+ const s = metrics.score;
1541
+ if (s.score === null) return "";
1542
+ const byKey = new Map(rows.map((f) => [problemKey(f), f]));
1543
+ const items = s.deductions.slice(0, 5).map((d) => {
1544
+ const f = byKey.get(problemKey(d));
1545
+ const text = f ? detailHtml(f.detail) : `${escapeHtml(platformLabel(d.platform ?? ""))} <code>${escapeHtml(d.event ?? "")}</code> ${escapeHtml(d.check)}`;
1546
+ return `<li><span class="num mono">−${d.points}</span><span>${f && ids.get(f) ? `<a href="#${ids.get(f)}">${text}</a>` : text} <span class="muted">· ${m.severity[d.severity]} · ${m.ui.owners[d.owner]}</span></span></li>`;
1547
+ });
1548
+ return `<div class="card" id="score"><div class="card-h"><h3>${m.html.score}</h3><p class="card-d">${escapeHtml(m.html.scoreLead)}</p></div><div class="card-c"><p class="score-v"><span class="v">${s.score}</span><span class="muted">/100</span>${s.lost ? ` <span class="muted xs">${escapeHtml(m.html.scoreLost(s.lost))}</span>` : ""}</p>${items.length ? `<ul class="deductions">${items.join("")}</ul>` : ""}</div></div>`;
1549
+ }
1550
+
1551
+ function overviewPanel(m, runs, rows, ids, metrics, trend, changes) {
1552
+ const right = [trend && trend.series.length >= 2 ? trendCard(m, trend, rows, ids, changes) : changes ? changesCard(m, changes) : "", scoreCard(m, metrics, rows, ids)].filter(Boolean).join("");
1553
+ return `<div class="ov-grid"><div class="stack">${fixFirst(m, runs, rows, ids, metrics)}${workingCard(m, runs, rows, metrics)}${journeysTable(m, runs, rows)}</div><div class="stack">${right}</div></div>`;
1554
+ }
1555
+
1556
+ /* ---------- journeys ---------- */
1557
+
1558
+ const consentChip = (k, v) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)} ${escapeHtml(v)}</span>`;
1559
+
1560
+ /** The journey's Consent Mode: a timing sentence, the first default and the first update, and how often they were re-sent. */
1561
+ function consentCard(m, run, info) {
1562
+ const cmds = (run.dataLayerLog ?? []).filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
1563
+ const head = `<div class="card-h"><h4>${m.html.consentMode}</h4></div>`;
1564
+ if (!cmds.length) return `<div class="card consent-card">${head}<div class="card-c"><p class="muted">${m.ui.consentNone}</p></div></div>`;
1565
+ const firstDefault = cmds.find((e) => e.value[1] === "default");
1566
+ const firstUpdate = cmds.find((e) => e.value[1] === "update" && (!firstDefault || e.t >= firstDefault.t));
1567
+ const tile = (e) => {
1568
+ const step = run.steps.find((s) => s.index === e.step);
1569
+ const sigs = Object.entries(e.value[2] ?? {}).filter(([, v]) => typeof v === "string");
1570
+ return `<li><div class="ch"><b>${e.value[1] === "default" ? m.ui.consentDefault : m.ui.consentUpdate}</b><span class="muted xs">${step ? `${m.ui.step} ${step.index} · ${escapeHtml(step.label)} · ` : ""}${escapeHtml(seconds(run, e.t))}</span></div><div class="sigs">${sigs.map(([k, v]) => `<span class="sig"><span>${escapeHtml(k)}</span><span class="${v === "granted" ? "granted" : "denied"}">${escapeHtml(v)}</span></span>`).join("")}</div></li>`;
1571
+ };
1572
+ const timing = !info ? "" : info.present ? m.html.consentTiming(info.ms, info.beforeFirstGoogleHit, info.v2Signals.present, info.v2Signals.missing) : m.html.consentNoDefault;
1573
+ const resent = cmds.filter((e) => e !== firstDefault && e !== firstUpdate).length;
1574
+ return `<div class="card consent-card">${head}<div class="card-c">${timing ? `<p class="xs">${escapeHtml(timing)}</p>` : ""}<ol class="ctl">${[firstDefault, firstUpdate].filter(Boolean).map(tile).join("")}</ol>${resent ? `<p class="muted xs">${escapeHtml(m.html.consentResent(resent))}</p>` : ""}</div></div>`;
1575
+ }
1576
+
1577
+ /** The reason a scenario cell counts as it does: cookieless pings, ad storage denied, or hits with cookies. */
1578
+ function scenarioReason(m, scenarios, platform, event, state) {
1579
+ const after = state === "before" ? scenarios.before : scenarios.states?.[state]?.after;
1580
+ const c = after?.[platform]?.[event];
1581
+ if (!c || !c.n) return "";
1582
+ if (c.cookieless >= c.n) return m.html.cellCookieless;
1583
+ if (c.adDenied >= c.n) return m.html.cellAdDenied;
1584
+ return m.html.cellWithCookies;
1585
+ }
1586
+
1587
+ /** Consent scenarios: one row per tag, one column per consent choice, hit counts with a reason per cell. */
1588
+ function scenariosCard(m, scenarios, rows, ids) {
1589
+ if (!scenarios) return "";
1590
+ const { cols, rows: tagRows } = consentTable(scenarios);
1591
+ const findingFor = (platform, event) => rows.find((f) => f.platform === platform && f.event === event && /^(after_reject|before_consent)$/.test(f.check));
1592
+ const head = `<tr><th scope="col">${m.ui.scenarioTag}</th>${cols.map((c) => `<th scope="col" class="num">${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
1593
+ const body = tagRows.map((r) => `<tr><td>${plat(r.platform, 16)} <code>${escapeHtml(r.event)}</code></td>${r.cells.map((c) => {
1594
+ if (c.count === null) return `<td class="num muted">–</td>`;
1595
+ const reason = scenarioReason(m, scenarios, r.platform, r.event, c.state);
1596
+ if (!c.problem) return `<td class="num">${c.count}${reason ? ` <span class="muted xs">${escapeHtml(reason)}</span>` : ""}</td>`;
1597
+ const f = findingFor(r.platform, r.event);
1598
+ return `<td class="num bad">${use("sev-broken", 12)} ${c.count}<span class="sr"> ${m.html.cellProblem}</span> <span class="xs">${escapeHtml(m.html.cellShouldNot)}${f && ids.get(f) ? ` <a href="#${ids.get(f)}">${m.html.findingLink}</a>` : ""}</span></td>`;
1599
+ }).join("")}</tr>`).join("");
1600
+ const notRun = cols.filter((c) => c !== "before" && !scenarios.states?.[c]?.via).map((c) => m.ui.scenarioCols[c]);
1601
+ return `<div class="card scenarios"><div class="card-h"><h3>${m.ui.scenarios}</h3><p class="card-d">${escapeHtml(m.html.scenariosLead(scenarios.cmp === "banner" ? m.html.cmpBanner : scenarios.cmp))}${notRun.length ? ` ${escapeHtml(m.ui.scenarioNotRun)}: ${escapeHtml(notRun.join(", "))}.` : ""}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.scenarios}</caption><thead>${head}</thead><tbody>${body}</tbody></table></div><div class="card-c"><p class="muted xs">${escapeHtml(m.html.scenariosLegend)}</p></div></div>`;
1602
+ }
1603
+
1604
+ const funnelStatus = (m, r) => (r.status === "ok" ? `<span class="y">${use("sev-ok", 12)} ${m.severity.ok}</span>` : r.status === "missing_params" ? `<span class="x">${use("sev-broken", 12)} ${escapeHtml(m.html.params(r.missingParams))}</span>` : `<span class="x">${use("sev-broken", 12)} ${m.html.notSent}</span>`);
1605
+
1606
+ /** Expected versus observed for one run, in plan order. */
1607
+ function funnelCard(m, fu) {
1608
+ if (!fu?.events?.length) return "";
1609
+ const rows = fu.events.map((r) => `<tr><td>${plat(r.platform, 16)} <code>${escapeHtml(r.event)}</code>${r.conversion ? ` <span class="badge">${m.html.conversion}</span>` : ""}</td><td>${funnelStatus(m, r)}</td><td class="num">${r.count}</td><td>${r.step !== null ? `${m.ui.step} ${r.step}${r.stepLabel ? ` · ${escapeHtml(r.stepLabel)}` : ""}` : "–"}</td><td class="xs">${r.page ? `<code>${escapeHtml(pathOf(r.page))}</code>` : "–"}</td></tr>`).join("");
1610
+ return `<div class="card"><div class="card-h"><h4>${m.html.funnel}</h4><p class="card-d">${escapeHtml(m.html.funnelLead(fu.observed, fu.expected, fu.ok))}${fu.firstMissing ? ` ${escapeHtml(m.html.funnelBreaks(fu.firstMissing))}` : ""}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.html.funnel}</caption><thead><tr><th scope="col">${m.ui.event}</th><th scope="col">${m.ui.status}</th><th scope="col" class="num">${m.html.sent}</th><th scope="col">${m.html.firstAt}</th><th scope="col">${m.html.page}</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
1611
+ }
1612
+
1613
+ /** The same event on two platforms for one run, with what differs, and the events only one platform sent. */
1614
+ function parityCard(m, pa) {
1615
+ if (!pa || (!pa.shared.length && !pa.onlyOn.length)) return "";
1616
+ const money = (p) => (p.value !== null ? ` · ${p.value}${p.currency ? ` ${escapeHtml(p.currency)}` : ""}` : "");
1617
+ const rows = pa.shared.map((s) => `<tr><td><code>${escapeHtml(s.event)}</code></td><td>${Object.entries(s.platforms).map(([k, p]) => `<span class="plat-v">${plat(k, 14)} <span class="mono">${p.count}×${money(p)}</span></span>`).join(" ")}</td><td>${s.mismatch.length ? `<span class="x">${use("sev-warning", 12)} ${escapeHtml(s.mismatch.map((x) => m.html.mismatch[x] ?? x).join(", "))}</span>` : `<span class="y">${use("sev-ok", 12)} ${m.html.agree}</span>`}</td></tr>`).join("");
1618
+ const only = pa.onlyOn.length ? `<p class="muted xs">${escapeHtml(m.html.onlyOnPlatform)} ${sortPlatforms(new Set(pa.onlyOn.map((o) => o.platform))).map((k) => `${plat(k, 14)}: ${pa.onlyOn.filter((o) => o.platform === k).map((o) => `<code>${escapeHtml(o.name)}</code>`).join(", ")}`).join(" · ")}</p>` : "";
1619
+ return `<div class="card print-skip"><div class="card-h"><h4>${m.html.parity}</h4><p class="card-d">${escapeHtml(m.html.parityLead)}</p></div>${rows ? `<div class="tscroll"><table class="dt"><caption class="sr">${m.html.parity}</caption><thead><tr><th scope="col">${m.ui.event}</th><th scope="col">${m.html.perPlatform}</th><th scope="col">${m.html.difference}</th></tr></thead><tbody>${rows}</tbody></table></div>` : ""}${only ? `<div class="card-c">${only}</div>` : ""}</div>`;
716
1620
  }
717
1621
 
718
- /** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
1622
+ /** The chips of the hits a step sent: platform icon, event name and the worst finding's glyph. */
1623
+ function hitChip(m, findings, run, step, hit) {
1624
+ const flag = hitFlag(findings, run, step, hit);
1625
+ return `<span class="hit${flag ? ` flag sev-${flag.severity}` : ""}">${platIcon(hit.platform, 14)}<code>${escapeHtml(hit.name)}</code>${flag ? `${use(`sev-${flag.severity}`, 12)}<span class="sr"> ${m.severity[flag.severity]}</span>` : ""}</span>`;
1626
+ }
1627
+
1628
+ function stepRow(m, lang, findings, run, ri, step, ids, flagged) {
1629
+ const hits = run.hits.filter((h) => h.step === step.index).sort((a, b) => a.t - b.t);
1630
+ const pushes = (run.dataLayerLog ?? []).filter((e) => e.step === step.index).sort((a, b) => a.t - b.t);
1631
+ const reqRows = requestRows(m, lang, run, step);
1632
+ const skipped = skippedText(run.skipped?.[step.index]);
1633
+ const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
1634
+ const id = stepAnchor(ri, step.index);
1635
+ const worst = [...hits.map((h) => hitFlag(findings, run, step, h)?.severity), flagged.get(id)].filter(Boolean).sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b])[0] ?? null;
1636
+ const meta = [step.pagePath ? `<code>${escapeHtml(pathOf(step.pagePath))}</code>` : "", step.action ? escapeHtml(step.action) : "", step.forced ? escapeHtml(m.ui.clickedThrough) : ""].filter(Boolean).join(" · ");
1637
+ const hasDetail = hits.length || pushes.length || reqRows.length;
1638
+ const row = `<tr id="${id}" class="srow${worst ? ` flagged sev-${worst}` : ""}${hasDetail ? " has-d" : ""}"><th scope="row"><span class="sidx">${step.index}</span></th><td class="slabel">${escapeHtml(step.label)}${worst ? `<span class="sr"> (${escapeHtml(m.html.hasFinding(m.severity[worst]))})</span>` : ""}${meta ? `<span class="smeta xs muted">${meta}</span>` : ""}</td><td class="time">${escapeHtml(seconds(run, step.t))}</td><td class="hits">${hits.length ? `<div class="hits-wrap">${hits.map((h) => hitChip(m, findings, run, step, h)).join("")}</div>` : `<span class="muted xs">${m.ui.noHits}</span>`}</td><td class="num">${pushes.length}</td></tr>`;
1639
+ if (!hasDetail) return row;
1640
+ const hitList = hits.length ? `<h5>${m.html.hitsInStep} <span class="n">${hits.length}</span></h5><ul class="evlist">${hits.map((h) => eventRow(m, findings, run, step, h, ids)).join("")}</ul>` : "";
1641
+ const pushList = pushes.length ? `<h5>dataLayer <span class="n">${pushes.length}</span></h5><ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : "";
1642
+ const net = reqRows.length || skippedN ? `<details class="netblock"><summary>${use("ui-chevron", 14, "chev")}<span>${m.ui.tabNetwork} <span class="n">${reqRows.length}</span></span></summary>${reqRows.length ? `<ul class="evlist">${reqRows.map((r) => r.html).join("")}</ul>` : ""}${skippedN ? `<p class="muted xs scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</details>` : "";
1643
+ return `${row}<tr class="drow${worst ? ` sev-${worst}` : ""}"><td colspan="5"><details${worst ? " open" : ""}><summary>${use("ui-chevron", 14, "chev")}<span>${escapeHtml(m.html.stepDetails(hits.length, pushes.length))}</span></summary><div class="sdetail">${hitList}${pushList}${net}</div></details></td></tr>`;
1644
+ }
1645
+
1646
+ /** The worst severity of the problem cards pointing at each step of the run, by step anchor. */
1647
+ function flaggedSteps(rows, runs, run, ri) {
1648
+ const out = new Map();
1649
+ for (const f of rows) {
1650
+ if (!(f.severity in SEVERITY_RANK)) continue;
1651
+ for (const w of f.where) {
1652
+ if (w.journey !== run.journeyId || w.viewport !== run.viewport) continue;
1653
+ const t = whereTarget(runs, w, f);
1654
+ if (!t?.step) continue;
1655
+ const id = stepAnchor(ri, t.step.index);
1656
+ if (!out.has(id) || SEVERITY_RANK[f.severity] < SEVERITY_RANK[out.get(id)]) out.set(id, f.severity);
1657
+ }
1658
+ }
1659
+ return out;
1660
+ }
1661
+
1662
+ function journeyPanel(m, lang, findings, rows, runs, run, ri, ids, metrics) {
1663
+ const steps = run.steps.filter((s) => s.index > 0);
1664
+ const flagged = flaggedSteps(rows, runs, run, ri);
1665
+ const n = problemsIn(rows, run);
1666
+ const badges = `${run.completed ? badge("ok", m.ui.completed) : badge("warning", m.ui.notCompleted)}${n ? badge(rows.filter((f) => f.severity in SEVERITY_RANK && f.where.some((w) => w.journey === run.journeyId && w.viewport === run.viewport)).sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])[0].severity, m.html.problems(n)) : ""}`;
1667
+ const table = steps.length
1668
+ ? `<div class="card"><div class="card-h"><h4>${m.html.stepsTitle}</h4><p class="card-d">${m.html.stepsLead}</p></div><div class="tscroll"><table class="steps"><caption class="sr">${m.html.stepsTitle}</caption><thead><tr><th scope="col">#</th><th scope="col">${m.html.stepCol}</th><th scope="col">${m.html.timeCol}</th><th scope="col">${m.html.hitsCol}</th><th scope="col" class="num">dataLayer</th></tr></thead><tbody>${steps.map((s) => stepRow(m, lang, findings, run, ri, s, ids, flagged)).join("")}</tbody></table></div></div>`
1669
+ : `<p class="empty">${m.ui.noSteps}</p>`;
1670
+ return `<article class="jpanel" id="j${ri}" role="tabpanel" aria-labelledby="jtab-${ri}">
1671
+ <header class="jhead"><div><h3>${escapeHtml(run.journeyId)} <span class="muted">· ${escapeHtml(run.viewport)}</span></h3>${run.note ? `<p class="muted xs">${escapeHtml(run.note)}</p>` : ""}</div><div class="jbadges">${badges}</div></header>
1672
+ ${consentCard(m, run, metrics.consent.consentMode.runs[ri])}${table}${funnelCard(m, metrics.funnel[ri])}${parityCard(m, metrics.parity[ri])}</article>`;
1673
+ }
1674
+
1675
+ function journeysPanel(m, lang, findings, rows, runs, ids, metrics, scenarios) {
1676
+ const sc = scenariosCard(m, scenarios, rows, ids);
1677
+ if (!runs.length) return `${sc}<p class="empty">${m.ui.noSteps}</p>`;
1678
+ const tabs = runs.map((r, ri) => `<button type="button" role="tab" id="jtab-${ri}" aria-controls="j${ri}" aria-selected="${ri === 0 ? "true" : "false"}"${ri === 0 ? "" : ' tabindex="-1"'}>${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span><span class="n">${problemsIn(rows, r)}</span></button>`).join("");
1679
+ return `${sc}<div class="jtabs js-only" role="tablist" aria-label="${m.ui.chooseJourney}">${tabs}</div>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, rows, runs, r, ri, ids, metrics)).join("\n")}`;
1680
+ }
1681
+
1682
+ /* ---------- scope and plan ---------- */
1683
+
719
1684
  const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
720
1685
  const coverageLines = (m, pc) => [
721
1686
  m.ui.coverageLine(pc.coverage),
@@ -730,29 +1695,6 @@ const planChangeItems = (pc) => [
730
1695
  ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
731
1696
  ];
732
1697
 
733
- /** "Changed since last run": one headline, then what was fixed and what is new. */
734
- const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
735
- const changeItems = (m, ch) => [
736
- ...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
737
- ...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
738
- ];
739
-
740
- function changesMd(m, ch) {
741
- const lines = [`## ${m.ui.changesTitle}`, ""];
742
- if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
743
- lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
744
- const items = changeItems(m, ch);
745
- if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
746
- if (ch.routes?.length) lines.push(...ch.routes.map((r) => `- ${mdText(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}`), "");
747
- return lines;
748
- }
749
-
750
- function changesCard(m, ch) {
751
- const head = `<div class="card-h"><div class="card-t">${m.ui.changesTitle}</div>${ch.previous ? `<div class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</div>` : ""}</div>`;
752
- if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
753
- const items = changeItems(m, ch);
754
- return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${ch.routes?.length ? `<ul class="changes">${ch.routes.map((r) => `<li>${escapeHtml(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}</li>`).join("")}</ul>` : ""}</div></div>`;
755
- }
756
1698
 
757
1699
  function planVsSiteMd(m, pc) {
758
1700
  const [overall, ...perPlatform] = coverageLines(m, pc);
@@ -765,399 +1707,234 @@ function planVsSiteMd(m, pc) {
765
1707
 
766
1708
  function planVsSiteCard(m, pc) {
767
1709
  const [overall, ...perPlatform] = coverageLines(m, pc);
768
- const rows = pcSort(pc).map((e) => `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.event)}</code></td><td>${badge(BUCKET_SEV[e.bucket], m.ui.bucket[e.bucket])}</td><td class="muted xs">${(e.details ?? []).map(detailHtml).join("<br>")}</td></tr>`).join("");
769
- return `<div class="card" id="plan-vs-site"><div class="card-h"><div class="card-t">${m.ui.planVsSite}</div><div class="card-d">${m.ui.planVsSiteLead}</div></div><div class="card-c"><p><strong>${escapeHtml(overall)}</strong></p>${perPlatform.length ? `<p class="muted xs">${perPlatform.map(escapeHtml).join("<br>")}</p>` : ""}<div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.event}</th><th>${m.ui.bucketCol}</th><th></th></tr></thead><tbody>${rows}</tbody></table></div></div></div>`;
1710
+ const rows = pcSort(pc).map((e) => `<tr><td>${plat(e.platform, 16)} <code>${escapeHtml(e.event)}</code></td><td>${badge(BUCKET_SEV[e.bucket], m.ui.bucket[e.bucket])}</td><td class="muted xs">${(e.details ?? []).map(detailHtml).join("<br>")}</td></tr>`).join("");
1711
+ return `<div class="card" id="plan-vs-site"><div class="card-h"><h3>${m.ui.planVsSite}</h3><p class="card-d">${m.ui.planVsSiteLead}</p></div><div class="card-c"><p><strong>${escapeHtml(overall)}</strong></p>${perPlatform.length ? `<p class="muted xs">${perPlatform.map(escapeHtml).join("<br>")}</p>` : ""}</div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.planVsSite}</caption><thead><tr><th scope="col">${m.ui.event}</th><th scope="col">${m.ui.bucketCol}</th><th scope="col">${m.html.detailsCol}</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
770
1712
  }
771
1713
 
772
- function coverageCard(m, plan, runs) {
773
- const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
774
- if (!plan?.events?.length || !runs.length) return `<div class="card">${head}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
1714
+ /** Each planned event × each journey: sent with its parameters, sent without them, not sent, or not expected there. */
1715
+ function coverageCard(m, plan, runs, metrics) {
1716
+ const head = (lead) => `<div class="card-h"><h3>${m.ui.planCoverage}</h3><p class="card-d">${lead}</p></div>`;
1717
+ if (!plan?.events?.length || !runs.length) return `<div class="card">${head(m.ui.planCoverageLead)}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
775
1718
  const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
776
1719
  const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
777
- const body = events
778
- .map((e) => {
779
- const cells = runs.map((r) => {
780
- if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
781
- return r.hits.some((h) => hitMatchesEvent(e, h)) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
782
- });
783
- const unchecked = !runs.some((r) => checks(r, e));
784
- return `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.name)}</code>${unchecked ? ` ${badge("muted", m.ui.notChecked)}` : ""}</td>${cells.join("")}</tr>`;
785
- })
786
- .join("");
787
- return `<div class="card">${head}<div class="card-c"><div class="tscroll"><table class="dt cov"><thead><tr><th>${m.ui.event}</th>${runs.map((r) => `<th>${escapeHtml(r.journeyId)} · ${escapeHtml(r.viewport)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div><p class="muted xs legend">${m.ui.coverageLegend}</p></div></div>`;
1720
+ const body = events.map((e) => {
1721
+ const cells = runs.map((r, ri) => {
1722
+ const row = metrics.funnel[ri]?.events.find((x) => x.platform === e.platform && x.event === e.name);
1723
+ if (!row || !checks(r, e)) return `<td class="c o">–<span class="sr"> ${m.html.notExpected}</span></td>`;
1724
+ if (row.status === "ok") return `<td class="c y">✓<span class="sr"> ${m.html.sent}</span></td>`;
1725
+ if (row.status === "missing_params") return `<td class="c x">${use("sev-broken", 12)} ${m.html.paramsShort}<span class="sr"> ${escapeHtml(m.html.params(row.missingParams))}</span></td>`;
1726
+ return `<td class="c x">✗<span class="sr"> ${m.html.notSent}</span></td>`;
1727
+ });
1728
+ const unchecked = !runs.some((r) => checks(r, e));
1729
+ const needs = e.required_params?.length ? `<span class="muted xs"> · ${escapeHtml(m.html.needs(e.required_params))}</span>` : "";
1730
+ return `<tr><td>${plat(e.platform, 16)} <code>${escapeHtml(e.name)}</code>${needs}${unchecked ? ` ${badge("muted", m.ui.notChecked)}` : ""}</td>${cells.join("")}</tr>`;
1731
+ }).join("");
1732
+ const c = metrics.coverage;
1733
+ const lead = c ? escapeHtml(m.ui.coverageLine({ implemented: c.counts.implemented, checkable: c.checkable, percent: c.percent.implemented, not_checked: c.counts.not_checked })) : m.ui.planCoverageLead;
1734
+ return `<div class="card" id="coverage">${head(lead)}<div class="tscroll"><table class="dt cov"><caption class="sr">${m.ui.planCoverage}</caption><thead><tr><th scope="col">${m.ui.event}</th>${runs.map((r) => `<th scope="col" class="c">${escapeHtml(r.journeyId)}<br><span class="muted xs">${escapeHtml(r.viewport)}</span></th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div><div class="card-c"><p class="muted xs">${escapeHtml(m.html.coverageLegend)}</p></div></div>`;
788
1735
  }
789
1736
 
790
- const PAGES_SHOWN = 5;
1737
+ const CONVERSION_CHECKS = ["value", "currency", "transactionId", "items", "duplicate", "parity", "eventId"];
791
1738
 
792
- function inventoryMd(m, runs) {
793
- const inv = buildInventory(runs);
794
- const lines = [`## ${m.ui.inventoryTitle}`, ""];
795
- if (!inv.vendors.length) return [...lines, m.ui.noInventory, ""];
796
- lines.push(`| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|");
797
- for (const v of inv.vendors) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", "))} |`);
798
- if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
799
- lines.push("");
800
- return lines;
1739
+ /** One row per purchase seen, with each integrity check as a pass, fail or not-applicable cell. */
1740
+ function conversionsCard(m, metrics) {
1741
+ const c = metrics.conversions;
1742
+ if (!c.rows.length) return "";
1743
+ const cellOf = (v) => (v === "pass" ? `<td class="c y">✓<span class="sr"> ${m.html.pass}</span></td>` : v === "fail" ? `<td class="c x">${use("sev-broken", 12)}<span class="sr"> ${m.html.fail}</span></td>` : `<td class="c o">–<span class="sr"> ${m.html.notApplicable}</span></td>`);
1744
+ const rows = c.rows.map((r) => `<tr><td>${escapeHtml(`${r.journey} · ${r.viewport}`)}</td><td>${plat(r.platform, 16)} <code>${escapeHtml(r.event)}</code></td><td class="mono xs">${r.transactionId ? escapeHtml(r.transactionId) : "–"}</td><td class="num">${r.value !== null ? `${escapeHtml(String(r.value))}${r.currency ? ` ${escapeHtml(r.currency)}` : ""}` : missingCell(m)}</td><td class="num">${r.items ?? "–"}</td>${CONVERSION_CHECKS.map((k) => cellOf(r.checks[k])).join("")}</tr>`).join("");
1745
+ return `<div class="card" id="conversions"><div class="card-h"><h3>${m.html.conversions}</h3><p class="card-d">${escapeHtml(m.html.conversionsLead(c.passed, c.total))}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.html.conversions}</caption><thead><tr><th scope="col">${m.ui.journey}</th><th scope="col">${m.ui.event}</th><th scope="col">${m.html.orderId}</th><th scope="col" class="num">${m.html.value}</th><th scope="col" class="num">${m.ui.items}</th>${CONVERSION_CHECKS.map((k) => `<th scope="col" class="c">${m.html.conversionChecks[k]}</th>`).join("")}</tr></thead><tbody>${rows}</tbody></table></div></div>`;
801
1746
  }
802
1747
 
1748
+ const PAGES_SHOWN = 5;
1749
+
803
1750
  function inventoryCard(m, runs) {
804
1751
  const inv = buildInventory(runs);
805
- const head = `<div class="card-h"><div class="card-t">${m.ui.inventoryTitle}</div><div class="card-d">${m.ui.inventoryLead}</div></div>`;
1752
+ const head = `<div class="card-h"><h3>${m.ui.inventoryTitle}</h3><p class="card-d">${m.ui.inventoryLead}</p></div>`;
806
1753
  if (!inv.vendors.length) return `<div class="card" id="inventory">${head}<div class="card-c"><p class="muted">${m.ui.noInventory}</p></div></div>`;
807
1754
  const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
808
1755
  const pages = (v) => `${names(v.pages.slice(0, PAGES_SHOWN))}${v.pages.length > PAGES_SHOWN ? ` <span class="muted">${m.ui.morePages(v.pages.length - PAGES_SHOWN)}</span>` : ""}`;
809
- const rows = inv.vendors.map((v) => `<tr><td>${escapeHtml(v.vendor)}</td><td>${escapeHtml(m.ui.party[v.party])}</td><td class="xs">${pages(v)}</td><td class="xs">${v.cookies.length ? names(v.cookies) : '<span class="muted">–</span>'}</td></tr>`).join("");
810
- const other = inv.otherCookies.length ? `<p class="muted xs legend">${m.ui.otherCookies}: ${inv.otherCookies.map((c) => `${escapeHtml(c.name)} (${escapeHtml(m.ui.party[c.party])})`).join(", ")}</p>` : "";
811
- return `<div class="card" id="inventory">${head}<div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.vendorCol}</th><th>${m.ui.partyCol}</th><th>${m.ui.pagesCol}</th><th>${m.ui.cookiesCol}</th></tr></thead><tbody>${rows}</tbody></table></div></div>${other ? `<div class="card-c">${other}</div>` : ""}</div>`;
1756
+ const rows = inv.vendors.map((v) => `<tr><td>${escapeHtml(v.vendor)}</td><td>${escapeHtml(m.ui.party[v.party])}</td><td class="num">${v.pages.length}</td><td class="xs">${pages(v)}</td><td class="xs">${v.cookies.length ? names(v.cookies) : '<span class="muted">–</span>'}</td></tr>`).join("");
1757
+ const other = inv.otherCookies.length ? `<div class="card-c"><p class="muted xs">${m.ui.otherCookies}: ${inv.otherCookies.map((c) => `${escapeHtml(c.name)} (${escapeHtml(m.ui.party[c.party])})`).join(", ")}</p></div>` : "";
1758
+ return `<div class="card" id="inventory">${head}<div class="tscroll"><table class="dt"><caption class="sr">${m.ui.inventoryTitle}</caption><thead><tr><th scope="col">${m.ui.vendorCol}</th><th scope="col">${m.ui.partyCol}</th><th scope="col" class="num">${m.ui.pagesCol}</th><th scope="col">${m.html.seenOn}</th><th scope="col">${m.ui.cookiesCol}</th></tr></thead><tbody>${rows}</tbody></table></div>${other}</div>`;
812
1759
  }
813
1760
 
1761
+ /** Platforms checked (seen or never seen) and vendors out of scope, with their request counts. */
814
1762
  function providersCard(m, runs) {
815
1763
  const provs = checkedProviders(runs);
816
1764
  const skipped = skippedTotals(runs);
817
1765
  if (!provs.length && !skipped.length) return "";
818
1766
  const calls = runs.flatMap((r) => r.network ?? []);
1767
+ const seen = new Set(runs.flatMap((r) => r.hits.map((h) => h.platform)));
819
1768
  const rows = [
820
- ...provs.map((k) => [platformLabel(k), badge("ok", m.ui.checked), calls.filter((c) => callProvider(c) === k).length]),
821
- ...skipped.map(([v, n]) => [v, badge("muted", m.ui.outOfScopeStatus), n]),
1769
+ ...provs.map((k) => {
1770
+ const n = calls.filter((c) => callProvider(c) === k).length;
1771
+ return [plat(k, 16), seen.has(k) || n ? badge("ok", m.ui.checked) : badge("info", m.html.checkedNotSeen), `${n}`];
1772
+ }),
1773
+ ...skipped.map(([v, n]) => [escapeHtml(v), badge("muted", m.ui.outOfScopeStatus), `${n}`]),
822
1774
  ];
823
- return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.providers}</div></div><div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.provider}</th><th>${m.ui.status}</th><th>${m.ui.requestsCol}</th></tr></thead><tbody>${rows.map(([n, s, c]) => `<tr><td>${escapeHtml(n)}</td><td>${s}</td><td class="num">${c}</td></tr>`).join("")}</tbody></table></div></div></div>`;
1775
+ const notSeen = provs.filter((k) => !seen.has(k));
1776
+ return `<div class="card" id="platforms"><div class="card-h"><h3>${m.ui.providers}</h3>${notSeen.length ? `<p class="card-d">${escapeHtml(m.html.neverSeen(notSeen.map(platformLabel).join(", ")))}</p>` : ""}</div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.providers}</caption><thead><tr><th scope="col">${m.ui.platform}</th><th scope="col">${m.ui.status}</th><th scope="col" class="num">${m.ui.requestsCol}</th></tr></thead><tbody>${rows.map(([n, s, c]) => `<tr><td>${n}</td><td>${s}</td><td class="num">${c}</td></tr>`).join("")}</tbody></table></div></div>`;
824
1777
  }
825
1778
 
826
- /** Steps a problem finding points at; they get a dot in the step list, and the journey opens on the first one. */
827
- function flaggedSteps(runs, rows) {
828
- const out = new Set();
829
- for (const f of rows) {
830
- if (!(f.severity in SEVERITY_RANK)) continue;
831
- for (const w of f.where) {
832
- const t = whereTarget(runs, w, f);
833
- if (t?.step) out.add(stepAnchor(t.ri, t.step.index));
834
- }
835
- }
836
- return out;
837
- }
1779
+ const ms = (v) => (v === null || v === undefined ? "–" : `${Math.round(v)} ms`);
838
1780
 
839
- /** Filter rules for the platform keys in this report: picking a source hides the other rows. */
840
- function filterCss(runs) {
841
- const keys = new Set(PLATFORM_ORDER);
842
- for (const r of runs) {
843
- for (const h of r.hits) keys.add(safeKey(h.platform));
844
- for (const c of r.network ?? []) keys.add(callProvider(c));
845
- }
846
- return `${[...keys].map((k) => `.filter>.f-${k}:checked~.evlist>li:not([data-p="${k}"])`).join(",")}{display:none}`;
1781
+ /** How long each run took and how far behind each step its first hit came. */
1782
+ function timingCard(m, metrics) {
1783
+ const t = metrics.timing.filter((r) => r.durationMs !== null);
1784
+ if (!t.length) return "";
1785
+ const rows = t.map((r) => `<tr><td>${escapeHtml(`${r.journey} · ${r.viewport}`)}</td><td class="num">${(r.durationMs / 1000).toFixed(1)} s</td><td class="num">${ms(r.medianFirstHitMs)}</td><td class="xs">${r.slowest[0] ? `${plat(r.slowest[0].platform, 14)} <code>${escapeHtml(r.slowest[0].event)}</code> · ${m.ui.step} ${r.slowest[0].step} · ${ms(r.slowest[0].ms)}` : "–"}</td></tr>`).join("");
1786
+ return `<div class="card print-skip" id="timing"><div class="card-h"><h3>${m.html.timing}</h3><p class="card-d">${escapeHtml(m.html.timingLead)}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.html.timing}</caption><thead><tr><th scope="col">${m.ui.journey}</th><th scope="col" class="num">${m.html.duration}</th><th scope="col" class="num">${m.html.medianFirstHit}</th><th scope="col">${m.html.slowestHit}</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
847
1787
  }
848
1788
 
849
- // The tabs, journeys and steps are switched by the URL hash (:target and :has()), so links work across tabs and every view can be shared.
850
- function navCss(runs) {
851
- const top = ["overview", "findings", "journeys", "scope"].map((t) => `body:has(#${t}:target,#${t} :target) .toptabs a[href="#${t}"]`).join(",");
852
- const out = [`${top}{color:var(--fg);border-bottom-color:var(--accent)}`];
853
- if (runs.length) {
854
- out.push(`${runs.map((_, ri) => `body:has(#j${ri}:target,#j${ri} :target) .jtabs a[href="#j${ri}"]`).join(",")}{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}`);
855
- const steps = runs.flatMap((r, ri) => r.steps.filter((s) => s.index > 0).map((s) => `body:has(#${stepAnchor(ri, s.index)}:target) a[data-s="${stepAnchor(ri, s.index)}"]`));
856
- if (steps.length) out.push(`${steps.join(",")}{background:var(--accent-bg);color:var(--fg)}`);
857
- }
858
- const sev = ["all", ...FINDING_SEVERITIES];
859
- out.push(`${sev.map((s) => `#fs-${s}:checked~.toolbar label[for=fs-${s}]`).join(",")}{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}`);
860
- out.push(`${sev.map((s) => `#fs-${s}:focus-visible~.toolbar label[for=fs-${s}]`).join(",")}{outline:2px solid var(--accent)}`);
861
- out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup>.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
862
- out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup:not(:has(li[data-sev=${s}]))`).join(",")}{display:none}`);
863
- return out.join("\n");
1789
+ /** The run's settings, for the reader who doubts a result. */
1790
+ function runCard(m, runs, { ai, plan, lang, against, planChanges }) {
1791
+ const skipped = skippedTotals(runs);
1792
+ const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
1793
+ const rows = [
1794
+ [m.ui.runPlatforms, checkedProviders(runs).map((k) => plat(k, 16)).join(" ") || '<span class="muted">–</span>'],
1795
+ [m.ui.runAi, escapeHtml(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)],
1796
+ [m.ui.runPlan, escapeHtml(plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan)],
1797
+ ...(against ? [[m.ui.planVsSite, escapeHtml(m.ui.checkedAgainst(against))]] : []),
1798
+ ...(planChanges ? [[m.html.planChanges, `${escapeHtml(m.ui.planChangesLine(planChanges))}${planChangeItems(planChanges).length ? `<ul class="changes xs">${planChangeItems(planChanges).map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>` : ""}`]] : []),
1799
+ [m.ui.runJourneys, escapeHtml(uniqList(runs.map((r) => r.journeyId)).join(", ") || "–")],
1800
+ [m.ui.runViewports, escapeHtml(uniqList(runs.map((r) => r.viewport)).join(", ") || "–")],
1801
+ ...(skippedN ? [[m.ui.outOfScopeStatus, escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))]] : []),
1802
+ [m.ui.runLanguage, escapeHtml(messages(lang).name)],
1803
+ [m.ui.runVersion, `<code>${escapeHtml(VERSION)}</code>`],
1804
+ ];
1805
+ return `<div class="card" id="run"><div class="card-h"><h3>${m.ui.runCard}</h3></div><div class="card-c"><dl class="run">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join("")}</dl></div></div>`;
864
1806
  }
865
1807
 
866
- export const CSS = `
867
- :root{
868
- --bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#EEF2F8;--border:#DCE2EE;--input:#848EA6;
869
- --accent:#2E6CDB;--accent-fg:#2459C0;--accent-bg:#EBF1FE;--orange:#D9531E;
870
- --danger:#C8323A;--danger-bg:#FCE9EA;--warn:#8C6106;--warn-bg:#FBF1DC;--warn-dot:#C99A2E;--risk:#B8441A;--risk-bg:#FFF0E8;--danger-dot:#C8323A;--ok:#17805A;--ok-bg:#E3F3EC;
871
- --shadow-sm:none;--radius:6px;
872
- --font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace}
873
- @media (prefers-color-scheme:dark){:root{
874
- --bg:#141927;--page:#0A0E1A;--fg:#F0F4FF;--muted:#8892AA;--muted-bg:#182035;--border:#1E2640;--input:#5F6B93;--accent:#4F8EF7;--orange:#FF6D33;
875
- --accent-fg:#8CBBFF;--accent-bg:#1A2F5C;--danger:#F2545B;--danger-bg:#3A1F22;--warn:#E8B44A;--warn-bg:#33290F;--warn-dot:#E8B44A;--risk:#FF6D33;--risk-bg:#2A1A0F;--danger-dot:#F2545B;--ok:#3FB98A;--ok-bg:#12302A;--shadow-sm:none}}
876
- *{box-sizing:border-box}
877
- html{scroll-padding-top:64px;scrollbar-gutter:stable;overflow-y:scroll}
878
- body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--page);color:var(--fg);font:400 14px/1.6 var(--font-sans);-webkit-font-smoothing:antialiased}
879
- a{color:var(--accent-fg);text-decoration:none}
880
- h1,h2,h3,h4,h5,h6{margin:0;font-weight:500;line-height:1.3}
881
- h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}h2,h3{font-size:14px}
882
- p{margin:0}
883
- strong{font-weight:600;color:var(--fg)}
884
- code,.mono,.time,pre{font-family:var(--font-mono)}
885
- code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
886
- .muted{color:var(--muted)}.xs{font-size:12px}
887
- .label{font-size:12px;color:var(--muted);margin:0 0 6px}
888
- .wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 16px}
889
- .topbar{background:var(--bg);border-bottom:1px solid var(--border)}
890
- .topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
891
- .product-logo img{display:block;height:26px;width:auto}
892
- .byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
893
- .logo-h img{display:block;height:18px;width:auto}
894
- .title{margin-top:20px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}
895
- .site{display:flex;align-items:center;gap:10px;min-width:0}
896
- .site .k{font:500 11px/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:var(--muted-bg);border:1px solid var(--border);border-radius:2px;padding:2px 6px}
897
- .site .v{font-size:20px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow-wrap:anywhere}
898
- .run{margin:0;display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px 16px;font-size:13px}
899
- .run dt{color:var(--muted)}.run dd{margin:0;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center;min-width:0;overflow-wrap:anywhere}
900
- .ptag.off{opacity:.55;text-decoration:line-through}
901
- .ctl{list-style:none;margin:0;padding:0;display:grid;gap:12px}
902
- .ctl>li{border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--page)}
903
- .ctl .ch{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;font-size:13px;margin-bottom:8px}
904
- .ctl .ch b{font-weight:600}
905
- .sigs{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:6px}
906
- .sig{display:flex;justify-content:space-between;gap:8px;align-items:center;border:1px solid var(--border);border-radius:4px;padding:5px 10px;font:12px var(--font-mono);background:var(--bg)}
907
- .sig .granted{color:var(--ok)}.sig .denied{color:var(--danger)}
908
- .jconsent{margin-bottom:16px}
909
- .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
910
- .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
911
- .title .plan-changes ul{margin:4px 0 0;padding-left:18px}
912
- .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
913
- .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
914
- .toptabs a{display:inline-flex;align-items:center;gap:6px;padding:12px 10px;color:var(--muted);font-weight:500;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px}
915
- .toptabs a:hover{color:var(--fg)}
916
- .n{font:500 12px/1.4 var(--font-mono);background:var(--muted-bg);color:var(--muted);padding:0 6px;border-radius:4px}
917
- body:not(:has(:target)) .toptabs a[href="#overview"]{color:var(--fg);border-bottom-color:var(--accent)}
918
- .tabpanel{display:none;padding:24px 0 64px}
919
- .tabpanel:target,.tabpanel:has(:target){display:block}
920
- body:not(:has(.tabpanel:target,.tabpanel :target)) #overview{display:block}
921
- .card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
922
- .card-h{padding:16px 20px 0}.card-c{padding:16px 20px 20px}.card-c.tight{padding:8px}
923
- .card-t{font-weight:500}.card-d{font-size:12px;color:var(--muted);margin-top:2px}
924
- .badge{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;white-space:nowrap;background:var(--muted-bg);color:var(--muted)}
925
- .badge.broken{background:var(--danger-bg);color:var(--danger)}.badge.risk{background:var(--risk-bg);color:var(--risk)}.badge.warning{background:var(--warn-bg);color:var(--warn)}.badge.ok{background:var(--ok-bg);color:var(--ok)}.badge.info{background:var(--accent-bg);color:var(--accent-fg)}
926
- .stack{display:grid;gap:16px}
927
- .metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
928
- .metric{padding:16px;border-top:3px solid var(--border)}
929
- .metric .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
930
- .metric .k::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
931
- .metric.broken{border-top-color:var(--danger-dot)}.metric.broken .k::before{background:var(--danger-dot)}.metric.broken .v{color:var(--danger)}
932
- .metric.risk{border-top-color:var(--risk)}.metric.risk .k::before{background:var(--risk)}.metric.risk .v{color:var(--risk)}
933
- .metric.warning{border-top-color:var(--warn-dot)}.metric.warning .k::before{background:var(--warn-dot)}.metric.warning .v{color:var(--warn)}
934
- .metric.ok{border-top-color:var(--ok)}.metric.ok .k::before{background:var(--ok)}
935
- .metric .v{font:600 24px/1.3 var(--font-mono);margin-top:4px}
936
- .headline{font-weight:500}
937
- .summary-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
938
- .summary-list li{display:grid;grid-template-columns:72px 1fr;gap:8px;align-items:baseline}
939
- .summary-list .badge{justify-self:start}
940
- .also{margin:0;padding-left:18px;display:grid;gap:6px}
941
- .scope{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;font-size:12px;color:var(--muted)}
942
- .scope-skipped,.legend{margin-top:8px}
943
- table.dt{width:100%;border-collapse:collapse}
944
- table.dt th,table.dt td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
945
- table.dt th{font-weight:500;color:var(--muted);font-size:12px}
946
- table.dt tr:last-child td{border-bottom:0}
947
- table.dt td.num{font-family:var(--font-mono)}
948
- table.dt td.num.bad{color:var(--danger);font-weight:600}
949
- table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
950
- .tscroll{overflow-x:auto}
951
- .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
952
- .sevfilter{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius)}
953
- .fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
954
- .sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
955
- .filter-host{position:relative}
956
- .changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
957
- .flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
958
- .finding{--sev:var(--warn-dot);--sev-fg:var(--warn);--sev-bg:var(--warn-bg);background:var(--bg);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
959
- .finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
960
- .finding.sev-risk{--sev:var(--risk);--sev-fg:var(--risk);--sev-bg:var(--risk-bg)}
961
- .finding.sev-info{--sev:var(--accent);--sev-fg:var(--accent-fg);--sev-bg:var(--accent-bg)}
962
- .finding>details>summary{display:flex;align-items:center;gap:12px;padding:14px 16px}
963
- .finding>details[open]>summary{background:linear-gradient(90deg,var(--sev-bg),transparent 70%)}
964
- .sev-icon{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:var(--sev-bg);color:var(--sev-fg);flex:none}
965
- .fhead{flex:1;min-width:0;display:grid;gap:4px}
966
- .ftitle{color:var(--fg);overflow-wrap:anywhere}
967
- .fmeta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted)}
968
- .finding summary .chev{margin-left:4px}
969
- .fbody{padding:16px;border-top:1px solid var(--border)}
970
- .impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--sev-bg);color:var(--fg);border-radius:var(--radius)}
971
- .impact svg{color:var(--sev-fg);flex:none;margin-top:3px}
972
- .impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
973
- .fcols{display:grid;gap:8px 32px;margin-top:4px}
974
- .fcol{min-width:0}
975
- .sec-h{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin:16px 0 8px}
976
- .sec-h svg{color:var(--accent-fg)}
977
- .ev-table{border:1px solid var(--border);border-radius:var(--radius);border-collapse:separate;border-spacing:0;overflow:hidden}
978
- .ev-table th{background:var(--muted-bg)}
979
- .missing{display:inline-block;padding:0 6px;border-radius:4px;background:var(--danger-bg);color:var(--danger)}
980
- .fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
981
- .fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
982
- .fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
983
- .ogroup{margin-top:20px}.ogroup-h{display:flex;align-items:baseline;gap:8px;font-size:14px;margin:0 0 10px}.ogroup-h .n{font-size:12px;color:var(--muted)}
984
- .verify{display:flex;flex-wrap:wrap;gap:6px 14px;margin:0;padding:0;list-style:none;font-size:13px}.verify a{color:var(--accent-fg)}
985
- .where{display:flex;flex-wrap:wrap;gap:6px}
986
- .chip{display:inline-flex;align-items:center;gap:8px;padding:4px 10px 4px 4px;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);font-size:12px;background:var(--bg)}
987
- span.chip{padding-left:10px}
988
- a.chip:hover{border-color:var(--accent);color:var(--fg)}
989
- .chip-step{display:inline-grid;place-items:center;min-width:20px;height:20px;border-radius:4px;background:var(--accent);color:#fff;font:500 12px var(--font-mono)}
990
- .sevbar{display:flex;height:8px;border-radius:4px;overflow:hidden;gap:2px;margin:0 0 6px}
991
- .sevbar span{display:block}
992
- .sevlegend{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12px;color:var(--muted);margin-bottom:16px}
993
- .sevlegend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
994
- body>main{flex:1 0 auto;width:100%}
995
- .footer{border-top:1px solid var(--border);background:var(--bg);margin-top:32px;flex:none}
996
- .footer .wrap{display:grid;gap:16px;padding:32px 16px}
997
- .footer p{font-size:12px;color:var(--muted)}
998
- .footer a{color:var(--fg)}.footer a:hover{color:var(--accent-fg)}
999
- .footer .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
1000
- .footer .f-meta{font:12px var(--font-mono);color:var(--muted)}
1001
- .logo-link{display:inline-flex}.logo-s img{display:block;height:56px;width:auto}
1002
- .f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
1003
- .jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius);margin-bottom:16px}
1004
- .jtabs a{display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:4px;color:var(--muted);font-weight:500}
1005
- .jtabs a:hover{color:var(--fg)}
1006
- #journeys:not(:has(.jpanel:target,.jpanel :target)) .jtabs a[href="#j0"]{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}
1007
- .jpanel{display:none}
1008
- .jpanel:target,.jpanel:has(:target){display:block}
1009
- #journeys:not(:has(.jpanel:target,.jpanel :target)) .jpanel:first-of-type{display:block}
1010
- .jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
1011
- .jgrid2{display:grid;gap:16px;align-items:start}
1012
- .stepnav{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:6px}
1013
- .stepnav ol{list-style:none;margin:0;padding:0;display:grid;gap:2px}
1014
- .stepnav a{display:grid;grid-template-columns:24px minmax(0,1fr) auto auto;gap:6px;align-items:center;padding:7px 8px;border-radius:4px;color:var(--muted)}
1015
- .stepnav a:hover{background:var(--muted-bg);color:var(--fg)}
1016
- .jpanel:not(:has(.sdetail:target)) .stepnav a.def{background:var(--accent-bg);color:var(--fg)}
1017
- .sidx{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:4px;border:1px solid var(--border);font:500 12px var(--font-mono);color:var(--muted);background:var(--bg)}
1018
- .sl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
1019
- .dot-flag{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--danger-dot)}
1020
- .sc{font:12px var(--font-mono);color:var(--muted);white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
1021
- .sdetail{display:none;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);scroll-margin-top:64px;min-width:0}
1022
- .sdetail:target{display:block}
1023
- .jpanel:not(:has(.sdetail:target)) .sdetail.default{display:block}
1024
- .shead{display:flex;gap:12px;align-items:flex-start;padding:16px}
1025
- .shead h3{overflow-wrap:anywhere}
1026
- .shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
1027
- .empty{color:var(--muted);padding:8px 0}
1028
- .tabs.seg{display:grid;grid-template-columns:repeat(4,auto) 1fr;padding:0 16px 16px}
1029
- .tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
1030
- .tabs.seg>label{padding:6px 12px;font-weight:500;color:var(--muted);cursor:pointer;background:var(--muted-bg);white-space:nowrap;display:inline-flex;gap:6px;align-items:center}
1031
- .tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
1032
- .tabs.seg>label:nth-of-type(4){border-radius:0 var(--radius) var(--radius) 0}
1033
- .tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
1034
- .tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
1035
- .tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
1036
- .tabs.seg>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs.seg>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs.seg>input:nth-of-type(3):checked~.panel:nth-of-type(3),.tabs.seg>input:nth-of-type(4):checked~.panel:nth-of-type(4){display:block}
1037
- .timeline{list-style:none;margin:0;padding:0 0 0 18px;border-left:2px solid var(--border);display:grid;gap:14px}
1038
- .tl-node{position:relative;display:grid;gap:6px;min-width:0}
1039
- .tl-node::before{content:"";position:absolute;left:-24px;top:4px;width:10px;height:10px;border-radius:50%;background:var(--bg);border:2px solid var(--accent)}
1040
- .tl-orphan::before{border-color:var(--muted)}
1041
- .tl-lbl{font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
1042
- .tl-gtm{display:flex;gap:8px;align-items:center;font-size:13px;padding-left:12px}
1043
- .tl-k{color:var(--muted)}
1044
- .tl-hits,.tl-none{margin-left:12px}
1045
- .filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
1046
- .filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
1047
- .filter>label{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--input);border-radius:var(--radius);font-size:12px;cursor:pointer;color:var(--muted);background:var(--bg)}
1048
- .filter>label:hover{color:var(--fg)}
1049
- .filter>input:checked+label{background:var(--fg);border-color:var(--fg);color:var(--bg)}
1050
- .filter>input:checked+label .n{background:transparent;color:inherit}
1051
- .filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
1052
- .filter>label:last-of-type{margin-bottom:4px}
1053
- .dot{display:inline-block;width:12px;height:12px;border-radius:2px;background:var(--muted);vertical-align:-2px}
1054
- .dot.p-ga4,.ptag.p-ga4::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjIwIiByeD0iMyIgZmlsbD0iI0Y5QUIwMCIvPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSI2IiBoZWlnaHQ9IjEzIiByeD0iMyIgZmlsbD0iI0UzNzQwMCIvPjxjaXJjbGUgY3g9IjYiIGN5PSIxOSIgcj0iMyIgZmlsbD0iI0UzNzQwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-google_ads,.ptag.p-google_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iOS41IiB5PSIxLjUiIHdpZHRoPSI2IiBoZWlnaHQ9IjIxIiByeD0iMyIgdHJhbnNmb3JtPSJyb3RhdGUoMzAgMTIuNSAxMikiIGZpbGw9IiNGQkJDMDQiLz48cmVjdCB4PSI4LjUiIHk9IjEuNSIgd2lkdGg9IjYiIGhlaWdodD0iMjEiIHJ4PSIzIiB0cmFuc2Zvcm09InJvdGF0ZSgtMzAgMTEuNSAxMikiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSI1LjUiIGN5PSIxOC41IiByPSIzIiBmaWxsPSIjMzRBODUzIi8+PC9zdmc+") center/contain no-repeat}.dot.p-meta,.ptag.p-meta::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIuNSAxNS41YzAtNC40IDIuMy05IDUtOSAzLjQgMCA2IDExIDkuNSAxMSAyLjIgMCA0LjUtMi42IDQuNS02LjVTMTkuOCA2LjUgMTcuNSA2LjVjLTMuNCAwLTYgMTEtOS44IDExLTIuOSAwLTUuMi0uNy01LjItMnoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzA4NjZGRiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-tiktok,.ptag.p-tiktok::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIyLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iIzI1RjRFRSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iI0ZFMkM1NSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIvPjxwYXRoIGQ9Ik0xMyAzdjExLjVhMy41IDMuNSAwIDEgMS0zLTMuNDZNMTMgM2MuNSAyLjUgMi41IDQgNSA0IiBzdHJva2U9IiMxMTEiLz48L2c+PC9zdmc+") center/contain no-repeat}.dot.p-x,.ptag.p-x::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjkwMSAxLjE1M2gzLjY4bC04LjA0IDkuMTlMMjQgMjIuODQ2aC03LjQwNmwtNS44LTcuNTg0LTYuNjM4IDcuNTg0SC40NzRsOC42LTkuODNMMCAxLjE1NGg3LjU5NGw1LjI0MyA2LjkzMlpNMTcuNjEgMjAuNjQ0aDIuMDM5TDYuNDg2IDMuMjRINC4yOThaIiBmaWxsPSIjMDAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-linkedin,.ptag.p-linkedin::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwLjQ0NyAyMC40NTJoLTMuNTU0di01LjU2OWMwLTEuMzI4LS4wMjctMy4wMzctMS44NTItMy4wMzctMS44NTMgMC0yLjEzNiAxLjQ0NS0yLjEzNiAyLjkzOXY1LjY2N0g5LjM1MVY5aDMuNDE0djEuNTYxaC4wNDZjLjQ3Ny0uOSAxLjYzNy0xLjg1IDMuMzctMS44NSAzLjYwMSAwIDQuMjY3IDIuMzcgNC4yNjcgNS40NTV2Ni4yODZ6TTUuMzM3IDcuNDMzYy0xLjE0NCAwLTIuMDYzLS45MjYtMi4wNjMtMi4wNjUgMC0xLjEzOC45Mi0yLjA2MyAyLjA2My0yLjA2MyAxLjE0IDAgMi4wNjQuOTI1IDIuMDY0IDIuMDYzIDAgMS4xMzktLjkyNSAyLjA2NS0yLjA2NCAyLjA2NXptMS43ODIgMTMuMDE5SDMuNTU1VjloMy41NjR2MTEuNDUyek0yMi4yMjUgMEgxLjc3MUMuNzkyIDAgMCAuNzc0IDAgMS43Mjl2MjAuNTQyQzAgMjMuMjI3Ljc5MiAyNCAxLjc3MSAyNGgyMC40NTFDMjMuMiAyNCAyNCAyMy4yMjcgMjQgMjIuMjcxVjEuNzI5QzI0IC43NzQgMjMuMiAwIDIyLjIyMiAwaC4wMDN6IiBmaWxsPSIjMEE2NkMyIi8+PC9zdmc+") center/contain no-repeat}.dot.p-microsoft_ads,.ptag.p-microsoft_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0YyNTAyMiIvPjxyZWN0IHg9IjEyLjUiIHk9IjEiIHdpZHRoPSIxMC41IiBoZWlnaHQ9IjEwLjUiIGZpbGw9IiM3RkJBMDAiLz48cmVjdCB4PSIxIiB5PSIxMi41IiB3aWR0aD0iMTAuNSIgaGVpZ2h0PSIxMC41IiBmaWxsPSIjMDBBNEVGIi8+PHJlY3QgeD0iMTIuNSIgeT0iMTIuNSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0ZGQjkwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-pinterest,.ptag.p-pinterest::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLjAxNyAwQzUuMzk2IDAgLjAyOSA1LjM2Ny4wMjkgMTEuOTg3YzAgNS4wNzkgMy4xNTggOS40MTcgNy42MTggMTEuMTYyLS4xMDUtLjk0OS0uMTk5LTIuNDAzLjA0MS0zLjQzOS4yMTktLjkzNyAxLjQwNi01Ljk1NyAxLjQwNi01Ljk1N3MtLjM1OS0uNzItLjM1OS0xLjc4MWMwLTEuNjYzLjk2Ny0yLjkxMSAyLjE2OC0yLjkxMSAxLjAyNCAwIDEuNTE4Ljc2OSAxLjUxOCAxLjY4OCAwIDEuMDI5LS42NTMgMi41NjctLjk5MiAzLjk5Mi0uMjg1IDEuMTkzLjYgMi4xNjUgMS43NzUgMi4xNjUgMi4xMjggMCAzLjc2OC0yLjI0NSAzLjc2OC01LjQ4NyAwLTIuODYxLTIuMDYzLTQuODY5LTUuMDA4LTQuODY5LTMuNDEgMC01LjQwOSAyLjU2Mi01LjQwOSA1LjE5OSAwIDEuMDMzLjM5NCAyLjE0My44ODkgMi43NDEuMDk5LjEyLjExMi4yMjUuMDg1LjM0NS0uMDkuMzc1LS4yOTMgMS4xOTktLjMzNCAxLjM2My0uMDUzLjIyNS0uMTcyLjI3MS0uNDAxLjE2NS0xLjQ5NS0uNjktMi40MzMtMi44NzgtMi40MzMtNC42NDYgMC0zLjc3NiAyLjc0OC03LjI1MiA3LjkyLTcuMjUyIDQuMTU4IDAgNy4zOTIgMi45NjcgNy4zOTIgNi45MjMgMCA0LjEzNS0yLjYwNyA3LjQ2Mi02LjIzMyA3LjQ2Mi0xLjIxNCAwLTIuMzU0LS42MjktMi43NTgtMS4zNzlsLS43NDkgMi44NDhjLS4yNjkgMS4wNDUtMS4wMDQgMi4zNTItMS40OTggMy4xNDYgMS4xMjMuMzQ1IDIuMzA2LjUzNSAzLjU1LjUzNSA2LjYwNyAwIDExLjk4NS01LjM2NSAxMS45ODUtMTEuOTg3QzIzLjk3IDUuMzkgMTguNTkyLjAyNiAxMS45ODUuMDI2TDEyLjAxNyAweiIgZmlsbD0iI0JEMDgxQyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-snapchat,.ptag.p-snapchat::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iI0ZGRkMwMCIvPjxwYXRoIGQ9Ik0xMi4yMDYuNzkzYy45OSAwIDQuMzQ3LjI3NiA1LjkzIDMuODIxLjUyOSAxLjE5My40MDMgMy4yMTkuMjk5IDQuODQ3bC0uMDAzLjA2Yy0uMDEyLjE4LS4wMjIuMzQ1LS4wMy41MS4wNzUuMDQ1LjIwMy4wOS40MDEuMDkuMy0uMDE2LjY1OS0uMTIgMS4wMzMtLjMwMS4xNjUtLjA4OC4zNDQtLjEwNC40NjQtLjEwNC4xODIgMCAuMzU5LjAyOS41MDkuMDkuNDUuMTQ5LjczNC40NzkuNzM0LjgzOC4wMTUuNDQ5LS4zOS44MzktMS4yMTMgMS4xNjgtLjA4OS4wMjktLjIwOS4wNzUtLjM0NC4xMTktLjQ1LjEzNS0xLjEzOS4zNi0xLjMzMy44MS0uMDkuMjI0LS4wNjEuNTI0LjEyLjg2OGwuMDE1LjAxNWMuMDYuMTM2IDEuNTI2IDMuNDc1IDQuNzkxIDQuMDE0LjI1NS4wNDQuNDM1LjI3LjQyLjUwOSAwIC4wNzUtLjAxNS4xNDktLjA0NS4yMjUtLjI0LjU2OS0xLjI3My45ODgtMy4xNDYgMS4yNzEtLjA1OS4wOTEtLjEyLjM3NS0uMTY0LjU3LS4wMjkuMTc5LS4wNzQuMzYtLjEzNC41NTMtLjA3Ni4yNzEtLjI3LjQwNS0uNTU1LjQwNWgtLjAzYy0uMTM1IDAtLjMxMy0uMDMxLS41MzgtLjA3NC0uMzYtLjA3NS0uNzY1LS4xMzUtMS4yNzMtLjEzNS0uMyAwLS41OTkuMDE1LS45MTMuMDc0LS42LjEwNC0xLjEyMy40NjQtMS43MjMuODg0LS44NTMuNTk5LTEuODI2IDEuMjg4LTMuMjk0IDEuMjg4LS4wNiAwLS4xMTktLjAxNS0uMTgtLjAxNWgtLjE0OWMtMS40NjggMC0yLjQyNy0uNjc1LTMuMjc5LTEuMjg4LS41OTktLjQyLTEuMTA3LS43NzktMS43MDctLjg4NC0uMzE0LS4wNDUtLjYyOS0uMDc0LS45MjgtLjA3NC0uNTQgMC0uOTU4LjA4OS0xLjI3Mi4xNDktLjIxMS4wNDMtLjM5MS4wNzQtLjU0LjA3NC0uMzc0IDAtLjUyMy0uMjI0LS41ODMtLjQyLS4wNjEtLjE5Mi0uMDktLjM4OS0uMTM1LS41NjctLjA0Ni0uMTgxLS4xMDUtLjQ5NC0uMTY2LS41Ny0xLjkxOC0uMjIyLTIuOTUtLjY0Mi0zLjE4OS0xLjIyNi0uMDMxLS4wNjMtLjA1Mi0uMTUtLjA1NS0uMjI1LS4wMTUtLjI0My4xNjUtLjQ2NS40Mi0uNTA5IDMuMjY0LS41NCA0LjczLTMuODc5IDQuNzkxLTQuMDJsLjAxNi0uMDI5Yy4xOC0uMzQ1LjIyNC0uNjQ1LjExOS0uODY5LS4xOTUtLjQzNC0uODg0LS42NTgtMS4zMzItLjgwOS0uMTIxLS4wMjktLjI0LS4wNzQtLjM0Ni0uMTE5LTEuMTA3LS40MzUtMS4yNTctLjkzLTEuMTk3LTEuMjczLjA5LS40NzkuNjc0LS43OTMgMS4xNjgtLjc5My4xNDYgMCAuMjcuMDI5LjM4My4wNzQuNDIuMTk0Ljc4OS4zIDEuMTA0LjMuMjM0IDAgLjM4NC0uMDYuNDY1LS4xMDVsLS4wNDYtLjU2OWMtLjA5OC0xLjYyNi0uMjI1LTMuNjUxLjMwNy00LjgzN0M3LjM5MiAxLjA3NyAxMC43MzkuODA3IDExLjcyNy44MDdsLjQxOS0uMDE1aC4wNnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykgc2NhbGUoLjc1KSIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEuMyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-criteo,.ptag.p-criteo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEiIGZpbGw9IiNGRTUwMDAiLz48cGF0aCBkPSJNMTYuMiA4LjZhNSA1IDAgMSAwIDAgNi44IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi42IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-hotjar,.ptag.p-hotjar::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjExOSA5LjgxNEMxMi44OTkgOC4yNyAxNi43MDQgNi4xNTUgMTYuNzA0IDBoLTQuNjA5YzAgMy40NDQtMS42NzYgNC4zNzUtNC4yMTQgNS43ODZDNS4xIDcuMzMgMS4yOTUgOS40NDQgMS4yOTUgMTUuNmg0LjYxYzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODZ6TTE4LjA5NiA4LjRjMCAzLjQ0NC0xLjY3NyA0LjM3Ni00LjIxNSA1Ljc4NS0yLjc3OCAxLjU0NC02LjU4NSAzLjY2LTYuNTg1IDkuODE1aDQuNjA5YzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODYgMi43OC0xLjU0NCA2LjU4Ni0zLjY1OCA2LjU4Ni05LjgxNGgtNC42MDl6IiBmaWxsPSIjRkYzQzAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-adobe,.ptag.p-adobe::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTAgMmg4LjVMMCAyMnpNMjQgMmgtOC41TDI0IDIyek0xMiA5bDUuNSAxM2gtMy42bC0xLjctNC4ySDguOXoiIGZpbGw9IiNFQjEwMDAiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-matomo,.ptag.p-matomo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThsNi05IDYgOSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzE1MkEwIiBzdHJva2Utd2lkdGg9IjIiLz48Y2lyY2xlIGN4PSI2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiM5NUM3NDgiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjkiIHI9IjMiIGZpbGw9IiMzMTUyQTAiLz48Y2lyY2xlIGN4PSIxOCIgY3k9IjE4IiByPSIzIiBmaWxsPSIjMzE1MkEwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-piwik_pro,.ptag.p-piwik_pro::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzBDMkQ1QiIvPjxwYXRoIGQ9Ik04IDE4VjZoNWEzLjUgMy41IDAgMDEwIDdIOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+") center/contain no-repeat}.dot.p-clarity,.ptag.p-clarity::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzAwNzhENCIvPjxwYXRoIGQ9Ik0xMiA1YTcgNyAwIDEwMCAxNE0xMiA5YTMgMyAwIDEwMCA2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}
1055
- .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
1056
- .evlist>li+li{border-top:1px solid var(--border)}
1057
- summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
1058
- summary:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1059
- .chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
1060
- .event>summary{display:flex;align-items:center;gap:10px;padding:8px 12px;flex-wrap:wrap}
1061
- .event>summary:hover{background:var(--muted-bg)}
1062
- .event[class*=flag-]>summary .ev::after{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--warn-dot);margin-left:8px;vertical-align:2px}
1063
- .event.flag-broken>summary .ev::after{background:var(--danger-dot)}.event.flag-risk>summary .ev::after{background:var(--risk)}
1064
- .event .tag{display:none}
1065
- .ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
1066
- .event .meta{font-size:12px;color:var(--muted)}
1067
- .event .time,.time{font-size:12px;color:var(--muted);margin-left:auto}
1068
- .ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
1069
- .ptag::before{content:"";width:14px;height:14px;flex:none;border-radius:2px;background:var(--muted)}
1070
- .event-body{padding:4px 12px 16px}
1071
- .flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius);background:var(--warn-bg);color:var(--warn)}
1072
- .flagnote.broken{background:var(--danger-bg);color:var(--danger)}.flagnote.risk{background:var(--risk-bg);color:var(--risk)}
1073
- .event-body h5{font-size:12px;color:var(--muted);font-weight:400;margin:12px 0 6px}
1074
- .event-body h5:first-child{margin-top:4px}
1075
- h5 .n{margin-left:4px}
1076
- .ebgrid{display:grid;gap:12px}
1077
- .ebgrid>*{min-width:0}
1078
- .ptable{width:100%;border-collapse:collapse;font:12px/1.5 var(--font-mono)}
1079
- .ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
1080
- .ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
1081
- .glossary th{min-width:170px}
1082
- .ptable td{overflow-wrap:anywhere}
1083
- .ptable tr:last-child>*{border-bottom:0}
1084
- .consents{display:flex;flex-wrap:wrap;gap:6px}
1085
- .consent{font-size:12px;padding:2px 6px;border-radius:4px}.consent.granted{background:var(--ok-bg);color:var(--ok)}.consent.denied{background:var(--warn-bg);color:var(--warn)}
1086
- .ebgrid aside{background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;align-self:start}
1087
- .ebgrid aside h5{margin-top:0!important}
1088
- .srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:12px}
1089
- .srclist li{display:grid;gap:2px;overflow-wrap:anywhere}.srclist .lbl{font-size:12px;color:var(--muted)}
1090
- .method{font:500 12px var(--font-mono);padding:0 5px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg)}
1091
- .status{color:var(--ok);font-family:var(--font-mono);font-size:12px}
1092
- .small{font-size:12px}
1093
- .item{border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px}
1094
- .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:10px}
1095
- .item-head b{font-weight:500}
1096
- .item-idx{font:12px var(--font-mono);color:var(--muted)}
1097
- .item-price{margin-left:auto;font-size:12px}
1098
- .grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
1099
- .grid4 dt{font:12px var(--font-mono);color:var(--muted);overflow-wrap:anywhere}.grid4 dd{margin:0;font:12px var(--font-mono);overflow-wrap:anywhere}
1100
- h6{margin:12px 0 6px;font-size:12px;font-weight:400;color:var(--muted)}
1101
- .pk{margin-right:4px}
1102
- .q{position:relative;display:inline-grid;place-items:center;width:15px;height:15px;border:1px solid var(--input);border-radius:50%;font:500 10px var(--font-sans);color:var(--muted);cursor:help;vertical-align:1px}
1103
- .q:hover,.q:focus{border-color:var(--accent);color:var(--accent-fg);outline:none}
1104
- .q:hover::after,.q:focus::after{content:attr(data-tip);position:absolute;left:0;top:calc(100% + 6px);z-index:5;width:260px;padding:8px 10px;background:var(--fg);color:var(--bg);font:400 12px/1.45 var(--font-sans);border-radius:var(--radius);white-space:normal;text-align:left}
1105
- tr.pr td{color:var(--muted)}
1106
- details.raw{margin-top:10px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
1107
- pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
1108
- .scope-note{margin-top:10px}
1109
- .cov td.c{text-align:center;font-family:var(--font-mono)}
1110
- .cov .y{color:var(--ok)}.cov .x{color:var(--danger);font-weight:600}.cov .o{color:var(--muted)}
1111
- @media (min-width:768px){
1112
- .wrap{padding:0 24px}
1113
- .metrics{grid-template-columns:repeat(4,1fr)}
1114
- .ov-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:start}
1115
- .fcols{grid-template-columns:1fr 1fr}
1116
- .footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:32px 24px}
1117
- .footer .wrap.plain{grid-template-columns:1fr auto}
1118
- .jgrid2{grid-template-columns:260px minmax(0,1fr)}
1119
- .stepnav{position:sticky;top:60px}
1120
- .ebgrid{grid-template-columns:2fr 1fr}
1121
- .grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
1122
- .event-body{padding:4px 16px 16px 38px}
1808
+ function scopePanel(m, runs, plan, metrics, planCheckResult, runInfo) {
1809
+ return `<div class="stack">${planCheckResult ? planVsSiteCard(m, planCheckResult) : ""}${coverageCard(m, plan, runs, metrics)}${conversionsCard(m, metrics)}${inventoryCard(m, runs)}${providersCard(m, runs)}${timingCard(m, metrics)}${runCard(m, runs, runInfo)}</div>`;
1123
1810
  }
1124
- @media (max-width:767px){.ov-grid{display:grid;gap:16px}}
1125
- @media (prefers-reduced-motion:reduce){*{transition:none!important}}
1126
- @media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
1127
- `;
1128
1811
 
1129
- export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
1812
+ /* ---------- page ---------- */
1813
+
1814
+ /** The inline script: tablists with arrow keys, hash deep links that open what holds the target, and the theme toggle. Fixed text, so its hash is fixed. */
1815
+ export const SCRIPT = `(function () {
1816
+ var html = document.documentElement;
1817
+ html.classList.add("js");
1818
+ function tablist(list) {
1819
+ var tabs = Array.prototype.slice.call(list.querySelectorAll("[role=tab]"));
1820
+ function select(tab, focus) {
1821
+ tabs.forEach(function (t) {
1822
+ var on = t === tab;
1823
+ t.setAttribute("aria-selected", on ? "true" : "false");
1824
+ t.tabIndex = on ? 0 : -1;
1825
+ var panel = document.getElementById(t.getAttribute("aria-controls"));
1826
+ if (panel) panel.classList.toggle("active", on);
1827
+ });
1828
+ if (focus) tab.focus();
1829
+ }
1830
+ tabs.forEach(function (t, i) {
1831
+ t.addEventListener("click", function () { select(t); });
1832
+ t.addEventListener("keydown", function (e) {
1833
+ var j = e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : -1;
1834
+ if (j < 0) return;
1835
+ e.preventDefault();
1836
+ select(tabs[(j + tabs.length) % tabs.length], true);
1837
+ });
1838
+ });
1839
+ return { tabs: tabs, select: select };
1840
+ }
1841
+ var lists = Array.prototype.map.call(document.querySelectorAll("[role=tablist]"), tablist);
1842
+ lists.forEach(function (l) { l.select(l.tabs.filter(function (t) { return t.getAttribute("aria-selected") === "true"; })[0] || l.tabs[0]); });
1843
+ function reveal() {
1844
+ var id = location.hash.slice(1);
1845
+ if (!id) return;
1846
+ var el = document.getElementById(id);
1847
+ if (!el) return;
1848
+ var node = el;
1849
+ while (node && node !== document.body) {
1850
+ if (node.getAttribute && node.getAttribute("role") === "tabpanel") {
1851
+ var tab = document.getElementById(node.getAttribute("aria-labelledby"));
1852
+ lists.forEach(function (l) { if (l.tabs.indexOf(tab) >= 0) l.select(tab); });
1853
+ }
1854
+ if (node.tagName === "DETAILS") node.open = true;
1855
+ node = node.parentNode;
1856
+ }
1857
+ var next = el.nextElementSibling;
1858
+ if (next && next.classList.contains("drow")) next.querySelector("details").open = true;
1859
+ el.scrollIntoView({ block: "start" });
1860
+ }
1861
+ window.addEventListener("hashchange", reveal);
1862
+ reveal();
1863
+ var btn = document.getElementById("theme");
1864
+ if (!btn) return;
1865
+ var order = ["auto", "light", "dark"];
1866
+ function apply(mode) {
1867
+ if (mode === "auto") html.removeAttribute("data-theme"); else html.setAttribute("data-theme", mode);
1868
+ btn.setAttribute("aria-pressed", mode === "auto" ? "false" : "true");
1869
+ btn.title = btn.getAttribute("data-" + mode);
1870
+ btn.setAttribute("aria-label", btn.getAttribute("data-" + mode));
1871
+ }
1872
+ var mode = "auto";
1873
+ try { mode = localStorage.getItem("aitd-theme") || "auto"; } catch (e) {}
1874
+ if (order.indexOf(mode) < 0) mode = "auto";
1875
+ apply(mode);
1876
+ btn.addEventListener("click", function () {
1877
+ mode = order[(order.indexOf(mode) + 1) % order.length];
1878
+ apply(mode);
1879
+ try { localStorage.setItem("aitd-theme", mode); } catch (e) {}
1880
+ });
1881
+ })();`;
1882
+
1883
+ /** The CSP source for the report's inline script, for the MCP server's Content-Security-Policy header and the page's own meta tag. */
1884
+ export const scriptCspSource = () => `'sha256-${createHash("sha256").update(SCRIPT).digest("base64")}'`;
1885
+
1886
+ /**
1887
+ * The HTML report. metrics (computeMetrics) and trend (history.js) are computed by the caller when it has
1888
+ * them; metrics is computed here otherwise so every report carries the verdict, and the Trend card is
1889
+ * left out without a history of at least two runs.
1890
+ */
1891
+ export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null, metrics = null, trend = null }) {
1130
1892
  const m = messages(lang);
1131
1893
  const rows = attachExplanations(findings, explained);
1894
+ metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
1132
1895
  const counts = summarize(rows);
1133
1896
  const problems = rows.filter((f) => f.severity !== "ok").length;
1134
- const flagged = flaggedSteps(runs, rows);
1897
+ const ids = cardIds(rows);
1898
+ const runInfo = { ai, plan, lang, against, planChanges, consentScenarios };
1135
1899
  const atd = `<a class="f-atd" href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="16">AI Tag Debugger</a>`;
1900
+ // A custom name or logo takes the place of the Analitik İşler branding. The AI Tag Debugger logo and credit stay, smaller, as with branding false.
1901
+ const whiteLabel = Boolean(brand?.logo || brand?.name);
1902
+ if (whiteLabel) branding = false;
1903
+ const brandName = brand?.name ? escapeHtml(brand.name) : "";
1904
+ const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
1905
+ const productLogo = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd");
1906
+ const atdByline = whiteLabel ? `<a href="https://github.com/analitikisler/ai-tag-debugger" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 18, "logo-atd")}</a>` : "";
1136
1907
  const footer = branding
1137
1908
  ? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a>
1138
1909
  <div><p>${m.ui.credit(atd)}</p><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div>
1139
- <div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
1140
- : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
1141
-
1910
+ <div class="f-meta">${escapeHtml(site ?? title)} · ${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
1911
+ : `<div class="wrap plain"><div class="f-meta">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"}</div><div class="f-meta">${escapeHtml(site ?? title)} · ${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
1912
+ const themeButton = `<button type="button" class="btn js-only" id="theme" aria-pressed="false" aria-label="${m.html.themeAuto}" title="${m.html.themeAuto}" data-auto="${m.html.themeAuto}" data-light="${m.html.themeLight}" data-dark="${m.html.themeDark}">${use("ui-theme", 16)}</button>`;
1913
+ const tab = (id, label, selected) => `<button type="button" role="tab" id="tab-${id}" aria-controls="${id}" aria-selected="${selected ? "true" : "false"}"${selected ? "" : ' tabindex="-1"'}>${label}</button>`;
1914
+ const panel = (id, heading, body) => `<section class="panel" id="${id}" role="tabpanel" aria-labelledby="tab-${id}"><h2>${heading}</h2>${body}</section>`;
1915
+ const body = `${verdictBlock(m, { site, title, generatedAt, findings, runs, lang, metrics, explained, counts, accepted: rows.filter((f) => f.accepted).length, runInfo, changes })}
1916
+ <nav class="tabbar" aria-label="${m.ui.sections}"><div class="wrap"><div class="tabs js-only" role="tablist" aria-label="${m.ui.sections}">${tab("overview", m.ui.tabOverview, true)}${tab("findings", `${m.ui.findings} <span class="n">${problems}</span>`)}${tab("journeys", `${m.ui.tabJourneys} <span class="n">${runs.length}</span>`)}${tab("scope", m.ui.tabScope)}</div></div></nav>
1917
+ <div class="wrap">
1918
+ ${panel("overview", m.ui.tabOverview, overviewPanel(m, runs, rows, ids, metrics, trend, changes))}
1919
+ ${panel("findings", `${m.ui.findings} <span class="n">${problems}</span>`, findingsPanel(m, runs, plan, rows, ids, counts))}
1920
+ ${panel("journeys", `${m.ui.tabJourneys} <span class="n">${runs.length}</span>`, journeysPanel(m, lang, findings, rows, runs, ids, metrics, consentScenarios))}
1921
+ ${panel("scope", m.ui.tabScope, scopePanel(m, runs, plan, metrics, planCheck, runInfo))}
1922
+ </div>`;
1923
+ const topbar = `<header class="topbar"><div class="wrap"><span class="product">${productLogo}</span><div class="actions">${atdByline}${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}${themeButton}</div></div></header>`;
1142
1924
  return `<!doctype html>
1143
1925
  <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1144
- <title>${escapeHtml(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
1145
- <style>${CSS}${filterCss(runs)}
1146
- ${navCss(runs)}</style></head><body>
1147
- <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
1148
- <header class="wrap title">
1149
- <h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
1150
- <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}${planChanges ? `\n<div class="meta plan-changes">${escapeHtml(m.ui.planChangesLine(planChanges))}${planChangeItems(planChanges).length ? `<ul>${planChangeItems(planChanges).map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>` : ""}</div>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
1151
- </header>
1152
- <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1153
- <a href="#overview">${m.ui.tabOverview}</a><a href="#findings">${m.ui.findings} <span class="n">${problems}</span></a><a href="#journeys">${m.ui.tabJourneys} <span class="n">${runs.length}</span></a><a href="#scope">${m.ui.tabScope}</a></div></nav>
1154
- <main class="wrap">
1155
- <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${changes ? changesCard(m, changes) : ""}${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs, { ai, plan, lang })}</section>
1156
- <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows, Boolean(explained))}</section>
1157
- <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged, consentScenarios)}</section>
1158
- <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${inventoryCard(m, runs)}${providersCard(m, runs)}</div></section>
1926
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${scriptCspSource()}">
1927
+ <meta name="color-scheme" content="light dark">
1928
+ <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
1929
+ <style>${CSS}${brandCss(brand?.color)}</style></head><body>
1930
+ <a class="skip" href="#main">${m.html.skip}</a>
1931
+ ${sprite(`${topbar}${body}`)}
1932
+ ${topbar}
1933
+ <main id="main">
1934
+ ${body}
1159
1935
  </main>
1160
1936
  <footer class="footer">${footer}</footer>
1937
+ <script>${SCRIPT}</script>
1161
1938
  </body></html>`;
1162
1939
  }
1163
1940