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 ( +
+
+ + +
+

+ {bilingual({ fr: "Circulation d'amour", en: 'Circulation of Love', es: 'Circulación del Amor', ja: '愛の循環', 'zh-Hans': '爱的流转', 'zh-Hant': '愛的流轉' })} +

+

+ {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} +

+ )} +
+
+
+ ); +} diff --git a/src/components/journal/HomeScreen.tsx b/src/components/journal/HomeScreen.tsx index c0f9109..0c9280e 100644 --- a/src/components/journal/HomeScreen.tsx +++ b/src/components/journal/HomeScreen.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; -import { Feather, CheckCircle2, Zap, Sprout, LogOut, Flame, CalendarDays, Mountain, PenLine, Trophy, Hourglass, Target, ListChecks, ChevronDown, ChevronUp, FlaskConical, Activity, LayoutGrid, Sparkles, Heart } from 'lucide-react'; +import { Feather, CheckCircle2, Zap, Sprout, LogOut, Flame, CalendarDays, Mountain, PenLine, Trophy, Hourglass, Target, ListChecks, ChevronDown, ChevronUp, FlaskConical, Activity, LayoutGrid, Sparkles, Heart, Waves } from 'lucide-react'; import { useState } from 'react'; import { BADGES } from '@/types/journal'; import { useLanguage } from '@/contexts/LanguageContext'; @@ -37,9 +37,10 @@ interface HomeScreenProps { onOpenLanguageSettings: () => void; onOpenVocabulary: () => void; onOpenProWaitlist: () => void; + onOpenCirculationFeed: () => void; } -export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, earnedBadges, onStartJournal, onStartFreeWrite, onViewProgress, onOpenChat, onOpenBrainDump, onOpenSmallWins, onOpenThoughtGarden, onOpenZenGarden, onOpenSandTimer, onOpenFocusPlan, onOpenTodoList, onOpenTinyExperiment, onOpenBodyScan, onOpenQuadrants, onOpenLanguageSettings, onOpenVocabulary, onOpenProWaitlist }: HomeScreenProps) { +export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, earnedBadges, onStartJournal, onStartFreeWrite, onViewProgress, onOpenChat, onOpenBrainDump, onOpenSmallWins, onOpenThoughtGarden, onOpenZenGarden, onOpenSandTimer, onOpenFocusPlan, onOpenTodoList, onOpenTinyExperiment, onOpenBodyScan, onOpenQuadrants, onOpenLanguageSettings, onOpenVocabulary, onOpenProWaitlist, onOpenCirculationFeed }: HomeScreenProps) { const { submitted: proWaitlistSubmitted } = useProWaitlist(); const { bilingual, t, targetLang } = useLanguage(); const isFr = targetLang === 'fr'; @@ -316,6 +317,25 @@ export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, e + {/* Circulation of Love — opt-in anonymous sharing in primary language. */} + +

{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}

diff --git a/src/components/journal/JournalApp.tsx b/src/components/journal/JournalApp.tsx index 51c0664..d74920e 100644 --- a/src/components/journal/JournalApp.tsx +++ b/src/components/journal/JournalApp.tsx @@ -31,6 +31,9 @@ import { TodoListScreen } from './TodoListScreen'; import { TinyExperimentScreen } from './TinyExperimentScreen'; import { QuadrantsScreen } from './QuadrantsScreen'; import { ProWaitlistScreen } from './ProWaitlistScreen'; +import { LettersInCirculationScreen } from './LettersInCirculationScreen'; +import { ShareALetterScreen } from './ShareALetterScreen'; +import { CirculationSettingsScreen } from './CirculationSettingsScreen'; import { LanguageSettingsScreen } from './LanguageSettingsScreen'; import { PhilosopherQuoteDialog } from './PhilosopherQuoteDialog'; @@ -119,6 +122,9 @@ export function JournalApp() { openProWaitlist, openBodyScan, openLanguageSettings, + openCirculationFeed, + openCirculationShare, + openCirculationSettings, goBackToEmotions, openVocabulary, vocabOrigin, @@ -161,6 +167,7 @@ export function JournalApp() { onOpenBodyScan={openBodyScan} onOpenQuadrants={openQuadrants} onOpenProWaitlist={openProWaitlist} + onOpenCirculationFeed={openCirculationFeed} onOpenLanguageSettings={openLanguageSettings} onOpenVocabulary={() => openVocabulary('home')} /> @@ -349,6 +356,25 @@ export function JournalApp() { )} + {currentStep === 'circulation-feed' && ( + + )} + + {currentStep === 'circulation-share' && ( + + )} + + {currentStep === 'circulation-settings' && ( + + )} + {currentStep === 'languagesettings' && ( )} diff --git a/src/components/journal/LettersInCirculationScreen.tsx b/src/components/journal/LettersInCirculationScreen.tsx new file mode 100644 index 0000000..d014e04 --- /dev/null +++ b/src/components/journal/LettersInCirculationScreen.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { ArrowLeft, Heart, Settings, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useLoveLetters, type LoveLetter } from '@/hooks/useLoveLetters'; +import { useCirculationSettings } from '@/hooks/useCirculationSettings'; + +interface LettersInCirculationScreenProps { + onBack: () => void; + onOpenShare: () => void; + onOpenSettings: () => void; +} + +function daysRemaining(expires: string | null): number { + if (!expires) return 0; + const ms = new Date(expires).getTime() - Date.now(); + return Math.max(0, Math.ceil(ms / (1000 * 60 * 60 * 24))); +} + +export function LettersInCirculationScreen({ onBack, onOpenShare, onOpenSettings }: LettersInCirculationScreenProps) { + const { t, bilingual } = useLanguage(); + const { settings, loading: settingsLoading, update } = useCirculationSettings(); + const { current, loading, error, hold } = useLoveLetters(); + const [opened, setOpened] = useState(null); + const [heldIds, setHeldIds] = useState>(new Set()); + + async function handleHold(id: string) { + if (heldIds.has(id)) return; + setHeldIds(prev => new Set(prev).add(id)); + await hold(id); + } + + const optedIn = settings.receive_letters; + + return ( +
+
+
+ + +
+ +
+

+ {bilingual({ fr: "Circulation d'amour", en: 'Circulation of Love', es: 'Circulación del Amor', ja: '愛の循環', 'zh-Hans': '爱的流转', 'zh-Hant': '愛的流轉' })} +

+

+ {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} +

+ )} + + {!loading && current.length === 0 && !error && ( +

+ {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} +

+ )} + +
+ {current.map((letter, i) => { + const held = heldIds.has(letter.id); + const days = daysRemaining(letter.expires_at); + return ( + + ); + })} +
+ + {error && ( +

+ {t({ fr: "On n'a pas pu charger. Réessayez.", en: "We couldn't load. Try again.", es: 'No pudimos cargar. Inténtalo de nuevo.', ja: '読み込めませんでした。もう一度お試しください。', 'zh-Hans': '没能加载。请再试一次。', 'zh-Hant': '沒能加載。請再試一次。' }).primary} +

+ )} + + )} +
+ + {opened && ( +
setOpened(null)} + > +
e.stopPropagation()} + > +

