@analitikisler/ai-tag-debugger 0.5.2 → 0.6.1

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
@@ -3,6 +3,10 @@ import { groupFindings, summarize } from "./checks.js";
3
3
  import { messages } from "./i18n.js";
4
4
  import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
5
5
  import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
6
+ import { LOGOS } from "./brand.js";
7
+ import { readFileSync } from "node:fs";
8
+
9
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
6
10
 
7
11
  const EXPLAIN_SYSTEM = `You are a senior digital analytics consultant reviewing an automated tracking audit. You receive findings from deterministic checks, the measurement plan, the hits captured on each step of each journey, and optionally a Google Tag Manager container export.
8
12
 
@@ -15,7 +19,9 @@ When a GTM container is provided, name the specific tags, triggers or variables
15
19
  Then write the summary:
16
20
  - headline: one sentence naming what matters most for data quality and ad spend;
17
21
  - points: the main problems, most important first, each one sentence with its severity;
18
- - also_check: things you can see in the captured hits that the findings list does not cover (for example a conversion that fires on page load, or a cookie banner click counted as a conversion). Only include what the captured data shows; leave the list empty otherwise.`;
22
+ - also_check: things you can see in the captured hits that the findings list does not cover (for example a conversion that fires on page load, or a cookie banner click counted as a conversion). Only include what the captured data shows; leave the list empty otherwise.
23
+
24
+ Formatting: in the headline, points, also_check, impact, likely_cause and fix_steps, wrap numbers and counts in double asterisks (for example sent **2** times, at **+70.4s**) and event, parameter, tag and trigger names in backticks (for example \`begin_checkout\`). Use no other formatting: no headings, lists, links or HTML.`;
19
25
 
20
26
  const SEVERITIES = ["broken", "risk", "warning", "info"];
21
27
  const EXPLAIN_SCHEMA = {
@@ -240,17 +246,6 @@ function callsForHit(run, hit) {
240
246
  return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
241
247
  }
242
248
 
243
- /** "4 planned events matched the plan: ga4:page_view, meta:AddToCart." as a lead line and event chips. */
244
- function planMatchHtml(detail) {
245
- const at = detail.indexOf(": ");
246
- if (at < 0) return escapeHtml(detail);
247
- const chips = detail.slice(at + 2).replace(/\.$/, "").split(", ").map((key) => {
248
- const i = key.indexOf(":");
249
- const p = i > 0 ? key.slice(0, i) : "";
250
- return `<span class="ptag${p ? ` p-${escapeHtml(p)}` : ""}">${p ? `${escapeHtml(platformLabel(p))} <b>${escapeHtml(key.slice(i + 1))}</b>` : `<b>${escapeHtml(key)}</b>`}</span>`;
251
- });
252
- return `<span>${escapeHtml(detail.slice(0, at))}</span><span class="chips">${chips.join("")}</span>`;
253
- }
254
249
 
255
250
  const icon = {
256
251
  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>',
@@ -435,12 +430,43 @@ ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawR
435
430
 
436
431
  const stepAnchor = (ri, si) => `j${ri}-s${si}`;
437
432
  const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
433
+ const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
434
+ const problemCount = (findings, run) => findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === run.journeyId && f.viewport === run.viewport).length;
435
+
436
+ // AI text marks numbers with **x** and names with `x`. Only those two marks become HTML; everything else is escaped.
437
+ const fmt = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
438
+ // Finding texts quote event and parameter names ("add_to_cart"); show those as code.
439
+ const detailHtml = (s) => escapeHtml(s).replace(/&quot;([^&]+?)&quot;/g, "<code>$1</code>");
440
+
441
+ // Lucide icons.
442
+ 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>`;
443
+ const ICON = {
444
+ 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"/>',
445
+ 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"/>',
446
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
447
+ impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
448
+ cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
449
+ 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"/>',
450
+ 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"/>',
451
+ 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"/>',
452
+ };
453
+ const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "info" : "warning"];
454
+ const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
455
+ const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
456
+ /** A logo with its light and dark versions, switched by the viewer's color scheme. */
457
+ 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>`;
458
+
459
+ /** 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. */
460
+ function whereTarget(runs, w, f) {
461
+ const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
462
+ if (ri < 0) return null;
463
+ const candidates = w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
464
+ const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
465
+ return { ri, step };
466
+ }
438
467
 
439
- function stepHtml(m, lang, findings, run, ri, step) {
468
+ function stepDetail(m, lang, findings, run, ri, step, isDefault) {
440
469
  const hits = run.hits.filter((h) => h.step === step.index);
441
- const flagged = hits.some((h) => hitFlag(findings, run, step, h));
442
- const groups = [...new Set(hits.map((h) => h.platform))].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
443
- const counts = groups.map((p) => `<span class="ptag p-${safeKey(p)}">${escapeHtml(platformLabel(p))} <b>${hits.filter((h) => h.platform === p).length}</b></span>`).join("");
444
470
  const id = `s${ri}-${step.index}`;
445
471
  const sortedHits = [...hits].sort((a, b) => platformRank(a.platform) - platformRank(b.platform) || a.t - b.t);
446
472
  const eventRows = sortedHits.map((h) => ({ key: safeKey(h.platform), label: platformLabel(h.platform), html: eventRow(m, findings, run, step, h) }));
@@ -449,91 +475,181 @@ function stepHtml(m, lang, findings, run, ri, step) {
449
475
  const skipped = skippedText(run.skipped?.[step.index]);
450
476
  const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
451
477
  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>`;
452
- return `<li class="step" id="${stepAnchor(ri, step.index)}"><details${flagged ? " open" : ""}>
453
- <summary><span class="idx">${step.index}</span><span class="label"><b>${escapeHtml(step.label)}</b>${step.forced ? `<span class="muted"> · ${m.ui.clickedThrough}</span>` : ""}</span><span class="time">${escapeHtml(seconds(run, step.t))}</span><span class="counts">${counts || `<span class="muted">${m.ui.noHits}</span>`}</span>${flagged ? `<span class="tag broken dot" title="${m.ui.hasProblems}"></span>` : ""}</summary>
454
- <div class="step-body v2"><div class="tabs steptabs">${tab(1, m.ui.tabEvents, eventRows.length)}${tab(2, "dataLayer", pushes.length)}${tab(3, m.ui.tabNetwork, reqRows.length)}
455
- <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="muted small">${m.ui.noHits}</p>`}</div>
456
- <div class="panel">${pushes.length ? `<ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : `<p class="muted small">${m.ui.noPushes}</p>`}</div>
457
- <div class="panel">${reqRows.length ? filtered(m, `${id}n`, reqRows, reqRows.map((r) => r.html).join("")) : `<p class="muted small">${m.ui.noRequests}</p>`}${skippedN ? `<p class="muted small scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</div>
458
- </div></div></details></li>`;
459
- }
460
-
461
- function journeysHtml(m, lang, findings, runs) {
462
- const overview = runs
463
- .map((r, ri) => {
464
- const flagged = findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === r.journeyId && f.viewport === r.viewport).length;
465
- return `<a class="jcard" href="#j${ri}"><span class="jname">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></span><span class="tag ${r.completed ? "ok" : "warning"}">${r.completed ? m.ui.completed : m.ui.notCompleted}</span><span class="jstats"><span><b>${r.steps.length - 1}</b> ${m.ui.stepsShort}</span><span><b>${r.hits.length}</b> ${m.ui.eventsShort}</span><span${flagged ? ' class="bad"' : ""}><b>${flagged}</b> ${m.ui.problemsShort}</span></span></a>`;
478
+ return `<section class="sdetail${isDefault ? " default" : ""}" id="${stepAnchor(ri, step.index)}" aria-label="${escapeHtml(step.label)}">
479
+ <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>
480
+ <div class="tabs seg">${tab(1, m.ui.tabEvents, eventRows.length)}${tab(2, "dataLayer", pushes.length)}${tab(3, m.ui.tabNetwork, reqRows.length)}
481
+ <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
482
+ <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>
483
+ <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>
484
+ </div></section>`;
485
+ }
486
+
487
+ /** 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. */
488
+ function journeyPanel(m, lang, findings, run, ri, flagged) {
489
+ const steps = run.steps.filter((s) => s.index > 0);
490
+ const def = steps.find((s) => flagged.has(stepAnchor(ri, s.index))) ?? steps[0];
491
+ const nav = steps
492
+ .map((s) => {
493
+ const hits = run.hits.filter((h) => h.step === s.index);
494
+ 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}`);
495
+ const a = stepAnchor(ri, s.index);
496
+ 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>`;
466
497
  })
467
498
  .join("");
468
- const journeys = runs
469
- .map((r, ri) => `<article class="journey" id="j${ri}"><header><h3>${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></h3><span class="tag ${r.completed ? "ok" : "warning"}">${r.completed ? m.ui.completed : m.ui.notCompleted}</span></header>${r.note ? `<p class="note">${escapeHtml(r.note)}</p>` : ""}
470
- <ol class="steps">${r.steps.filter((s) => s.index > 0).map((s) => stepHtml(m, lang, findings, r, ri, s)).join("\n")}</ol></article>`)
471
- .join("\n");
472
- return `<nav class="jgrid" aria-label="${m.ui.timeline}">${overview}</nav>${journeys}`;
499
+ const body = steps.length
500
+ ? `<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>`
501
+ : `<p class="empty">${m.ui.noSteps}</p>`;
502
+ 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>
503
+ ${body}</article>`;
473
504
  }
474
505
 
475
- function whereLink(runs, w, f) {
476
- const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
477
- // Findings name the step by label; when labels repeat, prefer the step where the finding's event fired.
478
- const candidates = ri >= 0 && w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
479
- const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
480
- const href = ri < 0 ? null : step ? `#${stepAnchor(ri, step.index)}` : `#j${ri}`;
481
- const text = `<b>${escapeHtml(w.journey)}</b> · ${escapeHtml(w.viewport)}${w.step ? `<span class="muted"> · ${escapeHtml(w.step)}</span>` : ""}`;
482
- return href ? `<li><a href="${href}">${text}</a></li>` : `<li>${text}</li>`;
506
+ function journeysTab(m, lang, findings, runs, flagged) {
507
+ if (!runs.length) return `<p class="empty">${m.ui.noSteps}</p>`;
508
+ 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("");
509
+ return `<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
483
510
  }
484
511
 
485
512
  /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
486
513
  function evidenceHtml(evidence) {
487
514
  try {
488
515
  const v = JSON.parse(evidence);
489
- if (v && typeof v === "object" && !Array.isArray(v)) return kvTable(Object.entries(v).map(([k, x]) => [k, cell(x)]));
516
+ 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>`;
490
517
  } catch { /* plain text */ }
491
518
  return `<pre>${escapeHtml(evidence)}</pre>`;
492
519
  }
493
520
 
494
- function findingHtml(m, runs, f, open) {
521
+ function findingCard(m, runs, f, open) {
495
522
  const e = f.explanation;
496
523
  const steps = fixSteps(e);
497
- return `<li class="finding ${f.severity}"><details${open ? " open" : ""}><summary>${icon.chevron}<span class="tag ${f.severity}">${m.severity[f.severity]}</span><span class="title">${escapeHtml(f.detail)}</span><span class="where-mini">${escapeHtml(m.ui.places(f.where.length))}</span></summary>
498
- <div class="fbody">${e?.impact ? `<p class="impact"><b>${m.ui.impact}.</b> ${escapeHtml(e.impact)}</p>` : ""}
499
- <div class="fcols"><section><h5>${m.ui.likelyCause}</h5>${e ? `<p>${escapeHtml(e.likely_cause)}</p>` : `<p class="muted">${m.ui.noExplanation}</p>`}${f.evidence ? `<h5>${m.ui.evidence}</h5>${evidenceHtml(f.evidence)}` : ""}</section>
500
- <section>${steps.length ? `<h5>${m.ui.fixSteps}</h5><ol class="fix-steps">${steps.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ol>` : ""}<h5>${m.ui.where}</h5><ul class="where">${f.where.map((w) => whereLink(runs, w, f)).join("")}</ul></section></div></div></details></li>`;
524
+ const journeysN = new Set(f.where.map((w) => `${w.journey}|${w.viewport}`)).size;
525
+ const stepsN = f.where.filter((w) => w.step).length;
526
+ const evidence = f.evidence
527
+ ? evidenceHtml(f.evidence)
528
+ : f.check === "missing_event" && f.platform
529
+ ? `<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>`
530
+ : "";
531
+ const chips = f.where
532
+ .map((w) => {
533
+ const t = whereTarget(runs, w, f);
534
+ 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>` : ""}`;
535
+ return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
536
+ })
537
+ .join("");
538
+ return `<li class="finding sev-${f.severity}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
539
+ <span class="sev-icon">${ic(sevIcon(f.severity), 18)}</span>
540
+ <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>
541
+ ${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
542
+ <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>` : ""}
543
+ <div class="fcols"><div class="fcol">${sec("cause", m.ui.likelyCause)}${e?.likely_cause ? `<p>${fmt(e.likely_cause)}</p>` : `<p class="muted">${m.ui.noExplanation}</p>`}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}</div>
544
+ <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></div></div></div></details></li>`;
501
545
  }
502
546
 
503
- function findingsHtml(m, runs, rows) {
547
+ const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
548
+ const SEV_COLOR = { broken: "var(--danger-dot)", risk: "var(--risk)", warning: "var(--warn-dot)", info: "var(--accent)" };
549
+
550
+ function findingsTab(m, runs, rows) {
504
551
  const problems = rows.filter((f) => f.severity !== "ok");
505
- if (!problems.length) return `<p class="lead">${m.ui.noProblems}</p>`;
506
- let first = true;
507
- return ["broken", "risk", "warning", "info"]
508
- .map((sev) => {
509
- const list = problems.filter((f) => f.severity === sev);
510
- if (!list.length) return "";
511
- const html = list.map((f) => {
512
- const out = findingHtml(m, runs, f, first && sev !== "info");
513
- first = false;
514
- return out;
552
+ if (!problems.length) return `<p class="empty">${m.ui.noProblems}</p>`;
553
+ const n = Object.fromEntries(FINDING_SEVERITIES.map((s) => [s, problems.filter((f) => f.severity === s).length]));
554
+ const present = FINDING_SEVERITIES.filter((s) => n[s]);
555
+ const radios = ["all", ...present].map((s) => `<input type="radio" name="fs" id="fs-${s}" class="fsr"${s === "all" ? " checked" : ""}>`).join("");
556
+ const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
557
+ const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
558
+ 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("");
559
+ const sorted = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s));
560
+ return `<div class="filter-host">${radios}
561
+ <div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
562
+ <div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
563
+ <ul class="flist">${sorted.map((f, i) => findingCard(m, runs, f, i === 0 && f.severity !== "info")).join("")}</ul></div>`;
564
+ }
565
+
566
+ function summaryCard(m, explained, problems) {
567
+ const s = summaryParts(explained?.summary);
568
+ let body;
569
+ if (!s) body = `<p class="muted">${problems ? m.ui.noSummary : m.ui.noProblems}</p>`;
570
+ else if (s.text) body = `<p class="summary-text">${fmt(s.text)}</p>`;
571
+ else {
572
+ 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>` : ""}`;
573
+ }
574
+ 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>`;
575
+ }
576
+
577
+ /** The providers that were checked: from the runs, or for older captures the platforms seen. */
578
+ function checkedProviders(runs) {
579
+ const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
580
+ if (!chosen.size) for (const r of runs) for (const h of r.hits) chosen.add(h.platform);
581
+ return sortPlatforms(chosen);
582
+ }
583
+
584
+ function skippedTotals(runs) {
585
+ const totals = {};
586
+ for (const r of runs) for (const counts of Object.values(r.skipped ?? {})) for (const [v, n] of Object.entries(counts)) totals[v] = (totals[v] ?? 0) + n;
587
+ return skippedText(totals);
588
+ }
589
+
590
+ const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
591
+
592
+ function overviewTab(m, findings, rows, counts, explained, runs) {
593
+ const problems = rows.filter((f) => f.severity !== "ok").length;
594
+ const metric = (sev) => `<div class="card metric ${sev}"><div class="k">${m.severity[sev]}</div><div class="v">${counts[sev]}</div></div>`;
595
+ const journeyRows = runs.map((r, ri) => {
596
+ const n = problemCount(findings, r);
597
+ 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>`;
598
+ });
599
+ const skipped = skippedTotals(runs);
600
+ const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
601
+ const provs = checkedProviders(runs);
602
+ return `<div class="stack">
603
+ <div class="metrics">${["broken", "risk", "warning", "ok"].map(metric).join("")}</div>
604
+ <div class="ov-grid">${summaryCard(m, explained, problems)}
605
+ <div class="stack">
606
+ ${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>` : ""}
607
+ ${provs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.ui.scopeCard}</div></div><div class="card-c"><div class="scope">${provs.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("")}</div>${skippedN ? `<p class="muted xs scope-skipped">${escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</p>` : ""}</div></div>` : ""}
608
+ </div></div></div>`;
609
+ }
610
+
611
+ /** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
612
+ function coverageCard(m, plan, runs) {
613
+ const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
614
+ if (!plan?.events?.length || !runs.length) return `<div class="card">${head}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
615
+ const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
616
+ const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
617
+ const body = events
618
+ .map((e) => {
619
+ const cells = runs.map((r) => {
620
+ if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
621
+ return r.hits.some((h) => h.platform === e.platform && h.name === e.name) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
515
622
  });
516
- return `<div class="sevgroup">${m.severity[sev]} · ${list.length}</div><ul class="findings">${html.join("")}</ul>`;
623
+ const unchecked = !runs.some((r) => checks(r, e));
624
+ 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>`;
517
625
  })
518
626
  .join("");
627
+ 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>`;
519
628
  }
520
629
 
521
- function summaryHtml(m, summary) {
522
- const s = summaryParts(summary);
523
- if (!s) return "";
524
- if (s.text) return `<div class="summary">${escapeHtml(s.text)}</div>`;
525
- return `<div class="summary v2">${s.headline ? `<p class="headline">${escapeHtml(s.headline)}</p>` : ""}${s.points.length ? `<ol>${s.points.map((p) => `<li><span class="tag ${p.severity}">${m.severity[p.severity]}</span><span>${escapeHtml(p.text)}</span></li>`).join("")}</ol>` : ""}${s.also_check.length ? `<h5>${m.ui.alsoCheck}</h5><ul class="also">${s.also_check.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>` : ""}</div>`;
630
+ function providersCard(m, runs) {
631
+ const provs = checkedProviders(runs);
632
+ const skipped = skippedTotals(runs);
633
+ if (!provs.length && !skipped.length) return "";
634
+ const calls = runs.flatMap((r) => r.network ?? []);
635
+ const rows = [
636
+ ...provs.map((k) => [platformLabel(k), badge("ok", m.ui.checked), calls.filter((c) => callProvider(c) === k).length]),
637
+ ...skipped.map(([v, n]) => [v, badge("muted", m.ui.outOfScopeStatus), n]),
638
+ ];
639
+ 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>`;
526
640
  }
527
641
 
528
- /** "Providers checked: GA4, Meta · Out of scope: Microsoft Clarity (5 requests skipped)". */
529
- function scopeHtml(m, runs) {
530
- const chosen = [...new Set(runs.flatMap((r) => r.providers ?? []))];
531
- if (!chosen.length) return "";
532
- const totals = {};
533
- for (const r of runs) for (const counts of Object.values(r.skipped ?? {})) for (const [v, n] of Object.entries(counts)) totals[v] = (totals[v] ?? 0) + n;
534
- const skipped = skippedText(totals);
535
- const n = skipped.reduce((a, [, x]) => a + x, 0);
536
- return `<div class="scope"><span>${m.ui.providersChecked}</span>${chosen.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("")}${n ? `<span>· ${escapeHtml(m.ui.outOfScope(skipped.map(([v]) => v).join(", "), n))}</span>` : ""}</div>`;
642
+ /** Steps a problem finding points at; they get a dot in the step list, and the journey opens on the first one. */
643
+ function flaggedSteps(runs, rows) {
644
+ const out = new Set();
645
+ for (const f of rows) {
646
+ if (!(f.severity in SEVERITY_RANK)) continue;
647
+ for (const w of f.where) {
648
+ const t = whereTarget(runs, w, f);
649
+ if (t?.step) out.add(stepAnchor(t.ri, t.step.index));
650
+ }
651
+ }
652
+ return out;
537
653
  }
538
654
 
539
655
  /** Filter rules for the platform keys in this report: picking a source hides the other rows. */
@@ -541,254 +657,298 @@ function filterCss(runs) {
541
657
  const keys = new Set(PLATFORM_ORDER);
542
658
  for (const r of runs) {
543
659
  for (const h of r.hits) keys.add(safeKey(h.platform));
544
- for (const c of r.network ?? []) keys.add(safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor));
660
+ for (const c of r.network ?? []) keys.add(callProvider(c));
545
661
  }
546
662
  return `${[...keys].map((k) => `.filter>.f-${k}:checked~.evlist>li:not([data-p="${k}"])`).join(",")}{display:none}`;
547
663
  }
548
664
 
665
+ // 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.
666
+ function navCss(runs) {
667
+ const top = ["overview", "findings", "journeys", "scope"].map((t) => `body:has(#${t}:target,#${t} :target) .toptabs a[href="#${t}"]`).join(",");
668
+ const out = [`${top}{color:var(--fg);border-bottom-color:var(--accent)}`];
669
+ if (runs.length) {
670
+ 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)}`);
671
+ 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)}"]`));
672
+ if (steps.length) out.push(`${steps.join(",")}{background:var(--accent-bg);color:var(--fg)}`);
673
+ }
674
+ const sev = ["all", ...FINDING_SEVERITIES];
675
+ 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)}`);
676
+ out.push(`${sev.map((s) => `#fs-${s}:focus-visible~.toolbar label[for=fs-${s}]`).join(",")}{outline:2px solid var(--accent)}`);
677
+ out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
678
+ return out.join("\n");
679
+ }
680
+
549
681
  const CSS = `
550
682
  :root{
551
- --bg:#F7F9FC;--surface:#FFFFFF;--surface-2:#F0F3F9;--border:#E1E6F0;--text:#0A0E1A;--muted:#5A6480;--heading:#0A0E1A;
552
- --accent:#4F8EF7;--accent-ink:#2F6FDB;--accent-soft:#EBF1FE;--orange:#FF6D33;--orange-soft:#FFF0E9;
553
- --danger:#C5221F;--danger-soft:#FCE8E6;--warn:#B45309;--warn-soft:#FEF3E2;--ok:#137333;--ok-soft:#E6F4EA;
554
- --font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace;
555
- --radius-sm:2px;--radius-md:4px;--radius-lg:6px}
683
+ --bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#F1F4F9;--border:#E3E7EF;--input:#E3E7EF;
684
+ --accent:#4F8EF7;--accent-fg:#2F6FDB;--accent-bg:#EBF1FE;--orange:#FF6D33;
685
+ --danger:#C5221F;--danger-bg:#FCE8E6;--warn:#A16207;--warn-bg:#FEF9C3;--warn-dot:#EAB308;--risk:#C2410C;--risk-bg:#FFEDD5;--danger-dot:#DC2626;--ok:#137333;--ok-bg:#E6F4EA;
686
+ --shadow-sm:0 1px 2px rgba(10,14,26,.06);--radius:6px;
687
+ --font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace}
556
688
  @media (prefers-color-scheme:dark){:root{
557
- --bg:#0A0E1A;--surface:#141927;--surface-2:#182035;--border:#1E2640;--text:#F0F4FF;--muted:#8892AA;--heading:#FFFFFF;
558
- --accent:#4F8EF7;--accent-ink:#7EAAF9;--accent-soft:#1A2F5C;--orange:#FF6D33;--orange-soft:#2A1A0F;
559
- --danger:#F28B82;--danger-soft:#3A1F1D;--warn:#FDBA74;--warn-soft:#33241A;--ok:#81C995;--ok-soft:#16301F}}
689
+ --bg:#141927;--page:#0A0E1A;--fg:#F0F4FF;--muted:#8892AA;--muted-bg:#182035;--border:#1E2640;--input:#26304D;
690
+ --accent-fg:#7EAAF9;--accent-bg:#1A2F5C;--danger:#F28B82;--danger-bg:#3A1F1D;--warn:#FACC15;--warn-bg:#332B0A;--warn-dot:#FACC15;--risk:#FB923C;--risk-bg:#3A2312;--danger-dot:#F87171;--ok:#81C995;--ok-bg:#16301F;--shadow-sm:none}}
560
691
  *{box-sizing:border-box}
561
- body{margin:0;background:var(--bg);color:var(--text);font:400 1rem/1.6 var(--font-sans);-webkit-font-smoothing:antialiased}
562
- a{color:var(--accent-ink);text-decoration:none}a:hover{text-decoration:underline}
563
- main{max-width:1120px;margin:0 auto;padding:24px 16px 96px}
564
- .muted{color:var(--muted)}
565
- code,pre,.time,.mono{font-family:var(--font-mono)}
566
- h1,h2,h3,h4{color:var(--heading);line-height:1.2;margin:0}
567
- h1{font-size:1.875rem;font-weight:700;letter-spacing:-.02em}
568
- h2{font-size:1.5rem;font-weight:700;margin:64px 0 8px}
569
- h3{font-size:1.25rem;font-weight:600}
570
- h5{margin:16px 0 8px;font:500 .75rem/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
571
- .lead{color:var(--muted);margin:0 0 24px;max-width:720px}
572
- .brand{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:16px;margin-bottom:40px;border-bottom:1px solid var(--border)}
573
- .credit{margin:32px 0 8px;font-size:13px;text-align:center}
574
- .logo{display:inline-flex;align-items:center;gap:.4em;font:800 18px/1 var(--font-sans);letter-spacing:-.03em;color:var(--heading)}
575
- .bars{display:inline-flex;align-items:flex-end;gap:.12em;height:.9em}.bars i{display:block;width:.22em;border-radius:1px;background:linear-gradient(var(--accent),var(--accent-soft))}
576
- .bars i:nth-child(1){height:45%}.bars i:nth-child(2){height:100%}.bars i:nth-child(3){height:70%;background:linear-gradient(var(--orange),var(--orange-soft))}
577
- .product{font:500 .75rem var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
578
- .eyebrow{font:500 .75rem var(--font-mono);color:var(--accent-ink);text-transform:uppercase;letter-spacing:.08em;margin-bottom:12px}
579
- .generated{color:var(--muted);font-size:.875rem;margin-top:8px}
580
- .metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:32px 0 24px}
581
- .metric{background:var(--surface);padding:16px 20px}
582
- .metric .num{display:block;font:500 26px/1.2 var(--font-mono);color:var(--heading)}
583
- .metric .lbl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
584
- .metric .lbl::before{content:"";width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
585
- .metric.broken .lbl::before{background:var(--danger)}.metric.broken .num{color:var(--danger)}
586
- .metric.risk .lbl::before,.metric.warning .lbl::before{background:var(--warn)}.metric.ok .lbl::before{background:var(--ok)}
587
- .summary{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--radius-lg);padding:16px 20px;font-size:1.125rem;line-height:1.5}
588
- .tag{display:inline-block;font:500 10px/1.5 var(--font-mono);padding:3px 8px;border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.08em;white-space:nowrap;background:var(--accent-soft);color:var(--accent-ink)}
589
- .tag.broken{background:var(--danger-soft);color:var(--danger)}.tag.risk,.tag.warning{background:var(--warn-soft);color:var(--warn)}.tag.ok,.tag.info{background:var(--ok-soft);color:var(--ok)}
590
- .tag.dot{width:8px;height:8px;padding:0;background:var(--danger)}
591
- .findings{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:12px}
592
- .finding{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--warn);border-radius:var(--radius-lg);padding:20px;transition:border-color .2s ease}
593
- .finding.broken{border-left-color:var(--danger)}
594
- .finding .head{display:flex;gap:12px;align-items:flex-start}
595
- .finding .title{font-size:1.0625rem;font-weight:600;color:var(--heading);line-height:1.4}
596
- .finding .cols{display:grid;gap:16px;margin-top:16px}
597
- .where{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;font-size:.875rem}
598
- .where a,.where li>b{color:var(--text)}.where a:hover{color:var(--accent-ink);text-decoration:none}
599
- .where a::before{content:"→ ";color:var(--accent)}
600
- .fix p{margin:0 0 8px;font-size:.9375rem}.fix b{font-weight:600}
601
- details.evidence{margin-top:12px}
602
- details.evidence>summary{font-size:.875rem;color:var(--muted)}
603
- .okplan{list-style:none;margin:16px 0 0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface)}
604
- .okplan li{padding:12px 16px;border-top:1px solid var(--border);font-size:.9375rem;display:flex;flex-direction:column;gap:8px}
605
- .okplan li:first-child{border-top:0}.okplan .who{flex:none;min-width:150px;font-weight:500}
606
- .okplan .what{display:flex;flex-direction:column;gap:8px}.chips{display:flex;flex-wrap:wrap;gap:6px}
607
- .step .counts>.muted{font-size:.8125rem}
608
- summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
609
- summary:focus-visible,.tabs>input:focus-visible+label,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
610
- .chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
611
- .jgrid{display:grid;grid-template-columns:1fr;gap:1px;background:var(--border);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0 8px}
612
- .jcard{display:grid;grid-template-columns:1fr auto;gap:8px;background:var(--surface);padding:16px 20px;color:var(--text);transition:background .15s ease}
613
- .jcard:hover{background:var(--surface-2);text-decoration:none}
614
- .jname{font-weight:600}.jstats{grid-column:1/-1;display:flex;gap:16px;font-size:.875rem;color:var(--muted)}
615
- .jstats b{font:500 1rem var(--font-mono);color:var(--heading);margin-right:2px}.jstats .bad b{color:var(--danger)}
616
- .journey{margin-top:48px;scroll-margin-top:16px}
617
- .journey>header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)}
618
- .note{color:var(--muted);font-size:.875rem;margin:8px 0 0}
619
- .steps{list-style:none;margin:16px 0 0;padding:0;position:relative}
620
- .steps::before{content:"";position:absolute;left:13px;top:8px;bottom:8px;width:1px;background:var(--border)}
621
- .step{position:relative;margin:0 0 8px;scroll-margin-top:16px}
622
- .step>details>summary{display:grid;grid-template-columns:28px 1fr auto;column-gap:12px;row-gap:6px;align-items:center;padding:8px 12px 8px 0;border-radius:var(--radius-lg)}
623
- .step>details>summary:hover .label b{color:var(--accent-ink)}
624
- .idx{position:relative;display:grid;place-items:center;width:28px;height:28px;border-radius:var(--radius-md);background:var(--surface);border:1px solid var(--border);font:500 .8125rem var(--font-mono);color:var(--muted)}
625
- .step>details[open]>summary .idx{background:var(--accent);border-color:var(--accent);color:#fff}
626
- .step .label{min-width:0;overflow-wrap:anywhere}
627
- .step .time,.event .time{font-size:.75rem;color:var(--muted);white-space:nowrap}
628
- .step .counts{grid-column:2/-1;display:flex;flex-wrap:wrap;gap:6px}
629
- .step .tag.dot{position:absolute;left:22px;top:6px}
630
- .step-body{margin:4px 0 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:12px}
631
- .ptag{display:inline-flex;align-items:center;gap:6px;font:500 .75rem var(--font-mono);color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface)}
632
- .ptag b{font-weight:500;color:var(--heading)}
633
- .ptag::before{content:"";width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
634
- .ptag.p-ga4::before{background:var(--orange)}.ptag.p-meta::before{background:var(--accent)}.ptag.p-google_ads::before{background:var(--ok)}.ptag.p-tiktok::before{background:var(--heading)}
635
- .platform+.platform{margin-top:16px}
636
- .platform h4{margin:0 0 8px}
637
- .events{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
638
- .events>li+li{border-top:1px solid var(--border)}
639
- .event>summary{display:flex;align-items:center;gap:10px;padding:10px 12px;flex-wrap:wrap}
640
- .event>summary:hover{background:var(--surface-2)}
641
- .event.flag-broken>summary{background:var(--danger-soft)}.event.flag-risk>summary,.event.flag-warning>summary{background:var(--warn-soft)}
642
- .ev{font:500 .875rem var(--font-mono);color:var(--heading);overflow-wrap:anywhere}
643
- .event .meta,.context .meta{font-size:.75rem;color:var(--muted)}
644
- .event .time{margin-left:auto}
645
- .tabs{display:grid;grid-template-columns:repeat(3,minmax(0,auto)) 1fr;border-top:1px solid var(--border);background:var(--surface)}
646
- .tabs>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
647
- .tabs>label{padding:8px 6px;font-size:.75rem;white-space:nowrap;min-width:0;font-weight:500;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
648
- .tabs>label:hover{color:var(--text)}
649
- .tabs>label .n{font:500 .6875rem var(--font-mono);background:var(--surface-2);color:var(--muted);padding:1px 4px;border-radius:var(--radius-sm)}
650
- .tabs>input:checked+label{color:var(--accent-ink);border-bottom-color:var(--accent)}
651
- .tabs>input:checked+label .n{background:var(--accent-soft);color:var(--accent-ink)}
652
- .tabs>.panel{display:none;grid-column:1/-1;padding:12px;border-top:1px solid var(--border)}
653
- .tabs>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs>input:nth-of-type(3):checked~.panel:nth-of-type(3){display:block}
654
- dl.kv{display:grid;grid-template-columns:1fr;gap:0 16px;margin:0;font:.8125rem/1.5 var(--font-mono)}
655
- dl.kv dt{color:var(--muted)}dl.kv dd+dt{margin-top:6px}dl.kv dd{margin:0;overflow-wrap:anywhere;color:var(--text)}
656
- .consent{font-size:.75rem;padding:1px 6px;border-radius:var(--radius-sm)}.consent.granted{background:var(--ok-soft);color:var(--ok)}.consent.denied{background:var(--warn-soft);color:var(--warn)}
657
- .hint{font-size:.8125rem;color:var(--muted);margin:0 0 8px}
658
- pre{margin:0;font:.8125rem/1.5 var(--font-mono);background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-md);padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
659
- pre.url{font-size:.75rem}
660
- .push{display:grid;grid-template-columns:1fr;gap:4px;align-items:start;margin-bottom:8px}
661
- .push .time{font-size:.75rem;color:var(--muted)}
662
- .req{margin-bottom:8px}
663
- .req-head{display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:.8125rem;margin-bottom:6px}
664
- .req-head .method{font:500 .6875rem var(--font-mono);padding:2px 6px;border-radius:var(--radius-sm);background:var(--accent-soft);color:var(--accent-ink)}
665
- .req-head .status{font:500 .8125rem var(--font-mono);color:var(--ok)}
666
- .req-head .time{font-size:.75rem;color:var(--muted)}
667
- details.context{margin-top:16px;border-top:1px solid var(--border);padding-top:12px}
668
- details.context>summary{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:.875rem;font-weight:500;color:var(--muted)}
669
- @media (min-width:768px){
670
- main{padding:40px 24px 96px}
671
- h1{font-size:2.25rem}
672
- .metrics{grid-template-columns:repeat(4,1fr)}
673
- .finding .cols{grid-template-columns:minmax(220px,1fr) 2fr}
674
- .jgrid{grid-template-columns:repeat(2,1fr)}
675
- .okplan li{flex-direction:row;gap:16px}
676
- .step>details>summary{grid-template-columns:28px 1fr auto auto}
677
- .step .counts{grid-column:auto;justify-content:flex-end}
678
- .step-body{margin-left:40px;padding:16px}
679
- .tabs>label{padding:8px 14px;font-size:.8125rem}
680
- .push{grid-template-columns:52px 1fr;gap:8px}.push .time{padding-top:11px}
681
- dl.kv{grid-template-columns:minmax(110px,max-content) 1fr;gap:4px 16px}dl.kv dd+dt{margin-top:0}
682
- }
683
- @media (max-width:339px){.tabs>label{white-space:normal}}
684
- @media (prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
685
- @media print{details>*{display:block}.tabs>.panel{display:block}.tabs>label{display:none}}
686
- .small{font-size:.8125rem}
687
- .scope{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;margin:0 0 24px;font-size:.875rem;color:var(--muted)}
688
- .summary.v2{font-size:1rem;line-height:1.6;padding:20px 24px}
689
- .summary.v2 .headline{font-size:1.125rem;font-weight:600;color:var(--heading);margin:0 0 12px;line-height:1.45}
690
- .summary.v2 ol{margin:0;padding:0;list-style:none;display:grid;gap:8px}
691
- .summary.v2 ol li{display:grid;grid-template-columns:64px 1fr;gap:12px;align-items:baseline}
692
- .summary.v2 ol li .tag{justify-self:start}
693
- .summary.v2 h5{margin-top:20px}
694
- .summary.v2 ul.also{margin:0;padding-left:18px;display:grid;gap:6px}
695
- .finding{padding:0}
696
- .finding>details>summary{display:flex;align-items:center;gap:12px;padding:16px 20px}
697
- .finding .title{flex:1;min-width:0}
698
- .where-mini{font-size:.75rem;color:var(--muted);white-space:nowrap}
699
- .fbody{padding:0 16px 20px}
700
- .impact{margin:0 0 16px;padding:10px 12px;background:var(--surface-2);border-radius:var(--radius-md);font-size:.9375rem}
701
- .fcols{display:grid;gap:24px}
702
- .fcols p{margin:0;font-size:.9375rem}
703
- .fix-steps{margin:0;padding-left:20px;display:grid;gap:6px;font-size:.9375rem}
704
- .sevgroup{margin:24px 0 8px;font:500 .75rem var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
705
- .sevgroup+.findings{margin-top:0}
706
- .step-body.v2{padding:0;overflow:visible}
707
- .steptabs{border-top:0;border-radius:var(--radius-lg)}
708
- .steptabs>label{font-size:.875rem;padding:12px 16px}
709
- .steptabs>.panel{padding:12px}
692
+ html{scroll-padding-top:64px}
693
+ 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}
694
+ a{color:var(--accent-fg);text-decoration:none}
695
+ h1,h2,h3,h4,h5,h6{margin:0;font-weight:500;line-height:1.3}
696
+ h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}h2,h3{font-size:14px}
697
+ p{margin:0}
698
+ strong{font-weight:600;color:var(--fg)}
699
+ code,.mono,.time,pre{font-family:var(--font-mono)}
700
+ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
701
+ .muted{color:var(--muted)}.xs{font-size:12px}
702
+ .label{font-size:12px;color:var(--muted);margin:0 0 6px}
703
+ .wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 16px}
704
+ .topbar{background:var(--bg);border-bottom:1px solid var(--border)}
705
+ .topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
706
+ .product-logo img{display:block;height:26px;width:auto}
707
+ .byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
708
+ .logo-h img{display:block;height:18px;width:auto}
709
+ .title{margin-top:28px}
710
+ .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
711
+ .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
712
+ .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
713
+ .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
714
+ .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}
715
+ .toptabs a:hover{color:var(--fg)}
716
+ .n{font:500 12px/1.4 var(--font-mono);background:var(--muted-bg);color:var(--muted);padding:0 6px;border-radius:4px}
717
+ body:not(:has(:target)) .toptabs a[href="#overview"]{color:var(--fg);border-bottom-color:var(--accent)}
718
+ .tabpanel{display:none;padding:24px 0 64px}
719
+ .tabpanel:target,.tabpanel:has(:target){display:block}
720
+ body:not(:has(.tabpanel:target,.tabpanel :target)) #overview{display:block}
721
+ .card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
722
+ .card-h{padding:16px 20px 0}.card-c{padding:16px 20px 20px}.card-c.tight{padding:8px}
723
+ .card-t{font-weight:500}.card-d{font-size:12px;color:var(--muted);margin-top:2px}
724
+ .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)}
725
+ .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)}
726
+ .stack{display:grid;gap:16px}
727
+ .metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
728
+ .metric{padding:16px;border-top:3px solid var(--border)}
729
+ .metric .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
730
+ .metric .k::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
731
+ .metric.broken{border-top-color:var(--danger-dot)}.metric.broken .k::before{background:var(--danger-dot)}.metric.broken .v{color:var(--danger)}
732
+ .metric.risk{border-top-color:var(--risk)}.metric.risk .k::before{background:var(--risk)}.metric.risk .v{color:var(--risk)}
733
+ .metric.warning{border-top-color:var(--warn-dot)}.metric.warning .k::before{background:var(--warn-dot)}.metric.warning .v{color:var(--warn)}
734
+ .metric.ok{border-top-color:var(--ok)}.metric.ok .k::before{background:var(--ok)}
735
+ .metric .v{font:600 24px/1.3 var(--font-mono);margin-top:4px}
736
+ .headline{font-weight:500}
737
+ .summary-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
738
+ .summary-list li{display:grid;grid-template-columns:72px 1fr;gap:8px;align-items:baseline}
739
+ .summary-list .badge{justify-self:start}
740
+ .also{margin:0;padding-left:18px;display:grid;gap:6px}
741
+ .scope{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;font-size:12px;color:var(--muted)}
742
+ .scope-skipped,.legend{margin-top:8px}
743
+ table.dt{width:100%;border-collapse:collapse}
744
+ table.dt th,table.dt td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
745
+ table.dt th{font-weight:500;color:var(--muted);font-size:12px}
746
+ table.dt tr:last-child td{border-bottom:0}
747
+ table.dt td.num{font-family:var(--font-mono)}
748
+ table.dt td.num.bad{color:var(--danger);font-weight:600}
749
+ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
750
+ .tscroll{overflow-x:auto}
751
+ .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
752
+ .sevfilter{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius)}
753
+ .fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
754
+ .sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
755
+ .filter-host{position:relative}
756
+ .flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
757
+ .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)}
758
+ .finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
759
+ .finding.sev-risk{--sev:var(--risk);--sev-fg:var(--risk);--sev-bg:var(--risk-bg)}
760
+ .finding.sev-info{--sev:var(--accent);--sev-fg:var(--accent-fg);--sev-bg:var(--accent-bg)}
761
+ .finding>details>summary{display:flex;align-items:center;gap:12px;padding:14px 16px}
762
+ .finding>details[open]>summary{background:linear-gradient(90deg,var(--sev-bg),transparent 70%)}
763
+ .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}
764
+ .fhead{flex:1;min-width:0;display:grid;gap:4px}
765
+ .ftitle{color:var(--fg);overflow-wrap:anywhere}
766
+ .fmeta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted)}
767
+ .finding summary .chev{margin-left:4px}
768
+ .fbody{padding:16px;border-top:1px solid var(--border)}
769
+ .impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--sev-bg);color:var(--fg);border-radius:var(--radius)}
770
+ .impact svg{color:var(--sev-fg);flex:none;margin-top:3px}
771
+ .impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
772
+ .fcols{display:grid;gap:8px 32px;margin-top:4px}
773
+ .fcol{min-width:0}
774
+ .sec-h{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin:16px 0 8px}
775
+ .sec-h svg{color:var(--accent-fg)}
776
+ .ev-table{border:1px solid var(--border);border-radius:var(--radius);border-collapse:separate;border-spacing:0;overflow:hidden}
777
+ .ev-table th{background:var(--muted-bg)}
778
+ .missing{display:inline-block;padding:0 6px;border-radius:4px;background:var(--danger-bg);color:var(--danger)}
779
+ .fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
780
+ .fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
781
+ .fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
782
+ .where{display:flex;flex-wrap:wrap;gap:6px}
783
+ .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)}
784
+ span.chip{padding-left:10px}
785
+ a.chip:hover{border-color:var(--accent);color:var(--fg)}
786
+ .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)}
787
+ .sevbar{display:flex;height:8px;border-radius:4px;overflow:hidden;gap:2px;margin:0 0 6px}
788
+ .sevbar span{display:block}
789
+ .sevlegend{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12px;color:var(--muted);margin-bottom:16px}
790
+ .sevlegend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
791
+ body>main{flex:1 0 auto;width:100%}
792
+ .footer{border-top:1px solid var(--border);background:var(--bg);margin-top:32px;flex:none}
793
+ .footer .wrap{display:grid;gap:16px;padding:32px 16px}
794
+ .footer p{font-size:12px;color:var(--muted)}
795
+ .footer a{color:var(--fg)}.footer a:hover{color:var(--accent-fg)}
796
+ .footer .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
797
+ .footer .f-meta{font:12px var(--font-mono);color:var(--muted)}
798
+ .logo-link{display:inline-flex}.logo-s img{display:block;height:56px;width:auto}
799
+ .f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
800
+ .jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius);margin-bottom:16px}
801
+ .jtabs a{display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:4px;color:var(--muted);font-weight:500}
802
+ .jtabs a:hover{color:var(--fg)}
803
+ #journeys:not(:has(.jpanel:target,.jpanel :target)) .jtabs a[href="#j0"]{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}
804
+ .jpanel{display:none}
805
+ .jpanel:target,.jpanel:has(:target){display:block}
806
+ #journeys:not(:has(.jpanel:target,.jpanel :target)) .jpanel:first-of-type{display:block}
807
+ .jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
808
+ .jgrid2{display:grid;gap:16px;align-items:start}
809
+ .stepnav{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:6px}
810
+ .stepnav ol{list-style:none;margin:0;padding:0;display:grid;gap:2px}
811
+ .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)}
812
+ .stepnav a:hover{background:var(--muted-bg);color:var(--fg)}
813
+ .jpanel:not(:has(.sdetail:target)) .stepnav a.def{background:var(--accent-bg);color:var(--fg)}
814
+ .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)}
815
+ .sl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
816
+ .dot-flag{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--danger-dot)}
817
+ .sc{font:12px var(--font-mono);color:var(--muted);white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
818
+ .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}
819
+ .sdetail:target{display:block}
820
+ .jpanel:not(:has(.sdetail:target)) .sdetail.default{display:block}
821
+ .shead{display:flex;gap:12px;align-items:flex-start;padding:16px}
822
+ .shead h3{overflow-wrap:anywhere}
823
+ .shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
824
+ .empty{color:var(--muted);padding:8px 0}
825
+ .tabs.seg{display:grid;grid-template-columns:repeat(3,auto) 1fr;padding:0 16px 16px}
826
+ .tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
827
+ .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}
828
+ .tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
829
+ .tabs.seg>label:nth-of-type(3){border-radius:0 var(--radius) var(--radius) 0}
830
+ .tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
831
+ .tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
832
+ .tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
833
+ .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){display:block}
710
834
  .filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
