@analitikisler/ai-tag-debugger 0.20.1 → 0.22.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.
@@ -1,14 +1,17 @@
1
1
  // The Consent section: one status line per platform, the Consent Mode card and the scenarios card. Denied is red,
2
2
  // granted green and "no signal" grey, all by token through chip("consent", ...).
3
- import { consentTable } from "../consent.js";
3
+ import { consentMatrix, consentTable, IGNORE, SCENARIO_COLUMNS } from "../consent.js";
4
+ import { cmpFacts } from "../cmp.js";
4
5
  import { isConsentEntry } from "../report-shared.js";
6
+ import { tagConsentStates } from "../checks.js";
5
7
  import { PLATFORM_ART, chip, esc, platformLabel, safeKey, sectionBlock, sortPlatforms, stepTitle, secs, use, badge } from "./lib.js";
6
8
 
7
9
  // Google tags carry Consent Mode signals on the hit; every other platform sends none, so its state is "none".
8
10
  const GOOGLE = new Set(["ga4", "google_ads"]);
9
- const SCENARIOS = ["before", "accept", "reject", "analytics"];
11
+ // The status line per platform keeps to no choice, accept and reject; the card lists every column that ran.
12
+ const STATUS_SCENARIOS = ["before", "accept", "reject"];
10
13
  const LINE_CHECKS = new Set(["before_consent", "after_reject", "consent_default_missing"]);
11
- const MODE_CHECKS = new Set(["consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
14
+ const MODE_CHECKS = new Set(["consent_default_missing", "consent_default_late", "consent_default_not_applied", "consent_v2_missing", "consent_v2_update"]);
12
15
  const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2, info: 3 };
13
16
 
14
17
  /** A platform's artwork, or its initials when it has none. Decorative: the chip's text names it once for screen readers. */
@@ -73,12 +76,18 @@ export function render(ctx) {
73
76
  // The scenarios can show a tag sent after Reject with no finding row for it; with no row at all (not even an
74
77
  // accepted one) the metric decides, so the line agrees with the flagged cell below.
75
78
  const sev = row?.severity ?? (!own.length && metrics.consent.platforms.find((p) => p.platform === platform)?.status !== "pass" ? "risk" : "ok");
76
- const cells = SCENARIOS.slice(0, 3).filter((s) => sc && afterOf(sc, s))
79
+ const cells = STATUS_SCENARIOS.filter((s) => sc && afterOf(sc, s))
77
80
  .map((s) => `<span class="cs-p"><span class="cs-k">${esc(t.cols[s])}</span>${total(platform, s)}</span>`).join("");
78
81
  return `<li class="card sev-${sev} cs-row" id="consent-${safeKey(platform)}">${platChip(platform)}${sevChip(sev, m.severity[sev])}${cells ? `<span class="cs-cells">${cells}</span>` : ""}${findingLink(row, "cs-go")}</li>`;
79
82
  }).join("");
80
83
  const list = lines ? `<ul class="cs-list" aria-label="${esc(t.statusLabel)}">${lines}</ul>` : "";
81
84
 
85
+ /* ---- consent platform ---- */
86
+ const platform = cmpFacts(m, runs);
87
+ const platformCard = platform
88
+ ? `<details class="card" id="consent-platform"><summary><span class="t">${esc(m.ui.cmp.title)}</span>${plain(esc(platform.vendor))}</summary><div class="body"><ul class="xs cm-timing">${platform.facts.map((f) => `<li>${esc(f)}</li>`).join("")}</ul></div></details>\n`
89
+ : "";
90
+
82
91
  /* ---- Consent Mode ---- */
83
92
  const mode = metrics.consent.consentMode;