+ {opened.content} +

+
+ {opened.pseudonym} + + {t({ fr: `${daysRemaining(opened.expires_at)}j`, en: `${daysRemaining(opened.expires_at)}d`, es: `${daysRemaining(opened.expires_at)}d`, ja: `あと${daysRemaining(opened.expires_at)}日`, 'zh-Hans': `还有 ${daysRemaining(opened.expires_at)} 天`, 'zh-Hant': `還有 ${daysRemaining(opened.expires_at)} 天` }).primary} + +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/journal/ShareALetterScreen.tsx b/src/components/journal/ShareALetterScreen.tsx new file mode 100644 index 0000000..38a64d8 --- /dev/null +++ b/src/components/journal/ShareALetterScreen.tsx @@ -0,0 +1,148 @@ +import { useId, useState } from 'react'; +import { ArrowLeft, RefreshCw, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useLoveLetters, type ModerationVerdict } from '@/hooks/useLoveLetters'; +import { useCirculationSettings } from '@/hooks/useCirculationSettings'; +import { usePseudonym } from '@/hooks/usePseudonym'; +import type { PseudonymLang } from '@/lib/pseudonyms'; + +interface ShareALetterScreenProps { + onBack: () => void; + onReleased: () => void; +} + +const MAX = 500; + +export function ShareALetterScreen({ onBack, onReleased }: ShareALetterScreenProps) { + const { t, bilingual, primaryLang } = useLanguage(); + const { share } = useLoveLetters(); + const { settings } = useCirculationSettings(); + const draftId = useId(); + const { pseudonym, regenerate, canRegenerate } = usePseudonym(primaryLang as PseudonymLang, draftId); + const [content, setContent] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [verdict, setVerdict] = useState(null); + const [note, setNote] = useState(null); + + const remaining = MAX - content.length; + const tooLong = content.length > MAX; + const trimmedEmpty = content.trim().length === 0; + const disabled = submitting || trimmedEmpty || tooLong; + + async function handleRelease() { + if (disabled) return; + setSubmitting(true); + setVerdict(null); + setNote(null); + const result = await share({ + content: content.trim(), + pseudonym, + ttl_days: settings.ttl_days, + }); + setSubmitting(false); + if (!result.ok) { + setVerdict('softfail'); + setNote(t({ fr: 'On n\'a pas pu envoyer. Réessayez.', en: "We couldn't send. Try again.", es: 'No pudimos enviar. Inténtalo de nuevo.', ja: '送信できませんでした。もう一度お試しください。', 'zh-Hans': '没能寄出。请再试一次。', 'zh-Hant': '沒能寄出。請再試一次。' }).primary); + return; + } + setVerdict(result.verdict); + setNote(result.note); + if (result.verdict === 'pass') { + onReleased(); + } + } + + return ( +
+
+ + +
+

+ {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} + + +
+ +