711
835
  .filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
712
- .filter>label{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font-size:.8125rem;cursor:pointer;color:var(--muted);background:var(--surface)}
713
- .filter>label:hover{border-color:var(--accent)}
714
- .filter>input:checked+label{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)}
836
+ .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)}
837
+ .filter>label:hover{color:var(--fg)}
838
+ .filter>input:checked+label{background:var(--fg);border-color:var(--fg);color:var(--bg)}
839
+ .filter>input:checked+label .n{background:transparent;color:inherit}
715
840
  .filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
716
- .filter .n{font:500 .6875rem var(--font-mono)}
717
- .dot{width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
718
- .dot.p-ga4{background:var(--orange)}.dot.p-meta{background:var(--accent)}.dot.p-google_ads{background:var(--ok)}.dot.p-tiktok{background:var(--heading)}
719
- .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);flex-basis:100%}
841
+ .filter>label:last-of-type{margin-bottom:4px}
842
+ .dot{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--muted)}
843
+ .dot.p-ga4,.ptag.p-ga4::before{background:var(--orange)}.dot.p-meta,.ptag.p-meta::before{background:var(--accent)}.dot.p-google_ads,.ptag.p-google_ads::before{background:var(--ok)}.dot.p-tiktok,.ptag.p-tiktok::before{background:var(--fg)}
844
+ .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
720
845
  .evlist>li+li{border-top:1px solid var(--border)}
