@analitikisler/ai-tag-debugger 0.16.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,14 +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
10
  import { brandCss } from "./whitelabel.js";
11
+ import { computeMetrics } from "./metrics.js";
12
+ import { historySummaryLines, sparkline } from "./history.js";
11
13
  import { readFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
12
15
 
13
16
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
17
 
@@ -18,7 +21,7 @@ For each finding with severity broken, risk or warning, give:
18
21
  - impact: one sentence on what this does to the data, the reports or ad spend;
19
22
  - likely_cause: the most likely cause, in plain language a marketing manager can follow and an analyst can act on;
20
23
  - fix_steps: the fix as short, ordered steps.
21
- 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.
22
25
 
23
26
  Then write the summary:
24
27
  - headline: one sentence naming what matters most for data quality and ad spend;
@@ -96,7 +99,7 @@ function hitsByStep(run) {
96
99
  * deterministic report is still written.
97
100
  */
98
101
  export async function explainFindings({ findings, plan, runs, gtmContainer, provider, providerName, client, model, lang = "en", log = () => {} }) {
99
- 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);
100
103
  if (actionable.length === 0) return { summary: { headline: messages(lang).ui.allGood, points: [], also_check: [] }, explanations: [] };
101
104
  try {
102
105
  const input = {
@@ -128,7 +131,11 @@ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "
128
131
 
129
132
  function attachExplanations(findings, explained) {
130
133
  const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
131
- 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 } : {}) }));
132
139
  }
133
140
 
134
141
  const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
@@ -147,7 +154,6 @@ function hitSeverity(findings, run, step, hit) {
147
154
 
148
155
  const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
149
156
  const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
150
- const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) => JSON.stringify(v)).join(", ")})` : JSON.stringify(value));
151
157
 
152
158
  // Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
153
159
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -155,65 +161,369 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
155
161
 
156
162
  const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
157
163
 
158
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null }) {
159
- const m = messages(lang);
160
- const rows = attachExplanations(findings, explained);
161
- const counts = summarize(rows);
162
- 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)}**`, ""];
163
- if (changes) lines.push(...changesMd(m, changes));
164
- if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
165
- lines.push(...inventoryMd(m, runs));
166
- const sum = summaryParts(explained?.summary);
167
- if (sum) {
168
- lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
169
- if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
170
- 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 ?? "");
171
184
  }
172
- if (consentScenarios) {
173
- const { cols, rows: tagRows } = consentTable(consentScenarios);
174
- lines.push(`## ${m.ui.scenarios}`, "", mdText(m.ui.scenariosLead(consentScenarios.cmp)), "");
175
- lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
176
- 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(" | ")} |`);
177
- 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;
178
210
  }
179
- lines.push(`## ${m.ui.findings}`, "");
180
- for (const f of rows.filter((r) => r.severity !== "ok")) {
181
- lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
182
- lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
183
- lines.push(`- ${m.ui.owners[ownerOf(f)]}`);
184
- const vl = verifyLinks(f);
185
- if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
186
- if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
187
- if (f.gtm) lines.push(`- ${m.ui.gtmTags}: ${f.gtm.length ? f.gtm.map((t) => `${t.name}${t.paused ? ` (${m.ui.gtmPaused})` : ""}, ${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map((x) => x.name).join(", ")}` : m.ui.gtmNoTrigger}`).join("; ") : m.ui.gtmNoTag}`);
188
- if (f.explanation) {
189
- if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
190
- lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
191
- }
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)}`);
192
320
  lines.push("");
193
321
  }
194
- 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)}`);
195
- 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} |`, "|---|---|---|---|---|---|");
196
408
  for (const r of runs) {
197
- lines.push(`### ${mdText(r.journeyId)} (${mdText(r.viewport)}): ${r.completed ? m.ui.completed : m.ui.notCompleted}`, "", mdText(r.note), "");
198
- for (const step of r.steps.filter((s) => s.index > 0)) {
199
- lines.push(`${step.index}. **${mdText(step.label)}** (${seconds(r, step.t)})${step.forced ? ` _${m.ui.clickedThrough}_` : ""}`);
200
- for (const h of r.hits.filter((x) => x.step === step.index)) {
201
- const flag = hitSeverity(findings, r, step, h);
202
- const params = Object.entries(h.params).map(([k, v]) => `${k}=${v}`).join(", ");
203
- lines.push(` - ${flag ? `**${m.severity[flag]}** ` : ""}${mdText(`${h.platform}:${h.name}${params ? ` (${params})` : ""}`)}`);
204
- }
205
- for (const e of (r.dataLayerLog ?? []).filter((x) => x.step === step.index)) {
206
- lines.push(` - dataLayer: \`${mdCode(isConsentEntry(e.value) ? formatPush(e.value) : JSON.stringify(e.value))}\``);
207
- }
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));
208
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")}`);
209
428
  lines.push("");
210
429
  }
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));
211
519
  // A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
212
520
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
213
521
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
214
522
  return lines.join("\n");
215
523
  }
216
524
 
525
+ const SEVERITY_ORDER_MD = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
526
+
217
527
  const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
218
528
  export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
219
529
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
@@ -270,11 +580,73 @@ function callsForHit(run, hit) {
270
580
  }
271
581
 
272
582
 
273
- const icon = {
274
- 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"/>',
275
621
  };
276
622
 
277
- 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>`;
278
650
  // Platform keys go into class names, ids and attribute selectors.
279
651
  const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
280
652
  const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
@@ -309,9 +681,9 @@ function itemCards(m, items) {
309
681
  })
310
682
  .join("");
311
683
  }
312
- 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)}` : "");
313
685
 
