···11+# Claude Instructions
22+33+## Skill Mappings
44+55+When working on TanStack-related tasks, read [AGENTS.md](AGENTS.md) and load the relevant SKILL.md files listed there before proceeding.
+95
HANDOFF.md
···11+# Handoff: UI polish — feed, login, and shell
22+33+## What this project is
44+55+Dudesky is a Bluesky client built with TanStack Start (React, SSR, file-based routing). It uses ATProto OAuth to authenticate users via their Bluesky handle. Stack: TanStack Start + Vite, Tailwind CSS v4, `@atproto/oauth-client-node` for OAuth, `better-sqlite3` for persistence.
66+77+## What was built last session
88+99+### Database-backed OAuth stores (`src/lib/db.ts`, `src/lib/oauth-client.ts`)
1010+1111+Replaced the in-memory `stateStore` / `sessionStore` with SQLite via `better-sqlite3`. DB file is `dudesky.db` in the project root (path overridable via `DB_PATH` env var). Two tables:
1212+1313+- `oauth_state` — short-lived PKCE/DPoP state, expires after 10 minutes (TTL enforced on each `set`)
1414+- `oauth_session` — long-lived token store keyed by DID
1515+1616+Both stores serialize ATProto state objects as JSON text.
1717+1818+### DID cookie (`src/routes/callback.tsx`)
1919+2020+After `client.callback()` succeeds, sets a `did` cookie via a mutable `new Response(null, { headers: { 'Set-Cookie': ..., 'Location': ... } })`. **Do not use `Response.redirect()` here** — it creates a response with immutable headers, which crashes TanStack Start's `mergeEventResponseHeaders` when it tries to attach queued cookies. The DID is not sensitive on its own; actual tokens live in `sessionStore`.
2121+2222+### Feed loader (`src/routes/feed.tsx`)
2323+2424+Uses `createServerFn` (not a raw loader) for all server-only work — `getCookie`, `client.restore()`, DB access via the OAuth client, and `agent.getTimeline()`. This is required because TanStack Start loaders are isomorphic (run on both client and server); `better-sqlite3` is Node-only and will crash in the browser if imported directly in a loader.
2525+2626+The server function returns a mapped subset of the feed data (not raw ATProto types) to avoid a type incompatibility with `createServerFn`'s serialization constraints (`{ [x: string]: unknown }` vs `{ [x: string]: {} }`).
2727+2828+## Current state of key files
2929+3030+```
3131+src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables
3232+src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore
3333+src/routes/callback.tsx ← sets DID cookie, redirects to /feed
3434+src/routes/feed.tsx ← createServerFn fetches timeline; basic card UI (needs polish)
3535+src/routes/login.tsx ← plain unstyled form; working, needs polish
3636+src/routes/__root.tsx ← shell with Header + Footer components, devtools, theme init script
3737+src/components/Header.tsx ← sticky nav with "Feed" link chip; uses design tokens
3838+src/components/Footer.tsx ← copyright + "Built with TanStack Start" line
3939+src/styles.css ← Tailwind v4 + custom design system (see below)
4040+```
4141+4242+## What needs to be done: UI cleanup
4343+4444+The auth flow and data loading all work end-to-end. The feed renders but looks rough. The next session is purely visual polish — no logic changes needed.
4545+4646+### Design system already in place
4747+4848+`src/styles.css` has a full set of CSS custom properties and utility classes to use:
4949+5050+**Color tokens** (light + dark + `prefers-color-scheme` variants):
5151+- `--sea-ink` / `--sea-ink-soft` — primary text colors
5252+- `--lagoon` / `--lagoon-deep` — teal accent
5353+- `--palm` — green accent
5454+- `--sand` / `--foam` / `--bg-base` — background layers
5555+- `--surface` / `--surface-strong` — card/panel backgrounds
5656+- `--line` — borders
5757+- `--inset-glint` — inner highlight on cards
5858+- `--header-bg` / `--chip-bg` / `--chip-line` — header/chip-specific
5959+6060+**Utility classes**:
6161+- `.page-wrap` — centered container, `min(1080px, calc(100% - 2rem))`
6262+- `.island-shell` — frosted glass card style (border + gradient bg + box-shadow + backdrop-blur)
6363+- `.island-kicker` — small-caps label style
6464+- `.nav-link` — link with animated underline
6565+- `.display-title` — Fraunces serif font
6666+- `.rise-in` — entrance animation (opacity + translateY, 700ms)
6767+- `.feature-card` — card with hover lift
6868+6969+**Fonts**: Manrope (sans body), Fraunces (serif display)
7070+7171+### Specific things to polish
7272+7373+**`src/routes/feed.tsx` — the main job:**
7474+- Post cards are bare `border rounded-lg` — should use `.island-shell` and the design token colors
7575+- Avatar images have no fallback for missing avatars
7676+- No timestamp on posts (it's available: `(item.post.record as { createdAt?: string }).createdAt`)
7777+- The feed data shape currently mapped in `getTimeline` only extracts `uri`, `text`, `author.{handle,displayName,avatar}` — if you need `createdAt` or other fields, add them to the mapped return object in the server function
7878+- No loading/pending state (`pendingComponent` on the route)
7979+- No error state (`errorComponent` on the route)
8080+8181+**`src/routes/login.tsx` — unstyled:**
8282+- The form (`<input>`, `<button>`) has zero styling
8383+- Should match the app's aesthetic — centered card layout, `.island-shell`, lagoon accent on the button
8484+8585+**`src/routes/__root.tsx` — minor:**
8686+- Page title is still "TanStack Start Starter" — should be "Dudesky" or similar
8787+8888+## Conventions
8989+9090+- Path alias `#/` maps to `src/`
9191+- No semicolons in `.ts`/`.tsx` files
9292+- Server route handlers return raw `Response` objects — use `new Response(null, { status: 302, headers: {...} })` for redirects, NOT `Response.redirect()` (immutable headers) and NOT TanStack Router's `redirect()` (doesn't work in server handlers)
9393+- `createServerFn` for any server-only code called from loaders (DB, cookies, secrets)
9494+- Env vars: `VITE_APP_URL`, `PRIVATE_KEY_0/1/2`, optionally `DB_PATH`
9595+- Read AGENTS.md and load the relevant SKILL.md files before working on TanStack-related tasks
···11+import Database from 'better-sqlite3'
22+import path from 'node:path'
33+44+const DB_PATH = process.env.DB_PATH ?? path.join(process.cwd(), 'dudesky.db')
55+66+const db = new Database(DB_PATH)
77+88+db.pragma('journal_mode = WAL')
99+db.pragma('foreign_keys = ON')
1010+1111+db.exec(`
1212+ CREATE TABLE IF NOT EXISTS oauth_state (
1313+ key TEXT PRIMARY KEY,
1414+ value TEXT NOT NULL,
1515+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
1616+ );
1717+1818+ CREATE TABLE IF NOT EXISTS oauth_session (
1919+ did TEXT PRIMARY KEY,
2020+ value TEXT NOT NULL,
2121+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
2222+ );
2323+`)
2424+2525+export { db }
+32-20
src/lib/oauth-client.ts
···11import { JoseKey, NodeOAuthClient, type NodeSavedState, type NodeSavedSession } from '@atproto/oauth-client-node'
22-33-interface SessionStore {
44- [index: string]: NodeSavedSession
55-}
66-77-interface StateStore {
88- [index: string]: NodeSavedState
99-}
22+import { db } from '#/lib/db'
1031111-const sessionStore: SessionStore = {}
1212-const stateStore: StateStore = {}
44+// OAuth PKCE/DPoP state is only needed for the ~10 minutes between /login and
55+// /callback, so we prune anything older than 10 minutes on each set.
66+const STATE_TTL_SECONDS = 600
137148const rootUrl = process.env.VITE_APP_URL
159···3529 ]),
36303731 stateStore: {
3838- async set(key: string, internalState: NodeSavedState): Promise<void> {
3939- stateStore[key] = internalState
3232+ async set(key: string, value: NodeSavedState): Promise<void> {
3333+ db.prepare(`
3434+ INSERT INTO oauth_state (key, value, created_at)
3535+ VALUES (?, ?, unixepoch())
3636+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at
3737+ `).run(key, JSON.stringify(value))
3838+3939+ // Prune expired state entries
4040+ db.prepare(`DELETE FROM oauth_state WHERE created_at < unixepoch() - ?`)
4141+ .run(STATE_TTL_SECONDS)
4042 },
4143 async get(key: string): Promise<NodeSavedState | undefined> {
4242- return stateStore[key]
4444+ const row = db.prepare(`
4545+ SELECT value FROM oauth_state
4646+ WHERE key = ? AND created_at >= unixepoch() - ?
4747+ `).get(key, STATE_TTL_SECONDS) as { value: string } | undefined
4848+ return row ? JSON.parse(row.value) : undefined
4349 },
4450 async del(key: string): Promise<void> {
4545- delete stateStore[key]
5151+ db.prepare(`DELETE FROM oauth_state WHERE key = ?`).run(key)
4652 },
4753 },
48544955 sessionStore: {
5050- async set(sub: string, session: NodeSavedSession): Promise<void> {
5151- sessionStore[sub] = session
5656+ async set(did: string, value: NodeSavedSession): Promise<void> {
5757+ db.prepare(`
5858+ INSERT INTO oauth_session (did, value, updated_at)
5959+ VALUES (?, ?, unixepoch())
6060+ ON CONFLICT(did) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
6161+ `).run(did, JSON.stringify(value))
5262 },
5353- async get(sub: string): Promise<NodeSavedSession | undefined> {
5454- return sessionStore[sub]
6363+ async get(did: string): Promise<NodeSavedSession | undefined> {
6464+ const row = db.prepare(`SELECT value FROM oauth_session WHERE did = ?`)
6565+ .get(did) as { value: string } | undefined
6666+ return row ? JSON.parse(row.value) : undefined
5567 },
5656- async del(sub: string): Promise<void> {
5757- delete sessionStore[sub]
6868+ async del(did: string): Promise<void> {
6969+ db.prepare(`DELETE FROM oauth_session WHERE did = ?`).run(did)
5870 },
5971 },
6072})