846
+ summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
847
+ summary:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
848
+ .chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
849
+ .event>summary{display:flex;align-items:center;gap:10px;padding:8px 12px;flex-wrap:wrap}
850
+ .event>summary:hover{background:var(--muted-bg)}
851
+ .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}
852
+ .event.flag-broken>summary .ev::after{background:var(--danger-dot)}.event.flag-risk>summary .ev::after{background:var(--risk)}
853
+ .event .tag{display:none}
854
+ .ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
855
+ .event .meta{font-size:12px;color:var(--muted)}
856
+ .event .time,.time{font-size:12px;color:var(--muted);margin-left:auto}
857
+ .ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
858
+ .ptag::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
721
859
  .event-body{padding:4px 12px 16px}
722
- .flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warn-soft);color:var(--warn);font-size:.875rem}
723
- .flagnote.broken{background:var(--danger-soft);color:var(--danger)}
724
- .ebgrid{display:grid;gap:16px}
725
- .ptable{width:100%;border-collapse:collapse;font:.8125rem/1.5 var(--font-mono)}
860
+ .flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius);background:var(--warn-bg);color:var(--warn)}
861
+ .flagnote.broken{background:var(--danger-bg);color:var(--danger)}.flagnote.risk{background:var(--risk-bg);color:var(--risk)}
862
+ .event-body h5{font-size:12px;color:var(--muted);font-weight:400;margin:12px 0 6px}
863
+ .event-body h5:first-child{margin-top:4px}
864
+ h5 .n{margin-left:4px}
865
+ .ebgrid{display:grid;gap:12px}
866
+ .ebgrid>*{min-width:0}
867
+ .ptable{width:100%;border-collapse:collapse;font:12px/1.5 var(--font-mono)}
726
868
  .ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
