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