Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)

Roadmap item #11. Ships the non-modal bottom-of-page cookie consent
banner, the default /privacy and /cookies policy pages, the
`cookie_consent` table + two §17 endpoints for server-side persistence,
the localStorage fallback for anonymous viewers, the /settings
"Privacy & cookies" tab for revisiting the choice, and the
`frontend/src/lib/consent.js` helper that roadmap item #13's analytics
SDK (v0.15.0) will gate against. No analytics SDK ships in this release
— the consent infrastructure goes in first so the gate is already in
place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names
the new table in §5, and surfaces four §19.2 candidates (content-repo
file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency).
Two new optional env vars (`VITE_PRIVACY_POLICY_URL`,
`VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub
pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 00:53:21 -07:00
parent f8e797ab09
commit 8aa65014b4
17 changed files with 1352 additions and 4 deletions
+103
View File
@@ -1947,3 +1947,106 @@
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+26
View File
@@ -14,6 +14,9 @@ import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -24,8 +27,19 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
.then(setMe)
@@ -134,6 +148,10 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
@@ -174,10 +192,18 @@ export default function App() {
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body — no catalog left pane, no propose modal.
+17
View File
@@ -532,6 +532,23 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
@@ -0,0 +1,164 @@
// CookieConsentBanner.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative — adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet — push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error — leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
@@ -28,7 +28,9 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
@@ -48,10 +50,67 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<PrivacyCookiesSection />
</div>
)
}
// ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ────────────
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// ── §15.4 email category toggles ───────────────────────────────────────────
function EmailPreferencesSection() {
+154
View File
@@ -0,0 +1,154 @@
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
//
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
// will read from this module. v0.13.0 ships the storage + the banner +
// the on-change pub/sub; no analytics SDK ships yet.
//
// Shape of a consent record:
//
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
//
// `essential` is always true at the API surface; it's included for
// symmetry. `recorded_at` is null when the user has not yet made a
// choice — the banner is shown until it's non-null.
//
// Precedence:
// - Anonymous viewer: localStorage is the only source.
// - Authenticated viewer: on sign-in, the server row (if any) overrides
// local; if the server has no row, the local choice is uploaded.
//
// The fan-out is intentionally tiny — three flags. The banner writes
// once; subscribers re-read on demand via `getConsent()` and can
// register `onConsentChange(cb)` to be notified of subsequent updates.
//
// IMPORTANT: don't import this from analytics SDKs that themselves
// set cookies on load. Read consent first, then conditionally `import()`
// the SDK module — that's the contract item #13 will follow.
const LS_KEY = 'rfc-app.cookie-consent.v1'
const DEFAULT = Object.freeze({
essential: true,
analytics: false,
other: false,
recorded_at: null,
})
const listeners = new Set()
function readLocal() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return normalize(parsed)
} catch {
return null
}
}
function writeLocal(record) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
} catch {
// localStorage may be unavailable (private mode, disabled storage).
// In that case we behave as if no choice was ever made — the banner
// shows on every load. Acceptable per §14.5: the user can still
// refuse to consent on each visit.
}
}
function normalize(record) {
return {
essential: true,
analytics: !!record.analytics,
other: !!record.other,
recorded_at: record.recorded_at || null,
}
}
// In-memory snapshot. Initialised lazily on first read so the module
// import order doesn't matter; refreshed by `setConsent` and
// `hydrateFromServer`.
let _snapshot = null
function snapshot() {
if (_snapshot == null) {
_snapshot = readLocal() || { ...DEFAULT }
}
return _snapshot
}
function emit() {
for (const cb of listeners) {
try { cb(snapshot()) } catch {}
}
}
/** Read the current consent record. Always returns a normalized object;
* `recorded_at: null` means the user has not yet chosen. */
export function getConsent() {
return snapshot()
}
/** True if the user has made a choice. The banner uses this to decide
* whether to render itself on load. */
export function hasChosen() {
return snapshot().recorded_at != null
}
/** Subscribe to consent updates. Returns an unsubscribe function. */
export function onConsentChange(cb) {
listeners.add(cb)
return () => listeners.delete(cb)
}
/** Write a new choice locally and emit. Returns the new snapshot. The
* server-side persistence path is handled separately by the banner /
* settings surface via the API client; this helper is for both anon
* and authenticated callers because localStorage is the always-on
* layer (the server row is a backup that survives sign-out). */
export function setConsent({ analytics = false, other = false } = {}) {
const next = normalize({
analytics,
other,
recorded_at: new Date().toISOString(),
})
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Adopt a server-side record as authoritative. Called by the banner /
* settings surface after sign-in when the server returns a non-null
* recorded_at. Updates local + memory + emits to subscribers. */
export function hydrateFromServer(record) {
if (!record || !record.recorded_at) return snapshot()
const next = normalize(record)
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Reset local state — used by the settings "Change" affordance to
* re-prompt the banner. Does not touch the server row; the user must
* re-confirm a choice and the banner uploads on save. */
export function clearLocal() {
try { localStorage.removeItem(LS_KEY) } catch {}
_snapshot = { ...DEFAULT }
emit()
return _snapshot
}
// Cross-tab sync: if another tab writes the key, mirror the change here.
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', e => {
if (e.key !== LS_KEY) return
_snapshot = readLocal() || { ...DEFAULT }
emit()
})
}
+125
View File
@@ -0,0 +1,125 @@
// Cookies.jsx — v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
// Privacy.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL → the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short — the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}