84
93
  const perRun = runs.map((run, ri) => {
@@ -116,13 +125,24 @@ export function render(ctx) {
116
125
  const resent = Math.max(0, ...withDefault.map((p) => p.resent));
117
126
  const modeFindings = rows.filter((f) => MODE_CHECKS.has(f.check) && !f.accepted && ids.get(f)).map((f) => findingLink(f)).join(" ");
118
127
  const none = !runs.length ? m.ui.md.consentNotChecked : m.ui.md.consentModeNone;
128
+ // The Google tag's last recorded state per run; runs that ended in the same state share one line.
129
+ const tagGroups = new Map();
130
+ for (const run of runs) {
131
+ const last = tagConsentStates(run).at(-1);
132
+ if (!last) continue;
133
+ const k = JSON.stringify({ ...last, step: undefined });
134
+ if (!tagGroups.has(k)) tagGroups.set(k, { last, runs: [] });
135
+ tagGroups.get(k).runs.push(run);
136
+ }
137
+ const viaTag = withDefault.some((p) => p.info.source === "tag") ? `<p class="xs muted">${esc(t.defaultViaTag)}</p>` : "";
138
+ const tagHtml = [...tagGroups.values()].map(({ last, runs: rs }) => `<div class="cm-tag">${tagGroups.size > 1 ? `<div class="chips cm-runs">${rs.map(runChip).join("")}</div>` : ""}<p class="xs"><b>${esc(t.tagState)}</b> · ${esc(t.tagFlags(last.usedDefault, last.usedUpdate, last.wasSetLate, last.waitPeriodTimedOut))}</p><div class="chips">${signalChips(m, Object.fromEntries(Object.entries(last.entries).map(([k, e]) => [k, e.update ?? e.default ?? "none"])))}</div></div>`).join("");
119
139
  const modeBody = withDefault.length
120
- ? `${timing ? `<ul class="cm-timing">${timing}</ul>` : ""}${groupHtml}${resent ? `<p class="xs muted cm-rep">${use("ui-clock", 12)} ${esc(t.resent(resent))}</p>` : ""}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`
121
- : `<p class="muted xs">${esc(none)}</p>${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`;
140
+ ? `${timing ? `<ul class="cm-timing">${timing}</ul>` : ""}${viaTag}${groupHtml}${tagHtml}${resent ? `<p class="xs muted cm-rep">${use("ui-clock", 12)} ${esc(t.resent(resent))}</p>` : ""}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`
141
+ : `<p class="muted xs">${esc(none)}</p>${tagHtml}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`;
122
142
  const modeCard = `<details class="card" id="consent-mode"><summary><span class="t">${esc(m.html.consentMode)}</span>${modeHead}${modeChips}<span class="end muted xs">${esc(t.runsWith(withDefault.length, runs.length))}</span></summary><div class="body">${modeBody}</div></details>`;
123
143
 
124
144
  /* ---- scenarios ---- */
125
- if (!sc) return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n<p class="muted xs cs-none" id="consent-scenarios">${esc(m.ui.md.noScenarios)}</p>`);
145
+ if (!sc) return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${platformCard}${modeCard}\n<p class="muted xs cs-none" id="consent-scenarios">${esc(m.ui.md.noScenarios)}</p>`);
126
146
  const table = consentTable(sc);
127
147
  const cols = table.cols.filter((s) => s !== "analytics" || sc.states?.analytics?.via);
128
148
  const before = rows.filter((f) => f.check === "before_consent" && !f.accepted);
@@ -145,8 +165,13 @@ export function render(ctx) {
145
165
  };
146
166
  const body = table.rows.map((r) => `<tr><th scope="row"><span class="cs-tag">${platChip(r.platform)}<code>${esc(r.event)}</code></span></th>${cols.map((s) => cellHtml(r, r.cells.find((c) => c.state === s))).join("")}</tr>`).join("");
147
167
  const supported = (s) => (s === "before" ? true : Boolean(sc.states?.[s]?.after));
148
- const summaryChips = SCENARIOS.map((s) => (supported(s) ? plain(esc(t.cols[s])) : chip("consent", "none", esc(`${t.cols[s]} · ${t.notSupported}`)))).join("");
168
+ // Captures from before the no-choice scenario existed have no column for it.
169
+ const summaryChips = SCENARIO_COLUMNS.filter((s) => s !== IGNORE || sc.states?.[IGNORE]).map((s) => (supported(s) ? plain(esc(t.cols[s])) : chip("consent", "none", esc(`${t.cols[s]} · ${t.notSupported}`)))).join("");
149
170
  const probChip = problemCount ? sevChip("risk", t.problems(problemCount)) : sevChip("ok", t.noProblems);
150
171
  const scenarios = `<details class="card" id="consent-scenarios"><summary><span class="t">${esc(t.scenarios)}</span>${summaryChips}<span class="end">${probChip}</span></summary><div class="body"><div class="tscroll"><table class="tbl cs-tbl"><caption class="sr">${esc(m.ui.scenarios)}</caption><thead><tr><th scope="col">${esc(m.ui.scenarioTag)}</th>${cols.map((s) => `<th scope="col">${esc(t.cols[s])}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div></div></details>`;
151
- return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n${scenarios}`);
172
+ const matrix = consentMatrix(m, sc);
173
+ const matrixCard = matrix
174
+ ? `\n<details class="card" id="consent-matrix"><summary><span class="t">${esc(m.ui.matrix.title)}</span>${matrix.rows.some((r) => r.cells.some((c) => c.problem)) ? sevChip("risk", t.problems(matrix.rows.reduce((n, r) => n + r.cells.filter((c) => c.problem).length, 0))) : sevChip("ok", t.noProblems)}</summary><div class="body">${matrix.landing ? `<p class="xs muted">${esc(m.ui.matrix.intro(matrix.landing))}</p>` : ""}<div class="tscroll"><table class="tbl cs-tbl"><caption class="sr">${esc(m.ui.matrix.title)}</caption><thead><tr><th scope="col"></th>${matrix.cols.map((c) => `<th scope="col">${esc(t.cols[c])}</th>`).join("")}</tr></thead><tbody>${matrix.rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${r.cells.map((c) => (c.problem ? `<td class="cs-bad"><span class="cs-c">${esc(c.text)}${badge("risk", "⚠")}<span class="sr"> ${esc(m.ui.matrix.problem)}</span></span></td>` : `<td class="xs">${esc(c.text)}</td>`)).join("")}</tr>`).join("")}</tbody></table></div><p class="xs muted">${esc(m.ui.matrix.legend)}</p></div></details>`
175
+ : "";
176
+ return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${platformCard}${modeCard}\n${scenarios}${matrixCard}`);
152
177
  }
