Guidance for Claude Code (and other LLM assistants) working in this repo. Humans should start with README.md and DEVELOPMENT_HANDBOOK.md; this file captures the non-obvious rules an assistant needs.
README.md— repo overview, package structure, dev-container setupDEVELOPMENT_HANDBOOK.md— patterns, tRPC, testing, db migrationsapps/<app>/README.md— app-specific notes;apps/website/README.mdis especially load-bearing- When working in a specific app, also read its
CLAUDE.mdif one exists (e.g.apps/website/CLAUDE.md).
TypeScript • Next.js pages router (usually no SSR) • React • Tailwind • zustand • zod • tRPC • vitest • eslint • @bluedot/ui • @bluedot/db (unified Airtable + Postgres) • Docker on K8s via Pulumi.
We're fans of boring technology — don't introduce new dependencies without good reason. If you reach for one, justify it in the PR.
- Always work from a fresh branch in a git worktree — never on
master, never on a stale branch.masterdrifts hourly, so rebase first:cd ~/code/bluedot git fetch origin git worktree add -b <handle>/<slug> ../bluedot-worktrees/<slug> origin/master cd ../bluedot-worktrees/<slug>
- Copy env files for every app you'll touch:
cp ~/code/bluedot/apps/<app>/.env.local apps/<app>/.env.local. Don't invent placeholders — ask a teammate if a file is missing. - If the branch is more than a few hours old when you come back to it, rebase on
origin/masteragain before pushing. - After the PR merges, clean up: from outside the worktree (e.g.
cd ~/code/bluedot), rungit worktree remove ../bluedot-worktrees/<slug>and delete the local branch. Add--forceif you've decided to discard uncommitted local changes; the command refuses to remove a dirty worktree otherwise.
- Look for existing components/utilities first. Most marketing primitives already live in
@bluedot/uiorapps/website/src/components/. Seeapps/website/README.md→ "Reusing existing components first". - Use the named
bluedot-*palette utilities for colour (text-bluedot-navy,bg-bluedot-normal, including opacity liketext-bluedot-navy/60) and the size tokens for type (text-size-md). Avoid raw hex (text-[#0037ff]) and the semanticcolor-*tokens (bg-color-primary-accent,border-color-divider). Exact pixel values likegap-[3px]are sometimes ok. Tokens are documented inapps/storybook/src/GettingStarted.mdxand defined inapps/website/src/globals.css. - Check existing components in Storybook (
apps/storybook/or storybook.k8s.bluedot.org) before building new ones. - Keep Storybook stories in sync with components. When you touch a component, update its story in the same PR:
- Add a user-facing component (marketing/landing section, shared primitive, reusable UI piece) → add a
ComponentName.stories.tsxalongside it with at least aDefaultstory plus any variants the component supports. Stories pay off when they expose something the live page doesn't easily show: multiple states (loading/empty/error), prop API surfaces (variants, emphasis levels), auth-gated render paths, or MSW-driven data variations. Skip stories for: pure-logic/utility/admin-internal components; components fully rendered inside an existing parent story (the parent covers them); pure-static marketing sections with no props, no data fetching, and no variants — the live page at the corresponding route is already the canonical preview. - Modify props or variants of a component that already has a story → update the story's
argsand add a new variant if you introduced one. - Remove a component → delete its
.stories.tsxin the same PR. - When unsure whether a story is warranted (e.g. small internal component, ambiguous reuse), ask the user.
- Add a user-facing component (marketing/landing section, shared primitive, reusable UI piece) → add a
- For non-trivial work, sketch a plan before editing.
- All new database access goes through
@bluedot/db. Don't talk to Airtable or Postgres directly, and don't bypass the abstraction. - Use Tailwind for new styles. Migrate BEM classes you touch.
- Comments: write them only when the why is non-obvious. Don't narrate what the code does, and don't reference the current task or PR ("added for issue #123") — that belongs in the PR description.
- Don't add features, abstractions, or error handling beyond what the task requires. Trust internal code; only validate at system boundaries.
pgAirtable(...) tables are synced from Airtable by pg-sync-service. Always ship schema changes in their own PR before any consumer code:
- Adding a column: 2 PRs.
- Add the column in
libraries/db/src/schema.tsand merge. Wait for a sync to finish before merging PR 2. - Then PR the code that uses it.
- Add the column in
- Removing a column: 2 PRs.
- Remove all usage and move the column to
deprecatedColumnsinlibraries/db/src/schema.ts. If the column used.notNull(), remove that first — deprecated columns must be nullable because they stop receiving sync updates. Merge and deploy to production. - Then delete it fully from
schema.ts. Don't delete before production deploy —pg-sync-servicegeneratesSELECTby column name (not*), so running code that still references the column will break.
- Remove all usage and move the column to
- Renaming / updating a column: 3–4 PRs depending on nullability.
- Add the new column in
libraries/db/src/schema.tsand merge. Wait for sync. - Move all application code to the new column.
- Move the old column to
deprecatedColumns. If it used.notNull(), you must remove that constraint first — but you can't do that until no code depends on it (removing.notNull()changes the type toT | null, breaking any code that assumes non-null). Merge and deploy to production. - Delete the old column from
deprecatedColumns.
- Add the new column in
Mixing schema additions and consumer code in one PR breaks staging because the table hasn't been materialised yet. Full rules in DEVELOPMENT_HANDBOOK.md — Database Guidelines.
- Run the app's full test suite (e.g.
cd apps/website && npm test). Typecheck and lint do not catch test-context regressions — for example, adding a component that callsuseRouter()to a page covered by an SSR/SEO test will pass typecheck and fail in CI. - Run lint and typecheck. After changes that touch shared types — especially
@bluedot/dbschema — also runnpx tsc --noEmitfrom the workspace root.npm testwon't catch fixture drift when a schema adds a required property. - UI changes — run a viewport sweep. Use a real browser (headless Playwright is fine) at 390 × 844, 768 × 1024, 1024 × 768, 1280 × 800, 1440 × 900, and 1920 × 1080. At each, check for horizontal scroll, overflowing content, broken sticky/fixed elements, and touch targets <44 px on mobile. Don't trust bounding-box measurements from dev tools — they round and lie about subpixel overflow; eyeball the rendered page. Re-check all four sides after a CSS fix, not just the one you changed. For full-bleed / desktop checks, screenshot at a viewport height of at least 1800 px so above-the-fold + below-the-fold land in one image.
- Prose surfaces — cap body text at ~65 ch. For blog posts, course descriptions, about pages, long-form marketing copy, wrap body text in
max-w-prose(≈65 ch) ormax-w-[70ch]. Lines longer than ~80 characters are genuinely hard to read (Baymard / WCAG 1.4.8). Doesn't apply to headings, nav, table cells, code blocks, dashboard data, form labels, or hero taglines. - If you genuinely cannot run tests (e.g. missing credentials), say so loudly in the PR body — don't paper over it.
- Commit prefix:
[feat],[fix],[style],[chore],[docs],[refactor]. - Open a real PR with
gh pr create— title + body. Don't leave a "create PR" link for the human to fill in. - UI screenshots: embed real
<img>tags, not text descriptions. Commit screenshots to.github/pr-screenshots/<n>/(where<n>is the PR number or branch slug), push, then embed via SHA-pinnedraw.githubusercontent.comURLs (<img width="390">for mobile,<img width="1280">for desktop). Dismiss any cookie banner before capturing. After embedding, push a follow-up commit deleting the screenshots from.github/pr-screenshots/so the folder doesn't grow. The SHA-pinned URLs will still work. If you can't take screenshots, note in the PR body that a human should take them.
Don't wait for the human to ask. As soon as the PR is open (with screenshots embedded if it's a UI change), run this loop autonomously.
- Trigger Claude bot:
gh pr comment <N> --body "@claude review". Bake it into the same turn asgh pr create/gh pr ready. - Greptile auto-reviews within ~5 min of PR open. Claude bot responds ~1–2 min after the trigger comment. CodeRabbit is rate-limited and often skips — that's fine.
- Poll for both bots' responses. Pull
commentsandreviews— GitHub stores them as separate object types and Greptile posts as a review:gh pr view <N> --json comments,reviews \ --jq '[.comments[], .reviews[]] | .[] | select(.author.login != "<your-gh-handle>" and .author.login != "render") | "\(.author.login): \(.body)"'
- Categorise every finding:
- P1 / critical / scale-inversion-class bugs → fix in a follow-up commit on the same branch, push.
- P2 / dead code / redundant ternary / cleanup → fix unless the cost is meaningful; bias toward fixing.
- Stylistic / nice-to-have → judge case by case; don't blindly apply.
- Reply on the PR explaining what you addressed and what you intentionally didn't:
gh pr comment <N> --body "...". - If you pushed substantive fixes, re-trigger: comment
@claude reviewagain to get a fresh pass.
If a bot is wrong — they sometimes are — push back politely in a PR reply explaining why. Don't silently ignore, and don't change correct code to appease a bot.
- Merging to
masterdeploys to staging only — it does NOT go to production. Production requires a GitHub release taggedwebsite/vX.Y.Z. Seeapps/website/README.md→ Production. - Dev server:
npm run startfromapps/website/→http://localhost:8000. Worktrees may use a different port — check terminal output. - New page = also add the route to
apps/website-proxy/src/nginx.template.conf. - Fonts: do not delete files from
apps/website/public/fonts/. 8+ other apps in this repo load them via HTTPS from bluedot.org; deletion silently breaks their typography.
- The repo is intended to run in a dev container. Don't assume the user has random global tools (e.g. ImageMagick,
gh,pulumi) installed on the host — install inside the container orbrew installexplicitly. - Airtable is finicky: field names are exact-match, official rate limit is 5 req/s per base (~50 burst), and latency is regularly 500ms+. Plan reads against Postgres, writes through
@bluedot/db.
apps/website— bluedot.org (Next.js pages router)apps/meet— meeting attendance + Zoom Web SDKapps/availability— time-availability formsapps/course-demos— interactive demos embedded in coursesapps/login— custom Keycloak buildapps/website-proxy— nginx routing for bluedot.org trafficapps/pg-sync-service— syncs Airtable → Postgresapps/infra— Pulumi K8s deployapps/storybook— design system docs (storybook.k8s.bluedot.org)libraries/ui— shared React components (@bluedot/ui)libraries/db— unified Airtable + Postgres (@bluedot/db)
Read how existing code does it and follow that pattern. If something feels like it belongs in this file but isn't here, propose adding it in your PR.