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