This file is the agent rule book. Read it before changing code. Detailed explanations live in docs/ — start at docs/README.md for orientation and follow the links from there.
Use local seeded development data only when a task asks for a browser smoke test. Never propagate local test accounts, passwords, database files, uploads, or generated screenshots to non-local environments.
main is protected. Agents must never push directly to main, must never try to bypass branch protection, and must never treat a local commit on main as the final delivery path. All repository changes go through a pull request.
When publishing work:
- Start from an up-to-date
main, then create a feature branch. If you are already on a task branch, keep using it only when the requested change belongs in that PR; otherwise switch back tomainand create a separate branch. - Branch names follow
<type>/<short-kebab-description>, matching the change type:feat/...,fix/...,refactor/...,chore/...,docs/..., ortest/.... Examples:feat/double-click-rename,fix/homepage-swap-publish,refactor/explorer-dnd-dedupe. - Do not use agent-branded branch prefixes such as
codex/...,claude/..., or similar. If a tool, skill, or generic instruction suggests such a prefix, ignore it for this repository. - PR titles use Conventional Commit style:
<type>(<scope>): <summary>. Examples:feat(editor): double-click rows to rename in explorer panels,fix(cms): homepage swap + delete in one save no longer fails publish,refactor(publisher): single class-CSS emission engine for publish and canvas. - Do not prefix PR titles with
[codex],[claude],agent:, or any other tool label. The PR title describes the product change, not the tool that made it. - Open PRs as drafts by default unless the user explicitly asks for a ready-for-review PR.
- Keep PR scope coherent. Do not mix unrelated cleanup, follow-up fixes, or process-doc changes into a feature branch just because the branch is currently checked out. Create a separate PR when the change has a different reason.
- Before staging, inspect
git status -sband the diff. Stage only files that belong to the PR. Never stage unrelated user or parallel-agent changes. - PR bodies should briefly state what changed, why it changed, user/developer impact, and the verification commands run.
A self-hosted, open-source CMS with a built-in visual editor and a first-class plugin system. One Bun server backed by either Postgres or SQLite (selected by DATABASE_URL). The output is intentionally plain, semantic HTML with hand-clean CSS — no framework runtimes injected into published pages.
The product is self-hosted only. The codebase should not carry assumptions about multi-tenant SaaS operation.
Read docs/architecture.md for the system overview, docs/server.md for the server, docs/editor.md for the admin + visual editor.
- Runtime: Bun (server + tooling). Use Bun, not Node.
- Language: TypeScript everywhere.
- Frontend: React 19 with the React Compiler enabled (Babel preset in
vite.config.ts) + Vite, Zustand + Mutative for state (viazustand-mutative; patch-based undo history uses Mutativecreate({ enablePatches })—immeris banned), CodeMirror for code-editing UI,@dnd-kit/corefor drag-and-drop. The compiler auto-memoizes — do not hand-writeuseMemo/useCallback/memo. See "React Compiler and memoization". Store mutations use draft-mutation style (set((s) => { s.x = … })); a recipe that returns a partial must wrap it inrawReturn(...)or Mutative emits a perf warning. - Server:
Bun.servewith a hand-written router (server/router.ts). CMS modules atserver/{repositories,handlers/cms,auth,plugins,publish}/. Deep dive:docs/server.md. - Database: Postgres (
Bun.sql) OR SQLite (bun:sqlite), selected byDATABASE_URL. OneDbClientinterface, two adapters, two migration files with identical IDs. Rules:docs/reference/database-dialects.md. - Content model: All content lives in
data_tables+data_rows. The four system tables (posts,pages,components,layouts) are seeded and locked from rename/delete. There are no separatepagesorpage_versionstables. - Validation: TypeBox at every untyped boundary. Schemas are source of truth (
type Foo = Static<typeof FooSchema>, never a parallelinterface).zodis banned repo-wide (the AI drivers pass TypeBox schemas through as JSON Schema, so no typebox→zod adapter is needed). Helpers + patterns:docs/reference/typebox-patterns.md. - Sanitization: DOMPurify at the publisher boundary (
src/core/sanitize.ts). - Plugins: Zip packages with a
plugin.jsonmanifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a QuickJS-WASM sandbox — no Node/Bun ambient access, network gated bynetwork.outboundpermission +networkAllowedHosts. The VM bootstrap (SDK factory +__run*dispatchers) is authored as typed TS inserver/plugins/quickjs/bootstrap/src/and bundled to committed string artifacts inbootstrap/generated/— after editing the source runbun run bootstrap:sync(gated byplugin-bootstrap-fresh.test.ts). Permission enforcement everywhere (VM, host, editor) validates againstgrantedPermissions, never the declaredpermissionsarray. Feature doc:docs/features/plugin-system.md. - Routing: In-house router at
src/admin/lib/routing/. Replacesreact-router-dom. Use it for all internal admin navigation, including links rendered from the site editor.react-router-domis banned, raw<a href="/admin...">hard navigations are banned in admin UI, andsrc/core/+src/modules/must not import the admin router. Gated byadmin-router-usage.test.ts. - Icons:
pixel-art-icons/icons/<name>— deep-imported, tree-shakeable. Vendored atvendor/pixel-art-icons/. Nolucide-react, no inline SVG strings — gated byno-third-party-icons.test.ts,direct-icon-imports.test.ts. Add a new icon by importing it and runningbun run icons:sync. - AI providers: No provider SDKs. Each driver in
server/ai/drivers/talks directly to its provider's REST API over HTTP/SSE, sharing one multi-turn tool loop (drivers/http/toolLoop.ts).@anthropic-ai/sdk,@anthropic-ai/claude-agent-sdk,@openai/agents, and@openrouter/agentare banned repo-wide.@modelcontextprotocol/sdkis scoped, not banned: allowed only underserver/ai/mcp/(Instatic's MCP server implements a real wire protocol), still banned in the drivers and the browser. Gated byai-driver-isolation.test.ts. - MCP server: Instatic exposes its CMS tools to external MCP clients (Claude Code, Codex, remote agents) at
/_instatic/mcp, authenticated by per-connector bearer tokens. Thin adapter over the existing tool engine: headless reads (content reads +read_styles) run in-process; ALL page editing (the full set of browser-execution editor tools) is relayed to the connector owner's open editor via the live editor bridge (server/ai/mcp/editorBridge.ts+useEditorMcpBridgeinSitePage), reusing the chat bridge machinery — the live editor store is the single source of truth (no headless DB-mutating page-tree tool, which would desync). Feature doc:docs/features/mcp-connectors.md. - Tree primitive: Every tree-of-nodes — pages, Visual Components, slot fills — uses one shape:
NodeTree<TNode>insrc/core/page-tree/treeSchema.ts. Mutations are tree-agnostic. Reference:docs/reference/page-tree.md. - Publishing: Three-layer pipeline. Layer A bakes fully-static pages to
uploads/published/current/<route>.htmlat publish time via a two-slot symlink swap (server/publish/staticArtefact.ts). Layer B is an in-memory LRU keyed by(urlPath, queryString, publishVersion)for dynamic routes (server/publish/renderCache.ts);bumpPublishVersion()evicts wholesale on every publish. Layer C emits<instatic-hole>placeholders for nodes auto-detected as request-dependent; a ~668 BIntersectionObserverruntime lazy-fetches each fragment from/_instatic/hole/<nodeId>. Auto-detection lives insrc/core/publisher/dynamicDetection.ts— one walker, four rules. Single entry:server/publish/publicRouter.ts:renderPublicResolution. Full design:docs/features/publisher.md. - Tests:
bun test. Architectural rules insrc/__tests__/architecture/*— when your change drifts a structural rule, fix the rule's gate test in the same change.
server/ Bun server: router, handlers, repositories, auth, plugins, publish, db
src/admin/ Admin app (React) — shell, workspaces, plugin host UI
src/admin/pages/site/ Visual editor (canvas, panels, toolbar, editor store)
src/core/ Engine: page tree, publisher, plugin SDK + runtime, persistence
src/modules/ First-party block modules (container, text, image, button, …)
src/ui/ Shared UI primitives (Button, Input, Tree, icons, cn)
src/styles/ Global tokens (globals.css)
docs/ Documentation (start at docs/README.md)
examples/ Plugin templates
vendor/ Vendored pixel-art-icons package
Source of truth for layout details: docs/architecture.md → "Folders, at a glance".
This project has live, self-hosted installations with real user data. That has direct consequences for how Claude must approach changes in this repo.
For code (TypeScript APIs, function signatures, types, modules, plugin SDK): there are no backward-compatibility obligations. Refactor freely, rename boldly, delete dead code, fix bad abstractions — the aggressive stance throughout this section applies in full.
Database schema is the exception. Live installations run the migration runner on every pull. A destructive schema change breaks those installations permanently. Every schema change ships as an additive, non-destructive migration. See "Database, schema, and stored data" below.
There is nothing to be backward compatible with — for code. TypeScript APIs, function signatures, types, and module shapes can change freely. Database schema is the explicit exception: see "Database, schema, and stored data" below.
- Do not preserve old function signatures, schemas, types, or APIs out of compatibility concern. If a cleaner shape exists, change it everywhere and delete the old one.
- Do not add deprecation shims. Don't keep a
legacyFoo()that forwards tofoo(). Just rename it and update callers. - Do not add migration paths from old behavior to new behavior unless it is genuinely required for currently developed code to keep functioning.
- Do not gate new behavior behind feature flags or version checks "to be safe." If the new behavior is correct, that is the only behavior.
- Do not leave both an old and new implementation side-by-side. Pick the right one and delete the other.
If a piece of code is in the wrong place, has the wrong shape, has confusing naming, or carries leftover assumptions — fix it at the source, even if it means refactoring multiple files.
You are explicitly authorized — and expected — to:
- Rename modules, files, types, and functions across the whole codebase to match the cleaner architecture.
- Move responsibilities between layers (e.g. push logic out of a handler into a repository, or out of a component into the engine) when that is correct.
- Delete dead code, unused exports, half-finished abstractions, and "just in case" parameters.
- Restructure folders if the current layout no longer reflects the architecture.
- Break and fix many call sites in a single change set when that is what the cleaner design requires.
What you must not do:
- Wrap new logic around old logic to "avoid touching it."
- Add a second way of doing something because the first way is awkward — fix the first way.
- Leave TODO/FIXME notes about cleanup instead of doing the cleanup.
- Hide the wrong abstraction behind a thin adapter so callers "don't notice."
- Justify a workaround with "to keep this PR small" or "to avoid breaking other things." Other things can break in this PR. Fix them in this PR.
The database is NOT disposable. Live, self-hosted installations exist with real user data. The migration runner executes every unrun migration on startup — a destructive schema change breaks those installations on the next pull.
- Every schema change ships as a new additive migration. Add it to BOTH
migrations-pg.tsANDmigrations-sqlite.tswith the next sequential ID and the same semantic effect. See "Database dialect rules" for the mechanics. - Never edit or rewrite a migration that has already been committed. If a past migration has the wrong shape, ship a new forward migration that corrects it.
- Never make a change that requires dropping or recreating the database. No
DROP TABLE, noDROP COLUMN(unless the column was added in the same unreleased branch and no installation has run that migration yet), no table rebuilds. Use additiveADD COLUMN(nullable or with a constantDEFAULT), backfill withUPDATE, and defer destructive cleanup to a future migration only when the transition is complete. - If stored JSON shapes (page trees, plugin manifests, settings) need to change, change the reader/writer code to handle the new shape and update everything that reads/writes them. A data-migration
UPDATEin a new migration is the right tool for bulk shape changes on existing rows.
The plugin SDK, runtime, and manifest format look like a public contract but carry no backward-compatibility guarantee yet — no third-party plugins have been published against them.
- If the SDK shape is wrong, change it. Update
examples/plugins/template/anddocs/features/plugin-system.mdin the same change. - The
apiVersionfield is not yet a stability promise. Don't invent legacy adapters for olderapiVersionvalues.
Choose (A) the cleaner architecture, requiring edits across several files, over (B) a smaller diff that leaves the architecture slightly worse. Always choose (A). The cheapest moment in a project's life to fix bad abstractions is before they calcify — don't defer it.
If you are unsure whether a refactor is in scope, default to yes, do it, and explain in the summary what you cleaned up and why. Do not ask permission to delete dead code, rename a poorly-named symbol, or fix a bad abstraction — just do it.
Detailed system: docs/design.md. The rules:
- No hardcoded hex / rgb / hsl in admin / ui CSS modules. Every color comes from a
var(--*)token insrc/styles/globals.css. If a needed token doesn't exist, add it. Gated bycss-token-policy.test.ts. - No
var(--name, fallback)in admin / ui CSS modules. Use barevar(--name). If the token doesn't exist, define it inglobals.css. Fallbacks hide missing tokens. For JS-driven custom properties, set defaults in a CSS rule instead of in everyvar(). Gated byno-css-var-fallbacks.test.ts. - Two-layer color model. Surfaces, borders, and default text are achromatic. Color is used as identity (rail tints
--rail-tint-mint/lilac/sky/peachfor categorical identity), as state (--editor-danger,--editor-warning,--editor-success-*,--editor-info-*), or as canvas affordance (--canvas-selection-ring,--canvas-hover-ring). Never decorative. - Card surface pattern. Tile cards (dashboard widgets and equivalents) are borderless:
--editor-surface-2on a darker--editor-surfaceparent with a 1px grid gap, 16px radius. Hover lifts the surface tone — never recolor a border. Canonical implementation:src/ui/components/Widget/Widget.module.css. - Border radius scale.
--editor-radius-sm(3px) for tight chips.--editor-radius(6px) for default editor controls and buttons.--panel-radius(12px) for floating overlay panels. 16px for tile cards.--input-radius(1em) for pill-shaped inputs. Don't introduce ad-hoc radius values. - CSS Modules only in
src/admin/,src/modules/,src/ui/. No Tailwind utility classes — gated bynoTailwindUtilities.test.ts. No Tailwind ecosystem deps (clsx,tailwind-merge,class-variance-authority,@radix-ui/*) — gated byno-tailwind-deps.test.ts. - No inline
style={{ ... }}except for dynamic CSS custom properties (style={{ '--x': value } as CSSProperties}) that the module reads back viavar(--x). - No
!importantin component CSS modules. Two legitimate exceptions:globals.css(prefers-reduced-motion),Button.module.css(specificity reset). - CSS Modules file naming:
Component.module.cssnext toComponent.tsx. Class names usecamelCase.
Shared primitives at src/ui/components/. Every interactive control in src/admin/ MUST use these primitives.
Button— every action button. Bare<button>is gated bybutton-primitive-usage.test.ts; exceptions listed in that file'sALLOWLISTwith §8 justifications. New exceptions need an §8 entry.Input,Switch,Select,SearchBar,ColorInput,FileUpload,Separator,ContextMenu,FilterBar— for the corresponding control type.Tree*(src/admin/pages/site/ui/Tree/) — for tree rows in DOM/site panels.- Class composition:
cnfrom@ui/cn— an in-house 3-line helper.
The React Compiler is enabled for the whole app (babel({ presets: [reactCompilerPreset()] }) in vite.config.ts, linted by eslint-plugin-react-compiler). It auto-memoizes every component and hook. Manual memoization is therefore noise — it adds clutter without improving performance and must not be written.
- Default: no
useMemo, nouseCallback, nomemo(). Write the plain value, the plain function, the plain component. The compiler memoizes them for you. New code MUST NOT introduce manual memoization, and existing manual memoization is being removed. useState(() => …)lazy initializers anduseRef(…)are NOT memoization — they are always fine and unaffected by this rule.
There are exactly three exceptions where memoization stays — keep it, and add a one-line comment saying why:
- The value/function is referenced in a
useEffect(or other hook) dependency array. The staticreact-hooks/exhaustive-depslint rule can't see the compiler's runtime memoization, so it still demands a stable identity there. Wrapping a function used as a dep inuseCallback(and the transitive closure it depends on) is required to keepbun run lintclean. If a removable plain value feeds a dep array, that's fine — only functions trip the rule. - A
React.memore-render bailout on a hot, list-rendered component (e.g. a recursive per-node canvas renderer).React.memoskips re-rendering on equal props — a different mechanism from the compiler's within-component memoization — so dropping it on an O(N) critical path is not behavior-preserving without runtime perf validation. Rare; justify in a comment. - The compiler genuinely cannot compile a function (escape hatch). Add the
"use no memo"directive at the top of that function body, or the existingeslint-disable react-compiler/react-compilerpattern, and keep the manual memoization it needs.
Gate: eslint-plugin-react-compiler (runs in bun run lint / CI) is the enforcement gate — it flags both compiler bailouts and react-compiler/react-compiler violations. react-doctor (bun run doctor) also surfaces react-doctor/react-compiler-no-manual-memoization, but as a warning only (React Compiler rules are downgraded to warn in react-doctor.config.json; bun run doctor fails only on error-tier Security/Correctness diagnostics). A new component that ships useMemo/useCallback/memo outside the three exceptions above is drift — remove the memoization. Full detail: docs/reference/react-compiler.md.
Detailed patterns: docs/reference/typebox-patterns.md. The rules:
Every untyped boundary uses TypeBox. Inside the boundary, code trusts the parsed value.
- HTTP responses (client):
@core/httpis a single three-layer stack — there is exactly ONE way to validate a response, expressed at the altitude you need:apiRequest(path, { schema, … })— the canonical entry. Does thefetchitself: setscredentials, serializes a JSON body (FormData passes through untouched), validates the success body againstschema, and throws a singleApiError(carrying the HTTP status) on failure. Detect cancellation withisAbortError(err). Default to this — do NOT hand-rollfetch+res.ok+res.json()in admin code.readEnvelope(res, Schema, fallbackMessage)— for the persistence layer, which performs its own injectablefetch(test seam) and then hands theResponsehere. Checksres.ok(throwsApiErrorwith status + the{ error }envelope message), then validates the body. Its no-body sibling isassertOk(res, fallback)forvoid/Blob/streaming/text responses.parseJsonResponse(res, Schema)— the low-level body-validation primitive thatapiRequestandreadEnvelopeare built on. It validates a body with NO HTTP-status semantics. Reserved for genuine primitives only: the@core/httpinternals, the XHR upload path (useUploadQueue), and server-side fetches of external APIs. Do NOT reach for it in admin/persistence code —assertOk(res, m); parseJsonResponse(res, S)is exactlyreadEnvelope(res, S, m); always write the latter.
JSON.parseof persisted data:safeParseJson(raw, Schema)for hard,parseJsonWithFallback(raw, Schema, default)for soft.- Request bodies (server): validate with a TypeBox schema before handing to handlers. The single shared body-parsing entry point is
readValidatedBody(req, Schema)inserver/http.ts. - Plugin manifests:
parsePluginManifestinsrc/core/plugins/manifest.ts. - Site documents loaded from storage:
validateSiteinsrc/core/persistence/validate.ts.
- Domain validation errors are typed
Errorsubclasses with apathfield. Examples:SiteValidationError,VisualComponentNameError,VisualComponentParamNameError,VisualComponentRecursionError. Add a typed class when callers need to distinguish causes — UI states, retry decisions, etc. - Generic
throw new Error(...)is fine for "this should never happen" invariants. It is not fine when the UI needs to render a specific error state.
- Server endpoints return
{ error: string }on failure (validated byErrorEnvelopeSchemain@core/http). apiRequest/readEnvelopesurface this message automatically viaresponseErrorMessage(res, fallback)(also in@core/http), which prefers the{ error }envelope, then raw response text, then the fallback.- Server logs use the prefix
console.error('[<module>]', err)— example:'[plugin:acme.workflow]'.
- Async UI handlers wrap in
try/catch. Logged errors use the prefixconsole.error('[<component>] <description>:', err). - Operation failures surface through the global toast bus —
pushToast({ kind: 'error', title, body })from@ui/components/Toast— for anything a user triggered that then failed (save / import / delete / publish / apply / network call). This is the default for user-visible errors. The single mounted<ToastProvider />renders them withrole="alert", so you don't hand-roll the a11y. UsegetErrorMessage(err, …)for thebody. - The only exception is field-local, non-blocking validation that belongs next to a specific control inside a form (e.g. an invalid token name in a dialog) — that may stay inline with
role="alert"/role="status". Operation results (a request that failed) are NOT field-local; toast them. Neveralert()/confirm()/prompt()— gated byno-native-browser-dialogs.test.ts. - Error message extraction:
getErrorMessage(err, 'Unknown <thing> error')fromsrc/core/utils/errorMessage.ts— handles theinstanceof Errorcheck and the empty-message fallback in one place. - Soft fallbacks (corrupted localStorage, missing optional config):
parseJsonWithFallback+ continue with defaults. - Hard fallbacks (corrupted required document, broken HTTP envelope): let the error bubble to the nearest error boundary. Do not silently mask.
catch (err) {}— silently swallowing. If genuinely safe, name it (catch (_err)) and add a one-line comment.console.login production code. Useconsole.error/console.warnwith a[<module>]prefix, or remove the log.- Re-throwing a wrapped
Errorthat loses the original stack. Usenew Error(message, { cause: err }). as Fooat a JSON / HTTP /JSON.parseboundary. Use a TypeBox schema instead. Gated byboundary-validation.test.ts(rules 1–5:res.json() as,JSON.parse as, rawfetch(), rawreq.json(), andbody.field as DeepTypeafterreadEnvelope/parseJsonResponse— embed the field's TypeBox schema in the envelope instead; allowlist interface-only deep types with a§5.xentry).- Importing
zodanywhere. It is banned repo-wide: the AI drivers hit each provider's REST API directly and pass TypeBox schemas through as JSON Schema, so there is no longer a typebox→zod adapter. Gated byai-driver-isolation.test.ts.
Detailed: docs/reference/database-dialects.md. The three rules:
- Repositories are dialect-naive. Use ANSI-standard SQL only. The five Postgres-isms —
now()in DML,::int,::jsonb,any($N::...),distinct on— are banned in anyDbClient-importing file underserver/. Gated bydb-postgres-isms.test.ts. - JSON columns end in
_json. The SQLite adapter auto-parses*_jsonstrings on read and auto-stringifies plain objects on write. Gated bydb-json-column-naming.test.ts. - Migrations are split per dialect with identical IDs.
server/db/migrations-pg.ts(PG dialect) andserver/db/migrations-sqlite.ts(SQLite dialect). Parity gated bymigration-parity.test.ts.
Adding a new migration: add it to BOTH migrations-pg.ts and migrations-sqlite.ts with the next sequential ID and the same semantic effect. Migrations must be additive and non-destructive — live installations run the migration runner on every pull. Never rewrite or delete a committed migration; if a past migration is wrong, ship a new forward migration that corrects it. Never write a migration that requires dropping or recreating the database.
Adding a JSON column: name it *_json.
Detailed: docs/reference/page-tree.md. The rule:
Every mutation in src/core/page-tree/mutations.ts takes a NodeTree<TNode> and is tree-agnostic — it knows nothing about pages vs. Visual Components. The only place that knows which tree is active is resolveActiveTreeTarget in src/admin/pages/site/store/slices/site/helpers.ts, consumed through mutateActiveTree(fn).
The 11 named tree-mutation store actions (insertNode, deleteNode, updateNodeProps, setBreakpointOverride, clearBreakpointOverride, renameNode, toggleNodeLocked, toggleNodeHidden, moveNode, duplicateNode, wrapNode) are one-liners that call mutateActiveTree. They MUST NOT contain their own kind === 'visualComponent' routing branch — gated by no-vc-mode-branches-in-mutations.test.ts.
Plugins reach the same 11 mutations through applyTreeOperation(tree, op) — exported from @core/page-tree, dispatched on op.kind. It is the same engine the editor exercises via mutateActiveTree; the plugin RPC cms.content.tree.mutate runs each operation through it so plugin code rides the editor's gates instead of bypassing them.
When a base.visual-component-ref is dropped, it auto-spawns one base.slot-instance child per slot param via syncSlotInstances (src/core/visualComponents/slotSync.ts). User content lives as ordinary children of the slot-instance, in the same page tree as everything else. The publisher pairs each base.slot-instance (consumer side) with the matching base.slot-outlet (in the VC's definition tree) by slotName.
There is no slotContent prop — all slot fills are materialized, locked nodes in the page tree.
Modules that publish a public API (an index.ts in a folder under src/core/, src/ui/components/<Component>/, etc.) own that barrel as their canonical entrypoint. Everything outside the module imports through the barrel; internal files within the module import from each other via relative paths.
- ✅ Outside
src/core/page-tree/:import { Page, PageNode } from '@core/page-tree' - ✅ Inside
src/core/page-tree/:import type { Page } from './page'(relative, NEVERfrom '@core/page-tree') - ❌ Outside the module:
import { Page } from '@core/page-tree/page'— bypasses the barrel
Deep imports into these engine modules are enforced by src/__tests__/architecture/no-core-barrel-deep-imports.test.ts:
@core/page-tree,@core/module-engine,@core/visualComponents,@core/publisher@core/framework— the framework engine (color, typography, spacing CSS generation)@core/framework-schema— pure leaf: TypeBox schemas + derived types for persisted framework token settings; no dependency on the engine or page-tree@core/fonts
Note: @core/framework-schema is a dependency of both @core/page-tree (for FrameworkSettingsSchema and GeneratedClassMetadataSchema) and @core/framework (for the persisted data shapes). This arrangement keeps the module graph one-directional — the engine depends on the schema leaf, not on the page tree. Any other module barrel is still a convention without a gate; treat deep imports in those as drift and migrate them to the barrel as part of whatever change you're making.
- Clear logic over clever logic. Straight-line code beats a generic abstraction with two callers.
- Names must be honest. A function called
renderPagerenders a page. - One reason per module. Files in
server/{repositories,handlers/cms,auth,plugins,publish}/*andsrc/core/*are organized by responsibility — keep them that way. - No dead code. Unused exports, parameters, types, files: delete them.
fallow(npx fallow dead-code) is the canonical tool;knip,madge, andjscpdremain available for second-opinion checks. - Health checks with coverage.
bun run fallow:healthrunsbun test --coverageand feeds the result tofallow health --coverage. Run before deciding whether a hotspot needs more tests vs. more refactoring. - No
anyto escape a type problem. Fix the type. - No commented-out code. Git remembers.
- Validate at the boundary, trust inside. Don't
as Fooyour way past it. - Schemas are source of truth.
type Foo = Static<typeof FooSchema>— never a parallelinterface Foonext toFooSchema. - Architecture tests are first-class. When you change a structural rule (folder layout, allowed imports, banned APIs, design tokens), update the matching test in
src/__tests__/architecture/. - Documentation tracks code. When you change code that a doc describes, update the doc in the same change. Doc rules:
docs/CONVENTIONS.md. - At the end of the task, your own changes must pass
bun test,bun run build, andbun run lint. Verification is an end-of-task gate, not a per-edit ritual.
- Always use
bun(notnpm/pnpm/yarn) for installs, scripts, and tests. - Lockfile is
bun.lock. Do not introducepackage-lock.jsonoryarn.lock. - Server scripts run with
bun --watch server/index.ts. Frontend dev runs withvite. - Run the full stack locally with
bun run dev(defaults to SQLite at.tmp/dev.db— no external dependencies) ordocker compose up --build(everything in containers with Postgres). SetDATABASE_URL=postgres://...beforebun run devto use Postgres instead. bun run buildrunstsc -b && vite build— both type-checking and bundling. A change that runs in dev but failstscis not done.
bun install
bun run build # tsc -b && vite build
bun test
bun run lintRun verification once, at the end of the task, before declaring work complete. You do not need to run bun test / bun run build / bun run lint after every edit — that wastes time. Make your changes, then verify.
Use bun run build and bun test for any non-trivial change. Add bun run lint if you touched .ts/.tsx files.
Multiple Claude sessions may be working on this repo at the same time. The working tree may already contain failing tests, type errors, or lint errors from work-in-progress in another session. That is not your problem to fix.
- Confirm that the code you wrote / files you touched typecheck, lint, and pass their tests.
- A pre-existing failure in an area you did not touch is not a blocker. Note it in your summary, then move on.
- Do not try to "fix" failures unrelated to your work — you'll collide with whoever is editing those files. Don't add band-aids, don't comment out failing tests, don't revert someone else's half-finished change.
- Do not skip verification entirely because "tests are probably broken anyway." Always run the checks, then triage: yours vs. not-yours.
- If a failure is ambiguous,
git status/git diffwill show what you actually changed. Anything outside that diff is not yours.
The bar is: your work is clean.
- Live installations exist with real user data. Refactor code freely — no compat shims needed. DB schema is the exception: every change ships as an additive, non-destructive migration; never rewrite a committed migration or require a DB drop.
- Never preserve backward compatibility in code, never leave band-aids, never duplicate "old vs new" code paths.
- If the architecture would be cleaner with a multi-file refactor — do the refactor, in this change.
- Every untyped boundary goes through TypeBox.
as Fooat a JSON boundary is a bug.zodis banned repo-wide — AI drivers pass TypeBox schemas straight to providers as JSON Schema. - UI uses shared primitives from
src/ui/, design tokens fromsrc/styles/globals.css, CSS Modules only. The React Compiler is on — no manualuseMemo/useCallback/memo(see "React Compiler and memoization" for the three exceptions). - Published output stays clean: clean HTML, clean CSS, clean TypeScript. No exceptions.
- Documentation tracks code — update
docs/in the same change. Readdocs/README.mdfor orientation. - Verify once at the end. Pre-existing failures from parallel sessions are not yours to fix.