727
869
  .ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
728
870
  .glossary th{min-width:170px}
729
871
  .ptable td{overflow-wrap:anywhere}
730
872
  .ptable tr:last-child>*{border-bottom:0}
731
- details.ctx>summary .n,h5 .n{font:500 .6875rem var(--font-mono);background:var(--surface-2);padding:1px 5px;border-radius:var(--radius-sm)}
732
- h5 .n{margin-left:4px}
733
873
  .consents{display:flex;flex-wrap:wrap;gap:6px}
734
- .srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:.8125rem}
735
- .srclist li{display:grid;gap:2px}.srclist .lbl{font-size:.75rem;color:var(--muted)}
736
- .method{font:500 .6875rem var(--font-mono);padding:2px 6px;border-radius:var(--radius-sm);background:var(--accent-soft);color:var(--accent-ink)}
737
- .status{color:var(--ok);font-family:var(--font-mono);font-size:.8125rem}
738
- .item{border:1px solid var(--border);border-radius:var(--radius-md);padding:12px;margin-bottom:8px;background:var(--surface)}
739
- .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 12px;margin-bottom:10px}
740
- .item-idx{font:500 .75rem var(--font-mono);color:var(--muted)}
741
- .item-price{margin-left:auto;color:var(--heading)}
874
+ .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)}
875
+ .ebgrid aside{background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;align-self:start}
876
+ .ebgrid aside h5{margin-top:0!important}
877
+ .srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:12px}
878
+ .srclist li{display:grid;gap:2px;overflow-wrap:anywhere}.srclist .lbl{font-size:12px;color:var(--muted)}
879
+ .method{font:500 12px var(--font-mono);padding:0 5px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg)}
880
+ .status{color:var(--ok);font-family:var(--font-mono);font-size:12px}
881
+ .small{font-size:12px}
882
+ .item{border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px}
883
+ .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:10px}
884
+ .item-head b{font-weight:500}
885
+ .item-idx{font:12px var(--font-mono);color:var(--muted)}
886
+ .item-price{margin-left:auto;font-size:12px}
742
887
  .grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
