@analitikisler/ai-tag-debugger 0.18.0 → 0.20.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/src/html/lib.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // renderer imports its icon, logo and id helpers from here, so the sections cannot drift apart while
4
4
  // both exist; this module imports neither of them. CONTRACT-IMPL.md in the build notes lists what each helper is for.
5
5
  import { hitMatchesEvent } from "../checks.js";
6
- import { LOGOS } from "../brand.js";
6
+ import { LOGOS, LOGO_ART } from "../brand.js";
7
7
  import { OWNERS, ownerOf } from "../verify.js";
8
8
  import { escapeHtml, platformLabel, safeKey, sortPlatforms } from "../report-shared.js";
9
9
 
@@ -52,9 +52,10 @@ const use = (id, size = 16, cls = "") => `<svg class="ic${cls ? ` ${cls}` : ""}"
52
52
 
53
53
  /** The sprite with only the symbols the page references, so a report never carries artwork it does not show. */
54
54
  function sprite(html) {
55
- const used = new Set([...html.matchAll(/href="#((?:sev|ui)-[a-z]+|p-[a-z0-9_]+)"/g)].map((x) => x[1]));
55
+ const used = new Set([...html.matchAll(/href="#((?:sev|ui)-[a-z]+|p-[a-z0-9_]+|logo-[a-z-]+)"/g)].map((x) => x[1]));
56
56
  const symbols = [...used].sort().map((id) => {
57
57
  if (id.startsWith("p-")) return `<symbol id="${id}" viewBox="0 0 24 24">${PLATFORM_ART[id.slice(2)]}</symbol>`;
58
+ if (id.startsWith("logo-")) return `<symbol id="${id}" viewBox="${LOGO_ART[id.slice(5)].viewBox}">${LOGO_ART[id.slice(5)].art}</symbol>`;
58
59
  return `<symbol id="${id}" ${STROKE}>${UI_SYMBOLS[id]}</symbol>`;
59
60
  });
60
61
  return `<svg hidden style="display:none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">${symbols.join("")}</svg>`;
@@ -84,8 +85,18 @@ const hostPath = (url) => {
84
85
 
85
86
  /** A severity badge: glyph and word, never colour alone. A badge without a severity is a plain grey chip. */
86
87
  const badge = (sev, text) => (UI_SYMBOLS[`sev-${sev}`] ? `<span class="badge sev-${sev}">${use(`sev-${sev}`, 14)}${escapeHtml(text)}</span>` : `<span class="badge">${escapeHtml(text)}</span>`);
87
- /** A logo with its light and dark versions as two images; the CSS shows the one that fits the colour scheme or the forced theme. */
88
- export const logo = (key, alt, height, cls) => `<span class="logo ${cls}"><img class="l-light" src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"><img class="l-dark" src="${LOGOS[`${key}-dark`]}" alt="${alt}" height="${height}"></span>`;
88
+ /**
89
+ * A logo at a given height. The brand's own logos (LOGO_ART) are drawn from the sprite, so their ink is the text colour and
90
+ * only the point is red, as brand/ai/BRAND.md §4 asks of a product's UI. Anything else in LOGOS (the favicon tiles, which
91
+ * carry their own ground) comes as its light and dark images, and the CSS shows the one that fits the colour scheme.
92
+ */
93
+ export const logo = (key, alt, height, cls) => {
94
+ const art = LOGO_ART[key];
95
+ if (!art) return `<span class="logo ${cls}"><img class="l-light" src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"><img class="l-dark" src="${LOGOS[`${key}-dark`]}" alt="${alt}" height="${height}"></span>`;
96
+ const [, , w, h] = art.viewBox.split(" ").map(Number);
97
+ const named = alt ? `role="img" aria-label="${escapeHtml(alt)}"` : 'aria-hidden="true"';
98
+ return `<svg class="logo ${cls}" width="${+((height * w) / h).toFixed(1)}" height="${height}" ${named}><use href="#logo-${key}"/></svg>`;
99
+ };
89
100
 
90
101
  /** The journey and step a finding's place points at. Steps are named by label; when labels repeat, prefer the step where the finding's event fired. */
91
102
  function whereTarget(runs, w, f) {
package/src/html/shell.js CHANGED
@@ -125,9 +125,9 @@ function topBar(ctx) {
125
125
  const t = m.html.shell;
126
126
  const brandName = brand?.name ? escapeHtml(brand.name) : "";
127
127
  const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
128
- const product = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : `${logo("atd-lockup", "AI Tag Debugger", 22, "l-lockup")}<img class="l-mark" src="${LOGOS["atd-icon"]}" alt="AI Tag Debugger" height="24">`;
129
- const atdByline = whiteLabel ? `<a href="${ATD_REPO}" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 16, "logo-atd")}</a>` : "";
130
- const aiByline = branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 16, "logo-h")}</a>` : "";
128
+ const product = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : `${logo("atd-lockup", "ai tag debugger", 22, "l-lockup")}${logo("mark", "ai tag debugger", 24, "l-mark")}`;
129
+ const atdByline = whiteLabel ? `<a href="${ATD_REPO}" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "ai tag debugger", 16, "logo-atd")}</a>` : "";
130
+ const aiByline = branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("master", "Analitik İşler", 16, "logo-h")}</a>` : "";
131
131
  const word = m.verdict.status[metrics.verdict.status];