314
- /** 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. */
315
687
  function hitFlag(findings, run, step, hit) {
316
688
  let worst = null;
317
689
  for (const f of findings) {
@@ -320,7 +692,7 @@ function hitFlag(findings, run, step, hit) {
320
692
  if (f.platform !== hit.platform || f.event !== hit.name) continue;
321
693
  if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
322
694
  }
323
- return worst && { severity: worst.severity, detail: worst.detail };
695
+ return worst;
324
696
  }
325
697
 
326
698
  const hostPath = (url) => {
@@ -332,19 +704,13 @@ const hostPath = (url) => {
332
704
  }
333
705
  };
334
706
 
335
- /** Radio buttons that filter the list after them by data-p. Only sources present are offered. */
336
- function filterBar(m, name, rows) {
337
- const counts = new Map();
338
- for (const r of rows) counts.set(r.key, { label: r.label, n: (counts.get(r.key)?.n ?? 0) + 1 });
339
- if (counts.size < 2) return "";
340
- const keys = [...counts.keys()].sort((a, b) => platformRank(a) - platformRank(b) || counts.get(a).label.localeCompare(counts.get(b).label));
341
- 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
342
- .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>`)
343
- .join("")}`;
344
- }
345
- 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
+ };
346
712
 
347
- function eventRow(m, findings, run, step, hit) {
713
+ function eventRow(m, findings, run, step, hit, ids = new Map()) {
348
714
  const flag = hitFlag(findings, run, step, hit);
349
715
  const pushes = pushesForHit(run, hit);
350
716
  const calls = callsForHit(run, hit);
@@ -355,16 +721,17 @@ function eventRow(m, findings, run, step, hit) {
355
721
  if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
356
722
  if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
357
723
  if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
358
- 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>` : "";
359
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>`;
360
726
  const reqText = calls.length
361
- ? 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("")
362
728
  : `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
363
- return `<li data-p="${safeKey(hit.platform)}"><details class="event${flag ? ` flag-${flag.severity}` : ""}"${flag ? " open" : ""}>
364
- <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>
365
- <div class="event-body">${flag ? `<p class="flagnote ${flag.severity}">${escapeHtml(flag.detail)}</p>` : ""}
366
- <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>
367
- <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>
368
735
  ${itemsBlock(m, items)}</div></details></li>`;
369
736
  }
370
737
 
@@ -375,8 +742,8 @@ function pushRow(m, run, e) {
375
742
  const items = objectItems(pushItems(e.value));
376
743
  const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
377
744
  const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
378
- 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>
379
- <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>`;
380
747
  }
381
748
 
382
749
  /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
@@ -419,12 +786,13 @@ function paramSets(call) {
419
786
  return [query];
420
787
  }
421
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. */
422
790
  function paramRows(m, lang, entries) {
423
791
  return entries
424
792
  .map(([k, v]) => {
425
793
  const tip = paramTip(k, lang);
426
794
  const isItem = /^pr\d+$/.test(k);
427
- 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>`;
428
796
  })
429
797
  .join("");
430
798
  }
@@ -436,17 +804,18 @@ function requestRows(m, lang, run, step) {
436
804
  const sets = paramSets(c);
437
805
  const hits = parseTrackingRequest(c.url, c.postData ?? null) ?? [];
438
806
  const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
807
+ const label = PROVIDERS[key] ?? c.vendor ?? key;
439
808
  sets.forEach((entries, i) => {
440
809
  const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
441
810
  const eventEntries = entries.filter(([k]) => isEventParam(k));
442
811
  const context = entries.filter(([k]) => !isEventParam(k));
443
812
  const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
444
813
  const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
445
- 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>
446
- <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>` : ""}
447
- ${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>` : ""}
448
- ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
449
- 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 });
450
819
  });
451
820
  }
452
821
  return rows;
@@ -455,31 +824,374 @@ ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawR
455
824
  const stepAnchor = (ri, si) => `j${ri}-s${si}`;
456
825
  const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
457
826
  const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
458
- const problemCount = (findings, run) => findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === run.journeyId && f.viewport === run.viewport).length;
459
827
 
460
828
  // AI text marks numbers with **x** and names with `x`. Only those two marks become HTML; everything else is escaped.
461
829
  const fmt = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
462
830
  // Finding texts quote event and parameter names ("add_to_cart"); show those as code.
463
831
  const detailHtml = (s) => escapeHtml(s).replace(/&quot;([^&]+?)&quot;/g, "<code>$1</code>");
464
832
 
465
- // Lucide icons.
466
- 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>`;
467
- const ICON = {
468
- 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"/>',
469
- 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"/>',
470
- info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
471
- impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
472
- cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
473
- gtm: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
474
- 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"/>',
475
- 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"/>',
476
- 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"/>',
477
- };
478
- const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "info" : "warning"];
479
- const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
480
- const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
481
- /** A logo with its light and dark versions, switched by the viewer's color scheme. */
482
- 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>`;
483
1195
 
484
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. */
485
1197
  function whereTarget(runs, w, f) {
@@ -490,112 +1202,82 @@ function whereTarget(runs, w, f) {
490
1202
  return { ri, step };
491
1203
  }
492
1204
 
493
- /** The push a hit came from: the latest matching push at or before the hit, else the first match. */
494
- function sourcePush(run, hit) {
495
- const pushes = pushesForHit(run, hit);
496
- return pushes.filter((e) => e.t <= hit.t).at(-1) ?? pushes[0] ?? null;
497
- }
498
-
499
- /** The step in order: each dataLayer push, the GTM event it raised, then the platform hits it caused. Hits with no push come last. */
500
- function timelinePanel(m, findings, run, step, pushes, hits) {
501
- const bySource = new Map();
502
- const orphans = [];
503
- for (const h of [...hits].sort((a, b) => a.t - b.t)) {
504
- const src = sourcePush(run, h);
505
- if (src && src.step === step.index) {
506
- if (!bySource.has(src)) bySource.set(src, []);
507
- bySource.get(src).push(h);
508
- } else orphans.push(h);
1205
+ const pathOf = (url) => {
1206
+ try {
1207
+ return new URL(url).pathname || "/";
1208
+ } catch {
1209
+ return String(url ?? "");
509
1210
  }
510
- 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>`);
511
- const nodes = [...pushes].sort((a, b) => a.t - b.t).map((e) => {
512
- const name = pushEvent(e.value);
513
- const gtm = isConsentEntry(e.value)
514
- ? `<span class="tl-k">${m.ui.timelineConsent}</span><code>${escapeHtml(`consent ${e.value[1] ?? ""}`.trim())}</code>`
515
- : typeof name === "string" && name
516
- ? `<span class="tl-k">${m.ui.timelineGtmEvent}</span><code>${escapeHtml(name)}</code>`
517
- : `<span class="tl-k muted">${m.ui.timelineNoEvent}</span>`;
518
- 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>`;
519
- });
520
- if (orphans.length) nodes.push(`<li class="tl-node tl-orphan"><div class="tl-lbl">${m.ui.timelineOther}</div>${hitList(orphans)}</li>`);
521
- return nodes.length ? `<ol class="timeline">${nodes.join("")}</ol>` : `<p class="empty">${m.ui.noHits}</p>`;
522
- }
1211
+ };
523
1212
 
524
- function stepDetail(m, lang, findings, run, ri, step, isDefault) {
525
- const hits = run.hits.filter((h) => h.step === step.index);
526
- const id = `s${ri}-${step.index}`;
527
- const sortedHits = [...hits].sort((a, b) => platformRank(a.platform) - platformRank(b.platform) || a.t - b.t);
528
- const eventRows = sortedHits.map((h) => ({ key: safeKey(h.platform), label: platformLabel(h.platform), html: eventRow(m, findings, run, step, h) }));
529
- const pushes = (run.dataLayerLog ?? []).filter((e) => e.step === step.index);
530
- const reqRows = requestRows(m, lang, run, step);
531
- const skipped = skippedText(run.skipped?.[step.index]);
532
- const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
533
- 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>`;
534
- return `<section class="sdetail${isDefault ? " default" : ""}" id="${stepAnchor(ri, step.index)}" aria-label="${escapeHtml(step.label)}">
535
- <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>
536
- <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)}
537
- <div class="panel">${timelinePanel(m, findings, run, step, pushes, hits)}</div>
538
- <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
539
- <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>
540
- <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>
541
- </div></section>`;
542
- }
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 ?? ""}`;
543
1217
 
544
- /** 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. */
545
- /** Every Consent Mode command of a journey (default, then updates), one tile per signal. */
546
- function consentCard(m, run) {
547
- const cmds = run.dataLayerLog.filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
548
- 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>`;
549
- if (!cmds.length) return `<div class="card jconsent">${head}<div class="card-c"></div></div>`;
550
- const items = cmds.map((e) => {
551
- const step = run.steps.find((s) => s.index === e.step);
552
- const sigs = Object.entries(e.value[2] ?? {}).filter(([, v]) => typeof v === "string");
553
- 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>`;
554
- });
555
- return `<div class="card jconsent">${head}<div class="card-c"><ol class="ctl">${items.join("")}</ol></div></div>`;
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;
556
1230
  }
557
1231
 
558
- function journeyPanel(m, lang, findings, run, ri, flagged) {
559
- const steps = run.steps.filter((s) => s.index > 0);
560
- const def = steps.find((s) => flagged.has(stepAnchor(ri, s.index))) ?? steps[0];
561
- const nav = steps
562
- .map((s) => {
563
- const hits = run.hits.filter((h) => h.step === s.index);
564
- 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}`);
565
- const a = stepAnchor(ri, s.index);
566
- 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>`;
567
- })
568
- .join("");
569
- const body = steps.length
570
- ? `<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>`
571
- : `<p class="empty">${m.ui.noSteps}</p>`;
572
- 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>
573
- ${consentCard(m, run)}${body}</article>`;
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));
574
1237
  }
575
1238
 
576
- /** Consent scenarios: one row per tag, one column per consent choice, hit counts after the choice. */
577
- function scenariosCard(m, scenarios) {
578
- if (!scenarios) return "";
579
- const { cols, rows } = consentTable(scenarios);
580
- const head = `<tr><th>${m.ui.scenarioTag}</th>${cols.map((c) => `<th>${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
581
- 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("");
582
- const notRun = cols.filter((c) => c !== "before" && !scenarios.states[c]?.via).map((c) => m.ui.scenarioCols[c]);
583
- 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>`;
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));
584
1244
  }
585
1245
 
586
- function journeysTab(m, lang, findings, runs, flagged, scenarios = null) {
587
- if (!runs.length) return `${scenariosCard(m, scenarios)}<p class="empty">${m.ui.noSteps}</p>`;
588
- 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("");
589
- 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")}`;
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;
590
1255
  }
591
1256
 
592
- /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
593
- function evidenceHtml(evidence) {
594
- try {
595
- const v = JSON.parse(evidence);
596
- 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>`;
597
- } catch { /* plain text */ }
598
- return `<pre>${escapeHtml(evidence)}</pre>`;
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>`;
599
1281
  }
600
1282
 
601
1283
  /** The tags in the GTM container export that send this finding's event, with their triggers. */
@@ -605,81 +1287,111 @@ function gtmHtml(m, tags) {
605
1287
  const trig = (t) => `${escapeHtml(t.name)}${t.event ? ` <span class="muted">(${escapeHtml(t.event)})</span>` : ""}`;
606
1288
  const rows = tags.map((t) => {
607
1289
  const notes = [t.paused ? m.ui.gtmPaused : "", t.consent.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
608
- return `<tr><th>${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>` : ""}</td></tr>`;
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>`;
609
1292
  });
610
- return `${sec("gtm", m.ui.gtmTags)}<table class="ptable ev-table gtm-tags"><tbody>${rows.join("")}</tbody></table>`;
1293
+ return `${sec("gtm", m.ui.gtmTags)}<table class="ptable gtm-tags" aria-label="${m.ui.gtmTags}"><tbody>${rows.join("")}</tbody></table>`;
611
1294
  }
612
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
+
613
1298
  function verifyHtml(m, f) {
614
1299
  const links = verifyLinks(f);
615
1300
  if (!links.length) return "";
616
- 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);
1318
+ }
1319
+
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}`;
617
1330
  }
618
1331
 
619
- function findingCard(m, runs, f, open, aiRan) {
1332
+ function findingCard(m, runs, plan, f, id, open) {
620
1333
  const e = f.explanation;
621
1334
  const steps = fixSteps(e);
622
- const journeysN = new Set(f.where.map((w) => `${w.journey}|${w.viewport}`)).size;
623
- const stepsN = f.where.filter((w) => w.step).length;
624
- const evidence = f.evidence
625
- ? evidenceHtml(f.evidence)
626
- : f.check === "missing_event" && f.platform
627
- ? `<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>`
628
- : "";
629
- const chips = f.where
630
- .map((w) => {
631
- const t = whereTarget(runs, w, f);
632
- 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>` : ""}`;
633
- return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
634
- })
635
- .join("");
636
- return `<li class="finding sev-${f.severity}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
637
- <span class="sev-icon">${ic(sevIcon(f.severity), 18)}</span>
638
- <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>
639
- ${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
640
- <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>` : ""}
641
- <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}` : ""}${gtmHtml(m, f.gtm)}</div>
642
- <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>`;
643
1348
  }
644
1349
 
645
1350
  const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
646
- const SEV_COLOR = { broken: "var(--danger-dot)", risk: "var(--risk)", warning: "var(--warn-dot)", info: "var(--accent)" };
647
1351
 
648
- 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) {
649
1364
  const problems = rows.filter((f) => f.severity !== "ok");
650
- if (!problems.length) return `<p class="empty">${m.ui.noProblems}</p>`;
651
- const n = Object.fromEntries(FINDING_SEVERITIES.map((s) => [s, problems.filter((f) => f.severity === s).length]));
652
- const present = FINDING_SEVERITIES.filter((s) => n[s]);
653
- const radios = ["all", ...present].map((s) => `<input type="radio" name="fs" id="fs-${s}" class="fsr"${s === "all" ? " checked" : ""}>`).join("");
654
- const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
655
- const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
656
- 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("");
657
- 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>`;
658
1372
  const groups = OWNERS.map((o) => {
659
1373
  const mine = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s && ownerOf(f) === o));
