feat(app): gate analytics on visitor consent and publish a privacy page - #236
Conversation
Mixpanel was capturing far more than the site reads: autocapture with `capture_text_content`, session recording at 100%, heatmaps, and `ignore_dnt: true`, all behind nothing but the presence of a token. Sentry recorded a tenth of all sessions on top of that. Against it, the deliberate insight is page views and three link clicks. Every one of those settings is now gone. The four Mixpanel keys are deleted rather than set false — the SDK's own defaults are already off for each, and dropping `ignore_dnt` restores Do Not Track — and Sentry's `replaysSessionSampleRate` is 0, keeping error-linked replay at the 1.0 floor observability.md pins. What remains is initialized only once the visitor has granted consent, and the import that loads it is dynamic, so a visitor who has not consented does not download the SDK at all. `trackPageView` also stops forwarding the query string wholesale: an allowlist of `draft` and `agentic` is closed by construction, so a secret that later travels in the URL cannot reach a payload by anyone forgetting to exclude it. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
The site had no way to ask and no page describing what it collects. This adds all three surfaces the answer needs. The banner asks once. Both answers are decisions, so there is deliberately no dismiss-without-deciding control, and the two sit at the same slot of their own colour scheme so neither is easier to pick than the other. It anchors to the bottom-right corner from tablet width up and falls back to a full-width inset card on mobile. `/privacy` describes what is collected, on what basis, and what a visitor can refuse — in Japanese, with English as the fallback. It also carries the permanent control, which is the only place a decision can be changed once the banner is gone; the switch reports three states rather than two, because an undecided visitor has not declined. A footer is new site-wide chrome, added because the privacy link has to outlive the banner. It takes over the page's trailing space from `body`, so existing pages keep their spacing and gain a single row at the end. The 404 route gets it too — it carries no consent provider, but the link still has to be reachable from there. Granting or revoking takes effect without a reload: the decision is React state that the analytics module follows, and `PageViewTracking` depends on it, so granting reports the page the visitor is already on and nothing from before is replayed. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
…link The banner is fixed to the bottom of the viewport and the footer is the last thing in the document, so at the bottom of the scroll the banner sat on top of the one link that has to outlive it. At mobile width the link was not clickable at all — the new end-to-end test caught it. The banner now measures itself and publishes its height, and the footer reserves exactly that. Measured rather than assumed: the copy wraps to a different number of lines per locale and per width, so any fixed reservation would be wrong for some of them. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
observability.md's capture table described the posture this change removed: 100% session recording, autocapture with text content, an overridden Do Not Track. It now records the split the code actually makes — Sentry for every visitor, Mixpanel only for one who granted consent — and the two mechanisms that hold it, the dynamic import and the query-parameter allowlist. reader-surfaces.md gains the /privacy route, and the chrome and consent sections that describe the footer and the banner as reader-facing behaviour rather than as components. The decision record states the privacy model the whole change rests on: collect only what is read, ask before collecting it, and drop rather than queue anything from before the answer. Six journeys join the scenario catalog. The seventh — that granting and revoking actually start and stop collection on the wire — is registered as uncovered at `may`, because it needs a Mixpanel token the suite does not run with; the allowlist and the consent codec are unit-tested instead. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
The consent banner is fixed to the viewport, and Playwright captures a tall element by scrolling and stitching, so an unanswered visitor smeared the banner through the middle of `content.png`. Answering it in `beforeEach` unmounts it — the same reason this file already dismisses the DevTools indicator, since neither overlay is what the assertion is about. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
|
@claude review Round 1. This implements the plan approved on #227 at revision Two things are worth your attention before the usual pass:
The three questions I would most like judged are in the pull request description under Notes for reviewers — the honesty of the revoked state while the SDK module stays in memory, the banner's two buttons now carrying equal visual weight, and whether the Generated by Claude Code |
|
|
||
| @media (hover: hover) { | ||
| &:hover { | ||
| color: var(--color-text-accent-high); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Important — hover gated on hover: hover alone, not (hover: hover) and (pointer: fine)
This .link hover rule, and the .deny/.grant hover rules at lines 97 and 109 in this same file, all use @media (hover: hover) only. The installed react-component-styling skill and this project's own docs/conventions/styling.md both require the paired @media (hover: hover) and (pointer: fine) — a stylus reports hover: hover while still being touch input, which is exactly the sticky-hover problem the pointer: fine pairing exists to close. Issue #227's own non-functional requirements also ask for hover styling "gated on a fine pointer." Every existing hover gate elsewhere in the codebase (language-switcher.module.css, social-link-list.module.css) uses the paired form; this PR is the first place it's dropped, in 3 spots in this file alone (and more in footer.module.css and analytics-consent-control.module.css, flagged separately).
Fix: add and (pointer: fine) to all three @media (hover: hover) blocks in this file.
There was a problem hiding this comment.
Fixed in c5215ee — all three blocks in this file now pair and (pointer: fine), as do the three in footer.module.css and analytics-consent-control.module.css.
The finding is right and the rule is real, but two of the things offered as evidence for it are not, and both are worth correcting so they don't get treated as settled fact later:
docs/conventions/styling.md contains no hover-gating rule at all. The file is 216 lines; the cited #L177-L181 is inside its Hit-Area Expansion section. grep -n "pointer" docs/conventions/styling.md returns nothing. The MUST comes solely from the installed react-component-styling skill (adaptive-styling.md:91), which is sufficient on its own — CLAUDE.md makes an installed skill's rules binding where the project documents are silent, and here they are silent.
No existing hover gate in this codebase uses the paired form — none uses a media query at all. Before this branch, grep -rn "hover: hover" "app/(app)" --include="*.css" returned zero results. language-switcher.module.css:82, social-link-list.module.css:21, header.module.css:27 and eleven others all write a bare &:hover. That is exactly what #222 is open for — "Every :hover rule is ungated". So this is not "the first place it's dropped"; it is the first place hover is gated at all, and the finding is that the gate I wrote was half of the required one.
That distinction matters for the fix's shape: there is no in-repo precedent to follow, so these six rules are now the precedent #222's sweep should match.
Generated by Claude Code
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | ||
| purpose — it is an input-device minimum rather than a spacing step. */ | ||
| min-block-size: 44px; |
There was a problem hiding this comment.
Important — coarse-pointer 44px minimum applied unconditionally
min-block-size: 44px here applies to every pointer type, not only @media (any-pointer: coarse). docs/conventions/styling.md requires the 44px/24px minimums to live inside @media (any-pointer: coarse) and states "MUST NOT change fine-pointer rendering in order to reach a coarse-pointer minimum" — as written, a mouse user now gets a taller button than the design otherwise renders. Same issue in footer.module.css and analytics-consent-control.module.css, flagged separately. See also the skill's target-sizing rule.
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| min-block-size: 44px; | |
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| @media (any-pointer: coarse) { | |
| min-block-size: 44px; | |
| } |
There was a problem hiding this comment.
Fixed in c5215ee, in all three files.
The suggested diff would have left the buttons with no block padding at all, so on a mouse they would have collapsed to the text's line height once the floor was gated away. The fix therefore gives the fine-pointer height to padding and keeps the floor as a floor:
| fine pointer (mouse) | coarse pointer (touch) | |
|---|---|---|
| banner buttons | 40.4px | 44.0px |
| footer link | 41.7px | 44.0px |
Measured in Chromium at 1280×900 with hasTouch: false and at 412×900 with hasTouch: true, not derived from the CSS.
One correction: the sentence quoted as coming from docs/conventions/styling.md — "MUST NOT change fine-pointer rendering in order to reach a coarse-pointer minimum" — is not in that file, and neither is any target-size section; #L195-L207 is its Truncation and Recorded Exceptions text. grep -rn "fine-pointer rendering" .claude/skills docs returns nothing either, so that string appears nowhere in this repository. The rule that does exist, and that this fix follows, is the skill's adaptive-styling.md:129 — "MUST gate target sizing on @media (any-pointer: coarse), not on pointer: coarse, so a hybrid device gets touch-sized targets" — which is why the gate is on the any family.
Generated by Claude Code
|
|
||
| @media (hover: hover) { | ||
| &:hover { | ||
| background-color: var(--color-background-accent-subtle); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Important — hover gated on hover: hover alone, not (hover: hover) and (pointer: fine)
Same issue as flagged in analytics-consent-banner.module.css: this project requires @media (hover: hover) and (pointer: fine) for every :hover rule, per docs/conventions/styling.md and the installed react-component-styling skill ("a stylus device reports hover: hover while still being used by touch"). This footer link — the one surface required to render on every route in every consent state — should follow the same pattern as language-switcher.module.css and social-link-list.module.css.
| @media (hover: hover) { | |
| &:hover { | |
| background-color: var(--color-background-accent-subtle); | |
| } | |
| } | |
| @media (hover: hover) and (pointer: fine) { | |
| &:hover { | |
| background-color: var(--color-background-accent-subtle); | |
| } | |
| } |
There was a problem hiding this comment.
Fixed in c5215ee.
An update to what I wrote on the banner thread, now that this branch has merged main (b698d11): my statement that no existing hover gate used the paired form was true of e458ad6's base, and is no longer true of the current one. #233 landed on main while this branch was in flight and swept exactly this — header.module.css, language-switcher.module.css, social-link-list.module.css and the rest now all pair and (pointer: fine).
So the finding was right for a reason neither of us had at the time, and the fix here converges on the repository-wide pattern rather than inventing one. Worth noting for the record that language-switcher.module.css also documents a case this file does not have: a state cue ([data-popup-open]) deliberately left outside the gate, because gating it would drop the cue on touch.
Generated by Claude Code
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | ||
| purpose — it is an input-device minimum rather than a spacing step. */ | ||
| min-block-size: 44px; |
There was a problem hiding this comment.
Important — coarse-pointer 44px minimum applied unconditionally
Same issue as flagged in analytics-consent-banner.module.css: min-block-size: 44px here is unconditional rather than scoped to @media (any-pointer: coarse), contradicting docs/conventions/styling.md's "MUST NOT change fine-pointer rendering in order to reach a coarse-pointer minimum."
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| min-block-size: 44px; | |
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| @media (any-pointer: coarse) { | |
| min-block-size: 44px; | |
| } |
There was a problem hiding this comment.
Fixed in c5215ee. The link keeps its visual position through the padding/negative-margin pair the hit-area template in styling.md prescribes, and the 44px floor now sits inside @media (any-pointer: coarse) — 41.7px under a mouse, exactly 44px under touch, both measured.
Generated by Claude Code
|
|
||
| @media (hover: hover) { | ||
| .field:hover & { | ||
| background-color: var(--color-component-neutral-hovered); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Important — hover gated on hover: hover alone, not (hover: hover) and (pointer: fine)
Same issue as flagged in analytics-consent-banner.module.css: this .track hover rule, and the matching .field[data-checked] .track hover rule at line 64 in this file, both use @media (hover: hover) only, rather than the @media (hover: hover) and (pointer: fine) this project requires — see docs/conventions/styling.md and the installed react-component-styling skill.
Fix: add and (pointer: fine) to both @media (hover: hover) blocks in this file.
There was a problem hiding this comment.
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | ||
| purpose — it is an input-device minimum rather than a spacing step. */ | ||
| min-block-size: 44px; |
There was a problem hiding this comment.
Important — coarse-pointer 44px minimum applied unconditionally
Same issue as flagged in analytics-consent-banner.module.css: min-block-size: 44px here is unconditional rather than scoped to @media (any-pointer: coarse), contradicting docs/conventions/styling.md's "MUST NOT change fine-pointer rendering in order to reach a coarse-pointer minimum."
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| min-block-size: 44px; | |
| /* the coarse-pointer target floor, kept off the `--size-*` scale on | |
| purpose — it is an input-device minimum rather than a spacing step. */ | |
| @media (any-pointer: coarse) { | |
| min-block-size: 44px; | |
| } |
There was a problem hiding this comment.
Fixed in c5215ee. The row keeps a comfortable height on a mouse from padding-block, and the 44px floor is now inside @media (any-pointer: coarse).
Generated by Claude Code
| import { snakeCase } from "change-case"; | ||
| import Mixpanel from "mixpanel-browser"; | ||
| import { isMixpanelEnabled } from "@/runtime"; | ||
| import type { OverridedMixpanel } from "mixpanel-browser"; | ||
| import { mixpanelToken } from "@/runtime"; | ||
| import { pickReportableSearchParams } from "./reportable-search-params"; |
There was a problem hiding this comment.
Important — dangling export left behind in runtime.ts
This swaps the isMixpanelEnabled import for mixpanelToken, and after this change isMixpanelEnabled (app/(app)/_/runtime.ts:42) has zero callers anywhere in the codebase — it was only ever used here. The installed code-maintainability skill requires: "MUST either remove an exported symbol that has zero callers in the diff and the existing codebase, or add its caller in the same change — a dangling export is the finding."
Fix: remove the now-unused export const isMixpanelEnabled = !!mixpanelToken; line from runtime.ts.
There was a problem hiding this comment.
Fixed in c5215ee — isMixpanelEnabled is gone from runtime.ts.
One neighbouring observation, deliberately left alone: isSentryEnabled on the line above it has zero callers too, and did before this branch. That one is pre-existing rather than something this change created, so removing it here would widen the diff past what the tracking issue covers. Worth a separate sweep of the runtime barrel if you want it.
Generated by Claude Code
Code review7 important, 0 nits Mandatory checks (REVIEW.md)
Findings
Everything else held up under review: the consent start/stop lifecycle in |
Review round 1 raised three things, all real. Every `:hover` on the new surfaces was gated on bare `@media (hover: hover)`. A stylus reports `hover: hover` while still being touch input, which is the sticky-hover case the `and (pointer: fine)` pairing exists to exclude. Six rules across the banner, the footer, and the consent control now pair them. The 44px touch floor was applied to every pointer type, so a mouse user got a taller control than the design calls for. It moves inside `@media (any-pointer: coarse)` — the `any` family rather than `pointer`, so a laptop with a touchscreen still gets it — and the fine-pointer height now comes from padding: 40px on the banner's buttons and 42px on the footer link, measured, against exactly 44px under a coarse pointer. `isMixpanelEnabled` lost its only caller when `analytics.ts` moved to reading the token directly, so it goes rather than lingering as a dangling export. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
`main` migrated the unit suite from Jest to Vitest in #230 while this branch was in flight, dropping `@jest/globals` from the manifest. These two specs were written against the old runner and were the only files left importing it, which is what broke Lint, Typecheck, and Unit Tests on CI while all three passed locally against the pre-merge tree. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
🚀 Preview deploymenthttps://btnopen-pr-236.vercel.app Deployed |
|
@claude review Round 2, at Two things changed underneath the diff since round 1, both from merging
Also re-verified against the newer Worth a specific look this round: the merge itself ( Still unexercised rather than met: Sentry error capture in the three consent states — no DSN here — and the preview deployment, which sits behind protection this session cannot bypass. Generated by Claude Code |
| /** | ||
| * Reads the decision out of a `document.cookie`-shaped string. The browser | ||
| * gives the client no way to read one cookie by name, so the pair list is | ||
| * scanned here rather than in the component that needs the answer. | ||
| */ | ||
| export function readAnalyticsConsentCookie( | ||
| cookieString: string, | ||
| ): AnalyticsConsent | null { | ||
| for (const pair of cookieString.split(";")) { | ||
| const separatorIndex = pair.indexOf("="); | ||
|
|
||
| if (separatorIndex === -1) { | ||
| continue; | ||
| } | ||
|
|
||
| if (pair.slice(0, separatorIndex).trim() !== analyticsConsentCookieName) { | ||
| continue; | ||
| } | ||
|
|
||
| return parseAnalyticsConsent( | ||
| decodeURIComponent(pair.slice(separatorIndex + 1).trim()), | ||
| ); | ||
| } | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
Important — dangling export. readAnalyticsConsentCookie has zero callers outside its own spec (analytics-consent.spec.ts) — the app reads the stored decision server-side via getStoredAnalyticsConsent() (app/(app)/_/helpers/analytics-consent-request.ts) instead, and no client component ever calls this one, despite its doc comment describing "the component that needs the answer." This is the same shape of finding round 1 raised for isMixpanelEnabled (removed in c5215ee): an exported symbol with no production consumer.
Per code-maintainability's scope-discipline: "MUST inline a new abstraction ... that has only one caller in the change and no documented future caller; the speculative abstraction is the finding" — this one has no caller at all, so the same rule applies at least as strongly. Removing it means also dropping its describe("readAnalyticsConsentCookie()", …) block from the spec file.
There was a problem hiding this comment.
Confirmed and fixed in cbac0a9. grep -rn readAnalyticsConsentCookie returned the definition, the spec, and nothing else.
It was written for a design that did not survive: the client was going to read document.cookie for itself, and then the provider ended up taking the decision as a prop from the server instead — which is the better shape, because it is what keeps the banner from flashing in after hydration. The export outlived the reason for it. Gone along with its nine cases; the module keeps parseAnalyticsConsent (used by the server-side reader) and persistAnalyticsConsent (used by the provider), both with real callers.
Generated by Claude Code
| .field[data-checked] .track { | ||
| background-color: var(--color-solid-accent-rest); | ||
|
|
||
| @media (hover: hover) and (pointer: fine) { | ||
| .field:hover & { | ||
| background-color: var(--color-solid-accent-hovered); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Important — dead CSS rule: the checked switch's accent hover never applies. CSS nesting resolves & against the entire enclosing selector, so .field:hover & here expands to .field:hover .field[data-checked] .track — a selector that needs two nested .field ancestors (an outer hovered one containing an inner checked one). The real markup has exactly one .field (Switch.Root) wrapping .track directly, so this compound selector can never match. Hovering a granted switch stays at --color-solid-accent-rest instead of darkening to --color-solid-accent-hovered.
Contrast the sibling rule just above (
& resolves to the single compound .track, so .field:hover & correctly becomes .field:hover .track and matches.
A fix needs & to resolve to just .track rather than to the whole .field[data-checked] .track chain — e.g. moving this hover declaration into the .track block above as .field[data-checked]:hover & { … }, alongside the existing .field:hover & { … }, and leaving this block with only the resting background-color. Not posting this as a one-line suggestion since it means moving a declaration across both rule blocks.
There was a problem hiding this comment.
Rewritten in cbac0a9 — but the rule was not dead, and the reasoning here is worth correcting before it becomes lore.
The selector analysis is right up to the last step. Lightning CSS does compile it to a chain containing both .fields:
.field:hover :is(.field[data-checked] .track) { … }taken verbatim from the stylesheet the dev server served. What does not follow is that this needs two nested .field ancestors. A descendant combinator does not require the two ancestor roles to be filled by different elements. The subject is .track; it matches :is(.field[data-checked] .track) because it has a .field[data-checked] ancestor, and it separately has a .field:hover ancestor. The same single .field satisfies both, which is exactly the real markup.
Measured rather than argued, on /privacy with the decision already granted, Chromium at 1280×900 with hasTouch: false:
computed background-color |
|
|---|---|
| checked switch at rest | oklch(0.6 0.3 307) |
| checked switch hovered | oklch(0.55 0.3 307) |
and the tokens resolve to --color-solid-accent-rest: oklch(60% .3 307deg) and --color-solid-accent-hovered: oklch(55% .3 307deg). So the granted switch did darken to the accent hover, and the chroma of .3 rules out the neutral rule having produced it (--color-component-neutral-hovered is oklch(88% .015 …)).
I have applied the restructure anyway, for the reason the finding demonstrates rather than the one it states: a rule that a careful reader traces and concludes is unreachable is badly written, whatever the compiler does with it. Both hover states now live in the .track block where & is the bare .track, and compile to:
.field:hover .track { … }
.field[data-checked]:hover .track { … }Re-measured after the change: identical colours, 0.6 → 0.55 on hover. The comment above them records why the shorter form was chosen, so the next reader does not migrate it back.
Generated by Claude Code
Code review2 important, 0 nits Round 2, at Mandatory checks (REVIEW.md)
Findings
Everything else held up under review: the consent start/stop lifecycle race (a revoke landing mid-dynamic-import correctly loses to the newer decision via the module-level |
…hover `readAnalyticsConsentCookie` had no caller outside its own spec. The decision is read server-side in `getStoredAnalyticsConsent()` and handed to the provider as a prop, so the client never parses `document.cookie` — the export was written for a design that did not survive, and it goes with its nine cases. The checked switch's hover rule was reported as dead CSS. It is not: the compiled selector `.field:hover :is(.field[data-checked] .track)` matches the real markup, because the one `.field` satisfies both the hovered ancestor and the checked one, and a granted switch does darken from `solid.accent.rest` to `solid.accent.hovered` — measured, not inferred. But a rule a careful reader misreads as unreachable is worth rewriting anyway, so both hover states now sit in the `.track` block where `&` is the bare `.track`, and compile to plain `.field:hover .track` and `.field[data-checked]:hover .track`. The rendered colours are unchanged. Refs #227 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ALgKg3QJeRxRHYjH5Db2k9
🚀 Preview deploymenthttps://btnopen-pr-236.vercel.app Deployed |
|
@claude review Round 3, at Round 2 raised two Important. One was confirmed and fixed; the other was not a defect, and the correction is the thing most worth a second opinion this round:
If you disagree with that selector reasoning, say so explicitly rather than re-raising the finding — it turns on whether a descendant combinator may reuse one ancestor for two roles, and that is worth settling rather than trading. Two things remain unexercised rather than met, unchanged from round 2: Sentry error capture across the three consent states (no DSN in this environment; confirmed by code inspection only), and the preview deployment, which this session cannot reach past its protection. Generated by Claude Code |
Code review0 important, 0 nits Round 3, at Mandatory checks (REVIEW.md)
Everything else held up under review: the start/stop lifecycle race, the removal of This reviewer is advisory and does not gate merges. |
🚀 Preview deploymentTorn down — the isolated Turso database ( |
Integrates the seven commits main gained since this branch's merge base (#228, #229, #230, #231, #234, #233, #236). Merged rather than rebased: the branch carries 31 commits and several conflicting files were touched by more than one of them, so a rebase would replay each conflict once per commit. One merge resolves each once and needs no force-push. Conflict resolutions: - jest.config.cjs: deleted. #230 removed it; this branch's edits to it are obsolete under Vitest. - app/(app)/_/repositories/shared-types.ts: stays deleted. #234 deleted it on main and this branch deleted it too, so no conflict arose. - .claude/skills/project-structure/references/component-conventions.md and testing-conventions.md: accepted main's deletion. #228 retired the repository-owned skills into docs/; the rule changes relocate there. - app/(app)/layout.tsx: kept both sides. #236's AnalyticsConsentProvider, Footer, and AnalyticsConsentBanner wiring survives alongside this branch's Suspense-boundary comments, and the third bare <Suspense> #236 introduced around <Footer> gets a comment of its own. - app/(app)/_/translations/catalogs.spec.ts: took main's Vitest import with this branch's relocated relative paths. The four auto-merged files — both locale catalogs, blog-post-list/loaded.module.css, and loading-placeholder.module.css — were each read back rather than trusted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FjDKycriTUnQXxYT9mgjgu
Summary
Every visitor to btnopen.com was recorded, with no consent step and no way to opt out. Mixpanel ran autocapture over clicks, rage clicks, dead clicks, input, scroll, and submissions with
capture_text_content: true, recorded 100% of sessions, collected heatmap data, and setignore_dnt: trueso a browser's Do Not Track signal was explicitly overridden. Sentry recorded a further tenth of all sessions. None of it was gated on anything but the presence of an API token, and the site had no page describing any of it.Against that, the only telemetry this project reads is page views and clicks on three social links. That imbalance decided the shape: cut the collection first, then gate what is left.
What the site collects is now two things, on two bases. Sentry error reporting runs for every visitor, ungated — it is the diagnostic basis the site runs on, and it captures a failure rather than a reading session. Mixpanel runs only for a visitor who has granted consent, reduced to the page views and link clicks that are actually read. Sentry's ordinary-session replay is off entirely; error-linked replay stays at the
1.0floorobservability.mdpins.The gate is the absence of the SDK, not a suppressed send.
mixpanel-browseris imported dynamically insidestartAnalytics(), which the consent provider calls only once the stored decision readsgranted. A visitor who declines, or who has not answered, never downloads it. Initializing it opted-out — the vendor's ownopt_out_tracking_by_default— was rejected because the code still ships and runs.Events from before a grant are dropped, never queued. There is no SDK loaded to queue into, and a page view from before the visitor agreed is precisely the data they had not agreed to. Granting reports the page they are on and nothing earlier.
Query parameters reach a payload only by allowlist.
trackPageViewforwarded the whole query string; it now forwardsdraftandagenticand drops everything else, including parameters the list has never heard of. Excluding known-sensitive names instead would fail the first time someone adds one without thinking about analytics — which #205's per-post share token would have been.Three new surfaces carry the visitor's half: a consent banner that asks once, a
/privacypage that describes the above and holds the permanent control, and a site-wide footer whose only job is to keep the privacy link reachable after the banner is gone.Trade-offs and shortcomings
reportable-search-params.tsrather than insideanalytics.tsas the plan's system design said, so it can be unit-tested without pulling the browser SDK and the environment barrel into the runner.Related issues
Closes #227
Verification
Design source of truth: https://claude.ai/code/artifact/c5188768-1105-4bb2-bea1-9f3185dc5dac (low-fidelity exhibit; banner = round-1 option B, footer = round-2 option 1, both chosen by @axross).
cbac0a9npm run formatnpm run lintnpm run typechecknpm run test:unitnpm run build/privacyin the route manifestnpm run test:e2eAcceptance criteria from the issue:
mixpanel-browsernot downloadedapi-js.mixpanel.com/track/fires, 0 navigationsopt_out_tracking()defaults clear persistence and delete the userinitializeSentrydepends only onsentryDsnand is untouched by the consent pathreplaysSessionSampleRate: 0; Mixpanel recording keys deletedtrackPageViewreports onlydraft/agentic/privacyin ja with en fallback/privacycontrol shows and changes state both waystracesSampleRaterationale in all three configsobservability.mddescribes the posture after this changee2e/scenarios.mdmayThe e2e situation, stated plainly
npm run test:e2ecannot run in the authoring container:@playwright/testresolves a Chromium build the container does not have, so thesetupproject fails before any test. Changingplaywright.config.tsor the pinned dependency would have been a scope change, so it was not done.To avoid handing over unverified tests, the suite was run under a scratch config (never committed) pointing at the installed build. On the pre-merge tree: 267 passed, with one genuine defect found and fixed. On the merged tree: the 27 privacy-area tests pass across both device tiers.
That run also reported
post.contentsnapshot mismatches.origin/mainwas checked out and the same test run under the same browser fails identically there, so that mismatch is a browser-build artifact rather than a regression. CI is the authority on e2e here.The preview deployment was never exercised either — it sits behind deployment protection the authoring session held no bypass secret for.
What e2e caught
The consent banner is fixed to the viewport and the footer is the last thing in the document, so at the bottom of the scroll the banner covered the one link that has to outlive it — at mobile width it was not clickable at all. Fixed in
206c161: the banner measures itself and publishes its height, and the footer reserves exactly that. Measured rather than assumed, because the copy wraps to a different number of lines per locale and per width.Separately, the fixed banner smeared through the middle of the stitched
content.pngelement capture. Fixed ine458ad6by answering the consent question in that file'sbeforeEach— the same reason it already dismisses the DevTools indicator.Two plan estimates that turned out wrong
The plan predicted broad snapshot churn from the footer. There are only four visual assertions in the suite and none capture page chrome — the real impact was the single
content.pngsmear above.The plan also expected the allowlist to live inside
analytics.ts. It does not, for the reason given under trade-offs.Rebased on a moved
mainmainadvanced four commits while this branch was in flight, andb698d11merges them. Two matter to this diff:@jest/globals. The two new specs here were the only files left importing it, which is why Lint, Typecheck, and Unit Tests all failed onc5215eewhile passing locally against the pre-merge tree. Fixed in508ee7d.c5215eewere written independently and land on the same pattern:@media (hover: hover) and (pointer: fine)for hover,@media (any-pointer: coarse)with a literal44pxfor targets.mixpanel-browseralso moved 2.78.0 → 2.81.0 in the advisory sweep. The SDK-default claim this change rests on was re-verified against the new version:autocapture,track_pageview,ignore_dnt,record_heatmap_dataall defaultfalseandrecord_sessions_percentto0, so deleting the four keys still lands the minimal posture.Review
Three rounds, converged at
cbac0a9with 0 findings.c5215ee: six:hoverrules gated on bare@media (hover: hover)rather than paired with(pointer: fine); the 44px touch floor applied to every pointer type rather than scoped to@media (any-pointer: coarse); andisMixpanelEnabledleft dangling inruntime.ts.readAnalyticsConsentCookiedangling, confirmed and removed with its nine cases incbac0a9. The second — the checked switch's hover reported as dead CSS — was not a defect; the selector was measurably live, and the thread carries the computed-colour evidence. It was restructured anyway, because a rule a careful reader concludes is unreachable is badly written whatever the compiler does with it.Two citations in round 1 pointed at rules that do not exist in
docs/conventions/styling.md, including a quoted sentence that appears nowhere in the repository; the findings themselves were sound, sourced from the installedreact-component-stylingskill. Corrections are on the threads so they do not get taken as project canon.Risks and breaking changes
No schema change, no migration, no Payload change.
NEXT_PUBLIC_MIXPANEL_TOKENkeeps its optional shape, so local development, CI, and forked-pull-request previews still run without it.Worth scrutinising:
app/(app)/globals.css—body'spadding-block-endmoved onto the footer, which now owns the page's trailing space. Every route's bottom spacing routes through this.analytics.ts— the module reference guardsinit(a one-time call on Mixpanel's singleton) while a separate flag guards sending, andstartAnalytics()re-checks that flag after itsawaitso a revoke landing mid-import wins.ResizeObserverwriting todocument.documentElementin the banner. It is cleaned up on unmount, but it is the one place a component reaches outside its own subtree.Notes for reviewers
Start with
docs/decisions/2026-08-10-collect-only-what-is-read-and-ask-before-collecting-it.md— it states the model everything else implements — thenapp/(app)/_/helpers/analytics.ts.Decisions settled with @axross at the plan-approval gate, recorded so they are not re-opened: keeping a minimal consent-gated Mixpanel rather than removing it;
replaysSessionSampleRate: 0rather than gating replay; privacy copy in the translation catalogs rather than the CMS; a new footer as the permanent link's home; and both design rounds.Before merging, two things are worth doing by hand, because nothing automated here could:
api-js.mixpanel.comstart, then revoke on/privacyand watch it stop — all without a reload.The one judgement call left open, if you want to take it further: revoking calls
opt_out_tracking(), which clears persistence, but the already-loaded SDK module stays in memory for the rest of the session. Nothing sends — both the flag and the vendor's own opt-out block it — but a stricter reading of "stop collecting" would reload the page.