CLAUDE.md17.8 KBView on GitHub # CLAUDE.md
This file provides guidance to coding agents working in this repository.
## Project Overview
Cedar is an open-source AI-powered email client built as a full-stack TypeScript monorepo with pnpm workspaces.
## Architecture
### Monorepo Structure
```text
apps/
├── mail/ React Router 7 frontend
├── server/ Hono + tRPC backend and background services
└── desktop/ Electron desktop app
packages/
├── cli/ nizzy environment tooling
├── db/ shared database schemas
├── eslint-config/ shared ESLint configuration
├── testing/ shared testing utilities
└── tsconfig/ shared TypeScript configurations
```
### Frontend (`apps/mail`)
- **Framework**: React Router 7
- **Routing**: file-based routing in `app/`
- **State**: Jotai + TanStack Query
- **UI**: Shadcn UI + Tailwind CSS v4
- **Key libraries**:
- tRPC
- TipTap
- Paraglide
### Backend (`apps/server`)
- **Framework**: Hono
- **Entry points**: `src/runtime/api-entry.ts`, `src/runtime/worker-entry.ts`, and `src/runtime/chat-entry.ts`
- **API pattern**: tRPC routes in `src/trpc/routes/`
- **Database**: PostgreSQL
- **Runtime**: AWS-native Node services with local adapter shims
- **Supporting primitives**:
- S3-backed object storage adapters
- SQS-backed queue adapters
- DynamoDB-backed KV adapters
- HTTP chat service via `CHAT_SERVICE_URL`
## Code Style
- **TypeScript**: strict mode enabled
- **Formatting**: 2-space indentation, single quotes, ~100 char line width
- **Imports**: use absolute imports
- **Components**: functional components with hooks
- **Async**: prefer async/await
## UI affordances
These are not polish — each one is a thing a user could not tell by looking.
- **Anything clickable gets `cursor-pointer`.** A row, a card, a badge, a `<div onClick>`,
a shadcn `Button` that inherits the default arrow — if clicking it does something, the
cursor has to say so before the click. A pointer is the only signal that separates "a
control" from "some text that happens to sit in a box".
- **Interactive elements own internal padding.** A hover or selected background that clamps
to the glyphs reads as a highlighter pen, not as a target. Put `px`/`py` INSIDE the hit
area (the `<button>`/`<a>`, not a wrapper around it), so the tinted surface has a margin
around its content, and inset the container that holds such rows (e.g. `p-1.5`) so that
surface never runs into the container's own border.
- **One surface per object — never stack containers.** A bordered card inside a dialog, a
bordered list inside a section card, a callout box inside a callout box: two frames drawn
around one thing. Pick the outer one and let the inner content be plain.
- **A container that opens or closes animates its HEIGHT.** Snapping to the final height is
worst exactly where the height is least predictable — a list whose rows can expand — because
the jump is the size of something the reader has not seen yet. `AnimatePresence` +
`initial/animate/exit` on `height` with `overflow-hidden`, one shared tween duration per
surface so nested disclosures read as one movement. Never a spring: overshooting a height
bounces every row below it.
## Forms
Every form is assembled from `components/ui/field.tsx`. Read that file's header before
building one; it is the guideline, and it exists because we had four label styles, three
private components all called `Field`, three control heights, an inline `inputClass` string
and two different action-row orders — for what is one pattern.
- **A component per control, not a class string per control.** `TextField`, `SelectField`,
`TextareaField` for the common cases; `Field` (label + hint + error wrapper) around anything
the kit does not cover — a checklist, a colour-options editor, a radix `Select`. A form with
no class strings in it cannot drift.
- **Never hand-roll a label, a hint, or an "(optional)" suffix.** `optional` is a prop; hints go
under the control, never between the label and it.
- **Errors live in the form, not in a toast.** Per-field where a field is wrong (`error` on the
field), per-form where the submission is (`FormError`). A toast disappears while the user is
still re-reading what they typed.
- **`FormActions` for the button row** — primary first, left-aligned, Cancel as a ghost beside it.
- **A form opened by a control renders on the same side of it as the control.** A form that
opens ABOVE the button that opened it pushes that button, and everything you were reading,
down the page at the moment you need to look at it.
- **`components/ui/form.tsx` is a different thing** — the react-hook-form binding the settings
pages use. Use it where a form is already on react-hook-form; use the kit everywhere else,
which is most places.
### The row
One field is one row, and the row has exactly three parts in exactly one arrangement. This
is the whole of it — it is what makes a settings surface read as a list of decisions rather
than a pile of controls, and any field that opts out of it is the one that looks broken.
```
┌──────────────────────────────────────────────────────────────┐
│ ⬢ Title [ control ] │
│ One line of description. │
├──────────────────────────────────────────────────────────────┤ ← hairline, inset to
│ ⬢ Next title [ control ] │ the row's padding
```
- **Title, description, control — never a fourth thing, never a different order.** An icon
may precede the title. Nothing goes between the title and its description.
- **The description is ONE line. Always.** If it does not fit, it is not a description, it
is documentation — cut it or move it. Two lines of grey text under every title turns a
scannable column into a wall, and the reader stops reading all of them. Aim under ~70
characters. A sentence that must survive intact belongs in the empty state, the docs, or
a callout — not under a label.
- **The control is vertically centred against the title/description block**, right-aligned,
and the same width as every other control in the surface (`FIELD_ROW_CONTROL`). A column
of controls that starts at a different x on each row is the thing the eye trips on.
- **Rows are separated by a hairline inset to the row's padding**, never a full-bleed rule
and never nothing. Measured off Linear: `1px` at `foreground/7%`, stopping short of the
card's edge so it reads as a seam between entries rather than a table border. The first
row has no line above it — `SETTINGS_ROWS_CONTAINER` handles that.
- **A wide control stacks INSIDE its row** (`stacked`) — a textarea, a checklist, an editor.
It keeps the row's padding and its divider; it just puts the control under the label
instead of beside it.
- **A row form needs a wide container.** In a dialog that is `size="form"`; below roughly
28rem the two columns collide and every row wraps, which is how forms end up stacked. The
exception is a POPOVER form, which fits in ~19rem because its CONTROL column is fixed — see
crystallized.md below.
## Panels, menus and selects
Anything that opens over the page — a menu, a submenu, a select, a display popover, a row form
in a popover — is specified in **`apps/mail/docs/crystallized.md`**. Read it before building
one. The measurements there are taken off Linear with `getComputedStyle`, and they are the
reason those surfaces look like one system rather than one per author.
The four things people get wrong, in order of how often:
- **The highlight is a pill inset 6px from the panel edge**, `rounded-lg`, on `bg-hover` — not a
full-bleed band and never `bg-sunken` (a sunken row on a raised popover is a hole punched
through it). Row content sits 14px from the panel edge, so there is 8px of air inside the pill.
- **Selection is a tick on the RIGHT — never a filled dot on the left.** In a select, a menu,
a submenu or a picker, and for radio sets exactly as for checkbox sets. shadcn's
`RadioItem` default (a `<Circle className="fill-current">` in a left gutter) is banned: the
disc out-weighs the label it marks, and the left lane belongs to the option's own icon. The
tick's lane is always reserved, so picking something never shifts the list sideways.
(`components/ui/radio-group.tsx`, a real radio input on a form, keeps its dot.)
- **A row that opens a submenu shows a trailing chevron.** Always. And the submenu's first row
lines up with the row that opened it, sitting flush against the parent panel.
- **Every menu level with more than ~10 options has its own filter field** — the root list and
each submenu — with no rule underneath it, just 6px of air.
- **State is a step on one lightness ladder**, not a new colour: `bg-raised` → `bg-control` →
`bg-hover` → `bg-selected`, with `bg-seam` for hairlines inside a panel (dimmer than the
panel's own `border-surface-border`).
`OptionPicker` (`components/ui/option-picker.tsx`) already implements all of this. Reach for it
instead of a Radix menu of checkbox/radio items any time the question is "which one(s)?", and
build a display/settings popover with `FieldPopover` (`components/ui/field.tsx`) rather than
assembling a `Popover` + `FieldRows` by hand.
**Import every floating primitive from `radix-ui`** — `Popover`, `Select`, `DropdownMenu`,
`Dialog`, `AlertDialog` — never from `@radix-ui/react-<name>`. Radix works out which open layer
owns an Escape via a registry inside `@radix-ui/react-dismissable-layer`, and two copies of that
module mean two registries that cannot see each other. That is not a theoretical risk: `Popover`
sat on a different copy from `Select`, so it believed it was always the topmost layer, and one
Escape aimed at a select inside a popover closed the popover too. No call-site handler can fix
it. See crystallized.md → Layering.
## Dependency Direction
Do not introduce circular imports. Preserve this dependency direction:
- Agents/workflows may import skills, tools, and services.
- Skills may import tools and skill utilities.
- Tools may import services, DB, env, and local tool helpers.
- Services must not import Mastra agents, skills, or tools.
Before adding a tool to a skill, verify the tool does not import the skills registry, agents,
or workflows. Run `pnpm deps:check` when touching Mastra tools, skills, or services.
## Gotchas
1. **React Router 7 vs Next.js**: frontend structure may resemble App Router conventions, but runtime semantics are React Router.
2. **Environment sync**: `pnpm nizzy sync` propagates the root `.env` into app-local env files.
3. **Local runtime ports**: this checkout's ports are whatever its `.env` sets — read
`PORT_API` / `PORT_WORKER` / `PORT_CHAT` there rather than assuming. The defaults are
API `8787`, worker `8788`, chat `8789`, frontend `5173`, but a machine running more than
one checkout gives each its own set, so these are frequently not the live values.
`apps/server/scripts/dev-runtime.mjs` deliberately `kill -9`s whatever holds those ports on
startup, so starting a second dev server silently kills the one already running — check the
ports first and reuse what is up.
4. **Database connection**: use the configured `DATABASE_URL`; do not assume a local Postgres instance contains live data.
4a. **Never `pnpm db:push` to apply a schema change.** It is interactive, and the
drizzle schema has drifted from prod far enough that before reaching your
columns it asks *"Is `cedar_input_crawl_entities` created or renamed from
another table?"* with ~19 rename candidates — and it would also apply any other
in-flight schema edits sitting in the working tree. Apply targeted additive DDL
instead (`ADD COLUMN IF NOT EXISTS`), and name foreign keys the way drizzle
would (`{table}_{col}_{reftable}_{refcol}_fk`); Postgres auto-names them
`{table}_{col}_fkey`, and a later migration will then add a second, redundant FK
on the same column instead of recognising the existing one. See
apps/mail/docs/wiki/meetings-tab.md.
5. **Turbo caching**: clear `.turbo` if stale build artifacts cause confusing behavior.
6. **Orphaned typechecks / vitest OOM Vite**: abandoned agent `tsc` or unscoped `vitest run`
processes exhaust RAM and make `localhost` hang with 0 bytes. See **AGENTS.md → Typecheck
hygiene** and **Vitest hygiene** (canonical) and `.cursor/rules/typecheck-hygiene.mdc`. Short version:
- Prefer `timeout 300 pnpm --filter @zero/mail|@zero/server run types` (or `types:test`)
- Prefer scoped vitest: `timeout 300 pnpm --filter @zero/server exec vitest run path/to/file.test.ts`
- Never background `tsc` / full `vitest run`; never use repo-wide `pnpm types` for routine checks
- Strained/blank page → `pnpm cleanup:orphans`, then restart `pnpm dev` / `pnpm run dev:axiom`
## Brand assets — there is exactly one Cedar logo
Cedar's logo is the **green cedar frond**: `apps/mail/public/CedarLogoTransparent.png`
(with `CedarLogo.png`, `CedarLogoRound.png` and the `cedar-logo+text-*.png` pair as its
variants). Reference it by path. Never inline a logo as SVG source, and never draw one.
This repo is a fork of Zero, and **Zero's mark — a black pixel-block glyph on a 191×191
canvas — is not our logo.** It shipped here as `black-icon.svg`, `white-icon.svg` and
`ai.svg`, wearing `alt="Cedar"` on the login page, which is exactly why it kept getting
picked up as ours. All three are deleted. Do not restore them, do not copy the shape out of
git history, and do not paste that `<path d="M38.125 190.625V152.5H0V38.125…">` anywhere.
`pnpm check:brand` enforces this and runs as part of `pnpm precommit`. It matches the path
DATA, not just the filenames, so renaming or recolouring the glyph does not get it past.
If you need a Cedar mark somewhere a PNG will not go (a self-contained HTML doc, an email),
downscale the real PNG and inline it as a data URI. Do not substitute a lookalike.
## Unused Code
Delete unused variables, imports, and parameters outright — never prefix with `_` as a workaround.
## NEVER cast
Do not write `as SomeType` (or `as any`, `as unknown as T`, or a non-null `!`) to make
TypeScript stop complaining. A cast does not check anything at runtime — it silences the one
tool that would have caught the mistake, and the error resurfaces in the browser as a crash
instead of at the keyboard as a red squiggle.
This is not hypothetical. `PipelineWidget` cast a tRPC result to
`{ id: string; name: string }[] | undefined`, but `aop.listAopsForUser` returns
`{ aops: [...] }`. The cast overrode the correct inferred type, `?? []` only guarded
`undefined` (not "an object that isn't an array"), and the moment the query resolved,
`aopList.map is not a function` took down the entire home rail through the error boundary.
**Instead:**
- **Derive the type, don't declare it.** tRPC/TanStack Query already know the shape — use the
inferred type. When you need to name it, read it off the router: `RouterOutputs['aop']['listAopsForUser']['aops'][number]`.
- **If the type looks wrong, go read the route.** A cast that "fixes" a type error is almost
always a sign you're wrong about the shape, not that TypeScript is.
- **Narrow with checks, not assertions.** `typeof`, `Array.isArray`, `in`, a discriminant
field, or a zod `parse` at the boundary — all of these are real at runtime; a cast is not.
- **At genuinely untyped boundaries** (raw SQL rows, `JSON.parse`, third-party `any`), validate
with zod and let the parsed result carry the type.
If a cast is truly unavoidable, it needs a comment saying what guarantees the shape and why
the compiler can't see it. "It was erroring" is not that reason.
## NEVER run `git stash`
Do not run `git stash` mid-session for any reason — not to verify whether TypeScript errors are pre-existing, not to "isolate" your changes, not to "clean" the working tree.
The working tree in this repo regularly contains in-progress work spanning multiple branches, workspaces, and concurrent sessions. `git stash` captures all of it indiscriminately, and a failed `git stash pop` (which happens any time the working tree has changed between stash and pop — including from linters and IDE auto-formatters) will tangle unrelated WIP together and can wipe work across the user's other sessions.
**Use these instead:**
- `git show HEAD:path/to/file` — read the committed version without touching the working tree.
- `git diff path/to/file` — see exactly what you changed (no stash needed).
- `git blame path/to/file -L <line>,<line>` — check whether a specific line was your edit.
- Filter typecheck output to files you actually touched (`pnpm types 2>&1 | grep "<your-files>"`) and trust that errors on lines you didn't edit are pre-existing.
- For genuine isolation, spawn an Agent with `isolation: "worktree"` (creates a separate git worktree, leaving the active tree untouched).
## Opening a PR from a separate worktree
When you do work in a separate git worktree (e.g. to isolate a fix) and open the PR from
there, **clean up any files you created in the primary local working tree** once the PR is
open — temporary scripts, design docs, scratch output, or edits that were only staging for
the worktree. Leftover untracked/modified files in the primary tree make it ambiguous
whether the user's own changes have been committed, and they can get swept into an unrelated
commit later. The rule: the branch/PR is the single source of truth for the change; the
primary working tree should be left exactly as you found it, with no residue from the
worktree task.
## Design Plans
When asked to design or plan a significant feature, create a design `.md` file containing:
- current state
- proposed changes
- critical files
- phased implementation plan
- verification steps