From 028579c6553165dd4193d019c78bbd88e5939638 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 6 Sep 2026 07:34:06 -0700 Subject: [PATCH 1/4] feat(site): narrow the analytics gate to what is actually asked of it The gate refused three things beyond the one the law requires, and each was costing readers that the numbers then had to be read without. Do Not Track is gone. The W3C withdrew the specification in 2019, Safari removed the header, and the browsers that still carry the setting leave it off, so a site that honours it is not honouring a decision -- it is honouring an accident of which browser build the reader happens to run. There is no obligation behind it and no intent expressed by it. Global Privacy Control stays, but as a default rather than a veto. It is a recognised opt-out signal in several US states and worth respecting where the reader has said nothing else, so a reader who sends one and has not used the control is treated as having declined, and is not asked either. What it no longer does is outrank the reader. Brave and DuckDuckGo send GPC by default, so the old ordering meant a reader who explicitly clicked Allow was still refused by a preference their browser had expressed on their behalf -- the site overriding a person with a default, while claiming to do the opposite. Guernsey, the Isle of Man and Jersey leave the prior-consent list. Their data protection law follows the GDPR, but none of them enacted a PECR equivalent, and it is PECR, not the GDPR, that governs storing an identifier on a device. They were on the list out of caution rather than because the rule reached them. The refusal branch at the bottom of the file goes with them, and with it the revoke that 3fa596f added upstream in onebox. That code existed to delete an identifier left behind when a granted reader later switched a signal on. A granted reader is now measured with the signal on, so nothing is left behind to delete, and the case it guarded cannot arise. The stop() that remains is in apply(), where a signal without a stored answer clears an identifier whose preference has since been cleared. The privacy page and the standing control on it say all of this. The buttons there are no longer disabled underneath a sentence explaining that they do not matter, because they now do. Driven against the script extracted from the built dist/index.html, with the trace lookup and gtag stubbed, over the nine cases that define the gate: no GPC US no answer GA runs, no banner no GPC DE no answer no GA, banner no GPC GB no answer no GA, banner no GPC JE no answer GA runs, no banner no GPC XX no answer no GA, banner no GPC US denied no GA, no banner GPC US no answer no GA, no banner GPC US granted GA runs, no banner GPC DE granted GA runs, no banner Claude-Session: https://claude.ai/code/session_01BKURdfwTvLLaBeDUppLHxx --- site/src/components/Analytics.astro | 67 ++++++++++--------- site/src/components/AnalyticsPreference.astro | 25 +++---- site/src/content/docs/privacy.mdx | 17 +++-- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index 2cb02252..e51e5163 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -14,17 +14,22 @@ * mean something. * * Whether consent must be asked for is a question about the reader's location, - * not about this site. The EEA, the UK and the Crown Dependencies require - * permission before the cookie is set; elsewhere measurement may run with a way - * to turn it off. Cloudflare already knows which one applies and says so at + * not about this site. The EEA and the UK require permission before the cookie + * is set; elsewhere measurement may run with a way to turn it off. Cloudflare already knows which one applies and says so at * /cdn-cgi/trace, so most readers are measured properly and never see a banner, * and the readers who are owed the question get it. A failed or unavailable * lookup is treated as "ask" — the safe direction, and what local development * gets, where the whole component is compiled out anyway. * - * Do Not Track and Global Privacy Control are honoured above all of it. A - * reader who has already answered this question in their browser is not asked - * it again, and is not measured either way. + * Global Privacy Control stands in for an answer that has not been given here. + * A reader who sends one and has not used the control below is treated as + * having declined, and is not asked either. It does not outrank an answer given + * on this site: Allow is the later and more specific statement of the same + * preference, so a reader who clicks it is measured with the signal still on. + * + * Do Not Track is not consulted. The W3C withdrew the specification in 2019, + * Safari removed the header, and Chrome and Firefox leave it off by default, so + * it carries neither a legal obligation nor a reliable statement of intent. * * The preference lives in localStorage rather than a cookie, so declining * stores nothing that travels with a request, and is mirrored across tabs. The @@ -51,14 +56,19 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; var STORAGE_KEY = "fanout.analytics-consent.v1"; var PREFERENCE_EVENT = "fanout:analytics-preference"; - // The EEA, the UK and the Crown Dependencies: the places that require - // permission before a browser stores an analytics identifier. A country - // this list does not name gets measurement with an opt-out instead. + // The EEA and the UK: the places whose law requires permission before a + // browser stores an analytics identifier. A country this list does not + // name gets measurement with an opt-out instead. + // + // Guernsey, the Isle of Man and Jersey were on this list and are not now. + // Their data protection law follows the GDPR, but none of them enacted a + // PECR equivalent, so the rule that governs storing an identifier does + // not reach them. var PRIOR_CONSENT = [ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", - "GB", "GG", "IM", "JE", + "GB", ]; var memoryPreference = null; @@ -66,12 +76,10 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; var listenersAttached = false; var requirementPromise = null; - // A reader who has set either signal has answered this already. - function signalsRefusal() { - var flags = [navigator.doNotTrack, window.doNotTrack, navigator.msDoNotTrack]; - for (var i = 0; i < flags.length; i++) { - if (flags[i] === "1" || flags[i] === "yes") return true; - } + // A default answer for a reader who has not given one here, rather than a + // veto over the one they do give. Consulted only where the preference is + // absent -- see mayRun and apply. + function signalsOptOut() { return navigator.globalPrivacyControl === true; } @@ -144,10 +152,6 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; } function start() { - // Above the stored preference on purpose: a browser-level refusal is - // not overridden by a click on this site's own control. - if (signalsRefusal()) return; - window["ga-disable-" + measurementId] = false; // Allowing after declining, without leaving the page. The tag is @@ -315,6 +319,17 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; if (accepted) accepted.hidden = true; return; } + // No answer stored here. A browser-level opt-out supplies one, and a + // reader who has already refused globally is not asked again. stop() + // rather than nothing, so an identifier left by an earlier grant whose + // preference has since been cleared does not survive the signal. + if (signalsOptOut()) { + stop(); + var refused = banner(); + if (refused) refused.hidden = true; + return; + } + consentRequirement().then(function (requirement) { if (readPreference() !== null) return; if (mayRun(requirement, null)) { @@ -331,7 +346,7 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; preference: readPreference, setPreference: writePreference, requirement: consentRequirement, - refused: signalsRefusal, + optedOut: signalsOptOut, subscribe: function (onChange) { window.addEventListener(PREFERENCE_EVENT, onChange); window.addEventListener("storage", function (event) { @@ -359,16 +374,6 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; } } - if (signalsRefusal()) { - // Neither measured nor asked. The banner stays in the document, - // hidden, so nothing depends on it having rendered. - ready(function () { - var element = banner(); - if (element) element.hidden = true; - }); - return; - } - ready(function () { var element = banner(); if (element) { diff --git a/site/src/components/AnalyticsPreference.astro b/site/src/components/AnalyticsPreference.astro index 659a07c6..83db6737 100644 --- a/site/src/components/AnalyticsPreference.astro +++ b/site/src/components/AnalyticsPreference.astro @@ -60,19 +60,10 @@ const enabled = function render() { var preference = api.preference(); - if (api.refused()) { - status.textContent = - "Your browser sends a Do Not Track or Global Privacy Control signal, so " + - "analytics do not run here whatever this control says."; - for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true; - return; - } - - for (var j = 0; j < buttons.length; j++) { - buttons[j].disabled = false; - buttons[j].setAttribute( + for (var i = 0; i < buttons.length; i++) { + buttons[i].setAttribute( "aria-pressed", - String(buttons[j].dataset.consent === preference), + String(buttons[i].dataset.consent === preference), ); } @@ -85,6 +76,16 @@ const enabled = return; } + // Nothing has been answered here. A Global Privacy Control signal is the + // answer until this control gives a different one, which is why the + // buttons stay live rather than being disabled underneath the sentence. + if (api.optedOut()) { + status.textContent = + "Your browser sends a Global Privacy Control signal, so analytics are off " + + "in this browser. Allow turns them on."; + return; + } + api.requirement().then(function (requirement) { if (api.preference() !== null) return; status.textContent = diff --git a/site/src/content/docs/privacy.mdx b/site/src/content/docs/privacy.mdx index 059b0af1..33bc9ad3 100644 --- a/site/src/content/docs/privacy.mdx +++ b/site/src/content/docs/privacy.mdx @@ -22,13 +22,18 @@ nothing on this page is in it. ### When it runs -- **In the EEA, the UK, Guernsey, the Isle of Man and Jersey**, nothing is - loaded and no identifier is stored until you say yes. The prompt is the only - thing that appears before you answer. +- **In the EEA and the UK**, nothing is loaded and no identifier is stored + until you say yes. The prompt is the only thing that appears before you + answer. - **Everywhere else**, analytics run and the control above turns them off. -- **If your browser sends Do Not Track or Global Privacy Control**, analytics - never run and you are never asked. That signal wins over everything else on - this page, including the buttons above. +- **If your browser sends Global Privacy Control**, analytics do not run and + you are not asked, because you have already answered. Choosing Allow above + overrides it for this browser: it is the more specific answer, and this site + does not treat a browser default as outranking your own decision. + +Do Not Track is not consulted. The specification was withdrawn in 2019, Safari +removed the header, and the browsers that still send one leave it off by +default, so it says nothing reliable about what a reader wants. Which of these applies is decided from the country Cloudflare reports for your connection. If that lookup fails, the site asks rather than assumes. From c060460a143e51b5d059b9a750abe6495f7d087f Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 6 Sep 2026 07:34:35 -0700 Subject: [PATCH 2/4] fix(site): tell the privacy control when the answer was cleared The listener that re-applies the preference treats a null storage key as the clear() case and acts on it. The one inside subscribe, which exists to redraw the control on /privacy, did not: it compared the key to STORAGE_KEY and nothing else. So a reader who cleared site data in another tab left /privacy displaying the answer they had just deleted, next to buttons whose pressed state described it. Measurement was correct throughout -- the other listener saw the event -- but the page said otherwise, which is the half of this feature the page exists to get right. onebox has carried the null case since 8146452. This is the same fix. Claude-Session: https://claude.ai/code/session_01BKURdfwTvLLaBeDUppLHxx --- site/src/components/Analytics.astro | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index e51e5163..46d738e1 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -350,7 +350,12 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; subscribe: function (onChange) { window.addEventListener(PREFERENCE_EVENT, onChange); window.addEventListener("storage", function (event) { - if (event.key === STORAGE_KEY) onChange(); + // A null key is clear(), where the preference is gone rather than + // changed. The listener that re-applies the preference already + // covers it; this one redraws the control that displays it, and + // without the null case /privacy went on describing an answer the + // browser no longer held. + if (event.key === STORAGE_KEY || event.key === null) onChange(); }); }, }; From 93b3deaa04f4fc4de949e0dc9776db1e44873e4d Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 6 Sep 2026 07:40:29 -0700 Subject: [PATCH 3/4] docs(site): say what the consent code actually does Review of the two commits before this one. No behaviour changes; the harness over the built script still passes all nine cases. signalsOptOut's comment sent the next reader to mayRun as well as apply. mayRun never consults the signal -- apply is the sole enforcement point -- so the obvious next step, reusing mayRun as the decision function, would have dropped the check and measured GPC readers again. The comment now says that, and says not to. The null-key note on subscribe claimed more than the change delivers. It reaches a tab that has not recorded an answer of its own; a tab that has keeps it, because readPreference falls back to memoryPreference and a clear() elsewhere does not reset it. That is the right behaviour -- the reader answered in this tab, and clearing storage in another did not withdraw it -- but the previous commit message described the fix as broader than it is. Dead CSS: nothing sets `disabled` on the preference buttons since the branch that did was removed, so the `:disabled` rule and the `:not(:disabled)` qualifier described a state the script can no longer produce. Also rewraps a comment line the earlier edit left at 125 columns in a block that wraps at 80. Claude-Session: https://claude.ai/code/session_01BKURdfwTvLLaBeDUppLHxx --- site/src/components/Analytics.astro | 18 ++++++++++++------ site/src/components/AnalyticsPreference.astro | 7 +------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index 46d738e1..1e045ce2 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -15,7 +15,8 @@ * * Whether consent must be asked for is a question about the reader's location, * not about this site. The EEA and the UK require permission before the cookie - * is set; elsewhere measurement may run with a way to turn it off. Cloudflare already knows which one applies and says so at + * is set; elsewhere measurement may run with a way to turn it off. Cloudflare + * already knows which one applies and says so at * /cdn-cgi/trace, so most readers are measured properly and never see a banner, * and the readers who are owed the question get it. A failed or unavailable * lookup is treated as "ask" — the safe direction, and what local development @@ -77,8 +78,9 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; var requirementPromise = null; // A default answer for a reader who has not given one here, rather than a - // veto over the one they do give. Consulted only where the preference is - // absent -- see mayRun and apply. + // veto over the one they do give. apply() is the only caller and the only + // place the signal is enforced; mayRun deliberately knows nothing about + // it, so do not reach for mayRun as the decision function. function signalsOptOut() { return navigator.globalPrivacyControl === true; } @@ -352,9 +354,13 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; window.addEventListener("storage", function (event) { // A null key is clear(), where the preference is gone rather than // changed. The listener that re-applies the preference already - // covers it; this one redraws the control that displays it, and - // without the null case /privacy went on describing an answer the - // browser no longer held. + // covers it; this one redraws the control that displays it. + // + // It reaches a tab that has not recorded an answer itself. A tab + // that has keeps it: readPreference falls back to memoryPreference, + // which a clear() in another tab does not reset, and should not -- + // the reader answered in this tab, and clearing storage elsewhere + // did not withdraw that. if (event.key === STORAGE_KEY || event.key === null) onChange(); }); }, diff --git a/site/src/components/AnalyticsPreference.astro b/site/src/components/AnalyticsPreference.astro index 83db6737..66739ce4 100644 --- a/site/src/components/AnalyticsPreference.astro +++ b/site/src/components/AnalyticsPreference.astro @@ -139,7 +139,7 @@ const enabled = cursor: pointer; } - .fo-preference-button:hover:not(:disabled) { + .fo-preference-button:hover { border-color: var(--sl-color-gray-4); color: var(--sl-color-white); } @@ -153,11 +153,6 @@ const enabled = color: var(--sl-color-black); } - .fo-preference-button:disabled { - opacity: 0.55; - cursor: not-allowed; - } - .fo-preference-button:focus-visible { outline: 2px solid var(--sl-color-accent-high); outline-offset: 2px; From 2b53b8d9153c9ee991cff9695768f2bb1abc82dd Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 6 Sep 2026 07:41:49 -0700 Subject: [PATCH 4/4] revert(site): keep the Crown Dependencies asking Removing them was argued from the statutes: Guernsey, the Isle of Man and Jersey follow the GDPR but none enacted a PECR equivalent, and it is PECR that makes storing something on a device the thing consent is owed for. That reading may well be right, and it is not the whole question -- their data protection authorities publish cookie guidance that asks for consent regardless, and a site is answerable to the regulator before it is answerable to a reading. The traffic involved is negligible, so the measurement given up is close to nothing and the exposure taken on was not. Restored, with the reasoning written down so the next person to look at the list finds an argument rather than three country codes that seem out of place. The rest of the relaxation is untouched. Do Not Track is still gone and Global Privacy Control is still a default rather than a veto, which is where the coverage was. Harness over the built script, nine cases, JE now expecting the banner again: all pass. Claude-Session: https://claude.ai/code/session_01BKURdfwTvLLaBeDUppLHxx --- site/src/components/Analytics.astro | 25 ++++++++++++++----------- site/src/content/docs/privacy.mdx | 6 +++--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index 1e045ce2..e62f2ec1 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -14,9 +14,9 @@ * mean something. * * Whether consent must be asked for is a question about the reader's location, - * not about this site. The EEA and the UK require permission before the cookie - * is set; elsewhere measurement may run with a way to turn it off. Cloudflare - * already knows which one applies and says so at + * not about this site. The EEA, the UK and the Crown Dependencies require + * permission before the cookie is set; elsewhere measurement may run with a way + * to turn it off. Cloudflare already knows which one applies and says so at * /cdn-cgi/trace, so most readers are measured properly and never see a banner, * and the readers who are owed the question get it. A failed or unavailable * lookup is treated as "ask" — the safe direction, and what local development @@ -57,19 +57,22 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; var STORAGE_KEY = "fanout.analytics-consent.v1"; var PREFERENCE_EVENT = "fanout:analytics-preference"; - // The EEA and the UK: the places whose law requires permission before a - // browser stores an analytics identifier. A country this list does not - // name gets measurement with an opt-out instead. + // The EEA, the UK and the Crown Dependencies: the places that require + // permission before a browser stores an analytics identifier. A country + // this list does not name gets measurement with an opt-out instead. // - // Guernsey, the Isle of Man and Jersey were on this list and are not now. - // Their data protection law follows the GDPR, but none of them enacted a - // PECR equivalent, so the rule that governs storing an identifier does - // not reach them. + // Guernsey, the Isle of Man and Jersey are here on the regulators' word + // rather than on a reading of the statutes. None of the three enacted a + // PECR equivalent, and it is PECR, not the GDPR, that makes storage + // itself the thing consent is owed for -- but their data protection + // authorities publish cookie guidance that asks for consent anyway, and + // three jurisdictions of negligible traffic are not worth being right + // about. Removing them is not a decision to take from this file. var PRIOR_CONSENT = [ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", - "GB", + "GB", "GG", "IM", "JE", ]; var memoryPreference = null; diff --git a/site/src/content/docs/privacy.mdx b/site/src/content/docs/privacy.mdx index 33bc9ad3..100f3aad 100644 --- a/site/src/content/docs/privacy.mdx +++ b/site/src/content/docs/privacy.mdx @@ -22,9 +22,9 @@ nothing on this page is in it. ### When it runs -- **In the EEA and the UK**, nothing is loaded and no identifier is stored - until you say yes. The prompt is the only thing that appears before you - answer. +- **In the EEA, the UK, Guernsey, the Isle of Man and Jersey**, nothing is + loaded and no identifier is stored until you say yes. The prompt is the only + thing that appears before you answer. - **Everywhere else**, analytics run and the control above turns them off. - **If your browser sends Global Privacy Control**, analytics do not run and you are not asked, because you have already answered. Choosing Allow above