@@ -3,8 +3,9 @@
3
3
  // [data-vp-switch], details.card), so shell.js drives it; the JOURNEYS script in script.js only keeps the viewport
4
4
  // when the journey changes. Ids come from lib.js (stepAnchor, hitId, pushId, requestId) so Findings links land here.
5
5
  import { canonicalEvent } from "../checks.js";
6
- import { parseTrackingRequest, providerOf, PROVIDERS } from "../parsers.js";
7
- import { decodeGa4Item, isEventParam, paramTip } from "../glossary.js";
6
+ import { CONSENT_SIGNALS, parseTrackingRequest, providerOf, PROVIDERS } from "../parsers.js";
7
+ import { decodeGa4Item, paramTip } from "../glossary.js";
8
+ import { callsForHit, hitParams, paramSets } from "../hit-params.js";
8
9
  import { SEVERITY_RANK, isConsentEntry } from "../report-shared.js";
9
10
  import {
10
11
  sectionBlock, use, plat, platIcon, badge, chip, n, esc, secs, pathOf, hostPath, safeKey, platformLabel, sortPlatforms, detailHtml,
@@ -63,53 +64,7 @@ function pushesForHit(run, hit) {
63
64
  return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
64
65
  }
65
66
 
66
- /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
67
- function callsForHit(run, hit) {
68
- // A late hit was sent during a later step, so its request is recorded there.
69
- const sentIn = hit.late?.during ?? hit.step;
70
- const calls = (run.network ?? []).filter((c) => c.step === sentIn && c.url === hit.url);
71
- const twins = run.hits.filter((h) => (h.late?.during ?? h.step) === sentIn && h.url === hit.url);
72
- return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
73
- }
74
-
75
- /* ---------- request parameters, moved from the 0.17 renderer ---------- */
76
-
77
- /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
78
- function flatten(obj, prefix = "", out = []) {
79
- for (const [k, v] of Object.entries(obj ?? {})) {
80
- const key = prefix ? `${prefix}.${k}` : k;
81
- if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
82
- else out.push([key, cell(v)]);
83
- }
84
- return out;
85
- }
86
-
87
- /** The parameter sets a request carried: one per event in a GA4 batch (query plus each body line), the query plus a flattened JSON body, or just the query. */
88
- function paramSets(call) {
89
- let u;
90
- try {
91
- u = new URL(call.url);
92
- } catch {
93
- return [[]];
94
- }
95
- const query = [...u.searchParams];
96
- const body = call.postData;
97
- if (!body) return [query];
98
- const trimmed = body.trim();
99
- if (trimmed.startsWith("{")) {
100
- try {
101
- return [[...query, ...flatten(JSON.parse(trimmed))]];
102
- } catch { /* not JSON, or cut at the size limit */ }
103
- }
104
- if (/^[^\s{]*=/.test(trimmed)) {
105
- return trimmed.split("\n").filter(Boolean).map((line) => {
106
- const merged = new Map(query);
107
- for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
108
- return [...merged];
109
- });
110
- }
111
- return [query];
112
- }
67
+ /* ---------- request parameters ---------- */
113
68
 
114
69
  /** Parameter rows with a glossary tip: the "?" shows the tip on hover and takes no focus; screen readers read the hidden span. */
115
70
  function paramRows(lang, entries) {
@@ -120,24 +75,6 @@ function paramRows(lang, entries) {
120
75
  }).join("");
121
76
  }
122
77
 
123
- /**
124
- * What a hit card shows as its parameters: the request's own parameter set (raw keys, with glossary tips), split
125
- * into the event's parameters and the session, device, page and consent ones. A GA4 batch carries one set per
126
- * event; the hit takes the set with its name. Without a captured request, the hit's parsed parameters.
127
- */
128
- function hitParams(run, hit) {
129
- const call = callsForHit(run, hit)[0] ?? null;
130
- if (!call) return { call, batch: null, event: Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && hit.items?.length)).map(([k, v]) => [k, cell(v)]), context: [] };
131
- const sets = paramSets(call);
132
- let i = 0;
133
- if (sets.length > 1) {
134
- const names = (parseTrackingRequest(call.url, call.postData ?? null) ?? []).map((h) => h.name);
135
- i = Math.max(0, names.indexOf(hit.name));
136
- }
137
- const entries = sets[i] ?? [];
138
- return { call, batch: sets.length > 1 ? [i + 1, sets.length] : null, event: entries.filter(([k]) => isEventParam(k)), context: entries.filter(([k]) => !isEventParam(k)) };
139
- }
140
-
141
78
  /* ---------- items: GA4 item strings and dataLayer item objects as one table ---------- */