132
132
  // 0–49 red, 50–89 amber, 90–100 green, as Lighthouse bands its scores; no score reads as a fail.
133
133
  const band = tally.score === null || tally.score < 50 ? "fail" : tally.score < 90 ? "mid" : "pass";
@@ -139,11 +139,11 @@ function topBar(ctx) {
139
139
 
140
140
  function footer(ctx) {
141
141
  const { m, site, title, generatedAt, brand, branding, whiteLabel } = ctx;
142
- const atd = `<a class="f-atd" href="${ATD_REPO}" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="14"> AI Tag Debugger</a>`;
142
+ const atd = `<a class="f-atd" href="${ATD_REPO}" rel="noopener noreferrer">${logo("favicon", "", 16, "f-mark")} ai tag debugger</a>`;
143
143
  const meta = `<span class="muted">${escapeHtml(site ?? title)} · ${escapeHtml(generatedAt)} · v${VERSION}</span>`;
144
- if (branding) return `<footer class="footer"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 40, "logo-s")}</a><p class="xs">${m.ui.credit(atd)} ${meta}</p></footer>`;
144
+ if (branding) return `<footer class="footer"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("master", "Analitik İşler", 24, "logo-s")}</a><p class="xs">${m.ui.credit(atd)} ${meta}</p></footer>`;
145
145
  const brandName = brand?.name ? escapeHtml(brand.name) : "";
146
- return `<footer class="footer"><p class="xs">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"} ${meta}</p></footer>`;
146
+ return `<footer class="footer"><p class="xs">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "ai tag debugger"} ${meta}</p></footer>`;
147
147
  }
148
148
 
149
149
  /** The expand / collapse pair under a section's h2, for every section but Summary that holds a details.card. */
@@ -172,7 +172,7 @@ export function renderHtml(report) {
172
172
  <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
173
173
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${scriptCspSource()}">
174
174
  <meta name="color-scheme" content="light dark">
175
- <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
175
+ <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/svg+xml"'} href="${brand?.logo ?? LOGOS["favicon-light"]}">
176
176
  <style>${CSS}${brandCss(brand?.color)}</style></head><body>
177
177
  <a class="skip" href="#main">${escapeHtml(t.skip)}</a>
178
178
  ${sprite(`${top}${nav}${sections}${foot}`)}
package/src/i18n.js CHANGED
@@ -26,7 +26,7 @@ const en = {
26
26
  name: "English",
27
27
  severity: { broken: "Broken", risk: "Risk", warning: "Warning", info: "Info", ok: "OK" },
28
28
  ui: {
29
- title: (host) => `AI Tag Debugger report: ${host}`,
29
+ title: (host) => `ai tag debugger report: ${host}`,
30
30
  generated: (at) => `Generated ${at} by ai-tag-debugger · Analitik İşler`,
31
31
  summary: "Summary",
32
32
  eyebrow: "AI tracking audit",
@@ -64,7 +64,7 @@ const en = {
64
64
  status: "Status",
65
65
  vendor: "Vendor",
66
66
  clickedThrough: "clicked through an overlapping element",
67
- generatedBy: "Generated by AI Tag Debugger",
67
+ generatedBy: "Generated by ai tag debugger",
68
68
  nothing: "None",
69
69
  counts: (c) => `${c.broken} broken · ${c.risk} risk · ${c.warning} warning · ${c.info} info · ${c.ok} ok`,
70
70
  accepted: "Accepted",
@@ -722,8 +722,8 @@ const tr = {
722
722
  name: "Turkish",
723
723
  severity: { broken: "Bozuk", risk: "Risk", warning: "Uyarı", info: "Bilgi", ok: "Sorunsuz" },
724
724
  ui: {
725
- title: (host) => `AI Tag Debugger raporu: ${host}`,
726
- generated: (at) => `${at} tarihinde ai-tag-debugger ile oluşturuldu · Analitik İşler`,
725
+ title: (host) => `ai tag debugger raporu: ${host}`,
726
+ generated: (at) => `${at} tarihinde ai tag debugger ile oluşturuldu · Analitik İşler`,
727
727
  summary: "Özet",
728
728
  eyebrow: "Yapay zekâ ile izleme denetimi",
729
729
  paramsShort: "parametre",
@@ -760,7 +760,7 @@ const tr = {
760
760
  status: "Durum",
761
761
  vendor: "Sağlayıcı",
762
762
  clickedThrough: "üstündeki bir öğeye rağmen tıklandı",
763
- generatedBy: "AI Tag Debugger ile oluşturuldu",
763
+ generatedBy: "ai tag debugger ile oluşturuldu",
764
764
  nothing: "Yok",
765
765
  counts: (c) => `${c.broken} bozuk · ${c.risk} risk · ${c.warning} uyarı · ${c.info} bilgi · ${c.ok} sorunsuz`,
766
766
  accepted: "Kabul edildi",
package/src/plan-html.js CHANGED
@@ -18,11 +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-strong)}
21
22
  .logo .l-dark{display:none}
22
23
  html[data-theme="dark"] .logo .l-light{display:none}
23
24
  html[data-theme="dark"] .logo .l-dark{display:inline}
24
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}}
25
- .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)}
26
27
  .plan{max-width:1100px;margin:0 auto;padding:var(--s5) var(--s6) var(--s7)}
27
28
  .plan-title{display:grid;gap:var(--s2);margin-bottom:var(--s5)}
28
29
  .plan-title .chips{margin-top:var(--s1)}
@@ -38,8 +39,8 @@ html[data-theme="dark"] .logo .l-dark{display:inline}
38
39
  .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
39
40
  .pl-trigger b{font-weight:500;color:var(--fg)}
40
41
  .chip .plat{display:inline-flex;align-items:center;gap:5px}
41
- .chip.on{--k:var(--accent);--k-fg:var(--accent-fg);background:var(--accent-bg)}
42
- .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)}
43
44
  .pl-card-body{padding:var(--s4)}
