@analitikisler/ai-tag-debugger 0.16.0 → 0.18.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.
@@ -0,0 +1,493 @@
1
+ // The Findings section: a filter bar over three groups. Failed rows (broken, risk, warning) are collapsed cards with
2
+ // Evidence / Fix / Where / Verify tabs; Unverified (info) and Passed rows are single lines that link to their proof.
3
+ // Filtering, tabs, groups and cards are the shell script's generic behaviors, driven by attributes; this module only
4
+ // writes markup. The evidence, GTM, verify and "fine on" data are read the way the 0.17 finding card read them.
5
+ import { verifyLinks } from "../verify.js";
6
+ import { consentTable } from "../consent.js";
7
+ import { changeLabel, fixSteps, platformRank, summaryParts } from "../report-shared.js";
8
+ import {
9
+ PLATFORM_ART, OWNERS, alsoFineOn, badge, chip, detailHtml, esc, fmt, hitAnchor, hitFor, hitsOf, hostPath, missingParamRows, ownerOf,
10
+ passedRows, pathOf, platformLabel, pushesOf, safeKey, secs, sectionBlock, sortPlatforms, stepTitle, use, whereTarget,
11
+ } from "./lib.js";
12
+
13
+ const SEVERITIES = ["broken", "risk", "warning", "info", "ok"];
14
+ const FAILED = new Set(["broken", "risk", "warning"]);
15
+ const VIEWPORTS = ["desktop", "mobile"];
16
+
17
+ /* ---------- small pieces ---------- */
18
+
19
+ const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ""))];
20
+ const placeKey = (w) => `${w.journey}\u0000${w.viewport}`;
21
+ /** The distinct runs (journey and viewport) a list of places covers, in first-seen order. */
22
+ const placesOf = (where) => uniq(where.map(placeKey)).map((k) => {
23
+ const [journey, viewport] = k.split("\u0000");
24
+ return { journey, viewport };
25
+ });
26
+ const placeText = (p) => `${p.journey} · ${p.viewport}`;
27
+ // Titles quote event and parameter names, as the checks' detail sentences do; on the page they become <code>.
28
+ const unquote = (s) => String(s ?? "").replace(/"/g, "");
29
+
30
+ /** A platform's artwork for a chip, or its initials when it has none. The chip's text names it, so no sr label here. */
31
+ function platArt(key) {
32
+ const k = safeKey(key);
33
+ if (PLATFORM_ART[k]) return use(`p-${k}`, 13);
34
+ const initials = platformLabel(key).split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
35
+ return `<span class="plat-fb s14" aria-hidden="true">${esc(initials)}</span>`;
36
+ }
37
+ const platChip = (p) => chip("plat", p, `${platArt(p)}${esc(platformLabel(p))}`);
38
+ const ownerChip = (m, o) => chip("owner", o, `<span class="dot"></span>${esc(m.ui.owners[o])}`);
39
+ const vpIcon = (vp) => use(vp === "mobile" ? "ui-mobile" : "ui-desktop", 13);
40
+ /** A run as a chip: journey hue and viewport icon. With an href it is a link to that place. */
41
+ function runChip(p, href) {
42
+ const inner = `${vpIcon(p.viewport)}${esc(placeText(p))}`;
43
+ return href ? `<a class="chip chip-vp ${safeKey(p.viewport)}" href="${href}">${inner}</a>` : chip("vp", p.viewport, inner);
44
+ }
45
+
46
+ /** The where cell of a row: one run as its chip, every run as "all n runs", one journey on both viewports as one chip
47
+ * with both icons, anything else as a count. The list of runs is in the tooltip. */
48
+ function whereCell(ctx, places) {
49
+ if (!places.length) return "";
50
+ if (places.length === 1) return runChip(places[0]);
51
+ const list = esc(places.map(placeText).join(", "));
52
+ if (ctx.runs.length > 1 && places.length === ctx.runs.length) return `<span class="chip plain" title="${list}">${esc(ctx.m.html.allRuns(places.length))}</span>`;
53
+ const journeys = uniq(places.map((p) => p.journey));
54
+ if (journeys.length === 1) return `<span class="chip plain run-multi" title="${list}">${places.map((p) => `<span class="vp-${safeKey(p.viewport)}">${vpIcon(p.viewport)}</span>`).join("")}${esc(journeys[0])}</span>`;
55
+ return `<span class="chip plain" title="${list}">${esc(ctx.m.html.findings.runs(places.length))}</span>`;
56
+ }
57
+
58
+ /** The filter attributes of a row: space-separated tokens per key (safeKey, so a token never holds a space) and the
59
+ * lowercased search text. Accepted rows say so, so a count can leave them out as the rail's does. */
60
+ function attrs({ sev, owner, plats, places, text, accepted }) {
61
+ const tokens = (xs) => esc(uniq(uniq(xs).map(safeKey)).join(" "));
62
+ return ` data-sev="${tokens([sev])}" data-owner="${tokens(owner ? [owner] : [])}" data-plat="${tokens(plats)}" data-journey="${tokens(places.map((p) => p.journey))}" data-vp="${tokens(places.map((p) => p.viewport))}" data-text="${esc(String(text).toLowerCase().replace(/\s+/g, " ").trim())}"${accepted ? " data-accepted" : ""}`;
63
+ }
64
+
65
+ /* ---------- reading the capture ---------- */
66
+
67
+ // Pixel event names and their GA4 / dataLayer counterparts, so a Meta "Purchase" finds the "purchase" push (as the
68
+ // 0.17 step rows paired them; Journeys has the same table).
69
+ const EVENT_ALIASES = {
70
+ pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
71
+ addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
72
+ completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
73
+ viewbasket: "viewcart", tracktransaction: "purchase",
74
+ };
75
+ const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
76
+ const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
77
+ // GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
78
+ const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
79
+ const pushText = (value) => (Array.isArray(value) && typeof value[0] === "string" ? `gtag(${value.map((v) => JSON.stringify(v, null, 2)).join(", ")})` : JSON.stringify(value, null, 2));
80
+ const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
81
+
82
+ /** The parameters a missing_params row lacks: the grouped row drops them, the raw findings keep them. */
83
+ const paramsOf = (ctx, f) => ctx.findings.find((x) => x.severity === f.severity && x.check === f.check && x.platform === f.platform && x.event === f.event && x.detail === f.detail && x.params)?.params ?? null;
84
+
85
+ /** The first place of a row that names a step, resolved to its run and step. */
86
+ function firstStep(runs, f) {
87
+ for (const w of f.where) {
88
+ const t = w.step ? whereTarget(runs, w, f) : null;
89
+ if (t?.step) return { run: runs[t.ri], ri: t.ri, step: t.step, w };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** How many separate sends of the row's event its first step holds; hits that repeat an event ID count once, as the check counts them. */
95
+ function sendsOf(runs, f) {
96
+ const at = firstStep(runs, f);
97
+ if (!at) return 0;
98
+ const hits = hitsOf(at.run, at.step.index).filter((h) => h.platform === f.platform && h.name === f.event);
99
+ return new Set(hits.map((h, i) => (h.eventId ? `id:${h.eventId}` : `n:${i}`))).size;
100
+ }
101
+
102
+ /** The step the row's journeys stopped at, when they all stopped at the same one; null otherwise. */
103
+ function stoppedStep(runs, f) {
104
+ const last = uniq(placesOf(f.where).map((p) => runs.find((r) => r.journeyId === p.journey && r.viewport === p.viewport)?.steps?.at(-1)?.index));
105
+ return last.length === 1 && last[0] > 0 ? last[0] : null;
106
+ }
107
+
108
+ const firstSentence = (s) => String(s ?? "").match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(s ?? "");
109
+
110
+ /** A row's short title as plain text with quoted names: the i18n title of its check when the capture gives what it
111
+ * needs, else the first sentence of the check's own detail. */
112
+ function titleOf(ctx, f) {
113
+ const T = ctx.m.html.findings.title;
114
+ if (f.check === "missing_params" && f.event) {
115
+ const params = paramsOf(ctx, f);
116
+ if (params?.length) return T.missing_params(f.event, params);
117
+ } else if (f.check === "duplicate" && f.event) {
118
+ const sends = sendsOf(ctx.runs, f);
119
+ if (sends > 1) return T.duplicate(f.event, sends);
120
+ } else if (["before_consent", "after_reject", "missing_event"].includes(f.check) && f.event) return T[f.check](f.event);
121
+ else if (f.check === "consent_default_missing") return T.consent_default_missing();
122
+ else if (f.check === "journey_incomplete") return T.journey_incomplete(stoppedStep(ctx.runs, f));
123
+ return firstSentence(f.detail);
124
+ }
125
+
126
+ /** The check's own consequence or fix text from i18n; a function gets the row. */
127
+ const checkText = (block, f) => {
128
+ const v = block?.[f.check];
129
+ return typeof v === "function" ? v(f) : typeof v === "string" ? v : "";
130
+ };
131
+
132
+ /* ---------- the Evidence tab ---------- */
133
+
134
+ const kvTable = (label, rows) => (rows.length ? `<table class="fnd-kv" aria-label="${esc(label)}"><tbody>${rows.map(([k, v]) => `<tr><th scope="row">${esc(k)}</th><td>${v}</td></tr>`).join("")}</tbody></table>` : "");
135
+ const missingChip = (m) => chip("sev", "broken", esc(m.ui.missing));
136
+ const consentChips = (consent) => Object.entries(consent).map(([k, v]) => chip("consent", v === "granted" ? "granted" : "denied", `${esc(k)} ${esc(v)}`)).join("");
137
+
138
+ /** The captured parameters, the hit the row points at or the request that never went, as the 0.17 card read them. */
139
+ function evidenceRows(ctx, f) {
140
+ const { m, runs, plan } = ctx;
141
+ const t = m.html.findings;
142
+ let captured = null;
143
+ if (f.evidence) {
144
+ try {
145
+ const v = JSON.parse(f.evidence);
146
+ if (v && typeof v === "object" && !Array.isArray(v)) captured = Object.entries(v).map(([k, x]) => [k, esc(cell(x))]);
147
+ } catch { /* plain text */ }
148
+ if (!captured) return { pre: `<pre>${esc(f.evidence)}</pre>`, rows: [] };
149
+ }
150
+ // An empty capture ({}: a pixel with no parameters) says nothing; the hit itself does.
151
+ const rows = captured?.length ? captured : [];
152
+ if (!rows.length) {
153
+ const found = hitFor(runs, f);
154
+ if (found) {
155
+ rows.push(...Object.entries(found.hit.params ?? {}).map(([k, v]) => [k, esc(cell(v))]));
156
+ rows.push([m.ui.request, `<span class="mono">${esc(hostPath(found.hit.url))}</span> · ${esc(secs(found.run, found.hit.t))}`]);
157
+ if (found.hit.consent) rows.push([m.ui.consent, consentChips(found.hit.consent)]);
158
+ else if (f.check === "before_consent" || f.check === "after_reject") rows.push([m.ui.consent, chip("consent", "none", esc(t.noSignal))]);
159
+ } else if (f.check === "missing_event" && f.platform) rows.push([m.ui.requestTo(platformLabel(f.platform)), missingChip(m)]);
160
+ }
161
+ for (const p of missingParamRows(plan, f, Object.fromEntries(rows))) rows.push([p, missingChip(m)]);
162
+ return { pre: "", rows };
163
+ }
164
+
165
+ /** Rows only some checks have: sends per platform for a duplicate, the Reject all scenario for a consent problem, where
166
+ * a journey stopped, how many runs pushed a Consent Mode default. */
167
+ function checkRows(ctx, f) {
168
+ const { m, runs } = ctx;
169
+ const t = m.html.findings;
170
+ if (f.check === "duplicate" && f.event) {
171
+ const at = firstStep(runs, f);
172
+ if (!at) return [];
173
+ const per = new Map();
174
+ for (const h of hitsOf(at.run, at.step.index)) if (eventKey(h.name) === eventKey(f.event)) per.set(h.platform, (per.get(h.platform) ?? 0) + 1);
175
+ return [...per].sort(([a], [b]) => platformRank(a) - platformRank(b)).map(([p, k]) => [platformLabel(p), esc(t.perAction(k))]);
176
+ }
177
+ if ((f.check === "before_consent" || f.check === "after_reject") && ctx.consentScenarios && f.platform && f.event) {
178
+ const row = consentTable(ctx.consentScenarios).rows.find((r) => r.platform === f.platform && r.event === f.event);
179
+ const reject = row?.cells.find((c) => c.state === "reject");
180
+ if (!reject || reject.count === null) return [];
181
+ const state = reject.problem ? chip("sev", "risk", `${use("sev-risk", 13)}${esc(t.stillSent)}`) : chip("sev", "ok", `${use("sev-ok", 13)}${esc(t.stopped)}`);
182
+ return [[m.ui.scenarioCols.reject, `${state} <a href="#consent-scenarios">${esc(m.ui.scenarios)}</a>`]];
183
+ }
184
+ if (f.check === "journey_incomplete") {
185
+ const p = placesOf(f.where)[0];
186
+ const run = p && runs.find((r) => r.journeyId === p.journey && r.viewport === p.viewport);
187
+ const last = run?.steps?.at(-1);
188
+ if (!last || last.index < 1) return [];
189
+ const rows = [[t.stoppedAt, `${esc(m.ui.md.step(last.index))} · ${esc(stepTitle(run, last))}`]];
190
+ const error = last.error ?? null;
191
+ if (error) rows.push([t.error, `<code>${esc(error)}</code>`]);
192
+ return rows;
193
+ }
194
+ if (f.check === "consent_default_missing" && runs.length) {
195
+ const present = (ctx.metrics.consent?.consentMode?.runs ?? []).filter((r) => r.present).length;
196
+ return [[m.html.consentMode, esc(t.defaultIn(present, runs.length))]];
197
+ }
198
+ return [];
199
+ }
200
+
201
+ /** The runs where the event was fine, as OK chips linking to them; for missing parameters, with the value they carried. */
202
+ function fineChips(ctx, f) {
203
+ const { m, runs } = ctx;
204
+ const fine = alsoFineOn(runs, f);
205
+ if (!fine.length) return "";
206
+ return `<p class="fnd-fine">${fine.map((r) => {
207
+ const hit = f.check === "missing_params" ? r.hits.find((h) => h.platform === f.platform && h.name === f.event) : null;
208
+ const amount = hit?.params?.value !== undefined && hit?.params?.currency ? `${cell(hit.params.value)} ${cell(hit.params.currency)}` : "";
209
+ return `<a class="chip chip-sev ok" href="#j${runs.indexOf(r)}">${use("sev-ok", 13)}${esc(m.html.findings.fineOn(`${r.journeyId} · ${r.viewport}`, amount))}</a>`;
210
+ }).join("")}</p>`;
211
+ }
212
+
213
+ /** For missing parameters: whether the dataLayer push behind the hit already lacked them, and the push itself. */
214
+ function pushLine(ctx, f) {
215
+ const at = firstStep(ctx.runs, f);
216
+ const params = paramsOf(ctx, f);
217
+ if (!at || !params?.length) return "";
218
+ const push = pushesOf(at.run, at.step.index).find((d) => pushEvent(d.value) !== undefined && eventKey(pushEvent(d.value)) === eventKey(f.event));
219
+ if (!push) return "";
220
+ const v = push.value;
221
+ const fields = Array.isArray(v) ? [v[2] ?? {}, v[2]?.ecommerce ?? {}] : [v, v.ecommerce ?? {}];
222
+ const lacks = params.every((p) => fields.every((o) => !o || typeof o !== "object" || o[p] === undefined));
223
+ const t = ctx.m.html.findings;
224
+ return `<p class="fnd-line">${lacks ? detailHtml(t.pushLacks(params)) : esc(t.pushCarries)}</p><details class="fnd-raw"><summary>${esc(t.pushRaw)}</summary><pre>${esc(pushText(v))}</pre></details>`;
225
+ }
226
+
227
+ /** The tags in the GTM container export that send this event, with their triggers and why each did or did not fire. */
228
+ function gtmHtml(m, tags) {
229
+ if (!tags) return "";
230
+ const head = `<h4 class="fnd-h">${use("ui-gtm", 14)}${esc(m.ui.gtmTags)}</h4>`;
231
+ if (!tags.length) return `${head}<p class="muted">${esc(m.ui.gtmNoTag)}</p>`;
232
+ const trig = (t) => `${esc(t.name)}${t.event ? ` <span class="muted">(${esc(t.event)})</span>` : ""}`;
233
+ const rows = tags.map((t) => {
234
+ const notes = [t.paused ? m.ui.gtmPaused : "", t.consent?.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
235
+ const why = t.evaluation ? `<div class="xs gtm-why">${esc(m.ui.gtmWhy(t.name, m.ui.gtmVerdict[t.evaluation.fired], t.evaluation.why))}</div>` : "";
236
+ const fires = t.triggers?.length ? `${esc(m.ui.gtmFiresOn)} ${t.triggers.map(trig).join(", ")}` : chip("sev", "broken", esc(m.ui.gtmNoTrigger));
237
+ return `<tr><th scope="row">${esc(t.name)}${notes.length ? `<div class="muted xs">${esc(notes.join(" · "))}</div>` : ""}</th><td>${fires}${t.blocking?.length ? `<div class="muted xs">${esc(m.ui.gtmBlockedBy)} ${t.blocking.map(trig).join(", ")}</div>` : ""}${why}</td></tr>`;
238
+ });
239
+ return `${head}<table class="fnd-kv gtm-tags" aria-label="${esc(m.ui.gtmTags)}"><tbody>${rows.join("")}</tbody></table>`;
240
+ }
241
+
242
+ function evidenceTab(ctx, f, title) {
243
+ const { m } = ctx;
244
+ const e = f.explanation;
245
+ const impact = e?.impact ? fmt(e.impact) : esc(checkText(m.consequence, f));
246
+ // The detail is the check's full sentence; it says more than a short title, except for missing parameters.
247
+ const detail = f.detail && f.check !== "missing_params" && unquote(f.detail) !== unquote(title) ? `<p class="fnd-detail">${detailHtml(f.detail)}</p>` : "";
248
+ const { pre, rows } = evidenceRows(ctx, f);
249
+ const table = kvTable(m.ui.evidence, [...checkRows(ctx, f), ...rows]);
250
+ return `${impact ? `<p class="fnd-impact">${use("ui-impact", 14)}<span>${impact}</span></p>` : ""}${detail}${pre}${table}${fineChips(ctx, f)}${f.check === "missing_params" ? pushLine(ctx, f) : ""}${gtmHtml(m, f.gtm)}`;
251
+ }
252
+
253
+ /* ---------- the Fix, Where and Verify tabs ---------- */
254
+
255
+ function fixTab(ctx, f, owner) {
256
+ const { m } = ctx;
257
+ const t = m.html.findings;
258
+ const text = checkText(m.fix, f);
259
+ const e = f.explanation;
260
+ const steps = fixSteps(e);
261
+ const cause = e?.likely_cause ? `<p><span class="muted">${esc(m.ui.likelyCause)}:</span> ${fmt(e.likely_cause)}</p>` : "";
262
+ const ai = steps.length || cause
263
+ ? `<div class="fnd-ai"><p class="fnd-ai-h">${use("ui-ai", 14)}${esc(t.ai)}</p>${cause}${steps.length ? `<ol class="fix-steps">${steps.map((s) => `<li><span>${fmt(s)}</span></li>`).join("")}</ol>` : ""}</div>`
264
+ : `<p class="fnd-ai muted">${esc(t.noModel)}</p>`;
265
+ return `<p class="fnd-fix">${text ? `<span>${esc(text)}</span>` : ""}${ownerChip(m, owner)}</p>${ai}`;
266
+ }
267
+
268
+ /** A replay step as one line of the recipe: the action and its target, as the journey recorded it. */
269
+ function recipeLine(r) {
270
+ if (!r?.action) return null;
271
+ const target = r.action === "goto" ? pathOf(r.url) : r.selector ?? "";
272
+ return [r.action, target, r.value !== undefined && r.value !== null ? JSON.stringify(String(r.value)) : ""].filter(Boolean).join(" ");
273
+ }
274
+
275
+ function whereTab(ctx, f) {
276
+ const { m, runs } = ctx;
277
+ const seen = new Set();
278
+ const items = f.where.filter((w) => {
279
+ const k = `${placeKey(w)}\u0000${w.step ?? ""}`;
280
+ if (seen.has(k)) return false;
281
+ seen.add(k);
282
+ return true;
283
+ }).map((w) => {
284
+ const a = hitAnchor(runs, f, w);
285
+ const step = a?.step;
286
+ const page = step?.pagePath ? `<code>${esc(pathOf(step.pagePath))}</code>` : esc(m.html.pageUnknown);
287
+ const where = step ? `<span class="fnd-step"><span class="muted">${esc(m.ui.md.step(step.index))} ·</span> ${esc(stepTitle(runs[a.ri], step))} <span class="muted">· ${page}</span></span>` : "";
288
+ return `<li>${runChip(w, a ? `#${a.id}` : null)}${where}</li>`;
289
+ });
290
+ const at = firstStep(runs, f);
291
+ let recipe = "";
292
+ if (at) {
293
+ const lines = at.run.steps.filter((s) => s.index >= 1 && s.index <= at.step.index).map((s) => recipeLine(s.replay)).filter(Boolean);
294
+ if (lines.length) recipe = `<h4 class="fnd-h">${esc(m.html.findings.replay)}${placesOf(f.where).length > 1 ? ` ${runChip(at.w)}` : ""}</h4><ol class="fnd-recipe">${lines.map((l) => `<li><code>${esc(l)}</code></li>`).join("")}</ol>`;
295
+ }
296
+ return `<ul class="fnd-where">${items.join("")}</ul>${recipe}`;
297
+ }
298
+
299
+ const externalLink = (m, url, name) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${esc(name)}${use("ui-external", 12)}<span class="sr"> (${esc(m.html.newTab)})</span></a>`;
300
+
301
+ /* ---------- rows ---------- */
302
+
303
+ /** A Failed row: a collapsed card whose summary is one line and whose body holds the four tabs. */
304
+ function failedRow(ctx, f) {
305
+ const { m, runs } = ctx;
306
+ const id = ctx.ids.get(f);
307
+ const owner = ownerOf(f);
308
+ const places = placesOf(f.where);
309
+ const title = titleOf(ctx, f);
310
+ const links = verifyLinks(f).slice(0, 3);
311
+ const tabs = [
312
+ ["ev", m.ui.evidence, "", evidenceTab(ctx, f, title)],
313
+ ["fix", m.ui.fixSteps, "", fixTab(ctx, f, owner)],
314
+ ["where", m.ui.where, places.length, whereTab(ctx, f)],
315
+ ...(links.length ? [["verify", m.ui.verify, "", `<ul class="fnd-links">${links.map((l) => `<li>${externalLink(m, l.url, l.name)}</li>`).join("")}</ul>`]] : []),
316
+ ];
317
+ const steps = f.where.map((w) => {
318
+ const t = whereTarget(runs, w, f);
319
+ return t?.step ? stepTitle(runs[t.ri], t.step) : "";
320
+ });
321
+ const text = [unquote(title), platformLabel(f.platform ?? ""), f.event, ...(paramsOf(ctx, f) ?? []), unquote(f.detail), ...places.map(placeText), ...steps, m.ui.owners[owner]].filter(Boolean).join(" ");
322
+ const accepted = f.accepted === true;
323
+ const note = accepted ? `<p class="note xs"><span class="badge">${use("sev-ok", 14)}${esc(m.html.accepted)}</span> ${esc(m.html.acceptedNote)}</p>` : "";
324
+ return `<details class="card sev-${f.severity}" id="${id}" data-print-open${attrs({ sev: f.severity, owner, plats: [f.platform], places, text, accepted })}>
325
+ <summary>${badge(f.severity, m.severity[f.severity])}<span class="f-t">${detailHtml(title)}${accepted ? ` ${badge("", m.html.accepted)}` : ""}</span><span class="c-owner">${ownerChip(m, owner)}</span><span class="c-plat">${f.platform ? platChip(f.platform) : ""}</span><span class="c-where">${whereCell(ctx, places)}</span></summary>
326
+ <div class="body">${note}<div class="tabs" data-tabs><div role="tablist" aria-label="${esc(m.html.findings.details(unquote(title)))}">${tabs.map(([k, label, count], i) => `<button type="button" role="tab" aria-controls="${id}-${k}" aria-selected="${i === 0}">${esc(label)}${count ? ` <span class="n">${count}</span>` : ""}</button>`).join("")}</div>
327
+ ${tabs.map(([k, label, , body]) => `<div role="tabpanel" id="${id}-${k}" data-label="${esc(label)}">${body}</div>`).join("\n")}</div></div></details>`;
328
+ }
329
+
330
+ /** A single-line row (Unverified or Passed): glyph, line, platform and run chips, and a link to what proves it. */
331
+ function lineRow(ctx, { id, sev, titleHtml, text, owner = "", plats, places, go, accepted = false }) {
332
+ const { m } = ctx;
333
+ const link = go ? `<a class="go" href="#${go}" aria-label="${esc(m.html.findings.showWhere)}">${use("ui-chevron", 16)}</a>` : "";
334
+ return `<div class="card line sev-${sev}" id="${id}"${attrs({ sev, owner, plats, places, text, accepted })}>${badge(sev, m.severity[sev])}<span class="f-t">${titleHtml}${accepted ? ` ${badge("", m.html.accepted)}` : ""}</span><span class="c-owner"></span><span class="c-plat">${sortPlatforms(uniq(plats)).map(platChip).join("")}</span><span class="c-where">${whereCell(ctx, places)}</span>${link}</div>`;
335
+ }
336
+
337
+ /** An info row: an event the journey never reached reads "`event` · not reached" and links to its coverage row. */
338
+ function unverifiedRow(ctx, f) {
339
+ const { m } = ctx;
340
+ const places = placesOf(f.where);
341
+ const early = f.check === "missing_event" && f.event;
342
+ const title = early ? `"${f.event}"` : firstSentence(f.detail);
343
+ const titleHtml = early ? `${detailHtml(title)} <span class="muted">· ${esc(m.html.findings.notReached)}</span>` : detailHtml(title);
344
+ const text = [unquote(title), early ? m.html.findings.notReached : "", platformLabel(f.platform ?? ""), unquote(f.detail), ...places.map(placeText)].filter(Boolean).join(" ");
345
+ // Coverage rows exist only when a plan was given.
346
+ const go = early && f.platform && ctx.plan ? `cov-${safeKey(f.platform)}-${safeKey(f.event)}` : null;
347
+ return lineRow(ctx, { id: ctx.ids.get(f), sev: "info", titleHtml, text, owner: ownerOf(f), plats: [f.platform], places, go, accepted: f.accepted === true });
348
+ }
349
+
350
+ /** The Passed rows, one per entry of passedRows (the count the Summary's OK chip and the rail show), with their filter tokens. */
351
+ function passedLines(ctx) {
352
+ const { m, runs } = ctx;
353
+ const t = m.html.findings.passed;
354
+ const allRuns = runs.map((r) => ({ journey: r.journeyId, viewport: r.viewport }));
355
+ return passedRows(ctx).map((p, i) => {
356
+ let title = "";
357
+ let plats = [];
358
+ let places = [];
359
+ let go = null;
360
+ if (p.kind === "campaign_params") {
361
+ title = t.campaign;
362
+ places = placesOf(p.row.where);
363
+ const w = p.row.where.find((x) => x.step);
364
+ go = w ? hitAnchor(runs, p.row, w)?.id ?? null : null;
365
+ } else if (p.kind === "coverage") {
366
+ const c = p.coverage;
367
+ title = t.coverage(c.counts.implemented, c.planned, c.percent?.implemented ?? null);
368
+ plats = Object.keys(c.platforms ?? {});
369
+ places = placesOf(allRuns);
370
+ go = "coverage";
371
+ } else if (p.kind === "consent_default") {
372
+ title = t.consentDefault(p.consentMode.defaultOnTime === true);
373
+ plats = runs.some((r) => (r.hits ?? []).some((h) => h.platform === "ga4")) ? ["ga4"] : [];
374
+ places = placesOf(p.consentMode.runs.filter((r) => r.present));
375
+ go = "consent-mode";
376
+ } else if (p.kind === "conversion") {
377
+ const c = p.conv;
378
+ title = t.conversion(c.event, `${cell(c.value)} ${cell(c.currency)}`.trim());
379
+ plats = [c.platform];
380
+ places = [{ journey: c.journey, viewport: c.viewport }];
381
+ go = hitAnchor(runs, { platform: c.platform, event: c.event }, { journey: c.journey, viewport: c.viewport, step: c.stepLabel })?.id ?? null;
382
+ }
383
+ const text = [unquote(title), ...plats.map(platformLabel), ...places.map(placeText), p.kind === "conversion" ? cell(p.conv.transactionId) : ""].filter(Boolean).join(" ");
384
+ return { html: lineRow(ctx, { id: `f-ok-${i + 1}`, sev: "ok", titleHtml: detailHtml(title), text, plats, places, go }), tokens: rowTokens("ok", "", plats, places) };
385
+ });
386
+ }
387
+
388
+ /* ---------- filter bar ---------- */
389
+
390
+ /** One row's tokens for the static chip counts: what the chip alone would show, as the script filters. */
391
+ const rowTokens = (sev, owner, plats, places, accepted = false) => ({ sev: [sev], owner: owner ? [owner] : [], plat: uniq(plats).map(safeKey), journey: uniq(places.map((p) => safeKey(p.journey))), vp: uniq(places.map((p) => safeKey(p.viewport))), accepted });
392
+
393
+ function filterBar(ctx, items) {
394
+ const { m, runs } = ctx;
395
+ const t = m.html.findings;
396
+ // Accepted rows are listed but not counted, as the tally behind Summary, the rail and the group headings; a chip
397
+ // that only accepted rows match still filters to them, so it stays enabled with its 0.
398
+ const counted = items.filter((it) => !it.accepted);
399
+ const count = (key, val) => counted.filter((it) => it[key].includes(val)).length;
400
+ const any = (key, val) => items.some((it) => it[key].includes(val));
401
+ const button = (key, val, label, cls, zeroShown = false) => {
402
+ const c = count(key, val);
403
+ const off = !any(key, val);
404
+ if (off && !zeroShown) return "";
405
+ return `<button type="button" data-filter="${key}=${esc(val)}" aria-pressed="false" class="${cls}${off ? " zero" : ""}"${off ? " disabled" : ""}>${label}<span class="n">${c}</span></button>`;
406
+ };
407
+ const sev = SEVERITIES.map((s) => button("sev", s, `<span class="dot"></span>${esc(m.severity[s])}`, `chip-sev ${s}`, true)).join("");
408
+ const owner = OWNERS.map((o) => button("owner", o, `<span class="dot"></span>${esc(m.ui.owners[o])}`, `chip-owner ${o}`)).join("");
409
+ const plats = sortPlatforms(uniq(items.flatMap((it) => it.plat))).map((p) => button("plat", p, `${platArt(p)}${esc(platformLabel(p))}`, `chip-plat ${p}`)).join("");
410
+ // Journeys in capture order, then any a finding names that no run has.
411
+ const journeyNames = uniq([...runs.map((r) => r.journeyId), ...ctx.rows.flatMap((f) => f.where.map((w) => w.journey))]);
412
+ const journeys = uniq(journeyNames.map(safeKey)).map((k) => button("journey", k, esc(journeyNames.find((j) => safeKey(j) === k)), "chip-journey")).join("");
413
+ const vpKeys = uniq([...VIEWPORTS, ...items.flatMap((it) => it.vp)]);
414
+ const vps = vpKeys.map((v) => button("vp", v, `${vpIcon(v)}${esc(v)}`, `chip-vp ${v}`)).join("");
415
+ const group = (label, html) => (html ? `<span class="fg"><span class="fl">${esc(label)}</span>${html}</span>` : "");
416
+ return `<div class="filterbar" data-filterbar="#f-list" role="group" aria-label="${esc(t.filterLabel)}">
417
+ <div class="fb-groups" id="fb-groups">${group(t.severity, sev)}${group(m.ui.md.owner, owner)}<div class="fb-rest">${group(m.ui.platform, plats)}${group(m.ui.journey, journeys)}${group(m.html.viewport, vps)}</div></div>
418
+ <label class="fb-search">${use("ui-search", 14)}<input type="search" data-filter-search placeholder="${esc(t.search)}" aria-label="${esc(t.searchLabel)}"></label>
419
+ <button type="button" class="fb-clear" data-filter-clear>${esc(m.html.shell.clear)}</button>
420
+ <input type="checkbox" class="fb-fold" aria-label="${esc(t.filters)}" data-label="${esc(t.filters)}" aria-controls="fb-groups">
421
+ <p data-filter-count aria-live="polite">${esc(m.html.shell.filterCount(counted.length, counted.length))}</p></div>`;
422
+ }
423
+
424
+ /* ---------- changed since last run ---------- */
425
+
426
+ const problemKey = (f) => [f.check, f.platform ?? "", f.event ?? ""].join("|");
427
+
428
+ /** "Changed since last run": what was fixed and what is new against the previous saved run, and route changes. */
429
+ function changesCard(ctx) {
430
+ const { m, changes: ch } = ctx;
431
+ if (!ch) return "";
432
+ const line = ch.previous ? m.ui.changesLine(ch) : "";
433
+ const summary = `<summary><h3 class="t">${esc(m.ui.changesTitle)}</h3>${line ? `<span class="end muted">${esc(line)}</span>` : ""}</summary>`;
434
+ if (!ch.previous) return `<details class="card fnd-changes" id="changes">${summary}<div class="body"><p class="muted">${esc(m.ui.noPreviousRun)}</p></div></details>`;
435
+ const open = new Map(ctx.rows.filter((f) => FAILED.has(f.severity) || f.severity === "info").map((f) => [problemKey(f), f]));
436
+ const item = (sev, tag, c, was) => {
437
+ const text = esc(`${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[was]})`);
438
+ const row = sev === "broken" ? open.get(problemKey(c)) : null;
439
+ const id = row ? ctx.ids.get(row) : null;
440
+ return `<li>${badge(sev, tag)} ${id ? `<a href="#${id}">${text}</a>` : text}</li>`;
441
+ };
442
+ const items = [...ch.fixed.map((c) => item("ok", m.ui.changeFixed, c, c.was)), ...ch.new.map((c) => item("broken", m.ui.changeNew, c, c.bucket))];
443
+ const routes = (ch.routes ?? []).map((r) => `<li>${esc(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}</li>`);
444
+ return `<details class="card fnd-changes" id="changes">${summary}<div class="body"><p class="muted xs">${esc(m.ui.changesLead(ch.previous, ch.mode))}</p>${items.length ? `<ul class="fnd-change-list">${items.join("")}</ul>` : ""}${routes.length ? `<ul class="fnd-change-list">${routes.join("")}</ul>` : ""}</div></details>`;
445
+ }
446
+
447
+ /**
448
+ * The model's summary points and its "worth checking" list. The Summary keeps only the headline, on one line; the rest
449
+ * sits here, collapsed next to the findings it talks about, so a model's extra leads are not lost from the HTML.
450
+ */
451
+ function aiSummaryCard(ctx) {
452
+ const { m } = ctx;
453
+ const s = summaryParts(ctx.explained?.summary);
454
+ if (!s?.points?.length && !s?.also_check?.length) return "";
455
+ const points = s.points.length ? `<ul class="fnd-change-list">${s.points.map((p) => `<li>${badge(p.severity, m.severity[p.severity])}<span>${fmt(p.text)}</span></li>`).join("")}</ul>` : "";
456
+ const also = s.also_check.length ? `<p class="xs muted">${esc(m.ui.alsoCheck)}</p><ul class="fnd-change-list also">${s.also_check.map((x) => `<li>${fmt(x)}</li>`).join("")}</ul>` : "";
457
+ return `<details class="card fnd-changes" id="ai-summary"><summary><h3 class="t">${esc(m.html.aiSummary)}</h3></summary><div class="body">${points}${also}</div></details>`;
458
+ }
459
+
460
+ /* ---------- section ---------- */
461
+
462
+ /** @param {import("./shell.js").Ctx} ctx */
463
+ export function render(ctx) {
464
+ const { m, rows, tally } = ctx;
465
+ const t = m.html.findings;
466
+ const failedRows = rows.filter((f) => FAILED.has(f.severity));
467
+ const infoRows = rows.filter((f) => f.severity === "info");
468
+ const passed = passedLines(ctx);
469
+ // The tokens of every row, in list order, for the chip counts and "n of m".
470
+ const items = [
471
+ ...failedRows.map((f) => rowTokens(f.severity, ownerOf(f), [f.platform], placesOf(f.where), f.accepted === true)),
472
+ ...infoRows.map((f) => rowTokens("info", ownerOf(f), [f.platform], placesOf(f.where), f.accepted === true)),
473
+ ...passed.map((p) => p.tokens),
474
+ ];
475
+ const head = (kind, label, count) => `<h3><span class="chip lg chip-group ${kind}"><b data-group-count>${count}</b> ${esc(label)}</span></h3>`;
476
+ // Nothing failed: say so under Failed, and the Passed group, open, is what was checked.
477
+ const none = failedRows.length ? "" : `<p class="fnd-none muted">${use("sev-ok", 14)}${esc(m.ui.noProblems)}</p>`;
478
+ const failed = `<div class="group" id="f-failed" data-filter-group>${head("failed", t.groups.failed, tally.failed)}${none}<div class="g-rows">${failedRows.map((f) => failedRow(ctx, f)).join("\n")}</div></div>`;
479
+ const details = (kind, label, count, html, open) => `<details class="group" id="f-${kind}" data-filter-group${open ? " open" : ""}><summary>${head(kind, label, count)}</summary><div class="g-rows">${html}</div></details>`;
480
+ const unverified = infoRows.length ? details("unverified", t.groups.unverified, tally.unverified, infoRows.map((f) => unverifiedRow(ctx, f)).join("\n"), false) : "";
481
+ const passedGroup = passed.length
482
+ ? details("passed", failedRows.length ? t.groups.passed : m.html.whatWasChecked, tally.passed, passed.map((p) => p.html).join("\n"), !failedRows.length)
483
+ : failedRows.length ? "" : `<p class="fnd-none muted">${esc(m.html.whatWasChecked)}: ${esc(m.html.nothingVerified)}</p>`;
484
+ const body = `${filterBar(ctx, items)}
485
+ ${[changesCard(ctx), aiSummaryCard(ctx)].filter(Boolean).join("\n")}
486
+ <div class="fnd-list" id="f-list">
487
+ ${failed}
488
+ ${unverified}
489
+ ${passedGroup}
490
+ <p class="fnd-empty muted" data-filter-empty hidden>${use("sev-info", 14)}${esc(t.empty)}</p>
491
+ </div>`;
492
+ return sectionBlock("findings", esc(m.html.shell.sections.findings), body, "fnd");
493
+ }