660
1374
  if (!mine.length) return "";
661
- const cards = mine.map((f) => {
662
- const open = first && f.severity !== "info";
663
- if (open) first = false;
664
- return findingCard(m, runs, f, open, aiRan);
665
- }).join("");
666
- 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>`;
667
1378
  }).join("");
668
- return `<div class="filter-host">${radios}
669
- <div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
670
- <div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
671
- ${groups}</div>`;
1379
+ return `${bar}<p class="muted xs lead">${m.html.findingsLead}${okCount ? ` ${escapeHtml(m.html.okInOverview(okCount))}` : ""}</p>${groups}`;
672
1380
  }
673
1381
 
674
- 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) {
675
1391
  const s = summaryParts(explained?.summary);
676
- let body;
677
- if (!s) body = `<p class="muted">${problems ? m.ui.noSummary : m.ui.noProblems}</p>`;
678
- else if (s.text) body = `<p class="summary-text">${fmt(s.text)}</p>`;
679
- else {
680
- 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>` : ""}`;
681
- }
682
- 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>`;
683
1395
  }
684
1396
 
685
1397
  /** The providers that were checked: from the runs, or for older captures the platforms seen. */
@@ -696,43 +1408,279 @@ function skippedTotals(runs) {
696
1408
  }
697
1409
 