142
79
 
143
80
  const STANDARD_ITEM_KEYS = new Set(["item_id", "item_name", "affiliation", "coupon", "discount", "index", "item_brand", "item_category", "item_category2", "item_category3", "item_category4", "item_category5", "item_list_id", "item_list_name", "item_variant", "location_id", "price", "quantity", "promotion_id", "promotion_name", "creative_name", "creative_slot"]);
@@ -178,7 +115,7 @@ function consentChip(t, key, v) {
178
115
  return `<span class="chip chip-consent ${safeKey(v)}" title="${esc(`${key}: ${v}`)}"><span class="k" aria-hidden="true">${esc(t.signals[key] ?? key)}</span><span aria-hidden="true">${mark}</span><span class="sr">${esc(`${key} ${v}`)}</span></span>`;
179
116
  }
180
117
  const hitConsent = (t, c) => {
181
- const keys = c ? ["ad_storage", "analytics_storage"].filter((k) => typeof c[k] === "string") : [];
118
+ const keys = c ? CONSENT_SIGNALS.filter((k) => typeof c[k] === "string") : [];
182
119
  return keys.length ? keys.map((k) => consentChip(t, k, c[k])).join("") : `<span class="chip chip-consent none" title="${esc(t.noSignalTitle)}">${esc(t.noSignal)}</span>`;
183
120
  };
184
121
  const missingChip = (m, k) => `<span class="chip chip-sev broken jr-miss"><span class="k">${esc(k)}</span>${esc(m.ui.missing)}</span>`;
@@ -339,6 +276,27 @@ function parityNote(m, run, step, parity) {
339
276
  }).join("");
340
277
  }
341
278
 
