Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 330 additions & 77 deletions site/src/components/Analytics.astro

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions site/src/components/AnalyticsConsent.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
/* The consent prompt, for readers whose location requires one.
*
* Markup only. Analytics.astro decides whether this is ever shown and handles
* the clicks: the question of whether to ask is answered in the head, before
* anything renders, and putting the logic in two places is how the banner ends
* up shown to someone who already declined.
*
* It renders on every page rather than only where the site footer does, because
* a reader who arrives from a search result lands on a documentation page, and
* a consent prompt that only appears on the landing page asks the question of
* the wrong half of the audience.
*
* `hidden` in the source, not `display: none` in a stylesheet: the prompt is
* absent for the reader whose location does not require it and for the reader
* who has already answered, and neither should see it flash while a stylesheet
* loads. A build without a measurement id never renders it at all.
*/
const enabled =
Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD;
---

{
enabled && (
<section
id="fo-analytics-consent"
class="fo-consent"
role="region"
aria-labelledby="fo-consent-title"
hidden
>
<div class="fo-consent-body">
<p id="fo-consent-title" class="fo-consent-title">Analytics on this site</p>
<p class="fo-consent-copy">
Fanout would like to count visits and which pages get read, using Google Analytics.
Nothing you send to your own Fanout instance is involved. <a href="/privacy">
What is collected
</a>.
</p>
</div>
<div class="fo-consent-actions">
<button type="button" class="fo-consent-button" data-consent="denied">
Do not allow
</button>
<button
type="button"
class="fo-consent-button fo-consent-button-primary"
data-consent="granted"
>
Allow
</button>
</div>
</section>
)
}

<style>
.fo-consent {
position: fixed;
z-index: calc(var(--sl-z-index-navbar) + 1);
inset-block-end: max(1rem, env(safe-area-inset-bottom));
inset-inline: 1rem;
margin-inline: auto;
max-width: 34rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.5rem;
padding: 1rem 1.15rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.6rem;
background: var(--sl-color-black);
box-shadow: 0 1rem 2.5rem -0.75rem rgb(0 0 0 / 0.5);
}

.fo-consent-title {
margin: 0 0 0.2rem;
font-size: var(--sl-text-sm);
font-weight: 600;
color: var(--sl-color-white);
}

.fo-consent-copy {
margin: 0;
font-size: var(--sl-text-xs);
line-height: 1.6;
color: var(--sl-color-gray-3);
}

.fo-consent-body {
flex: 1 1 16rem;
}

.fo-consent-actions {
display: flex;
gap: 0.5rem;
}

.fo-consent-button {
padding: 0.4rem 0.85rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.35rem;
background: transparent;
color: var(--sl-color-gray-2);
font: inherit;
font-size: var(--sl-text-xs);
cursor: pointer;
}

.fo-consent-button:hover {
border-color: var(--sl-color-gray-4);
color: var(--sl-color-white);
}

/* The landing page's primary action, in miniature: a solid fill of the
token that contrasts with the page, labelled in the token that contrasts
with the fill. `--sl-color-white` is a role rather than a colour — it is
near-black under the light theme — so a label set in it on an accent fill
is dark-on-blue in daylight. The pair below inverts correctly in both. */
.fo-consent-button-primary {
border-color: var(--sl-color-white);
background: var(--sl-color-white);
color: var(--sl-color-black);
}

.fo-consent-button-primary:hover {
border-color: var(--sl-color-gray-1);
background: var(--sl-color-gray-1);
color: var(--sl-color-black);
}

.fo-consent-button:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}

/* Stacked, the buttons want the full width rather than the right edge. */
@media (max-width: 30rem) {
.fo-consent-actions {
flex: 1 1 100%;
}

.fo-consent-button {
flex: 1;
}
}
</style>
164 changes: 164 additions & 0 deletions site/src/components/AnalyticsPreference.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
/* The standing analytics control, for the privacy page.
*
* The banner asks once. This is where the answer is changed afterwards, which
* is the half of consent that is easy to leave out: permission that cannot be
* withdrawn as readily as it was given was never permission.
*
* It states the current situation before offering the buttons, because "allowed
* or not" is the question a reader arrives with, and in the regions that do not
* require a prompt the honest answer is that measurement is already running.
*
* The status line is scripted rather than rendered, since the answer lives in
* the reader's browser and this site is static: every reader would otherwise be
* served the same cached sentence about a choice that is theirs alone.
*/
const enabled =
Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD;
---