698
1410
  const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
1411
+ const uniqList = (xs) => [...new Set(xs)];
699
1412
 
700
- /** The run's settings: platforms checked, AI model, plan, journeys, viewports, language and version. */
701
- function runCard(m, runs, provs, { ai, plan, lang }) {
702
- const uniq = (xs) => [...new Set(xs)];
703
- const ptags = provs.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("");
704
- const rows = [
705
- [m.ui.runPlatforms, ptags || '<span class="muted">–</span>'],
706
- [m.ui.runAi, escapeHtml(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)],
707
- [m.ui.runPlan, escapeHtml(plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan)],
708
- [m.ui.runJourneys, escapeHtml(uniq(runs.map((r) => r.journeyId)).join(", ") || "–")],
709
- [m.ui.runViewports, escapeHtml(uniq(runs.map((r) => r.viewport)).join(", ") || "–")],
710
- [m.ui.runLanguage, escapeHtml(messages(lang).name)],
711
- [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>`],
712
1429
  ];
713
- 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>`;
714
1431
  }
715
1432
 
716
- function overviewTab(m, findings, rows, counts, explained, runs, info) {
717
- const problems = rows.filter((f) => f.severity !== "ok").length;
718
- const metric = (sev) => `<div class="card metric ${sev}"><div class="k">${m.severity[sev]}</div><div class="v">${counts[sev]}</div></div>`;
719
- const journeyRows = runs.map((r, ri) => {
720
- const n = problemCount(findings, r);
721
- 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>`;
722
1466
  });
723
- const skipped = skippedTotals(runs);
724
- const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
725
- const provs = checkedProviders(runs);
726
- return `<div class="stack">
727
- <div class="metrics">${["broken", "risk", "warning", "ok"].map(metric).join("")}</div>
728
- <div class="ov-grid">${summaryCard(m, explained, problems)}
729
- <div class="stack">
730
- ${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>` : ""}
731
- ${runCard(m, runs, provs, info)}${skippedN ? `<p class="muted xs scope-skipped">${escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</p>` : ""}
732
- </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;
733
1525
  }
734
1526
 
735
- /** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
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>`;
1620
+ }
1621
+
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
+
736
1684
  const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
737
1685
  const coverageLines = (m, pc) => [
738
1686
  m.ui.coverageLine(pc.coverage),
@@ -747,29 +1695,6 @@ const planChangeItems = (pc) => [
747
1695
  ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
748
1696
  ];
749
1697
 
750
- /** "Changed since last run": one headline, then what was fixed and what is new. */
751
- const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
752
- const changeItems = (m, ch) => [
753
- ...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
754
- ...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
755
- ];
756
-
757
- function changesMd(m, ch) {
758
- const lines = [`## ${m.ui.changesTitle}`, ""];
759
- if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
760
- lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
761
- const items = changeItems(m, ch);
762
- if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
763
- if (ch.routes?.length) lines.push(...ch.routes.map((r) => `- ${mdText(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}`), "");
764
- return lines;
765
- }
766
-
767
- function changesCard(m, ch) {
768
- 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>`;
769
- if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
770
- const items = changeItems(m, ch);
771
- 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>`;
772
- }
773
1698
 
774
1699
  function planVsSiteMd(m, pc) {
775
1700
  const [overall, ...perPlatform] = coverageLines(m, pc);
@@ -782,374 +1707,195 @@ function planVsSiteMd(m, pc) {
782
1707
 
783
1708
  function planVsSiteCard(m, pc) {
784
1709
  const [overall, ...perPlatform] = coverageLines(m, pc);
785
- 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("");
786
- 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>`;
787
1712
  }
788
1713
 
789
- function coverageCard(m, plan, runs) {
790
- const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
791
- 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>`;
792
1718
  const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
793
1719
  const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
794
- const body = events
795
- .map((e) => {
796
- const cells = runs.map((r) => {
797
- if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
798
- return r.hits.some((h) => hitMatchesEvent(e, h)) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
799
- });
800
- const unchecked = !runs.some((r) => checks(r, e));
801
- 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>`;
802
- })
803
- .join("");
804
- 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>`;
805
1735
  }
806
1736
 
807
- const PAGES_SHOWN = 5;
1737
+ const CONVERSION_CHECKS = ["value", "currency", "transactionId", "items", "duplicate", "parity", "eventId"];
808
1738
 
809
- function inventoryMd(m, runs) {
810
- const inv = buildInventory(runs);
811
- const lines = [`## ${m.ui.inventoryTitle}`, ""];
812
- if (!inv.vendors.length) return [...lines, m.ui.noInventory, ""];
813
- lines.push(`| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|");
814
- for (const v of inv.vendors) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", "))} |`);
815
- if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
816
- lines.push("");
817
- 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>`;
818
1746
  }
819
1747
 
1748
+ const PAGES_SHOWN = 5;
1749
+
820
1750
  function inventoryCard(m, runs) {
821
1751
  const inv = buildInventory(runs);
822
- 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>`;
823
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>`;
824
1754
  const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
825
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>` : ""}`;
826
- 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("");
827
- 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>` : "";
828
- 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>`;
829
1759
  }
830
1760
 
1761
+ /** Platforms checked (seen or never seen) and vendors out of scope, with their request counts. */
831
1762
  function providersCard(m, runs) {
832
1763
  const provs = checkedProviders(runs);
833
1764
  const skipped = skippedTotals(runs);
834
1765
  if (!provs.length && !skipped.length) return "";
835
1766
  const calls = runs.flatMap((r) => r.network ?? []);
1767
+ const seen = new Set(runs.flatMap((r) => r.hits.map((h) => h.platform)));
836
1768
  const rows = [
837
- ...provs.map((k) => [platformLabel(k), badge("ok", m.ui.checked), calls.filter((c) => callProvider(c) === k).length]),
838
- ...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}`]),
839
1774
  ];
840
- 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>`;
841
1777
  }
842
1778
 
843
- /** Steps a problem finding points at; they get a dot in the step list, and the journey opens on the first one. */
844
- function flaggedSteps(runs, rows) {
845
- const out = new Set();
846
- for (const f of rows) {
847
- if (!(f.severity in SEVERITY_RANK)) continue;
848
- for (const w of f.where) {
849
- const t = whereTarget(runs, w, f);
850
- if (t?.step) out.add(stepAnchor(t.ri, t.step.index));
851
- }
852
- }
853
- return out;
854
- }
1779
+ const ms = (v) => (v === null || v === undefined ? "–" : `${Math.round(v)} ms`);
855
1780
 
856
- /** Filter rules for the platform keys in this report: picking a source hides the other rows. */
857
- function filterCss(runs) {
858
- const keys = new Set(PLATFORM_ORDER);
859
- for (const r of runs) {
860
- for (const h of r.hits) keys.add(safeKey(h.platform));
861
- for (const c of r.network ?? []) keys.add(callProvider(c));
862
- }
863
- 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>`;
864
1787
  }
865
1788
 
866
- // 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.
867
- function navCss(runs) {
868
- const top = ["overview", "findings", "journeys", "scope"].map((t) => `body:has(#${t}:target,#${t} :target) .toptabs a[href="#${t}"]`).join(",");
869
- const out = [`${top}{color:var(--fg);border-bottom-color:var(--accent)}`];
870
- if (runs.length) {
871
- 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)}`);
872
- 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)}"]`));
873
- if (steps.length) out.push(`${steps.join(",")}{background:var(--accent-bg);color:var(--fg)}`);
874
- }
875
- const sev = ["all", ...FINDING_SEVERITIES];
876
- 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)}`);
877
- out.push(`${sev.map((s) => `#fs-${s}:focus-visible~.toolbar label[for=fs-${s}]`).join(",")}{outline:2px solid var(--accent)}`);
878
- out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup>.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
879
- out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup:not(:has(li[data-sev=${s}]))`).join(",")}{display:none}`);
880
- 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>`;
881
1806
  }
