@analitikisler/ai-tag-debugger 0.19.0 → 0.20.1

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.
@@ -65,8 +65,10 @@ function pushesForHit(run, hit) {
65
65
 
66
66
  /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
67
67
  function callsForHit(run, hit) {
68
- const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
69
- const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
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);
70
72
  return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
71
73
  }
72
74
 
@@ -216,7 +218,7 @@ function runState(ctx, rows, run) {
216
218
  /* ---------- step pane: hits ---------- */
217
219
 
218
220
  /** The 1–3 parameters a practitioner scans first: missing required ones, value, items, the order id; a page title when there is nothing else. */
219
- function keyBits(ctx, t, hit, flag) {
221
+ function keyBits(ctx, t, hit, flag, run) {
220
222
  const { m } = ctx;
221
223
  const p = hit.params ?? {};
222
224
  const out = [];
@@ -228,6 +230,7 @@ function keyBits(ctx, t, hit, flag) {
228
230
  if (p.transaction_id) out.push(kv("transaction_id", p.transaction_id));
229
231
  if (hit.delivery === "server") out.push(`<span class="chip plain">${esc(m.ui.serverSideTag)}</span>`);
230
232
  if (!out.length && p.page_title) out.push(kv("", p.page_title));
233
+ if (hit.late) out.unshift(`<span class="chip plain" title="${esc(m.ui.lateHitTip((run?.steps ?? []).find((s) => s.index === hit.late.during)?.label ?? hit.late.during))}">${esc(m.ui.lateHit(hit.late.ms))}</span>`);
231
234
  if (!out.length) return `<span class="muted">${esc(m.ui.noParameters)}</span>`;
232
235
  return out.slice(0, 3).join("");
233
236
  }
@@ -261,10 +264,10 @@ function hitCard(ctx, t, rows, run, ri, step, hit, k) {
261
264
  const label = PROVIDERS[safeKey(hit.platform)] ?? platformLabel(hit.platform);
262
265
  const calls = callsForHit(run, hit);
263
266
  const reqLine = calls.length
264
- ? calls.map((c) => `<a class="mono" href="#${requestId(ri, step.index, stepCalls.indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
267
+ ? calls.map((c) => `<a class="mono" href="#${requestId(ri, c.step, (c.step === step.index ? stepCalls : requestsOf(run, c.step)).indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
265
268
  : `<span class="mono">${esc(hostPath(hit.url))}</span>`;
266
269
  const card = flag ? ids.get(flag) : null;
267
- return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
270
+ return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag, run)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
268
271
  <div class="body">${flag ? `<p class="jr-flag sev-${sev}">${use(`sev-${sev}`, 14)}<span>${detailHtml(flag.detail)}</span>${card ? `<a href="#${card}">${esc(m.html.findingLink)}</a>` : ""}</p>` : ""}
269
272
  <div class="jr-hb"><div><h4 class="jr-h">${esc(m.ui.parameters)}${n(event.length + missing.length)}</h4>${batch ? `<p class="muted xs jr-batch">${esc(m.ui.inBatch(batch[0], batch[1]))}</p>` : ""}${paramTable}${ctxTable}</div>
270
273
  ${items.length ? `<div><h4 class="jr-h">${esc(m.ui.items)}${n(items.length)}</h4>${itemsTable(m, t, items)}</div>` : ""}
package/src/i18n.js CHANGED
@@ -24,6 +24,8 @@ const journeysOf = (v) => v?.journeys ?? {};
24
24
 
25
25
  const en = {
26
26
  name: "English",
27
+ // The enhanced measurement settings as GA4's web stream page names them.
28
+ ga4Settings: { pageViews: "Page views", scrolls: "Scrolls", outboundClicks: "Outbound clicks", siteSearch: "Site search", videoEngagement: "Video engagement", fileDownloads: "File downloads", formInteractions: "Form interactions" },
27
29
  severity: { broken: "Broken", risk: "Risk", warning: "Warning", info: "Info", ok: "OK" },
28
30
  ui: {
29
31
  title: (host) => `ai tag debugger report: ${host}`,
@@ -78,6 +80,8 @@ const en = {
78
80
  customItemParams: "Custom item parameters",
79
81
  noItems: "no items",
80
82
  serverSideTag: "server-side",
83
+ lateHit: (ms) => `arrived ${(ms / 1000).toFixed(1)} s late`,
84
+ lateHitTip: (step) => `Sent after this step was over, during "${step}". It belongs to this step's page.`,
81
85
  source: "Source",
82
86
  request: "Request",
83
87
  noPushes: "No dataLayer pushes in this step.",
@@ -246,6 +250,8 @@ const en = {
246
250
  journeyIncomplete: (note) => `The journey did not reach its goal${note ? `: ${note}` : "."} Events after that point could not be checked.`,
247
251
  missingEvent: (platform, name) => `${label(platform)} ${q(name)} was not sent on this journey, although the journey reached its goal. ${label(platform)} recorded nothing for it.`,
248
252
  missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so it is unverified rather than missing.`,
253
+ missingEnhanced: (name, setting) => `GA4 ${q(name)} was not sent on this journey, although the journey reached its goal. GA4 sends it by itself, with no tag, when enhanced measurement is on: in GA4, Admin > Data streams > the web stream > Enhanced measurement, switch on "${en.ga4Settings[setting]}". A tag that sends it instead works too.`,
254
+ missingAutomatic: (name) => `GA4 ${q(name)} was not seen. GA4 makes it by itself, with no tag (first_visit and session_start from flags on its other hits, user_engagement when the page is hidden or left), so it needs no setup and is not counted as missing.`,
249
255
  missingParams: (name, params) => `${q(name)} was sent without ${andList(sortParams(params).map(q), "and")}.`,
250
256
  duplicate: (name, n) => `${q(name)} was sent ${n} times by one action. Every real one is counted ${n} times.`,
251
257
  purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, so ad platforms cannot optimise bids on it.`,
@@ -333,6 +339,7 @@ const en = {
333
339
  pii_datalayer: "The dataLayer holds unhashed personal data, so every script on the page can read it, not only the tag that needs it.",
334
340
  journey_incomplete: "The browser could not finish this journey, so every event after that point, including any conversion, is unverified.",
335
341
  unplanned_events: "Events fire that are not in the plan: either the plan is out of date or tags send things nobody asked for.",
342
+ ga4_automatic_event: "Nothing is lost: GA4 makes this event itself, and a short test visit can end before it does.",
336
343
  planned_out_of_scope: "Some planned events are on platforms this run did not check, so their status is unknown.",
337
344
  matches_plan: "These planned events fired with everything the plan requires.",
338
345
  },
@@ -369,6 +376,7 @@ const en = {
369
376
  pii_datalayer: "Push user data to the dataLayer already hashed, or restrict it to the tag that needs it and clear it afterwards.",
370
377
  journey_incomplete: "Read the note for why the browser stopped (a blocked click, a missing element, an error), fix the page or the step, and re-run the journey.",
371
378
  unplanned_events: "Add the events to the measurement plan if they are wanted, or remove the tags that send them.",
379
+ ga4_automatic_event: "No fix is needed. Keep the event in the plan for reference, or remove it, since no tag has to send it.",
372
380
  planned_out_of_scope: "Include their platforms in the next run's providers so they are checked.",
373
381
  matches_plan: "Nothing to fix.",
374
382
  },
@@ -405,6 +413,7 @@ const en = {
405
413
  pii_datalayer: "legal",
406
414
  journey_incomplete: "hygiene",
407
415
  unplanned_events: "hygiene",
416
+ ga4_automatic_event: "hygiene",
408
417
  planned_out_of_scope: "hygiene",
409
418
  matches_plan: "hygiene",
410
419
  },
@@ -720,6 +729,7 @@ const en = {
720
729
 
721
730
  const tr = {
722
731
  name: "Turkish",
732
+ ga4Settings: { pageViews: "Sayfa görüntülemeleri", scrolls: "Kaydırmalar", outboundClicks: "Giden tıklamalar", siteSearch: "Site araması", videoEngagement: "Video etkileşimi", fileDownloads: "Dosya indirmeleri", formInteractions: "Form etkileşimleri" },
723
733
  severity: { broken: "Bozuk", risk: "Risk", warning: "Uyarı", info: "Bilgi", ok: "Sorunsuz" },
724
734
  ui: {
725
735
  title: (host) => `ai tag debugger raporu: ${host}`,
@@ -774,6 +784,8 @@ const tr = {
774
784
  customItemParams: "Özel ürün parametreleri",
775
785
  noItems: "ürün yok",
776
786
  serverSideTag: "sunucu tarafı",
787
+ lateHit: (ms) => `${(ms / 1000).toFixed(1).replace(".", ",")} sn gecikmeli geldi`,
788
+ lateHitTip: (step) => `Bu adım bittikten sonra, "${step}" sırasında gönderildi. Bu adımın sayfasına ait.`,
777
789
  source: "Kaynak",
778
790
  request: "İstek",
779
791
  noPushes: "Bu adımda dataLayer gönderimi yok.",
@@ -938,6 +950,8 @@ const tr = {
938
950
  journeyIncomplete: (note) => `Yolculuk hedefine ulaşmadı${note ? `: ${note}` : "."} Bu noktadan sonraki olaylar kontrol edilemedi.`,
939
951
  missingEvent: (platform, name) => `${label(platform)} ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. ${label(platform)} bu olay için hiçbir veri almadı.`,
940
952
  missingEventEarly: (name) => `${q(name)} görülmedi; ancak yolculuk erken durduğu için bu olay eksik değil, doğrulanmamış sayılır.`,
953
+ missingEnhanced: (name, setting) => `GA4 ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. GA4 bu olayı geliştirilmiş ölçüm açıkken etiket gerekmeden kendisi gönderir: GA4'te Yönetici > Veri akışları > web akışı > Geliştirilmiş ölçüm altında "${tr.ga4Settings[setting]}" ayarını açın. Olayı gönderen bir etiket de aynı işi görür.`,
954
+ missingAutomatic: (name) => `GA4 ${q(name)} görülmedi. GA4 bu olayı etiket gerekmeden kendisi oluşturur (first_visit ve session_start diğer isteklerindeki işaretlerden, user_engagement sayfa gizlendiğinde ya da kapatıldığında), bu yüzden kurulum gerektirmez ve eksik sayılmaz.`,
941
955
  missingParams: (name, params) => `${q(name)} olayı ${andList(sortParams(params).map(q), "ve")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
942
956
  duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi. Her gerçek olay ${n} kez sayılıyor.`,
943
957
  purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez; reklam platformları teklifleri buna göre optimize edemez.`,
@@ -1024,6 +1038,7 @@ const tr = {
1024
1038
  pii_datalayer: "dataLayer karma yapılmamış kişisel veri tutuyor; sayfadaki her betik bunu okuyabilir, yalnızca ihtiyaç duyan etiket değil.",
1025
1039
  journey_incomplete: "Tarayıcı bu yolculuğu bitiremedi; o noktadan sonraki her olay, dönüşüm dâhil, doğrulanmamış durumda.",
1026
1040
  unplanned_events: "Planda olmayan olaylar gönderiliyor: ya plan güncel değil ya da etiketler kimsenin istemediği şeyler gönderiyor.",
1041
+ ga4_automatic_event: "Kayıp yok: GA4 bu olayı kendisi oluşturur ve kısa bir test ziyareti o olay gönderilmeden bitebilir.",
1027
1042
  planned_out_of_scope: "Plandaki bazı olaylar bu çalıştırmanın kontrol etmediği platformlarda; durumları bilinmiyor.",
1028
1043
  matches_plan: "Plandaki bu olaylar, planın istediği her şeyle birlikte gönderildi.",
1029
1044
  },
@@ -1058,6 +1073,7 @@ const tr = {
1058
1073
  pii_datalayer: "Kullanıcı verisini dataLayer'a karma yapılmış olarak gönderin ya da yalnızca ihtiyaç duyan etiketle sınırlayıp ardından temizleyin.",
1059
1074
  journey_incomplete: "Tarayıcının neden durduğunu nottan okuyun (engellenen bir tıklama, bulunamayan bir öğe, bir hata), sayfayı ya da adımı düzeltin ve yolculuğu yeniden çalıştırın.",
1060
1075
  unplanned_events: "İstenen olayları ölçüm planına ekleyin ya da onları gönderen etiketleri kaldırın.",
1076
+ ga4_automatic_event: "Düzeltme gerekmez. Olayı başvuru için planda tutabilir ya da kaldırabilirsiniz; hiçbir etiketin göndermesi gerekmez.",
1061
1077
  planned_out_of_scope: "Kontrol edilmeleri için platformlarını bir sonraki çalıştırmanın sağlayıcılarına ekleyin.",
1062
1078
  matches_plan: "Düzeltilecek bir şey yok.",
1063
1079
  },
@@ -1093,6 +1109,7 @@ const tr = {
1093
1109
  pii_datalayer: "legal",
1094
1110
  journey_incomplete: "hygiene",
1095
1111
  unplanned_events: "hygiene",
1112
+ ga4_automatic_event: "hygiene",
1096
1113
  planned_out_of_scope: "hygiene",
1097
1114
  matches_plan: "hygiene",
1098
1115
  },
package/src/mcp.js CHANGED
@@ -17,7 +17,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
17
17
  import { AuditSession } from "./browser.js";
18
18
  import { navigationError, privateHostChecker } from "./guard.js";
19
19
  import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
20
- import { checkRun, groupFindings, summarize } from "./checks.js";
20
+ import { checkRun, groupFindings, isConsentCommand, summarize } from "./checks.js";
21
21
  import { LANGUAGES, messages } from "./i18n.js";
22
22
  import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
23
23
  import { validatePlan } from "./schema.js";
@@ -29,7 +29,7 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
29
29
  import { createProvider } from "./providers.js";
30
30
  import { savePlan } from "./plan.js";
31
31
  import { changesLines, sinceLastRun } from "./changes.js";
32
- import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
32
+ import { DEFAULT_PROVIDERS, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
33
33
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
34
34
 
35
35
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -64,20 +64,68 @@ const audits = new Map();
64
64
  const text = (t) => ({ content: [{ type: "text", text: t }] });
65
65
  const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
66
66
 
67
- function describeHit(h) {
68
- const params = Object.entries(h.params ?? {})
69
- .filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k))
70
- .slice(0, 12)
71
- .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`.slice(0, 120))
72
- .join(", ");
73
- return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}`;
67
+ // Kept whole in the step result, wherever they appear: the end of a page address is where a UTM
68
+ // parameter or a gclid gets lost, so cutting it hides exactly what a campaign check needs.
69
+ const WHOLE_FIELDS = new Set(["page_location", "page_referrer"]);
70
+ const VALUE_LIMIT = 120;
71
+ const PUSH_LIMIT = 200;
72
+ const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
73
+ const show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
74
+
75
+ /** The page address fields anywhere in a pushed object, a few levels deep, as [name, value]. */
76
+ function wholeFieldsIn(value, depth = 0, out = []) {
77
+ if (!value || typeof value !== "object" || depth > 3) return out;
78
+ for (const [k, v] of Object.entries(value)) {
79
+ if (WHOLE_FIELDS.has(k) && typeof v === "string") out.push([k, v]);
80
+ else wholeFieldsIn(v, depth + 1, out);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function describeHit(h, steps) {
86
+ const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
87
+ const params = [
88
+ ...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
89
+ ...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
90
+ ].join(", ");
91
+ const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
92
+ return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}${late}`;
93
+ }
94
+
95
+ /** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
96
+ function describePush(p) {
97
+ if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
98
+ const signals = p.value[2] && typeof p.value[2] === "object" ? Object.entries(p.value[2]).map(([k, v]) => `${k}=${show(v)}`).join(" ") : show(p.value[2]);
99
+ return `- consent ${p.value[1]}: ${signals}`;
100
+ }
101
+ const json = JSON.stringify(p.value) ?? String(p.value);
102
+ const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
103
+ return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
104
+ }
105
+
106
+ /** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent parameters. */
107
+ function describeCall(c) {
108
+ let u;
109
+ try {
110
+ u = new URL(c.url);
111
+ } catch {
112
+ return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
113
+ }
114
+ const signals = ["gcs", "gcd", "dma", "npa"].filter((k) => u.searchParams.has(k)).map((k) => `${k}=${u.searchParams.get(k)}`).join(" ");
115
+ return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}`;
74
116
  }
75
117
 
76
- function stepCapture(audit, step) {
77
- const hits = audit.session.hits.filter((h) => h.step === step);
78
- const pushes = audit.session.dataLayerLog.filter((e) => e.step === step);
79
- const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map(describeHit)];
80
- if (pushes.length) lines.push(`dataLayer pushes: ${pushes.map((p) => JSON.stringify(p.value).slice(0, 160)).join(" | ")}`);
118
+ export function stepCapture(audit, step) {
119
+ const { session } = audit;
120
+ const hits = session.hits.filter((h) => h.step === step);
121
+ const pushes = session.dataLayerLog.filter((e) => e.step === step);
122
+ // Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
123
+ const lateHere = session.hits.filter((h) => h.late?.during === step);
124
+ const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
125
+ const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map((h) => describeHit(h, session.steps))];
126
+ if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
127
+ if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
128
+ if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
81
129
  return lines.join("\n");
82
130
  }
83
131
 
package/src/parsers.js CHANGED
@@ -64,6 +64,8 @@ export function parseGa4(url, postData) {
64
64
  for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
65
65
  const items = [...merged].filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v);
66
66
  const user = [...merged].filter(([k]) => isUserDataKey(k));
67
+ // GA4 makes first_visit and session_start from these flags on another hit; they are never sent by name.
68
+ const autoEvents = [["_fv", "first_visit"], ["_ss", "session_start"]].filter(([flag]) => merged.has(flag)).map(([, name]) => name);
67
69
  return {
68
70
  platform: "ga4",
69
71
  delivery: ga4Delivery(u),
@@ -72,6 +74,7 @@ export function parseGa4(url, postData) {
72
74
  params: ga4ParamsFrom(merged),
73
75
  consent: ga4Consent(merged),
74
76
  ...(items.length ? { items } : {}),
77
+ ...(autoEvents.length ? { autoEvents } : {}),
75
78
  ...(user.length ? { userFields: Object.fromEntries(user) } : {}),
76
79
  url,
77
80
  };
package/src/plan-html.js CHANGED
@@ -18,12 +18,12 @@ const PLAN_CSS = `
18
18
  .top-end{margin-left:auto;display:flex;align-items:center;gap:var(--s2)}
19
19
  .byline{display:inline-flex;align-items:center;gap:var(--s2);font-size:12px;color:var(--muted)}
20
20
  .logo{display:inline-flex}
21
- svg.logo{display:inline-block;flex:none;color:var(--fg)}
21
+ svg.logo{display:inline-block;flex:none;color:var(--fg-strong)}
22
22
  .logo .l-dark{display:none}
23
23
  html[data-theme="dark"] .logo .l-light{display:none}
24
24
  html[data-theme="dark"] .logo .l-dark{display:inline}
25
25
  @media (prefers-color-scheme:dark){html:not([data-theme="light"]) .logo .l-light{display:none}html:not([data-theme="light"]) .logo .l-dark{display:inline}}
26
- .eyebrow{font:600 11px/1.4 var(--font-sans);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
26
+ .eyebrow{font:500 12px/1.4 var(--font-sans);color:var(--muted)}
27
27
  .plan{max-width:1100px;margin:0 auto;padding:var(--s5) var(--s6) var(--s7)}
28
28
  .plan-title{display:grid;gap:var(--s2);margin-bottom:var(--s5)}
29
29
  .plan-title .chips{margin-top:var(--s1)}
@@ -39,8 +39,8 @@ html[data-theme="dark"] .logo .l-dark{display:inline}
39
39
  .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
40
40
  .pl-trigger b{font-weight:500;color:var(--fg)}
41
41
  .chip .plat{display:inline-flex;align-items:center;gap:5px}
42
- .chip.on{--k:var(--accent);--k-fg:var(--accent-fg);background:var(--accent-bg)}
43
- .chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg);background:var(--sev-warning-bg)}
42
+ .chip.on{--k:var(--accent);--k-fg:var(--accent-fg)}
43
+ .chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg)}
44
44
  .pl-card-body{padding:var(--s4)}
45
45
  .tbl td,.tbl th{vertical-align:top}
46
46
  /* a narrow table scrolls inside its .tscroll instead of breaking names mid-word */
package/src/verify.js CHANGED
@@ -14,6 +14,7 @@ const OWNER_BY_CHECK = {
14
14
  server_side: "developer",
15
15
  unplanned_events: "marketing",
16
16
  planned_out_of_scope: "marketing",
17
+ ga4_automatic_event: "marketing",
17
18
  };
18
19
 
19
20
  /** Who most likely fixes this finding: the site's developer, whoever runs Tag Manager, or marketing (the plan itself). Tag and consent setup is the default. */
package/src/whitelabel.js CHANGED
@@ -53,33 +53,66 @@ export const contrast = (a, b) => {
53
53
  return (h + 0.05) / (l + 0.05);
54
54
  };
55
55
  const MIN_TEXT_CONTRAST = 4.5;
56
- // The card surfaces of visual-system.md §1, which the link colour must read on.
57
- const LIGHT_CARD = "#ffffff";
58
- const DARK_CARD = "#141927";
56
+ const MIN_MARK_CONTRAST = 3;
57
+ // The grounds of visual-system.md §1 the accent is drawn on, in each theme: card (Tally's surface), page (bg), card-2
58
+ // (surface-2: hovered and selected rows, hovered controls) and well. Links sit on all four; the tint is added per colour.
59
+ const GROUNDS = {
60
+ light: ["#ffffff", "#f4f6fa", "#ecf1f8", "#e9eef5"],
61
+ dark: ["#0e1e34", "#071529", "#15263f", "#040e1f"],
62
+ };
63
+ const BLACK = [0, 0, 0];
64
+ const WHITE = [255, 255, 255];
65
+ // The dark tint is the brand colour laid on the navy card, as the default --accent-bg is.
66
+ const DARK_CARD_RGB = rgb(GROUNDS.dark[0]);
59
67
 
60
- /** The colour mixed step by step towards `toward` until it reads at 4.5:1 on `surface`; the input when it already does. */
61
- function readable(hex, toward, surface) {
68
+ /**
69
+ * The colour mixed step by step towards `toward` until it reads at `min`:1 on every ground; the input when it already
70
+ * does. Mixing towards black or white keeps the hue, so a brand colour only gets darker or lighter, never another colour.
71
+ */
72
+ function readable(hex, toward, grounds, min) {
62
73
  let c = hex;
63
- for (let i = 0; i < 20 && contrast(c, surface) < MIN_TEXT_CONTRAST; i++) c = mix(c, toward, 0.1);
74
+ while (grounds.some((g) => contrast(c, g) < min)) {
75
+ const next = mix(c, toward, 0.1);
76
+ if (next === c) break; // as far as rounding goes: black and white pass every ground long before this
77
+ c = next;
78
+ }
64
79
  return c;
65
80
  }
66
81
 
67
82
  /**
68
- * CSS that recolors the report's accent: the active tab rule, focus rings, links and the step plates (visual-system.md §9).
69
- * The light theme gets a link colour darkened until it reads on a white card, the dark theme one lightened until it
70
- * reads on the dark card, so a pale brand colour never gives unreadable links. The dark set is written under both dark
71
- * selectors the report uses, so a forced light theme on a dark OS keeps the light accent.
83
+ * The three accent tokens for a brand colour, per theme. The tint (--accent-bg) is the brand colour on the card: 10% on
84
+ * white in light, 25% on the navy in dark. The link colour (--accent-fg) is darkened (light) or lightened (dark) until it
85
+ * reads at 4.5:1 on the card, the page, card-2, the well and that tint; the colour for marks (--accent: the active tab
86
+ * and rail rule, focus rings, pressed filter edges) until it reaches 3:1 on the same grounds. A colour that already
87
+ * passes is left as it is.
88
+ * @returns {{ light: { accent: string, fg: string, bg: string }, dark: { accent: string, fg: string, bg: string } }}
89
+ */
90
+ export function brandTokens(color) {
91
+ const lightBg = mix(color, WHITE, 0.9);
92
+ const darkBg = mix(color, DARK_CARD_RGB, 0.75);
93
+ const light = [...GROUNDS.light, lightBg];
94
+ const dark = [...GROUNDS.dark, darkBg];
95
+ return {
96
+ light: { accent: readable(color, BLACK, light, MIN_MARK_CONTRAST), fg: readable(mix(color, BLACK, 0.2), BLACK, light, MIN_TEXT_CONTRAST), bg: lightBg },
97
+ dark: { accent: readable(mix(color, WHITE, 0.2), WHITE, dark, MIN_MARK_CONTRAST), fg: readable(mix(color, WHITE, 0.5), WHITE, dark, MIN_TEXT_CONTRAST), bg: darkBg },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * CSS that recolors the report's accent: the active tab rule, focus rings, links and the step plates (visual-system.md §9),
103
+ * from brandTokens(), so a pale or a very dark brand colour never gives unreadable links or invisible focus rings in either
104
+ * theme. The dark set is written under both dark selectors the report uses, so a forced light theme on a dark OS keeps
105
+ * the light accent.
72
106
  */
73
107
  export function brandCss(color) {
74
108
  if (!color) return "";
75
- const black = [0, 0, 0], white = [255, 255, 255];
76
- const light = `--accent:${color};--accent-fg:${readable(mix(color, black, 0.2), black, LIGHT_CARD)};--accent-bg:${mix(color, white, 0.9)}`;
77
- const dark = `--accent:${mix(color, white, 0.2)};--accent-fg:${readable(mix(color, white, 0.5), white, DARK_CARD)};--accent-bg:${mix(color, [10, 14, 26], 0.75)}`;
78
- return `:root{${light}}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${dark}}}:root[data-theme="dark"]{${dark}}`;
109
+ const { light, dark } = brandTokens(color);
110
+ const set = (t) => `--accent:${t.accent};--accent-fg:${t.fg};--accent-bg:${t.bg}`;
111
+ return `:root{${set(light)}}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${set(dark)}}}:root[data-theme="dark"]{${set(dark)}}`;
79
112
  }
80
113
 
81
114
  // A forwarded PDF loses its file name, so every page carries the document title and the page number (Chromium fills the spans).
82
- const PDF_FOOTER = '<div style="width:100%;font:9px system-ui,sans-serif;color:#4b5567;padding:0 10mm;display:flex;justify-content:space-between"><span class="title"></span><span><span class="pageNumber"></span> / <span class="totalPages"></span></span></div>';
115
+ const PDF_FOOTER = '<div style="width:100%;font:9px \'Google Sans\',system-ui,sans-serif;color:#566479;padding:0 10mm;display:flex;justify-content:space-between"><span class="title"></span><span><span class="pageNumber"></span> / <span class="totalPages"></span></span></div>';
83
116
 
84
117
  /** Prints an HTML report to a PDF with Chromium. The print styles stack every tab and open every details, so the PDF holds the whole report. */
85
118
  export async function writePdf(htmlPath, pdfPath) {