279
+ /** The step's cookie changes by name, and what the analytics and ad cookies say when the step changed it. */
280
+ function cookiePane(m, run, step) {
281
+ const changes = (run.cookieChanges ?? []).filter((c) => c.step === step.index);
282
+ const id = step.identity;
283
+ const before = (run.steps ?? []).filter((st) => st.index < step.index && st.identity).at(-1)?.identity;
284
+ const facts = [];
285
+ if (id && (changes.length || JSON.stringify(id) !== JSON.stringify(before))) {
286
+ if (id.ga_client_id) {
287
+ const prev = before?.ga_client_id;
288
+ const since = (run.steps ?? []).find((st) => st.identity?.ga_client_id === id.ga_client_id)?.index;
289
+ facts.push(m.ui.gaClientId(id.ga_client_id, prev ? since : null, prev && prev !== id.ga_client_id ? prev : null));
290
+ }
291
+ if (id.gcl_aw) facts.push(m.ui.gclAw(id.gcl_aw === "landing"));
292
+ if (id.fbc) facts.push(m.ui.fbc(id.fbc === "landing"));
293
+ }
294
+ const table = changes.length
295
+ ? `<table class="tbl jr-req" aria-label="${esc(m.ui.tabCookies)}"><thead><tr><th scope="col">${esc(m.ui.cookieCol)}</th><th scope="col">${esc(m.ui.domainCol)}</th><th scope="col">${esc(m.ui.changeCol)}</th></tr></thead><tbody>${changes.map((c) => `<tr><td><code>${esc(c.name)}</code></td><td class="mono xs">${esc(c.domain)}</td><td>${esc(m.ui.cookieChange[c.change] ?? c.change)}</td></tr>`).join("")}</tbody></table>`
296
+ : "";
297
+ return { count: changes.length, live: changes.length > 0 || facts.length > 0, html: `${facts.map((f) => `<p class="xs jr-note-s">${esc(f)}</p>`).join("")}${table ? `<div class="tscroll">${table}</div>` : ""}<p class="muted xs jr-note-s">${esc(m.ui.cookieNamesOnly)}</p>` };
298
+ }
299
+
342
300
  function stepPane(ctx, t, rows, run, ri, step, sev, selected) {
343
301
  const { m } = ctx;
344
302
  const id = stepAnchor(ri, step.index);
@@ -347,15 +305,19 @@ function stepPane(ctx, t, rows, run, ri, step, sev, selected) {
347
305
  const calls = requestsOf(run, step.index);
348
306
  const skipped = skippedOf(run, step.index);
349
307
  const skippedN = skipped.reduce((a, [, c]) => a + c, 0);
308
+ const cookies = cookiePane(m, run, step);
350
309
  const meta = [step.pagePath ? pathOf(step.pagePath) : null, ...actionText(m, step), secs(run, step.t)].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join("");
351
310
  const reqNotes = `${skippedN ? `<p class="muted xs jr-note-s">${esc(m.ui.skippedInStep(skipped.map(([v, c]) => `${v} ${c}`).join(", "), skippedN))}</p>` : ""}${calls.some((c) => c.bodyDropped) ? `<p class="muted xs jr-note-s">${esc(m.ui.bodyDropped)}</p>` : ""}`;
352
311
  const tabs = [
353
312
  ["hits", esc(m.html.hitsCol), m.html.hitsInStep, hits.length, hits.length ? `<div class="jr-list">${hits.map((h, k) => hitCard(ctx, t, rows, run, ri, step, h, k)).join("")}</div>` : `<p class="muted xs jr-empty">${esc(m.ui.noHits)}</p>`],
354
313
  ["dl", esc(t.dataLayer), t.dataLayer, pushes.length, pushes.length ? `<div class="jr-list">${pushes.map((e, k) => pushCard(ctx, t, run, ri, step, e, k)).join("")}</div>` : `<p class="muted xs jr-empty">${esc(m.ui.noPushes)}</p>`],
355
314
  ["req", esc(m.ui.tabNetwork), m.ui.tabNetwork, calls.length, `${calls.length ? requestTable(ctx, t, run, ri, step, calls) : `<p class="muted xs jr-empty">${esc(m.ui.noRequests)}</p>`}${reqNotes}`],
315
+ // Captures from before cookies were tracked per step have no cookieChanges, and no tab for them.
316
+ ...(run.cookieChanges ? [["ck", esc(m.ui.tabCookies), m.ui.tabCookies, cookies.count, cookies.html]] : []),
356
317
  ];
357
- // A tab with nothing in it is shown but disabled; the requests tab stays open when it only has the skipped-requests note.
358
- const live = (k, c) => c > 0 || (k === "req" && Boolean(reqNotes));
318
+ // A tab with nothing in it is shown but disabled; the requests tab stays open when it only has the skipped-requests note,
319
+ // and the cookies tab when it only says what the click id and client id cookies hold.
320
+ const live = (k, c) => c > 0 || (k === "req" && Boolean(reqNotes)) || (k === "ck" && cookies.live);
359
321
  const first = tabs.find(([k, , , c]) => live(k, c));
360
322
  const body = first
361
323
  ? `<div class="tabs jr-tabs" data-tabs><div role="tablist" aria-label="${esc(t.stepOf(step.index))}">${tabs.map(([k, label, , c]) => `<button type="button" role="tab" aria-controls="${id}-${k}" aria-selected="${first[0] === k}"${live(k, c) ? "" : " disabled"}>${label} ${n(c)}</button>`).join("")}</div>
package/src/html/scope.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // handling (which runs check which platform, the funnel cells, the inventory's parties and cookies, the skipped
4
4
  // vendors, the trend) is the 0.17 renderer's, so the numbers match the Markdown report and the CLI.
5
5
  import { buildInventory } from "../inventory.js";
6
+ import { containerRows } from "../containers.js";
6
7
  import { providerOf } from "../parsers.js";
7
8
  import { siteOf } from "../redact.js";
8
9
  import { VERSION, BUCKET_ORDER, coverageLines, pcSort, planChangeItems, routeList } from "../report-shared.js";
@@ -193,12 +194,24 @@ function inventoryLines(ctx) {
193
194
  return { lines, otherCookies: inv.otherCookies };
194
195
  }
195
196
 
197
+ /** The Google containers card: one line per container and the way it reached the page. Null when none was recorded. */
198
+ function containersCard(ctx) {
199
+ const { m, runs } = ctx;
200
+ const rows = containerRows(runs);
201
+ if (!rows.length) return null;
202
+ const c = m.ui.containers;
203
+ const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
204
+ const ids = uniq(rows.map((r) => r.id));
205
+ return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
206
+ }
207
+
196
208
  function inventorySection(ctx) {
197
209
  const { m, runs } = ctx;
198
210
  const t = m.html.scope;
199
211
  const { lines, otherCookies } = inventoryLines(ctx);
200
212
  const skipped = skippedTotals(runs);
201
- if (!lines.length && !skipped.length) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
213
+ const containers = containersCard(ctx);
214
+ if (!lines.length && !skipped.length && !containers) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
202
215
  const counts = (x) => `<span class="inv-m">${t.requests(x.requests, bold(x.requests))}</span><span class="inv-m">${t.pages(x.pages.size, bold(x.pages.size))}</span><span class="inv-m${x.cookies.length ? "" : " zero"}">${t.cookies(x.cookies.length, bold(x.cookies.length))}</span>`;
203
216
  const out = lines.map((x) => {
204
217
  const id = `inv-${safeKey(x.p)}`;
@@ -214,6 +227,7 @@ function inventorySection(ctx) {
214
227
  <div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
215
228
  <div role="tabpanel" id="${tid("cookies")}" data-label="${esc(m.ui.cookiesCol)}">${x.cookies.length ? `<ul class="inv-list">${x.cookies.map((ck) => `<li>${code(ck)}</li>`).join("")}</ul>` : none}</div></div></div></details></li>`;
216
229
  });