882
1807
 
883
- export const CSS = `
884
- :root{
885
- --bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#EEF2F8;--border:#DCE2EE;--input:#848EA6;
886
- --accent:#2E6CDB;--accent-fg:#2459C0;--accent-bg:#EBF1FE;--orange:#D9531E;
887
- --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;
888
- --shadow-sm:none;--radius:6px;
889
- --font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace}
890
- @media (prefers-color-scheme:dark){:root{
891
- --bg:#141927;--page:#0A0E1A;--fg:#F0F4FF;--muted:#8892AA;--muted-bg:#182035;--border:#1E2640;--input:#5F6B93;--accent:#4F8EF7;--orange:#FF6D33;
892
- --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}}
893
- *{box-sizing:border-box}
894
- html{scroll-padding-top:64px;scrollbar-gutter:stable;overflow-y:scroll}
895
- 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}
896
- a{color:var(--accent-fg);text-decoration:none}
897
- h1,h2,h3,h4,h5,h6{margin:0;font-weight:500;line-height:1.3}
898
- h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}h2,h3{font-size:14px}
899
- p{margin:0}
900
- strong{font-weight:600;color:var(--fg)}
901
- code,.mono,.time,pre{font-family:var(--font-mono)}
902
- code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
903
- .muted{color:var(--muted)}.xs{font-size:12px}
904
- .label{font-size:12px;color:var(--muted);margin:0 0 6px}
905
- .wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 16px}
906
- .topbar{background:var(--bg);border-bottom:1px solid var(--border)}
907
- .topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
908
- .product-logo img{display:block;height:26px;width:auto}
909
- .brand-name{font:600 16px var(--font-sans);color:var(--fg)}
910
- .byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
911
- .logo-h img{display:block;height:18px;width:auto}
912
- .title{margin-top:20px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}
913
- .site{display:flex;align-items:center;gap:10px;min-width:0}
914
- .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}
915
- .site .v{font-size:20px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow-wrap:anywhere}
916
- .run{margin:0;display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px 16px;font-size:13px}
917
- .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}
918
- .ptag.off{opacity:.55;text-decoration:line-through}
919
- .ctl{list-style:none;margin:0;padding:0;display:grid;gap:12px}
920
- .ctl>li{border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--page)}
921
- .ctl .ch{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;font-size:13px;margin-bottom:8px}
922
- .ctl .ch b{font-weight:600}
923
- .sigs{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:6px}
924
- .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)}
925
- .sig .granted{color:var(--ok)}.sig .denied{color:var(--danger)}
926
- .jconsent{margin-bottom:16px}
927
- .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
928
- .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
929
- .title .plan-changes ul{margin:4px 0 0;padding-left:18px}
930
- .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
931
- .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
932
- .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}
933
- .toptabs a:hover{color:var(--fg)}
934
- .n{font:500 12px/1.4 var(--font-mono);background:var(--muted-bg);color:var(--muted);padding:0 6px;border-radius:4px}
935
- body:not(:has(:target)) .toptabs a[href="#overview"]{color:var(--fg);border-bottom-color:var(--accent)}
936
- .tabpanel{display:none;padding:24px 0 64px}
937
- .tabpanel:target,.tabpanel:has(:target){display:block}
938
- body:not(:has(.tabpanel:target,.tabpanel :target)) #overview{display:block}
939
- .card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
940
- .card-h{padding:16px 20px 0}.card-c{padding:16px 20px 20px}.card-c.tight{padding:8px}
941
- .card-t{font-weight:500}.card-d{font-size:12px;color:var(--muted);margin-top:2px}
942
- .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)}
943
- .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)}
944
- .stack{display:grid;gap:16px}
945
- .metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
946
- .metric{padding:16px;border-top:3px solid var(--border)}
947
- .metric .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
948
- .metric .k::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
949
- .metric.broken{border-top-color:var(--danger-dot)}.metric.broken .k::before{background:var(--danger-dot)}.metric.broken .v{color:var(--danger)}
950
- .metric.risk{border-top-color:var(--risk)}.metric.risk .k::before{background:var(--risk)}.metric.risk .v{color:var(--risk)}
951
- .metric.warning{border-top-color:var(--warn-dot)}.metric.warning .k::before{background:var(--warn-dot)}.metric.warning .v{color:var(--warn)}
952
- .metric.ok{border-top-color:var(--ok)}.metric.ok .k::before{background:var(--ok)}
953
- .metric .v{font:600 24px/1.3 var(--font-mono);margin-top:4px}
954
- .headline{font-weight:500}
955
- .summary-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
956
- .summary-list li{display:grid;grid-template-columns:72px 1fr;gap:8px;align-items:baseline}
957
- .summary-list .badge{justify-self:start}
958
- .also{margin:0;padding-left:18px;display:grid;gap:6px}
959
- .scope{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;font-size:12px;color:var(--muted)}
960
- .scope-skipped,.legend{margin-top:8px}
961
- table.dt{width:100%;border-collapse:collapse}
962
- table.dt th,table.dt td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
963
- table.dt th{font-weight:500;color:var(--muted);font-size:12px}
964
- table.dt tr:last-child td{border-bottom:0}
965
- table.dt td.num{font-family:var(--font-mono)}
966
- table.dt td.num.bad{color:var(--danger);font-weight:600}
967
- table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
968
- .tscroll{overflow-x:auto}
969
- .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
970
- .sevfilter{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius)}
971
- .fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
972
- .sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
973
- .filter-host{position:relative}
974
- .changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
975
- .flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
976
- .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)}
977
- .finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
978
- .finding.sev-risk{--sev:var(--risk);--sev-fg:var(--risk);--sev-bg:var(--risk-bg)}
979
- .finding.sev-info{--sev:var(--accent);--sev-fg:var(--accent-fg);--sev-bg:var(--accent-bg)}
980
- .finding>details>summary{display:flex;align-items:center;gap:12px;padding:14px 16px}
981
- .finding>details[open]>summary{background:linear-gradient(90deg,var(--sev-bg),transparent 70%)}
982
- .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}
983
- .fhead{flex:1;min-width:0;display:grid;gap:4px}
984
- .ftitle{color:var(--fg);overflow-wrap:anywhere}
985
- .fmeta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted)}
986
- .finding summary .chev{margin-left:4px}
987
- .fbody{padding:16px;border-top:1px solid var(--border)}
988
- .impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--sev-bg);color:var(--fg);border-radius:var(--radius)}
989
- .impact svg{color:var(--sev-fg);flex:none;margin-top:3px}
990
- .impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
991
- .fcols{display:grid;gap:8px 32px;margin-top:4px}
992
- .fcol{min-width:0}
993
- .sec-h{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin:16px 0 8px}
994
- .sec-h svg{color:var(--accent-fg)}
995
- .ev-table{border:1px solid var(--border);border-radius:var(--radius);border-collapse:separate;border-spacing:0;overflow:hidden}
996
- .ev-table th{background:var(--muted-bg)}
997
- .missing{display:inline-block;padding:0 6px;border-radius:4px;background:var(--danger-bg);color:var(--danger)}
998
- .fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
999
- .fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
1000
- .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)}
1001
- .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)}
1002
- .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)}
1003
- .where{display:flex;flex-wrap:wrap;gap:6px}
1004
- .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)}
1005
- span.chip{padding-left:10px}
1006
- a.chip:hover{border-color:var(--accent);color:var(--fg)}
1007
- .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)}
1008
- .sevbar{display:flex;height:8px;border-radius:4px;overflow:hidden;gap:2px;margin:0 0 6px}
1009
- .sevbar span{display:block}
1010
- .sevlegend{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12px;color:var(--muted);margin-bottom:16px}
1011
- .sevlegend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
1012
- body>main{flex:1 0 auto;width:100%}
1013
- .footer{border-top:1px solid var(--border);background:var(--bg);margin-top:32px;flex:none}
1014
- .footer .wrap{display:grid;gap:16px;padding:32px 16px}
1015
- .footer p{font-size:12px;color:var(--muted)}
1016
- .footer a{color:var(--fg)}.footer a:hover{color:var(--accent-fg)}
1017
- .footer .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
1018
- .footer .f-meta{font:12px var(--font-mono);color:var(--muted)}
1019
- .logo-link{display:inline-flex}.logo-s img{display:block;height:56px;width:auto}
1020
- .f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
1021
- .jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius);margin-bottom:16px}
1022
- .jtabs a{display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:4px;color:var(--muted);font-weight:500}
1023
- .jtabs a:hover{color:var(--fg)}
1024
- #journeys:not(:has(.jpanel:target,.jpanel :target)) .jtabs a[href="#j0"]{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}
1025
- .jpanel{display:none}
1026
- .jpanel:target,.jpanel:has(:target){display:block}
1027
- #journeys:not(:has(.jpanel:target,.jpanel :target)) .jpanel:first-of-type{display:block}
1028
- .jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
1029
- .jgrid2{display:grid;gap:16px;align-items:start}
1030
- .stepnav{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:6px}
1031
- .stepnav ol{list-style:none;margin:0;padding:0;display:grid;gap:2px}
1032
- .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)}
1033
- .stepnav a:hover{background:var(--muted-bg);color:var(--fg)}
1034
- .jpanel:not(:has(.sdetail:target)) .stepnav a.def{background:var(--accent-bg);color:var(--fg)}
1035
- .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)}
1036
- .sl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
1037
- .dot-flag{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--danger-dot)}
1038
- .sc{font:12px var(--font-mono);color:var(--muted);white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
1039
- .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}
1040
- .sdetail:target{display:block}
1041
- .jpanel:not(:has(.sdetail:target)) .sdetail.default{display:block}
1042
- .shead{display:flex;gap:12px;align-items:flex-start;padding:16px}
1043
- .shead h3{overflow-wrap:anywhere}
1044
- .shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
1045
- .empty{color:var(--muted);padding:8px 0}
1046
- .tabs.seg{display:grid;grid-template-columns:repeat(4,auto) 1fr;padding:0 16px 16px}
1047
- .tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
1048
- .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}
1049
- .tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
1050
- .tabs.seg>label:nth-of-type(4){border-radius:0 var(--radius) var(--radius) 0}
1051
- .tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
1052
- .tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
1053
- .tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
1054
- .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}
1055
- .timeline{list-style:none;margin:0;padding:0 0 0 18px;border-left:2px solid var(--border);display:grid;gap:14px}
1056
- .tl-node{position:relative;display:grid;gap:6px;min-width:0}
1057
- .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)}
1058
- .tl-orphan::before{border-color:var(--muted)}
1059
- .tl-lbl{font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
1060
- .tl-gtm{display:flex;gap:8px;align-items:center;font-size:13px;padding-left:12px}
1061
- .tl-k{color:var(--muted)}
1062
- .tl-hits,.tl-none{margin-left:12px}
1063
- .filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
1064
- .filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
1065
- .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)}
1066
- .filter>label:hover{color:var(--fg)}
1067
- .filter>input:checked+label{background:var(--fg);border-color:var(--fg);color:var(--bg)}
1068
- .filter>input:checked+label .n{background:transparent;color:inherit}
1069
- .filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
1070
- .filter>label:last-of-type{margin-bottom:4px}
1071
- .dot{display:inline-block;width:12px;height:12px;border-radius:2px;background:var(--muted);vertical-align:-2px}
1072
- .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}
1073
- .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
1074
- .evlist>li+li{border-top:1px solid var(--border)}
1075
- summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
1076
- summary:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1077
- .chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
1078
- .event>summary{display:flex;align-items:center;gap:10px;padding:8px 12px;flex-wrap:wrap}
1079
- .event>summary:hover{background:var(--muted-bg)}
1080
- .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}
1081
- .event.flag-broken>summary .ev::after{background:var(--danger-dot)}.event.flag-risk>summary .ev::after{background:var(--risk)}
1082
- .event .tag{display:none}
1083
- .ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
1084
- .event .meta{font-size:12px;color:var(--muted)}
1085
- .event .time,.time{font-size:12px;color:var(--muted);margin-left:auto}
1086
- .ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
1087
- .ptag::before{content:"";width:14px;height:14px;flex:none;border-radius:2px;background:var(--muted)}
1088
- .event-body{padding:4px 12px 16px}
1089
- .flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius);background:var(--warn-bg);color:var(--warn)}
1090
- .flagnote.broken{background:var(--danger-bg);color:var(--danger)}.flagnote.risk{background:var(--risk-bg);color:var(--risk)}
1091
- .event-body h5{font-size:12px;color:var(--muted);font-weight:400;margin:12px 0 6px}
1092
- .event-body h5:first-child{margin-top:4px}
1093
- h5 .n{margin-left:4px}
1094
- .ebgrid{display:grid;gap:12px}
1095
- .ebgrid>*{min-width:0}
1096
- .ptable{width:100%;border-collapse:collapse;font:12px/1.5 var(--font-mono)}
1097
- .ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
1098
- .ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
1099
- .glossary th{min-width:170px}
1100
- .ptable td{overflow-wrap:anywhere}
1101
- .ptable tr:last-child>*{border-bottom:0}
1102
- .consents{display:flex;flex-wrap:wrap;gap:6px}
1103
- .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)}
1104
- .ebgrid aside{background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;align-self:start}
1105
- .ebgrid aside h5{margin-top:0!important}
1106
- .srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:12px}
1107
- .srclist li{display:grid;gap:2px;overflow-wrap:anywhere}.srclist .lbl{font-size:12px;color:var(--muted)}
1108
- .method{font:500 12px var(--font-mono);padding:0 5px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg)}
1109
- .status{color:var(--ok);font-family:var(--font-mono);font-size:12px}
1110
- .small{font-size:12px}
1111
- .item{border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px}
1112
- .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:10px}
1113
- .item-head b{font-weight:500}
1114
- .item-idx{font:12px var(--font-mono);color:var(--muted)}
1115
- .item-price{margin-left:auto;font-size:12px}
1116
- .grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
1117
- .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}
1118
- h6{margin:12px 0 6px;font-size:12px;font-weight:400;color:var(--muted)}
1119
- .pk{margin-right:4px}
1120
- .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}
1121
- .q:hover,.q:focus{border-color:var(--accent);color:var(--accent-fg);outline:none}
1122
- .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}
1123
- tr.pr td{color:var(--muted)}
1124
- details.raw{margin-top:10px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
1125
- 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}
1126
- .scope-note{margin-top:10px}
1127
- .cov td.c{text-align:center;font-family:var(--font-mono)}
1128
- .cov .y{color:var(--ok)}.cov .x{color:var(--danger);font-weight:600}.cov .o{color:var(--muted)}
1129
- @media (min-width:768px){
1130
- .wrap{padding:0 24px}
1131
- .metrics{grid-template-columns:repeat(4,1fr)}
1132
- .ov-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:start}
1133
- .fcols{grid-template-columns:1fr 1fr}
1134
- .footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:32px 24px}
1135
- .footer .wrap.plain{grid-template-columns:1fr auto}
1136
- .jgrid2{grid-template-columns:260px minmax(0,1fr)}
1137
- .stepnav{position:sticky;top:60px}
1138
- .ebgrid{grid-template-columns:2fr 1fr}
1139
- .grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
1140
- .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>`;
1141
1810
  }