743
- .grid4 dt{font:.6875rem var(--font-mono);color:var(--muted);overflow-wrap:anywhere}.grid4 dd{margin:0;font:.8125rem var(--font-mono);overflow-wrap:anywhere}
744
- h6{margin:12px 0 6px;font:500 .6875rem var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
888
+ .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}
889
+ h6{margin:12px 0 6px;font-size:12px;font-weight:400;color:var(--muted)}
745
890
  .pk{margin-right:4px}
746
- .q{position:relative;display:inline-grid;place-items:center;width:15px;height:15px;border:1px solid var(--border);border-radius:50%;font:500 10px var(--font-sans);color:var(--muted);cursor:help;vertical-align:1px}
747
- .q:hover,.q:focus{border-color:var(--accent);color:var(--accent-ink);outline:none}
748
- .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(--heading);color:var(--bg);font:400 .8125rem/1.45 var(--font-sans);border-radius:var(--radius-md);white-space:normal;text-align:left}
891
+ .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}
892
+ .q:hover,.q:focus{border-color:var(--accent);color:var(--accent-fg);outline:none}
893
+ .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}
749
894
  tr.pr td{color:var(--muted)}
750
- details.raw{margin-top:12px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:.8125rem;color:var(--muted)}details.raw pre{margin-top:8px}
751
- .scope-note{margin:12px 0 0}
895
+ details.raw{margin-top:10px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
896
+ 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}
897
+ .scope-note{margin-top:10px}
898
+ .cov td.c{text-align:center;font-family:var(--font-mono)}
899
+ .cov .y{color:var(--ok)}.cov .x{color:var(--danger);font-weight:600}.cov .o{color:var(--muted)}
752
900
  @media (min-width:768px){
753
- .fcols{grid-template-columns:3fr 2fr}
754
- .event-body{padding:4px 16px 16px 38px}
755
- .steptabs>.panel{padding:16px}
901
+ .wrap{padding:0 24px}
902
+ .metrics{grid-template-columns:repeat(4,1fr)}
903
+ .ov-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:start}
904
+ .fcols{grid-template-columns:1fr 1fr}
905
+ .footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:32px 24px}
906
+ .footer .wrap.plain{grid-template-columns:1fr auto}
907
+ .jgrid2{grid-template-columns:260px minmax(0,1fr)}
908
+ .stepnav{position:sticky;top:60px}
756
909
  .ebgrid{grid-template-columns:2fr 1fr}
