Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1140d67
chore: gitignore local-only superpowers scratch dirs
samkim Jul 29, 2026
e27a874
test: add jsdom + testing-library harness for component tests
samkim Jul 29, 2026
c36f871
fix: use native __dirname instead of import.meta in vitest.config.ts
samkim Jul 29, 2026
45833a3
test: cover annotation state logic (activation, set toggling, deep-li…
samkim Jul 29, 2026
7f44413
fix: revert unauthorized test-config changes, keep only the necessary…
samkim Jul 29, 2026
a3c3208
test: cover AnnotationSetSelect toggle behavior; fix vitest JSX trans…
samkim Jul 29, 2026
487d97e
fix: remove ExternalScripts.tsx accidentally committed in Task 3 (bel…
samkim Jul 29, 2026
b96d38b
test: cover ShareButton actions and selection_share_viewed emission
samkim Jul 29, 2026
02d5664
test: add e2e coverage for annotation URL-fragment deep-linking
samkim Jul 29, 2026
37d0ba9
chore: upgrade Next.js to 16.2.12 and React to 19.2.8
samkim Jul 29, 2026
f01bd68
fix: suppress newly-enforced react-hooks/set-state-in-effect lint err…
samkim Jul 29, 2026
1ac38fe
chore: bump incidental dependencies to latest minor/patch versions
samkim Jul 29, 2026
ad1f3ae
feat: port read-only az-consent cookie module from docs
samkim Jul 29, 2026
f054286
feat: consent-gated GTM container and PostHog init, replacing gtag.js
samkim Jul 29, 2026
dac8697
fix: restore noscript-wrapped GTM fallback, verify via SSR string-ren…
samkim Jul 29, 2026
0fd1367
feat: migrate annotation_active to PostHog, add annotation_inactive e…
samkim Jul 29, 2026
89d4f01
feat: migrate annotation_set_selected event to PostHog
samkim Jul 29, 2026
da81504
feat: migrate selection_share_* events to PostHog
samkim Jul 29, 2026
5e15392
feat: wire ExternalScripts into layout, remove legacy GTag component
samkim Jul 29, 2026
ec84dd7
test: add e2e coverage for consent-gated GTM network behavior
samkim Jul 29, 2026
0ccb768
fix: assert PostHog SDK opt-in state instead of live network capture,…
samkim Jul 29, 2026
ba57910
feat: prefix all PostHog event names with zanzibar_
samkim Jul 29, 2026
bf7a2e4
feat: add zanzibar_paper_info_menu_opened PostHog event
samkim Jul 29, 2026
30ac50a
fix: address final review findings for consent/analytics rollout
samkim Jul 29, 2026
e97e630
fix: correct consent/analytics gating bugs and dedupe isProd checks
samkim Jul 29, 2026
bef7a6b
refactor: dedupe eslint-disable rationale, cookie validation, and tes…
samkim Jul 29, 2026
dbe3703
fix: replace unused eslint-disable directives with plain comments
samkim Jul 29, 2026
f563634
Address code review follow-up: test coverage, GTM warning timing, isP…
samkim Jul 29, 2026
31273b1
fix: fall back to localhost:3000 when PREVIEW_ENDPOINT and VERCEL_URL…
samkim Jul 29, 2026
af009e0
fix: og image generation (chrome-aws-lambda incompatibility + missed …
samkim Jul 29, 2026
7c66958
fix: declare *.svg module type explicitly instead of relying on next-…
samkim Jul 29, 2026
3ed9da3
fix: use headless 'shell' mode for og image generation on Vercel
samkim Jul 29, 2026
4d6cbf0
debug: temporary diagnostics for blank OG image investigation (to be …
samkim Jul 29, 2026
cd98b4c
debug: expand OG image diagnostics, full-page screenshot (to be rever…
samkim Jul 29, 2026
a6f8123
debug: check computed style ancestry of the selection anchor (to be r…
samkim Jul 29, 2026
ac1176e
debug: test manual re-scrollIntoView before viewport screenshot (to b…
samkim Jul 29, 2026
bb07122
debug: try removing --single-process flag (to be reverted or kept if …
samkim Jul 29, 2026
4069a96
debug: call chromium.executablePath() before chromium.font() (to be r…
samkim Jul 29, 2026
bbbb7f8
debug: surface actual error via JSON response (to be reverted)
samkim Jul 29, 2026
ef3aee5
debug: return diagnostics as JSON body via debugJson query param (to …
samkim Jul 29, 2026
b9348cb
debug: restore --single-process to isolate which fix was necessary
samkim Jul 29, 2026
3f5753b
debug: revert to headless:true + raw chromium.args to isolate reorder…
samkim Jul 29, 2026
51d1f99
fix: call chromium.executablePath() before chromium.font() for OG images
samkim Jul 29, 2026
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
8 changes: 0 additions & 8 deletions .eslintrc.json

This file was deleted.

4 changes: 4 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ jobs:
config-file: cypress.config.ts
env:
PREVIEW_ENDPOINT: "http://localhost:3000"
NEXT_PUBLIC_VERCEL_ENV: "production"
NEXT_PUBLIC_GTM_ID: "GTM-TEST0000"
NEXT_PUBLIC_POSTHOG_KEY: "phc_test0000"
NEXT_PUBLIC_POSTHOG_HOST: "https://us.posthog.com"
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ next-env.d.ts
cypress/screenshots/*
cypress/videos/*
cypress/downloads/*

# superpowers subagent-driven-development scratch workspace
.superpowers/

# superpowers spec/plan docs (kept local only, not committed)
docs/superpowers/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ PREVIEW_ENDPOINT=http://localhost:3000 yarn run dev

Open [http://localhost:3000](http://localhost:3000) with your browser to see the paper.

### Environment Variables

Optional for local development, but required in Vercel project settings for the consent-gated analytics to run in production. Every one of these degrades silently to a no-op if unset:

```bash
NEXT_PUBLIC_VERCEL_ENV=production # Gates all analytics/consent code below; only "production" activates it
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX # Google Tag Manager container ID -- match authzed/web's container
NEXT_PUBLIC_POSTHOG_KEY=phc_... # PostHog project API key -- shared "Web+Cloud" PostHog project
NEXT_PUBLIC_POSTHOG_HOST=https://us.posthog.com # PostHog ingestion host
NEXT_PUBLIC_HUBSPOT_ID=1234567 # Optional: HubSpot tracking script portal ID
```

## Overview

The paper is presented by a reader app developed using the [NextJS] framework, [Tailwind CSS] for styling, and [MDX] for markdown integration in React.
Expand Down
32 changes: 32 additions & 0 deletions components/AnnotationSetSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { posthogJsMockFactory } from '../test/mockPosthog';

vi.mock('posthog-js', () => posthogJsMockFactory());

import posthog from 'posthog-js';
import { AnnotationManagerProvider, getAvailableAnnotationSets } from './annotation';
import AnnotationSetSelect from './AnnotationSetSelect';

describe('AnnotationSetSelect', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('switching the selected set toggles both sets and fires annotation_set_selected', async () => {
render(
<AnnotationManagerProvider>
<AnnotationSetSelect items={getAvailableAnnotationSets()} default="intro" />
</AnnotationManagerProvider>
);

await userEvent.click(screen.getByRole('button'));
await userEvent.click(await screen.findByText('SpiceDB vs Zanzibar'));

expect(posthog.capture).toHaveBeenCalledWith('zanzibar_annotation_set_selected', {
set_id: 'spicedb',
});
expect(await screen.findByText('SpiceDB vs Zanzibar')).toBeInTheDocument();
});
});
7 changes: 5 additions & 2 deletions components/AnnotationSetSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, Transition } fro
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment, useEffect, useState } from 'react';
import { useAnnotation } from './annotation';
import { gtag } from './GTag';
import posthog from 'posthog-js';

function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
Expand All @@ -28,6 +28,9 @@ export default function AnnotationSetSelect(props: SelectProps) {

useEffect(() => {
if (activeAnnotationSetIds.length === 1) {
// Same pre-existing set-state-in-effect pattern as above (see
// components/annotation.tsx's AnnotationManagerProvider URL-fragment effect).
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelected(
props.items.find((item) => item.value === activeAnnotationSetIds[0])
);
Expand All @@ -41,7 +44,7 @@ export default function AnnotationSetSelect(props: SelectProps) {
}
toggleAnnotationSet(value.value);
setSelected(value);
gtag('event', 'annotation_set_selected', {
posthog.capture('zanzibar_annotation_set_selected', {
set_id: value.value,
});
};
Expand Down
105 changes: 105 additions & 0 deletions components/ExternalScripts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('next/script', () => ({
default: (props: Record<string, unknown>) => <script {...props} />,
}));
vi.mock('../util/consent', () => ({
isEUVisitor: vi.fn(),
readConsentCookie: vi.fn(),
shouldOptOutCapturing: vi.fn(),
}));

describe('ExternalScripts', () => {
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
vi.resetModules();
vi.clearAllMocks();
});

it('renders nothing outside of production', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'preview');
vi.stubEnv('NEXT_PUBLIC_GTM_ID', 'GTM-TEST0000');
const { ExternalScripts } = await import('./ExternalScripts.js');
const { container } = render(<ExternalScripts />);
expect(container.querySelector('#gtm-script')).toBeNull();
});

it('renders the GTM bootstrap and noscript fallback in production when NEXT_PUBLIC_GTM_ID is set', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production');
vi.stubEnv('NEXT_PUBLIC_GTM_ID', 'GTM-TEST0000');

const { ExternalScripts } = await import('./ExternalScripts.js');
const { container } = render(<ExternalScripts />);

expect(container.querySelector('#gtm-script')).not.toBeNull();

// The noscript-wrapped fallback iframe can't be observed through
// @testing-library/react's render(): it's a from-scratch client mount
// with no SSR/hydration step, and react-dom never reconciles JSX
// children into a <noscript> host element on such a mount (browsers do
// the same whenever the scripting flag is enabled). Next.js actually
// serves this component through SSR, so render it the same way here —
// via renderToStaticMarkup — to verify the real, shipped markup.
const staticMarkup = renderToStaticMarkup(<ExternalScripts />);
expect(staticMarkup).toContain(
'<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TEST0000"'
);
expect(staticMarkup).toMatch(/<noscript><iframe[^>]*><\/iframe><\/noscript>/);
});

it('renders no GTM markup in production when NEXT_PUBLIC_GTM_ID is unset', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production');
const { ExternalScripts } = await import('./ExternalScripts.js');
const { container } = render(<ExternalScripts />);
expect(container.querySelector('#gtm-script')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});

it('never loads the HubSpot script when NEXT_PUBLIC_HUBSPOT_ID is unset, regardless of consent', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production');
const consent = await import('../util/consent/index.js');
vi.mocked(consent.shouldOptOutCapturing).mockReturnValue(false);
vi.mocked(consent.isEUVisitor).mockReturnValue(false);
vi.mocked(consent.readConsentCookie).mockReturnValue(null);

const { ExternalScripts } = await import('./ExternalScripts.js');
render(<ExternalScripts />);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.getElementById('hs-script-loader')).toBeNull();
});

it('loads the HubSpot script once consent resolves to opted-in', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production');
vi.stubEnv('NEXT_PUBLIC_HUBSPOT_ID', 'hs-test-id');
const consent = await import('../util/consent/index.js');
vi.mocked(consent.shouldOptOutCapturing).mockReturnValue(false);
vi.mocked(consent.isEUVisitor).mockReturnValue(false);
vi.mocked(consent.readConsentCookie).mockReturnValue(null);

const { ExternalScripts } = await import('./ExternalScripts.js');
render(<ExternalScripts />);

await waitFor(() =>
expect(document.getElementById('hs-script-loader')).not.toBeNull()
);
});

it('does not load the HubSpot script when consent resolves to opted-out', async () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production');
vi.stubEnv('NEXT_PUBLIC_HUBSPOT_ID', 'hs-test-id');
const consent = await import('../util/consent/index.js');
vi.mocked(consent.shouldOptOutCapturing).mockReturnValue(true);
vi.mocked(consent.isEUVisitor).mockReturnValue(true);
vi.mocked(consent.readConsentCookie).mockReturnValue(null);

const { ExternalScripts } = await import('./ExternalScripts.js');
render(<ExternalScripts />);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.getElementById('hs-script-loader')).toBeNull();
});
});
176 changes: 176 additions & 0 deletions components/ExternalScripts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import Script from 'next/script';
import { useEffect, useState } from 'react';
import { isEUVisitor, readConsentCookie, shouldOptOutCapturing } from '../util/consent';
import { isVercelProduction } from '../util/isProd';

declare global {
interface Window {
dataLayer: unknown[];
}
}

const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
const HUBSPOT_ID = process.env.NEXT_PUBLIC_HUBSPOT_ID;

// ISO-3166 country codes for Google Consent Mode's `region` field. This is a
// different list from util/consent/eu-detection.ts's EU_TIMEZONES (IANA
// timezone identifiers) and can't be merged with it, but if EU/EEA
// membership ever changes, check that list too.
const EU_REGION_CODES = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU',
'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES',
'SE', 'IS', 'LI', 'NO', 'GB',
];

function ConsentModeDefaults() {
return (
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
analytics_storage: 'granted',
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
functionality_storage: 'granted',
personalization_storage: 'granted',
security_storage: 'granted'
});
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
wait_for_update: 1000,
region: ${JSON.stringify(EU_REGION_CODES)}
});
`,
}}
/>
);
}

function GTMScript() {
if (!GTM_ID) return null;
return (
<>
<Script
id="gtm-script"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${GTM_ID}');
`,
}}
/>
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
title="gtm-noscript"
/>
</noscript>
</>
);
}

function HubSpotLoader() {
const [shouldLoad, setShouldLoad] = useState(false);

useEffect(() => {
// isEUVisitor()/shouldOptOutCapturing() read document.cookie and the
// browser's IANA timezone, both unavailable during SSR; deferring to an
// effect keeps the client's first render matching the server's (always
// `null`) output, avoiding a hydration mismatch. The resulting extra
// render is the intended tradeoff.
//
// Gating on the `statistics` field (via shouldOptOutCapturing), not
// `marketing`, is intentional even though HubSpot is a marketing/CRM
// tool: this matches the established, deliberate convention already used
// by authzed/web (src/consent/vendorSync.ts's `syncHubSpot(prefs.statistics)`)
// and authzed/docs (components/scripts.tsx's
// `setLoadHs(!shouldOptOutCapturing(isEUVisitor()))`). Don't "fix" this
// to `marketing` in isolation -- that would make this property
// inconsistent with the other two on this exact point.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldLoad(!shouldOptOutCapturing(isEUVisitor()));
}, []);

if (!HUBSPOT_ID || !shouldLoad) return null;

// `defer` only (no `async`): in real production, `next/script`'s default
// `afterInteractive` strategy never renders a literal host <script>
// element at all — it returns null and loads the script imperatively via
// `document.createElement` plus its own dedup cache, so this doesn't
// affect production behavior either way. The distinction only matters for
// this test file's simplified mock (`vi.mock('next/script', ...)` above),
// which does render a raw <script {...props} /> host element — and for
// that mock, React 19 treats a rendered `<script async src=...>` as a
// hoistable "resource" it inserts into <head> and deliberately never
// removes on unmount (by design, since scripts can't be safely
// interrupted once started). That permanent-until-navigation behavior
// would let a script id leak across unrelated component instances within
// the same document — including across tests in this file, which reuse
// one jsdom document — so a later test could observe a script loaded by
// an earlier one even though its own render decided not to load it.
// `defer` alone still loads the script without blocking parsing and isn't
// upgraded to that special resource handling under the mock.
return <Script id="hs-script-loader" defer src={`//js.hs-scripts.com/${HUBSPOT_ID}.js`} />;
}

export function ExternalScripts() {
useEffect(() => {
if (!GTM_ID) {
if (isVercelProduction) {
console.warn('NEXT_PUBLIC_GTM_ID is unset; Google Tag Manager will not load.');
}
return;
}

const prefs = readConsentCookie();
// No cookie means no real decision has been made yet. Don't synthesize
// one from the isEUVisitor() timezone heuristic and push it as a global
// `consent update` -- that would override ConsentModeDefaults' static,
// region-scoped default, which is based on Google's own (more
// authoritative) server-side IP geolocation. Leave that default standing
// until a real cookie-based decision exists.
if (!prefs) return;

const granted = (allowed: boolean) => (allowed ? 'granted' : 'denied');

window.dataLayer = window.dataLayer || [];
function gtag(...args: unknown[]) {
window.dataLayer.push(args);
}

gtag('consent', 'update', {
analytics_storage: granted(prefs.statistics),
ad_storage: granted(prefs.marketing),
ad_user_data: granted(prefs.marketing),
ad_personalization: granted(prefs.marketing),
functionality_storage: granted(prefs.preferences),
personalization_storage: granted(prefs.preferences),
security_storage: 'granted',
});
}, []);

if (!isVercelProduction) return null;

return (
<>
<ConsentModeDefaults />
<GTMScript />
<HubSpotLoader />
</>
);
}
Loading
Loading