@analitikisler/ai-tag-debugger 0.17.0 → 0.19.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,19 +1,25 @@
1
1
  import { AD_PLATFORMS, consentTable } from "./consent.js";
2
- import { OWNERS, ownerOf, verifyLinks } from "./verify.js";
2
+ import { ownerOf, verifyLinks } from "./verify.js";
3
3
  import { createProvider } from "./providers.js";
4
- import { canonicalEvent, groupFindings, hitMatchesEvent, summarize } from "./checks.js";
4
+ import { canonicalEvent, groupFindings, summarize } from "./checks.js";
5
5
  import { messages } from "./i18n.js";
6
- import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
7
- import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
8
6
  import { buildInventory } from "./inventory.js";
9
- import { LOGOS } from "./brand.js";
10
- import { brandCss } from "./whitelabel.js";
11
7
  import { computeMetrics } from "./metrics.js";
12
8
  import { historySummaryLines, sparkline } from "./history.js";
13
- import { readFileSync } from "node:fs";
14
- import { createHash } from "node:crypto";
9
+ import { cardIds } from "./html/lib.js";
10
+ import {
11
+ VERSION, SEVERITIES, summaryParts, fixSteps, attachExplanations, SEVERITY_RANK, SEVERITY_ORDER_MD, seconds, isConsentEntry, routeList,
12
+ isProblem, placeKey, uniqPlaces, mdHeadline, platformLabel, sortPlatforms, changeLabel, coverageLines, pcSort, planChangeItems,
13
+ } from "./report-shared.js";
14
+
15
+ // The HTML report lives in src/html/: shell.js assembles the page from one module per section.
16
+ // Callers keep importing everything from here.
17
+ export { VERSION, escapeHtml, platformLabel, verdictHeadline } from "./report-shared.js";
18
+ export { renderHtml } from "./html/shell.js";
19
+ export { CSS } from "./html/css.js";
20
+ export { SCRIPT, scriptCspSource } from "./html/script.js";
21
+ export { logo } from "./html/lib.js";
15
22
 
16
- export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
17
23
 
18
24
  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.
19
25
 
@@ -30,7 +36,6 @@ Then write the summary:
30
36
 
31
37
  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.`;
32
38
 
33
- const SEVERITIES = ["broken", "risk", "warning", "info"];
34
39
  const EXPLAIN_SCHEMA = {
35
40
  type: "object",
36
41
  properties: {
@@ -71,20 +76,6 @@ const EXPLAIN_SCHEMA = {
71
76
  additionalProperties: false,
72
77
  };
73
78
 
74
- /** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
75
- function summaryParts(summary) {
76
- if (!summary) return null;
77
- if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
78
- return {
79
- headline: String(summary.headline ?? ""),
80
- points: (Array.isArray(summary.points) ? summary.points : []).filter((p) => p && p.text).map((p) => ({ severity: SEVERITIES.includes(p.severity) ? p.severity : "info", text: String(p.text) })),
81
- also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
82
- };
83
- }
84
-
85
- /** Fix steps of an explanation; older explanations had one "fix" string. */
86
- const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
87
-
88
79
  function hitsByStep(run) {
89
80
  return run.steps.map((step) => ({
90
81
  step: step.label,
@@ -127,19 +118,6 @@ export async function explainFindings({ findings, plan, runs, gtmContainer, prov
127
118
  }
128
119
  }
129
120
 
130
- export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
131
-
132
- function attachExplanations(findings, explained) {
133
- const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
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 } : {}) }));
139
- }
140
-
141
- const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
142
-
143
121
  /** The most severe finding that points at this hit, if any (for highlighting in the timeline). */
144
122
  function hitSeverity(findings, run, step, hit) {
145
123
  let worst = null;
@@ -152,15 +130,10 @@ function hitSeverity(findings, run, step, hit) {
152
130
  return worst;
153
131
  }
154
132
 
155
- const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
156
- const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
157
-
158
133
  // Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
159
134
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
160
135
  const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
161
136
 
162
- const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
163
-
164
137
  // ---------- The Markdown report (docs/design/md-report-spec.md) ----------
165
138
  // A verdict first, evidence second, never a dump: the file goes into pull request comments, Slack
166
139
  // previews and mails, so it stays around 120 lines and the per-hit timeline lives in timeline.csv.
@@ -183,14 +156,10 @@ const mdPath = (url) => {
183
156
  return String(url ?? "");
184
157
  }
185
158
  };
186
- const isProblem = (f) => f.severity in SEVERITY_RANK;
187
159
  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
160
  const mdPlace = (w) => `${mdText(w.journey)} · ${mdText(w.viewport)}`;
191
161
  /** "GA4 `purchase`", or the check name for a finding without an event. */
192
162
  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
163
 
195
164
  /**
196
165
  * A text from one of the per-check tables (consequence, fix, verdict headline) that other parts of
@@ -250,44 +219,6 @@ function mdAlsoFineOn(m, runs, f) {
250
219
  return fine.length ? mdPlaces(m, runs, fine.map((r) => ({ journey: r.journeyId, viewport: r.viewport }))) : "";
251
220
  }
252
221
 
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
222
  /** "Changed since last run" in six lines at most: the headline, fixed, new, still open, and the route changes. */
292
223
  function mdChanges(m, ch, rows) {
293
224
  if (!ch?.previous) return [];
@@ -324,7 +255,7 @@ function mdPriorities(m, runs, metrics) {
324
255
  }
325
256
 
326
257
  /** 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) {
258
+ function mdFinding(m, runs, f, ids) {
328
259
  const e = f.explanation;
329
260
  const lines = [`### ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${mdDetail(f.detail)}`, `- ${m.ui.where}: ${mdWhere(m, runs, f.where)}`];
330
261
  const impact = e?.impact ?? mdTemplate(m.consequence, f.check, f);
