diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index 2cb02252..e62f2ec1 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -22,9 +22,15 @@ * 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 @@ -54,6 +60,14 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; // 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 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", @@ -66,12 +80,11 @@ 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. 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; } @@ -144,10 +157,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 +324,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,11 +351,20 @@ 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) { - 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. + // + // 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(); }); }, }; @@ -359,16 +388,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..66739ce4 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 = @@ -138,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); } @@ -152,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; diff --git a/site/src/content/docs/privacy.mdx b/site/src/content/docs/privacy.mdx index 059b0af1..100f3aad 100644 --- a/site/src/content/docs/privacy.mdx +++ b/site/src/content/docs/privacy.mdx @@ -26,9 +26,14 @@ nothing on this page is in it. 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.