Deep version of the style rules summarized in AGENTS.md; read when writing or reviewing code.
- Run
pnpm check(orvp check --fix, which also runs on staged files) before committing. vpr checkexcludesvite.config.tsfrom tsgolint (recursion limits); tsc still type-checks it.- oxfmt: double quotes, 2-space indent, 80-col, semicolons; never hand-wrap or hand-sort.
- oxlint: strict mode + type-aware linting (
typeAware: true,typeCheck: true),react-hooks/rules-of-hooks+exhaustive-deps,typescript/no-explicit-any,no-console(only.assert/.error/.info/.warn),unicorn/no-array-for-each/prefer-array-flat-map,react/no-danger.
- Prefer interfaces over types for object shapes;
typefor unions/aliases (type Expense = ReceiptExpense | MileageExpense). - No enums; use string unions (
type ExpenseType = "receipt" | "mileage"). - Avoid
any; narrow withunknown. - Descriptive names, with auxiliary-verb booleans:
isComplete,hasAmount. - No classes; pure functions only, except for error subclasses (
class DeepSeekError extends Error). - Early returns: handle errors/guards at the top of functions.
- Path alias
~/*→app/*; relative imports only for same-directory siblings (./x), never../. - Group imports by origin:
node:builtins → external packages →~/*→ relative (./+types/<name>last where present). import typefor type-only imports.- No barrel files; import from the concrete module.
- File structure: exports at top, then module-level helpers/constants, then types (see
app/lib/db/accounts.ts,inbound-email.server.ts).
- Functional components only, no classes.
- Named exports for shared components (
export function Button); route modulesexport defaultthe page. - Component naming: PascalCase; files in
app/components/(ui/for primitives). - No
dangerouslySetInnerHTML; escape untrusted text withescapeHtml(app/lib/escape.ts). - View transitions (
viewTransitionon<Form>/<Link>): react-router only starts one for a real navigation, never for a fetcher submission, and the element that should morph needs its ownview-transition-name. That name is a stacking context: afixeddialog rendered inside the named element paints under the elements that follow it and they swallow its clicks (scroll-dependent, so it fails only sometimes) — portal such a dialog to the body, asapp/components/settings/auto-imported-senders.tsxdoes. Guard reduced motion for the::view-transition-*pseudo-elements explicitly; the global*rule cannot reach them.
The a11y smoke tests (test/a11y.test.ts) enforce the contract (skip-link, page titles, keyboard shortcuts, focus trapping, aria-invalid, aria-pressed, aria-expanded), and the shared primitives already follow it. The rules:
- Icons carry
aria-hidden="true"(standalone icon buttons get anaria-labelon the<button>). - Inputs set
aria-invalid={true}when invalid (sharedInput/Textareado this automatically); pair error text witharia-describedby. - Overlays trap focus on open, restore it on close, close on Escape (
ConfirmDialog,Lightboxare the pattern). - WCAG AA contrast: nothing below
text-gray-500on white (placeholder, helper text, functional icons included). - Touch targets ≥ 24×24 CSS px (small icon-only buttons need at least
p-1). - Every route exports a
metawith a descriptive<title>; pages use<main id="main-content">+ exactly one<h1>; sections get<h2>. - Primary actions have keyboard shortcuts (editor: Enter saves, Escape cancels).
- Respect
prefers-reduced-motion(guard any new animation/smooth scroll). - Tests use accessible selectors (
getByRole/getByLabel/getByText).
- Prefer early returns over nested if/else.
- No
forEach; usefor...of,.map(),.filter(),.flatMap().
- Validate at the boundary: plain helper validators in
app/lib/validation.ts, completeness rules inapp/lib/completeness.ts. - Handle errors first (guard clauses); return early on bad input.
- Catch at boundaries and log with
console.warn/console.error(see Logging).
- Allowed methods:
.assert,.error,.info,.warn(noconsole.log). - Prefix runtime logs with a context tag:
console.warn("[draft-upload] …").
- scrypt for password hashing (
app/lib/passwords.ts); never store plaintext. - Escape untrusted text before embedding in HTML/SVG/email (
escapeHtml). - Sanitize free-text filenames (
sanitizeFilenamePart). - Authenticated responses must not be shared-cacheable. Receipt images use
Cache-Control: private; never flip topublic. Public marketing documents are the reverse case: the root loader omits session data on public paths, somarketingPageHeadersmarks thempublic, s-maxageand a CDN serves them. Every HTML document denies framing (X-Frame-Options: DENY+CSP: frame-ancestors 'none'in the root loader headers). HSTS comes from Vercel. - Server-side fetches of untrusted URLs go through
fetchPublicUrl(app/lib/ssrf.server.ts): literal + DNS-resolved private-address checks, re-checked on every redirect hop. Never raw-fetch an attacker-controlled URL.
- Conventional commits, lowercase type, optional scope:
type(scope): subject, e.g.feat(analytics): …. No emoji prefixes. - Imperative mood, atomic commits, explain why not just what.
- Run
pnpm checkbefore committing.