@analitikisler/ai-tag-debugger 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -17
- package/README.tr.md +16 -17
- package/package.json +3 -3
- package/src/brand.js +17 -11
- package/src/browser.js +44 -8
- package/src/html/consent.js +152 -0
- package/src/html/css.js +1182 -0
- package/src/html/findings.js +493 -0
- package/src/html/journeys.js +449 -0
- package/src/html/lib.js +283 -0
- package/src/html/scope.js +350 -0
- package/src/html/script.js +300 -0
- package/src/html/shell.js +191 -0
- package/src/html/summary.js +288 -0
- package/src/i18n.js +385 -171
- package/src/metrics.js +9 -3
- package/src/plan-html.js +74 -27
- package/src/report-shared.js +123 -0
- package/src/report.js +24 -1505
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// The Summary section (SPEC §2): the scorecard with its status chips, then six "about this run" cards. Nothing here is
|
|
2
|
+
// collapsible and nothing is counted twice: the chips read ctx.tally and ctx.rows as the rail and Findings do, the
|
|
3
|
+
// headline is verdictHeadline's (the CLI's, the alerts' and the Markdown title's), and the cost lines are the score's
|
|
4
|
+
// own deductions.
|
|
5
|
+
import { VERSION, summaryParts } from "../report-shared.js";
|
|
6
|
+
import { PLATFORM_ART, UI_SYMBOLS, chip, esc, fmt, platformLabel, safeKey, sectionBlock, sortPlatforms, use } from "./lib.js";
|
|
7
|
+
|
|
8
|
+
const PROBLEMS = ["broken", "risk", "warning"];
|
|
9
|
+
const SEVERITIES = [...PROBLEMS, "info", "ok"];
|
|
10
|
+
const VERDICT_SEV = { fail: "broken", attention: "warning", pass: "ok" };
|
|
11
|
+
// Screen readers read the cost line's parts as one sentence; the separator keeps them apart.
|
|
12
|
+
const SEP = '<span class="sr"> · </span>';
|
|
13
|
+
const same = (a, b) => (a ?? null) === (b ?? null);
|
|
14
|
+
const code = (s) => `<code>${esc(s)}</code>`;
|
|
15
|
+
// GA4 lists the money parameters first; the line names them in the order a reader fixes them.
|
|
16
|
+
const PARAM_ORDER = ["value", "currency", "transaction_id", "items"];
|
|
17
|
+
const byParamOrder = (a, b) => (PARAM_ORDER.indexOf(a) + 1 || 99) - (PARAM_ORDER.indexOf(b) + 1 || 99);
|
|
18
|
+
/** A sentence's first letter in capitals; the locale keeps Turkish "i" as "İ". */
|
|
19
|
+
const sentence = (s, lang) => (s ? s[0].toLocaleUpperCase(lang) + s.slice(1) : s);
|
|
20
|
+
|
|
21
|
+
/** A platform's artwork, or its initials on a neutral square for a platform without artwork. Decorative: the name follows. */
|
|
22
|
+
function platArt(key, size) {
|
|
23
|
+
const k = safeKey(key);
|
|
24
|
+
if (PLATFORM_ART[k]) return use(`p-${k}`, size);
|
|
25
|
+
const initials = platformLabel(key).split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
|
|
26
|
+
return `<span class="plat-fb s${size <= 13 ? 12 : size <= 15 ? 14 : 16}" aria-hidden="true">${esc(initials)}</span>`;
|
|
27
|
+
}
|
|
28
|
+
const platChip = (key) => chip("plat", key, `${platArt(key, 13)}${esc(platformLabel(key))}`);
|
|
29
|
+
/** A run as a chip: the journey with the viewport's glyph, coloured by viewport. */
|
|
30
|
+
const runChip = (journey, viewport) => {
|
|
31
|
+
const vp = safeKey(viewport);
|
|
32
|
+
return chip("vp", vp, `${UI_SYMBOLS[`ui-${vp}`] ? use(`ui-${vp}`, 13) : ""}${esc(journey)} · ${esc(viewport)}`);
|
|
33
|
+
};
|
|
34
|
+
const runsChip = (t, n) => `<span class="chip plain">${use("ui-journeys", 13)}${esc(t.runs(n))}</span>`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The step every unfinished run stopped at (its last step), or null when they differ or none got past the start: step 0
|
|
38
|
+
* is the start and a run that threw has no steps, neither a step a reader can find (Findings' stoppedStep, the same rule).
|
|
39
|
+
*/
|
|
40
|
+
function stopStep(runs) {
|
|
41
|
+
const stops = [...new Set(runs.filter((r) => !r.completed).map((r) => (r.steps ?? []).length - 1))];
|
|
42
|
+
return stops.length === 1 && stops[0] > 0 ? stops[0] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The score's deductions folded into the groups a reader fixes together: same check and the same event name compared
|
|
47
|
+
* case-insensitively (GA4 purchase and Meta Purchase are one fix), most points first. Each group keeps the row of its
|
|
48
|
+
* largest deduction, whose card the line links to, and every place any of its rows was seen.
|
|
49
|
+
*/
|
|
50
|
+
function costGroups(ctx) {
|
|
51
|
+
const { metrics, rows } = ctx;
|
|
52
|
+
const map = new Map();
|
|
53
|
+
for (const d of metrics.score?.deductions ?? []) {
|
|
54
|
+
const key = `${d.check}|${String(d.event ?? "").toLowerCase()}`;
|
|
55
|
+
if (!map.has(key)) map.set(key, { check: d.check, event: d.event, points: 0, items: [] });
|
|
56
|
+
const g = map.get(key);
|
|
57
|
+
g.points += d.points;
|
|
58
|
+
g.items.push(d);
|
|
59
|
+
}
|
|
60
|
+
const groups = [...map.values()];
|
|
61
|
+
for (const g of groups) {
|
|
62
|
+
g.top = g.items.reduce((a, d) => (d.points > a.points ? d : a));
|
|
63
|
+
g.severity = g.top.severity;
|
|
64
|
+
g.platforms = sortPlatforms(new Set(g.items.map((d) => d.platform).filter(Boolean)));
|
|
65
|
+
g.rows = rows.filter((r) => r.severity !== "ok" && g.items.some((d) => d.check === r.check && same(d.platform, r.platform) && same(d.event, r.event)));
|
|
66
|
+
g.row = g.rows.find((r) => same(r.platform, g.top.platform) && same(r.event, g.top.event)) ?? g.rows[0];
|
|
67
|
+
g.places = [...new Map(g.rows.flatMap((r) => r.where).map((w) => [`${w.journey}|${w.viewport}`, w])).values()];
|
|
68
|
+
}
|
|
69
|
+
return groups.sort((a, b) => b.points - a.points);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a group costs, in at most eight words. The raw findings carry the missing parameters; the grouped rows do not. */
|
|
73
|
+
function costText(ctx, g) {
|
|
74
|
+
const t = ctx.m.html.summary.cost;
|
|
75
|
+
const ev = g.event ? code(g.event) : "";
|
|
76
|
+
switch (g.check) {
|
|
77
|
+
case "missing_params": {
|
|
78
|
+
const params = [...new Set(ctx.findings.filter((f) => f.check === g.check && g.items.some((d) => same(d.platform, f.platform) && same(d.event, f.event))).flatMap((f) => f.params ?? []))].sort(byParamOrder);
|
|
79
|
+
return params.length ? t.missingParams(ev, params.map(code).join(", ")) : t.missingParamsAny(ev);
|
|
80
|
+
}
|
|
81
|
+
case "before_consent": return t.beforeConsent(ev);
|
|
82
|
+
case "after_reject": return t.afterReject(ev);
|
|
83
|
+
case "duplicate": {
|
|
84
|
+
// The count is only in the finding's sentence ("sent 2 times"); the quoted event name may hold digits of its own.
|
|
85
|
+
const n = Number(String(g.row?.detail ?? "").replace(/"[^"]*"/g, "").match(/\d+/)?.[0]) || null;
|
|
86
|
+
return t.duplicate(ev, n);
|
|
87
|
+
}
|
|
88
|
+
case "missing_event": return t.missingEvent(ev);
|
|
89
|
+
case "journey_incomplete": return esc(t.stopped(stopStep(ctx.runs), ctx.runs.filter((r) => !r.completed).length));
|
|
90
|
+
case "consent_default_missing": return esc(t.noConsentDefault);
|
|
91
|
+
default: {
|
|
92
|
+
// Any other check: the first sentence of the finding itself, which checks.js wrote in the report's language.
|
|
93
|
+
const first = String(g.row?.detail ?? g.check.replace(/_/g, " ")).split(/(?<=\.)\s/)[0].replace(/\.$/, "");
|
|
94
|
+
return esc(first).replace(/"([^&]+?)"/g, "<code>$1</code>");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where a group was seen: one or two runs as run chips, more as "n runs". */
|
|
100
|
+
function whereChips(ctx, g) {
|
|
101
|
+
const t = ctx.m.html.summary;
|
|
102
|
+
if (g.places.length > 2 || (g.places.length === ctx.runs.length && g.places.length > 1)) return runsChip(t, g.places.length);
|
|
103
|
+
return g.places.map((w) => runChip(w.journey, w.viewport)).join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function costLines(ctx, noScore) {
|
|
107
|
+
const { m, tally, ids } = ctx;
|
|
108
|
+
const t = m.html.summary;
|
|
109
|
+
const groups = costGroups(ctx);
|
|
110
|
+
const score = tally.score;
|
|
111
|
+
// SPEC 2.1: a perfect score has no line; 90–99 one line and "· n more"; below, up to three, the third summing the rest.
|
|
112
|
+
let shown = [];
|
|
113
|
+
let inline = "";
|
|
114
|
+
let tail = null;
|
|
115
|
+
if (noScore || score < 90) {
|
|
116
|
+
if (groups.length > 3) {
|
|
117
|
+
shown = groups.slice(0, 2);
|
|
118
|
+
const rest = groups.slice(2);
|
|
119
|
+
tail = { points: rest.reduce((a, g) => a + g.points, 0), severity: rest[0].severity, text: t.moreInFindings(rest.length) };
|
|
120
|
+
} else shown = groups;
|
|
121
|
+
} else if (score < 100) {
|
|
122
|
+
shown = groups.slice(0, 1);
|
|
123
|
+
if (groups.length > 1) inline = ` <span class="muted">· ${esc(t.more(groups.length - 1))}</span>`;
|
|
124
|
+
}
|
|
125
|
+
if (!shown.length) return "";
|
|
126
|
+
// A site that never loaded has no score, so its lines carry no points either.
|
|
127
|
+
const pts = (p) => (noScore ? "" : `<span class="pts">−${p}</span>`);
|
|
128
|
+
const lines = shown.map((g, i) => {
|
|
129
|
+
const id = g.row ? ids.get(g.row) : null;
|
|
130
|
+
const plats = g.platforms.map(platChip).join("");
|
|
131
|
+
return `<li class="sev-${g.severity}"><a href="#${id ?? "findings"}">${pts(g.points)}<span class="what">${costText(ctx, g)}${i === 0 ? inline : ""}</span>${SEP}<span class="pl">${plats}</span>${plats ? SEP : ""}<span class="wh">${whereChips(ctx, g)}</span></a></li>`;
|
|
132
|
+
});
|
|
133
|
+
if (tail) lines.push(`<li class="sev-${tail.severity}"><a href="#findings">${pts(tail.points)}<span class="what">${esc(tail.text)}</span></a></li>`);
|
|
134
|
+
return `<ul class="sc-costs${noScore ? " nopts" : ""}">${lines.join("")}</ul>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The model's summary as one muted line under the headline: its headline, or an older capture's plain text. */
|
|
138
|
+
function aiLine(ctx) {
|
|
139
|
+
const s = summaryParts(ctx.explained?.summary);
|
|
140
|
+
const text = s ? (s.text ?? s.headline) : "";
|
|
141
|
+
if (!text) return "";
|
|
142
|
+
return `<p class="sc-ai muted" title="${esc(text)}"><span class="ai-l">${esc(ctx.m.html.aiSummary)}</span> ${fmt(text)}</p>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Five severity chips counted as Findings counts them: problem rows per severity, Info = Unverified, OK = Passed. */
|
|
146
|
+
function statusChips(ctx) {
|
|
147
|
+
const { m, rows, tally } = ctx;
|
|
148
|
+
const counted = rows.filter((f) => !f.accepted);
|
|
149
|
+
const count = { info: tally.unverified, ok: tally.passed };
|
|
150
|
+
for (const s of PROBLEMS) count[s] = counted.filter((f) => f.severity === s).length;
|
|
151
|
+
const chips = SEVERITIES.map((s) => {
|
|
152
|
+
const inner = `${use(`sev-${s}`, 15)}<b>${count[s]}</b> ${esc(m.severity[s])}`;
|
|
153
|
+
// A zero keeps the row's shape but leads nowhere: Findings would show an empty list.
|
|
154
|
+
return count[s] ? `<a class="chip chip-sev ${s} lg" href="#findings?sev=${s}">${inner}</a>` : `<span class="chip chip-sev ${s} lg zero" aria-disabled="true">${inner}</span>`;
|
|
155
|
+
}).join("");
|
|
156
|
+
return `<div class="sc-chips chips" role="group" aria-label="${esc(m.html.countsLabel)}">${chips}</div>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function scorecard(ctx) {
|
|
160
|
+
const { m, lang, tally, verdict, accepted } = ctx;
|
|
161
|
+
const shell = m.html.shell;
|
|
162
|
+
const score = tally.score;
|
|
163
|
+
const noScore = score === null;
|
|
164
|
+
// The ring is banded by score as the top-bar chip is (0–49 red, 50–89 amber, 90–100 green); the word by verdict.
|
|
165
|
+
const band = noScore || score < 50 ? "fail" : score < 90 ? "mid" : "pass";
|
|
166
|
+
const sev = VERDICT_SEV[verdict.status] ?? "warning";
|
|
167
|
+
const ring = noScore
|
|
168
|
+
? `<div class="sc-ring" role="img" aria-label="${esc(shell.noScore(verdict.statusText))}"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="trk" cx="60" cy="60" r="52"/></svg><span class="sc-num sm-none">–</span></div>`
|
|
169
|
+
: `<div class="sc-ring" role="img" aria-label="${esc(shell.scoreLabel(score, verdict.statusText))}"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="trk" cx="60" cy="60" r="52"/>${score > 0 ? `<circle class="arc" cx="60" cy="60" r="52" pathLength="100" style="--score:${score}"/>` : ""}</svg><span class="sc-num">${score}</span><span class="sc-of">${esc(shell.outOf)}</span></div>`;
|
|
170
|
+
const headline = verdict.headline ? `${sentence(verdict.headline, lang)}.` : "";
|
|
171
|
+
return `<div class="scorecard band-${band}">${ring}
|
|
172
|
+
<p class="sc-verdict sev-${sev}">${use(`sev-${sev}`, 26)}${esc(verdict.statusText)}</p>
|
|
173
|
+
<p class="sc-head">${esc(headline)}</p>
|
|
174
|
+
${aiLine(ctx)}${costLines(ctx, noScore)}
|
|
175
|
+
${statusChips(ctx)}${accepted > 0 ? `\n<p class="meta accepted-line">${esc(m.ui.acceptedLine(accepted))}</p>` : ""}
|
|
176
|
+
</div>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ---------- about this run ---------- */
|
|
180
|
+
|
|
181
|
+
/** One card: icon, the label linking to the section that has the detail, the value and at most one secondary line. */
|
|
182
|
+
const card = (icon, label, href, val, sub, cls = "") => `<div class="info${cls}"><span class="ic-plate">${use(icon, 15)}</span><a class="lbl" href="#${href}">${esc(label)}${use("ui-chevron", 12)}</a><span class="val">${val}</span>${sub ? `<span class="sub">${sub}</span>` : ""}</div>`;
|
|
183
|
+
|
|
184
|
+
function vendorsCard(ctx) {
|
|
185
|
+
const { m, tally } = ctx;
|
|
186
|
+
const t = m.html.summary;
|
|
187
|
+
const seen = new Set(tally.vendors.seen);
|
|
188
|
+
const checked = sortPlatforms(tally.vendors.checked);
|
|
189
|
+
const chips = checked.map((p) => {
|
|
190
|
+
const word = seen.has(p) ? t.seen : t.notSeen;
|
|
191
|
+
return `<span class="chip chip-plat ${safeKey(p)}${seen.has(p) ? "" : " sm-unseen"}" title="${esc(word)}">${platArt(p, 13)}${esc(platformLabel(p))}<span class="sr"> (${esc(word)})</span></span>`;
|
|
192
|
+
}).join("");
|
|
193
|
+
const val = checked.length ? `<span class="chips">${chips}</span>` : '<span class="muted">–</span>';
|
|
194
|
+
return card("ui-vendors", m.html.platformsChecked, "inventory", val, checked.length ? esc(t.seenOf(seen.size, checked.length)) : "", " sm-chipval");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Problem rows that point at a run, as the 0.17 journeys table counted them. */
|
|
198
|
+
const problemsIn = (rows, run) => rows.filter((f) => PROBLEMS.includes(f.severity) && !f.accepted && f.where.some((w) => w.journey === run.journeyId && w.viewport === run.viewport)).length;
|
|
199
|
+
|
|
200
|
+
function journeysCard(ctx) {
|
|
201
|
+
const { m, runs, rows, tally } = ctx;
|
|
202
|
+
const t = m.html.summary;
|
|
203
|
+
if (!runs.length) return card("ui-journeys", m.ui.tabJourneys, "journeys", `<span class="muted">${esc(t.noRuns)}</span>`, "", " sm-matrixval");
|
|
204
|
+
const platformsIn = (r) => sortPlatforms(new Set((r.hits ?? []).map((h) => h.platform)));
|
|
205
|
+
const journeys = [...new Set(runs.map((r) => r.journeyId))];
|
|
206
|
+
const viewports = [...new Set(runs.map((r) => r.viewport))];
|
|
207
|
+
const cell = (j, vp) => {
|
|
208
|
+
const ri = runs.findIndex((r) => r.journeyId === j && r.viewport === vp);
|
|
209
|
+
if (ri < 0) return "<td></td>";
|
|
210
|
+
const r = runs[ri];
|
|
211
|
+
const state = r.completed ? "completed" : "incomplete";
|
|
212
|
+
// The cell shows a glyph and logos; the numbers the 0.17 journeys table showed are read out, not drawn.
|
|
213
|
+
const said = [`${j} · ${vp}: ${t.runState[state]}`, t.steps((r.steps ?? []).filter((s) => s.index > 0).length), t.hits((r.hits ?? []).length), t.problems(problemsIn(rows, r))].join(" · ");
|
|
214
|
+
const logos = platformsIn(r).map((p) => `<span class="sm-logo">${platArt(p, 15)}</span>`).join("");
|
|
215
|
+
return `<td><a href="#j${ri}">${chip("run", state, use(r.completed ? "sev-ok" : "sev-warning", 13))}${logos}<span class="sr">${esc(said)}</span></a></td>`;
|
|
216
|
+
};
|
|
217
|
+
const head = viewports.map((vp) => `<th scope="col">${UI_SYMBOLS[`ui-${safeKey(vp)}`] ? `${use(`ui-${safeKey(vp)}`, 14)}<span class="sr">${esc(vp)}</span>` : esc(vp)}</th>`).join("");
|
|
218
|
+
const body = journeys.map((j) => `<tr><th scope="row">${esc(j)}</th>${viewports.map((vp) => cell(j, vp)).join("")}</tr>`).join("");
|
|
219
|
+
const matrix = `<table class="sm-matrix"><caption class="sr">${esc(m.ui.tabJourneys)}</caption><thead><tr><td></td>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
220
|
+
const stop = stopStep(runs);
|
|
221
|
+
const sub = tally.completed
|
|
222
|
+
? [t.completedOf(tally.completed, tally.runs), t.steps(runs.reduce((a, r) => a + (r.steps ?? []).filter((s) => s.index > 0).length, 0)), t.hits(runs.reduce((a, r) => a + (r.hits ?? []).length, 0))]
|
|
223
|
+
: [t.completedOf(0, tally.runs), ...(stop !== null ? [t.stoppedAt(stop)] : [])];
|
|
224
|
+
return card("ui-journeys", m.ui.tabJourneys, "journeys", matrix, esc(sub.join(" · ")), " sm-matrixval");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function eventsCard(ctx) {
|
|
228
|
+
const t = ctx.m.html.summary;
|
|
229
|
+
const cov = ctx.metrics.coverage;
|
|
230
|
+
// Without a plan there is nothing to count against; say so instead of showing zeros.
|
|
231
|
+
if (!cov) return card("ui-events", t.events, "coverage", `<span class="sm-note">${esc(t.noPlan)}</span>`, "");
|
|
232
|
+
// Seen = sent, with or without its required parameters; the secondary line says how many had them all.
|
|
233
|
+
return card("ui-events", t.events, "coverage", t.plannedSeen(cov.planned, cov.counts.implemented + cov.counts.missing_params), esc(t.withParams(cov.percent.implemented)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The state of the first Consent Mode default pushed: denied or granted when every key agrees, mixed otherwise. */
|
|
237
|
+
function defaultState(runs) {
|
|
238
|
+
const push = runs.flatMap((r) => r.dataLayerLog ?? []).map((e) => e.value).find((v) => Array.isArray(v) && v[0] === "consent" && v[1] === "default");
|
|
239
|
+
const states = Object.values(push?.[2] ?? {});
|
|
240
|
+
if (!states.length) return null;
|
|
241
|
+
return states.every((s) => s === "denied") ? "denied" : states.every((s) => s === "granted") ? "granted" : "mixed";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function consentCard(ctx) {
|
|
245
|
+
const { m, metrics, runs, consentScenarios, tally } = ctx;
|
|
246
|
+
const t = m.html.summary;
|
|
247
|
+
const cm = metrics.consent?.consentMode ?? {};
|
|
248
|
+
const ms = (cm.runs ?? []).map((r) => r.ms).filter((x) => typeof x === "number");
|
|
249
|
+
const range = ms.length ? (Math.min(...ms) === Math.max(...ms) ? `${ms[0]}` : `${Math.min(...ms)}–${Math.max(...ms)}`) : null;
|
|
250
|
+
const state = defaultState(runs);
|
|
251
|
+
const stateChip = state ? ` ${chip("consent", state === "mixed" ? "none" : state, esc(state === "mixed" ? t.mixed : m.html.consent.state[state]))}` : "";
|
|
252
|
+
const val = cm.defaultPresent ? t.defaultAt(stateChip, range) : esc(t.noDefault);
|
|
253
|
+
// Scenarios: no choice, then each state the banner reached (accept, reject), as the Consent section lists them.
|
|
254
|
+
const sc = consentScenarios;
|
|
255
|
+
const scenarios = sc ? 1 + Object.values(sc.states ?? {}).filter((s) => s?.after).length : 0;
|
|
256
|
+
const cmp = sc ? (sc.cmp === "banner" ? sentence(m.html.cmpBanner, ctx.lang) : String(sc.cmp ?? "")) : "";
|
|
257
|
+
const sub = sc ? `${esc(cmp)} · ${esc(m.html.scenariosRun(scenarios))}` : esc(tally.completed ? t.noBanner : t.bannerNotReached);
|
|
258
|
+
return card("ui-consent", m.ui.consent, "consent", val, sub);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function aiCard(ctx) {
|
|
262
|
+
const { m, ai } = ctx;
|
|
263
|
+
const t = m.html.summary;
|
|
264
|
+
const val = ai ? `${esc(ai.name ?? "")}${ai.model ? ` <small>· ${esc(ai.model)}</small>` : ""}` : esc(t.scripted);
|
|
265
|
+
return card("ui-ai", m.ui.runAi, "run", val, esc(ai ? t.explains : t.deterministic));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function runCard(ctx) {
|
|
269
|
+
const { m, generatedAt, metrics } = ctx;
|
|
270
|
+
const t = m.html.summary;
|
|
271
|
+
// generatedAt arrives formatted ("2026-10-19 09:13 UTC"); the zone is set small, as in the mockup.
|
|
272
|
+
const at = /^(.*\S)\s+(UTC)$/.exec(String(generatedAt ?? ""));
|
|
273
|
+
const when = at ? `${esc(at[1])} <small>${esc(at[2])}</small>` : esc(generatedAt ?? "–");
|
|
274
|
+
const ms = (metrics.timing ?? []).reduce((a, r) => a + (r.durationMs ?? 0), 0);
|
|
275
|
+
const sub = [ms ? t.seconds((ms / 1000).toFixed(1)) : "", `v${VERSION}`].filter(Boolean).join(" · ");
|
|
276
|
+
return card("ui-clock", m.html.shell.sections.run, "run", when, esc(sub));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function about(ctx) {
|
|
280
|
+
const cards = [vendorsCard, journeysCard, eventsCard, consentCard, aiCard, runCard].map((f) => f(ctx)).join("");
|
|
281
|
+
return `<div class="grid sm-about" role="group" aria-label="${esc(ctx.m.html.summary.about)}">${cards}</div>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {import("./shell.js").Ctx} ctx */
|
|
285
|
+
export function render(ctx) {
|
|
286
|
+
return sectionBlock("summary", esc(ctx.m.html.shell.sections.summary), `${scorecard(ctx)}\n${about(ctx)}`, "sm");
|
|
287
|
+
}
|
|
288
|
+
|