@@ -343,7 +274,8 @@ function mdFinding(m, runs, f) {
343
274
  if (fix) lines.push(`- ${m.ui.fix}: ${fix}`);
344
275
  const vl = verifyLinks(f);
345
276
  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)}`, "");
277
+ // The HTML card's own id (cardIds): a site-level finding is f-site-…, and a repeated one ends in -2.
278
+ lines.push(`- ${m.ui.md.link}: report.html#${ids.get(f)}`, "");
347
279
  return lines;
348
280
  }
349
281
 
@@ -466,16 +398,6 @@ function mdTrend(m, trend, lang) {
466
398
  return [`## ${m.ui.md.trend}`, "", `\`${sparkline(trend)}\` ${mdText(headline)}`, ...rest.map(mdText), ""];
467
399
  }
468
400
 
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
401
  /**
480
402
  * The Markdown report: the verdict in the title, the meta and counts, what changed, the priorities by
481
403
  * owner, up to five full finding blocks, consent, what passed, the journeys with their flagged steps,
@@ -484,7 +406,10 @@ export function verdictHeadline({ findings = [], runs = [], metrics, lang = "en"
484
406
  */
485
407
  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
408
  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);
409
+ const grouped = attachExplanations(findings, explained);
410
+ // Ids are given in the HTML report's row order, before the sort, so a counter suffix lands on the same card.
411
+ const ids = cardIds(grouped);
412
+ const rows = [...grouped].sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
488
413
  const counts = summarize(rows);
489
414
  const accepted = rows.filter((f) => f.accepted).length;
490
415
  metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
@@ -508,7 +433,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
508
433
  lines.push(`## ${m.ui.findings}`, "");
509
434
  const problems = rows.filter((f) => f.severity !== "ok");
510
435
  if (!problems.length) lines.push(m.ui.noProblems, "");
511
- for (const f of problems.slice(0, MD_BLOCKS)) lines.push(...mdFinding(m, runs, f));
436
+ for (const f of problems.slice(0, MD_BLOCKS)) lines.push(...mdFinding(m, runs, f, ids));
512
437
  const rest = problems.slice(MD_BLOCKS);
513
438
  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
439
  lines.push(...mdConsent(m, metrics, consentScenarios));
@@ -516,1185 +441,12 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
516
441
  lines.push(...mdJourneys(m, runs, rows));
517
442
  lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
518
443
  lines.push(...mdInventory(m, runs));
519
- // A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
444
+ // A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
520
445
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
521
446
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
522
447
  return lines.join("\n");
523
448
  }
524
449
 
