Skip to content

feat(circulation): Circulation of Love — anonymous, time-bound letter sharing - #81

Merged
TrainTravel merged 6 commits into
mainfrom
feat/love-circulation
Jun 18, 2026
Merged

feat(circulation): Circulation of Love — anonymous, time-bound letter sharing#81
TrainTravel merged 6 commits into
mainfrom
feat/love-circulation

Conversation

@TrainTravel

Copy link
Copy Markdown
Owner

Summary

Inspired by Train's grandfather's idea of the circulation of love. An opt-in, anonymous, time-bound sharing layer where users release a short (≤500 char) letter in their primary language. It rides a 14-day current — visible to other opted-in users in the same primary language — then quietly archives. The only reaction is Hold this for a moment (silent, idempotent, private to the author).

🟡 What it is NOT (locked at spec time): not a social network, not cross-lingual, not public outside the app, not viral, not search-indexed.

What ships (5 phases bundled)

Phase Surface Files
1 Spec + migration + types docs/specs/love-circulation.md, supabase/migrations/20260613113018_…sql, types.ts regen
2 Edge fns moderate-letter, circulate-letters
3 Hooks + 3 screens + HomeScreen tile usePseudonym, useCirculationSettings, useLoveLetters; LettersInCirculationScreen, ShareALetterScreen, CirculationSettingsScreen
4 Drift animation + Lovable visual prompt animate-letter-drift keyframe in index.css, docs/launch/lovable-circulation-display.md
5 Tests + changelog 11 new unit tests + 5 new E2E specs + CHANGELOG entry

Hard constraints honored

  • Moderation cannot be bypassed. Client cannot insert a row with moderated_status != 'pending' (RLS); the moderate-letter edge fn inserts with the verdict baked in using service role. Gateway errors fail closed (softfail, never auto-publish).
  • RLS strict. Other users see only passed + live + matching language + their own receive_letters = true. Holds require the same gate + non-own letter. Authors always retain access to their own letters (Object Permanence per CLAUDE.md).
  • Idempotent reactions. letter_holdings PK = (letter_id, holder_id). Tapping twice = 1 hold. code 23505 treated as success.
  • Distinct cron secret. circulate-letters uses CIRCULATION_CRON_SECRET, not a user JWT — leaked user token can't trigger archival.
  • prefers-reduced-motion respected for the new drift animation.
  • Bilingual coverage for every new chrome string across en/fr/es/ja/zh-Hans/zh-Hant.

Defaults applied (open questions from the spec)

Marathon-mode build. Applied the spec's recommended defaults:

  1. Share via own flow only (not on JournalEntry).
  2. Silent reactions (author sees count only when they visit).
  3. Default TTL: 14 days.
  4. Drift teaser via the same screen shell for non-opted-in users (Join CTA replaces the feed).
  5. Cross-language: no, primary language only.

Override any of these before merge if needed.

Deploy notes

After merge, run these in Supabase:

supabase secrets set CIRCULATION_CRON_SECRET=$(openssl rand -hex 32)
supabase functions deploy moderate-letter
supabase functions deploy circulate-letters
# Then schedule circulate-letters daily at 03:00 UTC via the Supabase
# dashboard (or pg_cron) with header: Authorization: Bearer <secret>

