Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A letter app for two people who live apart. Not a messaging app — the point is
that a letter arrives, unfolds, and is read once, slowly. See PRD.md for the
original brief.
With no .env the app runs on an in-memory mock: three seeded letters, one
unread, a signed-in user, and a linked partner. That is the normal development
mode and the whole test suite runs against it. With .env present it talks to
Supabase — see supabase/README.md.README 전문 보기 ↗A letter app for two people who live apart. Not a messaging app — the point is
that a letter arrives, unfolds, and is read once, slowly. See PRD.md for the
original brief.
npm install
npm run dev
With no .env the app runs on an in-memory mock: three seeded letters, one
unread, a signed-in user, and a linked partner. That is the normal development
mode and the whole test suite runs against it. With .env present it talks to
Supabase — see supabase/README.md.
The same fallback applies in production, which is the trap: a deployment whose
environment variables are unset does not error, it serves the mock's three
seeded letters to every visitor. DEPLOY.md has the click-path and the two
things that bite.
| Command | Does |
|---|---|
npm run dev | Dev server |
npm test | Vitest, 116 logic-level tests across 5 files |
npm run typecheck | tsc -b — the real type gate |
npm run build | Typecheck plus production build |
npm run lint | oxlint |
npx tsc --noEmit checks nothing in this repo. The root tsconfig is
{"files": [], "references": [...]}, so it type-checks zero files and always
exits 0. Use npm run typecheck.
src/
app/ App shell and routes
auth/ AuthProvider, useAuth — session, profile, partner
components/ Layout, LetterCard, LetterModal, LetterSheet, ComposeLetter,
RequireAuth, InviteLink, ShareModal, Toast
routes/ Inbox, Compose, AuthScreen, SetupProfile, JoinPartner,
Settings, Chapters, Chapter, Archive, PublicLetter
design/ tokens.css, fonts, letterFonts, PaperTexture
data/ types, mockRepository, supabaseRepository, contractTests
hooks/ useLetters, useBonds, usePublicLetter, usePoll
lib/ slug, validation, format
supabase/ schema.sql, policies.sql, reset-test-data.sql, README.md
docs/superpowers/
specs/ Design spec and carry-over decisions
plans/ Task-by-task implementation plans
The one architectural idea: everything above src/data/ talks to a
repository interface, never to a backend. src/data/index.ts picks the mock or
the Supabase adapter based on whether VITE_SUPABASE_URL is set. That is why
the entire UI was built and reviewed before a database existed, and why the
swap was a one-line change.
Live database state, 2026-09-23. schema.sql and policies.sql were run
against the live Supabase project on that date, in that order, and the app was
confirmed working afterwards — inbox, sign-up and sending all exercised. That
covers every migration Phases 6, 7, 8 and 11 describe. The per-phase
"migration status" paragraphs below record what was true when each phase was
written, which is why several of them say the SQL was unexecuted; they are
history, not current status. This line is the current status.
One caveat that belongs with it: schema.sql drops
letters_select_participant so it can change scheduled_for's column type,
and deliberately does not recreate it — only policies.sql does. Between the
two files the letters table is deny-all. That is fail-closed, not a leak,
but the two files are one migration and must be run together.
Still unapplied, because they live on the unmerged phase-10-letter-themes
branch rather than here: that phase's theme column, and Phase 9's
letters_bond_created_idx.
Each phase was specified, planned task-by-task, implemented by a fresh agent per task, and reviewed twice — once per task, once across the whole branch. Reviews found real defects in every phase; the notable ones are recorded below because the reasoning is worth more than the diff.
Vite + React + TypeScript + Tailwind v4, design tokens, self-hosted fonts, the paper texture, and the app shell.
src/design/tokens.css,
consumed through Tailwind v4's @theme. One source of truth; no
tailwind.config.js.feTurbulence at opacity-[0.035]
embedded as a data URI — no network request, resolution-independent.Deviations from the PRD: Tailwind v4 (CSS-based theme, not
tailwind.config.js) and React 19 rather than 18. Three palette tokens were
added beyond the PRD's six — --color-paper-edge, --color-ink-muted,
--color-accent-soft — because six colours cannot build a UI.
Inbox, the unfolding letter view, and compose — fully usable with no backend.
LetterCard and LetterModal share a layoutId, which is what makes a card
grow into the opened letter rather than fade in.Found by review:
prefers-reduced-motion was unmet across the whole app. The CSS media query
disables CSS animation; Framer Motion animates in JavaScript and defaults to
reducedMotion: "never". Every per-task review saw the media query and
reasonably concluded the constraint was covered. Fixed with <MotionConfig reducedMotion="user">.Compose mounted its own useLetters and enabled Send before partnerId
resolved — invisible against an instant mock, a wrong-answer bug the day a
network is behind it.Schema, row-level security, email/password auth, and invite-link pairing.
link_partners links both profiles in one transaction so a link cannot
half-apply.security definer function taking the slug as
a mandatory argument, not a view.Found by review — the SQL had no tests and could not be executed, so read-through was its only check:
profiles policy subqueried profiles. Postgres rejects that as
infinite recursion, so every profile read and every letter send would have
failed the moment the SQL was applied.grant select on public_letters to anon
let anyone select it with no filter and receive every shared letter plus both
names. Unlisted-link privacy rests entirely on slug unguessability; a
listable view removes the need to guess. Replaced with a function.receiver_id into a stranger's inbox — RLS cannot restrict columns,
so a before-update trigger now does.A whole-branch review ran nine exploit attempts against the finished SQL —
forging partner_id, inserting to a stranger, re-pointing receiver_id,
enumerating profiles, reading a private letter through the share function,
anonymous reads — and all nine are blocked.
Archive and per-person delete, one merged timeline, and account deletion no longer destroys a letter.
sender_id and receiver_id
go to null, and a before delete trigger on profiles freezes the departing
person's name onto their letters (into sender_name and receiver_name,
separate from the live profile) while archiving the survivor's side. The
correspondence survives, the author's name is preserved.sender_archived_at and
receiver_archived_at timestamps record when each side archived, leaving
the other's copy untouched. sender_deleted_at and receiver_deleted_at
remove it from that person's side permanently — RLS stops them reading the
row, so recovery requires the dashboard.listConversation(): every letter you sent or received,
newest first, excluding archived ones. A second method, listArchived(),
returns the archived letters. This retires Phase 2's received-only inbox
and, with it, the earlier restriction that sent letters do not appear in
your own timeline.link_partners split its overloaded exception: ALREADY_LINKED when you
have a partner, LINK_ALREADY_USED when the code's owner does. Someone
opening a spent invite link is no longer told they are already connected to
someone when they are not connected to anyone.Fixed by this work:
letters foreign keys were
on delete cascade, so deleting an account destroyed the surviving partner's
entire correspondence. Changed to on delete set null so letters survive
their author, and a trigger archives the survivor's side.link_partners raised the same exception both
when the caller was linked and when the code was spent, so the UI could not
tell someone what actually went wrong.A letter can be published as an unlisted URL and read by someone who is not
signed in — /letter/:slug, rendered outside both the auth guard and the app
chrome. Sharing is a toggle: setShared(letterId, shared) flips visibility
without discarding the slug, so turning sharing off and back on revives the
same URL rather than minting a new one. The share panel is modelled on
Drive's "General access" section and nothing else — current state in plain
words, a Restricted / Anyone-with-the-link control, the URL, Copy and native
Share via the Web Share API. No roles, no people list, no expiry, no view
counts; a two-person app has no roles and nobody to invite.
Two decisions worth knowing:
get_public_letter checks sender_deleted_at is null and receiver_deleted_at is null, so either person deleting the letter breaks
the link. It deliberately does not check either archive column:
unlink_partner archives the whole correspondence on a breakup, and
revoking every link on archive would silently break every letter either
person had ever shared.share used to read the row, choose a slug,
and write it back — two round trips with a race in between. It is now a
single update guarded by .is('share_slug', null), so it can only ever
set a slug on a letter that doesn't have one yet; a second call falls
through to a plain is_public flip.The app was already on Vercel and already broken in the two ways that only
appear once deployed. vercel.json now rewrites every path to index.html,
and the route bundle was split so a stranger following a share link downloads
a fraction of what they used to. DEPLOY.md records the parts that live in
two dashboards rather than in this repository.
Why the 404s could not have been caught locally. Vite's dev server serves
index.html for any path it does not recognise, so /letter/<slug> and
/archive resolve invisibly in development. Vercel's static file server does
not: it looks for a file, finds none, and returns 404. Every route in this app
except / was therefore fine locally and dead in production — and
/letter/<slug> is the one URL nobody ever reaches by navigation. A direct hit
from a message is the only way anyone arrives there, so the single route that
had to survive a cold load was the single route guaranteed not to. One rewrite
rule fixes all of it.
The split, and what it is actually for. /letter/:slug is opened by
someone who did not choose to be here — on a phone, from a message, to read
one letter once. Before this phase they downloaded the entire application to
do it: one 609 kB chunk (179 kB gzipped) carrying auth, compose, archive,
both modals and framer-motion. PublicLetter went behind lazy() first, and
that alone changed almost nothing, because PublicLetter was never the heavy
part — the weight was everything statically imported alongside it. So the six
signed-in routes went lazy too, and MotionConfig moved inside AppChrome
so framer-motion loads only for the routes that animate. A stranger now gets
the shared 448 kB chunk (133 kB gzipped: React, the router, the Supabase
client) plus a 1.9 kB letter page; framer-motion's 120 kB sits in a chunk they
never request. The signed-in routes are split as a side effect, not as a goal
— those two people open the app and stay in it.
Found: the live site was serving fiction. Checked directly on 2026-09-10,
dearjee.vercel.app had no Supabase URL anywhere in its bundle and the mock's
seeded letter text present in it. The environment variables were never set, so
every visitor was reading three fabricated letters about someone waking before
an alarm. Nothing in this repository can fix that: Vite inlines VITE_-prefixed
variables at build time, so setting them in the Vercel dashboard does
nothing until a redeploy rebuilds the bundle. There is no server process to
restart, and no error to notice — the fallback is silent by design.
A structural responsive audit came out of this phase as well
(docs/superpowers/specs/2026-09-10-responsive-audit.md): no fixed width
anywhere, both share-URL rows correctly break-all, but the letter body
carries whitespace-pre-wrap without break-words, so a pasted URL inside a
letter is the most likely way a real page breaks at 375px. It reports; it
fixes nothing. Nobody on this project can see a rendered page, and that
verdict needs a phone.
A relationship became a row. profiles.partner_id used to be the whole model
of a relationship: it could say who you are with and nothing else — not that
a relationship ended, when it ran, or that two people had been together
twice. A new bonds table records one row per relationship, open
(ended_at is null) or closed, and letters.bond_id files each letter into
one. partner_id survives as a cache of the open bond, written only by the
two security definer functions that maintain both sides. Someone with no
open bond can still write — the letter is held (bond_id is null) until they
bond, at which point it joins that chapter.
LetterModal's "Move back"
(un-archiving) would have lifted a letter originally written to a former
partner onto the current partner's home page, because archiving was the
only state the UI tracked.receiver_id is what RLS reads to grant read access, so re-associating a
letter to a different chapter would itself be a permission grant, and
moving one would silently delete the other person's copy of a letter they
already received. There is no code path that changes which chapter a
delivered letter belongs to.created_at means written; sent_at means delivered. A letter can now sit
for weeks before a bond exists to receive it, and it reads as having been
written when it was.receiver_id immutability — added in Phase 3 after a receiver could
re-point a letter into a stranger's inbox — had to be narrowed rather than
relaxed. A held letter's receiver_id legitimately goes from null to
someone's id exactly once, when it is sent into the newly-formed bond. The
trigger now permits that one transition and still blocks every other
client-side rewrite.is_public or share_slug at the moment a letter is created, not just
afterward.Found by review:
letters_receiver_id_fkey is on delete set null, so when a recipient
deleted their account, their letters returned to receiver_id = null —
re-entering the same state as a genuinely held letter. Left alone, a sender
could then "send" a letter originally written to B into a later partner
C's bond, still carrying its original created_at. Fixed by also requiring
sent_at is null wherever the code distinguishes a held letter from a
delivered one: a letter that has never been sent has no sent_at, but one
that was delivered and then orphaned by deletion does, so the two cases no
longer look the same.listHeld in the mock skipped the deleted-letter filter the
database applies through RLS, so a letter its own author had deleted kept
reappearing in development while staying gone in production. And
Bond.partnerName returned a live name from the mock but an empty string
from production for the current chapter, which would have shown the
partner's actual name in development and the literal word "Someone" in
production — the two environments telling a different story about the same
screen./settings shipped without its auth guard — the only signed-in route
missing one. Beyond the obvious access problem, it also skipped the
redirect that stops the app rendering "Dear ," to someone who has never
named themselves, which every other signed-in route enforces./chapters — the only route into a correspondence that
had already ended.Changed after looking at it. The control began as a small fold glyph matching the mark it makes. Seen in the browser, the mark read well and the glyph did not — it showed the result without saying what pressing it meant. It is now a heart, outline when untouched and filled when loved, labelled "Love this letter"; the turned-down corner stays exactly as it was. The heart is the verb, the fold is the trace. This is not the chat reaction decision 1 rejected: that decision refused a VOCABULARY of named feelings, and one heart is still the single wordless gesture it chose — nothing is rendered on the letter itself. The fill is deliberately unanimated, because a heart that pops is the instinct this app keeps declining and would need a reduced-motion story the button's existing tap-scale already provides.
Migration status. supabase/schema.sql and supabase/policies.sql carry
the bonds table, the chapter-scoping changes, and the narrowed
receiver_id trigger, and are idempotent by inspection. Applying them to the
live project, and the one-shot supabase/reset-test-data.sql that follows,
is the owner's step and had not been run as of this record. Until it is, the
app continues to run on the mock in development and the pre-Phase-6 schema in
production.
A letter gained a voice and a face, and the inbox stopped needing a reload.
letters.salutation and letters.body_font are both nullable, and null means
"unset, use the default" — the recipient's name as before, Lora as before —
because every letter already in the database has nulls in both and must keep
looking exactly as it looks now. A non-null default would have silently
rewritten the appearance of existing correspondence.
body_font is constrained three times, because each layer can be
bypassed by the one below it: a TypeScript union, validateLetter's runtime
check, and a Postgres check constraint. The constraint is the only layer a
client cannot get around — body_font reaches a style attribute, so an
unconstrained string is a small injection surface. The mock enforces the
first two and reimplements the third in TypeScript, per the standing rule.@font-face subsets back on
the public route's critical path for a face most letters will never use.usePoll refetches every 30
seconds while the tab is visible, immediately on refocus, never while
hidden. Supabase Realtime was rejected for three reasons: it needs
replication enabled in the dashboard, which is exactly the kind of
silent-failure setup step this project has already been bitten by twice
(unset env vars, an unapplied migration); the mock would need a fake event
emitter to stay honest, which polling does not; and a dropped socket on
mobile means silently stale data unless polling is added underneath it
anyway. Mock parity was free — polling is just load() on a timer, which is
what decided it over a websocket.LetterModal
and PublicLetter capturing "the open letter" as a snapshot was harmless.
Polling replaces the list underneath an open reader, so the open letter is
now derived from the live list on every route instead of captured once —
otherwise a letter archived on the other device, or edited state arriving
from a refetch, could leave the modal showing something no longer true.LetterSheet is one component now, not two that had already drifted.
The recipient's modal and the public page used to duplicate the letter's
markup, with different spacing and the public page missing the modal's
chrome. Both now render the same LetterSheet, which takes presentation
props only and imports no values from src/data/. Applying the redesign —
a fold crease, a deckled edge, wider margins, the date as a postmark — to
only the modal would have left a stranger reading a plainer letter than the
recipient does, which is backwards: the public page is the one a person
chooses to show someone.Migration status. supabase/schema.sql and supabase/policies.sql carry
the two new columns, their check constraints, and get_public_letter's
widened return type — the function is dropped and recreated rather than
create or replaced, because its return type changed. Both files are
idempotent by inspection. Applying them to the live project is the owner's
step and had not been run as of this record.
A letter can now be written for a date that hasn't arrived yet. scheduled_for
is a nullable date column; null means deliver now, exactly what every
letter already did. Delivery has no job or cron behind it at all — the
recipient's existing RLS visibility rule gained one clause comparing the
column against current_date, so a letter surfaces on whichever poll happens
after midnight on its day, the same way delivery has always worked here.
enforce_letter_update now
computes a single sender_editing_pending condition — true only for the
letter's own sender, only while its date is still in the future — and
every other actor, and every other column, remains exactly as immutable as
before.message, salutation and body_font had never been grantable for
UPDATE, because nothing had ever been editable — a gap that would have
silently defeated the trigger regardless of how correct it was, the same
failure class already spent a long session on for an unrelated column.receiver_deleted_at rather than inventing a new
state, twice over: once for the sender's own voluntary cancel (the
existing per-letter delete, unchanged), and once for a bond ending before
delivery, where unlink_partner now sets it on the receiver's side only —
the sender keeps their own copy, labelled by the app as never having
arrived, and the recipient can never see it even if the same two people
bond again later and the date passes.Migration status. supabase/schema.sql and supabase/policies.sql carry
the new column, its constraint, the trigger's editing exception, and the
unlink cancellation step, and are idempotent by inspection. Applying them to
the live project is the owner's step and had not been run as of this record.
listConversation no longer fetches the whole current-bond history on every
load and every 30-second poll. It's cursor-paginated on (created_at, id),
30 letters at a time; scrolling near the bottom fetches the next page
automatically via an IntersectionObserver, matching a TikTok/Instagram-style
feed rather than a "load more" button.
spliceConversation in src/hooks/conversationPaging.ts) — a letter
can't fall through a seam that never moves.where clause. It used to fetch every bond letter and
filter in JavaScript after the fact; a limit applied before that filter
would have returned a page that looked full but wasn't, so the filter had
to move into the query itself before pagination could mean anything.policies.sql is untouched.
But review caught that no existing index could serve the new query:
letters_bond_id_idx carries no ordering and the two composite indexes
lead with sender_id/receiver_id, so Postgres would scan the whole bond
and sort all of it before taking 30 — on every page and every poll, which
is more database work per poll than before pagination existed. The payload
and the DOM would have got cheaper while the database got more expensive.
letters_bond_created_idx on letters (bond_id, created_at desc, id desc)
matches the query's sort exactly, making the limit a top-N index scan and
the cursor a seek.Toast component with an optional click handler
and a longer duration rather than inventing a second surface.Found by review (whole-branch, before merge):
loadMoreLetters
set the page-level error, and the routes early-return on that — so one
dropped connection while scrolling replaced the entire conversation with a
single line of text that nothing could clear: the sentinel that would
retry is gone with the grid, and load() only clears the error on a
non-silent load, which polls never are. A failed page now behaves like a
failed poll — the last-known-good list stays and the next intersection
retries. The same trap is already documented three functions below, in
setShared, which deliberately avoids it.loadMoreLetters does
not hold the mutating guard, so an interleaved load() with no boundary
yet refetched a first page and replaced lettersRef, discarding the
letter the in-flight page was anchored to; the boundary frozen afterwards
then named a letter no longer in the list, every later splice took the
boundaryIndex === -1 fallback, and it never came back without a sign-out.
The boundary is frozen before the fetch again — safe because a
newer-than-boundary refresh is unlimited and returns the whole prefix — and
reset if that fetch fails. The append is also deduped by id, which closes
the mirror case: archiving a letter above the boundary shifts a page-1
letter down into the in-flight page's range and it was appended twice.loading is false, but the effect was keyed on
[hasMoreLetters, loadMoreLetters]. If the render flipping
hasMoreLetters true were ever not the render flipping loading false,
the effect would run once against a null node and never re-run — infinite
scroll dead for the session, with no error. It worked only because React
happened to batch those two setStates. A callback ref fires when the node
actually mounts, so attachment no longer depends on React's scheduling.resetPagination cleared the ref but not the state, so a failed
reload left letters rendering the old conversation while lettersRef
was empty — and the next markRead would map over the empty ref and blank
the list. Both are cleared together now.loadMoreLetters was state, which does not take
effect until the next render; two calls in one tick would both pass it. It
is a ref.The person a letter was written to can now fold its corner — one quiet, wordless acknowledgment, found by the writer later rather than announced to them.
is_read was the precedent, and it was followed line for line.
acknowledged_at is a nullable receiver-only column, guarded by a clause in
enforce_letter_update using is distinct from, sitting outside the
if current_user = 'authenticated' wrapper — because nothing privileged
ever writes it, the same reason a folded letter survives a breakup and its
own author's account deletion untouched. is distinct from rather than
<> is load-bearing here specifically because null is the ordinary value on
both sides of the comparison, not an edge case: <> against a null yields
null, which is falsy in an if, so the guard would have silently permitted
every fold it exists to reject.enforce_letter_update bond-open and not-archived
too; that would have meant three more rules for the mock to reimplement
exactly, and mock/database divergence has been this project's most recurring
defect. One receiver-only rule in the database is one rule to keep in sync.reactions table. That forecloses more than one person
reacting to a letter, more than one reaction type, and any history of
folding and unfolding — there is exactly one boolean-shaped fact, "has the
receiver folded this," and the schema says so.Not verified, and this matters more here than anywhere in this record.
Nobody on this project can see a rendered page — no task in this phase ran a
browser. That leaves genuinely unchecked: whether the fold reads as a dog-ear
at all rather than an unlabeled smudge, where it lands on the card and in the
sheet, and the layout at 375px. The Supabase adapter's
setAcknowledged has no test coverage and cannot get any here — the contract
suite runs against the mock only, and the SQL behind the real trigger is
unexecuted — so it is verified by reading the code, not by running it.
A final whole-branch review found two more issues, both fixed in that pass
rather than deferred. First: toLetter mapped acknowledgedAt: r.acknowledged_at
straight from the wire row with no fallback. A wire row is not a type
guarantee — if this client deploys before the SQL below runs, select('*')
returns rows with no acknowledged_at key at all, so the field comes back
undefined, and every !== null folded-check downstream reads undefined
as truthy. The symptom would not have been "folding is broken": it would have
been every letter, in every list and every modal, on both people's screens,
showing a folded corner that nobody made — for the whole window between the
two deploys. Fixed with r.acknowledged_at ?? null. Second: the toggle's
accessible name flipped between "Fold the corner" and "Unfold the corner" at
the same time aria-pressed flipped, so a screen reader announced a folded
letter as "Unfold the corner, toggle button, pressed" — the double negation
the ARIA authoring guidance warns against. Fixed by giving the button a
stable aria-label and letting aria-pressed alone carry the state. The tap-target inconsistency noted in the same review (roughly
30px against the 44px this record has cited elsewhere) was left as written —
matching the three existing buttons beside it is still the right call, and
that whole action row wants a pass at some point; see the carryover doc.
Migration status. supabase/schema.sql and supabase/policies.sql carry
the acknowledged_at column, the trigger's is distinct from clause, and the
UPDATE column grant, and are idempotent by inspection. They have not been
executed against the live project. Deploy order is not free here: this SQL
must run before this client deploys. With the ?? null fix above, a client
deployed before the SQL simply shows no letters as folded and folding itself
fails (the update targets a column that does not exist); without that fix,
the same window would instead have shown every letter as folded. Either way,
folding will not work until the migration runs — deploy the SQL first. Phases
6, 7 and 8 also remain unapplied.
A letter in this app is meant to be written slowly, and until this phase,
closing the tab destroyed whatever had been written so far. The composer now
remembers one unfinished letter per user, kept on the device in
localStorage, restored without ceremony when the composer reopens, with a
single quiet line saying where the words came from and a "Start fresh" way
out.
listHeld /
sendHeld: written with no bond, addressed to nobody, kept until sent) that
building both would give the app two overlapping answers to "an unsent
letter I come back to."LetterRepository, and so no mock-parity
work — the standing rule that the mock must reimplement whatever the
database enforces has nothing to reimplement this time, because the
database was never involved. src/lib/draft.ts is pure and framework-free,
reading and writing through an injected StorageLike interface rather than
touching localStorage directly, which is what makes it testable under this
project's logic-level-tests-only rule with no jsdom.validateScheduledFor would
reject it at send. Silently losing a picker selection on restore is a
smaller harm than silently restoring one that is now invalid.Compose.tsx reads and writes a draft; the ?edit=<id> branch is
untouched.dearjee:draft:<userId>, and the user id is not optional.
Two accounts on one device is not hypothetical in an app whose whole premise
is two people — an unkeyed draft would show one person's unsent words to
the other on a shared laptop.readDraft validates rather than trusts. localStorage is
user-writable and outlives deploys, so a stored entry is an untrusted string
from an unknown version of this app. Anything that isn't an object with a
string message, a string salutation, a string savedAt that parses as a
date, and a that passes the existing reads
back as no draft at all, rather than a crash or a garbled composer.Found by review — the two defects are the most interesting part of this phase:
useDraft's clear() that makes it cancel the pending write rather than
race it.ComposeLetter's reporting effect
fires once on mount, so merely opening the composer re-saved the restored
draft with a fresh savedAt. Since the notice reads "Unsent, from
<date>" off that field, an untouched draft would have drifted to
showing the day you last opened the composer rather than the day you last
wrote — visible only across days, and exactly the kind of thing that
ships. Fixed by comparing the incoming draft's content against what was last
saved or restored and preserving the prior savedAt when nothing actually
changed.Not verified. Nobody ran a browser for this phase. The notice's wording,
its placement above the paper, and the layout at 375px are all unverified.
The debounce, the flush paths, the suppression flag, and the whole of
Compose.tsx's wiring have no automated coverage, because this repo has no
component or hook tests and this phase did not add the first ones — the tests
cover src/lib/draft.ts only. Review found four minor findings in total.
Two were test-name gaps in draft.test.ts narrower than their names
suggested (the "missing or not a string" test only exercised the wrong-type
case, not a genuinely absent key; the "non-object" test covered JSON null
but not arrays or primitives) — both are fixed now, with the missing-key and
array/primitive cases added alongside a new test for an unparseable
savedAt. The other two are recorded rather than fixed: useDraft's
lastContent/lastSavedAt are not reset by clear() — confirmed safe by a
comment at the call site, since a blank message routes to clearDraft rather
than writeDraft and startFresh remounts the hook anyway, but still worth
a reader's second look before trusting on sight — and Compose.tsx's return
block has grown to around 25 lines carrying both the notice and the composer.
Fix wave (post-review). Two Important findings from the whole-branch
review were fixed. First, the debounce only ever flushed on React unmount,
which is in-app navigation — browsers do not run effect cleanups on an actual
tab close, so a real close or reload within 600ms of the last keystroke lost
that text, and iOS Safari backgrounds and discards tabs rather than
unmounting them as its normal path. useDraft.ts now also flushes on
pagehide and on visibilitychange going to 'hidden', and a new
MAX_DEBOUNCE_MS ceiling (5s) guarantees a write lands within a few seconds
of the first unsaved keystroke even if someone types fast enough to keep
resetting the 600ms debounce indefinitely. Second, the restore notice was
shown for the whole session once restored was non-null at mount, with no
way for it to go stale — so a person who restored a draft and then wrote ten
new minutes over it still saw "Unsent, from <original date>." next to a
"Start fresh" button that would silently discard the new words with no
confirmation, unlike the adjacent cancel flow's deliberate "Discard this
letter?" prompt. Compose.tsx now hides the notice itself the moment the
composer's content diverges from restored, via a stable useCallback
wrapper around save that ComposeLetter's reporting effect depends on.
Migration status. There is no SQL in this phase at all — nothing to apply. That is the first time that has been true in this project.
Full detail in docs/superpowers/specs/2026-09-08-phase-3-carryover.md.
Decided, 2026-09-10: the palette stands. --color-ink-muted (#8a8078) is
3.74:1 against the paper, below WCAG AA for the letter dates and word count.
The owner chose to keep it: the faded, low-contrast warmth is the product, and
those two elements are peripheral rather than the letter itself. Revisit only
if the app is ever used by someone who finds them illegible. The focus ring was
fixed regardless — an invisible keyboard indicator is a defect, not a style.
Resolved in Phase 3b: Letters used to be deleted along with their author.
Both letters foreign keys were on delete cascade, so one person could
erase the other's correspondence by deleting their account. Changed to on delete set null, and a before delete trigger on profiles freezes the
departing person's name and archives the survivor's side so the correspondence
survives and moves quietly away.
Resolved in Phase 6: Inbox.tsx used to hardcode receiverName="you"
regardless of who actually received the letter. recipientOf now resolves
the real name — the signed-in profile's name when you are the receiver, the
letter's frozen receiverName or the current partner's name otherwise.
Cosmetic: .env.example and supabase/README.md name the Supabase
dashboard path slightly differently.
break-words on the body). Archive, Delete, close and share all meet a
44px tap target instead of the previous ~16–34px.font-hand (Caveat) regardless,
because it stands for a hand signing a page rather than for the letter's
typesetting, and should stay constant across a correspondence.LetterCard renders its snippet in the letter's own face via fontStack,
without calling the lazy loader — a grid of twelve cards fetching every
family at once is exactly the cost the loader exists to avoid. The card
falls back to the stack's serif until the letter is actually opened.setAcknowledged originally carried .eq('receiver_id', userId)
alongside the trigger, on the reasoning that a client-side filter would turn
"someone else's letter" into a clean not-found instead of a database
exception. But that filter made a sender's fold attempt match zero rows
before the trigger ever ran, so production would have returned "Letter not
found." while the mock, reimplementing the same rule in TypeScript, said
"Only the person it was written to can fold it." — the same behaviour,
worded two different ways, on a path the mock-only contract suite can never
reach because it never talks to a real trigger. The filter was dropped so
the trigger is the sole enforcement and the mapped copy the trigger produces
is what actually surfaces in production.PaperTexture gained an ornament slot, and it's a containing-block
fix rather than a convenience. Decoration passed as an ordinary child
resolves against the padded inner content box and paints over the letter's
words, because a positioned descendant paints above in-flow text regardless
of source order; ornament renders before the padded wrapper so a folded
corner sits in the unpadded outer box instead.PublicLetter never passes ornament, and its type is unchanged — nothing
in that path even has a slot to put a fold in. The contract suite's
existing getBySlug key-set assertion is what keeps this true going
forward: it would fail the day acknowledgedAt leaked into the public
shape, without anyone having to write a fold-specific test for it.bodyFontvalidateBodyFontsrc/hooks/useDraft.ts holds
the debounce, the unmount and tab-close flush, the suppression flag, and the
savedAt-preservation logic — around 50 lines of timing behaviour the plan
judged did not belong inlined into a route component. Compose.tsx wires
the hook up and owns the notice; useDraft.ts owns everything about
when a draft actually gets written.