757
910
  .grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
758
- .summary.v2 ol li{grid-template-columns:72px 1fr}
759
- .fbody{padding:0 20px 20px 48px}
911
+ .event-body{padding:4px 16px 16px 38px}
760
912
  }
761
- @media print{.filter>label,.filter>input{display:none}}
913
+ @media (max-width:767px){.ov-grid{display:grid;gap:16px}}
914
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
915
+ @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}}
762
916
  `;
763
917
 
764
- export function renderHtml({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
918
+ export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true }) {
765
919
  const m = messages(lang);
766
920
  const rows = attachExplanations(findings, explained);
767
921
  const counts = summarize(rows);
768
- const okItems = rows.filter((f) => f.severity === "ok").map((f) => `<li><span class="who">${escapeHtml(f.where[0].journey)} <span class="muted">· ${escapeHtml(f.where[0].viewport)}</span></span><span class="what">${planMatchHtml(f.detail)}</span></li>`).join("");
769
- const metric = (sev) => `<div class="metric ${sev}"><span class="num">${counts[sev]}</span><span class="lbl">${m.severity[sev]}</span></div>`;
922
+ const problems = rows.filter((f) => f.severity !== "ok").length;
923
+ const flagged = flaggedSteps(runs, rows);
924
+ 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>`;
925
+ const footer = branding
926
+ ? `<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>
927
+ <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>
928
+ <div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
929
+ : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
770
930
 
771
931
  return `<!doctype html>