The migration is the only required infrastructure piece — everything else gracefully degrades (the feed shows the empty/error state if the tables don't yet exist).

Test plan

  • npm test → 359 vitest pass (+11 new)
  • npm run test:e2e -- circulation.spec.ts → 5 specs pass
  • npx tsc --noEmit → 0 errors
  • Manual: opt in via HomeScreen tile → settings → release a passing letter → see it in the current → hold it
  • Manual: release a letter containing "tonight I will" → see softfail/block note with crisis line reference
  • Manual: switch to fr profile → confirm en letters are not visible (RLS language filter)
  • Manual: in prefers-reduced-motion, confirm letter cards don't drift

End-goal artifact

docs/launch/lovable-circulation-display.md is the Lovable update prompt Train asked for last conversation — paste it into Lovable's chat once this PR lands. Hands off the visual elevation (paper texture, varied drift duration, ghost-letter hint behind CTA) to Lovable while keeping the data + hooks + testids frozen.

🤖 Generated with Claude Code

TrainTravel and others added 6 commits June 13, 2026 13:31
Phase 1 of the Love Circulation feature.

What lands:

- docs/specs/love-circulation.md: full design, including what the
  feature is NOT (locks down scope), data model, user flow, RLS
  rationale, moderation requirements, 5 open questions for review.

- supabase/migrations/<ts>_love_circulation.sql:
  • love_letters table (author, content ≤500 chars, language,
    pseudonym, moderated_status, posted_at, expires_at, archived)
  • letter_holdings table (letter_id + holder_id composite PK so
    reactions are idempotent)
  • circulation_settings table (receive_letters, share_letters,
    ttl_days ∈ {7,14,30})
  • Hot-path indexes (live current by language; author's own
    letters; per-letter hold count)
  • Full RLS:
    - Authors always see their own letters
    - Other users see only passed + live + matching language +
      reader opted-in
    - Holds enforce: not your own letter, letter live, reader
      opted-in
    - Service role full access for moderation + TTL cron

Reviewer questions (in spec) before Phase 2:
  1. Share via existing JournalEntry or own flow only?
  2. Silent reactions to author or noisy?
  3. Default TTL: 7/14/30?
  4. Drift teaser for non-opted-in users?
  5. Cross-language sharing allowed?

Defaults applied in this PR are my best guesses; spec calls them out
so the reviewer can override before edge fns are built.

Token spend this turn: ~6k output (specs are dense but bounded).
Cumulative: ~6k. Estimate for full feature: ~80k.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds circulation_settings, letter_holdings, love_letters rows/inserts/updates
to match supabase/migrations/20260613113018. Mirrors the regen Supabase
would emit (alphabetical key order, no extra fields). Closes Phase 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
moderate-letter:
- Validates auth via shared requireAuth helper
- Calls Gemini 2.5 Flash with carefully reviewed moderation prompt
  (pass: sad/grief/anger OK; softfail: ambiguous self-harm + PII +
  named-person; block: explicit suicidal intent, harassment, sexual,
  spam, doxxing)
- Inserts the row server-side with verdict baked in (service role)
  so the client cannot bypass moderation
- Fails closed on gateway errors (softfail, never auto-publish)

circulate-letters:
- Flips archived=true on every expired letter; idempotent
- Auth via CIRCULATION_CRON_SECRET (distinct from user JWT — leaked
  user token can't trigger archival)
- Returns { archived: count, at: timestamp }

config.toml updated; both fns set verify_jwt = false because they
gate auth internally (matches existing pattern).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the user-facing surface for the Circulation of Love:

Data layer
- usePseudonym(language, seed): deterministic name from per-language
  pool, regenerable ONCE per draft (matches spec rule)
- useCirculationSettings: opt-in toggles + TTL chooser, upsert-style
- useLoveLetters: list + share via moderate-letter edge fn + idempotent
  hold/unhold

Screens
- LettersInCirculationScreen: drift feed with "Join the current" CTA for
  non-opted-in users; tap card → modal with single "Hold this for a
  moment" reaction
- ShareALetterScreen: 500-char textarea + pseudonym chip + softfail/
  block note rendering; release calls moderate-letter
- CirculationSettingsScreen: receive/share toggles + 7/14/30 TTL

Wiring
- 3 new JournalSteps: 'circulation-feed' | 'circulation-share' |
  'circulation-settings'
- useJournal exposes openCirculationFeed/Share/Settings
- JournalApp dispatches them
- HomeScreen gains a Waves-icon tile that opens the feed

Animation class `animate-letter-drift` is referenced on cards but the
keyframes ship in Phase 4 — silent no-op until then.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/index.css
- Adds animate-letter-drift utility and letterDrift @Keyframes — gentle
  sine-like translate3d + tiny rotation, 12s loop. Respects
  prefers-reduced-motion via the existing media-query block.

docs/launch/lovable-circulation-display.md
- Lovable update prompt to elevate the feed from "list of cards" to
  "current of paper letters drifting past." Spells out hard constraints
  (no new deps, preserve every testid, bilingual chrome, reduced-motion).
- Lists 7 concrete design moves (paper aesthetic, varied animation
  duration so cards drift past each other, softer Join CTA, more negative
  space, horizontal sway, opened-letter modal as paper unfolded, ghost-
  letter hint in HomeScreen tile) — Lovable picks the combination.
- The "end goal" the user asked for last conversation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…CHANGELOG

Unit (vitest)
- src/lib/pseudonyms.test.ts (8): FNV hash determinism + 32-bit range,
  pool ≥15 entries / no dups per language, in-pool selection,
  (lang, seed) stability, distinct names for distinct seeds.
- src/hooks/usePseudonym.test.ts (3): stable across rerenders, regenerate
  caps at 1 + canRegenerate flips false, respects language pool.

E2E (playwright)
- e2e/circulation.spec.ts (5): home tile visible + labelled bilingually,
  click → feed screen, non-opted-in shows Join CTA + hides Share button,
  feed→settings navigation, TTL chooser exposes all three durations.

Mocks circulation_settings + love_letters REST endpoints (return empty
arrays) so hooks don't crash on mount.

docs/CHANGELOG.md
- New 2026-06-18 section with full Circulation of Love writeup (what it
  is and is NOT, schema, RLS, edge fns, screens, animation, Lovable
  prompt, ADHD-friendly rationale).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@TrainTravel
TrainTravel merged commit bb3ba0b into main Jun 18, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant