# 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 `