525
- const SEVERITY_ORDER_MD = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
526
-
527
- const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
528
- export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
529
- const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
530
-
531
- // Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
532
- const EVENT_ALIASES = {
533
- pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
534
- addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
535
- completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
536
- viewbasket: "viewcart", tracktransaction: "purchase",
537
- };
538
- const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
539
- const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
540
- // GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
541
- const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
542
- const pushText = (value) => (Array.isArray(value) && typeof value[0] === "string" ? `gtag(${value.map((v) => JSON.stringify(v, null, 2)).join(", ")})` : JSON.stringify(value, null, 2));
543
-
544
- // Per run: step index -> event key -> the pushes with that event name. Built once so rendering stays linear.
545
- const pushIndexCache = new WeakMap();
546
- function pushIndex(run) {
547
- let index = pushIndexCache.get(run);
548
- if (!index) {
549
- index = new Map();
550
- for (const e of run.dataLayerLog ?? []) {
551
- const name = pushEvent(e.value);
552
- if (typeof name !== "string" || !eventKey(name)) continue;
553
- if (!index.has(e.step)) index.set(e.step, new Map());
554
- const byKey = index.get(e.step);
555
- const key = eventKey(name);
556
- if (!byKey.has(key)) byKey.set(key, []);
557
- byKey.get(key).push(e);
558
- }
559
- pushIndexCache.set(run, index);
560
- }
561
- return index;
562
- }
563
-
564
- /** Every dataLayer push in the hit's step whose event name matches it (directly or through a pixel alias). */
565
- const matchingPushes = (run, hit) => pushIndex(run).get(hit.step)?.get(eventKey(hit.name)) ?? [];
566
-
567
- /** The push behind the hit. When the same event fires as often as it was pushed (duplicates), they are paired in order. */
568
- function pushesForHit(run, hit) {
569
- const pushes = matchingPushes(run, hit);
570
- const key = eventKey(hit.name);
571
- const twins = run.hits.filter((h) => h.step === hit.step && h.platform === hit.platform && eventKey(h.name) === key);
572
- return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
573
- }
574
-
575
- /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
576
- function callsForHit(run, hit) {
577
- const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
578
- const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
579
- return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
580
- }
581
-
582
-
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"/>',
621
- };
622
-
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>`;
650
- // Platform keys go into class names, ids and attribute selectors.
651
- const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
652
- const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
653
-
654
- const STANDARD_ITEM_KEYS = new Set(["item_id", "item_name", "affiliation", "coupon", "discount", "index", "item_brand", "item_category", "item_category2", "item_category3", "item_category4", "item_category5", "item_list_id", "item_list_name", "item_variant", "location_id", "price", "quantity", "promotion_id", "promotion_name", "creative_name", "creative_slot"]);
655
- const ECOMMERCE_EVENTS = new Set(["view_item", "view_item_list", "select_item", "add_to_cart", "remove_from_cart", "view_cart", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "add_to_wishlist", "view_promotion", "select_promotion"]);
656
-
657
- /** GA4 item strings (pr1=…) as { fields, custom } pairs. */
658
- const ga4Items = (strings) => (strings ?? []).map(decodeGa4Item);
659
- /** dataLayer ecommerce.items objects as { fields, custom } pairs: standard GA4 item keys, then the site's own. */
660
- const objectItems = (list) =>
661
- list.filter((it) => it && typeof it === "object").map((it) => {
662
- const entries = Object.entries(it).map(([k, v]) => [k, cell(v)]);
663
- return { fields: entries.filter(([k]) => STANDARD_ITEM_KEYS.has(k)), custom: entries.filter(([k]) => !STANDARD_ITEM_KEYS.has(k)) };
664
- });
665
- /** The items array of a GTM-style ({ecommerce: {items}}) or gtag.js-style (["event", name, {items}]) push. */
666
- function pushItems(value) {
667
- const items = Array.isArray(value) ? value[2]?.items : value?.ecommerce?.items ?? value?.items;
668
- return Array.isArray(items) ? items : [];
669
- }
670
-
671
- function itemCards(m, items) {
672
- return items
673
- .map(({ fields, custom }, i) => {
674
- const get = (k) => fields.find(([n]) => n === k)?.[1];
675
- const price = get("price");
676
- const qty = get("quantity");
677
- const head = `<div class="item-head"><span class="item-idx">${i + 1}</span><b>${escapeHtml(get("item_name") ?? "")}</b><span class="mono muted">${escapeHtml(get("item_id") ?? "")}</span>${price !== undefined || qty !== undefined ? `<span class="item-price mono">${escapeHtml(price ?? "–")} × ${escapeHtml(qty ?? "1")}</span>` : ""}</div>`;
678
- const rest = fields.filter(([n]) => !["item_name", "item_id", "price", "quantity"].includes(n));
679
- const grid = (rows) => `<dl class="grid4">${rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v === "" ? "–" : v)}</dd></div>`).join("")}</dl>`;
680
- return `<div class="item">${head}${rest.length ? grid(rest) : ""}${custom.length ? `<h6>${m.ui.customItemParams}</h6>${grid(custom)}` : ""}</div>`;
681
- })
682
- .join("");
683
- }
684
- const itemsBlock = (m, items) => (items.length ? `<h6>${m.ui.items} <span class="n">${items.length}</span></h6>${itemCards(m, items)}` : "");
685
-
686
- /** The most severe finding that points at this hit, or null. */
687
- function hitFlag(findings, run, step, hit) {
688
- let worst = null;
689
- for (const f of findings) {
690
- if (!(f.severity in SEVERITY_RANK)) continue;
691
- if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
692
- if (f.platform !== hit.platform || f.event !== hit.name) continue;
693
- if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
694
- }
695
- return worst;
696
- }
697
-
698
- const hostPath = (url) => {
699
- try {
700
- const u = new URL(url);
701
- return u.host + u.pathname;
702
- } catch {
703
- return String(url ?? "");
704
- }
705
- };
706
-
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
- };
712
-
713
- function eventRow(m, findings, run, step, hit, ids = new Map()) {
714
- const flag = hitFlag(findings, run, step, hit);
715
- const pushes = pushesForHit(run, hit);
716
- const calls = callsForHit(run, hit);
717
- const items = ga4Items(hit.items);
718
- const params = Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && items.length));
719
- const bits = [];
720
- if (items.length) bits.push(m.ui.itemsCount(items.length));
721
- if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
722
- if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
723
- if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
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>` : "";
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>`;
726
- const reqText = calls.length
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("")
728
- : `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
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>
735
- ${itemsBlock(m, items)}</div></details></li>`;
736
- }
737
-
738
- const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
739
-
740
- function pushRow(m, run, e) {
741
- const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : pushEvent(e.value) ?? clip(JSON.stringify(e.value) ?? String(e.value), 80);
742
- const items = objectItems(pushItems(e.value));
743
- const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
744
- const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
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>`;
747
- }
748
-
749
- /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
750
- function flatten(obj, prefix = "", out = []) {
751
- for (const [k, v] of Object.entries(obj ?? {})) {
752
- const key = prefix ? `${prefix}.${k}` : k;
753
- if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
754
- else out.push([key, cell(v)]);
755
- }
756
- return out;
757
- }
758
-
759
- /**
760
- * The parameter sets a request carried: one per event in a GA4 batch (query plus
761
- * each body line), the query plus a flattened JSON body, or just the query.
762
- */
763
- function paramSets(call) {
764
- let u;
765
- try {
766
- u = new URL(call.url);
767
- } catch {
768
- return [[]];
769
- }
770
- const query = [...u.searchParams];
771
- const body = call.postData;
772
- if (!body) return [query];
773
- const trimmed = body.trim();
774
- if (trimmed.startsWith("{")) {
775
- try {
776
- return [[...query, ...flatten(JSON.parse(trimmed))]];
777
- } catch { /* not JSON, or cut at the size limit */ }
778
- }
779
- if (/^[^\s{]*=/.test(trimmed)) {
780
- return trimmed.split("\n").filter(Boolean).map((line) => {
781
- const merged = new Map(query);
782
- for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
783
- return [...merged];
784
- });
785
- }
786
- return [query];
787
- }
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. */
790
- function paramRows(m, lang, entries) {
791
- return entries
792
- .map(([k, v]) => {
793
- const tip = paramTip(k, lang);
794
- const isItem = /^pr\d+$/.test(k);
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>`;
796
- })
797
- .join("");
798
- }
799
-
800
- /** One row per event in each network request of the step. */
801
- function requestRows(m, lang, run, step) {
802
- const rows = [];
803
- for (const c of (run.network ?? []).filter((x) => x.step === step.index)) {
804
- const sets = paramSets(c);
805
- const hits = parseTrackingRequest(c.url, c.postData ?? null) ?? [];
806
- const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
807
- const label = PROVIDERS[key] ?? c.vendor ?? key;
808
- sets.forEach((entries, i) => {
809
- const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
810
- const eventEntries = entries.filter(([k]) => isEventParam(k));
811
- const context = entries.filter(([k]) => !isEventParam(k));
812
- const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
813
- const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
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 });
819
- });
820
- }
821
- return rows;
822
- }
823
-
824
- const stepAnchor = (ri, si) => `j${ri}-s${si}`;
825
- const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
826
- const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
827
-
828
- // AI text marks numbers with **x** and names with `x`. Only those two marks become HTML; everything else is escaped.
829
- const fmt = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
830
- // Finding texts quote event and parameter names ("add_to_cart"); show those as code.
831
- const detailHtml = (s) => escapeHtml(s).replace(/&quot;([^&]+?)&quot;/g, "<code>$1</code>");
832
-
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>`;
1195
-
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. */
1197
- function whereTarget(runs, w, f) {
1198
- const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
1199
- if (ri < 0) return null;
1200
- const candidates = w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
1201
- const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
1202
- return { ri, step };
1203
- }
1204
-
1205
- const pathOf = (url) => {
1206
- try {
1207
- return new URL(url).pathname || "/";
1208
- } catch {
1209
- return String(url ?? "");
1210
- }
1211
- };
1212
-
1213
- /** The same problem's identity across the grouped rows, the metrics and the history: severity, check, platform and event. */
1214
- const problemKey = (f) => [f.severity, f.check, f.platform ?? "", f.event ?? ""].join("|");
1215
- /** The grouped finding's full identity, as groupFindings keys it, so a metrics entry finds its card. */
1216
- const rowKey = (f) => `${problemKey(f)}|${f.detail ?? ""}`;
1217
-
1218
- /** Stable card ids (f-<platform>-<event>-<check>, with a counter when two cards would share one) for the problem rows. */
1219
- function cardIds(rows) {
1220
- const taken = new Map();
1221
- const ids = new Map();
1222
- for (const f of rows) {
1223
- if (f.severity === "ok") continue;
1224
- const base = ["f", safeKey(f.platform ?? "site"), f.event ? safeKey(f.event) : "", safeKey(f.check)].filter(Boolean).join("-");
1225
- const n = (taken.get(base) ?? 0) + 1;
1226
- taken.set(base, n);
1227
- ids.set(f, n === 1 ? base : `${base}-${n}`);
1228
- }
1229
- return ids;
1230
- }
1231
-
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));
1237
- }
1238
-
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));
1244
- }
1245
-
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;
1255
- }
1256
-
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>`;
1281
- }
1282
-
1283
- /** The tags in the GTM container export that send this finding's event, with their triggers. */
1284
- function gtmHtml(m, tags) {
1285
- if (!tags) return "";
1286
- if (!tags.length) return `${sec("gtm", m.ui.gtmTags)}<p class="muted">${m.ui.gtmNoTag}</p>`;
1287
- const trig = (t) => `${escapeHtml(t.name)}${t.event ? ` <span class="muted">(${escapeHtml(t.event)})</span>` : ""}`;
1288
- const rows = tags.map((t) => {
1289
- const notes = [t.paused ? m.ui.gtmPaused : "", t.consent.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
1290
- const why = t.evaluation ? `<div class="xs gtm-why">${escapeHtml(m.ui.gtmWhy(t.name, m.ui.gtmVerdict[t.evaluation.fired], t.evaluation.why))}</div>` : "";
1291
- return `<tr><th scope="row">${escapeHtml(t.name)}${notes.length ? `<div class="muted xs">${escapeHtml(notes.join(" · "))}</div>` : ""}</th><td>${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map(trig).join(", ")}` : `<span class="missing">${m.ui.gtmNoTrigger}</span>`}${t.blocking.length ? `<div class="muted xs">${m.ui.gtmBlockedBy} ${t.blocking.map(trig).join(", ")}</div>` : ""}${why}</td></tr>`;
1292
- });
1293
- return `${sec("gtm", m.ui.gtmTags)}<table class="ptable gtm-tags" aria-label="${m.ui.gtmTags}"><tbody>${rows.join("")}</tbody></table>`;
1294
- }
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
-
1298
- function verifyHtml(m, f) {
1299
- const links = verifyLinks(f);
1300
- if (!links.length) return "";
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}`;
1330
- }
1331
-
1332
- function findingCard(m, runs, plan, f, id, open) {
1333
- const e = f.explanation;
1334
- const steps = fixSteps(e);
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>`;
1348
- }
1349
-
1350
- const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
1351
-
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) {
1364
- const problems = rows.filter((f) => f.severity !== "ok");
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>`;
1372
- const groups = OWNERS.map((o) => {
1373
- const mine = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s && ownerOf(f) === o));
1374
- if (!mine.length) return "";
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>`;
1378
- }).join("");
1379
- return `${bar}<p class="muted xs lead">${m.html.findingsLead}${okCount ? ` ${escapeHtml(m.html.okInOverview(okCount))}` : ""}</p>${groups}`;
1380
- }
1381
-
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) {
1391
- const s = summaryParts(explained?.summary);
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>`;
1395
- }
1396
-
1397
- /** The providers that were checked: from the runs, or for older captures the platforms seen. */
1398
- function checkedProviders(runs) {
1399
- const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
1400
- if (!chosen.size) for (const r of runs) for (const h of r.hits) chosen.add(h.platform);
1401
- return sortPlatforms(chosen);
1402
- }
1403
-
1404
- function skippedTotals(runs) {
1405
- const totals = {};
1406
- 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;
1407
- return skippedText(totals);
1408
- }
1409
-
1410
- const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
1411
- const uniqList = (xs) => [...new Set(xs)];
1412
-
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>`],
1429
- ];
1430
- return `<dl class="facts">${facts.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join("")}</dl>`;
1431
- }
1432
-
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>`;
1466
- });
1467
- return `<div class="card"><div class="card-h"><h3>${m.html.fixFirst}</h3><p class="card-d">${m.html.fixFirstLead}</p></div><div class="card-c">${groups.join("")}</div></div>`;
1468
- }
1469
-
1470
- function workingCard(m, runs, rows, metrics) {
1471
- const lines = okLines(m, runs, rows);
1472
- const c = metrics.coverage;
1473
- if (c) lines.unshift(`<li>${badge("ok", m.severity.ok)}<span>${escapeHtml(m.html.coverageVerified(c.counts.implemented, c.checkable, c.percent.implemented))} <a href="#scope">${m.ui.planCoverage}</a></span></li>`);
1474
- if (!lines.length) return "";
1475
- return `<div class="card"><div class="card-h"><h3>${m.html.working}</h3></div><div class="card-c"><ul class="oklist">${lines.join("")}</ul></div></div>`;
1476
- }
1477
-
1478
- /** Problem cards that point at this run. */
1479
- const problemsIn = (rows, run) => rows.filter((f) => f.severity in SEVERITY_RANK && f.where.some((w) => w.journey === run.journeyId && w.viewport === run.viewport)).length;
1480
-
1481
- function journeysTable(m, runs, rows) {
1482
- if (!runs.length) return "";
1483
- const body = runs.map((r, ri) => {
1484
- const n = problemsIn(rows, r);
1485
- return `<tr><td><a href="#j${ri}">${escapeHtml(r.journeyId)}</a></td><td>${escapeHtml(r.viewport)}</td><td class="num">${r.steps.filter((s) => s.index > 0).length}</td><td class="num">${r.hits.length}</td><td class="num${n ? " bad" : ""}">${n ? `${use("sev-broken", 12)} ${n}` : "0"}</td></tr>`;
1486
- }).join("");
1487
- return `<div class="card"><div class="card-h"><h3>${m.ui.tabJourneys}</h3><p class="card-d">${m.html.journeysLead}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.tabJourneys}</caption><thead><tr><th scope="col">${m.ui.journey}</th><th scope="col">${m.html.viewport}</th><th scope="col" class="num">${m.ui.stepsCol}</th><th scope="col" class="num">${m.html.hitsCol}</th><th scope="col" class="num">${m.ui.problemsCol}</th></tr></thead><tbody>${body}</tbody></table></div></div>`;
1488
- }
1489
-
1490
- const SPARK_SERIES = ["broken", "risk", "warning"];
1491
-
1492
- /** Three count lines over the saved runs, evenly spaced by run; incomplete runs are hollow points. */
1493
- function sparklineSvg(m, t) {
1494
- const s = t.series;
1495
- const W = 280, H = 64, P = 6;
1496
- const max = Math.max(3, ...s.flatMap((p) => SPARK_SERIES.map((k) => p[k])));
1497
- const x = (i) => P + (i * (W - 2 * P)) / (s.length - 1);
1498
- const y = (v) => H - P - (v * (H - 2 * P)) / max;
1499
- const lines = SPARK_SERIES.map((k) => `<polyline class="sev-${k}" points="${s.map((p, i) => `${x(i).toFixed(1)},${y(p[k]).toFixed(1)}`).join(" ")}"/>`).join("");
1500
- const points = s.flatMap((p, i) => SPARK_SERIES.map((k) => `<circle class="sev-${k}${p.complete ? "" : " hollow"}" cx="${x(i).toFixed(1)}" cy="${y(p[k]).toFixed(1)}" r="${i === s.length - 1 ? 3.5 : 2.5}"><title>${escapeHtml(`${p.date ?? p.id} · ${p[k]} ${m.severity[k]}${p.complete ? "" : ` · ${m.html.runIncomplete}`}`)}</title></circle>`)).join("");
1501
- const label = SPARK_SERIES.map((k) => `${m.severity[k]}: ${s.map((p) => p[k]).join(", ")}`).join("; ");
1502
- return `<svg class="spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${escapeHtml(label)}">${lines}${points}</svg>`;
1503
- }
1504
-
1505
- const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
1506
- const changeItems = (m, ch) => [
1507
- ...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
1508
- ...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
1509
- ];
1510
- const routeLines = (ch) => (ch?.routes ?? []).map((r) => `<li>${escapeHtml(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}</li>`).join("");
1511
-
1512
- /** "Changed since last run" when there is no history: the headline, what was fixed and what is new. */
1513
- function changesCard(m, ch) {
1514
- const head = `<div class="card-h"><h3>${m.ui.changesTitle}</h3>${ch.previous ? `<p class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</p>` : ""}</div>`;
1515
- if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
1516
- const items = changeItems(m, ch);
1517
- return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${ch.routes?.length ? `<ul class="changes">${routeLines(ch)}</ul>` : ""}</div></div>`;
1518
- }
1519
-
1520
- /** One problem from the history as a line: the current card's title when it is still open, else platform, event and check. */
1521
- function historyLine(m, rows, ids, e) {
1522
- const f = rows.find((r) => problemKey(r) === problemKey(e));
1523
- const text = f ? detailHtml(f.detail) : `${e.platform ? `${escapeHtml(platformLabel(e.platform))} ` : ""}${e.event ? `<code>${escapeHtml(e.event)}</code> ` : ""}${escapeHtml(e.check)}`;
1524
- return f && ids.get(f) ? `<a href="#${ids.get(f)}">${text}</a>` : text;
1525
- }
1526
-
1527
- const dateOnly = (id) => String(id ?? "").slice(0, 10);
1528
-
1529
- /** The trend over the saved runs: a sparkline, the diff with the last run, flapping problems and the ones open longest. */
1530
- function trendCard(m, t, rows, ids, changes) {
1531
- const direction = `<p><strong>${escapeHtml(m.html.trendDirection[t.direction] ?? t.direction)}</strong> <span class="muted">${escapeHtml(m.html.trendSpan(t.runs, dateOnly(t.first), dateOnly(t.last)))}</span></p>`;
1532
- const diff = changes?.previous ? `<div id="changes"><p class="xs"><strong>${escapeHtml(m.ui.changesLine(changes))}</strong> <span class="muted">${escapeHtml(m.ui.changesLead(changes.previous, changes.mode))}</span></p>${changeItems(m, changes).length ? `<ul class="changes">${changeItems(m, changes).map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${changes.routes?.length ? `<ul class="changes">${routeLines(changes)}</ul>` : ""}</div>` : "";
1533
- const flapping = t.flapping.length ? `<h4 class="sec-h">${m.html.flapping}</h4><ul class="changes">${t.flapping.map((e) => `<li>${badge(e.severity, m.severity[e.severity])} ${historyLine(m, rows, ids, e)} <span class="muted">· ${escapeHtml(m.html.seenIn(e.seenIn, t.runs))}</span></li>`).join("")}</ul>` : "";
1534
- const open = t.openLongest.length ? `<h4 class="sec-h">${m.html.openLongest}</h4><ul class="changes">${t.openLongest.map((e) => `<li>${badge(e.severity, m.severity[e.severity])} ${historyLine(m, rows, ids, e)} <span class="muted">· ${escapeHtml(m.html.openSince(dateOnly(e.openSince), e.runsOpen, e.places.length))}</span></li>`).join("")}</ul>` : "";
1535
- const legend = `<p class="xs muted spark-legend">${SPARK_SERIES.map((k) => `<span class="sev-${k}"><i></i>${m.severity[k]}</span>`).join(" ")}</p>`;
1536
- return `<div class="card" id="trend"><div class="card-h"><h3>${m.html.trend}</h3><p class="card-d">${escapeHtml(m.html.trendLead)}</p></div><div class="card-c stack">${sparklineSvg(m, t)}${legend}${direction}${diff}${flapping}${open}</div></div>`;
1537
- }
1538
-
1539
- function scoreCard(m, metrics, rows, ids) {
1540
- const s = metrics.score;
1541
- if (s.score === null) return "";
1542
- const byKey = new Map(rows.map((f) => [problemKey(f), f]));
1543
- const items = s.deductions.slice(0, 5).map((d) => {
1544
- const f = byKey.get(problemKey(d));
1545
- const text = f ? detailHtml(f.detail) : `${escapeHtml(platformLabel(d.platform ?? ""))} <code>${escapeHtml(d.event ?? "")}</code> ${escapeHtml(d.check)}`;
1546
- return `<li><span class="num mono">−${d.points}</span><span>${f && ids.get(f) ? `<a href="#${ids.get(f)}">${text}</a>` : text} <span class="muted">· ${m.severity[d.severity]} · ${m.ui.owners[d.owner]}</span></span></li>`;
1547
- });
1548
- return `<div class="card" id="score"><div class="card-h"><h3>${m.html.score}</h3><p class="card-d">${escapeHtml(m.html.scoreLead)}</p></div><div class="card-c"><p class="score-v"><span class="v">${s.score}</span><span class="muted">/100</span>${s.lost ? ` <span class="muted xs">${escapeHtml(m.html.scoreLost(s.lost))}</span>` : ""}</p>${items.length ? `<ul class="deductions">${items.join("")}</ul>` : ""}</div></div>`;
1549
- }
1550
-
1551
- function overviewPanel(m, runs, rows, ids, metrics, trend, changes) {
1552
- const right = [trend && trend.series.length >= 2 ? trendCard(m, trend, rows, ids, changes) : changes ? changesCard(m, changes) : "", scoreCard(m, metrics, rows, ids)].filter(Boolean).join("");
1553
- return `<div class="ov-grid"><div class="stack">${fixFirst(m, runs, rows, ids, metrics)}${workingCard(m, runs, rows, metrics)}${journeysTable(m, runs, rows)}</div><div class="stack">${right}</div></div>`;
1554
- }
1555
-
1556
- /* ---------- journeys ---------- */
1557
-
1558
- const consentChip = (k, v) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)} ${escapeHtml(v)}</span>`;
1559
-
1560
- /** The journey's Consent Mode: a timing sentence, the first default and the first update, and how often they were re-sent. */
1561
- function consentCard(m, run, info) {
1562
- const cmds = (run.dataLayerLog ?? []).filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
1563
- const head = `<div class="card-h"><h4>${m.html.consentMode}</h4></div>`;
1564
- if (!cmds.length) return `<div class="card consent-card">${head}<div class="card-c"><p class="muted">${m.ui.consentNone}</p></div></div>`;
1565
- const firstDefault = cmds.find((e) => e.value[1] === "default");
1566
- const firstUpdate = cmds.find((e) => e.value[1] === "update" && (!firstDefault || e.t >= firstDefault.t));
1567
- const tile = (e) => {
1568
- const step = run.steps.find((s) => s.index === e.step);
1569
- const sigs = Object.entries(e.value[2] ?? {}).filter(([, v]) => typeof v === "string");
1570
- return `<li><div class="ch"><b>${e.value[1] === "default" ? m.ui.consentDefault : m.ui.consentUpdate}</b><span class="muted xs">${step ? `${m.ui.step} ${step.index} · ${escapeHtml(step.label)} · ` : ""}${escapeHtml(seconds(run, e.t))}</span></div><div class="sigs">${sigs.map(([k, v]) => `<span class="sig"><span>${escapeHtml(k)}</span><span class="${v === "granted" ? "granted" : "denied"}">${escapeHtml(v)}</span></span>`).join("")}</div></li>`;
1571
- };
1572
- const timing = !info ? "" : info.present ? m.html.consentTiming(info.ms, info.beforeFirstGoogleHit, info.v2Signals.present, info.v2Signals.missing) : m.html.consentNoDefault;
1573
- const resent = cmds.filter((e) => e !== firstDefault && e !== firstUpdate).length;
1574
- return `<div class="card consent-card">${head}<div class="card-c">${timing ? `<p class="xs">${escapeHtml(timing)}</p>` : ""}<ol class="ctl">${[firstDefault, firstUpdate].filter(Boolean).map(tile).join("")}</ol>${resent ? `<p class="muted xs">${escapeHtml(m.html.consentResent(resent))}</p>` : ""}</div></div>`;
1575
- }
1576
-
1577
- /** The reason a scenario cell counts as it does: cookieless pings, ad storage denied, or hits with cookies. */
1578
- function scenarioReason(m, scenarios, platform, event, state) {
1579
- const after = state === "before" ? scenarios.before : scenarios.states?.[state]?.after;
1580
- const c = after?.[platform]?.[event];
1581
- if (!c || !c.n) return "";
1582
- if (c.cookieless >= c.n) return m.html.cellCookieless;
1583
- if (c.adDenied >= c.n) return m.html.cellAdDenied;
1584
- return m.html.cellWithCookies;
1585
- }
1586
-
1587
- /** Consent scenarios: one row per tag, one column per consent choice, hit counts with a reason per cell. */
1588
- function scenariosCard(m, scenarios, rows, ids) {
1589
- if (!scenarios) return "";
1590
- const { cols, rows: tagRows } = consentTable(scenarios);
1591
- const findingFor = (platform, event) => rows.find((f) => f.platform === platform && f.event === event && /^(after_reject|before_consent)$/.test(f.check));
1592
- const head = `<tr><th scope="col">${m.ui.scenarioTag}</th>${cols.map((c) => `<th scope="col" class="num">${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
1593
- const body = tagRows.map((r) => `<tr><td>${plat(r.platform, 16)} <code>${escapeHtml(r.event)}</code></td>${r.cells.map((c) => {
1594
- if (c.count === null) return `<td class="num muted">–</td>`;
1595
- const reason = scenarioReason(m, scenarios, r.platform, r.event, c.state);
1596
- if (!c.problem) return `<td class="num">${c.count}${reason ? ` <span class="muted xs">${escapeHtml(reason)}</span>` : ""}</td>`;
1597
- const f = findingFor(r.platform, r.event);
1598
- return `<td class="num bad">${use("sev-broken", 12)} ${c.count}<span class="sr"> ${m.html.cellProblem}</span> <span class="xs">${escapeHtml(m.html.cellShouldNot)}${f && ids.get(f) ? ` <a href="#${ids.get(f)}">${m.html.findingLink}</a>` : ""}</span></td>`;
1599
- }).join("")}</tr>`).join("");
1600
- const notRun = cols.filter((c) => c !== "before" && !scenarios.states?.[c]?.via).map((c) => m.ui.scenarioCols[c]);
1601
- return `<div class="card scenarios"><div class="card-h"><h3>${m.ui.scenarios}</h3><p class="card-d">${escapeHtml(m.html.scenariosLead(scenarios.cmp === "banner" ? m.html.cmpBanner : scenarios.cmp))}${notRun.length ? ` ${escapeHtml(m.ui.scenarioNotRun)}: ${escapeHtml(notRun.join(", "))}.` : ""}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.ui.scenarios}</caption><thead>${head}</thead><tbody>${body}</tbody></table></div><div class="card-c"><p class="muted xs">${escapeHtml(m.html.scenariosLegend)}</p></div></div>`;
1602
- }
1603
-
1604
- const funnelStatus = (m, r) => (r.status === "ok" ? `<span class="y">${use("sev-ok", 12)} ${m.severity.ok}</span>` : r.status === "missing_params" ? `<span class="x">${use("sev-broken", 12)} ${escapeHtml(m.html.params(r.missingParams))}</span>` : `<span class="x">${use("sev-broken", 12)} ${m.html.notSent}</span>`);
1605
-
1606
- /** Expected versus observed for one run, in plan order. */
1607
- function funnelCard(m, fu) {
1608
- if (!fu?.events?.length) return "";
1609
- const rows = fu.events.map((r) => `<tr><td>${plat(r.platform, 16)} <code>${escapeHtml(r.event)}</code>${r.conversion ? ` <span class="badge">${m.html.conversion}</span>` : ""}</td><td>${funnelStatus(m, r)}</td><td class="num">${r.count}</td><td>${r.step !== null ? `${m.ui.step} ${r.step}${r.stepLabel ? ` · ${escapeHtml(r.stepLabel)}` : ""}` : "–"}</td><td class="xs">${r.page ? `<code>${escapeHtml(pathOf(r.page))}</code>` : "–"}</td></tr>`).join("");
1610
- return `<div class="card"><div class="card-h"><h4>${m.html.funnel}</h4><p class="card-d">${escapeHtml(m.html.funnelLead(fu.observed, fu.expected, fu.ok))}${fu.firstMissing ? ` ${escapeHtml(m.html.funnelBreaks(fu.firstMissing))}` : ""}</p></div><div class="tscroll"><table class="dt"><caption class="sr">${m.html.funnel}</caption><thead><tr><th scope="col">${m.ui.event}</th><th scope="col">${m.ui.status}</th><th scope="col" class="num">${m.html.sent}</th><th scope="col">${m.html.firstAt}</th><th scope="col">${m.html.page}</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
1611
- }
1612
-
1613
- /** The same event on two platforms for one run, with what differs, and the events only one platform sent. */
1614
- function parityCard(m, pa) {
1615
- if (!pa || (!pa.shared.length && !pa.onlyOn.length)) return "";
1616
- const money = (p) => (p.value !== null ? ` · ${p.value}${p.currency ? ` ${escapeHtml(p.currency)}` : ""}` : "");
1617
- const rows = pa.shared.map((s) => `<tr><td><code>${escapeHtml(s.event)}</code></td><td>${Object.entries(s.platforms).map(([k, p]) => `<span class="plat-v">${plat(k, 14)} <span class="mono">${p.count}×${money(p)}</span></span>`).join(" ")}</td><td>${s.mismatch.length ? `<span class="x">${use("sev-warning", 12)} ${escapeHtml(s.mismatch.map((x) => m.html.mismatch[x] ?? x).join(", "))}</span>` : `<span class="y">${use("sev-ok", 12)} ${m.html.agree}</span>`}</td></tr>`).join("");
1618
- const only = pa.onlyOn.length ? `<p class="muted xs">${escapeHtml(m.html.onlyOnPlatform)} ${sortPlatforms(new Set(pa.onlyOn.map((o) => o.platform))).map((k) => `${plat(k, 14)}: ${pa.onlyOn.filter((o) => o.platform === k).map((o) => `<code>${escapeHtml(o.name)}</code>`).join(", ")}`).join(" · ")}</p>` : "";
1619
- return `<div class="card print-skip"><div class="card-h"><h4>${m.html.parity}</h4><p class="card-d">${escapeHtml(m.html.parityLead)}</p></div>${rows ? `<div class="tscroll"><table class="dt"><caption class="sr">${m.html.parity}</caption><thead><tr><th scope="col">${m.ui.event}</th><th scope="col">${m.html.perPlatform}</th><th scope="col">${m.html.difference}</th></tr></thead><tbody>${rows}</tbody></table></div>` : ""}${only ? `<div class="card-c">${only}</div>` : ""}</div>`;
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
-
1684
- const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
1685
- const coverageLines = (m, pc) => [
1686
- m.ui.coverageLine(pc.coverage),
1687
- ...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
1688
- ];
1689
- const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
1690
- const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
1691
-
1692
- const planChangeItems = (pc) => [
1693
- ...pc.added.map((c) => `+ ${changeLabel(c)}`),
1694
- ...pc.removed.map((c) => `− ${changeLabel(c)}`),
1695
- ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
1696
- ];
1697
-
1698
450
 
1699
451
  function planVsSiteMd(m, pc) {
1700
452
  const [overall, ...perPlatform] = coverageLines(m, pc);
@@ -1705,239 +457,6 @@ function planVsSiteMd(m, pc) {
1705
457
  return lines;
1706
458
  }
1707
459
 
1708
- function planVsSiteCard(m, pc) {
1709
- const [overall, ...perPlatform] = coverageLines(m, pc);
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>`;
1712
- }
1713
-
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>`;
1718
- const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
1719
- const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
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>`;
1735
- }
1736
-
1737
- const CONVERSION_CHECKS = ["value", "currency", "transactionId", "items", "duplicate", "parity", "eventId"];
1738
-
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>`;
1746
- }
1747
-
1748
- const PAGES_SHOWN = 5;
1749
-
1750
- function inventoryCard(m, runs) {
1751
- const inv = buildInventory(runs);
1752
- const head = `<div class="card-h"><h3>${m.ui.inventoryTitle}</h3><p class="card-d">${m.ui.inventoryLead}</p></div>`;
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>`;
1754
- const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
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>` : ""}`;
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>`;
1759
- }
1760
-
1761
- /** Platforms checked (seen or never seen) and vendors out of scope, with their request counts. */
1762
- function providersCard(m, runs) {
1763
- const provs = checkedProviders(runs);
1764
- const skipped = skippedTotals(runs);
1765
- if (!provs.length && !skipped.length) return "";
1766
- const calls = runs.flatMap((r) => r.network ?? []);
1767
- const seen = new Set(runs.flatMap((r) => r.hits.map((h) => h.platform)));
1768
- const rows = [
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}`]),
1774
- ];
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>`;
1777
- }
1778
-
1779
- const ms = (v) => (v === null || v === undefined ? "–" : `${Math.round(v)} ms`);
1780
-
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>`;
1787
- }
1788
-
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>`;
1806
- }
1807
-
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>`;
1810
- }
1811
-
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 }) {
1892
- const m = messages(lang);
1893
- const rows = attachExplanations(findings, explained);
1894
- metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
1895
- const counts = summarize(rows);
1896
- const problems = rows.filter((f) => f.severity !== "ok").length;
1897
- const ids = cardIds(rows);
1898
- const runInfo = { ai, plan, lang, against, planChanges, consentScenarios };
1899
- const atd = `<a class="f-atd" href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="16">AI Tag Debugger</a>`;
1900
- // A custom name or logo takes the place of the Analitik İşler branding. The AI Tag Debugger logo and credit stay, smaller, as with branding false.
1901
- const whiteLabel = Boolean(brand?.logo || brand?.name);
1902
- if (whiteLabel) branding = false;
1903
- const brandName = brand?.name ? escapeHtml(brand.name) : "";
1904
- const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
1905
- const productLogo = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd");
1906
- const atdByline = whiteLabel ? `<a href="https://github.com/analitikisler/ai-tag-debugger" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 18, "logo-atd")}</a>` : "";
1907
- const footer = branding
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>
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>
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>`;
1924
- return `<!doctype html>
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">
1928
- <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
1929
- <style>${CSS}${brandCss(brand?.color)}</style></head><body>
1930
- <a class="skip" href="#main">${m.html.skip}</a>
1931
- ${sprite(`${topbar}${body}`)}
1932
- ${topbar}
1933
- <main id="main">
1934
- ${body}
1935
- </main>
1936
- <footer class="footer">${footer}</footer>
1937
- <script>${SCRIPT}</script>
1938
- </body></html>`;
1939
- }
1940
-
1941
460
  const csvCell = (v) => {
1942
461
  let s = v === null || v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
1943
462
  // Text from the page could run as a formula in Excel or Sheets; a leading ' keeps it text.