// Analytics consent: the opt-in gate in front of the session-scoped tier. // // WHY THIS EXISTS. The anonymous tier (hourly counts, no identifier, nothing // written to your device) needs no permission. The session tier does: it mints // an ephemeral id and stores it in sessionStorage, and storing an identifier on // someone's device engages ePrivacy Art. 5(3). Product analytics is not // "strictly necessary to provide a service the user explicitly requested", so // the exemption does not apply and the lawful basis is opt-in consent. // // RULES THIS FILE ENFORCES, all of them load-bearing: // * Nothing is stored and no id is minted before an explicit "Allow". Not on // first paint, not "pending a choice" — refusing by ignoring the banner must // leave exactly as little behind as refusing explicitly. // * Allow and No are the same size, same weight, same prominence. No pre-tick, // no "legitimate interest" second layer, no cookie-wall. // * Withdrawal is as easy as granting, reachable at any time from the footer // and the privacy page, and it deletes the live session id immediately. // * GPC / DNT are treated as a refusal already given: no banner is shown and // nothing is stored. Stricter than the letter of the law, and it means the // most privacy-conscious visitors are never nagged. // // The consent RECORD itself lives in localStorage. Storing a user's own choice // is strictly necessary to honour it, so it is exempt from the consent it // records — but it is deliberately the only thing kept across sessions. const KEY = "thermograph:analytics-consent"; //: Bump to re-ask everyone — only when what is collected materially changes. const POLICY_VERSION = 1; //: Re-ask a granter after this long. Never re-ask a refuser inside it: a banner //: that keeps reappearing after "no" is a dark pattern, not a question. const GRANT_TTL_MS = 183 * 24 * 3600 * 1000; // ~6 months const DENY_TTL_MS = 365 * 24 * 3600 * 1000; // ~12 months export const signalledOptOut = navigator.globalPrivacyControl === true || navigator.doNotTrack === "1" || window.doNotTrack === "1"; const listeners = []; let state = null; // "granted" | "denied" | null (not asked) function read() { try { const raw = localStorage.getItem(KEY); if (!raw) return null; const rec = JSON.parse(raw); if (rec.v !== POLICY_VERSION) return null; const ttl = rec.choice === "granted" ? GRANT_TTL_MS : DENY_TTL_MS; if (!rec.ts || Date.now() - rec.ts > ttl) return null; return rec.choice === "granted" ? "granted" : "denied"; } catch { return null; } } function write(choice) { try { localStorage.setItem(KEY, JSON.stringify({ v: POLICY_VERSION, choice, ts: Date.now() })); } catch { /* private mode: the choice just won't persist */ } } /** "granted" | "denied" | null. GPC/DNT short-circuits to "denied". */ export function consentState() { if (signalledOptOut) return "denied"; if (state === null) state = read(); return state; } export const hasConsent = () => consentState() === "granted"; /** Called with the new state whenever it changes — track.js uses this to drop * the live session id the instant consent is withdrawn. */ export function onConsentChange(cb) { listeners.push(cb); } export function setConsent(choice) { state = choice === "granted" ? "granted" : "denied"; write(state); hideBanner(); paintControls(); for (const cb of listeners) { try { cb(state); } catch { /* never break */ } } } // --- the banner --------------------------------------------------------------- // Rendered only when instrumentation is enabled AND no choice has been recorded // AND no opt-out signal is present. It is a bar, not a modal: it never blocks // reading the page, which is the whole product. let banner = null; function hideBanner() { if (banner) { banner.remove(); banner = null; } } function showBanner() { if (banner) return; banner = document.createElement("div"); banner.className = "consent-bar"; banner.setAttribute("role", "dialog"); banner.setAttribute("aria-label", "Analytics"); banner.innerHTML = `
Can we count how this page gets used? Anonymous, kept for 30 days, never shared, and never linked to you or to an account. What this collects
`; // The privacy link is resolved against the page, not hardcoded, so it is right // under both the root-served and /thermograph-prefixed deployments. const more = banner.querySelector(".consent-more"); more.href = new URL("privacy", document.baseURI).pathname; banner.querySelector(".consent-yes").addEventListener("click", () => setConsent("granted")); banner.querySelector(".consent-no").addEventListener("click", () => setConsent("denied")); document.body.appendChild(banner); } // --- the withdrawal control --------------------------------------------------- // Any [data-consent-control] element becomes a live "Analytics: on/off — change" // toggle. The footer carries one on every page, so withdrawing is never more // than one click from wherever the visitor is. function paintControls() { const s = consentState(); for (const el of document.querySelectorAll("[data-consent-control]")) { if (signalledOptOut) { el.textContent = "Analytics: off (your browser asked us not to)"; continue; } const on = s === "granted"; el.innerHTML = `Analytics: ${on ? "on" : "off"} `; el.querySelector(".consent-toggle").addEventListener( "click", () => setConsent(on ? "denied" : "granted")); } } export function initConsent() { // Instrumentation disabled for this environment: no banner, no control, no // storage. The flag is the outermost gate on everything. if (document.documentElement.dataset.tgEvents !== "1") return; paintControls(); if (signalledOptOut) return; // already refused, at the browser level if (consentState() === null) showBanner(); } initConsent();