1142
- @media (max-width:767px){.ov-grid{display:grid;gap:16px}}
1143
- @media (prefers-reduced-motion:reduce){*{transition:none!important}}
1144
- @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}}
1145
- `;
1146
1811
 
1147
- 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 }) {
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 }) {
1148
1892
  const m = messages(lang);
1149
1893
  const rows = attachExplanations(findings, explained);
1894
+ metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
1150
1895
  const counts = summarize(rows);
1151
1896
  const problems = rows.filter((f) => f.severity !== "ok").length;
1152
- const flagged = flaggedSteps(runs, rows);
1897
+ const ids = cardIds(rows);
1898
+ const runInfo = { ai, plan, lang, against, planChanges, consentScenarios };
1153
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>`;
1154
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.
1155
1901
  const whiteLabel = Boolean(brand?.logo || brand?.name);
@@ -1161,28 +1907,34 @@ export function renderHtml({ title, site = null, ai = null, generatedAt, finding
1161
1907
  const footer = branding
1162
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>
1163
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>
1164
- <div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
1165
- : `<div class="wrap plain"><div class="f-meta">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"}</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
1166
-
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>`;
1167
1924
  return `<!doctype html>
1168
1925
  <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
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">
1169
1928
  <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
1170
- <style>${CSS}${filterCss(runs)}
1171
- ${navCss(runs)}${brandCss(brand?.color)}</style></head><body>
1172
- <div class="topbar"><div class="wrap"><span class="product-logo">${productLogo}</span>${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>` : ""}</div></div>
1173
- <header class="wrap title">
1174
- <h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
1175
- <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>` : ""}
1176
- </header>
1177
- <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1178
- <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>
1179
- <main class="wrap">
1180
- <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>
1181
- <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows, Boolean(explained))}</section>
1182
- <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged, consentScenarios)}</section>
1183
- <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>
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}
1184
1935
  </main>
1185
1936
  <footer class="footer">${footer}</footer>
1937
+ <script>${SCRIPT}</script>
1186
1938
  </body></html>`;
1187
1939
  }
1188
1940