{
enabled ? (
<>
<p>
The site uses Google Analytics 4 to count visits and see which pages get read. It runs
only when it is allowed to, and it is configured to learn as little as will still
answer that question.
</p>
<div id="fo-analytics-preference" class="fo-preference">
<p class="fo-preference-status" aria-live="polite">
Checking whether analytics are running in this browser.
</p>
<div class="fo-preference-actions">
<button type="button" class="fo-preference-button" data-consent="denied">
Do not allow
</button>
<button type="button" class="fo-preference-button" data-consent="granted">
Allow
</button>
</div>
</div>
</>
) : (
<p>
This build of the site has no analytics configured, so nothing is measured, nothing is
stored, and there is nothing to turn off. The rest of this section describes what a build
that has them does.
</p>
)
}

<script is:inline>
(function () {
var root = document.getElementById("fo-analytics-preference");
var api = window.fanoutAnalytics;
if (!root || !api) return;

var status = root.querySelector(".fo-preference-status");
var buttons = root.querySelectorAll("[data-consent]");

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(
"aria-pressed",
String(buttons[j].dataset.consent === preference),
);
}

if (preference === "granted") {
status.textContent = "Analytics are allowed in this browser.";
return;
}
if (preference === "denied") {
status.textContent = "Analytics are not allowed in this browser.";
return;
}

api.requirement().then(function (requirement) {
if (api.preference() !== null) return;
status.textContent =
requirement === "not_required"
? "Analytics are running in this browser. You can turn them off here."
: "You have not yet answered the analytics question in this browser.";
});
}

root.addEventListener("click", function (event) {
var button = event.target instanceof Element && event.target.closest("[data-consent]");
if (!button) return;
api.setPreference(button.dataset.consent);
});

api.subscribe(render);
render();
})();
</script>

<style>
.fo-preference {
margin-block: 1rem;
padding: 1rem 1.15rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.6rem;
background: var(--sl-color-black);
}

.fo-preference-status {
margin: 0;
font-size: var(--sl-text-sm);
line-height: 1.6;
color: var(--sl-color-gray-2);
}

.fo-preference-actions {
display: flex;
gap: 0.5rem;
margin-block-start: 0.85rem;
}

.fo-preference-button {
padding: 0.4rem 0.85rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.35rem;
background: transparent;
color: var(--sl-color-gray-2);
font: inherit;
font-size: var(--sl-text-xs);
cursor: pointer;
}

.fo-preference-button:hover:not(:disabled) {
border-color: var(--sl-color-gray-4);
color: var(--sl-color-white);
}

/* Same inversion as the banner's primary action: `--sl-color-white` is the
token that contrasts with the page, so it is the fill here and never the
label on top of it. */
.fo-preference-button[aria-pressed="true"] {
border-color: var(--sl-color-white);
background: var(--sl-color-white);
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;
}
</style>
21 changes: 21 additions & 0 deletions site/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ import Pagination from "@astrojs/starlight/components/Pagination.astro";
* The copyright is no longer part of this component. It is chrome, not the end
* of an article, so it lives in SiteFooter.astro, which PageFrame puts below
* the content column entirely.
*
* Privacy is the exception to that split, and it earns it. Outside the regions
* that require a consent prompt no banner is ever shown, so the control on
* /privacy is the only way to turn analytics off — and SiteFooter, which
* carries the other link to it, renders on splash pages alone. A reader who
* arrives from a search result lands here, on a documentation page, and would
* have had no route to the control at all. One text link, not the whole band:
* the objection to a second footer here was duplicated navigation and social
* icons colliding with the fixed right sidebar, and a single word in the row
* that already exists is neither.
*/
const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
---
Expand All @@ -50,6 +60,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
<div class="meta sl-flex">
<EditLink />
<LastUpdated />
<a href="/privacy">Privacy</a>
</div>
)
}
Expand All @@ -75,4 +86,14 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
margin-inline-start: auto;
}

.meta a {
color: var(--sl-color-gray-3);
text-decoration: none;
}

.meta a:hover {
color: var(--sl-color-white);
text-decoration: underline;
}

</style>
2 changes: 2 additions & 0 deletions site/src/components/PageFrame.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
*/
import MobileMenuToggle from "@astrojs/starlight/components/MobileMenuToggle.astro";
import SiteFooter from "./SiteFooter.astro";
import AnalyticsConsent from "./AnalyticsConsent.astro";

const { hasSidebar } = Astro.locals.starlightRoute;
const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
Expand All @@ -45,6 +46,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
}
<div class="main-frame"><slot /></div>
{isSplash && <SiteFooter />}
<AnalyticsConsent />
</div>

<style>
Expand Down
Loading
Loading