230
+ if (containers) out.unshift(containers);
217
231
  const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
218
232
  if (skipped.length) {
219
233
  out.push(`<li><details class="card inv" id="inv-out-of-scope"><summary><span class="t">${esc(m.ui.outOfScopeStatus)}</span>${plain(t.requests(skippedN, bold(skippedN)))}<span class="inv-m">${esc(skipped.slice(0, 3).map(([v]) => v).join(", "))}${skipped.length > 3 ? " …" : ""}</span></summary><div class="body"><ul class="inv-list">${skipped.map(([v, k]) => `<li><span>${esc(v)}</span>${badge("muted", m.ui.outOfScopeStatus)}<span class="inv-n">${t.requests(k, bold(k))}</span></li>`).join("")}</ul></div></details></li>`);
@@ -172,7 +172,7 @@ function scorecard(ctx) {
172
172
  <p class="sc-verdict sev-${sev}">${use(`sev-${sev}`, 26)}${esc(verdict.statusText)}</p>
173
173
  <p class="sc-head">${esc(headline)}</p>
174
174
  ${aiLine(ctx)}${costLines(ctx, noScore)}
175
- ${statusChips(ctx)}${accepted > 0 ? `\n<p class="meta accepted-line">${esc(m.ui.acceptedLine(accepted))}</p>` : ""}
175
+ ${statusChips(ctx)}${accepted > 0 ? `\n<p class="meta accepted-line">${esc(m.ui.acceptedLine(accepted))}</p>` : ""}${ctx.runs.some((r) => r.live) ? `\n<p class="meta sev-warning">${use("sev-warning", 14)} ${esc(m.ui.liveRun)}</p>` : ""}
176
176
  </div>`;
177
177
  }
178
178