CLAUDE.md #
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview #
Leaflet is a full-stack TypeScript web app for shared writing and social publishing, built on Bluesky (AT Protocol). Users create collaborative documents ("Leaflets") and publish them as blog posts/newsletters ("Publications") that others can follow.
Tech Stack #
- Frontend: React 19 + Next.js 16 (App Router, Turbopack)
- Database: PostgreSQL via Supabase + Drizzle ORM
- Real-time sync: Replicache for optimistic updates and offline support
- Editor: ProseMirror with Yjs CRDT
- Social: AT Protocol (@atproto packages) for Bluesky integration
- Jobs: Inngest for async serverless functions
Commands #
npm run dev # Start Next.js dev server (port 3000)
npm run start-appview-dev # Start AT Protocol appview service
npm run start-feed-service-dev # Start feed subscription service
npx tsc # TypeScript type checking (used in CI)
npm run generate-db-types # Regenerate Supabase types after schema changes
npm run lexgen # Regenerate AT Protocol types from lexicons
Architecture #
Data Flow #
- Client mutations go through Replicache (
src/replicache/mutations.ts) for optimistic local updates - Server actions in
actions/persist changes to Supabase - CVR sync reconciles client and server state via Replicache
Document Model #
Documents are composed of blocks (text, image, embed, code, poll, etc.). Block components live in components/Blocks/. Blocks are rendered in linear lists by components/Blocks/index.tsx and in an xy canvas by components/Canvas.tsx.
AT Protocol / Bluesky #
- Lexicons (
lexicons/) define schemas for reading and writing records to the AT Protocol network and users' PDSs - Appview (
appview/) consumes the firehose to index published content - Feeds (
feeds/) provides subscription feeds for publications
Lexicon Workflow #
The source of truth for lexicon schemas is the TypeScript files in lexicons/src/ (e.g. facet.ts, publication.ts, blocks.ts). The JSON files in lexicons/pub/ are generated output — do not edit them directly, as npm run lexgen will overwrite them.
To add or modify a lexicon:
- Edit the relevant source file in
lexicons/src/ - Run
npm run lexgento regenerate both the JSON schemas and the TypeScript API types inlexicons/api/
Key Directories #
app/- Next.js App Router pages and API routesactions/- Server actions (mutations, auth, subscriptions)components/- React componentssrc/- Shared utilities, hooks, Replicache setupappview/- AT Protocol firehose consumer for indexingfeeds/- Feed service for publication subscriptionslexicons/- AT Protocol schema definitionssupabase/migrations/- Database schema
Patterns #
- Server actions: Export async functions with
'use server'directive, returnResult<T>fromsrc/result.ts - Replicache mutations: Named handlers in
src/replicache/mutations.ts, keep server mutations idempotent. When adding one:- Move/replace a fact with a single
assertFactthat reuses the existing fact id — neverretractFact(id)thenassertFact({id}). The retract+assert pair records two undo entries with an intermediate state where the fact is gone, so undo/redo flickers the block out of existence; oneassertFact(cardinality-many+ explicit id) captures the old value as a single clean undo entry. ReserveretractFactfor genuine deletions (usually paired withdeleteEntity). - Group a multi-mutation user action into one undo step with
undoManager.withUndoGroup(async () => { ... }), awaiting every mutation inside — undo entries are recorded asynchronously in mutator bodies, so a fire-and-forget or un-awaited mutation lands as its own separate Cmd-Z step. - Pass
ignoreUndo: truefor derived/follow-up writes that shouldn't be independently undoable (e.g. async link/image enrichment after a paste, the yjsblock/textdoc persistence).
- Move/replace a fact with a single
- React contexts:
DocumentProvider,LeafletContentProviderfor page-level data - Inngest functions: Async jobs in
app/api/inngest/functions/ - Icons: Icon components live in
components/Icons/. Each icon is a named export in its own file (e.g.RefreshSmall.tsx), importsPropsfrom./Props, spreads{...props}on the<svg>element, and usesfill="currentColor"instead of hardcoded colors likefill="black". - Popovers and menus: Use the existing
Popover(components/Popover),Menu, andMenuItem(components/Menu) components — do not create new popover/menu primitives. Avoid using buttons inside Popover or Modal triggers unless using a specific button component. If button is being used as trigger, always add an "asChild" prop to the Menu or Popover - Supabase query plans: Never combine
.order()on a table's own column with.limit()and a row-restricting embed (!inneror an embed-path filter) in one query — PostgREST compiles the embed into a LATERAL join that forces Postgres to walk the order column's index across the whole table probing the embed per row (this has caused repeated production full-table scans). Filter on the from-table's own indexed columns, start the query from the join table, or use a fenced SQL function for newest-N-across-a-join (examples:get_publication_feed_docs,get_tag_page_document_uris). CI enforces this vianpm run check-query-plans; the fix recipes andplan-checked:suppression rules are documented inscripts/check-supabase-query-plans.mjs. - Default theme: The default theme colors (accent/background/primary/highlights) are hardcoded in several disconnected places (
themeDefaults.ts,themeUtils.ts,app/globals.css:root,emails/shared.tsx,PubPresetPicker.tsx) with no shared import, so they drift easily. When changing any default color, use theupdate-default-themeskill (.claude/skills/update-default-theme/) which lists every place to update and keeps them in sync. - Custom domain settings: Custom domains are listed, verified, and assigned in three disconnected surfaces (profile
settings/domains, publication dashboardPubDomainSettings.tsx, editor share menuShareOptions/DomainOptions.tsx) that each hand-roll their own row UI over the same data. When changing domain row UI or behavior in any one of them, use thedomain-settingsskill (.claude/skills/domain-settings/), which lists the shared pieces and requires asking whether the change applies to the other two. - Publication settings: A setting in the General tab passes through several hand-maintained explicit field lists (both lexicons,
normalizePublication, both builders inbuildPublicationRecord.ts, four spots inSettingsContent.tsx,createPublication.ts) — none of them spread, so a field missing from one is dropped with no type error. When adding or changing a setting there, use thepublication-settingsskill (.claude/skills/publication-settings/), which walks the round trip and how to verify it saves and applies.
Comments #
Don't describe in a comment what the code already says, and don't capture counterfactuals — comments shouldn't be hysteretic. Don't narrate what changed, what it used to do, what you considered and rejected, or why something isn't done another way. Comment only on the non-obvious why that the code itself can't convey.
This is NOT the Next.js you know #
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.