diff --git a/docs/superpowers/plans/2026-08-15-gdpr-matomo-tracking.md b/docs/superpowers/plans/2026-08-15-gdpr-matomo-tracking.md new file mode 100644 index 0000000..6d5c691 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-gdpr-matomo-tracking.md @@ -0,0 +1,672 @@ +# GDPR-Gated Matomo Tracking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add consent-gated, cookieless Matomo analytics to every Starlight page without contacting the analytics origin before an affirmative visitor choice. + +**Architecture:** A dependency-free classic browser script in `public/` owns consent persistence, the accessible consent UI, and conditional Matomo loading. Starlight's supported global `head` option loads only that local controller, with an Astro-base-aware URL; the existing custom stylesheet owns the UI presentation. + +**Tech Stack:** Astro 7.1.6, Starlight 0.41.6, browser DOM APIs, Matomo JavaScript tracker API, Node's built-in test runner, `linkedom` 0.18.13. + +## Global Constraints + +- Do not request `https://analytics.msvincognito.nl/matomo.js` or create `window._paq` before affirmative consent. +- Use tracker endpoint `https://analytics.msvincognito.nl/matomo.php` and site ID `1`. +- Call `requireConsent`, `disableCookies`, and `setConsentGiven` before `trackPageView`. +- Declining analytics must not reduce wiki functionality or be visually harder than accepting. +- Keep a persistent **Privacy settings** control so consent can be changed or withdrawn. +- Link consent information to `https://msvincognito.nl/privacy-policy`. +- Add no third-party consent-management or analytics package. +- Preserve deployments at `/` and at a configured `BASE` subpath. +- Treat this frontend as one part of compliance; document the required Matomo server and privacy-notice follow-up. + +## Execution refinement + +During inline execution, Task 2's planned source-text assertions in `tests/project-structure.test.mjs` were replaced by `tests/matomo-build.test.mjs`. The replacement runs a real Astro production build with `BASE=/Incognito-Wiki` and asserts on the generated HTML, copied local controller, and emitted CSS. This follows the test-quality requirement to verify observable behavior rather than grep implementation text; `tests/project-structure.test.mjs` therefore remains unchanged. + +--- + +## File map + +- Create `public/matomo-consent.js`: dependency-free browser controller for consent storage, accessible UI, Matomo initialization, duplicate guards, and withdrawal. +- Create `tests/matomo-consent.test.mjs`: behavioral tests that execute the real controller in a lightweight DOM. +- Modify `astro.config.mjs`: register the local controller globally using Starlight `head` and a normalized Astro base path. +- Modify `src/styles/incognito.css`: style the consent banner, equal-choice actions, and persistent settings control. +- Modify `tests/project-structure.test.mjs`: lock down global registration, base-path handling, privacy link, and styling hooks. +- Modify `README.md`: document analytics behavior and the server-side deployment checklist. + +--- + +### Task 1: Consent controller behavior + +**Files:** +- Create: `public/matomo-consent.js` +- Create: `tests/matomo-consent.test.mjs` + +**Interfaces:** +- Consumes: browser `window`, `document`, and best-effort `window.localStorage`. +- Produces: storage key `incognito.analytics-consent.v1` with values `accepted` or `declined`; DOM IDs `incognito-analytics-consent`, `incognito-analytics-accept`, `incognito-analytics-decline`, and `incognito-privacy-settings`; guarded globals `window.__incognitoMatomoConsentController` and `window.__incognitoMatomoInitialized`. +- Loads: `https://analytics.msvincognito.nl/matomo.js` with element ID `incognito-matomo-script` only after consent. + +- [ ] **Step 1: Write the failing first-visit and acceptance tests** + +Create `tests/matomo-consent.test.mjs` with a harness that runs the real classic script in `linkedom`: + +```js +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; +import { parseHTML } from 'linkedom'; + +const controllerSource = await readFile('public/matomo-consent.js', 'utf8'); +const storageKey = 'incognito.analytics-consent.v1'; + +function runController({ decision, storageError = false } = {}) { + const { window } = parseHTML(''); + const values = new Map(decision ? [[storageKey, decision]] : []); + const localStorage = { + getItem(key) { + if (storageError) throw new Error('storage unavailable'); + return values.get(key) ?? null; + }, + setItem(key, value) { + if (storageError) throw new Error('storage unavailable'); + values.set(key, value); + }, + }; + Object.defineProperty(window, 'localStorage', { value: localStorage }); + vm.runInNewContext(controllerSource, { window, document: window.document }); + return { window, document: window.document, values }; +} + +test('first visit offers equal choices without initializing Matomo', () => { + const { window, document } = runController(); + + assert.equal(window._paq, undefined); + assert.equal(document.querySelector('script[src*="analytics.msvincognito.nl"]'), null); + assert.equal(document.getElementById('incognito-analytics-consent').hidden, false); + assert.equal(document.getElementById('incognito-analytics-accept').className, + document.getElementById('incognito-analytics-decline').className); + assert.equal( + document.querySelector('#incognito-analytics-consent a').href, + 'https://msvincognito.nl/privacy-policy', + ); +}); + +test('accepting persists consent and initializes cookieless Matomo once', () => { + const { window, document, values } = runController(); + + document.getElementById('incognito-analytics-accept').click(); + document.getElementById('incognito-analytics-accept').click(); + + assert.equal(values.get(storageKey), 'accepted'); + assert.deepEqual(Array.from(window._paq), [ + ['requireConsent'], + ['disableCookies'], + ['setConsentGiven'], + ['setTrackerUrl', 'https://analytics.msvincognito.nl/matomo.php'], + ['setSiteId', '1'], + ['trackPageView'], + ['enableLinkTracking'], + ]); + const scripts = document.querySelectorAll('#incognito-matomo-script'); + assert.equal(scripts.length, 1); + assert.equal(scripts[0].src, 'https://analytics.msvincognito.nl/matomo.js'); + assert.equal(scripts[0].async, true); +}); +``` + +- [ ] **Step 2: Run the new test to verify RED** + +Run: + +```bash +node --test tests/matomo-consent.test.mjs +``` + +Expected: FAIL because `public/matomo-consent.js` does not exist. + +- [ ] **Step 3: Add the minimal controller that passes first-visit and acceptance behavior** + +Create `public/matomo-consent.js` as a classic script. Keep all implementation inside an IIFE so only the two duplicate-guard flags and Matomo's `_paq` are global: + +```js +(function initializeAnalyticsConsent(window, document) { + 'use strict'; + + var storageKey = 'incognito.analytics-consent.v1'; + var accepted = 'accepted'; + var declined = 'declined'; + var trackerOrigin = 'https://analytics.msvincognito.nl/'; + + if (window.__incognitoMatomoConsentController) return; + window.__incognitoMatomoConsentController = true; + + function readDecision() { + try { + var value = window.localStorage.getItem(storageKey); + return value === accepted || value === declined ? value : null; + } catch (_error) { + return null; + } + } + + function writeDecision(value) { + try { + window.localStorage.setItem(storageKey, value); + return true; + } catch (_error) { + return false; + } + } + + function initializeMatomo() { + var queue = window._paq = window._paq || []; + if (window.__incognitoMatomoInitialized) return queue; + + window.__incognitoMatomoInitialized = true; + queue.push(['requireConsent']); + queue.push(['disableCookies']); + queue.push(['setConsentGiven']); + queue.push(['setTrackerUrl', trackerOrigin + 'matomo.php']); + queue.push(['setSiteId', '1']); + queue.push(['trackPageView']); + queue.push(['enableLinkTracking']); + + var tracker = document.createElement('script'); + tracker.id = 'incognito-matomo-script'; + tracker.async = true; + tracker.src = trackerOrigin + 'matomo.js'; + document.head.appendChild(tracker); + return queue; + } + + function createButton(id, label) { + var button = document.createElement('button'); + button.id = id; + button.className = 'incognito-consent-action'; + button.type = 'button'; + button.textContent = label; + return button; + } + + var banner = document.createElement('section'); + banner.id = 'incognito-analytics-consent'; + banner.setAttribute('role', 'dialog'); + banner.setAttribute('aria-labelledby', 'incognito-analytics-consent-title'); + banner.innerHTML = + ''; + + var actions = document.createElement('div'); + actions.className = 'incognito-consent-actions'; + var acceptButton = createButton('incognito-analytics-accept', 'Accept analytics'); + var declineButton = createButton('incognito-analytics-decline', 'Decline'); + actions.append(acceptButton, declineButton); + banner.appendChild(actions); + + var settingsButton = document.createElement('button'); + settingsButton.id = 'incognito-privacy-settings'; + settingsButton.type = 'button'; + settingsButton.textContent = 'Privacy settings'; + + function showBanner() { + banner.hidden = false; + settingsButton.hidden = true; + acceptButton.focus(); + } + + function hideBanner() { + banner.hidden = true; + settingsButton.hidden = false; + settingsButton.focus(); + } + + acceptButton.addEventListener('click', function acceptAnalytics() { + writeDecision(accepted); + if (window.__incognitoMatomoInitialized) { + window._paq.push(['setConsentGiven']); + window._paq.push(['trackPageView']); + } else { + initializeMatomo(); + } + hideBanner(); + }); + + declineButton.addEventListener('click', function declineAnalytics() { + writeDecision(declined); + if (window._paq) { + window._paq.push(['forgetConsentGiven']); + window._paq.push(['deleteCookies']); + } + hideBanner(); + }); + + settingsButton.addEventListener('click', showBanner); + document.body.append(banner, settingsButton); + + if (readDecision() === accepted) { + banner.hidden = true; + settingsButton.hidden = false; + initializeMatomo(); + } else if (readDecision() === declined) { + banner.hidden = true; + settingsButton.hidden = false; + } else { + showBanner(); + } +})(window, document); +``` + +During implementation, avoid calling `readDecision()` twice: store it in a local variable before the final branch. The duplicate click in the test must not generate a second page view; make `hideBanner()` or the accept handler ignore clicks while the banner is hidden. + +- [ ] **Step 4: Run the focused test and make the minimal corrections required for GREEN** + +Run: + +```bash +node --test tests/matomo-consent.test.mjs +``` + +Expected: PASS with 2 tests and no warnings. + +- [ ] **Step 5: Add decline, storage-failure, remembered-consent, and withdrawal tests** + +Append these behaviors to `tests/matomo-consent.test.mjs`: + +```js +test('declining persists the choice without creating Matomo state', () => { + const { window, document, values } = runController(); + document.getElementById('incognito-analytics-decline').click(); + + assert.equal(values.get(storageKey), 'declined'); + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-matomo-script'), null); + assert.equal(document.getElementById('incognito-privacy-settings').hidden, false); +}); + +test('remembered acceptance tracks on a later page load', () => { + const { window, document } = runController({ decision: 'accepted' }); + + assert.ok(window._paq.some(([method]) => method === 'trackPageView')); + assert.equal(document.querySelectorAll('#incognito-matomo-script').length, 1); + assert.equal(document.getElementById('incognito-analytics-consent').hidden, true); +}); + +test('remembered decline never initializes Matomo', () => { + const { window, document } = runController({ decision: 'declined' }); + + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-matomo-script'), null); + assert.equal(document.getElementById('incognito-privacy-settings').hidden, false); +}); + +test('storage failure defaults to no tracking and keeps consent available', () => { + const { window, document } = runController({ storageError: true }); + + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-analytics-consent').hidden, false); +}); + +test('privacy settings allow an accepted visitor to withdraw consent', () => { + const { window, document, values } = runController({ decision: 'accepted' }); + + document.getElementById('incognito-privacy-settings').click(); + document.getElementById('incognito-analytics-decline').click(); + + assert.equal(values.get(storageKey), 'declined'); + assert.deepEqual(Array.from(window._paq).slice(-2), [ + ['forgetConsentGiven'], + ['deleteCookies'], + ]); + + const later = runController({ decision: values.get(storageKey) }); + assert.equal(later.window._paq, undefined); + assert.equal(later.document.getElementById('incognito-matomo-script'), null); +}); + +test('running the local controller twice does not duplicate UI or tracking', () => { + const state = runController({ decision: 'accepted' }); + vm.runInNewContext(controllerSource, { window: state.window, document: state.document }); + + assert.equal(state.document.querySelectorAll('#incognito-analytics-consent').length, 1); + assert.equal(state.document.querySelectorAll('#incognito-matomo-script').length, 1); + assert.equal(state.window._paq.filter(([method]) => method === 'trackPageView').length, 1); +}); +``` + +- [ ] **Step 6: Run the expanded focused test to verify RED** + +Run: + +```bash +node --test tests/matomo-consent.test.mjs +``` + +Expected: at least the withdrawal or duplicate-initialization assertion FAILS until the controller fully guards state transitions. + +- [ ] **Step 7: Refine the controller minimally to make every state transition pass** + +Make the following exact corrections in `public/matomo-consent.js`: + +```js +var decision = readDecision(); + +acceptButton.addEventListener('click', function acceptAnalytics() { + if (banner.hidden) return; + writeDecision(accepted); + if (window.__incognitoMatomoInitialized) { + window._paq.push(['setConsentGiven']); + window._paq.push(['trackPageView']); + } else { + initializeMatomo(); + } + hideBanner(); +}); + +declineButton.addEventListener('click', function declineAnalytics() { + if (banner.hidden) return; + writeDecision(declined); + if (window._paq) { + window._paq.push(['forgetConsentGiven']); + window._paq.push(['deleteCookies']); + } + hideBanner(); +}); + +if (decision === accepted) { + banner.hidden = true; + settingsButton.hidden = false; + initializeMatomo(); +} else if (decision === declined) { + banner.hidden = true; + settingsButton.hidden = false; +} else { + showBanner(); +} +``` + +Keep the identical `incognito-consent-action` class on both consent buttons. Do not add an automatic timeout, scroll consent, geolocation exception, or remote request in the no-decision/declined branches. + +- [ ] **Step 8: Run the focused controller tests to verify GREEN** + +Run: + +```bash +node --test tests/matomo-consent.test.mjs +``` + +Expected: PASS with 8 tests and no warnings. + +- [ ] **Step 9: Commit the independently working controller** + +```bash +git add public/matomo-consent.js tests/matomo-consent.test.mjs +git commit -m "feat: add consent-gated Matomo controller" +``` + +--- + +### Task 2: Starlight integration, presentation, and operations + +**Files:** +- Modify: `astro.config.mjs` +- Modify: `src/styles/incognito.css` +- Modify: `tests/project-structure.test.mjs` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `public/matomo-consent.js` and its DOM IDs/classes from Task 1. +- Produces: one deferred `