These are the rules for how code in Eigen is written: typing, style, imports, the patterns every hook and route follows, and the checklist a change passes before anyone calls it done. Read them before you write code, whether you are a person or an agent, and expect a reviewer to judge your change against them (REVIEW-STANDARD.md). The idea behind all of them is that new code looks like the code next to it.
The rules are kept short on purpose. The reasoning and the BAD/GOOD code behind each section live in CODE-EXAMPLES.md, under the same heading, and where files go is in ARCHITECTURE.md. The rules a machine can count are enforced by Biome and a standards script, as Standards Gates explains.
This codebase values simplicity, directness, and consistency over cleverness or abstraction. Code should be obvious at a glance. When in doubt, look at what already exists in the same directory and match it exactly.
- Flat and direct: no service layers, no repository patterns, no dependency injection. Routes call domain classes directly. Domain classes query the database directly with Drizzle
- Don't extract single-use helpers: a method that handles a complete workflow inline is better than several small methods you have to trace through. Only extract when logic is reused
- Trust the type system: no defensive null checks on typed data, no fallback defaults for required fields. Validate at system boundaries, and trust internal code everywhere else. A boundary is anything a person or another program wrote: a request body, an uploaded or imported file, a collaborator's edit arriving through Yjs, an external API. That trust only holds while types tell the truth, which is why casts are banned (§ Typing)
- Consistency over originality: new code must look like the code next to it. Same patterns, same naming, same structure. Don't invent new patterns when existing ones work
- Fewer lines beat more: removing code is always a good idea when what remains is cleaner and easier to understand. Over-engineering (a flag threaded through layers, a prop every caller passes the same value for, a seam that closes a case that cannot happen) is a broken window: remove it on touch, and don't add it to close a nit
- No placeholders: no stub method, type, field or flag for later. Code lands with its first real caller
- Fix the root cause, through the framework's own pipeline: a guest session goes through better-auth's API, never a row inserted by hand. Code that routes around a framework is a hack
- Speed first: independent work runs in parallel (requests, uploads, copies), and heavy work stays off the main thread and the server's event loop. Fix a race where it starts, never by making work wait in line
- Code that wipes or overwrites data first writes a timestamped copy it can be restored from, and a failed copy stops the wipe; skip it only when there is nothing to lose. A guard alone is not enough: a flag kept in another file than the data it guards can be wrong about that data
Six slips recur in review here, each with BAD/GOOD code in CODE-EXAMPLES.md § Review Hot Spots: over-engineering, unnecessary error handling (try-catch only for fire-and-forget, external integrations and cleanup; never re-wrap ApiError), not matching existing patterns, adding code where it doesn't belong, adding unnecessary complexity, reinventing existing code.
Types flow end-to-end: Elysia route handler return type → Eden Treaty → hook → component. Don't break the chain. Reasoning per rule: CODE-EXAMPLES.md § Typing.
- No casts and no non-null assertions in new code: no
as Type,as any,as unknown asor!;as constis fine. Narrow with a type guard orinstanceof, or fix the type at its source (a route handler's return type, a schema, an optional field that is always set). Removing old casts elsewhere doesn't pay for a new one - Eden Treaty responses keep their inferred type: add an explicit return type to the backend route handler or domain method, using the shared type from
packages/lib/src/types/ - Shared types live in
packages/lib/src/types/[domain].ts: never redefine one; if it doesn't exist yet, add it there so FE and BE share it. The exception is@workspace/math, a leaf package with no dependencies, which owns its own (ARCHITECTURE.md § Dependency direction) typeoverinterface, except when methods are needed- Infer locally, annotate publicly: no annotations where TypeScript infers; always explicit return types on backend route handlers, domain methods, and hooks
import typefor type-only imports, as a statement of its own next to the value import from the same module (import type { A } from 'x'andimport { b } from 'x'), so each import fits on one line- Drizzle
.$inferSelectfor DB row types: never redefine column types by hand - One type per concept: pass
UserorDrivePatheven when a function reads two of its fields, never a narrower projection type. Convert a library's narrower shape where it enters. Test fixtures may keep their own - Readonly where the holder doesn't let you write: a hook, store or class that hands out a collection it owns types it
ReadonlySet,ReadonlyMaporreadonly T[], so a caller changes it only through the API that re-renders or persists. A function takes a collection it doesn't change as readonly too. TypeScript enforces it once the type says so, and no lint rule asks for it, so review does. Apply it to the code you touch, not as a sweep - Only
apps/api's tsconfig setsnoPropertyAccessFromIndexSignature;packages/sheet's does not, so an index-signature read that compiles there fails in the API
Examples per rule: CODE-EXAMPLES.md § Code Style.
- English everywhere: code, comments, docs, commit messages
- No JSDoc: code should be self-documenting
- Every
.ts/.tsxsource file,scripts/and thepackages/sheetfork included, opens with a header comment and a blank line: two to five plain lines on what the file does, why it exists, and where it sits in the bigger picture (who calls it, when it runs). It stays true whatever other files do: what other files own (kinds, parts, callers) appears only as examples, never as a complete list. No file paths or doc links, which go stale. Test files, generated files and config files have none, and neither does a barrel that only re-exports, whose path already says what it holds. An older file without one gets it the first time someone changes it - Beyond the header, comments only for high-complexity code or functions whose use isn't obvious to a maintainer of this repo, and they explain WHY, never WHAT. Write plain English that a non-native reader understands on the first read: short sentences, common words. Most code needs zero comments; no comment beats slop, and reviewers flag comment slop the same as dead code
- A comment exists to clarify. Write it the way you would explain the code to a colleague sitting next to you: whole sentences, one thought each, the reason first. Don't pack several facts into one sentence with commas and semicolons, and don't write in shorthand only the author can decode. If a comment doesn't make the code easier to understand, delete it
- Every class member states its access:
public,privateorprotected, the narrowest that compiles - Class members in this order: fields, constructor, getters, lifecycle (init, close), then the rest
- File names are kebab-case (
get-home.ts); route files follow TanStack Router's naming. Named exports only, noexport default, except in a config file whose tool requires one (vite.config.ts, the Drizzle config). An older file that breaks either is fixed when someone cleans it up - Backend errors use
ApiError:throw new ApiError(status, message)for user-facing HTTP errors, neverthrow new Error(); internal invariants (db not open, missing config) may usethrow new Error() - Think about every
await: a bare async call returns a truthy Promise, soif (!asyncFn())is always false. Fire-and-forget has a.catch(). Skip theawaitwhen blocking would hurt response time and failure is acceptable - Sanitize user-provided paths: refuse
..,/and control characters before filesystem or header use, and never put raw user input into an HTTP header for (const x of arr), notarr.forEach(es-toolkit'sforEach(coll, fn)excepted). Flip one when you touch it- Theme tokens, not colors:
text-muted-foreground,bg-muted, nottext-gray-500;selection-handlefor selection UI - Use
cn()for class merging: from@workspace/ui/lib/utils, never rawclsx/twMergeor string concatenation - Name for grep-ability; don't shadow libraries:
use*,Eigen*,*Dialog,*Provider,invalidate*,*Keys; never reuse a name a dependency owns (useSearchQuery, notuseSearch) or give three helpers one name (isMobile) - Z-index: app-level components don't set one, and anything above 50 needs a comment. The scale and its rules: LAYOUT.md § One z-index scale, and app code sets none
Workspace imports resolve through each package's exports map, with no tsconfig aliases: if a specifier doesn't resolve, export the module from its barrel or give it an exports entry. Never suffix a workspace specifier with .ts/.tsx. Prefer the barrel over a deep import; for types and constants this table beats SHARED-PRIMITIVES.md's Import from column (why). The backend imports lib only through React-free subpaths: ARCHITECTURE.md § Backend imports of lib.
| Specifier | What you get |
|---|---|
@workspace/ui |
Root barrel: the everyday components (ARCHITECTURE.md § Frontend, Everyday UI), the layout system (app/sidebar/toolbar, not pages), generic leaf primitives |
@workspace/ui/components/[area] |
Area barrel (drive, chat, comments, editor, media, user, …) |
@workspace/ui/components/[leaf] |
Extensionless deep import for a component its barrel doesn't export (@workspace/ui/components/search/doc-search-provider) |
@workspace/ui/components/layout/[dir] |
Layout system barrels: app, pages, sidebar, toolbar |
@workspace/ui/hooks/[hook] |
Shared DOM/interaction hooks (@workspace/ui/hooks/use-long-press) |
@workspace/ui/lib/utils |
cn() utility |
@workspace/lib/[domain] |
Domain barrel: hooks, query keys, invalidators |
@workspace/lib/types/[domain] |
Shared FE/BE types |
@workspace/lib/constants/[x] |
Shared constants (@workspace/lib/constants/stale-time) |
@workspace/lib/api |
Eden Treaty API client factories |
@workspace/lib/date |
Date formatting (formatDate, formatTime, formatTimeAgo) |
@workspace/lib/validation |
Shared FE/BE validation schemas |
@workspace/math |
The math package's one barrel: the equation tree, its OMML, MathML and LaTeX readers and writers, and the LaTeX command table (MATH.md) |
User-visible dates are day-month-year: "5 January 2026", "5 Jan", "5 Jan 2026, 14:30", "Mon 5 Jan", never "January 5, 2026". Format through formatDayMonth / formatDate in @workspace/lib/date (they assemble the order from the 'en' locale's parts; 'en-GB' spells a short September "Sept" in browsers), and write hand-built shapes as d MMM yyyy. Every toLocale*String on a date passes 'en', never [] or no locale, or a Dutch browser writes "januari" into the English UI; a sibling that passes [] is a bug to fix on touch. A plain number's .toLocaleString() stays as it is. Spelling everywhere else stays en-US: fix British spelling on touch.
UI labels are sentence case: only the first word and proper nouns start with a capital, so "Keep aspect ratio", "Page setup", "Delete event", never "Keep Aspect Ratio". That covers button text, menu items, dialog titles, tabs, field labels, tooltips, placeholders, toasts, empty states and section headings. Proper nouns keep their case: Eigen and its apps used as names (Drive, Docs, Sheets, Slides, Mail, Calendar, Contacts, Chat, Stickies, Vector, Space), other products (Word, Excel, PowerPoint, Google Docs, Thunderbird), protocols and formats (WebDAV, CalDAV, IMAP, S3, PDF, CSV, URL, ID, 2FA) and key names (Enter, Tab, Shift). bun scripts/check-standards.ts fails on a capitalized word after the first in a label prop or label element, and PROPER_NOUNS in scripts/sentence-case.ts is the one list of names that keep their capital. A help-center article quotes a label character for character, so a renamed label changes the articles that bold it in the same commit.
Worked examples: CODE-EXAMPLES.md § Key Patterns.
- Query keys: hierarchical per domain (
driveKeys.folder(ownerId, mountId, pathId)) and always includingownerId, or personal and team contexts serve each other's stale cache. EveryuseQueryhas:queryKeyfrom the domain keys,queryFnwith error checking, anenabledguard, an explicitstaleTime - API client (Eden Treaty):
driveApi({ ownerId })({ mountId }).folder({ pathId }).get(); types flow directly from the Elysia route definitions, no manual type sync - Error handling: in hooks (
packages/lib/src/core/[domain]/hooks/), never in app components: everyuseMutationhasonErrorusingonMutationErrorfromapi-error.ts; apps never addtry/catch+toast.error(). Full pattern: NOTIFICATIONS.md - Invalidation functions: export
invalidateFoo(queryClient, ...)next to the query keys; apps call these, neveruseQueryClient+invalidateQueries()directly - Timestamps are
Dateend to end: anINTEGERcolumn of Unix seconds with Drizzlemode: 'timestamp', routes that returnDate(never.toISOString()), and shared types that sayDate, because Eden's reviver turns the ISO string back into one. A path-param time bound stayst.Numeric().assertJson<T>in the API tests and a rawfetch+res.json()skip the reviver, so revive at that seam. The reviver also turns a bareYYYY-MM-DDinto aDate, so date-only payloads go throughplainApi
Before declaring any task complete, review every changed file against this list:
- Did you read 2-3 existing files in the same directory before writing new code?
- Are hooks in
packages/lib/src/core/[domain]/hooks/, not in app components? - Are there any unnecessary abstractions, helpers, wrappers, or indirection?
- Did you check
packages/ui/src/components/andpackages/lib/src/for existing utilities/components before writing new ones? (cn(),formatDate,TooltipButton,DeleteDialog, etc.) - Are you using theme tokens, not hardcoded colors?
- Did you avoid adding try-catch, null checks, or fallbacks for cases that can't happen?
- Do new
useQueryhooks haveenabledguards andstaleTime? - Does the new code match the patterns and naming of its neighbors?
- Does a hook, store or class you changed hand out a collection it owns as a mutable type, or take one it never changes as mutable?
- Does every file you changed open with a header comment that is still true?
- Would a colleague understand every comment you wrote on the first read, and does each one clarify something the code doesn't say?
- Any new
z-indexset above 50? If yes, is it on the documented exceptions or does it have a// Why:comment? (See LAYOUT.md § One z-index scale, and app code sets none)
The mechanical rules are enforced, not just documented: Biome carries what it can express, and bun scripts/check-standards.ts (part of bun run check) counts nineteen metrics over apps/ and packages/: twelve hard zeros and seven ratcheting counts whose allowance in scripts/standards-baseline.json can fall but never rise. Casts and ! are allowed per file, so a new one fails even where the same diff removes another, and a file the baseline doesn't list is allowed none; a git mv moves the file's entries by hand in the same commit. Each run rewrites scripts/standards-baseline.json when a count fell, counting the working tree: commit it with the change, or reset it if the drop isn't yours, and never leave it dirty in a shared tree. What each metric catches: CODE-EXAMPLES.md § Standards Gates.
An exported primitive needs a user. Search SHARED-PRIMITIVES.md before you build one, and export one only for a caller outside its file: a function, hook, constant or component the index lists that nothing else imports fails the standards script, and such a type counts against its baseline. One that only tests import is listed but passes, and the props type of a component that is used may stay exported.
Biome also enforces the import and class rules in § Code Style: style/useImportType with separatedType, and style/useConsistentMemberAccessibility with explicit.
Never restructure code only to silence a lint rule. Use a lint-clean idiom that reads naturally (/\p{Cc}/gu for control characters), else a biome-ignore with a one-line reason, and raise its baseline in scripts/standards-baseline.json by hand in the same commit. The ratchet stops laziness, not natural code.