From 70ac1ffe7afde6c2b6ea03060edb753eb8c3b801 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 18:26:34 -0700 Subject: [PATCH 01/33] chore(ds): use status tokens for warning and critical callouts (#1) Replace raw Tailwind utilities (bg-yellow-50, bg-red-50, border-red-300, text-red-*) on /hipaa and /crisis 911-emergency callouts with design-system status tokens. Extends the existing @theme inline aliases in globals.css with warning/error/critical background twins and a critical foreground alias. Resolves audit UX-08 (.audit-report.md). The /crisis 988 section already used bg-crisis-bg (calm blue, intentional) and is unchanged. Co-authored-by: Claude Opus 4.7 (1M context) --- app/crisis/page.tsx | 14 +++++++------- app/globals.css | 8 ++++++++ app/hipaa/page.tsx | 4 ++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/crisis/page.tsx b/app/crisis/page.tsx index fd7f5bb..543efa9 100644 --- a/app/crisis/page.tsx +++ b/app/crisis/page.tsx @@ -223,8 +223,8 @@ export default function CrisisPage() { {/* When to Call 911 */}
-
-

+
+

When to call 911 (Emergency)

@@ -232,23 +232,23 @@ export default function CrisisPage() {

  • - + You or someone else has taken action to harm themselves
  • - + There is an immediate medical emergency
  • - + Someone is threatening to harm themselves or others right now
  • - + There is an active life-threatening situation
-
+

Note: 988 is for crisis support and suicide prevention. 911 is for immediate medical emergencies and life-threatening situations. diff --git a/app/globals.css b/app/globals.css index fe377d2..95ac428 100644 --- a/app/globals.css +++ b/app/globals.css @@ -28,6 +28,14 @@ --color-warning: var(--color-status-warning); --color-error: var(--color-status-error); --color-info: var(--color-status-info); + --color-critical: var(--color-status-critical); + + /* Status backgrounds */ + --color-success-bg: var(--color-status-successBackground); + --color-warning-bg: var(--color-status-warningBackground); + --color-error-bg: var(--color-status-errorBackground); + --color-info-bg: var(--color-status-infoBackground); + --color-critical-bg: var(--color-status-criticalBackground); /* Crisis shorthand */ --color-crisis-bg: var(--color-crisis-background); diff --git a/app/hipaa/page.tsx b/app/hipaa/page.tsx index 927de70..5e97604 100644 --- a/app/hipaa/page.tsx +++ b/app/hipaa/page.tsx @@ -19,7 +19,7 @@ export default function HIPAAPage() {

{/* Important Distinction */} -
+

Important: Being is NOT a HIPAA Covered Entity

@@ -289,7 +289,7 @@ export default function HIPAAPage() {
{/* Attorney Review Notice */} -
+

⚠️ DRAFT DOCUMENT: This HIPAA Notice requires attorney review before publication. Note: Being is NOT legally subject to HIPAA (we're a wellness app, not a From 6e9f01bc3167754b95380f264243c4ee72697045 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 18:26:52 -0700 Subject: [PATCH 02/33] fix(a11y): add skip-to-content link and main landmark id (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a visually-hidden skip link as the first focusable element on the main layout fragment, revealed on focus. Annotates

with id="main" and tabIndex={-1} so focus lands inside content after activation. Resolves audit A11Y-09 (.audit-report.md) — the /accessibility page advertises this feature on line 90 but it was never wired up. Restores WCAG 2.1 AA conformance (2.4.1 Bypass Blocks). Co-authored-by: Claude Opus 4.7 (1M context) --- app/(main)/layout.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 0cc6050..8c5027c 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -15,6 +15,14 @@ export default function MainLayout({ }) { return ( <> + {/* Skip to main content (WCAG 2.4.1 Bypass Blocks) */} + + Skip to main content + + {/* Desktop Navigation (hidden on mobile) */} @@ -22,7 +30,7 @@ export default function MainLayout({ {/* Main Content */} -
+
{children}
From 00d9d62c3b53e12e0c6a405e8a4decce40bfb817 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 18:27:06 -0700 Subject: [PATCH 03/33] chore(test): scaffold vitest and add waitlist route tests (#3) Adds vitest as a devDependency with test/test:watch scripts and a vitest.config.ts that aliases @/ to repo root and runs in the node environment (Cloudflare Workers' nodejs_compat flag matches). Covers the waitlist POST handler across all 8 branches: - missing/invalid email (400) - missing NOTION_TOKEN or NOTION_WAITLIST_DB_ID (500) - no A/B variant (Notion payload without Variant, no trackConversion) - variant 'A' assigned (Variant select + trackConversion called) - Notion API non-ok response (500) - malformed request body (500 via outer catch) Resolves audit TEST-08 (.audit-report.md). Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/waitlist/route.test.ts | 137 +++ package-lock.json | 1771 +++++++++++++++++++++++++++++--- package.json | 7 +- vitest.config.ts | 14 + 4 files changed, 1812 insertions(+), 117 deletions(-) create mode 100644 app/api/waitlist/route.test.ts create mode 100644 vitest.config.ts diff --git a/app/api/waitlist/route.test.ts b/app/api/waitlist/route.test.ts new file mode 100644 index 0000000..5e6efe3 --- /dev/null +++ b/app/api/waitlist/route.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/ab-testing', () => ({ + getVariant: vi.fn(), + trackConversion: vi.fn(), +})); + +function buildRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/waitlist', { + method: 'POST', + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +type RouteModule = typeof import('./route'); +type AbModule = typeof import('@/lib/ab-testing'); + +async function importRouteFresh(): Promise<{ route: RouteModule; ab: AbModule }> { + vi.resetModules(); + const ab = (await import('@/lib/ab-testing')) as AbModule; + const route = (await import('./route')) as RouteModule; + return { route, ab }; +} + +describe('POST /api/waitlist', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NOTION_TOKEN', 'test-token'); + vi.stubEnv('NOTION_WAITLIST_DB_ID', 'test-db'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('returns 400 when email is missing', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + + const res = await route.POST(buildRequest({})); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Valid email required' }); + }); + + it('returns 400 when email lacks @', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + + const res = await route.POST(buildRequest({ email: 'invalid' })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Valid email required' }); + }); + + it('returns 500 when NOTION_TOKEN is missing', async () => { + vi.stubEnv('NOTION_TOKEN', ''); + const { route } = await importRouteFresh(); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Server configuration error' }); + }); + + it('returns 500 when NOTION_WAITLIST_DB_ID is missing', async () => { + vi.stubEnv('NOTION_WAITLIST_DB_ID', ''); + const { route } = await importRouteFresh(); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Server configuration error' }); + }); + + it('posts to Notion without Variant when no A/B variant assigned', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(fetchSpy).toHaveBeenCalledOnce(); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://api.notion.com/v1/pages'); + const sent = JSON.parse((init as RequestInit).body as string); + expect(sent.properties.Email.title[0].text.content).toBe('a@b.com'); + expect(sent.properties.Variant).toBeUndefined(); + expect(sent.properties['Signed Up'].date.start).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ); + expect(ab.trackConversion).not.toHaveBeenCalled(); + }); + + it('posts to Notion with Variant and tracks conversion when variant assigned', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue('A'); + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(200); + const sent = JSON.parse( + (fetchSpy.mock.calls[0][1] as RequestInit).body as string, + ); + expect(sent.properties.Variant).toEqual({ select: { name: 'A' } }); + expect(ab.trackConversion).toHaveBeenCalledWith('A', 'waitlist_signup'); + }); + + it('returns 500 when Notion API responds non-ok', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'invalid' }), { status: 500 }), + ); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Failed to join waitlist' }); + expect(ab.trackConversion).not.toHaveBeenCalled(); + }); + + it('returns 500 when request body is malformed JSON', async () => { + const { route } = await importRouteFresh(); + const res = await route.POST(buildRequest('not-json')); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Failed to join waitlist' }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9a00699..e167814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,8 @@ "eslint": "^9", "eslint-config-next": "^16.0.7", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.7" } }, "node_modules/@alloc/quick-lru": { @@ -8919,21 +8920,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -8941,9 +8942,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -9308,6 +9309,24 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", @@ -10617,6 +10636,16 @@ "wrangler": "^4.49.1" } }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", @@ -10663,6 +10692,289 @@ "license": "MIT", "peer": true }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -12201,6 +12513,13 @@ "license": "CC0-1.0", "peer": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -12499,6 +12818,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -13150,20 +13487,106 @@ "win32" ] }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "license": "MIT", "peer": true @@ -13691,6 +14114,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -13976,6 +14409,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -15260,6 +15703,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -15338,6 +15791,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -15722,7 +16185,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -17990,9 +18452,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -18328,6 +18790,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -18531,8 +19004,7 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -18564,9 +19036,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -18584,7 +19056,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -18864,6 +19336,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -19295,6 +19801,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -19348,6 +19861,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -19358,6 +19878,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -19853,15 +20380,32 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -19889,9 +20433,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -19901,6 +20445,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -20289,105 +20843,1075 @@ "node": ">= 0.8" } }, - "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "license": "MIT", - "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "webpack": "bin/webpack.js" + "vitest": "vitest.mjs" }, "engines": { - "node": ">=10.13.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "webpack-cli": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "peer": true, "engines": { - "node": ">=10.13.0" + "node": ">=18" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/vitest/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vitest/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.103.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", + "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" }, @@ -20599,6 +22123,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 67cd3a8..cb35924 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@mp2ez/being-design-system": "^1.0.1", @@ -25,6 +27,7 @@ "eslint": "^9", "eslint-config-next": "^16.0.7", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.7" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ac42853 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false, + include: ['**/*.test.ts', '**/*.test.tsx'], + exclude: ['node_modules', '.next', '.open-next'], + }, + resolve: { + alias: { '@': path.resolve(__dirname, '.') }, + }, +}); From 2771b75612d60aa441c0f2fd75bfc8b255ffc396 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 18:29:04 -0700 Subject: [PATCH 04/33] test(ab): cover variant assignment, cookie lookup, and conversion logic (#5) Covers lib/ab-testing.ts: - assignVariant: default 50/50, weighted (0.9/0.1), edge weights [1,0]/[0,1], fallback when weights sum < 1, distribution sanity check over 2000 draws. - getVariant: cookie A/B/absent/invalid via mocked next/headers cookies(). - getVariantFromRequest: equivalent cases via NextRequest cookie header. - trackConversion: logs in development, silent in production. Branched off chore/test-vitest-waitlist so the Vitest scaffold is available locally; rebase onto preview after PR 3 lands. Resolves audit TEST-09 (.audit-report.md). Co-authored-by: Claude Opus 4.7 (1M context) --- lib/ab-testing.test.ts | 203 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 lib/ab-testing.test.ts diff --git a/lib/ab-testing.test.ts b/lib/ab-testing.test.ts new file mode 100644 index 0000000..f868fde --- /dev/null +++ b/lib/ab-testing.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('next/headers', () => ({ + cookies: vi.fn(), +})); + +const RANDOM_DENOMINATOR = 0xffffffff + 1; + +function stubRandom(quantile: number) { + return vi + .spyOn(crypto, 'getRandomValues') + .mockImplementation((arr: ArrayBufferView | null) => { + const u32 = arr as Uint32Array; + u32[0] = Math.floor(quantile * RANDOM_DENOMINATOR); + return u32 as unknown as ArrayBufferView; + }); +} + +describe('lib/ab-testing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('assignVariant', () => { + it("returns 'A' when random falls in the first 50% with default config", async () => { + stubRandom(0.25); + const { assignVariant } = await import('./ab-testing'); + expect(assignVariant()).toBe('A'); + }); + + it("returns 'B' when random falls in the second 50% with default config", async () => { + stubRandom(0.75); + const { assignVariant } = await import('./ab-testing'); + expect(assignVariant()).toBe('B'); + }); + + it("returns 'A' for a 0.9/0.1 weighted experiment when random is small", async () => { + stubRandom(0.5); + const { assignVariant } = await import('./ab-testing'); + expect( + assignVariant({ + name: 'biased', + variants: ['A', 'B'], + weights: [0.9, 0.1], + }), + ).toBe('A'); + }); + + it("returns 'B' for a 0.9/0.1 weighted experiment when random is large", async () => { + stubRandom(0.95); + const { assignVariant } = await import('./ab-testing'); + expect( + assignVariant({ + name: 'biased', + variants: ['A', 'B'], + weights: [0.9, 0.1], + }), + ).toBe('B'); + }); + + it("always returns 'A' when weights are [1, 0]", async () => { + const { assignVariant } = await import('./ab-testing'); + for (const q of [0, 0.25, 0.5, 0.999]) { + stubRandom(q); + expect( + assignVariant({ + name: 'all-a', + variants: ['A', 'B'], + weights: [1, 0], + }), + ).toBe('A'); + } + }); + + it("always returns 'B' when weights are [0, 1]", async () => { + const { assignVariant } = await import('./ab-testing'); + for (const q of [0, 0.25, 0.5, 0.999]) { + stubRandom(q); + expect( + assignVariant({ + name: 'all-b', + variants: ['A', 'B'], + weights: [0, 1], + }), + ).toBe('B'); + } + }); + + it('returns the last variant via fallback when weights sum below 1', async () => { + stubRandom(0.99); + const { assignVariant } = await import('./ab-testing'); + expect( + assignVariant({ + name: 'underweight', + variants: ['A', 'B'], + weights: [0.1, 0.1], + }), + ).toBe('B'); + }); + + it('produces a roughly correct distribution across many real-random draws', async () => { + const { assignVariant } = await import('./ab-testing'); + const counts = { A: 0, B: 0 }; + const N = 2000; + for (let i = 0; i < N; i++) counts[assignVariant()]++; + expect(counts.A / N).toBeGreaterThan(0.4); + expect(counts.A / N).toBeLessThan(0.6); + }); + }); + + describe('getVariant (from cookies())', () => { + async function setCookieValue(value: string | undefined) { + const { cookies } = await import('next/headers'); + (cookies as ReturnType).mockResolvedValue({ + get: vi.fn().mockReturnValue(value === undefined ? undefined : { value }), + }); + } + + it("returns 'A' when cookie value is 'A'", async () => { + await setCookieValue('A'); + const { getVariant } = await import('./ab-testing'); + expect(await getVariant()).toBe('A'); + }); + + it("returns 'B' when cookie value is 'B'", async () => { + await setCookieValue('B'); + const { getVariant } = await import('./ab-testing'); + expect(await getVariant()).toBe('B'); + }); + + it('returns null when the cookie is absent', async () => { + await setCookieValue(undefined); + const { getVariant } = await import('./ab-testing'); + expect(await getVariant()).toBeNull(); + }); + + it("returns null when the cookie value is not 'A' or 'B'", async () => { + await setCookieValue('C'); + const { getVariant } = await import('./ab-testing'); + expect(await getVariant()).toBeNull(); + }); + }); + + describe('getVariantFromRequest', () => { + function buildRequest(cookie?: string): NextRequest { + const headers = new Headers(); + if (cookie) headers.set('cookie', cookie); + return new NextRequest('http://localhost/', { headers }); + } + + it("returns 'A' when request carries an A cookie", async () => { + const { getVariantFromRequest } = await import('./ab-testing'); + expect(getVariantFromRequest(buildRequest('being_ab_variant=A'))).toBe('A'); + }); + + it("returns 'B' when request carries a B cookie", async () => { + const { getVariantFromRequest } = await import('./ab-testing'); + expect(getVariantFromRequest(buildRequest('being_ab_variant=B'))).toBe('B'); + }); + + it('returns null when no cookie is set', async () => { + const { getVariantFromRequest } = await import('./ab-testing'); + expect(getVariantFromRequest(buildRequest())).toBeNull(); + }); + + it('returns null for an unexpected cookie value', async () => { + const { getVariantFromRequest } = await import('./ab-testing'); + expect(getVariantFromRequest(buildRequest('being_ab_variant=C'))).toBeNull(); + }); + }); + + describe('trackConversion', () => { + it('logs in development mode', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { trackConversion } = await import('./ab-testing'); + + trackConversion('A', 'waitlist_signup', { source: 'homepage' }); + + expect(logSpy).toHaveBeenCalledWith('[A/B] Conversion tracked:', { + variant: 'A', + event: 'waitlist_signup', + metadata: { source: 'homepage' }, + }); + }); + + it('does not log in production mode', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { trackConversion } = await import('./ab-testing'); + + trackConversion('B', 'download_click'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); +}); From e87461f2d654b4f3926ea9bbdbfbcc0801171e76 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 19:08:58 -0700 Subject: [PATCH 05/33] fix(sec): unconditional cookie Secure + site-wide security headers (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit middleware.ts: A/B cookie's `secure` flag was gated on `process.env.NODE_ENV === 'production'`, which is not reliably 'production' on Cloudflare Workers. Set unconditionally to `true` — the cookie is non- essential and there's no scenario where we want it shipped over HTTP. next.config.ts: add `headers()` returning HSTS, X-Frame-Options DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and CSP for all routes. CSP allows the Notion API (waitlist) and Cloudflare Turnstile (used in the follow-up SEC-08 PR). Resolves audit SEC-09 + SEC-11 (.audit-report.md). Co-authored-by: Claude Opus 4.7 (1M context) --- middleware.ts | 2 +- next.config.ts | 42 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/middleware.ts b/middleware.ts index f4a1e75..6cfa236 100644 --- a/middleware.ts +++ b/middleware.ts @@ -33,7 +33,7 @@ export function middleware(request: NextRequest) { maxAge: AB_COOKIE_MAX_AGE, path: '/', sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', + secure: true, httpOnly: false, // Accessible to client JS for tracking }); diff --git a/next.config.ts b/next.config.ts index eabfbd8..1e2ab86 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,17 +1,53 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' https://api.notion.com https://challenges.cloudflare.com", + "frame-src https://challenges.cloudflare.com", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +]; + const nextConfig: NextConfig = { turbopack: { // Fix workspace root detection issue root: process.cwd(), // Enable raw imports for markdown files rules: { - '*.md': { - loaders: ['raw-loader'], - as: '*.js', + "*.md": { + loaders: ["raw-loader"], + as: "*.js", }, }, }, + async headers() { + return [ + { + source: "/:path*", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; From 51d8ad2f823fb019f1c24d8f1fdbe644b20131f6 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 19:09:07 -0700 Subject: [PATCH 06/33] chore(ts): enable strict flags + fix ab-testing types (#9) tsconfig.json: add noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, noImplicitReturns. lib/ab-testing.ts: refactor ExperimentConfig from {variants[], weights[]} to entries: {variant, weight}[] so the two halves can no longer drift in length. Add non-null assertions to crypto.getRandomValues result and the fallback-variant access (both are provably safe at runtime). Test fixes surfaced by the new strict mode: - waitlist route.test.ts: fetchSpy.mock.calls[0] is T|undefined under noUncheckedIndexedAccess; add ! assertion where the prior expectation already proved the call happened. - ab-testing.test.ts: stubRandom's mockImplementation signature was a too-loose ArrayBufferView; cast through typeof crypto.getRandomValues to satisfy the typed-array overloads. Resolves audit TS-12 + TS-13 (.audit-report.md). Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/waitlist/route.test.ts | 4 ++-- lib/ab-testing.test.ts | 39 +++++++++++++++++++++------------- lib/ab-testing.ts | 27 +++++++++++++++-------- tsconfig.json | 5 +++++ 4 files changed, 49 insertions(+), 26 deletions(-) diff --git a/app/api/waitlist/route.test.ts b/app/api/waitlist/route.test.ts index 5e6efe3..b769c9a 100644 --- a/app/api/waitlist/route.test.ts +++ b/app/api/waitlist/route.test.ts @@ -86,7 +86,7 @@ describe('POST /api/waitlist', () => { expect(await res.json()).toEqual({ success: true }); expect(fetchSpy).toHaveBeenCalledOnce(); - const [url, init] = fetchSpy.mock.calls[0]; + const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe('https://api.notion.com/v1/pages'); const sent = JSON.parse((init as RequestInit).body as string); expect(sent.properties.Email.title[0].text.content).toBe('a@b.com'); @@ -108,7 +108,7 @@ describe('POST /api/waitlist', () => { expect(res.status).toBe(200); const sent = JSON.parse( - (fetchSpy.mock.calls[0][1] as RequestInit).body as string, + (fetchSpy.mock.calls[0]![1] as RequestInit).body as string, ); expect(sent.properties.Variant).toEqual({ select: { name: 'A' } }); expect(ab.trackConversion).toHaveBeenCalledWith('A', 'waitlist_signup'); diff --git a/lib/ab-testing.test.ts b/lib/ab-testing.test.ts index f868fde..fa64079 100644 --- a/lib/ab-testing.test.ts +++ b/lib/ab-testing.test.ts @@ -10,11 +10,10 @@ const RANDOM_DENOMINATOR = 0xffffffff + 1; function stubRandom(quantile: number) { return vi .spyOn(crypto, 'getRandomValues') - .mockImplementation((arr: ArrayBufferView | null) => { - const u32 = arr as Uint32Array; - u32[0] = Math.floor(quantile * RANDOM_DENOMINATOR); - return u32 as unknown as ArrayBufferView; - }); + .mockImplementation(((arr: Uint32Array): Uint32Array => { + arr[0] = Math.floor(quantile * RANDOM_DENOMINATOR); + return arr; + }) as typeof crypto.getRandomValues); } describe('lib/ab-testing', () => { @@ -46,8 +45,10 @@ describe('lib/ab-testing', () => { expect( assignVariant({ name: 'biased', - variants: ['A', 'B'], - weights: [0.9, 0.1], + entries: [ + { variant: 'A', weight: 0.9 }, + { variant: 'B', weight: 0.1 }, + ], }), ).toBe('A'); }); @@ -58,8 +59,10 @@ describe('lib/ab-testing', () => { expect( assignVariant({ name: 'biased', - variants: ['A', 'B'], - weights: [0.9, 0.1], + entries: [ + { variant: 'A', weight: 0.9 }, + { variant: 'B', weight: 0.1 }, + ], }), ).toBe('B'); }); @@ -71,8 +74,10 @@ describe('lib/ab-testing', () => { expect( assignVariant({ name: 'all-a', - variants: ['A', 'B'], - weights: [1, 0], + entries: [ + { variant: 'A', weight: 1 }, + { variant: 'B', weight: 0 }, + ], }), ).toBe('A'); } @@ -85,8 +90,10 @@ describe('lib/ab-testing', () => { expect( assignVariant({ name: 'all-b', - variants: ['A', 'B'], - weights: [0, 1], + entries: [ + { variant: 'A', weight: 0 }, + { variant: 'B', weight: 1 }, + ], }), ).toBe('B'); } @@ -98,8 +105,10 @@ describe('lib/ab-testing', () => { expect( assignVariant({ name: 'underweight', - variants: ['A', 'B'], - weights: [0.1, 0.1], + entries: [ + { variant: 'A', weight: 0.1 }, + { variant: 'B', weight: 0.1 }, + ], }), ).toBe('B'); }); diff --git a/lib/ab-testing.ts b/lib/ab-testing.ts index 975684b..b9e81c2 100644 --- a/lib/ab-testing.ts +++ b/lib/ab-testing.ts @@ -17,17 +17,24 @@ export const AB_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds // Experiment configuration export type Variant = 'A' | 'B'; +export interface VariantWeight { + variant: Variant; + weight: number; +} + export interface ExperimentConfig { name: string; - variants: Variant[]; - weights: number[]; // Must sum to 1.0 + // Coupled variant/weight pairs so the two can't drift in length. + entries: VariantWeight[]; } // Default experiment: 50/50 split export const DEFAULT_EXPERIMENT: ExperimentConfig = { name: 'default', - variants: ['A', 'B'], - weights: [0.5, 0.5], + entries: [ + { variant: 'A', weight: 0.5 }, + { variant: 'B', weight: 0.5 }, + ], }; /** @@ -35,18 +42,20 @@ export const DEFAULT_EXPERIMENT: ExperimentConfig = { * Uses crypto.getRandomValues for unbiased randomness */ export function assignVariant(config: ExperimentConfig = DEFAULT_EXPERIMENT): Variant { - const random = crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1); + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + const random = buf[0]! / (0xffffffff + 1); let cumulative = 0; - for (let i = 0; i < config.variants.length; i++) { - cumulative += config.weights[i]; + for (const { variant, weight } of config.entries) { + cumulative += weight; if (random < cumulative) { - return config.variants[i]; + return variant; } } // Fallback to last variant (shouldn't happen with proper weights) - return config.variants[config.variants.length - 1]; + return config.entries[config.entries.length - 1]!.variant; } /** diff --git a/tsconfig.json b/tsconfig.json index e83516c..437444d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,11 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", From eaeaa70fc4a896f4f61dd7eb996cfd1561773ad8 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 19:09:17 -0700 Subject: [PATCH 07/33] perf: extract /philosophy accordions into client subcomponent (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /philosophy was a 554-line 'use client' page hydrating an entire React component tree just to support 4 accordion buttons. Most of the page is static prose that benefits from SSR. Extract the 'Deepen Your Practice' accordion section into a new components/philosophy/PracticeAccordions.tsx client subcomponent that owns the useState + toggle logic. Convert app/(main)/philosophy/page.tsx to a server component (removed 'use client', useState import, toggleSection handler) and reference in place. Initial HTML now carries all the static prose (hero, principles, comparisons, CTA) — only the accordion section hydrates. One-open-at-a-time behavior preserved. While extracting, also wrap the decorative emoji and +/- indicators in aria-hidden spans and add aria-expanded / aria-controls on each accordion button so the section is properly announced to screen readers. Resolves audit PERF-W-01 (.audit-report.md). Co-authored-by: Claude Opus 4.7 (1M context) --- app/(main)/philosophy/page.tsx | 282 ++----------------- components/philosophy/PracticeAccordions.tsx | 266 +++++++++++++++++ 2 files changed, 288 insertions(+), 260 deletions(-) create mode 100644 components/philosophy/PracticeAccordions.tsx diff --git a/app/(main)/philosophy/page.tsx b/app/(main)/philosophy/page.tsx index 3e5f4a3..22a94a2 100644 --- a/app/(main)/philosophy/page.tsx +++ b/app/(main)/philosophy/page.tsx @@ -3,21 +3,17 @@ * Professional SaaS Design * Updated structure: Five Core Principles as primary framework * NOTE: Content validated by philosopher agent + * + * Server component. Only the "Deepen Your Practice" accordion section is + * a client subcomponent (PracticeAccordions) — everything else is static + * content rendered on the server. */ -'use client'; - -import { useState } from 'react'; import BrainIcon from '@/components/shared/BrainIcon'; import Button from '@/components/shared/Button'; +import PracticeAccordions from '@/components/philosophy/PracticeAccordions'; export default function PhilosophyPage() { - const [expandedSection, setExpandedSection] = useState(null); - - const toggleSection = (section: string) => { - setExpandedSection(expandedSection === section ? null : section); - }; - return (
{/* Hero Section */} @@ -31,11 +27,11 @@ export default function PhilosophyPage() {

- Stoic Mindfulness is not just Stoicism plus mindfulness—it's a unique integrated system + Stoic Mindfulness is not just Stoicism plus mindfulness—it’s a unique integrated system that combines ancient philosophical wisdom with modern contemplative practice and neuroscience.

- It's the practice of aware wisdom: bringing full presence to each moment + It’s the practice of aware wisdom: bringing full presence to each moment while guided by principles that help you discern what truly matters and respond with virtue.

@@ -131,7 +127,7 @@ export default function PhilosophyPage() { {/* Principle 1: Aware Presence */}
-
🧘
+

1. Aware Presence

@@ -139,7 +135,7 @@ export default function PhilosophyPage() { swept away by them.

- "Am I present and aware right now?" + “Am I present and aware right now?”

@@ -148,15 +144,15 @@ export default function PhilosophyPage() { {/* Principle 2: Radical Acceptance */}
-
🌊
+

2. Radical Acceptance

- This is what's happening. Acceptance doesn't mean approval—it means acknowledging + This is what’s happening. Acceptance doesn’t mean approval—it means acknowledging reality as the foundation for wise action.

- "Can I accept this moment as it is?" + “Can I accept this moment as it is?”

@@ -165,7 +161,7 @@ export default function PhilosophyPage() { {/* Principle 3: Sphere Sovereignty */}
-
🎯
+

3. Sphere Sovereignty

@@ -173,7 +169,7 @@ export default function PhilosophyPage() { attachment to outcomes. Govern only your domain.

- "What's actually within my control here?" + “What’s actually within my control here?”

@@ -182,7 +178,7 @@ export default function PhilosophyPage() { {/* Principle 4: Virtuous Response */}
-
⚖️
+

4. Virtuous Response

@@ -190,7 +186,7 @@ export default function PhilosophyPage() { or temperance requires. Choose the response that aligns with your values.

- "What does virtue require of me now?" + “What does virtue require of me now?”

@@ -199,7 +195,7 @@ export default function PhilosophyPage() { {/* Principle 5: Interconnected Living */}
-
🌍
+

5. Interconnected Living

@@ -207,7 +203,7 @@ export default function PhilosophyPage() { shares a common nature and common good.

- "How can I serve the common good?" + “How can I serve the common good?”

@@ -252,7 +248,7 @@ export default function PhilosophyPage() {
  • ✓ Awareness + philosophy
  • ✓ Wise discernment
  • -
  • ✓ Meaning & flourishing
  • +
  • ✓ Meaning & flourishing
  • ✓ Integrated with ethics
@@ -274,7 +270,7 @@ export default function PhilosophyPage() {
  • • Philosophical study
  • • Intellectual reasoning
  • • Retrospective analysis
  • -
  • • Reading & reflection
  • +
  • • Reading & reflection
  • @@ -295,242 +291,8 @@ export default function PhilosophyPage() {
    - {/* Deepen Your Practice */} -
    -
    -

    - Deepen Your Practice -

    - - {/* The Four Cardinal Virtues */} -
    - - - {expandedSection === 'virtues' && ( -
    -
    - {/* Wisdom */} -
    -
    🦉
    -

    Wisdom

    -

    - Discerning what is truly good, bad, or indifferent. Understanding what is - within your control and what is not. -

    -
    - "You have power over your mind—not outside events." -
    — Marcus Aurelius, Meditations 6.8
    -
    -
    - - {/* Courage */} -
    -
    🦁
    -

    Courage

    -

    - Not the absence of fear, but action in spite of it. Facing difficulties - with resilience and maintaining your principles. -

    -
    - "It is because we do not dare that things are difficult." -
    — Seneca, Letters to Lucilius 104.26
    -
    -
    - - {/* Justice */} -
    -
    ⚖️
    -

    Justice

    -

    - Treating others with fairness, kindness, and respect. Recognizing our - responsibilities to the larger community. -

    -
    - "What brings no benefit to the hive brings none to the bee." -
    — Marcus Aurelius, Meditations 6.54
    -
    -
    - - {/* Temperance */} -
    -
    🌊
    -

    Temperance

    -

    - Self-control and moderation. Finding balance, avoiding excess, and - cultivating discipline in thought and action. -

    -
    - "It is in our power not to want what we don't have." -
    — Seneca, Letters to Lucilius 123.3 (adapted)
    -
    -
    -
    -
    - )} -
    - - {/* Entry Level */} -
    - - - {expandedSection === 'entry' && ( -
    -
    -

    Morning & Evening Check-ins

    -

    - Morning: Set your intention. What principle will you practice today?
    - Evening: Review your day. Where did awareness and virtue meet? Where can you grow? -

    -
    - -
    -

    Aware Presence Practice

    -

    - Notice your breath. Observe sensations in your body. Watch thoughts arise and pass - without getting swept away. This is the foundation of all practice. -

    -
    - -
    -

    Sphere Sovereignty Sorting

    -

    - When worry arises, ask: "Is this within my control?" If yes, plan action. If no, - practice acceptance. This is the cornerstone of Stoic practice. -

    -
    -
    - )} -
    - - {/* Intermediate Level */} -
    - - - {expandedSection === 'intermediate' && ( -
    -
    -

    Premeditatio Malorum (Negative Visualization)

    -

    - Rational contemplation, not catastrophizing: Briefly imagine losing - what you value to cultivate gratitude and prepare for uncertainty. Time-limited (2-3 minutes), - followed by appreciation for the present. This builds resilience without anxiety. -

    -
    - -
    -

    The View from Above

    -

    - Zoom out: see yourself, your city, your country, the Earth from space. This cosmic - perspective helps you see daily worries in proper proportion. -

    -
    - -
    -

    Voluntary Discomfort

    -

    - Occasionally practice small discomforts: a cold shower, fasting, saying no to pleasures. - This builds resilience and reminds you that comfort is not required for well-being. -

    -
    -
    - )} -
    - - {/* Advanced Level */} -
    - - - {expandedSection === 'advanced' && ( -
    -
    -

    Amor Fati (Love of Fate)

    -

    - Beyond acceptance: loving what happens. Not passive resignation, but active embrace - of reality as the raw material for growth. Every obstacle becomes fuel for virtue. -

    -
    - -
    -

    Memento Mori (Remember Death)

    -

    - Your time is limited—this isn't morbid, it's liberating. When you remember mortality, - trivial concerns fade and what truly matters becomes clear. Every moment is precious. -

    -
    - -
    -

    Sympatheia (Interconnection)

    -

    - Deep recognition that you are part of a larger whole. Your actions ripple outward. - Justice isn't optional—it's recognition that humanity shares a common nature and - common good. Bring presence to this truth. -

    -
    -
    - )} -
    -
    -
    + {/* Deepen Your Practice — interactive accordions (client subcomponent) */} + {/* Download CTA */}
    diff --git a/components/philosophy/PracticeAccordions.tsx b/components/philosophy/PracticeAccordions.tsx new file mode 100644 index 0000000..5e48247 --- /dev/null +++ b/components/philosophy/PracticeAccordions.tsx @@ -0,0 +1,266 @@ +/** + * Philosophy "Deepen Your Practice" — interactive accordions. + * + * Extracted from app/(main)/philosophy/page.tsx so the rest of that page + * can stay server-rendered. One-open-at-a-time behavior is preserved. + */ + +'use client'; + +import { useState } from 'react'; + +type SectionId = 'virtues' | 'entry' | 'intermediate' | 'advanced'; + +export default function PracticeAccordions() { + const [expandedSection, setExpandedSection] = useState(null); + + const toggleSection = (section: SectionId) => { + setExpandedSection(expandedSection === section ? null : section); + }; + + return ( +
    +
    +

    + Deepen Your Practice +

    + + {/* The Four Cardinal Virtues */} +
    + + + {expandedSection === 'virtues' && ( +
    +
    + {/* Wisdom */} +
    + +

    Wisdom

    +

    + Discerning what is truly good, bad, or indifferent. Understanding what is + within your control and what is not. +

    +
    + “You have power over your mind—not outside events.” +
    — Marcus Aurelius, Meditations 6.8
    +
    +
    + + {/* Courage */} +
    + +

    Courage

    +

    + Not the absence of fear, but action in spite of it. Facing difficulties + with resilience and maintaining your principles. +

    +
    + “It is because we do not dare that things are difficult.” +
    — Seneca, Letters to Lucilius 104.26
    +
    +
    + + {/* Justice */} +
    + +

    Justice

    +

    + Treating others with fairness, kindness, and respect. Recognizing our + responsibilities to the larger community. +

    +
    + “What brings no benefit to the hive brings none to the bee.” +
    — Marcus Aurelius, Meditations 6.54
    +
    +
    + + {/* Temperance */} +
    + +

    Temperance

    +

    + Self-control and moderation. Finding balance, avoiding excess, and + cultivating discipline in thought and action. +

    +
    + “It is in our power not to want what we don’t have.” +
    — Seneca, Letters to Lucilius 123.3 (adapted)
    +
    +
    +
    +
    + )} +
    + + {/* Entry Level */} +
    + + + {expandedSection === 'entry' && ( +
    +
    +

    Morning & Evening Check-ins

    +

    + Morning: Set your intention. What principle will you practice today?
    + Evening: Review your day. Where did awareness and virtue meet? Where can you grow? +

    +
    + +
    +

    Aware Presence Practice

    +

    + Notice your breath. Observe sensations in your body. Watch thoughts arise and pass + without getting swept away. This is the foundation of all practice. +

    +
    + +
    +

    Sphere Sovereignty Sorting

    +

    + When worry arises, ask: “Is this within my control?” If yes, plan action. If no, + practice acceptance. This is the cornerstone of Stoic practice. +

    +
    +
    + )} +
    + + {/* Intermediate Level */} +
    + + + {expandedSection === 'intermediate' && ( +
    +
    +

    Premeditatio Malorum (Negative Visualization)

    +

    + Rational contemplation, not catastrophizing: Briefly imagine losing + what you value to cultivate gratitude and prepare for uncertainty. Time-limited (2-3 minutes), + followed by appreciation for the present. This builds resilience without anxiety. +

    +
    + +
    +

    The View from Above

    +

    + Zoom out: see yourself, your city, your country, the Earth from space. This cosmic + perspective helps you see daily worries in proper proportion. +

    +
    + +
    +

    Voluntary Discomfort

    +

    + Occasionally practice small discomforts: a cold shower, fasting, saying no to pleasures. + This builds resilience and reminds you that comfort is not required for well-being. +

    +
    +
    + )} +
    + + {/* Advanced Level */} +
    + + + {expandedSection === 'advanced' && ( +
    +
    +

    Amor Fati (Love of Fate)

    +

    + Beyond acceptance: loving what happens. Not passive resignation, but active embrace + of reality as the raw material for growth. Every obstacle becomes fuel for virtue. +

    +
    + +
    +

    Memento Mori (Remember Death)

    +

    + Your time is limited—this isn’t morbid, it’s liberating. When you remember mortality, + trivial concerns fade and what truly matters becomes clear. Every moment is precious. +

    +
    + +
    +

    Sympatheia (Interconnection)

    +

    + Deep recognition that you are part of a larger whole. Your actions ripple outward. + Justice isn’t optional—it’s recognition that humanity shares a common nature and + common good. Bring presence to this truth. +

    +
    +
    + )} +
    +
    +
    + ); +} From ea694372175ffdb9a61ec438217f7b90e8a7fb25 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 22 May 2026 19:09:27 -0700 Subject: [PATCH 08/33] fix(a11y): label expansion, aria-current, decorative-glyph hiding (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MobileBottomTabs: expand 'Phil'/'Feat' to full words for screen readers (icons preserve the visual abbreviation); wrap emoji in aria-hidden; add aria-current='page' on the active tab; aria-label on the nav. - MobileHeader: aria-label='988 Crisis Support' on the compact 988 pill. - home page: wrap the three '→' CTA arrows in aria-hidden spans so they aren't announced as 'right arrow'. - (standalone) splash: replace the placeholder-as-sole-label anti-pattern with a visually-hidden
    - {/* Testing & Compliance */} + {/* Reporting Accessibility Issues */}

    - Testing & Compliance + Reporting Accessibility Issues

    -

    - We regularly test Being's accessibility through: +

    + We design to WCAG 2.1 Level AA standards. If you encounter an + accessibility barrier, please report it to{' '} + + accessibility@being.fyi + {' '} + and we will work to address it.

    -
      -
    • - - Automated accessibility testing with aXe and Lighthouse -
    • -
    • - - Manual testing with VoiceOver, TalkBack, and NVDA screen readers -
    • -
    • - - Keyboard-only navigation testing -
    • -
    • - - Color contrast validation -
    • -
    • - - User testing with people who rely on assistive technologies -
    • -
    {/* Feedback & Support */} From 1d4df453b30faf2cd6c5df50e94ac6ac53f14b00 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sun, 24 May 2026 12:13:50 -0700 Subject: [PATCH 22/33] =?UTF-8?q?fix(copy):=20trial=20duration=2028=20days?= =?UTF-8?q?=20=E2=86=92=201=20month=20for=20Apple=20intro-offer=20alignmen?= =?UTF-8?q?t=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple StoreKit introductory offers only support these durations: 3 days, 1 week, 2 weeks, 1 month, 2 months, 3 months, 6 months, 1 year. "28 days" is not a configurable duration. The underlying App Store offer will be configured as P1M (1 month). Marketing copy must match — a mismatch between website copy and App Store offer is both an App Store Review Guideline 2.3.7 violation (accurate metadata) and FTC §5 exposure (misleading offer claim). 6 occurrences updated across home, features, download, and the coming-soon splash: - 4× "28-day free trial" → "1 month free trial" - 1× "28 days" → "1 month" (download page big-display) - 1× "28-day free trial included" → "1 month free trial included" No copy that just says "free trial" (without a duration) needs to change. Co-authored-by: Claude Opus 4.7 (1M context) --- app/(main)/download/page.tsx | 4 ++-- app/(main)/features/page.tsx | 2 +- app/(main)/home/page.tsx | 4 ++-- app/(standalone)/page.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/(main)/download/page.tsx b/app/(main)/download/page.tsx index 0020565..7f03d74 100644 --- a/app/(main)/download/page.tsx +++ b/app/(main)/download/page.tsx @@ -21,7 +21,7 @@ export default function DownloadPage() { Download Being

    - Start your Stoic Mindfulness practice today. 28-day free trial included. + Start your Stoic Mindfulness practice today. 1 month free trial included. No credit card required.

    @@ -104,7 +104,7 @@ export default function DownloadPage() {
    🎁

    Free Trial

    -
    28 days
    +
    1 month

    Full access, no credit card

      diff --git a/app/(main)/features/page.tsx b/app/(main)/features/page.tsx index 79e781c..024245a 100644 --- a/app/(main)/features/page.tsx +++ b/app/(main)/features/page.tsx @@ -291,7 +291,7 @@ export default function FeaturesPage() {

      Experience Stoic Mindfulness with evidence-based self-monitoring tools. - Start your 28-day free trial today. + Start your 1 month free trial today.

      @@ -153,7 +153,7 @@ export default function Home() {

      - Join thousands practicing Stoic Mindfulness. Start your 28-day free trial today. + Join thousands practicing Stoic Mindfulness. Start your 1 month free trial today.

      diff --git a/app/(standalone)/page.tsx b/app/(standalone)/page.tsx index fe5fbb6..af3cdd8 100644 --- a/app/(standalone)/page.tsx +++ b/app/(standalone)/page.tsx @@ -149,7 +149,7 @@ export default function ComingSoon() { - 28-day free trial + 1 month free trial From ebe58fad3c2fe7dcec5051a5863a74604e45140a Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sun, 24 May 2026 12:13:53 -0700 Subject: [PATCH 23/33] fix(copy): remove HIPAA-level + clinical terminology from marketing (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(copy): remove "HIPAA-level" + "clinical" terminology from marketing Same FTC §5 + regulatory-applicability.md pattern as the /hipaa page removal: claiming "HIPAA-level" encryption/privacy when Being is not a HIPAA-covered entity creates deceptive-practices exposure even when technically accurate (AES-256 is fine encryption — the issue is invoking the HIPAA brand to imply equivalence). Per regulatory-applicability.md terminology table: - "HIPAA-compliant encryption" → "AES-256 encryption" - "Clinical assessment" → "Wellness self-assessment" Three replacements: 1. home page Privacy callout: "HIPAA-level encryption" → "AES-256 encryption" 2. features page hero subhead: "clinical assessments...with HIPAA- level privacy" → "wellness self-assessments...with privacy- first design and AES-256 encryption" 3. features page Privacy & Security section: "HIPAA-level encryption" → "AES-256 encryption" Also updates the JSDoc comment in features/page.tsx to use the correct terminology. These references surfaced during the legal-cleanup PR series (see #24-#28). Same legal exposure pattern, missed by the original handoff because they were inline marketing copy, not whole pages. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(copy): also fix "Clinical tools" → "Wellness self-monitoring tools" Found while doing a final HIPAA/clinical sweep — same regulatory- applicability.md terminology pattern as the other fixes in this PR. The home page's Science pillar copy referred to PHQ-9 + GAD-7 as "clinical tools." Per the terminology table: - "Clinical assessment" / "Clinical tool" → "Wellness screening" or "self-monitoring tool" Also softened "evidence-based care" → "evidence-based practice" since "care" implies clinical/treatment relationship that Being explicitly disclaims. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(main)/features/page.tsx | 6 +++--- app/(main)/home/page.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/(main)/features/page.tsx b/app/(main)/features/page.tsx index 024245a..8b8c4b7 100644 --- a/app/(main)/features/page.tsx +++ b/app/(main)/features/page.tsx @@ -1,7 +1,7 @@ /** * Features Page - Being app capabilities * Professional SaaS Design - * Showcases daily check-ins, clinical tools, crisis support, privacy + * Showcases daily check-ins, wellness self-assessments, crisis support, privacy */ import BrainIcon from '@/components/shared/BrainIcon'; @@ -21,7 +21,7 @@ export default function FeaturesPage() {

      Being combines ancient Stoic wisdom with modern mental health science. Daily check-ins, - clinical assessments, and crisis support—all with HIPAA-level privacy. + wellness self-assessments, and crisis support, with privacy-first design and AES-256 encryption.

      @@ -242,7 +242,7 @@ export default function FeaturesPage() { Privacy & Security

      - Your mental health data is yours. We protect it with HIPAA-level encryption + Your mental health data is yours. We protect it with AES-256 encryption and local-first storage.

      diff --git a/app/(main)/home/page.tsx b/app/(main)/home/page.tsx index f7f5ad2..d2d0a5d 100644 --- a/app/(main)/home/page.tsx +++ b/app/(main)/home/page.tsx @@ -104,7 +104,7 @@ export default function Home() { Science

      - Clinical tools (PHQ-9, GAD-7) track your mental health. Immediate crisis support via 988. Mindfulness meets evidence-based care. + Wellness self-monitoring tools (PHQ-9, GAD-7) help you track your mental health. Immediate crisis support via 988. Mindfulness grounded in evidence-based practice.

      - HIPAA-level encryption. All data stored locally on your device. We never sell your information. Your mental health data belongs to you. + AES-256 encryption. All data stored locally on your device. We never sell your information. Your mental health data belongs to you.

      Date: Sun, 24 May 2026 12:13:57 -0700 Subject: [PATCH 24/33] fix(legal): remove /hipaa page and footer link (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /hipaa page used "HIPAA Notice" terminology (term-of-art) while disclaiming HIPAA applicability, and made voluntary commitments mirroring HIPAA §164.524 and 60-day breach notification. The page also carried a publicly visible "Draft document - Attorney review required" banner and [TO BE DETERMINED] placeholders. Per the regulatory-applicability source-of-truth doc, Being must not use HIPAA framing in user-facing materials. The 60-day breach notification commitment is preserved in a new privacy-policy section under the FTC Health Breach Notification Rule (16 CFR Part 318) — see companion PR in the being repo. Co-authored-by: Claude Opus 4.7 (1M context) --- app/hipaa/page.tsx | 303 ------------------------------- components/navigation/Footer.tsx | 5 - tests/pages.smoke.test.tsx | 5 +- 3 files changed, 2 insertions(+), 311 deletions(-) delete mode 100644 app/hipaa/page.tsx diff --git a/app/hipaa/page.tsx b/app/hipaa/page.tsx deleted file mode 100644 index 576b800..0000000 --- a/app/hipaa/page.tsx +++ /dev/null @@ -1,303 +0,0 @@ -/** - * HIPAA Notice Page - * NOTE: REQUIRES ATTORNEY REVIEW BEFORE LAUNCH - * Explains HIPAA-level privacy practices (Being is not a covered entity) - */ - -export default function HIPAAPage() { - return ( -
      -
      -
      -

      - HIPAA Notice -

      -

      - ⚠️ Draft document - Attorney review required -

      -
      - -
      - {/* Important Distinction */} -
      -

      - Important: Being is NOT a HIPAA Covered Entity -

      -

      - Being is a wellness application, not a healthcare provider. We are not a - healthcare provider, health plan, or healthcare clearinghouse. Therefore, Being is - not legally subject to HIPAA regulations. -

      -

      - HIPAA applies to "covered entities" (healthcare providers, health plans, healthcare clearinghouses) - and their "business associates." Being does not fall into either category because we provide - wellness tools and self-monitoring resources, not medical services. -

      -

      - We voluntarily choose to adopt HIPAA-level security and privacy standards because - we deeply care about protecting your mental health data. This is our commitment to you, not a - legal obligation. We believe wellness data deserves the same protection as medical data. -

      -
      - - {/* Our Commitment */} -
      -

      - Our Voluntary Commitment to HIPAA-Level Privacy -

      -

      - We choose to adopt HIPAA-equivalent practices because we care about your privacy. -

      -

      - Even though HIPAA does not legally apply to Being (we're a wellness app, not a healthcare - provider), we voluntarily implement the following HIPAA-inspired safeguards to protect your - mental health information: -

      - -

      - Administrative Safeguards -

      -
        -
      • - - Privacy and security policies reviewed regularly -
      • -
      • - - Employee training on data privacy and security -
      • -
      • - - Limited access to user data (least privilege principle) -
      • -
      • - - Incident response plan for data breaches -
      • -
      - -

      - Physical Safeguards -

      -
        -
      • - - Secure cloud infrastructure with restricted physical access -
      • -
      • - - Data center security controls and monitoring -
      • -
      • - - Secure disposal of electronic media -
      • -
      - -

      - Technical Safeguards -

      -
        -
      • - - Encryption: AES-256 at rest, TLS 1.3 in transit -
      • -
      • - - Access Controls: Multi-factor authentication, role-based access -
      • -
      • - - Audit Logging: Comprehensive logging of data access -
      • -
      • - - Automatic Logoff: Session timeouts for inactive users -
      • -
      • - - Data Integrity: Validation and verification of data accuracy -
      • -
      -
      - - {/* Your Protected Health Information */} -
      -

      - What Information We Protect -

      -

      - We voluntarily protect the following wellness information with HIPAA-level security - (even though we're not legally required to do so): -

      -
        -
      • - - Wellness assessments (PHQ-9, GAD-7 scores and responses for self-monitoring) -
      • -
      • - - Daily check-in data (mood, emotions, thoughts) -
      • -
      • - - Journal entries and personal reflections -
      • -
      • - - Progress tracking and historical data -
      • -
      • - - Any personally identifiable information (email, name) -
      • -
      -
      - - {/* When We May Disclose Information */} -
      -

      - When We May Disclose Your Information -

      -

      - Following HIPAA-inspired principles (by choice, not legal requirement), we limit disclosure - of your information to: -

      -
        -
      • - 1. - With Your Authorization: When you explicitly request to share - data with healthcare providers -
      • -
      • - 2. - For Treatment, Payment, Operations: To provide our services, - process payments, and improve the app (de-identified data only) -
      • -
      • - 3. - As Required by Law: When compelled by valid legal process - (subpoena, court order) -
      • -
      • - 4. - Public Health & Safety: To prevent serious threat to health - or safety (rare, emergency situations only) -
      • -
      -
      - - {/* Your Rights */} -
      -

      - Your Privacy Rights -

      -

      - Inspired by HIPAA principles (which we voluntarily adopt), you have the right to: -

      -
        -
      • - - Access: Request and receive a copy of your health information -
      • -
      • - - Amendment: Request corrections to inaccurate information -
      • -
      • - - Accounting: Request a list of disclosures we've made -
      • -
      • - - Restriction: Request limits on how we use your information -
      • -
      • - - Confidential Communication: Request communication via specific methods -
      • -
      • - - Portability: Export your data in portable format -
      • -
      • - - Deletion: Request deletion of your account and all data -
      • -
      -
      - - {/* Breach Notification */} -
      -

      - Breach Notification -

      -

      - In the unlikely event of a data breach affecting your protected information, we will - notify you within 60 days via email and/or in-app notification, following HIPAA breach - notification standards (even though we're not legally required to comply with HIPAA). - The notification will include: -

      -
        -
      • - - Description of what happened -
      • -
      • - - Types of information involved -
      • -
      • - - Steps you should take to protect yourself -
      • -
      • - - Steps we're taking to investigate and prevent future breaches -
      • -
      -
      - - {/* Contact */} -
      -

      - Questions or Concerns? -

      -

      - If you have questions about our voluntary HIPAA-level privacy practices: -

      -
      -

      - Privacy Officer:{' '} - - privacy@being.fyi - -

      -

      - Mailing Address: [TO BE DETERMINED] -

      -
      -
      - - {/* Effective Date */} -
      -

      - Effective Date: [TO BE DETERMINED]
      - Last Reviewed: [TO BE DETERMINED] -

      -
      -
      - - {/* Attorney Review Notice */} -
      -

      - ⚠️ DRAFT DOCUMENT: This HIPAA Notice requires attorney review before - publication. Note: Being is NOT legally subject to HIPAA (we're a wellness app, not a - healthcare provider), but we voluntarily adopt HIPAA-level practices. Attorney should - review to ensure accurate representation of this voluntary commitment. -

      -
      -
      -
      - ); -} diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index 738cde8..f35ff13 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -37,11 +37,6 @@ export default function Footer() { Terms of Service -
    • - - HIPAA Notice - -
    diff --git a/tests/pages.smoke.test.tsx b/tests/pages.smoke.test.tsx index 4b0dcd2..af4bd28 100644 --- a/tests/pages.smoke.test.tsx +++ b/tests/pages.smoke.test.tsx @@ -5,8 +5,8 @@ * 1. Non-trivial markup is emitted (length > 100, contains '<') * 2. For regulated / safety-critical pages, specific content tokens are * present in the output. A regression that wipes the 988 string from - * /crisis or the HIPAA non-applicability statement from /hipaa would - * pass step 1 but fail step 2. + * /crisis or the FTC Health Breach Notification commitment from the + * privacy policy would pass step 1 but fail step 2. * * The (standalone) splash is covered separately in tests/standalone.test.tsx * because its 'use client' + stateful hooks don't survive renderToString. @@ -30,7 +30,6 @@ const pages: readonly Page[] = [ { name: 'terms', load: () => import('@/app/(main)/terms/page'), requires: ['Terms'] }, { name: 'cookies', load: () => import('@/app/(main)/cookies/page'), requires: ['Cookie'] }, { name: 'crisis', load: () => import('@/app/crisis/page'), requires: ['988', 'Crisis Lifeline'] }, - { name: 'hipaa', load: () => import('@/app/hipaa/page'), requires: ['NOT a HIPAA Covered Entity'] }, { name: 'california-privacy', load: () => import('@/app/privacy/california/page'), requires: ['CCPA'] }, { name: 'privacy-practices', load: () => import('@/app/privacy-practices/page'), requires: [] }, { name: 'accessibility', load: () => import('@/app/accessibility/page'), requires: ['WCAG'] }, From 0b37284e1b51f050b693734243bb50c4f9b6c20a Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sun, 24 May 2026 12:19:31 -0700 Subject: [PATCH 25/33] fix(legal): remove /privacy-practices ghost page (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /privacy-practices route was reachable by direct URL (not in footer) and used "Notice of Privacy Practices" as the page title — a term of art defined in 45 CFR §164.520 (HIPAA Privacy Rule). Using that title while disclaiming HIPAA applicability creates FTC §5 exposure via the same legal-exposure pattern as /hipaa. The substantive content was already redundant with the privacy policy. Per regulatory-applicability.md: "Do not claim HIPAA compliance in code comments, documentation, or user-facing materials." The page title violated this regardless of disclaimer text. Companion PRs in mp2ez/being delete the source markdown (docs/legal/notice-of-privacy-practices.md) and update the regulatory-applicability.md Related Documents table. Co-authored-by: Claude Opus 4.7 (1M context) --- app/privacy-practices/page.tsx | 13 ------------- tests/pages.smoke.test.tsx | 1 - 2 files changed, 14 deletions(-) delete mode 100644 app/privacy-practices/page.tsx diff --git a/app/privacy-practices/page.tsx b/app/privacy-practices/page.tsx deleted file mode 100644 index 0af1cda..0000000 --- a/app/privacy-practices/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/notice-of-privacy-practices.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Notice of Privacy Practices | Being', - description: 'How Being handles your mental health and wellness information.', -}; - -export default function PrivacyPracticesPage() { - return ; -} diff --git a/tests/pages.smoke.test.tsx b/tests/pages.smoke.test.tsx index af4bd28..dfddb3b 100644 --- a/tests/pages.smoke.test.tsx +++ b/tests/pages.smoke.test.tsx @@ -31,7 +31,6 @@ const pages: readonly Page[] = [ { name: 'cookies', load: () => import('@/app/(main)/cookies/page'), requires: ['Cookie'] }, { name: 'crisis', load: () => import('@/app/crisis/page'), requires: ['988', 'Crisis Lifeline'] }, { name: 'california-privacy', load: () => import('@/app/privacy/california/page'), requires: ['CCPA'] }, - { name: 'privacy-practices', load: () => import('@/app/privacy-practices/page'), requires: [] }, { name: 'accessibility', load: () => import('@/app/accessibility/page'), requires: ['WCAG'] }, { name: 'disclaimers', load: () => import('@/app/disclaimers/page'), requires: ['medical'] }, { name: 'do-not-sell', load: () => import('@/app/do-not-sell/page'), requires: ['Do Not Sell'] }, From 910affb5d599bc82d7c58513914c0efefd9b3fad Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sun, 24 May 2026 12:19:34 -0700 Subject: [PATCH 26/33] fix(legal): remove /do-not-sell ghost page (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /do-not-sell route was reachable by direct URL (not in footer) and duplicated content that now lives in the privacy policy. CCPA §1798.135(b)(1) allows businesses that do not sell or share personal information to publish a statement to that effect in their privacy policy in lieu of maintaining a separate opt-out link. Being genuinely does not sell — folding the disclosure into the privacy policy is compliant and reduces page sprawl. The new "No Sale or Sharing of Personal Information" section in the privacy policy (companion PRs in mp2ez/being) preserves the load- bearing GPC honoring statement that this page contained — needed for TDPSA and CPA universal-opt-out compliance. Co-authored-by: Claude Opus 4.7 (1M context) --- app/do-not-sell/page.tsx | 13 ------------- tests/pages.smoke.test.tsx | 1 - 2 files changed, 14 deletions(-) delete mode 100644 app/do-not-sell/page.tsx diff --git a/app/do-not-sell/page.tsx b/app/do-not-sell/page.tsx deleted file mode 100644 index c9287d9..0000000 --- a/app/do-not-sell/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/do-not-sell.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Do Not Sell My Personal Information | Being', - description: 'Exercise your right to opt out of the sale or sharing of your personal information.', -}; - -export default function DoNotSellPage() { - return ; -} diff --git a/tests/pages.smoke.test.tsx b/tests/pages.smoke.test.tsx index dfddb3b..053ced9 100644 --- a/tests/pages.smoke.test.tsx +++ b/tests/pages.smoke.test.tsx @@ -33,7 +33,6 @@ const pages: readonly Page[] = [ { name: 'california-privacy', load: () => import('@/app/privacy/california/page'), requires: ['CCPA'] }, { name: 'accessibility', load: () => import('@/app/accessibility/page'), requires: ['WCAG'] }, { name: 'disclaimers', load: () => import('@/app/disclaimers/page'), requires: ['medical'] }, - { name: 'do-not-sell', load: () => import('@/app/do-not-sell/page'), requires: ['Do Not Sell'] }, { name: 'support', load: () => import('@/app/support/page'), requires: [] }, ]; From 2d6ca6edafd3fde680ee1f8930d4924ba760501a Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sun, 24 May 2026 14:38:16 -0700 Subject: [PATCH 27/33] fix(legal): remove unlinked /disclaimers route (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /disclaimers route was deployed but not linked from anywhere in the website (footer link was removed earlier; TOS Section 3 link to the singular /disclaimer was repaired by deleting the link entirely, since TOS Section 3 carries the load-bearing medical-disclaimer language already). The medical disclaimer remains accessible via: - TOS Section 3 (https://being.fyi/terms) - The in-app Legal Documents screen (mobile app bundles docs/legal/medical-disclaimer.md via Metro) Removes the route file only. The content file docs/legal/medical-disclaimer.md stays — the React Native app still requires it via legalDocuments.ts. Co-authored-by: Claude Opus 4.7 (1M context) --- app/disclaimers/page.tsx | 13 ------------- tests/pages.smoke.test.tsx | 1 - 2 files changed, 14 deletions(-) delete mode 100644 app/disclaimers/page.tsx diff --git a/app/disclaimers/page.tsx b/app/disclaimers/page.tsx deleted file mode 100644 index b4b146b..0000000 --- a/app/disclaimers/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/medical-disclaimer.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Medical Disclaimer | Being', - description: 'Important medical and wellness disclaimers for the Being app.', -}; - -export default function DisclaimersPage() { - return ; -} diff --git a/tests/pages.smoke.test.tsx b/tests/pages.smoke.test.tsx index 053ced9..d96dce9 100644 --- a/tests/pages.smoke.test.tsx +++ b/tests/pages.smoke.test.tsx @@ -32,7 +32,6 @@ const pages: readonly Page[] = [ { name: 'crisis', load: () => import('@/app/crisis/page'), requires: ['988', 'Crisis Lifeline'] }, { name: 'california-privacy', load: () => import('@/app/privacy/california/page'), requires: ['CCPA'] }, { name: 'accessibility', load: () => import('@/app/accessibility/page'), requires: ['WCAG'] }, - { name: 'disclaimers', load: () => import('@/app/disclaimers/page'), requires: ['medical'] }, { name: 'support', load: () => import('@/app/support/page'), requires: [] }, ]; From 8addbcb15302cafbf5943e044832e8c6d208b346 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Mon, 25 May 2026 03:11:03 -0700 Subject: [PATCH 28/33] feat(legal): add /privacy/multi-state route and footer links (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the website-side companion to INFRA-151. Adds the /privacy/multi-state route (CCPA + TDPSA + CPA + CTDPA + VCDPA survey) that the rewritten §5.3 of privacy-policy.md now promises, and surfaces both California Privacy Rights and Multi-State Privacy Rights from the footer Legal column to satisfy FTC §5 reasonable-accessibility for the newly-promised disclosures. Co-authored-by: Claude Opus 4.7 (1M context) --- app/privacy/multi-state/page.tsx | 14 ++++++++++++++ components/navigation/Footer.tsx | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 app/privacy/multi-state/page.tsx diff --git a/app/privacy/multi-state/page.tsx b/app/privacy/multi-state/page.tsx new file mode 100644 index 0000000..0990ffe --- /dev/null +++ b/app/privacy/multi-state/page.tsx @@ -0,0 +1,14 @@ +import { LegalPage } from '@/components/legal/LegalPage'; +import content from '@/content/legal/multi-state-privacy.md'; + +export const dynamic = 'force-static'; + +export const metadata = { + title: 'Multi-State Privacy Rights | Being', + description: + 'Your privacy rights under Texas, Colorado, Connecticut, and Virginia state privacy laws, with a California summary.', +}; + +export default function MultiStatePrivacyPage() { + return ; +} diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index f35ff13..313f499 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -37,6 +37,16 @@ export default function Footer() { Terms of Service +
  • + + California Privacy Rights + +
  • +
  • + + Multi-State Privacy Rights + +
  • From 487ecc31e700ea2cfafa0f8bf0707b419832262a Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Mon, 25 May 2026 14:36:00 -0700 Subject: [PATCH 29/33] feat(privacy): add Sec-GPC server-side detection + cookie policy update (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the website-side technical half of INFRA-151. Middleware detects Sec-GPC: 1, sets being_gpc cookie + X-GPC-Honored response header, and clears the cookie when the header is absent (per-request semantics, not persisted preferences). Client-side GpcNotice component renders an acknowledgement on /privacy, /privacy/multi-state, /privacy/california, /cookies, and /support — matching the scope of the public commitment in privacy-policy.md §5.3. Discovery confirmed the site ships zero third-party trackers, so the "suppress trackers on GPC=1" half of the original spec collapses; the implementation is detect-and-acknowledge, not detect-and-gate. /cookies page rewritten to honestly disclose the two first-party functional cookies (being_ab_variant from INFRA-93, being_gpc from this change) — the prior "we don't use cookies" claim was inaccurate even before this change. Vary: Sec-GPC declared in next.config.ts headers() as intent, but Next.js's RSC layer overwrites it in both dev and prod. Filed as a follow-up requiring OpenNext Worker middleware to append after the framework writes. Co-authored-by: Claude Opus 4.7 (1M context) --- app/(main)/cookies/page.tsx | 131 +++++++++++++++++++------------ app/(main)/privacy/page.tsx | 3 +- app/privacy/california/page.tsx | 3 +- app/privacy/multi-state/page.tsx | 3 +- app/support/page.tsx | 3 +- components/legal/GpcNotice.tsx | 62 +++++++++++++++ components/legal/LegalPage.tsx | 8 +- lib/gpc.test.ts | 100 +++++++++++++++++++++++ lib/gpc.ts | 45 +++++++++++ middleware.test.ts | 108 +++++++++++++++++++++---- middleware.ts | 47 ++++++++--- next.config.ts | 6 ++ 12 files changed, 437 insertions(+), 82 deletions(-) create mode 100644 components/legal/GpcNotice.tsx create mode 100644 lib/gpc.test.ts create mode 100644 lib/gpc.ts diff --git a/app/(main)/cookies/page.tsx b/app/(main)/cookies/page.tsx index d5e48d2..bea2864 100644 --- a/app/(main)/cookies/page.tsx +++ b/app/(main)/cookies/page.tsx @@ -1,98 +1,129 @@ /** * Cookie Policy Page - * Being's minimal cookie usage (we don't use cookies!) + * First-party functional cookies only; no third-party trackers. */ +import { GpcNotice } from '@/components/legal/GpcNotice'; + +export const metadata = { + title: 'Cookie Policy | Being', + description: 'How Being uses first-party functional cookies and honors Global Privacy Control signals.', +}; + export default function CookiesPage() { return (
    +

    Cookie Policy

    - Last Updated: May 23, 2026 + Last Updated: May 25, 2026

    - {/* The Good News */} + {/* Summary */}

    - Good News: We Don't Use Cookies + First-Party Functional Cookies Only

    - Being's website (www.being.fyi) does not use cookies, tracking - pixels, or similar technologies. We don't track you across the web, and we don't - use third-party advertising or analytics cookies. + Being’s website (www.being.fyi) uses two small first-party + cookies for site functionality. We don’t use third-party advertising or analytics + cookies, tracking pixels, or cross-site trackers.

    - This page exists to clarify what minimal data collection occurs and why. + We honor the Global Privacy Control (Sec-GPC) signal automatically — no + banner, no preference center.

    - {/* What Are Cookies? */} + {/* The Two Cookies */}

    - What Are Cookies? + Cookies We Use

    -

    - Cookies are small text files stored on your device by websites you visit. They're - commonly used to: -

    + +

    + being_ab_variant +

    +
      +
    • Purpose: Assigns you to an A or B variant so we can measure which version of the site converts better.
    • +
    • Type: First-party functional.
    • +
    • Value: Either A or B — no identifier, no behavioral data.
    • +
    • Lifetime: 30 days.
    • +
    • Shared with third parties: No. The variant label is stored alongside waitlist signups in our internal Notion database for conversion analysis; we don’t share it with advertising or analytics vendors.
    • +
    + +

    + being_gpc +

      -
    • - - Remember login information -
    • -
    • - - Track user behavior across websites -
    • -
    • - - Serve targeted advertising -
    • -
    • - - Analyze website traffic and usage -
    • +
    • Purpose: Caches the Global Privacy Control signal so the page can display an acknowledgement.
    • +
    • Type: First-party functional.
    • +
    • Value: 1 when your browser sends Sec-GPC: 1; otherwise the cookie is cleared.
    • +
    • Lifetime: 24 hours; automatically cleared on any subsequent request that doesn’t carry the header.
    • +
    • Shared with third parties: No.
    -

    - Being does none of these things on our website. -

    {/* What We Don't Use */}

    - What We Don't Use + What We Don’t Use

    • - No Advertising Cookies: We don't serve ads or use ad networks + No Advertising Cookies: We don’t serve ads or use ad networks.
    • - No Analytics Cookies: No Google Analytics, Facebook Pixel, or similar tracking + No Analytics Cookies: No Google Analytics, PostHog, Mixpanel, or similar.
    • - No Social Media Cookies: No social media widgets or share buttons with tracking + No Social Media Cookies: No social media widgets or share buttons with tracking.
    • - No Third-Party Cookies: No external services that set cookies + No Third-Party Cookies: No external services set cookies on our pages.
    • - No Session Cookies: Our website doesn't require login, so no session management + No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar.
    + {/* Global Privacy Control */} +
    +

    + Global Privacy Control +

    +

    + If your browser sends the Sec-GPC: 1 request header (Brave, DuckDuckGo, + Firefox with an extension, etc.), we treat it as an opt-out of any sale or sharing of + personal information under CCPA, TDPSA, CPA, and CTDPA. +

    +

    + In practice, because we don’t use third-party analytics or advertising trackers, + there is no sale or sharing to opt out of — but the signal is recorded and acknowledged + with a visible notice on our privacy pages, and an X-GPC-Honored: 1 response + header confirms detection to anyone inspecting the request. +

    +

    + See the{' '} + + Multi-State Privacy Rights + {' '} + page for the underlying state law obligations. +

    +
    + {/* Mobile App */}

    @@ -136,7 +167,7 @@ export default function CookiesPage() {
    • - IP Address: To prevent abuse and ensure security (automatically deleted after 30 days) + IP Address: To prevent abuse and ensure security
    • @@ -152,8 +183,8 @@ export default function CookiesPage() {

    - This data is not used for tracking, profiling, or advertising. It's retained for - security purposes and automatically deleted after 30 days. + This data is not used for tracking, profiling, or advertising. It’s retained + for a limited period for security purposes only.

    @@ -163,11 +194,13 @@ export default function CookiesPage() { Your Choices

    - Since we don't use cookies, you don't need to accept a cookie banner or manage - cookie preferences. + You can clear either cookie at any time via your browser’s site-data settings. + Clearing being_ab_variant just reassigns a variant on your next visit; + clearing being_gpc is harmless because the cookie reflects the header, + not a stored preference.

    - If you have concerns about server logs or mobile app data storage, please see our{' '} + For questions about server logs, mobile app data storage, or anything else, see our{' '} Privacy Policy {' '} @@ -178,15 +211,15 @@ export default function CookiesPage() {

    - {/* Changes to This Policy */} + {/* Changes */}

    Changes to This Policy

    - If we ever decide to use cookies in the future (unlikely), we will update this policy - and notify you prominently on our website. We are committed to transparency and will - always respect your privacy choices. + If we add or change cookies in the future, we’ll update this page and bump the + Last Updated date above. We’re committed to transparency and to honoring your + privacy choices by default.

    diff --git a/app/(main)/privacy/page.tsx b/app/(main)/privacy/page.tsx index 61bde1b..2145975 100644 --- a/app/(main)/privacy/page.tsx +++ b/app/(main)/privacy/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/privacy-policy.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function PrivacyPolicyPage() { - return ; + return } />; } diff --git a/app/privacy/california/page.tsx b/app/privacy/california/page.tsx index f67e8aa..556a9b2 100644 --- a/app/privacy/california/page.tsx +++ b/app/privacy/california/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/california-privacy.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function CaliforniaPrivacyPage() { - return ; + return } />; } diff --git a/app/privacy/multi-state/page.tsx b/app/privacy/multi-state/page.tsx index 0990ffe..008b19b 100644 --- a/app/privacy/multi-state/page.tsx +++ b/app/privacy/multi-state/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/multi-state-privacy.md'; export const dynamic = 'force-static'; @@ -10,5 +11,5 @@ export const metadata = { }; export default function MultiStatePrivacyPage() { - return ; + return } />; } diff --git a/app/support/page.tsx b/app/support/page.tsx index ccfdeba..57a6ae1 100644 --- a/app/support/page.tsx +++ b/app/support/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/support.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function SupportPage() { - return ; + return } />; } diff --git a/components/legal/GpcNotice.tsx b/components/legal/GpcNotice.tsx new file mode 100644 index 0000000..b08b729 --- /dev/null +++ b/components/legal/GpcNotice.tsx @@ -0,0 +1,62 @@ +'use client'; + +/** + * Global Privacy Control acknowledgement notice. + * + * Renders when either signal is detected client-side: + * - `being_gpc=1` cookie set by middleware (set when Sec-GPC: 1 is received) + * - `navigator.globalPrivacyControl === true` (JS API exposed by some browsers) + * + * Either channel alone triggers display — different browsers/extensions expose + * different combinations. + * + * Uses useSyncExternalStore so the SSR snapshot is `false` (no flash) and the + * client snapshot is computed after hydration. + * + * @see INFRA-151 + */ + +import { useSyncExternalStore } from 'react'; + +function subscribe(): () => void { + return () => {}; +} + +function getClientSnapshot(): boolean { + if (typeof document !== 'undefined') { + const hasCookie = document.cookie.split(';').some((c) => c.trim() === 'being_gpc=1'); + if (hasCookie) return true; + } + if (typeof navigator !== 'undefined') { + return (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true; + } + return false; +} + +function getServerSnapshot(): boolean { + return false; +} + +export function GpcNotice() { + const show = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (!show) return null; + + return ( + + ); +} diff --git a/components/legal/LegalPage.tsx b/components/legal/LegalPage.tsx index b89dc73..c401e8a 100644 --- a/components/legal/LegalPage.tsx +++ b/components/legal/LegalPage.tsx @@ -1,19 +1,25 @@ import { marked } from 'marked'; +import type { ReactNode } from 'react'; interface LegalPageProps { content: string; + banner?: ReactNode; } /** * Renders a legal document from markdown with consistent styling. * Uses design system tokens for colors and spacing. * Server component using marked for edge runtime compatibility. + * + * `banner` slot renders above the article — used for cross-cutting + * notices like GpcNotice that need the page's background container. */ -export function LegalPage({ content }: LegalPageProps) { +export function LegalPage({ content, banner }: LegalPageProps) { const html = marked(content); return (
    + {banner}
    ({ + cookies: vi.fn(), +})); + +describe('lib/gpc', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isGpcSignal', () => { + it("returns true for the literal '1'", async () => { + const { isGpcSignal } = await import('./gpc'); + expect(isGpcSignal('1')).toBe(true); + }); + + it.each([ + ['0', '0'], + ['empty', ''], + ['null', null], + ['undefined', undefined], + ['true', 'true'], + ['on', 'on'], + ['1, 1 (list form)', '1, 1'], + ['leading space', ' 1'], + ['trailing space', '1 '], + ['2', '2'], + ])('returns false for %s', async (_label, value) => { + const { isGpcSignal } = await import('./gpc'); + expect(isGpcSignal(value as string | null | undefined)).toBe(false); + }); + }); + + describe('getGpcFromRequest', () => { + function buildRequest(headerValue?: string): NextRequest { + const headers = new Headers(); + if (headerValue !== undefined) headers.set('sec-gpc', headerValue); + return new NextRequest('http://localhost/', { headers }); + } + + it("returns true when Sec-GPC: 1 is present", async () => { + const { getGpcFromRequest } = await import('./gpc'); + expect(getGpcFromRequest(buildRequest('1'))).toBe(true); + }); + + it('returns false when the header is absent', async () => { + const { getGpcFromRequest } = await import('./gpc'); + expect(getGpcFromRequest(buildRequest())).toBe(false); + }); + + it("returns false for Sec-GPC: 0", async () => { + const { getGpcFromRequest } = await import('./gpc'); + expect(getGpcFromRequest(buildRequest('0'))).toBe(false); + }); + + it('returns false for a malformed value', async () => { + const { getGpcFromRequest } = await import('./gpc'); + expect(getGpcFromRequest(buildRequest('true'))).toBe(false); + }); + }); + + describe('getGpcFromCookie', () => { + async function setCookieValue(value: string | undefined) { + const { cookies } = await import('next/headers'); + (cookies as ReturnType).mockResolvedValue({ + get: vi.fn().mockReturnValue(value === undefined ? undefined : { value }), + }); + } + + it("returns true when cookie value is '1'", async () => { + await setCookieValue('1'); + const { getGpcFromCookie } = await import('./gpc'); + expect(await getGpcFromCookie()).toBe(true); + }); + + it('returns false when the cookie is absent', async () => { + await setCookieValue(undefined); + const { getGpcFromCookie } = await import('./gpc'); + expect(await getGpcFromCookie()).toBe(false); + }); + + it("returns false when the cookie value is '0'", async () => { + await setCookieValue('0'); + const { getGpcFromCookie } = await import('./gpc'); + expect(await getGpcFromCookie()).toBe(false); + }); + + it('returns false for an unexpected cookie value', async () => { + await setCookieValue('true'); + const { getGpcFromCookie } = await import('./gpc'); + expect(await getGpcFromCookie()).toBe(false); + }); + }); +}); diff --git a/lib/gpc.ts b/lib/gpc.ts new file mode 100644 index 0000000..b0bda3a --- /dev/null +++ b/lib/gpc.ts @@ -0,0 +1,45 @@ +/** + * Global Privacy Control (GPC) detection. + * + * Reads the `Sec-GPC` request header per the GPC spec + * (https://globalprivacycontrol.org/) and exposes helpers for middleware + * (request-time) and server components (cookie-time). + * + * Only the literal string `'1'` is the affirmative signal; everything else + * — `'0'`, `''`, malformed, absent — is treated as no signal. + * + * @see INFRA-151 + */ + +import { cookies } from 'next/headers'; +import { NextRequest } from 'next/server'; + +export const GPC_COOKIE_NAME = 'being_gpc'; +export const GPC_COOKIE_MAX_AGE = 24 * 60 * 60; // 24 hours in seconds +export const GPC_REQUEST_HEADER = 'sec-gpc'; +export const GPC_RESPONSE_HEADER = 'X-GPC-Honored'; + +/** + * Returns true when the header value is the affirmative GPC signal. + * Per spec, only the literal string `'1'` qualifies. + */ +export function isGpcSignal(headerValue: string | null | undefined): boolean { + return headerValue === '1'; +} + +/** + * Reads the GPC signal from a NextRequest (for use in middleware). + */ +export function getGpcFromRequest(request: NextRequest): boolean { + return isGpcSignal(request.headers.get(GPC_REQUEST_HEADER)); +} + +/** + * Reads the cached GPC signal from cookies (for use in Server Components). + * The cookie is set/cleared by middleware on every request, so this reflects + * the current request's signal as of the most recent middleware pass. + */ +export async function getGpcFromCookie(): Promise { + const cookieStore = await cookies(); + return cookieStore.get(GPC_COOKIE_NAME)?.value === '1'; +} diff --git a/middleware.test.ts b/middleware.test.ts index 27df938..2557e45 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -1,14 +1,11 @@ /** * Middleware unit tests. * - * Tests the composition: "on no existing variant, assign + set cookie with - * the right attributes; on existing variant, do nothing." Underlying - * lib/ab-testing.ts functions are mocked — they have their own tests in - * lib/ab-testing.test.ts. - * - * The cookie attributes (secure, sameSite, httpOnly, path, maxAge) are - * the regression surface: middleware.ts's only meaningful logic is the - * cookies.set() call. + * Two surfaces under test: + * 1. A/B variant cookie attributes (INFRA-93) — mocked lib/ab-testing + * 2. GPC detection (INFRA-151) — exercises lib/gpc directly via Sec-GPC + * request header. Tests the contract: Vary always set; X-GPC-Honored + + * being_gpc cookie when header is '1'; cookie cleared otherwise. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -34,7 +31,13 @@ async function importFresh(): Promise<{ return { middleware: mod.middleware, ab }; } -describe('middleware', () => { +function buildRequest(headers: Record = {}): NextRequest { + const h = new Headers(); + for (const [k, v] of Object.entries(headers)) h.set(k, v); + return new NextRequest('http://localhost/', { headers: h }); +} + +describe('middleware — A/B variant', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -44,8 +47,7 @@ describe('middleware', () => { (ab.getVariantFromRequest as ReturnType).mockReturnValue(null); (ab.assignVariant as ReturnType).mockReturnValue('A'); - const request = new NextRequest('http://localhost/'); - const response = middleware(request); + const response = middleware(buildRequest()); const cookie = response.cookies.get('being_ab_variant'); expect(cookie?.value).toBe('A'); @@ -61,8 +63,7 @@ describe('middleware', () => { (ab.getVariantFromRequest as ReturnType).mockReturnValue(null); (ab.assignVariant as ReturnType).mockReturnValue('B'); - const request = new NextRequest('http://localhost/'); - const response = middleware(request); + const response = middleware(buildRequest()); expect(response.cookies.get('being_ab_variant')?.value).toBe('B'); }); @@ -71,10 +72,87 @@ describe('middleware', () => { const { middleware, ab } = await importFresh(); (ab.getVariantFromRequest as ReturnType).mockReturnValue('B'); - const request = new NextRequest('http://localhost/'); - const response = middleware(request); + const response = middleware(buildRequest()); expect(ab.assignVariant).not.toHaveBeenCalled(); expect(response.cookies.get('being_ab_variant')).toBeUndefined(); }); }); + +describe('middleware — GPC detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Vary: Sec-GPC is set in next.config.ts headers() rather than middleware + // because the RSC layer overwrites Vary headers set in middleware. See the + // next.config.ts securityHeaders block. + + it('sets X-GPC-Honored and being_gpc cookie when Sec-GPC: 1', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest({ 'sec-gpc': '1' })); + + expect(response.headers.get('X-GPC-Honored')).toBe('1'); + const cookie = response.cookies.get('being_gpc'); + expect(cookie?.value).toBe('1'); + expect(cookie?.maxAge).toBe(24 * 60 * 60); + expect(cookie?.path).toBe('/'); + expect(cookie?.sameSite).toBe('lax'); + expect(cookie?.secure).toBe(true); + expect(cookie?.httpOnly).toBe(false); + }); + + it('does not set X-GPC-Honored when the header is absent', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest()); + + expect(response.headers.get('X-GPC-Honored')).toBeNull(); + }); + + it('treats Sec-GPC: 0 as no signal (per spec, only "1" is affirmative)', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest({ 'sec-gpc': '0' })); + + expect(response.headers.get('X-GPC-Honored')).toBeNull(); + }); + + it('treats a malformed value as no signal', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest({ 'sec-gpc': 'true' })); + + expect(response.headers.get('X-GPC-Honored')).toBeNull(); + }); + + it('clears the being_gpc cookie when the header is absent', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest()); + + // NextResponse.cookies.delete() emits a Set-Cookie with empty value and + // an expired date (the exact attrs are framework-internal — we only + // assert the deletion signal: an empty-value cookie was emitted). + const cookie = response.cookies.get('being_gpc'); + expect(cookie?.value).toBe(''); + }); + + it('coexists with A/B variant assignment (both signals set on same response)', async () => { + const { middleware, ab } = await importFresh(); + (ab.getVariantFromRequest as ReturnType).mockReturnValue(null); + (ab.assignVariant as ReturnType).mockReturnValue('A'); + + const response = middleware(buildRequest({ 'sec-gpc': '1' })); + + expect(response.cookies.get('being_ab_variant')?.value).toBe('A'); + expect(response.cookies.get('being_gpc')?.value).toBe('1'); + expect(response.headers.get('X-GPC-Honored')).toBe('1'); + }); +}); diff --git a/middleware.ts b/middleware.ts index 6cfa236..be0443e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,10 +1,15 @@ /** - * Next.js Middleware - A/B Testing Variant Assignment + * Next.js Middleware * - * Assigns visitors to A/B test variants on first visit. - * Cookie persisted for 30 days for consistent experience. + * Two responsibilities: + * 1. A/B testing variant assignment — INFRA-93 + * 2. Global Privacy Control detection — INFRA-151 + * (https://globalprivacycontrol.org/) * - * @see INFRA-93 + * GPC is treated as authoritative per-request: the `Sec-GPC: 1` header sets + * the cookie + `X-GPC-Honored` response header; its absence clears the cookie. + * `Vary: Sec-GPC` is set in next.config.ts headers() — the RSC layer + * overwrites Vary headers set here, so the cache-key directive lives there. */ import { NextResponse } from 'next/server'; @@ -15,18 +20,20 @@ import { assignVariant, getVariantFromRequest, } from '@/lib/ab-testing'; +import { + GPC_COOKIE_NAME, + GPC_COOKIE_MAX_AGE, + GPC_RESPONSE_HEADER, + getGpcFromRequest, +} from '@/lib/gpc'; export function middleware(request: NextRequest) { const response = NextResponse.next(); - // Check if variant already assigned + // A/B variant assignment (first visit only) const existingVariant = getVariantFromRequest(request); - if (!existingVariant) { - // Assign new variant const variant = assignVariant(); - - // Set cookie with 30-day expiry response.cookies.set({ name: AB_COOKIE_NAME, value: variant, @@ -36,18 +43,32 @@ export function middleware(request: NextRequest) { secure: true, httpOnly: false, // Accessible to client JS for tracking }); - - // Log in development if (process.env.NODE_ENV === 'development') { console.log(`[A/B] New visitor assigned variant: ${variant}`); } } + // GPC detection (every request — header is authoritative) + if (getGpcFromRequest(request)) { + response.cookies.set({ + name: GPC_COOKIE_NAME, + value: '1', + maxAge: GPC_COOKIE_MAX_AGE, + path: '/', + sameSite: 'lax', + secure: true, + httpOnly: false, // Client reads it to render the GpcNotice + }); + response.headers.set(GPC_RESPONSE_HEADER, '1'); + } else { + response.cookies.delete(GPC_COOKIE_NAME); + } + return response; } -// Only run on pages that might have A/B variants -// Excludes API routes, static files, and internal Next.js routes +// Only run on pages that might have A/B variants or need GPC detection. +// Excludes API routes, static files, and internal Next.js routes. export const config = { matcher: [ /* diff --git a/next.config.ts b/next.config.ts index 1e2ab86..2b4fca3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -26,6 +26,12 @@ const securityHeaders = [ "form-action 'self'", ].join("; "), }, + // GPC cache-key safety (INFRA-151) — declared here but overridden by + // Next.js's RSC layer (Vary becomes `rsc, next-router-*, Accept-Encoding`). + // Kept as intent declaration; relying on Set-Cookie + Cache-Control to + // keep GPC-bearing responses out of shared caches. A proper fix requires + // an OpenNext Worker middleware to append Vary after Next.js writes it. + { key: "Vary", value: "Sec-GPC" }, ]; const nextConfig: NextConfig = { From bd5c6482be520084eb2ee6e25d37bf99a4ae14b2 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Mon, 25 May 2026 14:40:08 -0700 Subject: [PATCH 30/33] fix(perf): cache parsed markdown per Worker isolate to avoid Error 1102 (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegalPage was calling marked() inside the component body on every render. OpenNext on Cloudflare Workers re-executes server components per request even for force-static pages, so the markdown parse ran on every request to /privacy, /terms, /support, /privacy/california, /privacy/multi-state, and /cookies — adding ~50ms (P50) to ~500ms (P99) of Worker CPU per page load. Workers analytics for being-website-preview showed 6 exceededResources (Error 1102) failures in a 2-hour window post-INFRA-151 — the privacy-policy.md §5.3 rewrite added ~15 lines, which pushed borderline requests over the per-isolate CPU budget. User-reported failure had Ray ID a017a0ac992cd43b. Fix: memoize marked() output in a module-scope Map keyed on the content string. Content imports are module-level (identity-stable), so the cache is bounded by the number of legal docs (currently 6). First request on a fresh isolate parses; all subsequent requests on the same isolate hit the cache (<1ms). Co-authored-by: Claude Opus 4.7 (1M context) --- components/legal/LegalPage.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/components/legal/LegalPage.tsx b/components/legal/LegalPage.tsx index c401e8a..ede7ca2 100644 --- a/components/legal/LegalPage.tsx +++ b/components/legal/LegalPage.tsx @@ -6,6 +6,27 @@ interface LegalPageProps { banner?: ReactNode; } +// Per-isolate cache for parsed markdown. Content strings are module-level +// imports — identity-stable across all requests — so a Map keyed on the +// string instance is a safe cache. First request on a fresh Worker isolate +// pays the parse cost (~50ms for the larger docs); subsequent requests on +// the same isolate hit the cache (<1ms). +// +// Why this exists: OpenNext on Cloudflare Workers re-executes server +// components per request even for `force-static` pages, so without this +// cache `marked()` runs on every request. That pushed CPU P99 to ~500ms +// and caused intermittent Worker 1102 (exceededResources) errors. +const htmlCache = new Map(); + +function renderMarkdown(content: string): string { + let html = htmlCache.get(content); + if (html === undefined) { + html = marked(content) as string; + htmlCache.set(content, html); + } + return html; +} + /** * Renders a legal document from markdown with consistent styling. * Uses design system tokens for colors and spacing. @@ -15,7 +36,7 @@ interface LegalPageProps { * notices like GpcNotice that need the page's background container. */ export function LegalPage({ content, banner }: LegalPageProps) { - const html = marked(content); + const html = renderMarkdown(content); return (
    From c742e16f056263b7beef8379272dd75042c02502 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Mon, 25 May 2026 15:19:54 -0700 Subject: [PATCH 31/33] fix(theme): register brand tokens in @theme + migrate v3 opacity syntax (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Tailwind v4 migration gaps that combined to break the footer on the deployed preview. # Problem 1: brand-* / crisis-* / accent-* utilities weren't generated Tailwind v4 only generates utility classes for color tokens declared inside @theme. The design system package exposes its tokens as :root variables, which makes them usable via raw var(--color-X) but does not trigger Tailwind utility generation. The local @theme inline block had aliases for sage-*, status-*, and crisis-bg, but was missing: - brand-midnight, brand-sage, brand-blue-gray, brand-off-white - crisis-text, crisis-border - accent-50/100/500/600/700 Result: every className like `bg-brand-midnight`, `text-brand-sage`, `border-crisis-border` silently became a no-op. Most visibly: the footer's `bg-brand-midnight` did nothing, so the footer rendered with no background — and all its dark-bg-styled text colors (text-white, text-gray-300/400) became invisible or barely visible on the page's white background. Also broken (now fixed by the same change): the "Download Being" CTA button on /home (uses bg-brand-sage), the sage-green section links on /home, the GpcNotice's sage-green border + midnight strong text, and every text-brand-midnight heading on /crisis. The brand-blue-gray and brand-off-white aliases bridge a casing gap: the design system stores those as camelCase variables (--color-brand-blueGray, --color-brand-offWhite) but the codebase uses kebab-case utility classes. # Problem 2: bg-opacity-* / border-opacity-* are Tailwind v3 syntax Tailwind v4 removed these in favor of slash syntax (bg-color/N, border-color/N) that keeps the color and alpha coupled. The old classes silently produce no CSS in v4. Affected: - Footer.tsx: `bg-crisis-bg bg-opacity-10` → `bg-crisis-bg/10` - Footer.tsx: `border-crisis-border border-opacity-20` → `border-crisis-border/20` - DesktopNav.tsx: `hover:bg-opacity-80` → `hover:bg-crisis-bg/80` - QRCodePlaceholder.tsx: `bg-opacity-90` → `bg-brand-midnight/90` # Verification - CSS bundle before: 35115 bytes, zero `.bg-brand-*` rules - CSS bundle after: 37983 bytes, includes `.bg-brand-midnight`, `.bg-brand-sage`, `.text-brand-midnight`, `.bg-accent-*`, etc. - Computed styles on /home footer after build: background-color rgb(27, 41, 81) = #1B2951 (brand-midnight) — confirmed via Chrome DevTools against `npm run start`. - Crisis disclaimer box now renders as 10% / 20% translucent overlay on the dark footer (was 100% pale-blue floating on white before). Co-authored-by: Claude Opus 4.7 (1M context) --- app/globals.css | 25 +++++++++++++++++++++++-- components/navigation/DesktopNav.tsx | 2 +- components/navigation/Footer.tsx | 2 +- components/shared/QRCodePlaceholder.tsx | 2 +- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/globals.css b/app/globals.css index 21f5b13..ce3fb6a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -14,7 +14,26 @@ @theme inline { /* === ALIASES === */ - /* Map Tailwind conventions to design system naming */ + /* Map Tailwind conventions to design system naming. + * + * Tailwind v4 only generates utility classes for tokens registered in this + * @theme block. Tokens that only live in :root (from the design system) are + * usable via raw var(...) but won't produce bg-* / text-* / border-* utilities. + * Every design-system token used as a utility class must be re-declared here. + */ + + /* Brand colors — design system uses camelCase internals, Tailwind needs kebab */ + --color-brand-midnight: var(--color-brand-midnight); + --color-brand-sage: var(--color-brand-sage); + --color-brand-blue-gray: var(--color-brand-blueGray); + --color-brand-off-white: var(--color-brand-offWhite); + + /* Accent scale (used directly + via sage-* aliases below) */ + --color-accent-50: var(--color-accent-50); + --color-accent-100: var(--color-accent-100); + --color-accent-500: var(--color-accent-500); + --color-accent-600: var(--color-accent-600); + --color-accent-700: var(--color-accent-700); /* Sage → Accent (backward compat for existing code) */ --color-sage-50: var(--color-accent-50); @@ -37,8 +56,10 @@ --color-info-bg: var(--color-status-infoBackground); --color-critical-bg: var(--color-status-criticalBackground); - /* Crisis shorthand */ + /* Crisis colors (shorthand for design system tokens) */ --color-crisis-bg: var(--color-crisis-background); + --color-crisis-text: var(--color-crisis-text); + --color-crisis-border: var(--color-crisis-border); /* Semantic */ --color-background: var(--background); diff --git a/components/navigation/DesktopNav.tsx b/components/navigation/DesktopNav.tsx index ec21b07..a618bd5 100644 --- a/components/navigation/DesktopNav.tsx +++ b/components/navigation/DesktopNav.tsx @@ -36,7 +36,7 @@ export default function DesktopNav() { {/* 988 Crisis Button (calm styling, not alarm) */} 988 Crisis Support diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index 313f499..0b44147 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -12,7 +12,7 @@ export default function Footer() {