From f412c7fb9c4f3a6e3d0e48a21569cf8924393df2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 20:37:20 -0700 Subject: [PATCH 1/7] feat(website): add the canonical privacy policy One /privacy route replaces the scattering of analytics promises: what is collected, why, which processors receive it, indefinite default retention, and how to unsubscribe or request deletion. It describes categories and purposes rather than an event catalog. A published catalog goes stale the first time an event changes, and a stale one is worse than a general one; the page also avoids installation claims and absolute guarantees it cannot continuously verify. Its spec fails if any of those grow back. Also lists /privacy in the sitemap inventory and links it from the footer bottom bar. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/privacy/page.spec.tsx | 99 +++++++++++++ apps/website/src/app/privacy/page.tsx | 130 ++++++++++++++++++ .../src/components/shared/Footer.spec.tsx | 10 ++ apps/website/src/components/shared/Footer.tsx | 9 +- apps/website/src/lib/site-metadata.ts | 2 +- apps/website/src/lib/sitemap-dates.spec.ts | 13 ++ apps/website/src/styles/chrome.css | 4 + apps/website/src/styles/pages.css | 39 ++++++ 8 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 apps/website/src/app/privacy/page.spec.tsx create mode 100644 apps/website/src/app/privacy/page.tsx diff --git a/apps/website/src/app/privacy/page.spec.tsx b/apps/website/src/app/privacy/page.spec.tsx new file mode 100644 index 000000000..7b80ea1af --- /dev/null +++ b/apps/website/src/app/privacy/page.spec.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('../../components/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../components/ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../components/ui/Eyebrow', () => ({ + Eyebrow: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +import PrivacyPage, { metadata } from './page'; + +/** + * One canonical policy replaces the previous scattering of analytics promises. + * These assertions pin what it must say and, just as importantly, what it must + * never grow back into: a per-event catalog, an installation-behavior claim, or + * an absolute guarantee that a future change could quietly falsify. + */ +describe('privacy policy metadata', () => { + it('declares its own canonical path', () => { + expect(metadata.alternates?.canonical).toBe('/privacy'); + }); + + it('carries a title and description', () => { + expect(String(metadata.title)).toMatch(/privacy/i); + expect(String(metadata.description ?? '')).not.toBe(''); + }); +}); + +describe('privacy policy content', () => { + const text = () => { + render(); + return document.body.textContent ?? ''; + }; + + it('names the information Threadplane collects', () => { + const body = text(); + for (const category of [ + /information you submit/i, + /website analytics/i, + /product analytics/i, + ]) { + expect(body).toMatch(category); + } + }); + + it('names every processor that receives data', () => { + const body = text(); + for (const processor of [ + 'Vercel', + 'Neon', + 'PostHog', + 'Resend', + 'Google', + 'Anthropic', + ]) { + expect(body).toContain(processor); + } + }); + + it('states indefinite default retention rather than a fixed window', () => { + expect(text()).toMatch(/indefinite/i); + }); + + it('explains deletion, email opt-out, and how to make contact', () => { + const body = text(); + expect(body).toMatch(/delet/i); + expect(body).toMatch(/unsubscribe|opt out|opt-out/i); + expect(body).toContain('brian@threadplane.ai'); + }); + + it('is reachable as a single heading-led document', () => { + render(); + expect( + screen.getByRole('heading', { level: 1, name: /privacy/i }) + ).toBeTruthy(); + }); + + it.each([ + ['an installation behavior claim', /install/i], + ['a never-collected list', /never collect|we will never|do not collect/i], + ['an absolute guarantee', /\bguarantee/i], + ['a per-event catalog', /event name|event catalog|property name/i], + ])('does not make %s', (_label, pattern) => { + expect(text()).not.toMatch(pattern); + }); +}); diff --git a/apps/website/src/app/privacy/page.tsx b/apps/website/src/app/privacy/page.tsx new file mode 100644 index 000000000..ec3a47af8 --- /dev/null +++ b/apps/website/src/app/privacy/page.tsx @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +import { Container } from '../../components/ui/Container'; +import { Section } from '../../components/ui/Section'; +import { Eyebrow } from '../../components/ui/Eyebrow'; +import { createPageMetadata } from '../../lib/site-metadata'; + +const CONTACT_EMAIL = 'brian@threadplane.ai'; + +export const metadata = createPageMetadata({ + title: 'Privacy — Threadplane', + description: + 'What Threadplane collects, why, who processes it, how long it is kept, and how to opt out or request deletion.', + pathname: '/privacy', + type: 'website', +}); + +/** + * The single canonical statement of what Threadplane collects and why. + * + * It deliberately describes categories and purposes rather than an event or + * property catalog. A catalog would be a maintenance trap: it goes stale the + * first time an event is added, and a stale published catalog is worse than a + * general one. For the same reason this page makes no claim about behavior it + * cannot continuously verify. + */ +export default function PrivacyPage() { + return ( +
+ +
+ Legal +

+ Privacy +

+

+ This page describes what Threadplane collects, why it is collected, + who processes it, and the choices available to you. It applies to + the Threadplane website and to the Threadplane software libraries + and services. +

+ +

Information you submit

+

+ When you request a guide, subscribe to updates, or contact us, we + receive what you enter in the form: your email address and, where + the form asks for them, your name, company, and message. Replies you + send to our email are received and read as ordinary correspondence. +

+ +

Website analytics

+

+ We record how pages are used — pages viewed, referring source, and + broad technical details such as browser and approximate region — to + understand what people find useful. This is used in aggregate. +

+ +

Product analytics

+

+ Threadplane software may report operational facts about how the + product is running so we can see which capabilities work in real + deployments. These reports describe activity, not content. We do not + want and do not seek the substance of what your application is + doing: prompts, messages, tool inputs and outputs, application + state, and source code are outside what this reporting is designed + to carry. +

+ +

How the information is used

+

+ To operate and support the product, to understand and improve how it + is used, to answer you when you get in touch, and — where you have + asked to hear from us — to send relevant email. +

+ +

Who processes it

+

+ Threadplane relies on a small number of service providers who + process data on our behalf: Vercel (hosting), Neon (database), + PostHog (analytics), Resend (email delivery), Google (business email + and calendar), and Anthropic (AI processing used to prepare internal + summaries). Each processes data under its own agreement with us and + for our purposes only. +

+ +

Retention

+

+ Information is retained indefinitely by default, so that account and + correspondence history stays intact. Where you ask us to delete + information, we do so as described below. +

+ +

Your choices

+

+ Every marketing email carries an unsubscribe link, and unsubscribing + stops further marketing email. Replying to ask us to stop has the + same effect. To request deletion of the information we hold about + you, or to ask what that is, email{' '} + {CONTACT_EMAIL} and we will + act on it. Some records are kept where we are required to keep them. +

+ +

Security

+

+ Data is held with the providers named above, protected in transit + and at rest by their standard controls, and access is limited to + those who need it to run the product. +

+ +

International processing

+

+ Our providers operate in the United States and elsewhere, so + information may be processed outside the country where you live. +

+ +

Changes

+

+ When this page changes materially, the change applies from the date + it is published here. +

+ +

Contact

+

+ Questions about this page or about the information we hold:{' '} + {CONTACT_EMAIL}. +

+
+
+
+ ); +} diff --git a/apps/website/src/components/shared/Footer.spec.tsx b/apps/website/src/components/shared/Footer.spec.tsx index ce0e0a105..c83e9eb85 100644 --- a/apps/website/src/components/shared/Footer.spec.tsx +++ b/apps/website/src/components/shared/Footer.spec.tsx @@ -127,3 +127,13 @@ describe('Footer newsletter growth policy', () => { expect(screen.queryByText(/subscribed/i)).toBeNull(); }); }); + +describe('Footer legal navigation', () => { + it('links the canonical privacy policy from the bottom bar', () => { + render(