44
45
  .tbl td,.tbl th{vertical-align:top}
45
46
  /* a narrow table scrolls inside its .tscroll instead of breaking names mid-word */
@@ -99,12 +100,12 @@ export function renderPlanHtml(plan, { id = "", generatedAt = "", branding = tru
99
100
  const events = plan.events ?? [];
100
101
  const custom = events.filter((ev) => ev.custom).length;
101
102
  const title = `Measurement plan${plan.site ? `: ${plan.site}` : ""}`;
102
- const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai-tag-debugger ${VERSION}`].filter(Boolean).join(" · ");
103
+ const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai tag debugger ${VERSION}`].filter(Boolean).join(" · ");
103
104
  const consent = plan.consent_mode ? flag("Consent mode on", "on") : flag("Consent mode off");
104
105
  const footer = branding
105
- ? `<a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 40, "logo-s")}</a><div class="f-links xs"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div><p class="f-meta xs muted">v${VERSION}</p>`
106
- : `<p class="xs">AI Tag Debugger</p><p class="f-meta xs muted">v${VERSION}</p>`;
107
- const body = `<header class="top"><span class="brand">${logo("atd-lockup", "AI Tag Debugger", 22, "l-lockup")}<img class="l-mark" src="${LOGOS["atd-icon"]}" alt="AI Tag Debugger" height="24"></span><span class="top-site"><span class="eyebrow">Measurement plan</span>${plan.site ? `<b>${e(plan.site)}</b>` : ""}</span><div class="top-end">${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 16, "logo-h")}</a>` : ""}</div></header>
106
+ ? `<a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("master", "Analitik İşler", 24, "logo-s")}</a><div class="f-links xs"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div><p class="f-meta xs muted">v${VERSION}</p>`
107
+ : `<p class="xs">ai tag debugger</p><p class="f-meta xs muted">v${VERSION}</p>`;
108
+ const body = `<header class="top"><span class="brand">${logo("atd-lockup", "ai tag debugger", 22, "l-lockup")}${logo("mark", "ai tag debugger", 24, "l-mark")}</span><span class="top-site"><span class="eyebrow">Measurement plan</span>${plan.site ? `<b>${e(plan.site)}</b>` : ""}</span><div class="top-end">${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("master", "Analitik İşler", 16, "logo-h")}</a>` : ""}</div></header>
108
109
  <main class="plan">
109
110
  <div class="plan-title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="muted xs">${e(meta)}</div><div class="chips">${consent}</div></div>
110
111
  <section class="card"><div class="pl-card-h"><h2>Events</h2><p class="pl-card-d">What should be tracked, where it fires and which parameters it carries.</p></div>${events.length ? events.map(eventCard).join("") : `<p class="pl-empty muted xs">No events.</p>`}</section>
@@ -114,7 +115,7 @@ ${userPropertiesCard(plan.user_properties ?? [])}
114
115
  return `<!doctype html>
115
116
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
116
117
  <meta name="color-scheme" content="light dark">
117
- <title>${e(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
118
+ <title>${e(title)}</title><link rel="icon" type="image/svg+xml" href="${LOGOS["favicon-light"]}">
118
119
  <style>${TOKENS}\n${COMPONENTS}\n${PRODUCT}\n${PLAN_CSS}</style></head><body>
119
120
  ${sprite(body)}
120
121
  ${body}
package/src/report.js CHANGED
@@ -441,7 +441,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
441
441
  lines.push(...mdJourneys(m, runs, rows));
442
442
  lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
443
443
  lines.push(...mdInventory(m, runs));
444
- // A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
444
+ // A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
445
445
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
446
446
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
447
447
  return lines.join("\n");
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) {