AGENTS.md
<!-- BEGIN:nextjs-agent-rules -->Next.js: ALWAYS read docs before coding
Before any Next.js work, find and read the relevant doc in node_modules/next/dist/docs/. Your training data is outdated — the docs are the source of truth.
Project
Personal portfolio site for Tommy Chow. Dark mode only.
Commands
pnpm dev # Start development server
pnpm build # Production build (auto-runs gallery via prebuild)
pnpm start # Start production server (Node.js)
pnpm gallery # Regenerate gallery manifest (run when images change)
pnpm preview # Build and preview on local Cloudflare Workers
pnpm deploy # Build and deploy to Cloudflare Workers
pnpm upload # Build and upload to Cloudflare Workers (no deploy)
pnpm cf-typegen # Generate CloudflareEnv types from wrangler.jsonc
pnpm lint # Run ESLint
pnpm typecheck # TypeScript type checking (tsc --noEmit)
pnpm format # Format with Prettier
pnpm check # Full check: typecheck + lint + build
pnpm ui:update # Regenerate all shadcn components to latest
pnpm clean # Delete .next, .open-next, and node_modules
pnpm nuke # Delete .next, .open-next, node_modules, and pnpm-lock.yaml
Architecture
Next.js 16 App Router with React 19. Deployed on Cloudflare Workers via @opennextjs/cloudflare.
Runtime: Node.js >= 22, pnpm 10
Key Configuration
- React Compiler: Enabled for automatic memoization
- Typed Routes: Enabled for type-safe
hrefprops - Path Alias:
@/*maps to./src/* - Strict TypeScript:
noUncheckedIndexedAccess,noImplicitReturns,noFallthroughCasesInSwitch,noImplicitOverride,verbatimModuleSyntax
Source Structure
src/app/- App Router pages and layoutssrc/components/- React components (ui/subdirectory for shadcn — add withpnpm dlx shadcn@latest add <component>)- Only the components actually in use live in
src/components/ui/(currentlybutton.tsxandpopover.tsx). Add more as needed. <Button>has noasChildprop (Base UI, not Radix).
- Only the components actually in use live in
src/lib/- Utilities (cn()for className merging), constants, and server-only codesrc/hooks/- Custom React hooks
Cloudflare Workers
wrangler.jsonc- Cloudflare Workers configuration (bindings, R2, services, etc.)open-next.config.ts- OpenNext adapter configcloudflare-env.d.ts- Generated types for Cloudflare bindings (runpnpm cf-typegento regenerate)
Environment
SITE_URL— Base URL for the site. Declared inwrangler.jsonc, defaults tohttp://localhost:3000for local dev. Used insrc/lib/constants.tsformetadataBase, sitemap, and robots.txt.src/env.d.tsnarrowsprocess.env.SITE_URLtostring | undefined. Without it, the auto-generatedcloudflare-env.d.tstypes it as a literal fromwrangler.jsonc, which makes the??fallback look like dead code to TS even though it's needed inpnpm dev. Don't declare secrets inwrangler.jsonc(committed to git) — set production secrets via Cloudflare dashboard orpnpx wrangler secret put.
Images
- Static/known images (the gallery): pre-generate webp variants at build time via
sharpinscripts/generate-gallery-manifest.tsand use plain<img srcset>. Don't switch tonext/imagewith the Cloudflare IMAGES binding for these — it bills per-call with no dedup and/_next/imageresponses aren't edge-cached without a Cache Rule. - Dynamic/user-uploaded images (none today): would require uncommenting the IMAGES binding in
wrangler.jsoncand configuring a Cache Rule for/_next/image*in the Cloudflare dashboard (Caching → Cache Rules → Edge TTL override 1 year). Without it, every cache miss re-bills.
Gallery System
Images in public/gallery/images/ are processed by pnpm gallery into src/lib/gallery-manifest.json using sharp and thumbhash. The manifest is committed to git and re-exported by src/lib/server-utils.ts.
Key Libraries
- nuqs — Type-safe URL search params (
useQueryState,useQueryStates) - motion — Animation library (Framer Motion v12+). Import from
motion/react(e.g.,import { motion, useMotionValue } from 'motion/react'), notframer-motion - react-medium-image-zoom — Zoomable images in the gallery
- lucide-react — Icons;
react-icons/fa6for brand icons (FaGithub,FaLinkedin)
Code Style
Enforced by pnpm lint (ESLint) and pnpm format (Prettier). Project-specific conventions beyond the global code style:
- Inline type imports (
import { type Foo }) — enforced byconsistent-type-importswithfixStyle: 'inline-type-imports' - Prefix unused variables with
_—no-unused-varswhitelists the^_pattern - Prettier auto-sorts imports and Tailwind classes — don't sort manually
- Use
interfacefor component props, colocated directly above the component (interface FooProps { ... }) - Use
cn()from@/lib/utilsfor conditional/merged classes (combinesclsx+tailwind-merge); usetwJoinfromtailwind-mergewhen you just need to concatenate static strings without merging conflicts - Colors via CSS custom properties:
--foreground,--background,--muted-foreground, etc.
Gotchas
- Dark mode only: App uses a dark-first design — don't introduce light-mode specific assumptions
- shadcn uses @base-ui/react: Not Radix UI — imports differ from older shadcn examples, and most components don't expose
asChild useSearchParams()needs Suspense: Always wrap components usinguseSearchParams()in a<Suspense>boundary — required for production builds- Never remove
tw-animate-css: Required by shadcn/ui components for animations. Check shadcn dependencies before removing any package - No
pnpmprefix inside package.json scripts: The package manager is already the script runner. Use bare commands (e.g.,next build, notpnpm next build) - Page components: Colocate client components with pages (e.g.,
GalleryClient.tsxalongsidepage.tsx) - Server utilities:
src/lib/server-utils.tsusesimport 'server-only'to enforce server-only code - Dev tools:
next-devtools-mcpandchrome-devtools-mcpare fetched on demand viapnpm dlx(see.mcp.json) — not installed as deps
Updating shadcn
Preset: base-vega + neutral (see components.json). Update command: pnpm ui:update. Never use shadcn apply — see Gotchas.
This project's button.tsx and popover.tsx are heavily customized (sizing, colors, popup styling). Running pnpm ui:update will overwrite them with vanilla shadcn output and lose the customizations. If you need to sync upstream changes, prefer manual edits over a blind regenerate, or back up the customized files first.
Workflow
- Ensure clean working tree:
git status - Run
pnpm ui:update - Check for silently stripped components: if the shadcn output says "Skipped N files (might be identical)" for more components than seems right, your
globals.cssis probably missing a new theme token. Proceed to step 4. Otherwise skip to 6 - Generate a reference project in a sandbox path:
Diffpnpm dlx shadcn@latest init --template next --base base --preset vega --name fresh --yes --cwd "C:/Users/Tommy/Developer/shadcn-fresh-ref"shadcn-fresh-ref/fresh/app/globals.cssagainstsrc/app/globals.css. Look for new--*tokens in@theme inline {}and any chart/color palette changes - Add missing tokens to
src/app/globals.cssmanually, then re-runpnpm ui:update. Repeat until the skipped count stabilizes. Clean up the sandbox dir after:rm -rf "C:/Users/Tommy/Developer/shadcn-fresh-ref" git diffthe full changeset, commit
Gotchas
add --allscope:shadcn add --all --overwrite --yesiterates through components already installed insrc/components/ui/and re-renders each from the registry. It does NOT install brand-new components from the registry — for those, useshadcn add <name>explicitly. Base-incompatible components (seeformbelow) are silently excluded.addis config-aware: If a newer component references a CSS variable missing fromglobals.css, shadcn silently strips the class from the rendered output and skips the file as "identical". Add missing tokens toglobals.cssfirst.- Misleading skip hint:
"use --overwrite to overwrite"is printed even when--overwriteis already passed. It means "rendered output matches disk", not "you forgot a flag". formis Radix-only: The shadcnformcomponent depends on@radix-ui/react-slotfor theasChildpattern and has no Base UI variant. For form composition, usereact-hook-formdirectly without the shadcn wrapper, or check basecn.dev for Base UI ports.- Don't use
shadcn apply: It writes files outsidesrc/components/ui/(layout.tsx,globals.css,lib/utils.ts,package.json) with its own template style, and has a broken dedupe that inserts duplicate imports when quote styles differ. - Preset name mismatch:
components.jsonstores the style as"base-vega"(with prefix), but the CLIinit/applyaccepts onlyvega(no prefix) with an explicit--base baseflag.