Personal moodboard web app for assembling clothing purchases on a free-form canvas.
Paste a product URL, an LLM extracts the title, brand, price, and images, and a draggable card lands on a pannable, zoomable canvas. Items snap into place with spring physics, expand to focus when clicked, and drag at near-native latency. The point is to think about a wardrobe spatially — group outfits, compare alternatives, sit with a purchase before pulling the trigger — instead of juggling browser tabs.
Interaction feel is treated as a first-class goal, not polish. Optimistic UI everywhere: cards appear immediately on add, drags commit fire-and-forget, deletes roll back on failure.
Current state: canvas (pan/zoom/drag/expand), URL parse → R2 → board item, optimistic add/delete, position sync, end-to-end types via tRPC. No auth, no multi-board UI, no production deploy yet.
| Layer | Choice |
|---|---|
| Frontend | React 19, Vite, TypeScript |
| Styling | Tailwind v4 (@tailwindcss/vite, no config file) |
| Animation | Framer Motion (springs, layoutId for expand-to-focus) |
| Gesture | @use-gesture/react |
| Client state | Zustand (interaction only — multi-select ids/primaryId, z-indices) |
| Server state | TanStack Query v5 |
| API | tRPC over Hono on Cloudflare Workers |
| DB | Cloudflare D1 (SQLite) + Drizzle ORM |
| Image storage | Cloudflare R2, streamed via GET /api/images/* (not tRPC) |
| AI | Anthropic Claude Haiku claude-haiku-4-5-20251001 (direct fetch, no SDK) |
| Validation | Zod 4 |
pnpm workspaces monorepo.
web/ React frontend
worker/ Hono + tRPC backend on Cloudflare Workers
The frontend imports AppRouter from worker/src/router for end-to-end types — keep that path working. Each workspace has its own CLAUDE.md with layering rules and conventions; CLAUDE.md at the root has the architectural invariants.
Requires Node, pnpm via corepack, and an Anthropic API key.
# 1. Put ANTHROPIC_API_KEY in worker/.dev.vars (gitignored)
# 2. From the repo root:
pnpm install
pnpm db:migrate:local # apply migrations to local D1
pnpm db:seed:local # seed board-1 with 3 placeholder items
pnpm dev # web (5173) + worker (8787) concurrentlyThen open the Vite URL printed in the terminal. The frontend reads the active board from localStorage (activeBoardId) — the seed creates board-1.
Run pnpm dev:web or pnpm dev:worker to start only one.
All scripts work from the repo root or from inside a workspace. Bare names hit the natural target; :web / :worker suffixes pin a workspace.
| Script | What it does |
|---|---|
dev |
web + worker, concurrently |
build |
web production build |
preview |
serve the built frontend |
lint |
typecheck + ESLint across workspaces |
test |
vitest across workspaces |
deploy |
wrangler deploy (worker) |
db:generate |
new Drizzle migration (-- --name <desc>) |
db:migrate |
apply migrations to remote D1 |
db:migrate:local |
apply migrations to local D1 |
db:seed:local |
apply seed.sql to local D1 |
db:studio |
drizzle-kit studio UI |
check-app.spec.ts at the repo root is a Playwright smoke check (loads the canvas, screenshots, counts cards). Run with pnpm exec playwright test.
- TypeScript strict mode everywhere.
- Prettier with
prettier-plugin-organize-importsandprettier-plugin-tailwindcss— don't hand-sort imports. - Husky + lint-staged run
eslint --fix --max-warnings=0on staged.ts/.tsxand Prettier on the rest. Don't bypass with--no-verify. - File naming: components
PascalCase.tsx, hooksuseCamelCase.ts, everything elsecamelCase.ts. - Comments only when the why is non-obvious; no emojis in code.
.npmrcenforcesminimum-release-age=4320(3 days) as a supply-chain guard. New deps that need install-time native compilation must be added topnpm-workspace.yaml'sallowBuilds.
These are load-bearing — see the per-workspace CLAUDE.md files for the long form.
Frontend
web/src/api/*.ts— pure async wrappers around the tRPC client. No React.web/src/hooks/*.ts— TanStack Query hooks. All server state lives here.web/src/store/*.ts— Zustand, interaction state only. Never server data.- Position PATCH on drag end is fire-and-forget; Framer Motion already shows the right position.
- URL add uses an optimistic skeleton card (id prefix
__skeleton__) injected inonMutate, swapped inonSuccess, rolled back inonError.
Backend
- tRPC routers in
worker/src/routers/are thin — parse input, delegate toworker/src/services/. - Services take
Db(drizzle) as a parameter, not the request context. - Image bytes never go through tRPC — they stream from R2 via
GET /api/images/*. - URL parsing is two-stage: a deterministic extractor (
parser/candidates.ts) builds a scored, numbered image-candidate pool from og tags, JSON-LD, microdata, raw script-JSON scans, and<img>tags (with related/recommendation containers penalized); Claude Haiku then reads a structured evidence document and selects candidate indices rather than emitting URLs, so it cannot hallucinate. Static stage works without the API; failures are covered by a fixture-driven test suite (pnpm test:evalruns the Claude path against real pages). Seeworker/CLAUDE.mdfor the spec.