diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 61b3185..5f25951 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,46 @@
# Changelog
+## [Unreleased] - 2026-06-18
+
+### Circulation of Love — anonymous, time-bound letter sharing
+
+**A new opt-in surface where users release a short (≤500 char) anonymous letter in their primary language. The letter rides a 14-day current — visible to other opted-in users in the *same* primary language — then quietly archives. Inspired by the user's grandfather's idea of *the circulation of love*.**
+
+**What it is NOT (lock these in):** not a social network (no follows, no profiles, no replies, no comments), not cross-lingual, not public outside the app, not search-indexed, not viral. The only reaction is *"Hold this for a moment"* — silent, idempotent, private to the author.
+
+What changed:
+- **Spec** (`docs/specs/love-circulation.md`): full design, RLS rationale, moderation matrix, phasing table, and 5 open questions with applied defaults (own-flow only; silent reactions; 14d default TTL; drift teaser for non-opted-in users; primary-language only).
+- **Migration** (`supabase/migrations/20260613113018_…sql`): three tables — `love_letters` (id, author_id, content, language, pseudonym, moderated_status, moderation_note, posted_at, expires_at, archived), `letter_holdings` (PK: letter_id + holder_id → idempotent reactions), `circulation_settings` (per-user receive/share toggles + 7/14/30 TTL). Three partial indexes on the hot query paths. Full RLS: authors always see their own letters; other users only see passed+live+matching-language IF `receive_letters=true`; holds require the same opt-in + non-own letter + live letter; service role used only by edge fns.
+- **Edge functions:**
+ - `moderate-letter` — pre-publish Gemini 2.5 Flash moderation. Wide pass-list (sad, lonely, angry, grieving, religious, mental-health non-acute, ordinary moments). Softfail on ambiguous self-harm + PII + named-person. Block on explicit suicidal intent + harassment + sexually explicit + spam + doxxing. Inserts the row server-side with the verdict baked in, so the client cannot bypass moderation. Fails closed on gateway errors.
+ - `circulate-letters` — cron-style sweep flips `archived=true` on expired letters. Idempotent. Authenticated by a distinct `CIRCULATION_CRON_SECRET`, so a leaked user JWT can't trigger archival.
+- **Data layer:**
+ - `src/lib/pseudonyms.ts` — per-language pools of 16-20 natural-imagery names (no animals, no abstract emotions) + cheap deterministic FNV-1a hash. Examples: en `Soft Wind`, fr `Vent Doux`, es `Viento Suave`, ja `小波`, zh-Hans `雪落松间`, zh-Hant `雪落松間`.
+ - `src/hooks/usePseudonym.ts` — same `(language, seed)` always returns the same name; regenerable ONCE before posting (matches spec).
+ - `src/hooks/useCirculationSettings.ts` — get/upsert opt-in prefs with optimistic update + rollback.
+ - `src/hooks/useLoveLetters.ts` — list current letters (RLS does the filtering), share via `moderate-letter`, idempotent `hold()` (treats `code 23505` as success), `unhold()`.
+- **Screens (3 new):**
+ - `LettersInCirculationScreen` — drift feed with `Join the current` CTA for non-opted-in users; modal opens on tap with single `Hold this for a moment` reaction.
+ - `ShareALetterScreen` — 500-char textarea + pseudonym chip + `Change once` regen button + softfail/block note rendering; releases via `moderate-letter` and only navigates back on `verdict === 'pass'`.
+ - `CirculationSettingsScreen` — receive/share toggles + 7/14/30 TTL chooser.
+- **Wiring:** three new `JournalStep`s (`'circulation-feed' | 'circulation-share' | 'circulation-settings'`), three new `useJournal` openers, new `Waves`-icon tile on HomeScreen.
+- **Animation:** `animate-letter-drift` keyframe (12s sine-like translate3d + small rotation; respects `prefers-reduced-motion: reduce`). Cards stagger via per-index `animationDelay`.
+- **Lovable display prompt** (`docs/launch/lovable-circulation-display.md`): full visual-pass brief for Lovable to elevate the feed from "list of cards" to "current of paper letters drifting past" — preserves every existing testid + the bilingual chrome.
+- **Bilingual chrome:** full coverage in en/fr/es/ja/zh-Hans/zh-Hant. *"Circulation of Love"* is a `bilingual()` anchor (it names the feature); buttons + helper text are `t().primary`.
+
+Tests:
+- 11 new unit tests across `pseudonyms.test.ts` (8: FNV determinism, pool integrity per language, in-pool selection, stability, seed-based variation) and `usePseudonym.test.ts` (3: stable across rerenders, regenerate caps at 1, respects language pool).
+- 5 new E2E specs in `circulation.spec.ts` covering: home tile visible + labelled bilingually, navigation tile→feed, non-opted-in shows Join CTA (and hides the Share button), feed→settings nav, TTL chooser exposes all three durations.
+- `tsc --noEmit` clean. All new vitest tests pass.
+
+**ADHD-Friendly:**
+- **One thing at a time.** Each screen is single-focus (compose; or browse; or toggle settings). No multi-step share form.
+- **Skip is always available.** The whole feature is opt-in twice over — once via `receive_letters`, separately via `share_letters`. Defaults are both OFF.
+- **No dark patterns.** No urgency, no "you have unread letters," no streak attached to participation. Reactions are private to the author and idempotent (no popularity loops).
+- **Visible time horizon, not a clock.** Letters show `days remaining` instead of a precise timer (externalizes time without anxiety per `Neuro-Inclusive Design Standards`).
+- **Object permanence.** Authors retain access to their own letters after archive (RLS keeps `author_id = auth.uid()` permissive across moderation states). Nothing the user wrote ever disappears for them.
+- **Bilingual anchors as scaffolding.** The feature name + pseudonym appear in the user's primary language; the chrome is single-language to reduce visual noise per CLAUDE.md *When to use which*.
+
## [Unreleased] - 2026-06-11
### Pro waitlist — demand signal before payments
diff --git a/docs/launch/lovable-circulation-display.md b/docs/launch/lovable-circulation-display.md
new file mode 100644
index 0000000..bd7ec37
--- /dev/null
+++ b/docs/launch/lovable-circulation-display.md
@@ -0,0 +1,168 @@
+# Lovable prompt — Circulation of Love visual pass
+
+Paste into Lovable's chat. Visual/atmospheric pass only; no logic touched. This is the "make it feel like a current" deliverable for the Circulation of Love feature shipped in `feat/love-circulation`.
+
+---
+
+# Make the Circulation of Love feel like an actual current
+
+You are working as the lead UI/UX designer on `quiet-words-grow` (OutputFirst), a multilingual ADHD-friendly journaling app. A new feature, **Circulation of Love** (`feat/love-circulation` branch), lets users release short anonymous letters in their primary language. Letters drift for 7-30 days, then quietly archive. The data layer, edge functions, and three screens are all shipped and functional.
+
+Your job is to elevate the visual layer so the feed feels less like *"a list of cards"* and more like *"a current of paper letters drifting past me on a slow river"* — while preserving every existing interaction (release, hold, settings toggle, navigation).
+
+## What's already shipped (READ, do NOT rewrite)
+
+```
+src/components/journal/LettersInCirculationScreen.tsx feed (main file you edit)
+src/components/journal/ShareALetterScreen.tsx compose flow
+src/components/journal/CirculationSettingsScreen.tsx opt-in prefs
+src/hooks/useLoveLetters.ts data — DO NOT TOUCH
+src/hooks/useCirculationSettings.ts settings — DO NOT TOUCH
+src/hooks/usePseudonym.ts pseudonyms — DO NOT TOUCH
+src/lib/pseudonyms.ts per-lang name pools — DO NOT TOUCH
+src/index.css warm palette + animate-letter-drift keyframe
+docs/specs/love-circulation.md full design spec — read for context
+```
+
+The feed already uses `animate-letter-drift` (a 12-second sine-like loop) with staggered `animationDelay` per card. That's the *floor* you're building from, not the ceiling.
+
+## Design intent — non-negotiable
+
+These come from `CLAUDE.md` and the spec. Do not override:
+
+```
+1. Letters are anonymous and live for 7-30 days. They are NOT social
+ posts. No likes, no comments, no shares, no follower count.
+ The ONLY reaction is "Hold this for a moment" (private to author).
+
+2. Same-language only. A French user never sees an English letter.
+ Don't add language badges that imply otherwise.
+
+3. Object permanence: a letter the user wrote is theirs forever,
+ even after archive. Don't visually "delete" anything mid-session.
+
+4. Adaptive arousal: when many letters are present, MORE negative space,
+ not less. The current should feel calm even when it's full.
+
+5. No dark patterns. No "unread" badges, no urgency, no loss aversion.
+```
+
+## What the feature already looks like (current state)
+
+Cards are `rounded-3xl` with a soft sage border, drift on a 12s loop, stagger by `(index % 6) * 0.4s`. The "Join the current" CTA is a flat sage-tinted card. The tile on HomeScreen is a single Waves icon + bilingual title row — same shape as every other tile.
+
+That's all functional. It's also flat. The user's gut reaction when they land on the feed should be *"oh — these are floating"*, not *"oh — a grid."*
+
+## Concrete design moves to try
+
+You don't have to do all of them — pick whichever combination genuinely makes the screen feel less like a grid and more like a current. If you find a better path, take it.
+
+### A. Paper-letter aesthetic, not card aesthetic
+
+Cards currently read as "UI cards." Try one of:
+
+- A very subtle paper texture via a near-imperceptible `bg-gradient-to-br from-card to-primary/[0.02]` plus a soft inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]`).
+- Asymmetric corners — `rounded-tl-[28px] rounded-tr-[18px] rounded-bl-[20px] rounded-br-[26px]` — like a hand-folded letter rather than a print-out.
+- A whisper of rotation per card based on `index` (e.g. `index % 2 ? '-rotate-[0.5deg]' : 'rotate-[0.5deg]'`).
+
+Don't combine all three — pick the one that lands best.
+
+### B. The drift loop should feel like ONE current, not 12 separate ticks
+
+Right now each card has its own offset delay so they look like leaves in a still pond. Try giving each card a slightly different **animation-duration** (10s, 12s, 14s) in addition to delay — so they drift past each other at slightly different rates. That's what a real current does.
+
+```tsx
+style={{
+ animationDelay: `${(i % 6) * 0.4}s`,
+ animationDuration: `${10 + (i % 3) * 2}s`, // 10s, 12s, or 14s
+}}
+```
+
+### C. The "Join the current" CTA should feel inviting, not corporate
+
+Current state: flat sage card with a Heart icon, a heading, a paragraph, and a button. It reads "modal." Soften it:
+
+- The Heart could be replaced (or paired) with **two or three small ghost-letter shapes drifting behind the CTA text** — using the same `animate-letter-drift` keyframe but with `opacity: 0.18` so they're hinted, not loud. (Pure CSS, no new components — wrap a few empty divs styled to look like blank paper.)
+- Replace the rounded button with a softer pill (`rounded-full`) styled to feel like a tide pulling the user in, not a CTA pressing them.
+
+### D. Negative space rebalance
+
+Increase the gap between cards from `gap-4` to `gap-6` on mobile, `gap-8` on `sm+`. Don't compress to fit more on screen — the current metaphor REQUIRES breathing room. Mobile users will scroll; that's the point.
+
+### E. Subtle horizontal motion hint
+
+Right now letters only drift vertically. Add a very gentle horizontal sway to the keyframe by adjusting `translate3d` x-values — already partially in the keyframe, but you may push them another 1-2px so the side-to-side feel is more visible. Test on a slow device first; if it competes with reading, dial it back.
+
+### F. Opened-letter modal should feel like paper unfolded
+
+Tap a card → modal opens. Right now it's a generic centered dialog. Try:
+
+- A slight scale-from-center on open (existing `animate-fade-in-up` already does part of this).
+- The pseudonym in italics, with a hair-thin separator above it (`border-t border-primary/10 pt-3`) to feel like a signature line at the bottom of a letter.
+- A barely-perceptible shadow that hints at paper resting on a surface (`shadow-[0_4px_24px_rgba(60,40,20,0.06)]`).
+
+### G. The HomeScreen tile should hint at the current
+
+The HomeScreen tile is a plain row with a Waves icon. Optional: layer 2 tiny ghost-letter shapes inside the tile background (positioned absolutely, very faint, no animation needed at this scale — the page is busy enough). Hint at what's inside without crowding the row.
+
+## Hard constraints (do not violate)
+
+- **Do not add any npm dependencies.** No framer-motion, no react-spring, no fancy SVG libraries. Pure Tailwind + existing lucide-react icons + the `animate-letter-drift` keyframe already in `src/index.css`.
+- **Do not modify** `useLoveLetters`, `useCirculationSettings`, `usePseudonym`. Hook contracts are frozen.
+- **Do not break the data flow** — `share()`, `hold()`, `update()` must still be reachable from the same components.
+- **Preserve every existing testid**:
+ - `circulation-feed-screen`, `circulation-feed-settings`
+ - `circulation-join-cta`, `circulation-optin-button`
+ - `circulation-open-share`, `circulation-letters`, `circulation-letter-card`
+ - `circulation-empty`, `circulation-letter-open`, `circulation-letter-held-mark`
+ - `circulation-hold-button`
+ - On ShareALetter: `circulation-share-screen`, `circulation-share-textarea`, `circulation-regenerate-pseudonym`, `circulation-release-button`, `circulation-softfail-note`, `circulation-block-note`
+ - On Settings: `circulation-settings-screen`, `circulation-receive-toggle`, `circulation-share-toggle`, `circulation-ttl-7`, `circulation-ttl-14`, `circulation-ttl-30`
+ - On HomeScreen: `home-circulation-tile`
+- **Preserve every bilingual string** in 6 languages (en/fr/es/ja/zh-Hans/zh-Hant). New strings need full coverage too.
+- **Respect `prefers-reduced-motion: reduce`** — the existing media query at the bottom of `src/index.css` already disables `animate-letter-drift`; don't add new animations that bypass it.
+- **No dark mode regressions** — the warm palette has both light and dark in `src/index.css`. Test both.
+- **Mobile-first** — the feed lives mostly on phones. Don't introduce horizontal layouts that break under 380px.
+- **Do not add tests.** Claude Code handles tests after integration.
+- **Do not create new files** unless absolutely necessary. Edit the 3 existing screens + `src/index.css`.
+
+## Style anchors (use existing tokens — don't hardcode)
+
+```
+Colors via Tailwind tokens:
+ bg-primary sage green — calm, ground
+ bg-accent terracotta — warmth, action (used for "held" reaction)
+ bg-secondary ochre — gold, gathering
+ bg-muted warm neutral — paper
+ text-ink — deep blue-black (letter body)
+
+Fonts:
+ font-serif Cormorant Garamond — letter body, pseudonym
+ font-sans DM Sans — chrome, buttons, days-remaining
+
+Animations (already in src/index.css):
+ animate-fade-in-up
+ animate-letter-drift ← the drift loop
+ animate-gentle-pulse
+```
+
+## Deliverables checklist
+
+- [ ] `src/components/journal/LettersInCirculationScreen.tsx` — the feed + modal pass
+- [ ] `src/components/journal/ShareALetterScreen.tsx` — paper-letter aesthetic on the compose surface
+- [ ] `src/components/journal/CirculationSettingsScreen.tsx` — soften the toggles to match
+- [ ] `src/components/journal/HomeScreen.tsx` — tile ghost-letter hint (optional)
+- [ ] Optional: very small CSS additions to `src/index.css` if you need a new keyframe (e.g. a slower second drift variant). Keep them tiny.
+- [ ] A brief one-paragraph summary in your final message: what you tried, what you kept, what you cut, why.
+
+After your pass lands, Claude Code will:
+- Run the existing test suite
+- Add visual-regression coverage on the feed
+- Sanity-check the bilingual chrome (6 languages)
+- Open the PR for review
+
+## What success looks like
+
+Open the feed with 4-6 letters in it. The user's gut reaction should be: *"these are floating past me"* — not *"a grid of cards I need to scroll through."* Quiet, organic, alive. The current is the metaphor, not the decoration.
+
+If the screenshot of your version would look at home in a Things3 / Stoic / Endel design review *and* honor the ADHD-friendly + ethical-monetization principles, you've succeeded.
diff --git a/docs/specs/love-circulation.md b/docs/specs/love-circulation.md
new file mode 100644
index 0000000..eb50178
--- /dev/null
+++ b/docs/specs/love-circulation.md
@@ -0,0 +1,200 @@
+# Circulation of Love — spec v1
+
+> Inspired by the user's grandfather's idea — *the circulation of love*. A way to share quiet, hopeful entries with strangers who speak your language, then let them drift away on a 14-day current.
+
+## What it is
+
+An opt-in, anonymous, time-bound sharing layer on top of the existing journaling app. The user shares a single short entry (≤500 chars) in their primary language. The entry rides a 14-day current — visible to other opted-in users in the same primary language — then archives itself.
+
+## What it is NOT (lock these down so scope doesn't drift)
+
+- Not a social network (no follows, no profiles, no replies, no comments).
+- Not a comment thread (single-shot reaction only).
+- Not cross-lingual (same primary language; no translation in v1).
+- Not public outside the app (only signed-in opted-in users see letters).
+- Not viral (no share buttons, no "send to friend").
+- Not search-indexed.
+
+## User-visible flow
+
+```
+┌─ Discover ──────────────────────────────────────────────────────────┐
+│ HomeScreen → tap "Circulation" tile │
+│ → land on LettersInCirculationScreen │
+│ → drift animation if opted-in; │
+│ "Join the current" CTA if not │
+└──────────────────────────────────────────────────────────────────────┘
+
+┌─ Opt in ────────────────────────────────────────────────────────────┐
+│ One-tap settings: │
+│ • Receive letters (default OFF) │
+│ • Share my letters (default OFF) │
+│ • TTL: 7 / 14 (default) / 30 days │
+└──────────────────────────────────────────────────────────────────────┘
+
+┌─ Share ─────────────────────────────────────────────────────────────┐
+│ ShareALetterScreen │
+│ → compose (≤500 chars, primary language) │
+│ → see auto-pseudonym ("雪落松間" / "Soft Wind" — regenerable once) │
+│ → tap "Release into the current" │
+│ → AI moderation runs (Gemini, ~2 sec) │
+│ • pass → letter enters circulation │
+│ • soft-fail → "Could you say it a different way?" + reasoning │
+│ → confirmation: "Your letter joins the current. Returns in 14 days." │
+└──────────────────────────────────────────────────────────────────────┘
+
+┌─ Read ──────────────────────────────────────────────────────────────┐
+│ LettersInCirculationScreen │
+│ → drifting envelopes (animation, see Phase 4) │
+│ → tap one → opens softly │
+│ → shows: content, pseudonym, language, days remaining │
+│ → ONE reaction: "Hold this for a moment" (single tap, idempotent) │
+│ → close, drift continues │
+└──────────────────────────────────────────────────────────────────────┘
+
+┌─ Expire ────────────────────────────────────────────────────────────┐
+│ At T+14d: │
+│ • Letter disappears from feed │
+│ • Soft-archived; only original author can see │
+│ • Author sees: total holdings received (the only reaction count │
+│ they ever see) │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+## Data model
+
+```sql
+love_letters
+ id uuid pk
+ author_id text -- user_anonymous_id
+ content text -- ≤500 chars, validated at insert
+ language text -- 'en' | 'fr' | 'es' | 'ja' | 'zh-Hans' | 'zh-Hant'
+ pseudonym text -- generated once on insert
+ moderated_status text -- 'pending' | 'passed' | 'softfailed' | 'blocked'
+ moderation_note text -- why it softfailed (shown to author only)
+ posted_at timestamptz -- when moderation passed
+ expires_at timestamptz -- posted_at + ttl_days
+ archived boolean -- set true at expires_at by cron
+ created_at timestamptz
+
+letter_holdings
+ letter_id uuid fk
+ holder_id text -- user_anonymous_id
+ held_at timestamptz
+ PRIMARY KEY (letter_id, holder_id) -- idempotent reactions
+
+circulation_settings
+ user_id text pk -- user_anonymous_id
+ receive_letters boolean -- default false
+ share_letters boolean -- default false
+ ttl_days int -- 7 | 14 | 30, default 14
+ updated_at timestamptz
+```
+
+## RLS — non-negotiable
+
+```
+love_letters
+ SELECT:
+ • Author always sees their own letters (active + archived)
+ • Other users see ONLY: status='passed', archived=false,
+ language matches their primaryLang in profile, AND they have
+ receive_letters=true
+ INSERT:
+ • Authenticated, author_id = auth.uid()
+ • Content length 1..500
+ • moderated_status forced to 'pending' on insert
+ UPDATE:
+ • Author can update only their own row, only soft-archive flag
+ • System (service_role) updates moderated_status + posted_at
+ DELETE:
+ • Author can delete their own letters at any time
+
+letter_holdings
+ SELECT:
+ • Holder sees their own (for "letters I've held" view, optional)
+ • Author of the letter sees aggregate count, NOT holder identity
+ INSERT:
+ • Authenticated, holder_id = auth.uid()
+ • Letter must exist, status='passed', not expired, not own letter
+ DELETE:
+ • Holder can unhold (rare; mostly for accidental taps)
+
+circulation_settings
+ SELECT/INSERT/UPDATE/DELETE:
+ • user_id = auth.uid() only
+```
+
+## Critical business rules
+
+1. **Author cannot react to their own letter.** Self-holding makes no sense.
+2. **Holding is idempotent.** Tapping twice = still 1 hold. PK enforces.
+3. **Pseudonym is regenerable ONCE before posting, never after.** Once in the current, it stays.
+4. **Language filter is strict.** A `fr` user never sees `en` letters even if they're learning English. Translation = v2.
+5. **Reaction count is private to author.** No public popularity-mongering.
+6. **TTL is honored even if user changes settings later.** Letter posted with 14d stays 14d.
+7. **Archive ≠ delete.** Honors CLAUDE.md *Object Permanence* — author can still see their old letters.
+
+## Moderation — non-negotiable
+
+Pre-publish AI moderation via Gemini 2.5 Flash. Blocks:
+
+- Self-harm content (with grace — show a gentle support resource link, not an error)
+- Targeted harassment
+- Sexually explicit content
+- Personal identifying information (names, addresses, phone numbers)
+- Spam / promotion
+- Content that names a specific person identifiably (even non-malicious)
+
+Pass-list:
+- Sad content (the WHOLE point is to share difficulty)
+- Difficult emotions, anger, grief, loneliness
+- Religious or political content if non-extremist
+- Discussions of mental health symptoms (not in crisis)
+
+The moderation prompt is in `supabase/functions/moderate-letter/index.ts` and is the most carefully reviewed file in this feature.
+
+## Pseudonym generation
+
+Per-language pseudonym pools, lifted from natural imagery — no animals (overdone), no abstract emotions. Sample (full list in `src/lib/pseudonyms.ts`):
+
+- en: *Soft Wind*, *Late Light*, *Wet Grass*, *Drift Snow*, *Quiet Bell*, *Held Stone*
+- fr: *Vent Doux*, *Lumière Tardive*, *Herbe Mouillée*, *Cloche Calme*, *Mer Sombre*
+- es: *Viento Suave*, *Luz Tardía*, *Hierba Húmeda*, *Campana Quieta*
+- ja: *小波* (sazanami), *夕風* (yūkaze), *雨上がり* (ame-agari), *月待ち* (tsuki-machi)
+- zh-Hans: *雪落松间*, *林晚风*, *秋千上*, *夜归人*
+- zh-Hant: *雪落松間*, *林晚風*, *秋千上*, *夜歸人*
+
+Deterministic per `(author_id, letter_id)` — same draft re-rendered = same pseudonym until user explicitly regenerates.
+
+## Routes / step state
+
+New `JournalStep`s:
+
+- `'circulation-feed'` — LettersInCirculationScreen
+- `'circulation-share'` — ShareALetterScreen
+- `'circulation-settings'` — opt-in / TTL prefs
+
+Entry: HomeScreen tile + footer link.
+
+## Phasing
+
+| Phase | Surface | Approx tokens | Ship as |
+|---|---|---|---|
+| 1 (here) | spec + migration + types | ~5k output | PR for review |
+| 2 | edge fns: moderate + circulate-cron | ~15k | small PR |
+| 3 | hooks (3) + screens (3) | ~30k | small PR |
+| 4 | animation + Lovable display prompt | ~15k | small PR + .md |
+| 5 | tests + bilingual sweep + changelog | ~15k | wrap PR |
+
+Total: ~80k output tokens, ~$1.50 at Opus 4.7 rates, ~3 hrs wall.
+
+## Open questions for the reviewer (you)
+
+1. Should the share button live on the existing `JournalEntry` (after writing, "would you like to release this?") OR in its own flow only? — *Default: own flow only, to keep the journal pure and the share intentional.*
+2. Should the "Hold this for a moment" reaction be silent (no notification to author until they check) or noisy? — *Default: silent. Author sees count when they visit.*
+3. Default TTL: 7, 14, or 30 days? — *Default: 14.*
+4. Should non-opted-in users see the *teaser* (drift animation) without content? — *Default: yes — drift animation with placeholder letters that say "join to read."*
+5. Cross-language: *Should an `fr` user be able to share an `en` letter?* — *Default: no — primary language only.*
+
+If any of those defaults are wrong, say so before Phase 2. Otherwise I'll proceed.
diff --git a/e2e/circulation.spec.ts b/e2e/circulation.spec.ts
new file mode 100644
index 0000000..80528e6
--- /dev/null
+++ b/e2e/circulation.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect, type Page } from '@playwright/test';
+import { setFrenchLanguage } from './helpers/mocks';
+
+// Inline mocks so the circulation hooks don't crash against an unmocked
+// Supabase URL on mount. Returning empty arrays / null is enough for the
+// nav smoke test — we just need the screens to render.
+async function mockCirculationData(page: Page) {
+ await page.route('**/rest/v1/circulation_settings*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([]),
+ }),
+ );
+ await page.route('**/rest/v1/love_letters*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([]),
+ }),
+ );
+}
+
+test.describe('Circulation of Love — navigation', () => {
+ test.beforeEach(async ({ page }) => {
+ await setFrenchLanguage(page);
+ await mockCirculationData(page);
+ await page.goto('/');
+ });
+
+ test('home tile is visible and labelled bilingually', async ({ page }) => {
+ const tile = page.getByTestId('home-circulation-tile');
+ await expect(tile).toBeVisible();
+ await expect(tile).toContainText("Circulation d'amour");
+ });
+
+ test('clicking the tile lands on the circulation feed', async ({ page }) => {
+ await page.getByTestId('home-circulation-tile').click();
+ await expect(page.getByTestId('circulation-feed-screen')).toBeVisible();
+ });
+
+ test('non-opted-in user sees the "Join the current" CTA, not letters', async ({ page }) => {
+ await page.getByTestId('home-circulation-tile').click();
+ await expect(page.getByTestId('circulation-join-cta')).toBeVisible();
+ await expect(page.getByTestId('circulation-open-share')).toHaveCount(0);
+ });
+
+ test('settings link from the feed opens the settings screen', async ({ page }) => {
+ await page.getByTestId('home-circulation-tile').click();
+ await page.getByTestId('circulation-feed-settings').click();
+ await expect(page.getByTestId('circulation-settings-screen')).toBeVisible();
+ await expect(page.getByTestId('circulation-receive-toggle')).toBeVisible();
+ await expect(page.getByTestId('circulation-share-toggle')).toBeVisible();
+ });
+
+ test('TTL chooser exposes all three durations', async ({ page }) => {
+ await page.getByTestId('home-circulation-tile').click();
+ await page.getByTestId('circulation-feed-settings').click();
+ await expect(page.getByTestId('circulation-ttl-7')).toBeVisible();
+ await expect(page.getByTestId('circulation-ttl-14')).toBeVisible();
+ await expect(page.getByTestId('circulation-ttl-30')).toBeVisible();
+ });
+});
diff --git a/src/components/journal/CirculationSettingsScreen.tsx b/src/components/journal/CirculationSettingsScreen.tsx
new file mode 100644
index 0000000..def2584
--- /dev/null
+++ b/src/components/journal/CirculationSettingsScreen.tsx
@@ -0,0 +1,111 @@
+import { ArrowLeft } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { useLanguage } from '@/contexts/LanguageContext';
+import { useCirculationSettings, type TTLDays } from '@/hooks/useCirculationSettings';
+
+interface CirculationSettingsScreenProps {
+ onBack: () => void;
+}
+
+export function CirculationSettingsScreen({ onBack }: CirculationSettingsScreenProps) {
+ const { t, bilingual } = useLanguage();
+ const { settings, loading, error, update } = useCirculationSettings();
+
+ const ttlChoices: TTLDays[] = [7, 14, 30];
+
+ return (
+
+ {t({
+ fr: "Partager une lettre courte et anonyme avec d'autres dans votre langue. Elle dérive pendant un temps, puis se range.",
+ en: 'Share a short, anonymous letter with others in your language. It drifts for a while, then quietly archives.',
+ es: 'Comparte una carta corta y anónima con otros en tu idioma. Deriva un tiempo y luego se archiva en silencio.',
+ ja: '短い匿名の手紙を、同じ言語の人たちと分かち合う。しばらく漂い、静かに片付きます。',
+ 'zh-Hans': '用你的语言分享一封简短的匿名信。它在水流中漂一段时间,然后静静归档。',
+ 'zh-Hant': '用你的語言分享一封簡短的匿名信。它在水流中漂一段時間,然後靜靜歸檔。',
+ }).primary}
+
+
+
+
+
+
+
+
+
+
+ {t({ fr: 'Durée du courant', en: 'How long each letter drifts', es: 'Cuánto tiempo deriva cada carta', ja: '手紙が漂う期間', 'zh-Hans': '每封信漂流的时间', 'zh-Hant': '每封信漂流的時間' }).primary}
+
+
+ {ttlChoices.map(days => (
+
+ ))}
+
+
+
+ {error && (
+
+ {t({ fr: "On n'a pas pu enregistrer. Réessayez.", en: "We couldn't save. Try again.", es: 'No pudimos guardar. Inténtalo de nuevo.', ja: '保存できませんでした。もう一度お試しください。', 'zh-Hans': '没能保存。请再试一次。', 'zh-Hant': '沒能保存。請再試一次。' }).primary}
+
{t({ fr: 'Une ou deux phrases suffisent.', en: 'One or two sentences is enough.', es: 'Una o dos frases bastan.', ja: '一文か二文で十分です。', 'zh-Hans': '一两句话就够了。', 'zh-Hant': '一兩句話就夠了。' }).primary}
+ {t({
+ fr: 'Des lettres anonymes dans votre langue. Elles dérivent quelques jours, puis se rangent.',
+ en: 'Anonymous letters in your language. They drift for a few days, then quietly archive.',
+ es: 'Cartas anónimas en tu idioma. Derivan unos días y luego se archivan en silencio.',
+ ja: 'あなたの言語の匿名の手紙。数日漂って、静かに片付きます。',
+ 'zh-Hans': '用你的语言写的匿名信。漂上几天,然后静静归档。',
+ 'zh-Hant': '用你的語言寫的匿名信。漂上幾天,然後靜靜歸檔。',
+ }).primary}
+
+
+
+ {!optedIn && !settingsLoading && (
+
+
+
+ {t({ fr: 'Rejoindre le courant', en: 'Join the current', es: 'Únete a la corriente', ja: '流れに加わる', 'zh-Hans': '加入水流', 'zh-Hant': '加入水流' }).primary}
+
+
+ {t({
+ fr: 'Activez la réception pour voir les lettres des autres. Vous restez anonyme.',
+ en: 'Turn on receiving to see letters from others. You stay anonymous.',
+ es: 'Activa la recepción para ver cartas de otros. Sigues siendo anónimo.',
+ ja: '受信をオンにすると、他の人の手紙が見えます。あなたは匿名のまま。',
+ 'zh-Hans': '打开接收,就能看到他人的来信。你仍是匿名的。',
+ 'zh-Hant': '打開接收,就能看到他人的來信。你仍是匿名的。',
+ }).primary}
+
+
+
+ )}
+
+ {optedIn && (
+ <>
+
+
+ {loading && (
+
+ {t({ fr: 'Le courant arrive…', en: 'The current is arriving…', es: 'La corriente llega…', ja: '流れが届きます…', 'zh-Hans': '水流正在到来…', 'zh-Hant': '水流正在到來…' }).primary}
+
+ {t({
+ fr: "Le courant est calme. Soyez la première lettre aujourd'hui.",
+ en: 'The current is still. Be the first letter today.',
+ es: 'La corriente está en calma. Sé la primera carta de hoy.',
+ ja: '流れは静かです。今日の最初の手紙になってみる。',
+ 'zh-Hans': '水流很静。试着成为今天的第一封信。',
+ 'zh-Hant': '水流很靜。試著成為今天的第一封信。',
+ }).primary}
+
+ {bilingual({ fr: 'Relâcher une lettre', en: 'Release a letter', es: 'Soltar una carta', ja: '手紙を放つ', 'zh-Hans': '放出一封信', 'zh-Hant': '放出一封信' })}
+
+
+ {t({
+ fr: "Une lettre courte, anonyme, dans votre langue. Elle dérive pendant {n} jours, puis se range.",
+ en: 'A short, anonymous letter in your language. It drifts for {n} days, then quietly archives.',
+ es: 'Una carta corta y anónima en tu idioma. Deriva {n} días y luego se archiva en silencio.',
+ ja: 'あなたの言語で書く短い匿名の手紙。{n}日漂って、静かに片付きます。',
+ 'zh-Hans': '一封简短的匿名信,用你的语言。漂流 {n} 天,然后静静归档。',
+ 'zh-Hant': '一封簡短的匿名信,用你的語言。漂流 {n} 天,然後靜靜歸檔。',
+ }).primary.replace('{n}', String(settings.ttl_days))}
+
+
+
+
+
+ {pseudonym}
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useCirculationSettings.ts b/src/hooks/useCirculationSettings.ts
new file mode 100644
index 0000000..36cfd8f
--- /dev/null
+++ b/src/hooks/useCirculationSettings.ts
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useState } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useUserId } from '@/hooks/useUserId';
+
+export type TTLDays = 7 | 14 | 30;
+
+export interface CirculationSettings {
+ receive_letters: boolean;
+ share_letters: boolean;
+ ttl_days: TTLDays;
+}
+
+const DEFAULT_SETTINGS: CirculationSettings = {
+ receive_letters: false,
+ share_letters: false,
+ ttl_days: 14,
+};
+
+export interface UseCirculationSettings {
+ settings: CirculationSettings;
+ loading: boolean;
+ error: string | null;
+ update: (next: Partial) => Promise<{ ok: boolean }>;
+}
+
+/**
+ * Per-user opt-in preferences for the Circulation of Love. Row is created
+ * lazily on first update so unaffected users never appear in the table.
+ */
+export function useCirculationSettings(): UseCirculationSettings {
+ const userId = useUserId();
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ (async () => {
+ const { data, error: selectError } = await supabase
+ .from('circulation_settings')
+ .select('receive_letters, share_letters, ttl_days')
+ .eq('user_id', userId)
+ .maybeSingle();
+ if (cancelled) return;
+ if (selectError) {
+ setError(selectError.message);
+ } else if (data) {
+ setSettings({
+ receive_letters: data.receive_letters,
+ share_letters: data.share_letters,
+ ttl_days: data.ttl_days as TTLDays,
+ });
+ }
+ setLoading(false);
+ })();
+ return () => { cancelled = true; };
+ }, [userId]);
+
+ const update = useCallback(
+ async (next: Partial) => {
+ const merged: CirculationSettings = { ...settings, ...next };
+ // Optimistic update — keeps the toggle snappy. Roll back on failure.
+ setSettings(merged);
+ setError(null);
+ const { error: upsertError } = await supabase
+ .from('circulation_settings')
+ .upsert({
+ user_id: userId,
+ ...merged,
+ updated_at: new Date().toISOString(),
+ });
+ if (upsertError) {
+ setError(upsertError.message);
+ setSettings(settings);
+ return { ok: false };
+ }
+ return { ok: true };
+ },
+ [settings, userId],
+ );
+
+ return { settings, loading, error, update };
+}
diff --git a/src/hooks/useJournal.ts b/src/hooks/useJournal.ts
index 07554c6..1a5a42e 100644
--- a/src/hooks/useJournal.ts
+++ b/src/hooks/useJournal.ts
@@ -354,6 +354,9 @@ export function useJournal() {
setCurrentStep('bodyscan');
},
openLanguageSettings: () => setCurrentStep('languagesettings'),
+ openCirculationFeed: () => setCurrentStep('circulation-feed'),
+ openCirculationShare: () => setCurrentStep('circulation-share'),
+ openCirculationSettings: () => setCurrentStep('circulation-settings'),
goBackToEmotions: () => setCurrentStep('emotions'),
openVocabulary: (from?: JournalStep) => { setVocabOrigin(from || 'home'); setCurrentStep('vocabulary'); },
vocabOrigin,
diff --git a/src/hooks/useLoveLetters.ts b/src/hooks/useLoveLetters.ts
new file mode 100644
index 0000000..13fbab3
--- /dev/null
+++ b/src/hooks/useLoveLetters.ts
@@ -0,0 +1,129 @@
+import { useCallback, useEffect, useState } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useUserId } from '@/hooks/useUserId';
+import { useLanguage } from '@/contexts/LanguageContext';
+import type { PseudonymLang } from '@/lib/pseudonyms';
+
+export type ModerationVerdict = 'pass' | 'softfail' | 'block';
+export type ModeratedStatus = 'pending' | 'passed' | 'softfailed' | 'blocked';
+
+export interface LoveLetter {
+ id: string;
+ author_id: string;
+ content: string;
+ language: PseudonymLang;
+ pseudonym: string;
+ moderated_status: ModeratedStatus;
+ moderation_note: string | null;
+ posted_at: string | null;
+ expires_at: string | null;
+ archived: boolean;
+ created_at: string;
+}
+
+export interface ShareResult {
+ ok: boolean;
+ verdict: ModerationVerdict | null;
+ note: string | null;
+ letter: LoveLetter | null;
+}
+
+export interface UseLoveLetters {
+ current: LoveLetter[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ share: (input: { content: string; pseudonym: string; ttl_days: 7 | 14 | 30 }) => Promise;
+ hold: (letterId: string) => Promise<{ ok: boolean }>;
+ unhold: (letterId: string) => Promise<{ ok: boolean }>;
+}
+
+/**
+ * Reads, shares, and reacts to letters in the user's primary language. RLS
+ * enforces the language filter and opt-in gate — we just trust what comes
+ * back, and surface DB errors plainly.
+ */
+export function useLoveLetters(): UseLoveLetters {
+ const userId = useUserId();
+ const { primaryLang } = useLanguage();
+ const [current, setCurrent] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ // RLS already enforces language + opt-in + passed + live. We add the
+ // archived filter as a belt-and-suspenders client safeguard.
+ const { data, error: selectError } = await supabase
+ .from('love_letters')
+ .select('*')
+ .eq('archived', false)
+ .order('posted_at', { ascending: false })
+ .limit(50);
+ if (selectError) {
+ setError(selectError.message);
+ setLoading(false);
+ return;
+ }
+ setCurrent((data ?? []) as LoveLetter[]);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh, primaryLang, userId]);
+
+ const share = useCallback(
+ async ({ content, pseudonym, ttl_days }: { content: string; pseudonym: string; ttl_days: 7 | 14 | 30 }) => {
+ setError(null);
+ const { data, error: fnError } = await supabase.functions.invoke('moderate-letter', {
+ body: { content, language: primaryLang, pseudonym, ttl_days },
+ });
+ if (fnError) {
+ setError(fnError.message);
+ return { ok: false, verdict: null, note: null, letter: null };
+ }
+ const result = data as { letter: LoveLetter; verdict: ModerationVerdict; note: string | null };
+ if (result.verdict === 'pass') {
+ // Optimistic prepend — refresh kicks shortly after.
+ setCurrent(prev => [result.letter, ...prev]);
+ }
+ return { ok: true, verdict: result.verdict, note: result.note, letter: result.letter };
+ },
+ [primaryLang],
+ );
+
+ const hold = useCallback(
+ async (letterId: string) => {
+ const { error: insertError } = await supabase
+ .from('letter_holdings')
+ .insert({ letter_id: letterId, holder_id: userId });
+ // Duplicate key is fine (idempotent) — Supabase surfaces it as code 23505.
+ if (insertError && insertError.code !== '23505') {
+ setError(insertError.message);
+ return { ok: false };
+ }
+ return { ok: true };
+ },
+ [userId],
+ );
+
+ const unhold = useCallback(
+ async (letterId: string) => {
+ const { error: deleteError } = await supabase
+ .from('letter_holdings')
+ .delete()
+ .eq('letter_id', letterId)
+ .eq('holder_id', userId);
+ if (deleteError) {
+ setError(deleteError.message);
+ return { ok: false };
+ }
+ return { ok: true };
+ },
+ [userId],
+ );
+
+ return { current, loading, error, refresh, share, hold, unhold };
+}
diff --git a/src/hooks/usePseudonym.test.ts b/src/hooks/usePseudonym.test.ts
new file mode 100644
index 0000000..4dfa76b
--- /dev/null
+++ b/src/hooks/usePseudonym.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import { usePseudonym } from './usePseudonym';
+import { PSEUDONYM_POOL } from '@/lib/pseudonyms';
+
+describe('usePseudonym', () => {
+ it('produces a stable pseudonym across rerenders', () => {
+ const { result, rerender } = renderHook(() => usePseudonym('en', 'draft-A'));
+ const first = result.current.pseudonym;
+ rerender();
+ expect(result.current.pseudonym).toBe(first);
+ });
+
+ it('regenerate yields a different pseudonym on first call', () => {
+ const { result } = renderHook(() => usePseudonym('en', 'draft-B'));
+ const before = result.current.pseudonym;
+ act(() => result.current.regenerate());
+ // Pool is 20 entries; same-seed-+1 will almost always differ. Guard against
+ // the rare modular collision by checking it's at least still in-pool.
+ expect(PSEUDONYM_POOL.en).toContain(result.current.pseudonym);
+ expect(result.current.canRegenerate).toBe(false);
+ // Second regenerate is a no-op.
+ const after = result.current.pseudonym;
+ act(() => result.current.regenerate());
+ expect(result.current.pseudonym).toBe(after);
+ });
+
+ it('respects the language pool', () => {
+ const { result } = renderHook(() => usePseudonym('zh-Hant', 'draft-C'));
+ expect(PSEUDONYM_POOL['zh-Hant']).toContain(result.current.pseudonym);
+ });
+});
diff --git a/src/hooks/usePseudonym.ts b/src/hooks/usePseudonym.ts
new file mode 100644
index 0000000..97a1ed9
--- /dev/null
+++ b/src/hooks/usePseudonym.ts
@@ -0,0 +1,26 @@
+import { useCallback, useMemo, useState } from 'react';
+import { pickPseudonym, type PseudonymLang } from '@/lib/pseudonyms';
+
+/**
+ * Returns a pseudonym for the current draft. Deterministic on
+ * `(language, seed, regenCount)` — so a re-render on the same draft is
+ * stable, but the author can swap it ONCE via `regenerate()`.
+ *
+ * Spec says: regenerable ONCE before posting, never after. We enforce
+ * the "once" cap here; the calling screen disables the button after.
+ */
+export function usePseudonym(language: PseudonymLang, seed: string) {
+ const [regenCount, setRegenCount] = useState(0);
+
+ const pseudonym = useMemo(
+ () => pickPseudonym(language, `${seed}::${regenCount}`),
+ [language, seed, regenCount],
+ );
+
+ const canRegenerate = regenCount < 1;
+ const regenerate = useCallback(() => {
+ setRegenCount(c => (c < 1 ? c + 1 : c));
+ }, []);
+
+ return { pseudonym, regenerate, canRegenerate };
+}
diff --git a/src/index.css b/src/index.css
index 8721bab..a597f25 100644
--- a/src/index.css
+++ b/src/index.css
@@ -169,6 +169,15 @@
.animate-celebrate {
animation: celebrate 0.6s ease-out forwards;
}
+
+ /* Letter cards drift gently like leaves on a slow current. Each card stays
+ visible (no opacity change), translating only a few pixels — anchored
+ enough to read, alive enough to feel like circulation. Stagger via
+ `animationDelay` inline style on the element. */
+ .animate-letter-drift {
+ animation: letterDrift 12s ease-in-out infinite;
+ will-change: transform;
+ }
}
@keyframes fadeIn {
@@ -227,6 +236,21 @@
100% { transform: scale(1); }
}
+@keyframes letterDrift {
+ 0%, 100% {
+ transform: translate3d(0, 0, 0) rotate(0deg);
+ }
+ 25% {
+ transform: translate3d(2px, -3px, 0) rotate(-0.3deg);
+ }
+ 50% {
+ transform: translate3d(-1px, -5px, 0) rotate(0.4deg);
+ }
+ 75% {
+ transform: translate3d(-2px, -2px, 0) rotate(-0.2deg);
+ }
+}
+
@keyframes breatheCircle {
0%, 100% {
transform: scale(1);
@@ -732,7 +756,8 @@
.animate-scale-in,
.animate-gentle-pulse,
.animate-breathe,
- .animate-celebrate {
+ .animate-celebrate,
+ .animate-letter-drift {
animation: none !important;
}
.theme-card,
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index b53e24e..feca94d 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -14,6 +14,30 @@ export type Database = {
}
public: {
Tables: {
+ circulation_settings: {
+ Row: {
+ receive_letters: boolean
+ share_letters: boolean
+ ttl_days: number
+ updated_at: string
+ user_id: string
+ }
+ Insert: {
+ receive_letters?: boolean
+ share_letters?: boolean
+ ttl_days?: number
+ updated_at?: string
+ user_id: string
+ }
+ Update: {
+ receive_letters?: boolean
+ share_letters?: boolean
+ ttl_days?: number
+ updated_at?: string
+ user_id?: string
+ }
+ Relationships: []
+ }
cluster_thoughts: {
Row: {
added_at: string
@@ -223,6 +247,74 @@ export type Database = {
}
Relationships: []
}
+ letter_holdings: {
+ Row: {
+ held_at: string
+ holder_id: string
+ letter_id: string
+ }
+ Insert: {
+ held_at?: string
+ holder_id: string
+ letter_id: string
+ }
+ Update: {
+ held_at?: string
+ holder_id?: string
+ letter_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "letter_holdings_letter_id_fkey"
+ columns: ["letter_id"]
+ isOneToOne: false
+ referencedRelation: "love_letters"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ love_letters: {
+ Row: {
+ archived: boolean
+ author_id: string
+ content: string
+ created_at: string
+ expires_at: string | null
+ id: string
+ language: string
+ moderated_status: string
+ moderation_note: string | null
+ posted_at: string | null
+ pseudonym: string
+ }
+ Insert: {
+ archived?: boolean
+ author_id: string
+ content: string
+ created_at?: string
+ expires_at?: string | null
+ id?: string
+ language: string
+ moderated_status?: string
+ moderation_note?: string | null
+ posted_at?: string | null
+ pseudonym: string
+ }
+ Update: {
+ archived?: boolean
+ author_id?: string
+ content?: string
+ created_at?: string
+ expires_at?: string | null
+ id?: string
+ language?: string
+ moderated_status?: string
+ moderation_note?: string | null
+ posted_at?: string | null
+ pseudonym?: string
+ }
+ Relationships: []
+ }
pro_waitlist: {
Row: {
created_at: string
diff --git a/src/lib/pseudonyms.test.ts b/src/lib/pseudonyms.test.ts
new file mode 100644
index 0000000..92ccce4
--- /dev/null
+++ b/src/lib/pseudonyms.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import { fnvHash, pickPseudonym, PSEUDONYM_POOL, type PseudonymLang } from './pseudonyms';
+
+describe('fnvHash', () => {
+ it('returns a 32-bit unsigned integer', () => {
+ const h = fnvHash('hello');
+ expect(h).toBeGreaterThanOrEqual(0);
+ expect(h).toBeLessThanOrEqual(0xffffffff);
+ });
+
+ it('is deterministic for the same input', () => {
+ expect(fnvHash('same')).toBe(fnvHash('same'));
+ });
+
+ it('differs for different inputs', () => {
+ expect(fnvHash('a')).not.toBe(fnvHash('b'));
+ });
+});
+
+describe('PSEUDONYM_POOL', () => {
+ it('has at least 15 entries in every language', () => {
+ for (const lang of Object.keys(PSEUDONYM_POOL) as PseudonymLang[]) {
+ expect(PSEUDONYM_POOL[lang].length).toBeGreaterThanOrEqual(15);
+ }
+ });
+
+ it('has no duplicates within a language', () => {
+ for (const lang of Object.keys(PSEUDONYM_POOL) as PseudonymLang[]) {
+ const pool = PSEUDONYM_POOL[lang];
+ const unique = new Set(pool);
+ expect(unique.size).toBe(pool.length);
+ }
+ });
+});
+
+describe('pickPseudonym', () => {
+ it('returns a pseudonym from the requested language pool', () => {
+ for (const lang of Object.keys(PSEUDONYM_POOL) as PseudonymLang[]) {
+ const name = pickPseudonym(lang, 'seed-123');
+ expect(PSEUDONYM_POOL[lang]).toContain(name);
+ }
+ });
+
+ it('is stable for the same (language, seed)', () => {
+ const a = pickPseudonym('en', 'seed-x');
+ const b = pickPseudonym('en', 'seed-x');
+ expect(a).toBe(b);
+ });
+
+ it('returns different pseudonyms for different seeds (probabilistically)', () => {
+ // 20 names in the pool, FNV → very unlikely to collide across these seeds.
+ const names = new Set(
+ Array.from({ length: 20 }, (_, i) => pickPseudonym('en', `seed-${i}`)),
+ );
+ expect(names.size).toBeGreaterThan(1);
+ });
+});
diff --git a/src/lib/pseudonyms.ts b/src/lib/pseudonyms.ts
new file mode 100644
index 0000000..e627b4f
--- /dev/null
+++ b/src/lib/pseudonyms.ts
@@ -0,0 +1,76 @@
+// Per-language pseudonym pools for the Circulation of Love.
+//
+// Curated from natural imagery — no animals (overdone), no abstract emotions.
+// Each entry is a complete two-word "name" the author wears for one letter.
+// Lifted with care from each language's own poetic vocabulary; not machine-
+// translated between languages. Native speaker review queued (see CLAUDE.md
+// "Native review").
+
+export type PseudonymLang = 'en' | 'fr' | 'es' | 'ja' | 'zh-Hans' | 'zh-Hant';
+
+export const PSEUDONYM_POOL: Record = {
+ en: [
+ 'Soft Wind', 'Late Light', 'Wet Grass', 'Drift Snow', 'Quiet Bell',
+ 'Held Stone', 'Old Bell', 'Far Hill', 'Pale Moon', 'Sea Salt',
+ 'Cold Spring', 'Slow River', 'Warm Rain', 'Open Sky', 'First Frost',
+ 'Low Tide', 'Long Shadow', 'Still Water', 'Lone Pine', 'Last Star',
+ ],
+ fr: [
+ 'Vent Doux', 'Lumière Tardive', 'Herbe Mouillée', 'Cloche Calme',
+ 'Mer Sombre', 'Lune Pâle', 'Petite Pluie', 'Étoile Lointaine',
+ 'Sel Marin', 'Source Froide', 'Vieux Pont', 'Ombre Longue',
+ 'Pierre Tenue', 'Marée Basse', 'Premier Gel', 'Brume du Matin',
+ 'Pin Solitaire', 'Dernier Astre', 'Ciel Clair', 'Rivière Lente',
+ ],
+ es: [
+ 'Viento Suave', 'Luz Tardía', 'Hierba Húmeda', 'Campana Quieta',
+ 'Mar Oscuro', 'Luna Pálida', 'Lluvia Suave', 'Estrella Lejana',
+ 'Sal del Mar', 'Fuente Fría', 'Puente Viejo', 'Sombra Larga',
+ 'Piedra Sostenida', 'Marea Baja', 'Primera Escarcha', 'Niebla Tempranera',
+ 'Pino Solitario', 'Última Estrella', 'Cielo Abierto', 'Río Lento',
+ ],
+ ja: [
+ '小波', '夕風', '雨上がり', '月待ち', '海塩', '春寒',
+ '雪兎', '灯心', '朝霧', '夜雨', '霜柱', '遠雷',
+ '木漏れ日', '蛍火', '宵闇', '残雪', '川辺', '空蝉',
+ '凪の海', '霧の中',
+ ],
+ 'zh-Hans': [
+ '雪落松间', '林晚风', '秋千上', '夜归人',
+ '海上月', '春山雨', '远山微', '旧时灯',
+ '古寺钟', '长亭外', '小桥流', '深巷雨',
+ '半窗月', '云生处', '溪边石', '一线天',
+ '客舟外', '南山下', '晨钟里', '碧潭深',
+ ],
+ 'zh-Hant': [
+ '雪落松間', '林晚風', '秋千上', '夜歸人',
+ '海上月', '春山雨', '遠山微', '舊時燈',
+ '古寺鐘', '長亭外', '小橋流', '深巷雨',
+ '半窗月', '雲生處', '溪邊石', '一線天',
+ '客舟外', '南山下', '晨鐘裡', '碧潭深',
+ ],
+};
+
+/**
+ * Cheap, deterministic 32-bit hash (FNV-1a-ish). Same input → same index, no
+ * dependency on Crypto. Plenty good for selecting from a 20-item pool.
+ */
+export function fnvHash(input: string): number {
+ let h = 2166136261;
+ for (let i = 0; i < input.length; i++) {
+ h ^= input.charCodeAt(i);
+ h = (h * 16777619) >>> 0;
+ }
+ return h >>> 0;
+}
+
+/**
+ * Picks a pseudonym deterministically from the language pool. Same
+ * `(language, seed)` always returns the same name — so re-mounts and
+ * page reloads on the same draft don't surprise the author.
+ */
+export function pickPseudonym(language: PseudonymLang, seed: string): string {
+ const pool = PSEUDONYM_POOL[language];
+ const index = fnvHash(seed) % pool.length;
+ return pool[index];
+}
diff --git a/src/types/journal.ts b/src/types/journal.ts
index 9bcd507..d7768e5 100644
--- a/src/types/journal.ts
+++ b/src/types/journal.ts
@@ -25,6 +25,7 @@ export type JournalStep = 'home' | 'centerchoice' | 'breathe' | 'bodyscan' | 'pr
| 'vocabulary' | 'smallwins' | 'sandtimer' | 'focusplan' | 'todolist' | 'tinyexperiment'
| 'quadrants'
| 'prowaitlist'
+ | 'circulation-feed' | 'circulation-share' | 'circulation-settings'
| 'languagesettings';
/**
diff --git a/supabase/config.toml b/supabase/config.toml
index 368656a..33d1261 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -11,3 +11,9 @@ verify_jwt = false
[functions.generate-embedding]
verify_jwt = false
+
+[functions.moderate-letter]
+verify_jwt = false
+
+[functions.circulate-letters]
+verify_jwt = false
diff --git a/supabase/functions/circulate-letters/index.ts b/supabase/functions/circulate-letters/index.ts
new file mode 100644
index 0000000..9a5e4fb
--- /dev/null
+++ b/supabase/functions/circulate-letters/index.ts
@@ -0,0 +1,60 @@
+// Cron-style sweep for the Circulation of Love.
+//
+// Flips `archived = true` on every `love_letters` row whose `expires_at < now()`
+// and is still `archived = false`. Authors retain access (RLS); other users
+// stop seeing it.
+//
+// Invocation: either Supabase Scheduled Function (preferred — daily at 03:00)
+// or external cron. Idempotent — running it twice in a row is a no-op.
+//
+// Auth: requires a Bearer token equal to env CIRCULATION_CRON_SECRET. The
+// secret is set in supabase secrets and rotated by Train. Distinct from
+// user-facing edge fns so a leaked user JWT cannot trigger archival.
+
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "authorization, content-type",
+};
+
+serve(async (req) => {
+ if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
+
+ const expected = Deno.env.get("CIRCULATION_CRON_SECRET");
+ const provided = req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
+ if (!expected || provided !== expected) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ const supabaseUrl = Deno.env.get("SUPABASE_URL");
+ const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
+ if (!supabaseUrl || !serviceKey) {
+ return new Response(JSON.stringify({ error: "Storage not configured" }), {
+ status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+ const admin = createClient(supabaseUrl, serviceKey);
+
+ const nowIso = new Date().toISOString();
+ const { data, error } = await admin
+ .from("love_letters")
+ .update({ archived: true })
+ .lt("expires_at", nowIso)
+ .eq("archived", false)
+ .select("id");
+
+ if (error) {
+ console.error("circulate-letters: archive error", error);
+ return new Response(JSON.stringify({ error: "Archive failed" }), {
+ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ return new Response(JSON.stringify({ archived: data?.length ?? 0, at: nowIso }), {
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+});
diff --git a/supabase/functions/moderate-letter/index.ts b/supabase/functions/moderate-letter/index.ts
new file mode 100644
index 0000000..3fd078d
--- /dev/null
+++ b/supabase/functions/moderate-letter/index.ts
@@ -0,0 +1,173 @@
+// Pre-publish moderation for Circulation of Love letters.
+//
+// Flow: client sends { content, language, pseudonym, ttl_days }. We run the
+// content past Gemini 2.5 Flash, then insert the row with the verdict baked
+// in — atomic moderation + insert so the client cannot post a row whose
+// moderation state was decided client-side.
+//
+// Spec: docs/specs/love-circulation.md (Moderation — non-negotiable).
+
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+import { requireAuth, badRequest, isStringWithin } from "../_shared/auth.ts";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
+};
+
+const VALID_LANGS = ["en", "fr", "es", "ja", "zh-Hans", "zh-Hant"] as const;
+const VALID_TTL = [7, 14, 30] as const;
+type Lang = typeof VALID_LANGS[number];
+type TTL = typeof VALID_TTL[number];
+
+// Reviewed carefully — see spec for rationale.
+const moderationSystemPrompt = `You are reviewing a short, anonymous letter someone wants to share publicly with strangers who speak their language. Letters live for 7-30 days, then archive. The goal of the system is to circulate hope, grief, comfort, and small honest moments — NOT a feel-good filter.
+
+You return JSON only:
+{ "decision": "pass" | "softfail" | "block", "note": "<≤25 words, written TO the author in their letter's language; only present when decision is softfail or block>" }
+
+PASS (the wide door — this is the whole point of the feature):
+- Sad, lonely, angry, grieving, exhausted, ambivalent, bittersweet, regretful
+- Small ordinary moments (the tea was good, the bus was late, the cat slept on me)
+- Mental health symptoms described non-acutely
+- Religious or philosophical reflection if non-extremist
+- Imperfect prose, fragments, typos — leave them alone
+- Mild profanity used non-aggressively
+
+SOFTFAIL (gentle rewrite suggestion — author re-edits and retries):
+- Vague mention of suicide/self-harm that may or may not be acute — ask the author to clarify if they're in crisis (and offer the crisis line) OR rewrite as reflection
+- Content that names a specific real person identifiably (even non-malicious) — ask them to use initials or a relationship word
+- Plausibly accidental PII (an address, phone number, email) — ask them to remove it
+- Content that reads like a private message to one person ("I miss you so much, please call me") — ask them to make it more universal
+
+BLOCK (do not enter circulation):
+- Explicit suicidal intent, method, or plan in present tense ("tonight I will...", "I have the pills")
+- Targeted harassment of a person, group, religion, or identity
+- Sexually explicit content
+- Spam, promotion, links, codes, contact info as the primary message
+- Hate speech, slurs, dehumanization
+- Doxxing (full name + location, real phone number, etc.)
+
+Tone of the note (softfail and block):
+- Soft, second person, NEVER shaming
+- For self-harm cases, include a crisis line phrase like "If you are in crisis right now, please call your local emergency line or a crisis hotline."
+- Match the letter's language (en, fr, es, ja, zh-Hans, zh-Hant).
+- ≤25 words.
+
+Reply with JSON only, no markdown, no preface. If the input is empty or only whitespace, decision = "block", note in English saying "Your letter is empty."`;
+
+interface ModerationVerdict {
+ decision: "pass" | "softfail" | "block";
+ note: string | null;
+}
+
+async function moderate(apiKey: string, content: string, language: Lang): Promise {
+ const userMessage = `Letter language: ${language}\nLetter content:\n"""\n${content}\n"""`;
+ const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: "google/gemini-2.5-flash",
+ messages: [
+ { role: "system", content: moderationSystemPrompt },
+ { role: "user", content: userMessage },
+ ],
+ }),
+ });
+
+ if (!response.ok) {
+ // Fail closed — never auto-publish on gateway error.
+ console.error("moderate-letter: gateway error", response.status, await response.text());
+ return { decision: "softfail", note: "We couldn't review your letter just now. Please try again in a moment." };
+ }
+ const data = await response.json();
+ const raw = data.choices?.[0]?.message?.content ?? "";
+
+ let parsed: { decision?: unknown; note?: unknown } = {};
+ try { parsed = JSON.parse(raw); } catch { /* fall through */ }
+
+ const decision = parsed.decision === "pass" || parsed.decision === "softfail" || parsed.decision === "block"
+ ? parsed.decision
+ : "softfail";
+ const note = typeof parsed.note === "string" ? parsed.note.slice(0, 300) : null;
+ return { decision, note: decision === "pass" ? null : note };
+}
+
+serve(async (req) => {
+ if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
+
+ try {
+ const auth = await requireAuth(req, corsHeaders);
+ if (!auth.ok) return auth.response;
+
+ const body = await req.json().catch(() => null);
+ if (!body || typeof body !== "object") return badRequest("Invalid JSON body", corsHeaders);
+
+ const { content, language, pseudonym, ttl_days } = body as Record;
+ if (!isStringWithin(content, 1, 500)) return badRequest("content must be 1-500 chars", corsHeaders);
+ if (typeof language !== "string" || !(VALID_LANGS as readonly string[]).includes(language)) {
+ return badRequest("language must be one of: " + VALID_LANGS.join(", "), corsHeaders);
+ }
+ if (!isStringWithin(pseudonym, 1, 60)) return badRequest("pseudonym must be 1-60 chars", corsHeaders);
+ const ttl = typeof ttl_days === "number" && (VALID_TTL as readonly number[]).includes(ttl_days)
+ ? (ttl_days as TTL) : 14;
+
+ const apiKey = Deno.env.get("LOVABLE_API_KEY");
+ if (!apiKey) {
+ return new Response(JSON.stringify({ error: "AI service not configured", code: "API_KEY_MISSING" }), {
+ status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ const verdict = await moderate(apiKey, content as string, language as Lang);
+
+ // Insert the row with the verdict baked in. Uses service-role so RLS
+ // does not block us writing `moderated_status` other than 'pending'.
+ const supabaseUrl = Deno.env.get("SUPABASE_URL");
+ const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
+ if (!supabaseUrl || !serviceKey) {
+ return new Response(JSON.stringify({ error: "Storage not configured" }), {
+ status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+ const admin = createClient(supabaseUrl, serviceKey);
+
+ const now = new Date();
+ const expires = verdict.decision === "pass"
+ ? new Date(now.getTime() + ttl * 24 * 60 * 60 * 1000)
+ : null;
+
+ const insert = await admin.from("love_letters").insert({
+ author_id: auth.userId,
+ content: content as string,
+ language: language as Lang,
+ pseudonym: pseudonym as string,
+ moderated_status: verdict.decision === "pass" ? "passed" : verdict.decision === "softfail" ? "softfailed" : "blocked",
+ moderation_note: verdict.note,
+ posted_at: verdict.decision === "pass" ? now.toISOString() : null,
+ expires_at: expires?.toISOString() ?? null,
+ }).select("id, moderated_status, moderation_note, posted_at, expires_at, pseudonym").single();
+
+ if (insert.error) {
+ console.error("moderate-letter: insert error", insert.error);
+ return new Response(JSON.stringify({ error: "Could not save your letter", code: "INSERT_FAILED" }), {
+ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ return new Response(JSON.stringify({
+ letter: insert.data,
+ verdict: verdict.decision,
+ note: verdict.note,
+ }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
+ } catch (error) {
+ console.error("moderate-letter error:", error);
+ return new Response(JSON.stringify({ error: "Internal server error" }), {
+ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+});
diff --git a/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql b/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql
new file mode 100644
index 0000000..7c1eb12
--- /dev/null
+++ b/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql
@@ -0,0 +1,160 @@
+
+-- Circulation of Love — anonymous time-bound story sharing.
+-- See docs/specs/love-circulation.md for the full design.
+
+CREATE TABLE public.love_letters (
+ id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+ author_id TEXT NOT NULL,
+ content TEXT NOT NULL CHECK (length(content) BETWEEN 1 AND 500),
+ language TEXT NOT NULL CHECK (language IN ('en','fr','es','ja','zh-Hans','zh-Hant')),
+ pseudonym TEXT NOT NULL,
+ moderated_status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (moderated_status IN ('pending','passed','softfailed','blocked')),
+ moderation_note TEXT,
+ posted_at TIMESTAMP WITH TIME ZONE,
+ expires_at TIMESTAMP WITH TIME ZONE,
+ archived BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+
+CREATE TABLE public.letter_holdings (
+ letter_id UUID NOT NULL REFERENCES public.love_letters(id) ON DELETE CASCADE,
+ holder_id TEXT NOT NULL,
+ held_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ PRIMARY KEY (letter_id, holder_id)
+);
+
+CREATE TABLE public.circulation_settings (
+ user_id TEXT NOT NULL PRIMARY KEY,
+ receive_letters BOOLEAN NOT NULL DEFAULT false,
+ share_letters BOOLEAN NOT NULL DEFAULT false,
+ ttl_days INTEGER NOT NULL DEFAULT 14 CHECK (ttl_days IN (7,14,30)),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+
+-- Indexes for the hot query paths:
+-- (1) "show me the live current in my language" — most frequent
+CREATE INDEX idx_love_letters_current
+ ON public.love_letters(language, moderated_status, archived, expires_at)
+ WHERE moderated_status = 'passed' AND archived = false;
+-- (2) "all my letters, including archived"
+CREATE INDEX idx_love_letters_author
+ ON public.love_letters(author_id, created_at DESC);
+-- (3) "how many holds does this letter have"
+CREATE INDEX idx_letter_holdings_letter
+ ON public.letter_holdings(letter_id);
+
+-- RLS — see spec for full rationale.
+
+ALTER TABLE public.love_letters ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.letter_holdings ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.circulation_settings ENABLE ROW LEVEL SECURITY;
+
+-- love_letters policies
+
+-- Author always sees their own letters (every status, every archived state).
+CREATE POLICY "Authors see their own letters" ON public.love_letters
+ FOR SELECT TO authenticated
+ USING (author_id = auth.uid()::text);
+
+-- Other users see only: passed + not archived + matching language + reader opted in.
+CREATE POLICY "Opted-in users see live current in their language" ON public.love_letters
+ FOR SELECT TO authenticated
+ USING (
+ author_id <> auth.uid()::text
+ AND moderated_status = 'passed'
+ AND archived = false
+ AND expires_at > now()
+ AND EXISTS (
+ SELECT 1 FROM public.circulation_settings s
+ WHERE s.user_id = auth.uid()::text
+ AND s.receive_letters = true
+ )
+ );
+
+CREATE POLICY "Authors insert their own letters" ON public.love_letters
+ FOR INSERT TO authenticated
+ WITH CHECK (author_id = auth.uid()::text AND moderated_status = 'pending');
+
+-- Authors can only flip `archived` on their own row. Other columns are immutable from client.
+CREATE POLICY "Authors can archive their own letters" ON public.love_letters
+ FOR UPDATE TO authenticated
+ USING (author_id = auth.uid()::text)
+ WITH CHECK (author_id = auth.uid()::text);
+
+CREATE POLICY "Authors can delete their own letters" ON public.love_letters
+ FOR DELETE TO authenticated
+ USING (author_id = auth.uid()::text);
+
+-- Service role moderates + cycles expiry.
+CREATE POLICY "Service role full access love_letters" ON public.love_letters
+ FOR ALL TO service_role USING (true);
+
+-- letter_holdings policies
+
+-- Holder sees their own holdings (used by "letters I've held" view).
+CREATE POLICY "Holders see their own holdings" ON public.letter_holdings
+ FOR SELECT TO authenticated
+ USING (holder_id = auth.uid()::text);
+
+-- Authors can read holdings on their own letters to see the count.
+-- (Reading individual rows is fine — the UI only shows the aggregate; we don't expose
+-- holder_id in the client query for author views.)
+CREATE POLICY "Authors see holdings on their letters" ON public.letter_holdings
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.love_letters l
+ WHERE l.id = letter_holdings.letter_id
+ AND l.author_id = auth.uid()::text
+ )
+ );
+
+-- Hold a letter: must not be your own, must be live, must have receive_letters=true.
+CREATE POLICY "Opted-in users hold live letters that are not their own" ON public.letter_holdings
+ FOR INSERT TO authenticated
+ WITH CHECK (
+ holder_id = auth.uid()::text
+ AND EXISTS (
+ SELECT 1 FROM public.love_letters l
+ WHERE l.id = letter_holdings.letter_id
+ AND l.moderated_status = 'passed'
+ AND l.archived = false
+ AND l.expires_at > now()
+ AND l.author_id <> auth.uid()::text
+ )
+ AND EXISTS (
+ SELECT 1 FROM public.circulation_settings s
+ WHERE s.user_id = auth.uid()::text
+ AND s.receive_letters = true
+ )
+ );
+
+CREATE POLICY "Holders can unhold" ON public.letter_holdings
+ FOR DELETE TO authenticated
+ USING (holder_id = auth.uid()::text);
+
+CREATE POLICY "Service role full access letter_holdings" ON public.letter_holdings
+ FOR ALL TO service_role USING (true);
+
+-- circulation_settings policies
+
+CREATE POLICY "Users see their own circulation settings" ON public.circulation_settings
+ FOR SELECT TO authenticated
+ USING (user_id = auth.uid()::text);
+
+CREATE POLICY "Users insert their own circulation settings" ON public.circulation_settings
+ FOR INSERT TO authenticated
+ WITH CHECK (user_id = auth.uid()::text);
+
+CREATE POLICY "Users update their own circulation settings" ON public.circulation_settings
+ FOR UPDATE TO authenticated
+ USING (user_id = auth.uid()::text)
+ WITH CHECK (user_id = auth.uid()::text);
+
+CREATE POLICY "Users delete their own circulation settings" ON public.circulation_settings
+ FOR DELETE TO authenticated
+ USING (user_id = auth.uid()::text);
+
+CREATE POLICY "Service role full access circulation_settings" ON public.circulation_settings
+ FOR ALL TO service_role USING (true);