···17171818### DID cookie (`src/routes/callback.tsx`)
19192020-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`.
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.
21212222-### Feed loader (`src/routes/feed.tsx`)
2222+**Important:** the DID is set as-is (no `encodeURIComponent`) — colons are valid in cookie values, and encoding them causes `client.restore()` to fail because the session is stored under the plain DID (`did:plc:...`) but the cookie would contain `did%3Aplc%3A...`.
23232424-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.
2424+### Auth-aware header + logout (`src/components/Header.tsx`, `src/routes/logout.tsx`, `src/routes/__root.tsx`)
25252626-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]: {} }`).
2626+- `__root.tsx` uses a `shellComponent`/`component` split: `RootDocument` is the HTML-only shell, `RootLayout` is where `Route.useLoaderData()` works. The root loader calls a `getAuthState` server fn that reads the `did` cookie.
2727+- `Header` accepts `did: string | null` — shows Feed link + Sign out button only when logged in.
2828+- `logout.tsx` is a server POST handler that sets `did=; Max-Age=0` and redirects to `/login`.
27292828-### Feed UI polish (`src/routes/feed.tsx`)
3030+### Feed UI — reply parents (`src/routes/feed.tsx`)
29313030-- Cards now use `.island-shell` (frosted glass style) instead of bare `border rounded-lg`
3131-- Outer wrapper uses `bg-[--bg-base]` for the page background color token
3232+- Each feed item now includes a `reply` field (mapped from `item.reply?.parent`, cast to known shape, discriminated by `$type === 'app.bsky.feed.defs#postView'`).
3333+- Feed cards are wrapped in a `<Link to="/post/$uri">` for navigation to the thread view.
3434+- When `item.reply` is non-null, a parent snippet is shown above the main post: left-bordered block with the parent author + truncated text, followed by an `↩ replying to @handle` kicker label.
3535+- If `client.restore(did)` throws (expired/missing session), the `did` cookie is cleared and the user is redirected to `/login` cleanly.
3636+3737+### Thread route stub (`src/routes/post.$uri.tsx`)
3838+3939+Created as a stub — renders "Thread view coming soon" with the decoded URI. Full implementation is the next task.
4040+4141+### Biome formatter (`biome.json`)
4242+4343+Configured project-wide: tabs, single quotes, no semicolons, `organizeImports` assist. `src/routeTree.gen.ts` is excluded via `files.includes` negation pattern. All files are currently lint/format clean.
32443345## Current state of key files
34463547```
3636-src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables
3737-src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore
3838-src/routes/callback.tsx ← sets DID cookie, redirects to /feed
3939-src/routes/feed.tsx ← createServerFn fetches timeline; island-shell card UI
4040-src/routes/login.tsx ← plain unstyled form; working, needs polish
4141-src/routes/__root.tsx ← shell with Header + Footer components, devtools, theme init script
4242-src/components/Header.tsx ← sticky nav with "Feed" link chip; uses design tokens
4343-src/components/Footer.tsx ← copyright + "Built with TanStack Start" line
4444-src/styles.css ← Tailwind v4 + custom design system (see below)
4848+src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables
4949+src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore
5050+src/routes/callback.tsx ← sets DID cookie (plain, not encoded), redirects to /feed
5151+src/routes/logout.tsx ← POST handler, clears did cookie, redirects to /login
5252+src/routes/feed.tsx ← createServerFn fetches timeline + reply parents; island-shell cards with Link wrapper
5353+src/routes/post.$uri.tsx ← stub thread view route (not yet implemented)
5454+src/routes/login.tsx ← styled island-shell card with Fraunces heading
5555+src/routes/__root.tsx ← shellComponent/component split; root loader for auth state
5656+src/components/Header.tsx ← auth-aware sticky nav (Feed link + Sign out when logged in)
5757+src/components/Footer.tsx ← copyright + "Built with TanStack Start" line
5858+src/styles.css ← Tailwind v4 + custom design system (see below)
5959+biome.json ← formatter/linter config
4560```
46614762## Design system
···71867287**Fonts**: Manrope (sans body), Fraunces (serif display)
73887474-## Remaining UI polish (carry-over)
7575-7676-These were not finished last session:
7777-7878-**`src/routes/feed.tsx`:**
7979-- `text-gray-500` on the handle should be `text-[--sea-ink-soft]` (hardcoded color, won't respect dark mode)
8080-- No timestamp — add `createdAt: (item.post.record as { createdAt?: string }).createdAt ?? null` to the mapped return in `getTimeline`, then render it
8181-- No avatar fallback for posts where `author.avatar` is null
8282-- No `pendingComponent` or `errorComponent` on the route
8383-8484-**`src/routes/login.tsx`:**
8585-- Form has zero styling — needs centered card layout, `.island-shell`, lagoon accent on the button
8686-8787-**`src/routes/__root.tsx`:**
8888-- Page title is still "TanStack Start Starter" — should be "Dudesky"
8989-9090-## Before threading: two features to add
9191-9292-### 1. Images in posts
9393-9494-Bluesky posts can have image embeds. The embed lives at `item.post.embed` — when it's an image embed, the type is `app.bsky.embed.images#view` and it has an `images` array where each entry has `thumb` (URL), `fullsize` (URL), and `alt` (string).
8989+## Done: threaded reply views ✓
95909696-Add `images` to the mapped return in `getTimeline`:
9191+Both the feed reply-parent UI and the full thread view are implemented and Playwright-verified.
97929898-```ts
9999-images: (item.post.embed as { images?: { thumb: string; fullsize: string; alt: string }[] } | undefined)?.images ?? []
100100-```
9393+### Feed reply parent UI (`src/routes/feed.tsx`) ✓
10194102102-Then render them below the post text — a simple responsive grid of `<img>` tags with `src={thumb}` and `alt={alt}` works for starters.
9595+Visually verified — reply posts show a left-bordered parent snippet above the main post card.
10396104104-### 2. Link previews
105105-106106-Bluesky posts can have an external embed (link preview card) at `item.post.embed`. Discriminate on `$type` — the value for a link preview is `'app.bsky.embed.external#view'`. The embed's `external` object has `uri`, `title`, `description`, and optionally `thumb` (image URL).
107107-108108-Add `linkPreview` to the mapped return:
9797+### Thread view (`src/routes/post.$uri.tsx`) ✓
10998110110-```ts
111111-const embed = item.post.embed as { $type?: string; external?: { uri: string; title: string; description: string; thumb?: string } } | undefined
112112-linkPreview: embed?.$type === 'app.bsky.embed.external#view' ? (embed.external ?? null) : null
113113-```
9999+- `getThread` server fn calls `agent.getPostThread()`, walks `parent` chain root-first into `ancestors[]`, collects direct `replies[]`
100100+- Ancestors stack above focal post (dimmed, each links to its own thread), connector lines between sections
101101+- Focal post shown prominently with larger avatar and full timestamp
102102+- Replies listed below with same card style as feed
103103+- Navigation between thread levels verified end-to-end
114104115115-Render as a card below the post text — thumbnail on the left, title + description + domain on the right, full card is a link to `uri`. Use `.island-shell` or a bordered inset style.
105105+## Next: images + link previews
116106117117-Note: a post has at most one embed — it's images OR a link preview, not both. Use `$type` to discriminate: `'app.bsky.embed.images#view'` for images, `'app.bsky.embed.external#view'` for link previews.
107107+**Images:** `item.post.embed` with `$type === 'app.bsky.embed.images#view'` has an `images` array (`{ thumb, fullsize, alt }`). Map as `images` in the server fn, render below post text as a responsive grid.
118108119119-## Next: threaded reply views
109109+**Link previews:** `$type === 'app.bsky.embed.external#view'` has `external: { uri, title, description, thumb? }`. Render as a bordered card below post text; full card is a link to `uri`.
120110121121-Goal: clicking a post opens a thread view showing the post, its ancestors (parent chain), and its replies.
122122-123123-### ATProto API
124124-125125-`agent.getPostThread({ uri })` returns a `ThreadViewPost` which has:
126126-- `post` — the post itself
127127-- `parent` — `ThreadViewPost | NotFoundPost | BlockedPost | undefined` (walk up for ancestors)
128128-- `replies` — `ThreadViewPost[] | ...` (direct replies)
129129-130130-The `uri` for each post is already in the mapped feed data as `item.uri`.
131131-132132-### Suggested route
133133-134134-`src/routes/post.$uri.tsx` — file-based dynamic route. The `uri` param will be the AT URI, but since it contains `/` characters (`at://did:plc:.../app.bsky.feed.post/...`) it needs to be encoded when used as a URL segment (use `encodeURIComponent` / `decodeURIComponent`).
135135-136136-### Data shape to map in the server fn
137137-138138-Same `createServerFn` pattern as `feed.tsx`. Map to a plain serializable object — don't pass raw ATProto types through. Suggested shape:
139139-140140-```ts
141141-{
142142- post: { uri, text, author, createdAt },
143143- ancestors: Array<{ uri, text, author, createdAt }>, // ordered root-first
144144- replies: Array<{ uri, text, author, createdAt }>,
145145-}
146146-```
147147-148148-Walk `parent` recursively to build `ancestors`, collect `replies` array directly.
111111+A post has at most one embed — images OR link preview, not both. Discriminate by `$type`.
149112150113## Conventions
151114152115- Path alias `#/` maps to `src/`
153153-- No semicolons in `.ts`/`.tsx` files
116116+- No semicolons in `.ts`/`.tsx` files (Biome enforced)
117117+- Tabs for indentation, single quotes (Biome enforced)
154118- 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)
155119- `createServerFn` for any server-only code called from loaders (DB, cookies, secrets)
120120+- ATProto type narrowing: cast `item.reply?.parent` to a local interface, then discriminate by `$type` — `in` checks don't narrow the union reliably
156121- Env vars: `VITE_APP_URL`, `PRIVATE_KEY_0/1/2`, optionally `DB_PATH`
157122- Read AGENTS.md and load the relevant SKILL.md files before working on TanStack-related tasks
158123- Playwright is configured for WSL2 with `--no-sandbox` in `~/.claude/plugins/cache/claude-plugins-official/playwright/unknown/.mcp.json`
159159-- To inject auth into the headless browser: set cookie `did=<url-encoded-did>` on `localhost:3001` via `browser_evaluate`, then navigate to `/feed`
124124+- Dev server runs on port 3001 (3000 is taken by something else)
125125+- **Playwright auth workaround:** after user logs in manually and lands on `/feed`, take a screenshot immediately — do NOT navigate away first, as subsequent navigations lose the session