772
932
  <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
773
- <title>${escapeHtml(title)}</title>
774
- <style>${CSS}${filterCss(runs)}</style></head><body><main>
775
- <div class="brand">${branding ? `<span class="logo"><span class="bars" aria-hidden="true"><i></i><i></i><i></i></span>analitik işler</span>` : ""}<span class="product">AI Tag Debugger</span></div>
776
- <header>
933
+ <title>${escapeHtml(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
934
+ <style>${CSS}${filterCss(runs)}
935
+ ${navCss(runs)}</style></head><body>
936
+ <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
937
+ <header class="wrap title">
777
938
  <div class="eyebrow">${m.ui.eyebrow}</div>
778
939
  <h1>${escapeHtml(title)}</h1>
779
- <div class="generated">${escapeHtml(m.ui.generated(generatedAt))}</div>
940
+ <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>
780
941
  </header>
781
- <section class="metrics" aria-label="${m.ui.findings}">${metric("broken")}${metric("risk")}${metric("warning")}${metric("ok")}</section>
782
- ${scopeHtml(m, runs)}
783
- ${summaryHtml(m, explained?.summary)}
784
- <section><h2>${m.ui.findings}</h2>
785
- ${findingsHtml(m, runs, rows)}</section>
786
- ${okItems ? `<section><h2>${m.ui.matchingPlan}</h2><ul class="okplan">${okItems}</ul></section>` : ""}
787
- <section><h2>${m.ui.timeline}</h2>
788
- <p class="lead">${m.ui.timelineLeadV2}</p>
789
- ${journeysHtml(m, lang, findings, runs)}</section>
790
- ${branding ? `<footer class="credit muted">${m.ui.generatedBy} · <a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a></footer>` : ""}
791
- </main></body></html>`;
942
+ <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
943
+ <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>
944
+ <main class="wrap">
945
+ <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${overviewTab(m, findings, rows, counts, explained, runs)}</section>
946
+ <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
947
+ <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
948
+ <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
949
+ </main>
950
+ <footer class="footer">${footer}</footer>
951
+ </body></html>`;
792
952
  }
793
953
 
794
954
  const csvCell = (v) => {