@craft-ts/mcp 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "path": "/guide",
9
9
  "title": "Guide",
10
- "body": "# Guide\n\nThe guide is organised by **what you are trying to do**. If you are starting\nout, the [Learn path](/learn/) is a better entry point — it introduces the same\nmaterial one idea at a time.\n\n## Start here\n\nFour pages carry most of the weight. Reading them in this order is worth an\nafternoon:\n\n1. [The mental model](/guide/concepts/mental-model) — the principles the API\n follows and the guarantees they provide\n2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the\n five-way decision you make constantly\n3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all\n five share\n4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel\n everything is built on\n5. [Insertions](/guide/concepts/insertions) — how behaviour is composed\n\n## Project setup\n\n[Create a CraftTS project](/guide/create-project) — interactive and\nnon-interactive starters, configuration options, and first checks\n\n## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\n[State machines](/guide/state/state-machines) ·\n[query](/guide/state/server-state) ·\n[Mutations](/guide/state/mutations) ·\n[queryParams](/guide/state/url-state) ·\n[asyncProcess](/guide/state/async-process) ·\n[Collections](/guide/state/collections) ·\n[Persistence](/guide/state/persistence) ·\n[Selecting](/guide/state/select) ·\n[Reacting to mutations](/guide/state/react-on-mutation) ·\n[Schema validation](/guide/state/schema-validation)\n\n### Structuring the app\n\n[craftService](/guide/app/craft-service) ·\n[Service scopes](/guide/app/service-scopes) ·\n[Shaping the public API](/guide/app/expose-api) ·\n[Abstract services](/guide/app/abstract-services) ·\n[App start](/guide/app/app-start) ·\n[Lazy services](/guide/app/lazy-services)\n\n[Server functions](/guide/app/server-functions)\n\n### Recommended approaches\n\n[Inject at the point of use](/guide/patterns/inject-at-point-of-use)\n\n### Routing and type-safe DI\n\n[Setup](/guide/routing/setup) ·\n[CLI automation](/guide/routing/automation) ·\n[ESLint rules](/guide/routing/eslint-rules) ·\n[Route providers](/guide/routing/route-providers) ·\n[Guards](/guide/routing/guards) ·\n[Exception handling](/guide/routing/exception-handling) ·\n[Pending UI](/guide/routing/pending-ui) ·\n[Route load errors](/guide/routing/route-load-errors) ·\n[Scaling routes](/guide/routing/scaling)\n\n### Components and templates\n\n[Components](/guide/components/) ·\n[Fine-grained reactivity](/guide/components/fine-grained-reactivity) ·\n[Progressive `forNode`](/guide/components/schedule-for) ·\n[Directives and `.pipe(...)`](/guide/components/directives) ·\n[Customization](/guide/components/customization) ·\n[Content projection](/guide/components/content-projection) ·\n[Encapsulated styles](/guide/components/styles) ·\n[Accessibility](/guide/components/accessibility)\n\n### Forms\n\n[Overview](/guide/forms/) ·\n[Validators](/guide/forms/validation) ·\n[Submitting](/guide/forms/submit) ·\n[Nested forms](/guide/forms/nested)\n\n### Testing\n\n[Services](/guide/testing/services) ·\n[Components](/guide/testing/components) ·\n[Type-level tests](/guide/testing/type-level) ·\n[Browser boundaries](/guide/testing/browser-boundaries) ·\n[Architecture rules](/guide/testing/architecture) ·\n[Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n\n### Reactivity utilities\n\n[craftComputed](/guide/reactivity/craft-computed) ·\n[craftEffect](/guide/reactivity/craft-effect) ·\n[craftMethod](/guide/reactivity/craft-method) ·\n[source$](/guide/reactivity/source) ·\n[on$](/guide/reactivity/on)\n\n### Going further\n\n[SSR and hydration](/guide/advanced/ssr-hydration) ·\n[Program operators](/guide/advanced/program-operators) ·\n[Pattern matching](/guide/advanced/pattern-matching) ·\n[Observability](/guide/advanced/observability) ·\n[Live page MCP](/guide/ai/dev-page) ·\n[Coding agents](/resources/ai-agents)\n\n## Looking for one symbol?\n\nThe [API index](/reference/) lists every export with a one-line description.\n"
10
+ "body": "# Guide\n\nThe guide is organised by **what you are trying to do**. If you are starting\nout, the [Learn path](/learn/) is a better entry point — it introduces the same\nmaterial one idea at a time.\n\n## Start here\n\nFour pages carry most of the weight. Reading them in this order is worth an\nafternoon:\n\n1. [The mental model](/guide/concepts/mental-model) — the principles the API\n follows and the guarantees they provide\n2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the\n five-way decision you make constantly\n3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all\n five share\n4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel\n everything is built on\n5. [Insertions](/guide/concepts/insertions) — how behaviour is composed\n\n## Project setup\n\n[Create a CraftTS project](/guide/create-project) — interactive and\nnon-interactive starters, configuration options, and first checks\n\n## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\n[State machines](/guide/state/state-machines) ·\n[query](/guide/state/server-state) ·\n[Mutations](/guide/state/mutations) ·\n[queryParams](/guide/state/url-state) ·\n[asyncProcess](/guide/state/async-process) ·\n[Collections](/guide/state/collections) ·\n[Persistence](/guide/state/persistence) ·\n[Selecting](/guide/state/select) ·\n[Reacting to mutations](/guide/state/react-on-mutation) ·\n[Schema validation](/guide/state/schema-validation)\n\n### Structuring the app\n\n[craftService](/guide/app/craft-service) ·\n[Service scopes](/guide/app/service-scopes) ·\n[Shaping the public API](/guide/app/expose-api) ·\n[Abstract services](/guide/app/abstract-services) ·\n[App start](/guide/app/app-start) ·\n[Lazy services](/guide/app/lazy-services)\n\n[Server functions](/guide/app/server-functions)\n\n### Recommended approaches\n\n[Inject at the point of use](/guide/patterns/inject-at-point-of-use)\n\n### Routing and type-safe DI\n\n[Setup](/guide/routing/setup) ·\n[CLI automation](/guide/routing/automation) ·\n[ESLint rules](/guide/routing/eslint-rules) ·\n[Route providers](/guide/routing/route-providers) ·\n[Guards](/guide/routing/guards) ·\n[Exception handling](/guide/routing/exception-handling) ·\n[Pending UI](/guide/routing/pending-ui) ·\n[Route load errors](/guide/routing/route-load-errors) ·\n[Scaling routes](/guide/routing/scaling)\n\n### Components and templates\n\n[Components](/guide/components/) ·\n[Fine-grained reactivity](/guide/components/fine-grained-reactivity) ·\n[Progressive `forNode`](/guide/components/schedule-for) ·\n[Directives and `.pipe(...)`](/guide/components/directives) ·\n[Customization](/guide/components/customization) ·\n[Content projection](/guide/components/content-projection) ·\n[Encapsulated styles](/guide/components/styles) ·\n[Accessibility](/guide/components/accessibility)\n\n### Forms\n\n[Overview](/guide/forms/) ·\n[Validators](/guide/forms/validation) ·\n[Submitting](/guide/forms/submit) ·\n[Nested forms](/guide/forms/nested)\n\n### Testing\n\n[Services](/guide/testing/services) ·\n[Components](/guide/testing/components) ·\n[Type-level tests](/guide/testing/type-level) ·\n[Browser boundaries](/guide/testing/browser-boundaries) ·\n[Architecture rules](/guide/testing/architecture) ·\n[Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n\n### Reactivity utilities\n\n[craftComputed](/guide/reactivity/craft-computed) ·\n[craftEffect](/guide/reactivity/craft-effect) ·\n[craftMethod](/guide/reactivity/craft-method) ·\n[source$](/guide/reactivity/source) ·\n[on$](/guide/reactivity/on)\n\n### Going further\n\n[SSR and hydration](/guide/advanced/ssr-hydration) ·\n[Program operators](/guide/advanced/program-operators) ·\n[Pattern matching](/guide/advanced/pattern-matching) ·\n[Observability](/guide/advanced/observability)\n\n### AI agents\n\n[AI agents overview](/guide/ai/) ·\n[Coding agents](/resources/ai-agents) ·\n[MCP tools](/guide/ai/mcp-tools) ·\n[Live page MCP](/guide/ai/dev-page) ·\n[Send context to AI](/guide/ai/send-context-webhook)\n\n## Looking for one symbol?\n\nThe [API index](/reference/) lists every export with a one-line description.\n"
11
11
  },
12
12
  {
13
13
  "path": "/guide/advanced/effect",
@@ -39,15 +39,25 @@
39
39
  "title": "Temporal runtime",
40
40
  "body": "# Temporal runtime\n\nCraft treats time as a runtime capability rather than as a direct call to the\nbrowser or Node timer APIs. This gives asynchronous programs one temporal\nseam that can be replaced in tests, inspected during diagnostics, and cleaned\nup with the lifetime that created it.\n\n**Use it when** a Craft program needs a delay, timeout, retry backoff, polling\nor another cancellable time-based operation.\n**Do not use it** for civil dates such as timestamps stored in a database:\nthose are dates, not elapsed-time measurements.\n\n## Import\n\n```typescript\nimport {\n CRAFT_TEMPORAL_RUNTIME,\n craftSleep,\n exponentialTemporalSchedule,\n fixedTemporalSchedule,\n provideCraftTemporalRuntime,\n VirtualCraftTemporalRuntime,\n withCraftTimeout,\n} from '@craft-ts/core';\n```\n\n## The temporal model\n\nThe runtime separates three responsibilities:\n\n- **clock** — reads monotonic time for durations and civil time for dates;\n- **task** — schedules one cancellable callback or sleep operation;\n- **schedule** — decides whether an operation continues and how long the next\n wait should be.\n\n```text\nCraft program\n ├── waits → craftSleep(...)\n ├── times out → withCraftTimeout(...)\n └── retries → a temporal schedule\n │\n ▼\nCRAFT_TEMPORAL_RUNTIME\n ├── RealCraftTemporalRuntime\n └── VirtualCraftTemporalRuntime\n```\n\n`setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` are runtime\nimplementation details. A polling loop should normally be expressed as a\nsequence of operations and a schedule so it can stop when its owner is\ndestroyed.\n\n## Waiting in a Craft program\n\n`craftSleep` is a yieldable delay. It does not create a native timer when the\ngenerator is created. The asynchronous Craft driver receives the request and\ndelegates it to the configured temporal runtime.\n\n```typescript\nimport { craftGen, craftSleep } from '@craft-ts/core';\n\nconst refreshAfterDelay = craftGen(function* () {\n yield* craftSleep(500, { owner: 'refresh' });\n return 'refresh now';\n});\n```\n\nThe delay can be used in route guards and in asynchronous primitive loaders,\nwhich already use the asynchronous program driver:\n\n```typescript\nconst data = query('data', {\n loader: function* () {\n yield* craftSleep(100);\n return loadData();\n },\n});\n```\n\nWhen a query is retriggered, its loader abort signal is propagated to pending\ntemporal awaits. A stale `craftSleep` is therefore cancelled and its generator\ndoes not resume. The resource still protects the latest result if an operation\nhas already passed its sleep or does not observe the signal.\n\nMutation loaders intentionally keep their already-started temporal operation\nvalid when a new mutation is triggered. The previous resource result can still\nbe ignored as stale, but the mutation's generator is not interrupted.\n\nSynchronous drivers such as `craftUse` cannot suspend on `craftSleep`. They\nfail with an explicit async-driver error instead of silently creating an\nuntracked Promise.\n\n## Replacing the runtime in tests\n\n`VirtualCraftTemporalRuntime` never waits for wall-clock time. It starts at\nzero by default, orders equal deadlines by creation order, and exposes the\npending tasks for assertions.\n\n```typescript\nimport { ɵInjector as Injector } from '@craft-ts/core';\nimport {\n executeGeneratorCompatibleFactoryAsync,\n provideCraftTemporalRuntime,\n VirtualCraftTemporalRuntime,\n} from '@craft-ts/core';\n\nconst clock = new VirtualCraftTemporalRuntime();\nconst injector = Injector.create({\n providers: [provideCraftTemporalRuntime(clock)],\n});\n\nconst result = executeGeneratorCompatibleFactoryAsync({\n factory: function* () {\n yield* craftSleep(100);\n return 'done';\n },\n thisArg: undefined,\n getInjector: () => injector,\n args: [],\n invalidYieldErrorMessage: 'Invalid Craft yield.',\n});\n\nawait clock.advanceBy(99);\n// The program is still suspended.\n\nawait clock.advanceBy(1);\nawait expect(result).resolves.toMatchObject({\n kind: 'done',\n value: 'done',\n});\n```\n\nThe main test operations are:\n\n```typescript\nawait clock.advanceBy(250); // move time forward\nawait clock.advanceTo(1_000); // move to an exact time\nawait clock.advanceToNextTask(); // execute the nearest task\nawait clock.runUntilIdle(); // execute until no task remains\nclock.pendingTasks(); // inspect all pending tasks\nclock.pendingTasks('refresh'); // inspect one owner\nclock.reset(); // cancel tasks and restore the clock\n```\n\nTasks with the same deadline run in creation order. A task created while an\nexpired task is running is then considered at the same virtual time and is\nalso executed before the clock advances beyond that deadline.\n\n## Timeouts\n\n`withCraftTimeout` races an operation against the configured runtime. If the\noperation wins, the timeout task is cancelled. If the deadline wins, the\nPromise rejects with `CraftTimeoutError`.\n\n```typescript\nconst response = await withCraftTimeout(\n fetch('/api/report').then((response) => response.json()),\n 5_000,\n { owner: 'report-loader' },\n);\n```\n\nThe timeout controls the Craft operation's result. It does not automatically\ncancel an external HTTP request. Pass an `AbortSignal` to the underlying API\nwhen the resource itself must be interrupted as well.\n\n## Schedules\n\nA schedule is a pure policy. It does not own a timer and it does not run an\ninterval. It receives the next attempt number and returns either a delay or a\nstop decision.\n\n```typescript\nconst backoff = exponentialTemporalSchedule(100, {\n factor: 2,\n maxAttempts: 4,\n maxDelayMs: 2_000,\n});\n\nbackoff.next({ attempt: 1, elapsedMs: 0 }); // { done: false, delayMs: 100 }\nbackoff.next({ attempt: 2, elapsedMs: 100 }); // { done: false, delayMs: 200 }\n```\n\nAvailable policies include:\n\n```typescript\nfixedTemporalSchedule(500, { maxAttempts: 3 });\nexponentialTemporalSchedule(100, { factor: 2 });\nsequenceTemporalSchedule([100, 250, 1_000]);\n```\n\n`retry` uses the temporal runtime for non-zero backoff delays and accepts a\ncustom schedule when the built-in policies are not enough:\n\n```typescript\nconst user =\n yield *\n loadUser().pipe(\n retry({\n times: 3,\n schedule: exponentialTemporalSchedule(200, {\n factor: 2,\n maxDelayMs: 2_000,\n }),\n }),\n );\n```\n\nPrefer a schedule over `setInterval` for polling. The operation completes one\nstep, the schedule decides whether another step is needed, and the next task\nis created only after the current step has finished. This avoids accidental\noverlap and makes destruction cancellable.\n\n## Ownership and cleanup\n\nEvery task can carry an `owner` label for inspection. Runtime integrations that\nhave a `DestroyRef` attach the task to that lifetime:\n\n```typescript\nconst task = runtime.schedule(refresh, 1_000, {\n kind: 'polling',\n owner: 'user-list',\n destroyRef,\n});\n\ntask.cancel(); // idempotent; returns whether cancellation happened\n```\n\nDestroying the owner cancels its pending tasks. A suspended `craftSleep` is\nrejected with `TemporalCancelledError`, so a destroyed program cannot resume\nand mutate state after its lifetime has ended.\n\n## Choosing the right abstraction\n\n| Need | Use |\n| -------------------------------- | ------------------------------------------- |\n| Wait once inside a generator | `craftSleep` |\n| Bound an operation by a deadline | `withCraftTimeout` |\n| Retry after an error | `retry` with a schedule |\n| Repeat work without overlap | one operation plus a schedule |\n| Store a timestamp | a civil date value, not the monotonic clock |\n| Test time-dependent behavior | `VirtualCraftTemporalRuntime` |\n\n## What not to do\n\nAvoid timer Promises created directly inside Craft programs:\n\n```typescript\n// Avoid\nyield * new Promise((resolve) => setTimeout(resolve, 500));\n```\n\nUse the temporal request instead:\n\n```typescript\n// Prefer\nyield * craftSleep(500);\n```\n\nDirect timer globals are reported by the `no-direct-temporal-globals` dev-tools\nrule. The temporal runtime implementation itself is the explicit exception.\n\n## Limitations\n\n- Browser background-tab throttling is not simulated by the virtual runtime.\n- Microtasks and macrotasks remain distinct; advancing virtual time flushes the\n microtasks caused by the tasks it executes.\n- RxJS schedulers are not automatically replaced by the Craft runtime.\n- A timeout does not cancel an external resource unless that resource accepts\n and observes an abort signal.\n- Timers created by third-party APIs remain outside Craft ownership.\n\n## See also\n\n- [`retry`](/guide/advanced/program-operators#retrypolicy)\n- [`asyncProcess`](/guide/state/async-process)\n- [Testing services](/guide/testing/services)\n- [Generators and `yield*`](/guide/concepts/generators)\n"
41
41
  },
42
+ {
43
+ "path": "/guide/ai",
44
+ "title": "AI agents",
45
+ "body": "# AI agents\n\nThis section gathers everything that gives an AI agent access to CraftTS\nknowledge, application state, runtime behavior, or debugging context.\n\nThere are three complementary layers:\n\n1. **Knowledge**: documentation, examples, `llms.txt`, and Agent Skills.\n2. **Observation and control**: MCP tools for the live browser, the runtime\n registry, and application logs.\n3. **Application context**: `provideSendContextToAi`, which lets a developer\n assemble a selected screen, timeline, snapshot, and optional DOM/CSS capture\n before copying or sending it to an AI service.\n\n## Choose the right surface\n\n| Need | Start here | Access |\n| ----------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------ |\n| Learn CraftTS conventions or find an API | [Coding agents](/resources/ai-agents) | `@craft-ts/mcp` |\n| Fill, click, navigate, or inspect the running app | [Live page MCP](/guide/ai/dev-page) | `@craft-ts/function-registry-mcp` → `page` |\n| Read or change a published primitive during development | [MCP tools](/guide/ai/mcp-tools) | `registry.*` tools |\n| Search logs from a reproducible flow | [MCP tools](/guide/ai/mcp-tools) | `@craft-ts/log-mcp` → `logs.*` |\n| Ask what a node depends on, or what a change can break | [MCP tools](/guide/ai/mcp-tools) | `@craft-ts/graph-mcp` → `graph.*` |\n| Give an AI a human-selected debugging context | [Send context to AI](/guide/ai/send-context-webhook) | `provideSendContextToAi` |\n| Understand the tracing and snapshot data behind the context | [Observability](/guide/advanced/observability) | Craft providers and runtime hooks |\n\nThe tools are deliberately separated by boundary. The documentation MCP is\nread-only and works offline. The registry MCP can mutate development state and\nmust only be connected to a local development app. The logs MCP reads local\nJSONL files; its `logs.clear` operation is destructive. The graph MCP reads the\nstatic dependency graph of the project and only writes the graph file it\nrebuilds.\n\n## MCP servers\n\nCraftTS has four MCP servers, each with a different responsibility:\n\n- [`@craft-ts/mcp`](https://www.npmjs.com/package/@craft-ts/mcp) gives an agent\n the published documentation, examples, skills, and LLM entry points.\n- [`@craft-ts/function-registry-mcp`](/guide/ai/mcp-tools) bridges a running\n browser tab to `page` and `registry.*` tools.\n- [`@craft-ts/log-mcp`](/guide/ai/mcp-tools) exposes the local log store through\n `logs.*` tools.\n- [`@craft-ts/graph-mcp`](/guide/ai/mcp-tools) answers architecture questions\n from the static dependency graph through `graph.*` tools.\n\nSee [MCP tools](/guide/ai/mcp-tools) for the complete tool inventory and the\nboundary between read-only and mutating operations.\n\n## Context integrations\n\nThis section is also the home for context providers and future agent-facing\nintegrations. `provideSendContextToAi` is the first application-facing context\nsurface: it turns an interaction into structured context that can be copied or\nsent to a protected webhook.\n\nLucene and Context Workbook are not present as packages, tools, or documented\nintegrations in this repository yet. When they are introduced, their setup,\npermissions, context model, and MCP tools should be documented under this\nsection and added to the table above rather than creating another AI-related\nnavigation branch.\n"
46
+ },
42
47
  {
43
48
  "path": "/guide/ai/dev-page",
44
49
  "title": "Live page MCP",
45
50
  "body": "# Live page MCP\n\nThe running development tab publishes its named controls. A coding agent fills,\nclicks, and inspects **that** page — no second browser, no DOM reverse-engineering.\n\n**Use it when** a Cursor agent must drive or inspect the `ng serve` tab you\nalready have open.\n**Not when** you are writing Craft away from a running app — use\n[`@craft-ts/mcp`](/resources/ai-agents) for docs and skills. **Not when** you\nwant to mutate a primitive without the UI — use the `registry.*` tools on the\nsame local MCP.\n\n## Connect the local MCP\n\nThe tool lives on `@craft-ts/function-registry-mcp`, not on the published\n`@craft-ts/mcp` docs server. From the craft-ts repo:\n\n```sh\nnpm run registry:mcp\n```\n\nPoint Cursor at that stdio server. It already listens on `ws://127.0.0.1:3333`\nfor the demo tab. Each tab keeps a stable `clientId` in `sessionStorage`.\n\n## One ready tab\n\nEach tab has a `clientId` in `sessionStorage`. Duplicating a tab copies it; the\nbroker assigns a new id (`hello/ok`) so the two tabs do not fight.\n\nOmit `clientId` when **exactly one tab is `ready`**. A ghost `reloading` card\n(HMR, F5) does not count. Two `ready` tabs → pass `clientId` from\n`registry.clients` (id, status, url). Never pick “latest”. The error is\n`Multiple ready page clients; clientId is required. Available clients: <id> ready <url>, <id> ready <url>`.\nZero ready with several ghosts is\n`No ready page client. Reloading: <id> (last url <url>), <id> (last url <url>)`.\nZero cards is `page client is not connected`.\n\nClosing the tab sends `page/goodbye`; the card is dropped. Opening a new tab is\na new id. If `page client \"<id>\" is not connected`, call `registry.clients` and\nretry without id when a single ready remains.\n\nClosing without goodbye (crash) looks like reload for up to 20s.\n\n## One tool: `page`\n\nOmit `act` to read the current surface. The broker **always asks the live tab**\n— a Craft `value:` that changed without a DOM mutation is still current. Pass\n`act` to run a batch, then receive the **new** state in the same round-trip.\n\nDefault `detail` is `\"controls\"`: the named interactive surface (id, role,\naccessible name, value, enabled, index, and `track` when the node is inside\n`forNode`). Pass `detail: \"dom-styles\"` only to debug layout or CSS — it is large\nand opt-in.\n\n`id` is the literal local name from the helper:\n\n\n\nThat name is unique in the app graph\n(`assertInteractiveElementNamed`). The renderer writes `data-craft-name=\"save\"`.\nDo not prefix it with the component name. When `forNode` repeats the same id, pass\n`match.index` or `match.track`.\n\n## Fill, click, goto, ready\n\n`act: [{ \"goto\": \"/login-form\" }]` navigates in the tab (Craft router).\nThe WebSocket stays up. Prefer `goto` over clicking `navLink` — every nav item\nshares that id. Paths like `/login-form` and full URLs both work.\n\nA `fill` sets the control and dispatches one `input` or `change` (then blur), so\n`CraftFieldDirective` validation and touched state run. A click is `act` with\nonly `id`. The batch runs in order and stops on the first error.\n\nWhile `ng serve` rebuilds, the socket drops but the broker **keeps** the client\ncard. `page` waits until the tab is `ready` again (up to `timeoutMs`, default\n20s). You do not poll.\n\n## See also\n\n- [Coding agents](/resources/ai-agents) — which MCP to use for docs vs the live tab\n- [Architecture rules](/guide/testing/architecture) — unique interactive names\n- [Observability](/guide/advanced/observability) — primitive traces, not DOM\n"
46
51
  },
52
+ {
53
+ "path": "/guide/ai/mcp-tools",
54
+ "title": "MCP tools",
55
+ "body": "# MCP tools\n\nCraftTS exposes separate MCP servers for documentation, a running application,\nlocal logs, and the static dependency graph. Connect only the server required\nfor the task.\n\n## Documentation MCP: `@craft-ts/mcp`\n\nInstall it in an application or run it without adding a dependency:\n\n```bash\nnpm install -D @craft-ts/mcp@beta\nnpx -y @craft-ts/mcp@beta\n```\n\nRegister it in `.mcp.json`:\n\n```json\n{\n \"mcpServers\": {\n \"craft-ts\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@craft-ts/mcp@beta\"]\n }\n }\n}\n```\n\n| Tool | Purpose |\n| ------------------------ | ------------------------------------------------------ |\n| `get_best_practices` | Load CraftTS rules and the `AGENTS.md` snippet |\n| `search_documentation` | Search Learn, Guide, Reference, Resources, or examples |\n| `get_documentation_page` | Read one documentation page as Markdown |\n| `find_examples` | Find tutorial and demo examples for a task |\n| `list_skills` | List the Agent Skills shipped with the package |\n| `get_skill` | Load a skill or one of its reference files |\n| `get_llms_txt` | Get the public `llms.txt` URLs and bundled page index |\n\nThis server is read-only and searches the documentation bundled at publish\ntime. It is the right starting point when an agent needs to understand the\nCraftTS API or conventions.\n\n## Live page and registry MCP\n\n`@craft-ts/function-registry-mcp` connects over stdio to an MCP client and over\nWebSocket to the running development tab:\n\n```bash\nnpm run registry:mcp\n```\n\nThe [Live page MCP](/guide/ai/dev-page) page explains the named-control\ncontract, client selection, and `page` actions.\n\n### Browser surface\n\n| Tool | Purpose |\n| ------------------ | -------------------------------------------------------------------------------------- |\n| `page` | Read named controls, then `goto`, `fill`, `click`, or `press` and return the new state |\n| `registry.clients` | List connected tabs and their `ready`, `connecting`, or `reloading` state |\n\nIf multiple tabs are ready, pass the explicit `clientId`. Never guess based on\nwhich tab connected most recently.\n\n### Registry surface\n\n| Tool | Purpose |\n| ------------------------ | ---------------------------------------------------------------------------- |\n| `registry.list` | List active registry entries |\n| `registry.get` | Read one registry entry |\n| `registry.call` | Invoke an active registry entry |\n| `registry.logs` | Read registry and bridge events |\n| `registry.override` | Replace a primitive method at runtime for development |\n| `registry.restore` | Remove a runtime override |\n| `registry.<primitive>.*` | Read or mutate `query`, `mutation`, `asyncProcess`, and `queryParams` values |\n\nThe primitive-specific tools are:\n\n```text\nregistry.query.get / set / update / patch\nregistry.mutation.get / set / update / patch\nregistry.asyncProcess.get / set / update / patch\nregistry.queryParams.get / set / update / patch\n```\n\n`query`, `mutation`, and `asyncProcess` support an optional `id` for grouped\ninstances. `queryParams` does not use grouped instance IDs. Runtime overrides\nand mutating value tools are development-only operations and should never be\nconnected to an untrusted or production browser.\n\n## Logs MCP: `@craft-ts/log-mcp`\n\nThe logs server reads JSONL files written by the Craft log server. It does not\ntalk to the browser or ingest logs itself:\n\n```bash\nnpm run logs:mcp\n```\n\n| Tool | Purpose |\n| ------------- | --------------------------------------------------------------------------- |\n| `logs.stats` | Summarise levels, host tags, client IDs, files, and time range |\n| `logs.search` | Filter by text, level, host ancestry, correlation ID, client, or time range |\n| `logs.tail` | Read the most recent entries |\n| `logs.clear` | Delete all active and rotated log files |\n\nUse `logs.stats` before `logs.search` to understand what is available. The\n`from` filter follows Craft host-tag ancestry, and `correlationId` finds all\nentries from one correlated flow.\n\n`logs.clear` is the only destructive tool in this server. Use it only when a\nclean reproduction is intentional.\n\n## Graph MCP: `@craft-ts/graph-mcp`\n\nThe graph server answers architecture questions about **your** project from the\nsame static analysis as the [architecture rules](/guide/testing/architecture):\nroutes, components, services, primitives and the proven relations between them.\nIt needs no running application. It reads `craft-dependency-graph.json` when the\nfile exists and otherwise analyses the TypeScript program.\n\n```bash\nnpm install -D @craft-ts/graph-mcp\n```\n\nProjects created with `craft create` already register it. In an existing\nproject, add it to `.mcp.json`:\n\n```json\n{\n \"mcpServers\": {\n \"craft-ts-graph\": { \"command\": \"npx\", \"args\": [\"craft-ts-graph-mcp\"] }\n }\n}\n```\n\n| Tool | Purpose |\n| ------------------ | -------------------------------------------------------------------------------- |\n| `graph.status` | Source and build time of the graph, counts per kind, diagnostics |\n| `graph.rebuild` | Re-analyse the program and overwrite the graph file |\n| `graph.search` | Find nodes by label or id |\n| `graph.node` | One node: metrics, relations with their proofs, optionally its source |\n| `graph.neighbors` | The subgraph around a node, up to three relations away |\n| `graph.path` | The shortest relation chains between two nodes |\n| `graph.impact` | Every node whose output may change when this node changes |\n| `graph.hotspots` | God nodes and hotspots, optionally weighted by git churn |\n| `graph.report` | The [graph report](/guide/testing/graph-insights#report) as JSON |\n| `graph.violations` | The rules `assertArchitecture` enforces, by name, with their messages |\n\nEvery answer carries `stale`: `true` when a source file changed after the graph\nwas built, `unknown` when no tsconfig is available to check. An agent calls\n`graph.rebuild` before answering about recent code.\n\n| Variable | Default |\n| ---------------------- | --------------------------------------------------------------------- |\n| `CRAFT_GRAPH_ROOT` | The current directory |\n| `CRAFT_GRAPH_TSCONFIG` | First of `tsconfig.graph.json`, `tsconfig.app.json`, `tsconfig.json` |\n| `CRAFT_GRAPH_FILE` | `craft-dependency-graph.json` |\n| `CRAFT_GRAPH_COVERAGE` | Unset; a `coverage-final.json` to attach coverage per node and route |\n| `CRAFT_GRAPH_DOCS` | Unset; comma-separated Markdown globs linked to the nodes they cite |\n| `CRAFT_GRAPH_READONLY` | Unset; `1` removes `graph.rebuild`, for CI or shared environments |\n\nAll tools except `graph.rebuild` are read-only, and `graph.rebuild` only writes\nthe graph file.\n\n### Projects created earlier\n\n`craft agents sync` adds today's agent wiring to a project generated by an\nolder CraftTS:\n\n```bash\nnpx craft agents sync --dry-run # list what would change\nnpx craft agents sync\nnpm install && npm run graph\n```\n\nIt adds the skills and hooks of the agents the project already uses (detected\nfrom `.agents/`, `.claude/`, `.cursor/` and `.gemini/`, or named with\n`--agents`), registers the graph server in `.mcp.json` without touching the\nservers already there, adds the `graph` and `graph:mcp` scripts and the\ndependency, and ignores the graph output. It never writes application code, and\na second run reports that nothing changed.\n\nDo not use `craft create --force` for this: it rewrites every generated file,\n`src/` included.\n\n## Which server should an agent use?\n\n| Situation | Server |\n| ------------------------------------------- | ------------------------------------------------------- |\n| “How should I write this Craft code?” | Documentation MCP |\n| “What is visible on the running page?” | Function registry MCP → `page` |\n| “What value does this query have?” | Function registry MCP → `registry.query.get` |\n| “What happened during this flow?” | Logs MCP → `logs.stats`, then `logs.search` |\n| “What does this service depend on?” | Graph MCP → `graph.search`, then `graph.node` |\n| “What can this change break?” | Graph MCP → `graph.impact` |\n| “What context should I send to an AI?” | `provideSendContextToAi`, then copy or send the payload |\n"
56
+ },
47
57
  {
48
58
  "path": "/guide/ai/send-context-webhook",
49
- "title": "Send context to an AI webhook",
50
- "body": "# Send context to an AI webhook\n\n`provideSendContextToAi` is copy-only by default. Configure an endpoint to make\nthe default chat send its generated context directly from the browser:\n\n\n\nThe primary action is then `Send`. The request is a JSON `POST` with this\nversioned shape:\n\n```json\n{\n \"version\": 1,\n \"prompt\": \"# Instruction\\nInvestigate this screen\",\n \"instruction\": \"Investigate this screen\",\n \"selectedElements\": [],\n \"events\": [],\n \"snapshot\": [],\n \"captures\": {},\n \"component\": {\n \"hostName\": \"OrdersPage\",\n \"tagList\": [\"component:OrdersPage#1\"],\n \"coords\": { \"x\": 120, \"y\": 80 },\n \"outerHTML\": \"<section>…</section>\"\n }\n}\n```\n\n`prompt` is the Markdown generated from the other fields and the selected\noptions. `component` is omitted when the chat was opened from the launcher\nwithout a captured component. `captures.component` and `captures.page` are\npresent only when their corresponding DOM/CSS options were selected. Timeline\nevents generated by the webhook itself are excluded from later payloads.\n\n## Success and failure\n\nAny `2xx` response is successful, including `200`, `202`, and `204`. Network\nfailures, timeouts, and non-`2xx` responses show an error in the chat. The\nfailed request is never copied automatically. `Retry` sends the exact same\npayload again, while `Copy payload` copies that same JSON only after the user\nasks for it. `Copy JSON` remains the separate timeline export action.\n\nWithout `endpoint`, the primary action remains `Copy prompt` and no webhook\nrequest is made.\n\n## Browser and security constraints\n\nThe endpoint must allow the browser application's origin through CORS, and it\nmust accept a JSON `POST`. The URL is application configuration shipped to the\nbrowser: it is not a secret. This first version has no configurable headers or\nsecret storage, so use a protected intermediary when the receiving agent needs\nauthentication.\n\n## Complete integration example\n\nThe frontend can point at a same-origin proxy or an explicitly CORS-enabled\nagent gateway:\n\n```ts\n// app.config.ts\nimport { provideSendContextToAi } from '@craft-ts/component';\n\nexport const providers = [\n provideSendContextToAi({\n endpoint: 'https://agent.example.com/hooks/context',\n }),\n];\n```\n\nThe gateway validates the version, forwards the Markdown prompt and structured\ncontext to the agent, and acknowledges the webhook with any `2xx` status:\n\n```ts\n// agent-gateway.ts (illustrative Express endpoint)\napp.post('/hooks/context', express.json(), async (request, response) => {\n const payload = request.body as {\n version: number;\n prompt: string;\n instruction: string;\n selectedElements: unknown[];\n events: unknown[];\n snapshot: unknown[];\n captures: Record<string, unknown>;\n };\n\n if (payload.version !== 1 || typeof payload.prompt !== 'string') {\n response.status(400).json({ error: 'Unsupported context payload' });\n return;\n }\n\n await agent.run({\n prompt: payload.prompt,\n context: {\n instruction: payload.instruction,\n selectedElements: payload.selectedElements,\n events: payload.events,\n snapshot: payload.snapshot,\n captures: payload.captures,\n },\n });\n response.sendStatus(202);\n});\n```\n"
59
+ "title": "Send application context to AI",
60
+ "body": "# Send application context to AI\n\n`provideSendContextToAi` adds a developer-oriented context inspector to a Craft\napplication. It is useful when an AI assistant needs more than a copied error\nmessage: the selected component, the recent user journey, the relevant app\nstate, and optionally the DOM and computed CSS.\n\nTypical uses include:\n\n- asking an AI assistant to explain or fix a broken screen;\n- preparing a reproducible bug report or a support ticket;\n- investigating a failed HTTP request, navigation, mutation, or query;\n- sending a consistent, structured context to an internal debugging agent.\n\nThe feature is user-driven. It does not call an AI service by itself. Without\nan `endpoint`, everything stays in the browser and the user copies the prompt\nor the timeline when they choose to.\n\n## Minimal setup\n\nRegister the provider once in the application providers. The default UI then\nadds an `AI context` launcher in the bottom-right corner and a context menu to\nCraft component hosts.\n\n\n\nThe two entry points are equivalent:\n\n- open the launcher to start with an empty context, then interact with the app;\n- right-click a component to start with that component already selected.\n\nFrom the chat, the user can add more elements with another right-click, remove\nselected elements, write an instruction, record or clear the timeline, and\nchoose which sections to include in the generated Markdown prompt.\n\n## What is collected\n\nThe session combines several kinds of context:\n\n- **Selected elements**: tag name, text, outer HTML, and optionally a selector.\n- **Component information**: the host name, Craft host tags, click coordinates,\n the clicked element, and truncated host HTML.\n- **Timeline**: DOM interactions, HTTP requests, router activity, primitive\n activity, and app snapshot reports. HTTP and navigation entries are linked by\n operation and correlation IDs when those services provide them.\n- **App snapshots**: the reports emitted by the app snapshot registry while the\n context is being prepared.\n- **DOM and CSS captures**: the selected component or the full page, including\n computed styles. These captures are optional because they can be large and\n briefly pause the page while they are collected.\n\nThe default prompt contains selected elements, component information, the\ntimeline summary, and app snapshots when they exist. Timeline JSON and DOM/CSS\ncaptures are opt-in. The checkboxes in the chat change the Markdown prompt;\nthe webhook also receives the structured fields so an agent can process them\nwithout parsing Markdown.\n\nThe chat also supports recording a named **clip**. A clip is a subset of the\ntimeline, which is useful when an investigation contains several unrelated\ninteractions. `Copy JSON` exports the visible timeline (or the selected clip),\nwhereas `Copy prompt` builds the AI-oriented Markdown document.\n\n## Send the context to an agent\n\nPass a browser-accessible webhook URL to enable the `Send` action:\n\n\n\nThe browser sends a JSON `POST` with this versioned shape:\n\n```json\n{\n \"version\": 1,\n \"prompt\": \"# Instruction\\nInvestigate this screen\",\n \"instruction\": \"Investigate this screen\",\n \"selectedElements\": [],\n \"events\": [],\n \"snapshot\": [],\n \"captures\": {},\n \"component\": {\n \"hostName\": \"OrdersPage\",\n \"tagList\": [\"component:OrdersPage#1\"],\n \"coords\": { \"x\": 120, \"y\": 80 },\n \"outerHTML\": \"<section>…</section>\"\n }\n}\n```\n\n`prompt` is generated from the instruction, the selected options, and the\nstructured fields. `component` is omitted when the chat was opened from the\nlauncher without a captured component. `captures.component` and\n`captures.page` are present only when their corresponding DOM/CSS options were\nselected. Events generated by the webhook request itself are excluded from\nthe context sent to that webhook.\n\nAny `2xx` response is successful, including `200`, `202`, and `204`. Network\nfailures, timeouts, and non-`2xx` responses are shown in the chat. `Retry`\nreuses the exact same payload, and `Copy payload` copies that payload only\nafter a failed request.\n\nThe endpoint is application configuration shipped to the browser, not a\nsecret. It must allow the application's origin through CORS and accept JSON\n`POST` requests. Put authentication and secret management in a protected\nsame-origin proxy or agent gateway.\n\n## Customize the collected context with DI\n\n`provideSendContextToAi()` installs the default session, but the session reads\nits policy and extensions from injection tokens. These providers can be placed\nalongside it in `appConfig`:\n\n\n\n### Retention, redaction, and serialization\n\n`SEND_CONTEXT_RETENTION_POLICY` limits the number and approximate size of\nevents kept in memory. The default is 500 events and 2 MiB.\n\n`SEND_CONTEXT_REDACTOR` runs before values are serialized. Use it to remove\napplication-specific secrets or personal data. The built-in redactor already\nredacts keys matching `password`, `secret`, `token`, `authorization`, and\n`cookie`; replacing it means taking responsibility for the complete policy.\n\n`SEND_CONTEXT_VALUE_SERIALIZER` converts values that are not naturally JSON\nfriendly, such as `Date`, `Error`, `BigInt`, functions, or circular objects.\nBoth hooks apply to event `payload`, `response`, and `state` values.\n\n### Add, enrich, or filter events\n\nUse the multi providers to extend the timeline without changing feature code:\n\n- `provideSendContextEventSource(...)` connects an application event bus to the\n session. The source may return a cleanup function.\n- `provideSendContextEventEnricher(...)` adds common metadata such as a tenant,\n release, route, or feature flag to every event.\n- `provideSendContextEventFilter(...)` drops noisy or sensitive events before\n they enter the session.\n\nAn event source emits through `session.capture(...)` or `session.emit(...)`.\nThe `kind` can be one of the built-in kinds (`dom`, `http`, `navigation`,\n`primitive`, `snapshot`, `custom`) or an application-specific string.\n\nThe session and its record controller are also injectable:\n\n```ts\nimport {\n SEND_CONTEXT_RECORD_CONTROLLER,\n SEND_CONTEXT_SESSION,\n} from '@craft-ts/component';\nimport { ɵinject as inject } from '@craft-ts/core';\n\nconst record = inject(SEND_CONTEXT_RECORD_CONTROLLER);\nrecord.startRecord('Checkout failure');\n\n// Later, from the same application flow:\nrecord.stopRecord();\nconst summary = record.exportSummary();\nconst json = record.exportJson();\n\nconst session = inject(SEND_CONTEXT_SESSION);\nsession.capture('custom', 'emitted', {\n name: 'checkout.validation',\n state: { step: 'payment' },\n});\n```\n\nUse the record controller for clips and the session for low-level event\nemission, subscription, clearing, and programmatic export.\n\n## Customize the UI with DI\n\nThere are three levels of UI customization:\n\n| Provider | What it replaces or adds | When to use it |\n| ------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ |\n| `provideSendContextChatComponent(() => MyChat)` | The default chat panel | Keep the built-in launcher and context menu, but replace the panel |\n| `provideSendContextUiRenderer(() => MyRenderer)` | The complete renderer | Own the launcher/chat lifecycle and render the whole experience |\n| `SEND_CONTEXT_LAUNCHER_COMPONENT` / `SEND_CONTEXT_CONTEXT_MENU_COMPONENT` | The floating launcher or right-click menu | Match the application's controls or visual language |\n\nThe complete renderer receives `SendContextUiContext`. It exposes the live\nsession, events, clips, selected targets, captured payload, DOM capture element,\nrecording state, endpoint, and operations such as `addTarget`, `removeTarget`,\n`selectClip`, and `close`.\n\n```ts\nimport {\n provideSendContextChatComponent,\n provideSendContextUiRenderer,\n type SendContextUiContext,\n} from '@craft-ts/component';\n\n// A chat replacement keeps the default surrounding behavior.\nprovideSendContextChatComponent(() => MyChat);\n\n// A complete renderer receives the live context as its `context` input.\nprovideSendContextUiRenderer(() => MyRenderer);\n\n// MyRenderer's input contract is:\n// { context: Input<SendContextUiContext>; onClose: Output<() => void> }\n```\n\n`provideSendContextChatSection`, `provideSendContextChatAction`, and\n`provideSendContextExportSection` are multi providers intended for a custom\nrenderer. They let feature libraries contribute sections, commands, or export\nviews without depending on one global renderer. The built-in chat does not\nrender those extension entries itself; a custom renderer reads them from\n`SendContextUiContext`.\n\nFor example, a feature can contribute an action that starts a named clip:\n\n```ts\nimport { provideSendContextChatAction } from '@craft-ts/component';\n\nconst providers = [\n provideSendContextChatAction({\n id: 'record-checkout',\n label: 'Record checkout flow',\n run: (context) => context.session.startRecord('Checkout flow'),\n }),\n];\n```\n\nThe UI provider tokens are regular DI contracts, so a custom launcher or menu\ncan be registered directly:\n\n```ts\nimport {\n SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n SEND_CONTEXT_LAUNCHER_COMPONENT,\n} from '@craft-ts/component';\n\nconst providers = [\n {\n provide: SEND_CONTEXT_LAUNCHER_COMPONENT,\n useValue: MyLauncher,\n },\n {\n provide: SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n useValue: MyContextMenu,\n },\n];\n```\n\n## Complete integration example\n\nAn application can combine the default UI, a protected endpoint, stricter\nretention, and application-specific event filtering:\n\n```ts\nimport { craftAppConfig } from '@craft-ts/core';\nimport {\n provideSendContextEventFilter,\n provideSendContextToAi,\n SEND_CONTEXT_RETENTION_POLICY,\n} from '@craft-ts/component';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideSendContextToAi({\n endpoint: '/internal/ai/context',\n }),\n {\n provide: SEND_CONTEXT_RETENTION_POLICY,\n useValue: { maxEvents: 250, maxBytes: 1024 * 1024 },\n },\n provideSendContextEventFilter((event) => event.name !== 'healthcheck'),\n ],\n});\n```\n\nThe server-side endpoint should validate `version`, authenticate the user,\napply any additional server-side redaction, and then forward either `prompt`\nor the structured context to the selected agent.\n"
51
61
  },
52
62
  {
53
63
  "path": "/guide/app/abstract-services",
@@ -217,7 +227,7 @@
217
227
  {
218
228
  "path": "/guide/forms",
219
229
  "title": "Forms",
220
- "body": "# Forms\n\nThere is no `FormBuilder` here. **A form is derived from a state** — its field\ntree, its validity and its error types are all consequences of that state and of\nthe mutation it submits to, so they cannot drift apart from them.\n\n**Use it when** you collect input that needs validation and a typed submission.\n**Not when** a single input maps to a single state — a plain\n[`state`](/guide/state/local-state) with a `set` is enough.\n\n::: tip Start with the guided version\n[Learn step 8](/learn/08-forms) builds a small form end to end before you dig\ninto the individual insertions.\n:::\n\n## Choose the insertion\n\nWhen a requirement mentions a form, start from `state` and add `insertForm`.\nThis map keeps the form tree, validation and mutation in one graph:\n\n| Need | Recommended API |\n| ------------------------------- | -------------------------------------- |\n| Form derived from state | `state` + `insertForm` |\n| Nested field or object branch | `insertSelectFormTree` |\n| Field attributes and validation | `insertFormAttributes` |\n| Whole-form validation | `insertFormSchema` |\n| Submit to a mutation | `insertFormSubmit` |\n| Field errors | `field.exceptions` or `fieldErrorNode` |\n| Submission state | `form().submitting()` |\n\n`insertNoopTypingAnchor` is only a type-inference anchor for a selected field;\nit adds no runtime behaviour. The common field shape is therefore:\n\n```ts\ninsertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n);\n```\n\n## Native controls, one binding rule\n\n`CraftFieldDirective` supports text inputs, checkboxes, selects and textareas.\nKeep a stable `id`/`htmlFor` pair and put the directive on the native control:\n\n```ts\nimport { input, option, select, textarea } from '@craft-ts/component';\n\ninput('animal-name', { id: 'animal-name' }).pipe(\n CraftFieldDirective(animal.form.selectName()),\n);\n\ninput('animal-available', { id: 'animal-available', type: 'checkbox' }).pipe(\n CraftFieldDirective(animal.form.selectAvailable()),\n);\n\nselect('animal-species', { id: 'animal-species' }, [\n option('dog', { value: 'dog' }, 'Dog'),\n option('cat', { value: 'cat' }, 'Cat'),\n]).pipe(CraftFieldDirective(animal.form.selectSpecies()));\n\ntextarea('animal-notes', { id: 'animal-notes' }).pipe(\n CraftFieldDirective(animal.form.selectNotes()),\n);\n```\n\nA checkbox maps to a boolean, a select to its option value, and a textarea to\na string; nested fields use the corresponding selector chain.\n\n## A complete form in one file\n\nThis is the shortest complete path: typed state, required/email validation, a\nmutation, server exceptions, submitting state and errors rendered next to the\ncontrols. The text in a real application should come from its i18n catalogue.\n\n```ts\nimport {\n button,\n craftComponent,\n fieldErrorNode,\n form,\n input,\n label,\n p,\n} from '@craft-ts/component';\nimport {\n cEmail,\n cRequired,\n CraftFieldDirective,\n craftException,\n insertForm,\n insertFormAttributes,\n insertFormSubmit,\n insertNoopTypingAnchor,\n insertSelectFormTree,\n mutation,\n state,\n type ValidatedFormValue,\n} from '@craft-ts/core';\n\ntype Animal = { name: string; email: string };\n\nconst saveAnimal = mutation('saveAnimal', {\n method: (value: NonNullable<ValidatedFormValue<Animal>>) => value,\n loader: ({ params }) =>\n params.email.endsWith('@taken.test')\n ? craftException({ _tag: 'EMAIL_ALREADY_USED' }, { field: 'email' })\n : params,\n});\n\nexport const AnimalForm = craftComponent(\n 'AnimalForm',\n {},\n function* () {\n const animal = yield* state(\n 'animalForm',\n { name: '', email: '' } satisfies Animal,\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n ),\n insertFormSubmit(saveAnimal),\n ),\n );\n return { animal };\n },\n ({ animal }) =>\n form(\n 'animal-form',\n {\n *submit(event) {\n event.preventDefault();\n yield* animal.form.submit();\n },\n },\n [\n label({ htmlFor: 'animal-name' }, 'Name'),\n input('animal-name', { id: 'animal-name' })\n .pipe(CraftFieldDirective(animal.form.selectName()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Name is required.'),\n }),\n ),\n label({ htmlFor: 'animal-email' }, 'Email'),\n input('animal-email', { id: 'animal-email', type: 'email' })\n .pipe(CraftFieldDirective(animal.form.selectEmail()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n ),\n button(\n 'animal-submit',\n { type: 'submit', disabled: animal.form.submitting },\n 'Save',\n ),\n p(function* () {\n if (!(yield* animal.form.hasSubmitExceptions())) return '';\n return 'The server rejected this animal.';\n }),\n ],\n ),\n);\n```\n\nThe advanced version uses the same primitives for nested `address` fields,\nconditional visibility and asynchronous validation. Keep the branch insertion\nin the feature rather than hiding it in a component library; see\n[Nested forms](/guide/forms/nested) and\n[Validation](/guide/forms/validation#casyncvalidate).\n\n## Why it is shaped this way\n\nThree pillars, all of which follow from deriving rather than declaring:\n\n1. **Form Insertions** - Modular composition to tackle logic complexity\n2. **Type-safe errors** - Synchronous and asynchronous validation with type-safe exceptions (inferred from validators and submit handler)\n3. **Parallel Forms** - Support for multiple forms in the same state with automatic scoping\n\nAll of this is possible because the logic is entirely derived from the state.\n\n## Form Insertions\n\nForm insertions enable modular composition of functionality:\n\n### insertForm\n\nThe primary insertion that derives a typed form from a primitive.\n\n```ts\nimport { craftUse, state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertNoopTypingAnchor,\n insertSelectFormTree,\n cRequired,\n cEmail,\n} from '@craft-ts/core';\n\nconst userFormState = craftUse(\n state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n })),\n ),\n ),\n ),\n);\n\nconst form = userFormState.form;\nconst nameField = form.selectName();\nconst emailField = form.selectEmail();\n```\n\n> Note: It only works with the `state` primitive from now.\n\n> `insertNoopTypingAnchor` is a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...)\n\n### insertFormAttributes\n\nAdds attributes and validators to a form field.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n disable: () => isLoading(),\n hidden: () => !showField(),\n })),\n ),\n ),\n ),\n);\n\n// Access email field and its exceptions\nconst form = formState.form;\nconst emailField = form.selectEmail();\nconst errors = emailField()().exceptions.list; // fully typed list of exceptions\nconst emailError = emailField()().exceptions.byValidator['cEmail'];\n```\n\n### Bind a field to the DOM\n\n`CraftFieldDirective` is the DOM adapter for a `CraftField`. It binds the field\nin both directions, marks it touched on blur, and reflects field state through\nnative attributes and `craft-*` CSS classes.\n\nIn a Craft template, apply the functional directive to the concrete node:\n\n```ts\nimport { CraftFieldDirective } from '@craft-ts/core';\n\ninput({\n type: 'email',\n}).pipe(CraftFieldDirective(loginForm.form.selectEmail()));\n```\n\n`insertSelectFormTree` materializes its branch lazily. When validators or other\ninsertions are attached through it, bind the field returned by `selectEmail()`\n(or the corresponding `selectXxx()` method). Binding the raw\n`loginForm.form.email` field bypasses that materialization, so those insertions\nare not registered.\n\nThe directive supports text inputs and textareas, numeric and temporal inputs,\ncheckboxes, radio groups and selects. Validators also project native constraints\nsuch as `required`, `min`, `max`, `minlength` and `maxlength`.\n\nFor a custom control, provide `CRAFT_FIELD_VALUE_CONTROL` or\n`CRAFT_FIELD_CHECKBOX_CONTROL` on the component root. Native Craft nodes use the\nfunctional directive directly.\n\n### Render validation exceptions exhaustively\n\n`fieldErrorNode.exhaustive` turns validation cases carried by\n`CraftFieldDirective` or exposed by the component logic into compile-time UI\nobligations. Every reachable code must have one handler, and an unreachable\nhandler is also rejected.\n\n```ts\nimport { fieldErrorNode, input, p } from '@craft-ts/component';\n\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n );\n```\n\nThe field stays mounted and invalid while a message is visible. The block adds\nand merges `aria-invalid` and `aria-describedby`; it does not throw an\nexception or feed route `handleExceptions`.\n\nUse `fieldErrorNode.partial` when only some codes belong near the field.\nHandled codes are removed from its contract and the remaining codes continue\nto the next field-exception boundary:\n\n```ts\ninput({ id: 'password', type: 'password' })\n .pipe(CraftFieldDirective(loginForm.form.selectPassword()))\n .pipe(\n fieldErrorNode.partial({\n required: () => p('Password is required.'),\n }),\n );\n```\n\nHere `password.required` is handled locally, while `password.minLength` must\nstill be handled by an enclosing `partial` or `exhaustive` block. A partial\nblock may omit reachable codes, but an unreachable handler remains a TypeScript\nerror.\n\nAt a component boundary, group handlers by static field path. Identical codes\non different fields remain separate obligations:\n\n```ts\nconst SafeLoginForm = BaseLoginForm.pipe(\n fieldErrorNode.exhaustive({\n email: {\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n },\n password: {\n required: () => p('Password is required.'),\n minLength: ({ exception }) =>\n p(`Use at least ${exception.payload} characters.`),\n },\n }),\n);\n```\n\nObject branches may also carry group or cross-field validators. Materialize the\nbranch in the component logic and return it from the factory:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts cases, for example `credentials.passwordMismatch`, are part of the\ncomponent contract even when the group itself is not passed to\n`CraftFieldDirective`. Handle the grouped path on an enclosing template VNode\nor with `BaseComponent.pipe(fieldErrorNode.exhaustive(...))`. If it remains\nunhandled, rendering, mounting, and `loadCraftComponent` reject the component\nat compile time. See [Form exception handling](/guide/forms/exceptions) for the\ncomplete group example.\n\nBy default the block reads the field's `visibleExceptions` directly. The form\nowns that visibility policy; the default is touched or submitted:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only that field's visible exceptions are rendered. A submit\nattempt reveals the remaining exceptions for every field. Available states are\n`dirty`, `touched`, and `submitted`; a block can override\nthe inherited policy with `visibility: 'always'`, another `anyOf` combination,\nor a predicate. `mode` is `first` (validator order) or `all`, and `position` is\n`before` or `after`. Resetting the form clears dirty, touched, and submitted,\nso inherited messages are hidden again.\n\nCustom and async validators participate through their declared exception\nunion exactly like built-ins: their codes must be handled even when the current\nvisibility policy hides them.\n\n### insertFormSchema\n\nAdds a form-level `StandardSchemaV1` validator. Issues are projected onto the\nmatching fields by their schema path, while root and unmaterialized issues stay\navailable through `schemaExceptions()`.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)),\n ),\n);\nconst form = formState.form;\n\nform.email.errors();\nform.hasSchemaExceptions();\nform.schemaExceptions();\n```\n\nThe form keeps the schema input value. Schema transformations belong at the\nsubmit boundary, for example through the mutation's `methodSchema`.\n\n### insertFormSubmit\n\n`insertFormSubmit` connects the form to a mutation. It submits only validated\nform values and exposes the mutation's loading and typed exception state on the\nform.\n\nSee [Submitting a form](/guide/forms/submit) for the complete submission\nworkflow, including success handling and exception transformations.\n\n## The pages\n\n- **[Validation](/guide/forms/validation)** — built-in, custom and async validators\n- **[Submitting](/guide/forms/submit)** — wiring a form to a mutation, typed submit exceptions\n- **[Nested forms](/guide/forms/nested)** — sub-trees and sub-form fields\n- **[Exception handling](/guide/forms/exceptions)** — reading and shaping form errors\n- **[Complete examples](/guide/forms/examples)** — two forms end to end\n\n## See Also\n\n- [Validators](/guide/forms/validation)\n- [Submitting](/guide/forms/submit)\n- [Learn step 8](/learn/08-forms) — a form built end to end\n"
230
+ "body": "# Forms\n\nThere is no `FormBuilder` here. **A form is derived from a state** — its field\ntree, its validity and its error types are all consequences of that state and of\nthe mutation it submits to, so they cannot drift apart from them.\n\n**Use it when** you collect input that needs validation and a typed submission.\n**Not when** a single input maps to a single state — a plain\n[`state`](/guide/state/local-state) with a `set` is enough.\n\n::: tip Start with the guided version\n[Learn step 8](/learn/08-forms) builds a small form end to end before you dig\ninto the individual insertions.\n:::\n\n## Choose the insertion\n\nWhen a requirement mentions a form, start from `state` and add `insertForm`.\nThis map keeps the form tree, validation and mutation in one graph:\n\n| Need | Recommended API |\n| ------------------------------- | -------------------------------------- |\n| Form derived from state | `state` + `insertForm` |\n| Nested field or object branch | `insertSelectFormTree` |\n| Field attributes and validation | `insertFormAttributes` |\n| Whole-form validation | `insertFormSchema` |\n| Submit to a mutation | `insertFormSubmit` |\n| Field errors | `field.exceptions` or `fieldErrorNode` |\n| Submission state | `form().submitting()` |\n\n`insertNoopTypingAnchor` is only a type-inference anchor for a selected field;\nit adds no runtime behaviour. The common field shape is therefore:\n\n```ts\ninsertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n);\n```\n\n## When an input drives an action\n\nThe recommended ESLint preset enables\n`craft-ts/require-form-for-input-action`. It catches the common interaction\nwhere an input writes a local value and a button later reads that value to call\nan action such as `mutation.mutate(...)` or `asyncProcess.method(...)`. The\ndependency may be nested in a record or pass through a local variable; it does\nnot have to be the only argument.\nThat interaction is a form workflow, even when it currently has only one\nfield:\n\n```ts\n// ❌ input state is assembled by hand and consumed by a button action\nconst title = yield* state('title', '');\n\ninput({ value: title, input: (event) => setTitle(eventValue(event)) });\nbutton({ click: () => addTodo.mutate(title()) }, 'Add');\n```\n\nModel the value and the submit boundary together instead:\n\n```ts\n// ✅ state + insertForm + form submit\nconst titleForm = yield* state(\n 'titleForm',\n '',\n insertForm(\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n insertFormSubmit(addTodo),\n ),\n);\n\nform('AddTodoForm', {\n *submit(event) {\n event.preventDefault();\n yield* titleForm.form.submit();\n },\n}, [\n input('TodoTitleInput', { type: 'text' }).pipe(\n CraftFieldDirective(titleForm.form),\n ),\n button('AddTodoButton', { type: 'submit' }, 'Add'),\n]);\n```\n\nThe rule is intentionally conservative: it does not report an input unless an\naction in the same Craft template consumes the input's bound value. It also\ndoes not replace type checking: validation, `insertFormAttributes`,\n`CraftFieldDirective`, field errors, and the exact `insertFormSubmit` wiring\nremain the form author's responsibility. The architecture check adds the\ncross-file constraint: a mutation-backed form must use `insertFormSubmit` and a\nnative `type: 'submit'` button; the button must not call `mutate(...)` directly.\n\n`insertForm()` by itself is a valid form insertion, but it is only the base\nderivation. Add `insertFormAttributes` when the form has validators or field\nrules, and add `insertFormSubmit(mutation)` when the form owns a mutation\nsubmission. `insertSelectFormTree` is needed for fields inside an object-valued\nform; it is not needed for a scalar string form.\n\n## Native controls, one binding rule\n\n`CraftFieldDirective` supports text inputs, checkboxes, selects and textareas.\nKeep a stable `id`/`htmlFor` pair and put the directive on the native control:\n\n```ts\nimport { input, option, select, textarea } from '@craft-ts/component';\n\ninput('animal-name', { id: 'animal-name' }).pipe(\n CraftFieldDirective(animal.form.selectName()),\n);\n\ninput('animal-available', { id: 'animal-available', type: 'checkbox' }).pipe(\n CraftFieldDirective(animal.form.selectAvailable()),\n);\n\nselect('animal-species', { id: 'animal-species' }, [\n option('dog', { value: 'dog' }, 'Dog'),\n option('cat', { value: 'cat' }, 'Cat'),\n]).pipe(CraftFieldDirective(animal.form.selectSpecies()));\n\ntextarea('animal-notes', { id: 'animal-notes' }).pipe(\n CraftFieldDirective(animal.form.selectNotes()),\n);\n```\n\nA checkbox maps to a boolean, a select to its option value, and a textarea to\na string; nested fields use the corresponding selector chain.\n\n## A complete form in one file\n\nThis is the shortest complete path: typed state, required/email validation, a\nmutation, server exceptions, submitting state and errors rendered next to the\ncontrols. The text in a real application should come from its i18n catalogue.\n\n```ts\nimport {\n button,\n craftComponent,\n fieldErrorNode,\n form,\n input,\n label,\n p,\n} from '@craft-ts/component';\nimport {\n cEmail,\n cRequired,\n CraftFieldDirective,\n craftException,\n insertForm,\n insertFormAttributes,\n insertFormSubmit,\n insertNoopTypingAnchor,\n insertSelectFormTree,\n mutation,\n state,\n type ValidatedFormValue,\n} from '@craft-ts/core';\n\ntype Animal = { name: string; email: string };\n\nconst saveAnimal = mutation('saveAnimal', {\n method: (value: NonNullable<ValidatedFormValue<Animal>>) => value,\n loader: ({ params }) =>\n params.email.endsWith('@taken.test')\n ? craftException({ _tag: 'EMAIL_ALREADY_USED' }, { field: 'email' })\n : params,\n});\n\nexport const AnimalForm = craftComponent(\n 'AnimalForm',\n {},\n function* () {\n const animal = yield* state(\n 'animalForm',\n { name: '', email: '' } satisfies Animal,\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n ),\n insertFormSubmit(saveAnimal),\n ),\n );\n return { animal };\n },\n ({ animal }) =>\n form(\n 'animal-form',\n {\n *submit(event) {\n event.preventDefault();\n yield* animal.form.submit();\n },\n },\n [\n label({ htmlFor: 'animal-name' }, 'Name'),\n input('animal-name', { id: 'animal-name' })\n .pipe(CraftFieldDirective(animal.form.selectName()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Name is required.'),\n }),\n ),\n label({ htmlFor: 'animal-email' }, 'Email'),\n input('animal-email', { id: 'animal-email', type: 'email' })\n .pipe(CraftFieldDirective(animal.form.selectEmail()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n ),\n button(\n 'animal-submit',\n { type: 'submit', disabled: animal.form.submitting },\n 'Save',\n ),\n p(function* () {\n if (!(yield* animal.form.hasSubmitExceptions())) return '';\n return 'The server rejected this animal.';\n }),\n ],\n ),\n);\n```\n\nThe advanced version uses the same primitives for nested `address` fields,\nconditional visibility and asynchronous validation. Keep the branch insertion\nin the feature rather than hiding it in a component library; see\n[Nested forms](/guide/forms/nested) and\n[Validation](/guide/forms/validation#casyncvalidate).\n\n## Why it is shaped this way\n\nThree pillars, all of which follow from deriving rather than declaring:\n\n1. **Form Insertions** - Modular composition to tackle logic complexity\n2. **Type-safe errors** - Synchronous and asynchronous validation with type-safe exceptions (inferred from validators and submit handler)\n3. **Parallel Forms** - Support for multiple forms in the same state with automatic scoping\n\nAll of this is possible because the logic is entirely derived from the state.\n\n## Form Insertions\n\nForm insertions enable modular composition of functionality:\n\n### insertForm\n\nThe primary insertion that derives a typed form from a primitive.\n\n```ts\nimport { craftUse, state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertNoopTypingAnchor,\n insertSelectFormTree,\n cRequired,\n cEmail,\n} from '@craft-ts/core';\n\nconst userFormState = craftUse(\n state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n })),\n ),\n ),\n ),\n);\n\nconst form = userFormState.form;\nconst nameField = form.selectName();\nconst emailField = form.selectEmail();\n```\n\n> Note: It only works with the `state` primitive from now.\n\n> `insertNoopTypingAnchor` is a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...)\n\n### insertFormAttributes\n\nAdds attributes and validators to a form field.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n disable: () => isLoading(),\n hidden: () => !showField(),\n })),\n ),\n ),\n ),\n);\n\n// Access email field and its exceptions\nconst form = formState.form;\nconst emailField = form.selectEmail();\nconst errors = emailField()().exceptions.list; // fully typed list of exceptions\nconst emailError = emailField()().exceptions.byValidator['cEmail'];\n```\n\n### Bind a field to the DOM\n\n`CraftFieldDirective` is the DOM adapter for a `CraftField`. It binds the field\nin both directions, marks it touched on blur, and reflects field state through\nnative attributes and `craft-*` CSS classes.\n\nIn a Craft template, apply the functional directive to the concrete node:\n\n```ts\nimport { CraftFieldDirective } from '@craft-ts/core';\n\ninput({\n type: 'email',\n}).pipe(CraftFieldDirective(loginForm.form.selectEmail()));\n```\n\n`insertSelectFormTree` materializes its branch lazily. When validators or other\ninsertions are attached through it, bind the field returned by `selectEmail()`\n(or the corresponding `selectXxx()` method). Binding the raw\n`loginForm.form.email` field bypasses that materialization, so those insertions\nare not registered.\n\nThe directive supports text inputs and textareas, numeric and temporal inputs,\ncheckboxes, radio groups and selects. Validators also project native constraints\nsuch as `required`, `min`, `max`, `minlength` and `maxlength`.\n\nFor a custom control, provide `CRAFT_FIELD_VALUE_CONTROL` or\n`CRAFT_FIELD_CHECKBOX_CONTROL` on the component root. Native Craft nodes use the\nfunctional directive directly.\n\n### Render validation exceptions exhaustively\n\n`fieldErrorNode.exhaustive` turns validation cases carried by\n`CraftFieldDirective` or exposed by the component logic into compile-time UI\nobligations. Every reachable code must have one handler, and an unreachable\nhandler is also rejected.\n\n```ts\nimport { fieldErrorNode, input, p } from '@craft-ts/component';\n\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldErrorNode.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n );\n```\n\nThe field stays mounted and invalid while a message is visible. The block adds\nand merges `aria-invalid` and `aria-describedby`; it does not throw an\nexception or feed route `handleExceptions`.\n\nUse `fieldErrorNode.partial` when only some codes belong near the field.\nHandled codes are removed from its contract and the remaining codes continue\nto the next field-exception boundary:\n\n```ts\ninput({ id: 'password', type: 'password' })\n .pipe(CraftFieldDirective(loginForm.form.selectPassword()))\n .pipe(\n fieldErrorNode.partial({\n required: () => p('Password is required.'),\n }),\n );\n```\n\nHere `password.required` is handled locally, while `password.minLength` must\nstill be handled by an enclosing `partial` or `exhaustive` block. A partial\nblock may omit reachable codes, but an unreachable handler remains a TypeScript\nerror.\n\nAt a component boundary, group handlers by static field path. Identical codes\non different fields remain separate obligations:\n\n```ts\nconst SafeLoginForm = BaseLoginForm.pipe(\n fieldErrorNode.exhaustive({\n email: {\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n },\n password: {\n required: () => p('Password is required.'),\n minLength: ({ exception }) =>\n p(`Use at least ${exception.payload} characters.`),\n },\n }),\n);\n```\n\nObject branches may also carry group or cross-field validators. Materialize the\nbranch in the component logic and return it from the factory:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts cases, for example `credentials.passwordMismatch`, are part of the\ncomponent contract even when the group itself is not passed to\n`CraftFieldDirective`. Handle the grouped path on an enclosing template VNode\nor with `BaseComponent.pipe(fieldErrorNode.exhaustive(...))`. If it remains\nunhandled, rendering, mounting, and `loadCraftComponent` reject the component\nat compile time. See [Form exception handling](/guide/forms/exceptions) for the\ncomplete group example.\n\nBy default the block reads the field's `visibleExceptions` directly. The form\nowns that visibility policy; the default is touched or submitted:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only that field's visible exceptions are rendered. A submit\nattempt reveals the remaining exceptions for every field. Available states are\n`dirty`, `touched`, and `submitted`; a block can override\nthe inherited policy with `visibility: 'always'`, another `anyOf` combination,\nor a predicate. `mode` is `first` (validator order) or `all`, and `position` is\n`before` or `after`. Resetting the form clears dirty, touched, and submitted,\nso inherited messages are hidden again.\n\nCustom and async validators participate through their declared exception\nunion exactly like built-ins: their codes must be handled even when the current\nvisibility policy hides them.\n\n### insertFormSchema\n\nAdds a form-level `StandardSchemaV1` validator. Issues are projected onto the\nmatching fields by their schema path, while root and unmaterialized issues stay\navailable through `schemaExceptions()`.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)),\n ),\n);\nconst form = formState.form;\n\nform.email.errors();\nform.hasSchemaExceptions();\nform.schemaExceptions();\n```\n\nThe form keeps the schema input value. Schema transformations belong at the\nsubmit boundary, for example through the mutation's `methodSchema`.\n\n### insertFormSubmit\n\n`insertFormSubmit` connects the form to a mutation. It submits only validated\nform values and exposes the mutation's loading and typed exception state on the\nform.\n\nSee [Submitting a form](/guide/forms/submit) for the complete submission\nworkflow, including success handling and exception transformations.\n\n## The pages\n\n- **[Validation](/guide/forms/validation)** — built-in, custom and async validators\n- **[Submitting](/guide/forms/submit)** — wiring a form to a mutation, typed submit exceptions\n- **[Nested forms](/guide/forms/nested)** — sub-trees and sub-form fields\n- **[Exception handling](/guide/forms/exceptions)** — reading and shaping form errors\n- **[Complete examples](/guide/forms/examples)** — two forms end to end\n\n## See Also\n\n- [Validators](/guide/forms/validation)\n- [Submitting](/guide/forms/submit)\n- [Learn step 8](/learn/08-forms) — a form built end to end\n"
221
231
  },
222
232
  {
223
233
  "path": "/guide/forms/examples",
@@ -232,12 +242,12 @@
232
242
  {
233
243
  "path": "/guide/forms/nested",
234
244
  "title": "Nested forms",
235
- "body": "# Nested forms\n\n`insertSelectFormTree` targets a branch of the form, and `insertSubFormField`\ndeclares a sub-form inside it — for state that is not flat.\n\n**Use them when** the state has nested objects or arrays of objects.\n**Not when** the form is one level deep — attach\n[`insertFormAttributes`](/guide/forms/) directly.\n\n## insertSelectFormTree\n\nSelects and composes nested sub-forms.\n\n```ts\ninterface ProductForm {\n name: string;\n variants: Array<{\n color: string;\n stock: number;\n }>;\n}\n\nconst { productFormState } = state(\n 'productFormState',\n { name: '', variants: [] } as ProductForm,\n insertForm(\n insertSelectFormTree(\n 'variant',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cMin({ min: 0 })],\n })),\n ),\n ),\n);\n\n// Access sub-forms\nconst form = productFormState.form();\nconst variant0 = form.selectVariant(0);\nconst allVariants = form.items();\n```\n\nSelection is lazy: calling `selectVariant(...)`, `items()`, or an object\nselector such as `selectEmail()` materializes the selected branch and registers\nits insertions. Pass that selected field to DOM bindings; accessing the raw\nfield tree alone does not run the branch insertions.\n\nWhen an object branch has a group validator but no matching DOM control,\nmaterialize it in the component logic and return the selected group from the\nfactory instead:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts typed validation cases then belong to the component contract without\nrequiring `CraftFieldDirective(credentials)`. Bind the leaf controls and handle\nthe group path on an enclosing `fieldErrorNode`. See\n[Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator).\n\n## insertSubFormField\n\nExposes a derived sub-form from a parent value through a lens. This is useful when the form field is not stored as a nested object in the state, but can still be read and written from the parent value.\n\n```ts\nimport { state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertSubFormField,\n splitLens,\n cRequired,\n} from '@craft-ts/core';\n\nconst { appointmentFormState } = state(\n 'appointmentFormState',\n '2026-05-10 12:00',\n insertForm(\n insertSubFormField(\n 'date',\n splitLens(' ', 0),\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSubFormField('time', splitLens(' ', 1)),\n ),\n);\n\nconst form = appointmentFormState.form();\nconst dateField = form.selectDate();\nconst timeField = form.selectTime();\n\nconsole.log(dateField.value()); // '2026-05-10'\nconsole.log(timeField.value()); // '12:00'\n\ndateField.set('2026-05-11');\ntimeField.set('09:30');\n\nconsole.log(appointmentFormState()); // '2026-05-11 09:30'\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Validation](/guide/forms/validation)\n"
245
+ "body": "# Nested forms\n\n`insertSelectFormTree` targets a branch of the form, and `insertSubFormField`\ndeclares a sub-form inside it — for state that is not flat.\n\n**Use them when** the state has nested objects or arrays of objects.\n**Not when** the form is one level deep — attach\n[`insertFormAttributes`](/guide/forms/) directly.\n\n## insertSelectFormTree\n\nSelects and composes nested sub-forms.\n\n```ts\ninterface ProductForm {\n name: string;\n variants: Array<{\n color: string;\n stock: number;\n }>;\n}\n\nconst { productFormState } = state(\n 'productFormState',\n { name: '', variants: [] } as ProductForm,\n insertForm(\n insertSelectFormTree(\n 'variant',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cMin({ min: 0 })],\n })),\n ),\n ),\n);\n\n// Access sub-forms\nconst form = productFormState.form;\nconst variant0 = form.selectVariant(0);\nconst allVariants = form.items();\n```\n\nSelection is lazy: calling `selectVariant(...)`, `items()`, or an object\nselector such as `selectEmail()` materializes the selected branch and registers\nits insertions. Pass that selected field to DOM bindings; accessing the raw\nfield tree alone does not run the branch insertions.\n\nWhen an object branch has a group validator but no matching DOM control,\nmaterialize it in the component logic and return the selected group from the\nfactory instead:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts typed validation cases then belong to the component contract without\nrequiring `CraftFieldDirective(credentials)`. Bind the leaf controls and handle\nthe group path on an enclosing `fieldErrorNode`. See\n[Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator).\n\n## insertSubFormField\n\nExposes a derived sub-form from a parent value through a lens. This is useful when the form field is not stored as a nested object in the state, but can still be read and written from the parent value.\n\n```ts\nimport { state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertSubFormField,\n splitLens,\n cRequired,\n} from '@craft-ts/core';\n\nconst { appointmentFormState } = state(\n 'appointmentFormState',\n '2026-05-10 12:00',\n insertForm(\n insertSubFormField(\n 'date',\n splitLens(' ', 0),\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSubFormField('time', splitLens(' ', 1)),\n ),\n);\n\nconst form = appointmentFormState.form;\nconst dateField = form.selectDate();\nconst timeField = form.selectTime();\n\nconsole.log(dateField.value()); // '2026-05-10'\nconsole.log(timeField.value()); // '12:00'\n\ndateField.set('2026-05-11');\ntimeField.set('09:30');\n\nconsole.log(appointmentFormState()); // '2026-05-11 09:30'\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Validation](/guide/forms/validation)\n"
236
246
  },
237
247
  {
238
248
  "path": "/guide/forms/submit",
239
249
  "title": "Submitting a form",
240
- "body": "# Submitting a form\n\n`insertFormSubmit` connects the form to a [mutation](/guide/state/mutations), so\nsubmission gets its loading state, its failure state and — the point — a **typed\nunion of the exceptions submission can produce**, inferred from that mutation.\n\n**Use it when** the form writes somewhere.\n**Reshape the codes** with the `exceptions` pipeline when the server's vocabulary\nisn't the one your UI should show.\n\n```ts\nconst { updateUserMutation } = mutation('updateUserMutation', {\n method: (data: ValidatedFormValue<UserForm>) => data,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.patch(({ response, status }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) {\n return;\n }\n\n return craftException(\n { _tag: 'USER_EMAIL_ALREADY_EXISTS' },\n { message: 'This email is already used' as const },\n );\n },\n ],\n }));\n },\n});\n\nconst { userFormState } = state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n ),\n insertFormSubmit(updateUserMutation, {\n success: () => {\n console.log('Form submitted successfully');\n return undefined;\n },\n exceptions: [\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const emailConflict =\n submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS;\n\n if (!emailConflict) return undefined;\n\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n emailConflict.payload,\n );\n },\n ],\n }),\n ),\n);\n\n// Submit the form\nuserFormState.form().submit(); // Automatically triggers the mutation\n\n// Submit exceptions are inferred from the mutation and the `exceptions` rules.\nconst submitErrors = userFormState.form().submitExceptions();\nconst firstSubmitError = submitErrors[0]?.code; // 'EMAIL_NOT_AVAILABLE'\n```\n\n::: warning What `success` is for\n`success` runs inside the **derivation of the submit exception list**, and its\nreturn value is appended to that list. Its purpose is to raise an exception the\nserver reported alongside a successful response — not to run side effects.\nResetting the form, navigating or showing a toast from there mutates state\ninside a computation and re-runs whenever the exceptions recompute. Drive those\nfrom your own code after `submit()`, or from the mutation itself.\n\nThe example above logs from `success` only to show where the hook fires.\n:::\n\n`insertFormSubmit` preserves mutation exceptions by default. Use `exceptions` as\nan ordered pipeline when you want to refine the submit exceptions exposed by the\nform:\n\n```ts\ninsertFormSubmit(updateUserMutation, {\n exceptions: [\n // `omit` autocompletes the exception codes produced by `updateUserMutation`.\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n\n // Returning a Craft exception appends it to the current submit exceptions.\n ({ submitCraftResource }) => {\n if (submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS) {\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n { message: 'This email is already used' as const },\n );\n }\n\n return undefined;\n },\n ],\n});\n```\n\nReturning an array, like `omit(...)`, replaces the current submit exception list.\nReturning a single `craftException(...)` adds it. The final inferred union is\navailable through:\n\n```ts\nconst submitExceptions = userFormState.form().submitExceptions();\nconst aggregatedSubmitExceptions = userFormState.form().exceptions().submit;\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Form exceptions](/guide/forms/exceptions)\n"
250
+ "body": "# Submitting a form\n\n`insertFormSubmit` connects the form to a [mutation](/guide/state/mutations), so\nsubmission gets its loading state, its failure state and — the point — a **typed\nunion of the exceptions submission can produce**, inferred from that mutation.\n\n**Use it when** the form writes somewhere.\n**Reshape the codes** with the `exceptions` pipeline when the server's vocabulary\nisn't the one your UI should show.\n\n```ts\nconst { updateUserMutation } = mutation('updateUserMutation', {\n method: (data: ValidatedFormValue<UserForm>) => data,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.patch(({ response, status }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) {\n return;\n }\n\n return craftException(\n { _tag: 'USER_EMAIL_ALREADY_EXISTS' },\n { message: 'This email is already used' as const },\n );\n },\n ],\n }));\n },\n});\n\nconst { userFormState } = state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n ),\n insertFormSubmit(updateUserMutation, {\n success: () => {\n console.log('Form submitted successfully');\n return undefined;\n },\n exceptions: [\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const emailConflict =\n submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS;\n\n if (!emailConflict) return undefined;\n\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n emailConflict.payload,\n );\n },\n ],\n }),\n ),\n);\n\n// Submit the form\nuserFormState.form.submit(); // Automatically triggers the mutation\n\n// Submit exceptions are inferred from the mutation and the `exceptions` rules.\nconst submitErrors = userFormState.form.submitExceptions();\nconst firstSubmitError = submitErrors[0]?.code; // 'EMAIL_NOT_AVAILABLE'\n```\n\n::: warning What `success` is for\n`success` runs inside the **derivation of the submit exception list**, and its\nreturn value is appended to that list. Its purpose is to raise an exception the\nserver reported alongside a successful response — not to run side effects.\nResetting the form, navigating or showing a toast from there mutates state\ninside a computation and re-runs whenever the exceptions recompute. Drive those\nfrom your own code after `submit()`, or from the mutation itself.\n\nThe example above logs from `success` only to show where the hook fires.\n:::\n\n`insertFormSubmit` preserves mutation exceptions by default. Use `exceptions` as\nan ordered pipeline when you want to refine the submit exceptions exposed by the\nform:\n\n```ts\ninsertFormSubmit(updateUserMutation, {\n exceptions: [\n // `omit` autocompletes the exception codes produced by `updateUserMutation`.\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n\n // Returning a Craft exception appends it to the current submit exceptions.\n ({ submitCraftResource }) => {\n if (submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS) {\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n { message: 'This email is already used' as const },\n );\n }\n\n return undefined;\n },\n ],\n});\n```\n\nReturning an array, like `omit(...)`, replaces the current submit exception list.\nReturning a single `craftException(...)` adds it. The final inferred union is\navailable through:\n\n```ts\nconst submitExceptions = userFormState.form.submitExceptions();\nconst aggregatedSubmitExceptions = userFormState.form.exceptions().submit;\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Form exceptions](/guide/forms/exceptions)\n"
241
251
  },
242
252
  {
243
253
  "path": "/guide/forms/validation",
@@ -327,7 +337,7 @@
327
337
  {
328
338
  "path": "/guide/routing/eslint-rules",
329
339
  "title": "ESLint rules",
330
- "body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nThe recommended preset bans every TypeScript assertion in authored Craft code,\nincluding `as const`:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];\n```\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-explicit-craft-template-return-type': 'error',\n 'craft-ts/no-extracted-craft-component-parts': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/max-craft-component-lines': 'warn',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-explicit-resource-loader-type': 'error',\n 'craft-ts/no-explicit-craft-insertion-type': 'error',\n 'craft-ts/no-craft-primitive-type-assertion': 'error',\n 'craft-ts/prefer-insert-deep-yieldable': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/require-craft-computed-for-dynamic-template-lookup`: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a named `craftComputed()` in the component logic factory and bind that value directly\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-explicit-craft-template-return-type`: forbids explicit return annotations on render callbacks inside `craftComponent(...)`. A broad annotation such as `(): CraftNodeChildren` widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:\n\n ```ts\n const pendingStatusMessage = (message: string) => p(message);\n\n // ❌ The annotation erases the concrete node/dependency information.\n pendingNode({\n fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),\n reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),\n });\n\n // ✅ The concrete `p(...)` node stays visible to Craft's inference.\n pendingNode({\n fallback: () => pendingStatusMessage('Loading…'),\n reloading: () => pendingStatusMessage('Reloading…'),\n });\n ```\n\n The rule is autofixable with `eslint --fix`. Return annotations on DOM event\n and output callbacks remain allowed because those callbacks do not produce\n rendered children.\n\n- `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and\n template passed to `craftComponent(...)` to stay inline. Keeping both parts at\n the component boundary preserves contextual type inference and makes the\n component's behaviour readable in one place. The rule reports both extracted\n identifiers independently.\n\n Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's\n two halves behind names at the call site:\n\n ```ts\n // ❌ craft-ts/no-extracted-craft-component-parts\n const ReviewLogic = craftGen(function* () {\n return { review, decide };\n });\n\n const ReviewTemplate = craftTemplate(({ decide }) =>\n div([button({ click: decide }, 'Review')]),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n ReviewLogic,\n ReviewTemplate,\n );\n ```\n\n After — keep the logic and template callback in the component call:\n\n ```ts\n // ✅\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n craftGen(function* () {\n return { review, decide };\n }),\n ({ decide }) => div([button({ click: decide }, 'Review')]),\n );\n ```\n\n The rule only rejects identifiers in the logic and template argument\n positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)`\n expressions remain valid. A direct template callback is usually the simplest\n form because `craftComponent(...)` can contextually type it from the inline\n logic factory.\n\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/max-craft-component-lines`: reports a file that declares a `craftComponent(...)` once it exceeds **700 non-import lines** (`import` statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:\n\n ```ts\n // ❌ craft-ts/max-craft-component-lines\n // review-app.ts — 3894 lines: filtering, sorting, diff computation,\n // pagination, and the full markup tree all inlined in one logic factory\n // and one template.\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filtered = craftComputed(() => /* 80 lines of filtering */ []);\n const diff = craftComputed(() => /* 150 lines of diffing */ null);\n // …dozens more computeds and craftMethods…\n return { subjects, filtered, diff /* … */ };\n },\n ({ filtered, diff /* … */ }) =>\n div(\n {},\n /* a thousand-plus lines of markup for the filter bar, the diff\n viewport, the review card list, and the pagination controls */\n ),\n );\n\n // ✅ Business logic moves to a craftService; independent template\n // regions become their own craftComponent, each testable and readable\n // on its own.\n export const ReviewFilters = craftService(\n { name: 'ReviewFilters', scope: 'global' },\n () => ({\n filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],\n }),\n );\n\n export const SubjectDiffViewport = craftComponent(\n 'SubjectDiffViewport',\n {},\n (subject: Input<Subject>) => ({ subject }),\n ({ subject }) => div({} /* … */),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filters = injectX(ReviewFilters);\n const filtered = craftComputed(() =>\n filters.filter(subjects(), criteria()),\n );\n return { filtered /* … */ };\n },\n ({ filtered }) =>\n div(\n {},\n forNode(filtered, (subject) => SubjectDiffViewport({ subject })),\n ),\n );\n ```\n\n Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is\n still too generous for your team.\n\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-type-assertions-in-craft-code`: forbids TypeScript type assertions in authored Craft code, including `as const` and angle-bracket assertions; the narrow `undefined as T | undefined` seed is allowed for intentionally optional state values. Use correct API typing or `satisfies` for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.\n- `craft-ts/no-explicit-resource-loader-type`: forbids explicit parameter and return annotations on `query`, `mutation`, and `asyncProcess` loaders; let the resource infer its contract from `params`, `method`, and the yielded operations instead of writing `Generator<...>` or `{ params: string }`\n- `craft-ts/no-explicit-craft-insertion-type`: forbids explicit parameter and return annotations on callbacks passed to `insert*Pipe`; let the primitive infer the insertion context and derived output\n- `craft-ts/no-craft-primitive-type-assertion`: forbids chained assertions such as `as unknown as Generator<...>` around Craft primitive generators, which can hide the inferred output and dependency contract\n- `craft-ts/prefer-insert-deep-yieldable`: rejects adapting a property of a primitive result with `deepYieldable(...)`; add `insertDeepYieldable()` to the primitive and read the property directly\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\nThe loader signature should also stay inferred:\n\n```ts\n// Incorrect: these annotations can mask a mismatch in the resource contract.\nloader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {\n return yield* client({ token: params });\n}\n\n// Correct: infer params and the generator result from the resource and body.\nloader: function* ({ params }) {\n return yield* client({ token: params });\n}\n```\n\n`no-explicit-resource-loader-type` reports only annotations on the loader\nsignature. Type annotations for local variables and function contracts outside\nthe loader remain allowed.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn (\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n }))\n);\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\n### Preserve primitive inference\n\nThe insertion callback already receives a contextual type, and the primitive\nalready knows the complete type of its generator. Do not repeat either type at\nthe boundary:\n\n```ts\n// ❌ craft-ts/no-explicit-craft-insertion-type\ninsertQueryPipe(\n ({ resource }): SpaceQueryView => ({\n items: craftComputed(() => resource.value()),\n }),\n);\n\n// ❌ craft-ts/no-craft-primitive-type-assertion\nconst generator = query('spaceItems', config) as unknown as Generator<\n unknown,\n SpaceQueryRef,\n unknown\n>;\n\n// ✅\nconst generator = query(\n 'spaceItems',\n config,\n insertQueryPipe(({ resource }) => ({\n items: craftComputed(() => resource.value()),\n })),\n);\n```\n\nThe assertion is especially harmful around a composed insertion pipe: it\nreplaces the type that carries the derived properties and their dependencies.\n\n### Prefer primitive deep-yieldable insertions\n\nWhen a property is read from the result of a primitive, expose the deep view at\nthe primitive boundary. This keeps the property reader connected to the\nprimitive and avoids an extra adapter:\n\n```ts\n// ❌ craft-ts/prefer-insert-deep-yieldable\nconst spaceQuery = yield * spaceQueryGenerator;\nconst deepItems = deepYieldable(spaceQuery.items);\n\n// ✅ add insertDeepYieldable() to the query call, then:\nconst spaceQuery = yield * spaceQueryGenerator;\nconst items = spaceQuery.items;\n```\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog =\n yield * state('catalog', { products }, insertDeepYieldable('products'));\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
340
+ "body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nThe recommended preset bans every TypeScript assertion in authored Craft code,\nincluding `as const`:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];\n```\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-explicit-craft-template-return-type': 'error',\n 'craft-ts/no-extracted-craft-component-parts': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/require-form-for-input-action': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/max-craft-component-lines': 'warn',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-explicit-resource-loader-type': 'error',\n 'craft-ts/no-explicit-craft-insertion-type': 'error',\n 'craft-ts/no-craft-primitive-type-assertion': 'error',\n 'craft-ts/prefer-insert-deep-yieldable': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/require-craft-computed-for-dynamic-template-lookup`: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a named `craftComputed()` in the component logic factory and bind that value directly\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-explicit-craft-template-return-type`: forbids explicit return annotations on render callbacks inside `craftComponent(...)`. A broad annotation such as `(): CraftNodeChildren` widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:\n\n ```ts\n const pendingStatusMessage = (message: string) => p(message);\n\n // ❌ The annotation erases the concrete node/dependency information.\n pendingNode({\n fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),\n reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),\n });\n\n // ✅ The concrete `p(...)` node stays visible to Craft's inference.\n pendingNode({\n fallback: () => pendingStatusMessage('Loading…'),\n reloading: () => pendingStatusMessage('Reloading…'),\n });\n ```\n\n The rule is autofixable with `eslint --fix`. Return annotations on DOM event\n and output callbacks remain allowed because those callbacks do not produce\n rendered children.\n\n- `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and\n template passed to `craftComponent(...)` to stay inline. Keeping both parts at\n the component boundary preserves contextual type inference and makes the\n component's behaviour readable in one place. The rule reports both extracted\n identifiers independently.\n\n Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's\n two halves behind names at the call site:\n\n ```ts\n // ❌ craft-ts/no-extracted-craft-component-parts\n const ReviewLogic = craftGen(function* () {\n return { review, decide };\n });\n\n const ReviewTemplate = craftTemplate(({ decide }) =>\n div([button({ click: decide }, 'Review')]),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n ReviewLogic,\n ReviewTemplate,\n );\n ```\n\n After — keep the logic and template callback in the component call:\n\n ```ts\n // ✅\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n craftGen(function* () {\n return { review, decide };\n }),\n ({ decide }) => div([button({ click: decide }, 'Review')]),\n );\n ```\n\n The rule only rejects identifiers in the logic and template argument\n positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)`\n expressions remain valid. A direct template callback is usually the simplest\n form because `craftComponent(...)` can contextually type it from the inline\n logic factory.\n\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/require-form-for-input-action`: rejects a button's direct `mutate(...)` or `method(...)` call when it consumes an input-bound value, including through a local record or variable; use `insertForm`, `insertFormAttributes`, and `insertFormSubmit` for mutation-backed forms, then submit a native `form(...)` with a `type: 'submit'` button\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/max-craft-component-lines`: reports a file that declares a `craftComponent(...)` once it exceeds **700 non-import lines** (`import` statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:\n\n ```ts\n // ❌ craft-ts/max-craft-component-lines\n // review-app.ts — 3894 lines: filtering, sorting, diff computation,\n // pagination, and the full markup tree all inlined in one logic factory\n // and one template.\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filtered = craftComputed(() => /* 80 lines of filtering */ []);\n const diff = craftComputed(() => /* 150 lines of diffing */ null);\n // …dozens more computeds and craftMethods…\n return { subjects, filtered, diff /* … */ };\n },\n ({ filtered, diff /* … */ }) =>\n div(\n {},\n /* a thousand-plus lines of markup for the filter bar, the diff\n viewport, the review card list, and the pagination controls */\n ),\n );\n\n // ✅ Business logic moves to a craftService; independent template\n // regions become their own craftComponent, each testable and readable\n // on its own.\n export const ReviewFilters = craftService(\n { name: 'ReviewFilters', scope: 'global' },\n () => ({\n filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],\n }),\n );\n\n export const SubjectDiffViewport = craftComponent(\n 'SubjectDiffViewport',\n {},\n (subject: Input<Subject>) => ({ subject }),\n ({ subject }) => div({} /* … */),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filters = injectX(ReviewFilters);\n const filtered = craftComputed(() =>\n filters.filter(subjects(), criteria()),\n );\n return { filtered /* … */ };\n },\n ({ filtered }) =>\n div(\n {},\n forNode(filtered, (subject) => SubjectDiffViewport({ subject })),\n ),\n );\n ```\n\n Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is\n still too generous for your team.\n\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-type-assertions-in-craft-code`: forbids TypeScript type assertions in authored Craft code, including `as const` and angle-bracket assertions; the narrow `undefined as T | undefined` seed is allowed for intentionally optional state values. Use correct API typing or `satisfies` for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.\n- `craft-ts/no-explicit-resource-loader-type`: forbids explicit parameter and return annotations on `query`, `mutation`, and `asyncProcess` loaders; let the resource infer its contract from `params`, `method`, and the yielded operations instead of writing `Generator<...>` or `{ params: string }`\n- `craft-ts/no-explicit-craft-insertion-type`: forbids explicit parameter and return annotations on callbacks passed to `insert*Pipe`; let the primitive infer the insertion context and derived output\n- `craft-ts/no-craft-primitive-type-assertion`: forbids chained assertions such as `as unknown as Generator<...>` around Craft primitive generators, which can hide the inferred output and dependency contract\n- `craft-ts/prefer-insert-deep-yieldable`: rejects adapting a property of a primitive result with `deepYieldable(...)`; add `insertDeepYieldable()` to the primitive and read the property directly\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\nThe loader signature should also stay inferred:\n\n```ts\n// Incorrect: these annotations can mask a mismatch in the resource contract.\nloader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {\n return yield* client({ token: params });\n}\n\n// Correct: infer params and the generator result from the resource and body.\nloader: function* ({ params }) {\n return yield* client({ token: params });\n}\n```\n\n`no-explicit-resource-loader-type` reports only annotations on the loader\nsignature. Type annotations for local variables and function contracts outside\nthe loader remain allowed.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn (\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n }))\n);\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\n### Preserve primitive inference\n\nThe insertion callback already receives a contextual type, and the primitive\nalready knows the complete type of its generator. Do not repeat either type at\nthe boundary:\n\n```ts\n// ❌ craft-ts/no-explicit-craft-insertion-type\ninsertQueryPipe(\n ({ resource }): SpaceQueryView => ({\n items: craftComputed(() => resource.value()),\n }),\n);\n\n// ❌ craft-ts/no-craft-primitive-type-assertion\nconst generator = query('spaceItems', config) as unknown as Generator<\n unknown,\n SpaceQueryRef,\n unknown\n>;\n\n// ✅\nconst generator = query(\n 'spaceItems',\n config,\n insertQueryPipe(({ resource }) => ({\n items: craftComputed(() => resource.value()),\n })),\n);\n```\n\nThe assertion is especially harmful around a composed insertion pipe: it\nreplaces the type that carries the derived properties and their dependencies.\n\n### Prefer primitive deep-yieldable insertions\n\nWhen a property is read from the result of a primitive, expose the deep view at\nthe primitive boundary. This keeps the property reader connected to the\nprimitive and avoids an extra adapter:\n\n```ts\n// ❌ craft-ts/prefer-insert-deep-yieldable\nconst spaceQuery = yield * spaceQueryGenerator;\nconst deepItems = deepYieldable(spaceQuery.items);\n\n// ✅ add insertDeepYieldable() to the query call, then:\nconst spaceQuery = yield * spaceQueryGenerator;\nconst items = spaceQuery.items;\n```\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog =\n yield * state('catalog', { products }, insertDeepYieldable('products'));\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
331
341
  },
332
342
  {
333
343
  "path": "/guide/routing/exception-handling",
@@ -447,12 +457,12 @@
447
457
  {
448
458
  "path": "/guide/style/attestation",
449
459
  "title": "Attestation: a judgement that survives a refactor",
450
- "body": "# Attestation: a judgement that survives a refactor\n\nA snapshot suite records _what the output was_. Renaming a local variable\nchanges no pixel, and yet the whole suite asks to be looked at again — so people\nrun `--update-snapshots`, and the file that was supposed to record a human\ndecision records nothing at all.\n\nThis records something else:\n\n> **A person looked at this output and judged it correct, and that judgement\n> holds for as long as the code producing it has not moved.**\n\n## Two caches, never one\n\nTwo questions look alike and are not:\n\n| question | keyed on | a wrong answer costs |\n| ------------------------------ | --------------------------- | -------------------- |\n| should this be re-run? | fingerprint of a code slice | CPU |\n| should a human be asked again? | hash of the evidence | somebody's afternoon |\n\nFrom which the rule the whole design rests on: **when the code changes and the\nevidence does not, the attestation carries itself forward**, marked `renewed`,\nwith a note saying \"code changed, output unchanged\".\n\nThat is also why the code fingerprint is allowed to be _cautious_. A slice that\nis too wide only costs a re-run. Only a slice that is too narrow is dangerous —\nit misses a regression in silence, and nobody is ever asked about it again.\n\n| state | meaning |\n| --------- | --------------------------------------------- |\n| `current` | the fingerprint has not moved: nothing to do |\n| `renewed` | code moved, output did not: carried, no human |\n| `review` | the output differs: a human has to look |\n| `missing` | never attested |\n\n## The evidence is a digest, not a picture\n\nWhat a person judges is the **layout digest**: boxes rounded to the half pixel,\nintrinsic sizes, a closed list of computed styles, and a set of discrete facts —\nline counts, column counts, what wraps, what clips, what scrolls, what overlaps.\n\nThree things follow, and each is why the digest exists rather than a screenshot:\n\n- it is **diffable**. `.card padding 8→12` is a sentence a reviewer reads in a\n second; two images are not, and a reviewer who cannot see what changed\n approves everything.\n- it is **assertable**. Overflow, truncation, overlap and contrast are\n comparisons of numbers, so they are **failures**, not queue items — nobody is\n asked, and the message names the node and the pixel count.\n- it is **stable**. Anti-aliasing and font hinting move pixels without moving\n layout; under an image comparison every one of those is a review item.\n\nThe PNG is still kept, in the content-addressed store, for the human. It is a\nreview aid, never the reference.\n\n```ts\nimport {\n assertNoLayoutViolations,\n collectLayoutDigest,\n makeDeterministic,\n} from '@craft-ts/style-testing';\n\nawait makeDeterministic(page);\nawait page.goto('/users');\nconst digest = await collectLayoutDigest(page, {\n root: '[data-testid=userCard]',\n});\n\nassertNoLayoutViolations(digest, { scenario: 'locale=de-DE' });\n// → userCard/title hides 34px of \"Benutzerkontoeinstellungen\".\n```\n\n## Determinism is a feature, not hygiene\n\nIt carries two independent mechanisms. The **carry-forward** reads \"the code\nmoved, the output did not\"; a wobbling render never produces the same output\ntwice, the queue fills with changes nobody made, and people start stamping. The\n**bisection** reads a discrete signature as a function of one parameter; a wobble\nmanufactures thresholds that do not exist.\n\n`makeDeterministic` freezes the clock, seeds `Math.random`, kills animations,\ntransitions and the caret, and refuses every network request by default. A\nhundred consecutive renders of one scenario must produce a hundred identical\ndigests before anything else is worth building:\n\n```ts\nawait assertDeterministic(\n async () => JSON.stringify(await digestOf(page)),\n 100,\n);\n```\n\n## Where it tips over\n\nA content axis is continuous and a layout does not care about most of it. What\nit has are **thresholds**. `findTransitions` samples a coarse grid — word\nboundaries, digit-count changes — and bisects only inside the intervals where\nthe discrete signature actually moved.\n\nThen the report that arrives _before_ the bug:\n\n```\nuserCard/title: 1 → 2 lines at 34 characters.\nToday's German string is 33. Margin: 1 (3%), below 15%.\n```\n\nNothing is broken. That is the point: it fails in CI, with no human and no\npixel, on the translation nobody has written yet.\n\n```ts\nconst search = await findTransitions(signatureAt, {\n axis: 'title',\n min: 1,\n max: 80,\n});\nassertMargins([marginOf(search, longest.longestLength)]);\n```\n\nA bisection is not exactly true — it finds the thresholds that exist between the\npoints it looked at. So the sample count goes into the attestation as an\n**assumption**, and an attestation never says \"validated\"; it says \"validated,\nunder this assumption\". A changed assumption sends the subject back to review\nrather than quietly becoming a lie.\n\n## Translation as a source of axes\n\nThe catalogue is a TypeScript value, which makes two of these exact:\n\n- **the longest locale** for a screen is a computation over the keys that screen\n uses. One axis point, and the right one — where \"test it in German\" is only an\n approximation.\n- **the plural categories** are already declared and already checked exhaustive\n per locale. They are axis points by construction.\n\nThe **pseudo-locale** is the approximation, and it is the one that finds the\n_future_ case: 40% longer, `[[bracketed]]` so truncation is visible, every letter\naccented so an un-externalised string stands out.\n\n```ts\nimport {\n longestLocale,\n pseudoCatalog,\n findHardCodedText,\n} from '@craft-ts/i18n/testing';\n\nlongestLocale([en, de, ja], usedKeys); // → { id: 'de-DE', longestKey: 'account.settings' }\nfindHardCodedText(visibleStrings); // → ['Submit'] ← never went through the catalogue\n```\n\nTwo pressures, opposite failure modes, kept apart throughout: a rising\n`min-content` (an unbreakable word, a URL, a long number) stops a column\nshrinking; a rising `max-content` (a long but breakable sentence) steals width\nfrom its siblings in an `auto` track. A long sentence with spaces in it usually\ndoes not move `min-content` at all.\n\n## The command line\n\nCapture a real route into a portable report, then let the CLI derive every\nfingerprint from the current dependency graph:\n\n```sh\nnpm run attest:visual:capture\n\nnpm run attest:visual:status\n```\n\nThe capture starts the demo server when needed and writes the report, PNG\nscreenshots, and frozen `.snapshot.html` documents to `.craft/runs/`. The\nreport contains repository-relative graph node ids, digests and screenshot\npaths. It deliberately contains no code fingerprint: accepting a fingerprint\nfrom an old browser run could keep a stale slice current forever. Reports,\nscreenshots and frozen documents are regenerable and ignored; the ledger is\nnot.\n\nTo choose another report path, set `CRAFT_VISUAL_REPORT` on both commands:\n\n```sh\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:capture\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:status\n```\n\n```sh\nnpm run attest:visual:review\n\nnpx tsx libs/cli/src/bin/craft-ts.ts attest why 'visual:userCard#viewport=md'\nnpx tsx libs/cli/src/bin/craft-ts.ts attest renew \\\n --subject 'visual:userCard#viewport=md' --verdict ok\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind visual \\\n --report .craft/runs/design-system.json \\\n --tsconfig apps/demo/tsconfig.graph.json\nnpx tsx libs/cli/src/bin/craft-ts.ts attest unwatched\n```\n\nFor the unified review surface, use the DevTool. It combines visual captures\nand template obligations in one queue:\n\n```sh\nnpm run attest:devtools\n```\n\n### The reviewer reviews itself\n\nThe review application can use the same mechanism on its own UI. It runs in two\nsuccessive sessions so the queue cannot change while it is being captured: the\nfirst instance renders a deterministic fixture queue and freezes representative\nstates; the second instance reviews that visual report together with template\nobligations derived from the review application's own CraftTS graph.\n\n```sh\nnpm run attest:review-app:capture\nnpm run attest:review-app:status\nnpm run attest:review-app:review\n```\n\nThe capture includes the review page's happy path at mobile and desktop sizes,\nthe review queue in dark French, the regeneration confirmation, the visual-test\ninventory, and the template-obligation inventory. Every portable snapshot is\nreplayed immediately and must reproduce the live layout digest.\nThe first run reports missing decisions until a reviewer explicitly accepts or\nrejects them. Later unchanged evidence is carried forward by the usual ledger.\n\nThe review sidebar also offers **Regenerate all evidence**. It opens a\nconfirmation describing the current scope and whether previous decisions\nexist. Regeneration replaces the report, screenshots, and frozen documents,\nthen re-reads the graph and rebuilds the queue. It never clears the ledger:\nunchanged evidence stays current and only new or changed evidence returns to a\nreviewer. Any unsaved reason on the open card is discarded.\n\nThis control is shown only when the CLI session explicitly names an npm script:\n\n```sh\ncraft-ts attest devtools \\\n --report .craft/runs/project.json \\\n --tsconfig apps/project/tsconfig.graph.json \\\n --regenerate-script attest:project:capture\n```\n\nOnly an npm script name is accepted, not an arbitrary shell command. The script\nmust recreate the report supplied to `--report`; a failed run preserves the\nexisting queue.\n\nAfter rejecting views with comments, use **Prepare Codex iteration** in the\nreview sidebar. It generates, next to the report, a readable\n`<report>.review-feedback.md`, a structured `<report>.review-feedback.json`,\nand a copyable `<report>.codex-prompt.md`. The prompt contains the project root,\nreport, ledger, evidence store, graph `tsconfig`, capture script, source file\npaths, scenarios, measured changes, comments and any digest nodes pointed to by\nthe reviewer. Only the latest `rejected` cards are included. Because these\npaths come from the CLI session, `apps/demo` and the review application's\nself-attestation resolve to different, correct project contexts.\n\n### One happy path for every page\n\nApplication-level coverage is declared once and expanded into mobile and\ndesktop captures by default:\n\n```ts\nimport {\n defineHappyPathHttpMocks,\n defineVisualAppConfig,\n visualAppHappyPaths,\n} from '@craft-ts/style-testing';\n\nexport const homeHappyPath = defineHappyPathHttpMocks(\n 'home-page.happy-path.ts',\n {\n 'GET /api/users': { response: [{ id: '42', name: 'Ada' }] },\n },\n);\n\nexport const visualTestConfig = defineVisualAppConfig({\n pages: [\n {\n id: 'home',\n route: '',\n url: '/',\n component: 'component:src/app/home-page.ts:HomePage',\n mocks: homeHappyPath,\n },\n ],\n});\n\nfor (const scenario of visualAppHappyPaths(visualTestConfig)) {\n test(scenario.id, async ({ page }) => {\n await page.setViewportSize(scenario.viewport);\n await page.goto(scenario.page.url);\n // Install scenario.page.mocks, wait for the happy UI, then collectCapture.\n });\n}\n```\n\nWithout an explicit `viewports` value, CraftTS uses `mobile: 390x844` and\n`desktop: 1440x1000`. Keep each response dataset in a sibling\n`*.happy-path.ts` file. `matchHappyPathHttpRequest` turns that dataset into a\nrequest match suitable for `page.route`; when a `craftRoutes` registry is\navailable, wrap its exhaustive, response-typed `mockHttpRequestForRoute` result\nwith `defineRouteHappyPathHttpMocks('page.happy-path.ts', routeMock)`.\n\nAdd `assertVisualHappyPathArchitecture(graph.graph, visualTestConfig)` to the\napplication architecture suite. It fails when a routed page, a required\nviewport, or a Craft HTTP endpoint has no successful happy-path fixture. The\nfixture feeds a deterministic test environment only; application code keeps\nall remote work inside its `query`, `mutation`, or `asyncProcess` loader.\n\nSet `CRAFT_REVIEW_APP_REPORT` to the same path on all three commands to relocate\nthe default `.craft/runs/review-app.json` report. The implementation notes and\nthe exact workflow live in `libs/review-attestation/attestation-app/README.md` in the\nrepository.\n\nTemplate obligations do not need a Playwright report. They are derived from the\ncurrent graph and their canonical proof objects are written to\n`.craft/evidence/`:\n\n```sh\nnpm run attest:templates:status\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nTwo of these carry the rest.\n\n**`why`** names the graph nodes that moved inside the subject's slice, and when\na person last actually looked at it. A review that cannot answer \"why am I being\nasked this?\" is a review that gets stamped.\n\n**`unwatched`** lists the nodes that moved and belong to no attested subject —\n_what changed while nobody was looking_. It falls out of the machinery for free.\n\n`renew --all` is allowed and is **marked** as a bulk renewal in every\nattestation it writes, and `status` counts them. A bulk renewal that left no\ntrace would turn the register into a rubber stamp, which is worse than having no\nregister.\n\n## What a reviewer is shown\n\nTwo artefacts, and the reviewer switches between them.\n\n**The frozen page** is the render itself: the DOM, the styles, and the form\nstate, serialised at the moment the digest was taken. It replays as a real\ndocument — real boxes, real `:hover` — which is what lets someone click an\nelement and name it instead of clicking a pixel and hoping.\n\nFreezing means more than serialising the DOM. Craft injects its styles through\n`adoptedStyleSheets`, which `outerHTML` cannot see at all. And keeping the\nstylesheets verbatim would leave every `@media` to be re-evaluated against the\n_reviewer's_ window: on the demo's route the two conditions in play are\n`(min-width: 48rem)` and `(prefers-color-scheme: dark)` — exactly the two axes of\nthe matrix — so four scenarios would collapse into whatever that laptop said.\nMedia and supports are therefore evaluated at capture time and their winning\nbranch inlined. Container queries are left alone, because they ask about the\npage's own layout, which the replay reproduces.\n\nThe snapshot carries **no script**. Inertness is a property of the artefact, not\na guard that has to hold: nothing to block, nothing to leak, no `craftMethod`\nfiring on a stray click. The review application does its interactive work from\nthe parent frame, reaching into a same-origin iframe.\n\n**The screenshot** is the fallback, and the check on the checker: the digest is\nblind to anything that does not move a box or a listed style, so a swapped\nbackground or a wrong icon passes every automated test and is obvious to an eye.\n\n### The replay is checked, not trusted\n\nBefore anything is drawn on it, the replay is re-measured with the collector\nthat produced the evidence and compared against the attested digest. A missing\nfont, a media query left conditional, a stylesheet that could be neither read\nnor fetched — each produces a document that looks plausible and measures\ndifferently, and a reviewer would judge it without ever knowing.\n\nWhen it does not match, the card **moves the reviewer to the screenshot by\nitself** and says why in the same sentence, and the verdict is recorded as\n`degraded`: judging a photograph and judging the document are different claims.\nThe choice is a fallback, not a lock — asking for the page brings it back, still\nlabelled for what it is.\n\nThe message names the cause, not its symptoms. A subject the frozen page does\nnot contain reported \"36 attested node(s) are absent\" followed by forty\naddresses beginning `html/head/meta`: every consequence of one fact, and none of\nthem stating it. It now reads\n\n> The frozen page has no `.design-system-host` in it, so what it shows is not\n> this component. That happens when the stored snapshot is older than the report\n> it is paired with, or when the component's root selector changed after it was\n> captured.\n\nwhich is the same finding with the reviewer's next move in it.\n\nTwo things the check caught while it was being built, which is what it is for:\na marker stylesheet that set `position: relative` on the attested root and moved\nthe tree it was supposed to annotate, and a 1px border on the frame, which is\nsubtracted from the viewport inside it and made every measurement 2px narrow.\n\n## Knowing what is actually being judged\n\nA capture shows the whole page — shell, navigation, neighbours — because a\ncomponent has to be judged in the frame it sits in. So the reviewer has to be\nable to tell the subject from the decor, or a remark lands on a card that does\nnot cover it.\n\nThe digest answers this exactly: its paths **are** the attested set. Three tiers\nfollow, and all three come from data that already exists:\n\nThe screen is laid out in the order the work happens: the queue on the left,\nthe evidence in the middle, the verdict on the right, where it stays in place\nwhile a long capture is scrolled.\n\nThe surface speaks English and French, and follows the system's light or dark\npreference until the reviewer chooses otherwise — both controls sit in the\nsidebar, and both are applied before the first paint rather than corrected a\nframe later. The French dictionary is typed as the English one, so a message\nadded on one side and forgotten on the other does not compile.\n\nNeither reaches the frozen page. It is a render that was captured, not an\ninterface: translating it, or repainting its ground, would make it something\nother than what was measured. Enforcing that turned up a fidelity bug the tool\nhad been hiding by being permanently dark — a page paints its own colours, but\nnot the canvas underneath, and that comes from `color-scheme`, which was the\n_reviewer's_ preference. A component captured on white came back on black for\nanyone whose machine asks for dark. The replay now declares the scheme its\ncapture was taken in.\n\n| tier | source | shown as |\n| -------- | ------------------------------ | ----------------------------------------- |\n| changed | the paths in the readable diff | outlined, and the reason the card is here |\n| attested | the digest's own paths | selectable, highlighted on hover |\n| decor | everything else | dimmed, never removed |\n\nThe outlines carry a legend, drawn from the same object that paints them — a key\nthat keeps its own copy of a colour is a key that will one day name the wrong\none. Entries for tiers this card has none of are not shown, so the legend\ndescribes the page in front of the reviewer rather than the system in general.\n\nSelection is a set, not a node. Ctrl-click (cmd on a Mac) adds one, and dragging\na box takes everything it touches; the count is stated beside the reason field\nthat is about to name them. A remark covering a row of buttons was otherwise the\nsame sentence retyped once per button, which is also how a queue fills with\nfindings nobody can group afterwards. The band is drawn beside the frame and\nnever inside it: adding an element to the frozen document would break the only\nclaim it makes.\n\n### One reason, several complaints\n\nA rejection is rarely about one thing. Right-clicking a selection drops a\nreference into the reason **where the reviewer is typing**:\n\n> The title is cut at 34px in German. `[#1: 2 nodes]` And the row below\n> overflows its box. `[#2: 5 nodes]`\n\nThe reason stays one piece of prose, and each reference carries the text written\nsince the one before it — so the second complaint is filed against the second\ngroup and not, as a single note against every node would have it, against all\nseven.\n\nSelecting _is_ referencing: there is no second gesture. Pointing at part of the\npage drops the reference straight into the reason, and refining the selection\nedits that same reference rather than adding another — a click followed by a\nctrl-click leaves one saying \"2 nodes\", not a stale \"1 node\" beside it. Typing\nends the session: the reference belongs to a sentence now, and the next\nselection starts its own. Emptying the selection takes the reference back out,\nbecause a reference to nothing is worse than none.\n\nThe field is a `contenteditable`, not a `textarea`, so a reference is an element\nrather than the literal characters `[#1: 2 nodes]`: hovering it lists the\naddresses it stands for **and paints those nodes in the frozen page**, dashed\nrather than solid so it cannot be mistaken for the selection. That is the whole reason for the swap. As text, the\nanswer to \"which nodes is this one about\" had to live in a list somewhere else\non the page, and a reviewer reading a sentence had to leave it to find out. The\nplain text is still the model — everything downstream reads the serialised\nstring — so the chips are a rendering of the reason and never a second version\nof it.\n\nTwo things that swap broke, both worth stating because neither is obvious. The\nfield is not scrollable: a clipping context cuts the tooltip off any reference\non the first line, and which nodes a reference covers must not depend on where\nin the sentence it was written. And the keyboard shortcuts had to learn about\nit — the guard knew `input`, `textarea` and `select`, so typing \"And the row is\ncut\" pressed `a`, Accept, and filed a verdict the reviewer never reached.\n\nPosition settles that, not punctuation. The first rule tried was \"the sentence\nthe token stands in\", and it was wrong for the way people write: the complaint\nis typed, ended, and _then_ the group is pointed at, so the caret is past the\nfull stop and the token opens the next sentence rather than closing its own.\nReferencing first and explaining after reads the other way round, so a group\nwith nothing before it takes what follows.\n\nThe tokens are scaffolding. What is recorded is the prose with them removed,\nplus the addresses each one pointed at — and the text remains the only state:\ndeleting a reference deletes it, with no second list left holding a claim the\nreason no longer makes.\n\nAnd the mistake is made unrecordable rather than merely discouraged. A rejection\ncarries the path of the node it is about, so the server can refuse one that\nnames something this subject does not attest:\n\n> `demo-nav/toggle` is not attested by this subject. File the remark on the card\n> that covers it.\n\n### Attested is not the same as looked at\n\nThe capture also records the gap, because it is large. On the demo's route: **36\nnodes attested, 21 off screen, 1 covered** by the page's own fixed button. An\nattestation that stayed quiet about that would claim a coverage it does not\nhave, so the card states it and the screenshot draws the line where the viewport\nended.\n\nThe verdict buttons carry what they _do_. Three of the five are accepted by the\nledger and two are not, and nothing in the words says which — a reviewer\nchoosing between \"Known issue\" and \"Block\" is choosing between \"stops asking\"\nand \"asks every run\", which is the only difference that matters and the one they\ncould not see.\n\n\"Fit to window\" applies to the frozen page too, by `transform: scale()` and\nnever by a width: the frame has to stay exactly the viewport the page laid\nitself out in, or the replay stops being the render that was measured. A\ntransform changes what is painted and nothing about what was measured.\n\n\"Fit to window\" bounds both axes. Bounding the width alone — which is what it\ndid — fits a picture wider than the canvas and does nothing whatsoever to a\nnarrow one, and every capture on the demo route is 375 or 768 wide and 916 tall:\nthe control showed the whole render on one scenario and two thirds of it on the\nnext, for a reason that had nothing to do with what was being judged. It is not\noffered while the frozen page is on screen, because scaling that page would\nrelayout it and it would stop being the render that was measured.\n\nThe one covered node is the case only the frozen page can resolve: **lift what\nis covering it** and see what was underneath. In a screenshot those pixels have\nalready been replaced.\n\nThe control names what it will lift — `Hide button.clear-cache-btn` — and is\noffered only when something is actually covering the component. It used to read\n\"Hide 1 overlay\", which asked the reviewer what an overlay is and counted the\nwrong thing: covered _nodes_, when one button sitting on five of them is one\nthing to lift. Worse, it marked every fixed element on the page whether or not\nit covered anything, and marked nothing that covered without being fixed — so on\nmost cards it lifted something irrelevant, and on the cards that mattered it\ncould do nothing while the coverage line insisted a node was covered.\n\nWhat is lifted is now decided by probing the replay with the collector's own\nrule, which is also where the clamping bug in that rule was found: a sample\npoint outside the viewport was pulled back to the edge, so a node straddling the\nfold was reported as covered by whatever happened to sit on the fold line.\nSamples outside the viewport are skipped in both places now.\n\n## The review queue\n\nTwo mechanisms keep it from being abandoned, and neither is optional. The\ncarry-forward keeps everything whose output did not move out of the queue\nentirely. And the queue **clusters by the shape of the diff**: one border-radius\nchange produces two hundred scenarios with an identical delta, and one decision\ncovers all of them — with the cluster written into every attestation it covered,\nso \"judged\" and \"judged alongside 199 others\" stay distinguishable.\n\nThe review surface is itself a CraftTS application. A `query` owns the live\nqueue, a `mutation` records each decision, and local `state` owns navigation,\nnotes and evidence zoom. The Node server remains the authority for the ledger\nand the content-addressed evidence store. A card disappears only after that\nserver confirms the write; failures remain visible and reviewable.\n`attest review` attempts to open the local URL in the default browser and always\nprints it so headless or remote environments can open it manually.\n\nFirst-time captures are deliberately **not clustered**. With no approved digest\nthere is no delta proving that two new screenshots represent the same change.\nThe UI shows one decision per scenario, its exact viewport, captured element\nsize, colour scheme, browser version and target selector. Real identical deltas\nmay still be clustered, with every covered scenario listed before the decision.\n\nThe default shortcuts are `j`/`k` to move, `a` to accept, `n` to accept with a\nnon-empty note and `r` to reject. A rejection requires a non-empty reason. That\nreason is stored in the ledger and shown prominently if the scenario returns to\nthe review queue, so it can guide the corrective code change. The same actions\nare available as buttons.\n\n## What this does not replace\n\n`visualMatrix` stays. It is the cheap tier for out-of-flow things — modals,\npopovers, tooltips — which have no neighbourhood, and for purely pictorial axes,\nwhich have no layout consequence. Both are enumerable from the sheets alone,\nwith no page in sight.\n"
460
+ "body": "# Attestation: a judgement that survives a refactor\n\nA snapshot suite records _what the output was_. Renaming a local variable\nchanges no pixel, and yet the whole suite asks to be looked at again — so people\nrun `--update-snapshots`, and the file that was supposed to record a human\ndecision records nothing at all.\n\nThis records something else:\n\n> **A person looked at this output and judged it correct, and that judgement\n> holds for as long as the code producing it has not moved.**\n\n## Two caches, never one\n\nTwo questions look alike and are not:\n\n| question | keyed on | a wrong answer costs |\n| ------------------------------ | --------------------------- | -------------------- |\n| should this be re-run? | fingerprint of a code slice | CPU |\n| should a human be asked again? | hash of the evidence | somebody's afternoon |\n\nFrom which the rule the whole design rests on: **when the code changes and the\nevidence does not, the attestation carries itself forward**, marked `renewed`,\nwith a note saying \"code changed, output unchanged\".\n\nThat is also why the code fingerprint is allowed to be _cautious_. A slice that\nis too wide only costs a re-run. Only a slice that is too narrow is dangerous —\nit misses a regression in silence, and nobody is ever asked about it again.\n\n| state | meaning |\n| --------- | --------------------------------------------- |\n| `current` | the fingerprint has not moved: nothing to do |\n| `renewed` | code moved, output did not: carried, no human |\n| `review` | the output differs: a human has to look |\n| `missing` | never attested |\n\n## The evidence is a digest, not a picture\n\nWhat a person judges is the **layout digest**: boxes rounded to the half pixel,\nintrinsic sizes, a closed list of computed styles, and a set of discrete facts —\nline counts, column counts, what wraps, what clips, what scrolls, what overlaps.\n\nThree things follow, and each is why the digest exists rather than a screenshot:\n\n- it is **diffable**. `.card padding 8→12` is a sentence a reviewer reads in a\n second; two images are not, and a reviewer who cannot see what changed\n approves everything.\n- it is **assertable**. Overflow, truncation, overlap and contrast are\n comparisons of numbers, so they are **failures**, not queue items — nobody is\n asked, and the message names the node and the pixel count.\n- it is **stable**. Anti-aliasing and font hinting move pixels without moving\n layout; under an image comparison every one of those is a review item.\n\nThe PNG is still kept, in the content-addressed store, for the human. It is a\nreview aid, never the reference.\n\n```ts\nimport {\n assertNoLayoutViolations,\n collectLayoutDigest,\n makeDeterministic,\n} from '@craft-ts/style-testing';\n\nawait makeDeterministic(page);\nawait page.goto('/users');\nconst digest = await collectLayoutDigest(page, {\n root: '[data-testid=userCard]',\n});\n\nassertNoLayoutViolations(digest, { scenario: 'locale=de-DE' });\n// → userCard/title hides 34px of \"Benutzerkontoeinstellungen\".\n```\n\n## Determinism is a feature, not hygiene\n\nIt carries two independent mechanisms. The **carry-forward** reads \"the code\nmoved, the output did not\"; a wobbling render never produces the same output\ntwice, the queue fills with changes nobody made, and people start stamping. The\n**bisection** reads a discrete signature as a function of one parameter; a wobble\nmanufactures thresholds that do not exist.\n\n`makeDeterministic` freezes the clock, seeds `Math.random`, kills animations,\ntransitions and the caret, and refuses every network request by default. A\nhundred consecutive renders of one scenario must produce a hundred identical\ndigests before anything else is worth building:\n\n```ts\nawait assertDeterministic(\n async () => JSON.stringify(await digestOf(page)),\n 100,\n);\n```\n\n## Where it tips over\n\nA content axis is continuous and a layout does not care about most of it. What\nit has are **thresholds**. `findTransitions` samples a coarse grid — word\nboundaries, digit-count changes — and bisects only inside the intervals where\nthe discrete signature actually moved.\n\nThen the report that arrives _before_ the bug:\n\n```\nuserCard/title: 1 → 2 lines at 34 characters.\nToday's German string is 33. Margin: 1 (3%), below 15%.\n```\n\nNothing is broken. That is the point: it fails in CI, with no human and no\npixel, on the translation nobody has written yet.\n\n```ts\nconst search = await findTransitions(signatureAt, {\n axis: 'title',\n min: 1,\n max: 80,\n});\nassertMargins([marginOf(search, longest.longestLength)]);\n```\n\nA bisection is not exactly true — it finds the thresholds that exist between the\npoints it looked at. So the sample count goes into the attestation as an\n**assumption**, and an attestation never says \"validated\"; it says \"validated,\nunder this assumption\". A changed assumption sends the subject back to review\nrather than quietly becoming a lie.\n\n## Translation as a source of axes\n\nThe catalogue is a TypeScript value, which makes two of these exact:\n\n- **the longest locale** for a screen is a computation over the keys that screen\n uses. One axis point, and the right one — where \"test it in German\" is only an\n approximation.\n- **the plural categories** are already declared and already checked exhaustive\n per locale. They are axis points by construction.\n\nThe **pseudo-locale** is the approximation, and it is the one that finds the\n_future_ case: 40% longer, `[[bracketed]]` so truncation is visible, every letter\naccented so an un-externalised string stands out.\n\n```ts\nimport {\n longestLocale,\n pseudoCatalog,\n findHardCodedText,\n} from '@craft-ts/i18n/testing';\n\nlongestLocale([en, de, ja], usedKeys); // → { id: 'de-DE', longestKey: 'account.settings' }\nfindHardCodedText(visibleStrings); // → ['Submit'] ← never went through the catalogue\n```\n\nTwo pressures, opposite failure modes, kept apart throughout: a rising\n`min-content` (an unbreakable word, a URL, a long number) stops a column\nshrinking; a rising `max-content` (a long but breakable sentence) steals width\nfrom its siblings in an `auto` track. A long sentence with spaces in it usually\ndoes not move `min-content` at all.\n\n## The command line\n\nCapture a real route into a portable report, then let the CLI derive every\nfingerprint from the current dependency graph:\n\n```sh\nnpm run attest:visual:capture\n\nnpm run attest:visual:status\n```\n\nThe capture starts the demo server when needed and writes the report, PNG\nscreenshots, and frozen `.snapshot.html` documents to `.craft/runs/`. The\nreport contains repository-relative graph node ids, digests and screenshot\npaths. It deliberately contains no code fingerprint: accepting a fingerprint\nfrom an old browser run could keep a stale slice current forever. Reports,\nscreenshots and frozen documents are regenerable and ignored; the ledger is\nnot.\n\nTo choose another report path, set `CRAFT_VISUAL_REPORT` on both commands:\n\n```sh\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:capture\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:status\n```\n\n```sh\nnpm run attest:visual:review\n\nnpx tsx libs/cli/src/bin/craft-ts.ts attest why 'visual:userCard#viewport=md'\nnpx tsx libs/cli/src/bin/craft-ts.ts attest renew \\\n --subject 'visual:userCard#viewport=md' --verdict ok\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind visual \\\n --report .craft/runs/design-system.json \\\n --tsconfig apps/demo/tsconfig.graph.json\nnpx tsx libs/cli/src/bin/craft-ts.ts attest unwatched\n```\n\nFor the unified review surface, use the DevTool. It combines visual captures\nand template obligations in one queue:\n\n```sh\nnpm run attest:devtools\n```\n\n### The reviewer reviews itself\n\nThe review application can use the same mechanism on its own UI. It runs in two\nsuccessive sessions so the queue cannot change while it is being captured: the\nfirst instance renders a deterministic fixture queue and freezes representative\nstates; the second instance reviews that visual report together with template\nobligations derived from the review application's own CraftTS graph.\n\n```sh\nnpm run attest:review-app:capture\nnpm run attest:review-app:status\nnpm run attest:review-app:review\n```\n\nThe capture includes the review page's happy path at mobile and desktop sizes,\nthe review queue in dark French, the regeneration confirmation, the visual-test\ninventory, and the template-obligation inventory. Every portable snapshot is\nreplayed immediately and must reproduce the live layout digest.\nThe first run reports missing decisions until a reviewer explicitly accepts or\nrejects them. Later unchanged evidence is carried forward by the usual ledger.\n\nThe review sidebar also offers **Regenerate all evidence**. It opens a\nconfirmation describing the current scope and whether previous decisions\nexist. Regeneration replaces the report, screenshots, and frozen documents,\nthen re-reads the graph and rebuilds the queue. It never clears the ledger:\nunchanged evidence stays current and only new or changed evidence returns to a\nreviewer. Any unsaved reason on the open card is discarded.\n\nThis control is shown only when the CLI session explicitly names an npm script:\n\n```sh\ncraft-ts attest devtools \\\n --report .craft/runs/project.json \\\n --tsconfig apps/project/tsconfig.graph.json \\\n --regenerate-script attest:project:capture\n```\n\nOnly an npm script name is accepted, not an arbitrary shell command. The script\nmust recreate the report supplied to `--report`; a failed run preserves the\nexisting queue.\n\nAfter rejecting views with comments, use **Prepare Codex iteration** in the\nreview sidebar. It generates, next to the report, a readable\n`<report>.review-feedback.md`, a structured `<report>.review-feedback.json`,\nand a copyable `<report>.codex-prompt.md`. The prompt contains the project root,\nreport, ledger, evidence store, graph `tsconfig`, capture script, source file\npaths, scenarios, measured changes, comments and any digest nodes pointed to by\nthe reviewer. Only the latest `rejected` cards are included. Because these\npaths come from the CLI session, `apps/demo` and the review application's\nself-attestation resolve to different, correct project contexts.\n\n### One happy path for every page\n\nApplication-level coverage is declared once and expanded into mobile and\ndesktop captures by default:\n\n```ts\nimport {\n defineHappyPathHttpMocks,\n defineVisualAppConfig,\n visualAppHappyPaths,\n} from '@craft-ts/style-testing';\n\nexport const homeHappyPath = defineHappyPathHttpMocks(\n 'home-page.happy-path.ts',\n {\n 'GET /api/users': { response: [{ id: '42', name: 'Ada' }] },\n },\n);\n\nexport const visualTestConfig = defineVisualAppConfig({\n pages: [\n {\n id: 'home',\n route: '',\n url: '/',\n component: 'component:src/app/home-page.ts:HomePage',\n mocks: homeHappyPath,\n },\n ],\n});\n\nfor (const scenario of visualAppHappyPaths(visualTestConfig)) {\n test(scenario.id, async ({ page }) => {\n await page.setViewportSize(scenario.viewport);\n await page.goto(scenario.page.url);\n // Install scenario.page.mocks, wait for the happy UI, then collectCapture.\n });\n}\n```\n\nWithout an explicit `viewports` value, CraftTS uses `mobile: 390x844` and\n`desktop: 1440x1000`. Keep each response dataset in a sibling\n`*.happy-path.ts` file. `matchHappyPathHttpRequest` turns that dataset into a\nrequest match suitable for `page.route`; when a `craftRoutes` registry is\navailable, wrap its exhaustive, response-typed `mockHttpRequestForRoute` result\nwith `defineRouteHappyPathHttpMocks('page.happy-path.ts', routeMock)`.\n\nAdd `assertVisualHappyPathArchitecture(graph.graph, visualTestConfig)` to the\napplication architecture suite. It fails when a routed page, a required\nviewport, or a Craft HTTP endpoint has no successful happy-path fixture. The\nfixture feeds a deterministic test environment only; application code keeps\nall remote work inside its `query`, `mutation`, or `asyncProcess` loader.\n\nSet `CRAFT_REVIEW_APP_REPORT` to the same path on all three commands to relocate\nthe default `.craft/runs/review-app.json` report. The implementation notes and\nthe exact workflow live in `libs/review-attestation/attestation-app/README.md` in the\nrepository.\n\nTemplate obligations do not need a Playwright report. They are derived from the\ncurrent graph and their canonical proof objects are written to\n`.craft/evidence/`:\n\n```sh\nnpm run attest:templates:status\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nTwo of these carry the rest.\n\n**`why`** names the graph nodes that moved inside the subject's slice, and when\na person last actually looked at it. A review that cannot answer \"why am I being\nasked this?\" is a review that gets stamped.\n\n**`unwatched`** lists the nodes that moved and belong to no attested subject —\n_what changed while nobody was looking_. It falls out of the machinery for free.\n\n`renew --all` is allowed and is **marked** as a bulk renewal in every\nattestation it writes, and `status` counts them. A bulk renewal that left no\ntrace would turn the register into a rubber stamp, which is worse than having no\nregister.\n\n## What a reviewer is shown\n\nTwo artefacts, and the reviewer switches between them.\n\n**The frozen page** is the render itself: the DOM, the styles, and the form\nstate, serialised at the moment the digest was taken. It replays as a real\ndocument — real boxes, real `:hover` — which is what lets someone click an\nelement and name it instead of clicking a pixel and hoping.\n\nFreezing means more than serialising the DOM. Craft injects its styles through\n`adoptedStyleSheets`, which `outerHTML` cannot see at all. And keeping the\nstylesheets verbatim would leave every `@media` to be re-evaluated against the\n_reviewer's_ window: on the demo's route the two conditions in play are\n`(min-width: 48rem)` and `(prefers-color-scheme: dark)` — exactly the two axes of\nthe matrix — so four scenarios would collapse into whatever that laptop said.\nMedia and supports are therefore evaluated at capture time and their winning\nbranch inlined. Container queries are left alone, because they ask about the\npage's own layout, which the replay reproduces.\n\nThe snapshot carries **no script**. Inertness is a property of the artefact, not\na guard that has to hold: nothing to block, nothing to leak, no `craftMethod`\nfiring on a stray click. The review application does its interactive work from\nthe parent frame, reaching into a same-origin iframe.\n\n**The screenshot** is the fallback, and the check on the checker: the digest is\nblind to anything that does not move a box or a listed style, so a swapped\nbackground or a wrong icon passes every automated test and is obvious to an eye.\n\n### The replay is checked, not trusted\n\nBefore anything is drawn on it, the replay is re-measured with the collector\nthat produced the evidence and compared against the attested digest. A missing\nfont, a media query left conditional, a stylesheet that could be neither read\nnor fetched — each produces a document that looks plausible and measures\ndifferently, and a reviewer would judge it without ever knowing.\n\nWhen it does not match, the card **moves the reviewer to the screenshot by\nitself** and says why in the same sentence, and the verdict is recorded as\n`degraded`: judging a photograph and judging the document are different claims.\nThe choice is a fallback, not a lock — asking for the page brings it back, still\nlabelled for what it is.\n\nThe message names the cause, not its symptoms. A subject the frozen page does\nnot contain reported \"36 attested node(s) are absent\" followed by forty\naddresses beginning `html/head/meta`: every consequence of one fact, and none of\nthem stating it. It now reads\n\n> The frozen page has no `.design-system-host` in it, so what it shows is not\n> this component. That happens when the stored snapshot is older than the report\n> it is paired with, or when the component's root selector changed after it was\n> captured.\n\nwhich is the same finding with the reviewer's next move in it.\n\nTwo things the check caught while it was being built, which is what it is for:\na marker stylesheet that set `position: relative` on the attested root and moved\nthe tree it was supposed to annotate, and a 1px border on the frame, which is\nsubtracted from the viewport inside it and made every measurement 2px narrow.\n\n## Knowing what is actually being judged\n\nA capture shows the whole page — shell, navigation, neighbours — because a\ncomponent has to be judged in the frame it sits in. So the reviewer has to be\nable to tell the subject from the decor, or a remark lands on a card that does\nnot cover it.\n\nThe digest answers this exactly: its paths **are** the attested set. Three tiers\nfollow, and all three come from data that already exists:\n\nThe screen is laid out in the order the work happens: the queue on the left,\nthe evidence in the middle, the verdict on the right, where it stays in place\nwhile a long capture is scrolled.\n\nThe surface speaks English and French, and follows the system's light or dark\npreference until the reviewer chooses otherwise — both controls sit in the\nsidebar, and both are applied before the first paint rather than corrected a\nframe later. The French dictionary is typed as the English one, so a message\nadded on one side and forgotten on the other does not compile.\n\nNeither reaches the frozen page. It is a render that was captured, not an\ninterface: translating it, or repainting its ground, would make it something\nother than what was measured. Enforcing that turned up a fidelity bug the tool\nhad been hiding by being permanently dark — a page paints its own colours, but\nnot the canvas underneath, and that comes from `color-scheme`, which was the\n_reviewer's_ preference. A component captured on white came back on black for\nanyone whose machine asks for dark. The replay now declares the scheme its\ncapture was taken in.\n\n| tier | source | shown as |\n| -------- | ------------------------------ | ----------------------------------------- |\n| changed | the paths in the readable diff | outlined, and the reason the card is here |\n| attested | the digest's own paths | selectable, highlighted on hover |\n| decor | everything else | dimmed, never removed |\n\nThe outlines carry a legend, drawn from the same object that paints them — a key\nthat keeps its own copy of a colour is a key that will one day name the wrong\none. Entries for tiers this card has none of are not shown, so the legend\ndescribes the page in front of the reviewer rather than the system in general.\n\nSelection is a set, not a node. Ctrl-click (cmd on a Mac) adds one, and dragging\na box takes everything it touches; the count is stated beside the reason field\nthat is about to name them. A remark covering a row of buttons was otherwise the\nsame sentence retyped once per button, which is also how a queue fills with\nfindings nobody can group afterwards. The band is drawn beside the frame and\nnever inside it: adding an element to the frozen document would break the only\nclaim it makes.\n\n### One reason, several complaints\n\nA rejection is rarely about one thing. Right-clicking a selection drops a\nreference into the reason **where the reviewer is typing**:\n\n> The title is cut at 34px in German. `[#1: 2 nodes]` And the row below\n> overflows its box. `[#2: 5 nodes]`\n\nThe reason stays one piece of prose, and each reference carries the text written\nsince the one before it — so the second complaint is filed against the second\ngroup and not, as a single note against every node would have it, against all\nseven.\n\nSelecting _is_ referencing: there is no second gesture. Pointing at part of the\npage drops the reference straight into the reason, and refining the selection\nedits that same reference rather than adding another — a click followed by a\nctrl-click leaves one saying \"2 nodes\", not a stale \"1 node\" beside it. Typing\nends the session: the reference belongs to a sentence now, and the next\nselection starts its own. Emptying the selection takes the reference back out,\nbecause a reference to nothing is worse than none.\n\nThe field is a `contenteditable`, not a `textarea`, so a reference is an element\nrather than the literal characters `[#1: 2 nodes]`: hovering it lists the\naddresses it stands for **and paints those nodes in the frozen page**, dashed\nrather than solid so it cannot be mistaken for the selection. That is the whole reason for the swap. As text, the\nanswer to \"which nodes is this one about\" had to live in a list somewhere else\non the page, and a reviewer reading a sentence had to leave it to find out. The\nplain text is still the model — everything downstream reads the serialised\nstring — so the chips are a rendering of the reason and never a second version\nof it.\n\nTwo things that swap broke, both worth stating because neither is obvious. The\nfield is not scrollable: a clipping context cuts the tooltip off any reference\non the first line, and which nodes a reference covers must not depend on where\nin the sentence it was written. And the keyboard shortcuts had to learn about\nit — the guard knew `input`, `textarea` and `select`, so typing \"And the row is\ncut\" pressed `a`, Accept, and filed a verdict the reviewer never reached.\n\nPosition settles that, not punctuation. The first rule tried was \"the sentence\nthe token stands in\", and it was wrong for the way people write: the complaint\nis typed, ended, and _then_ the group is pointed at, so the caret is past the\nfull stop and the token opens the next sentence rather than closing its own.\nReferencing first and explaining after reads the other way round, so a group\nwith nothing before it takes what follows.\n\nThe tokens are scaffolding. What is recorded is the prose with them removed,\nplus the addresses each one pointed at — and the text remains the only state:\ndeleting a reference deletes it, with no second list left holding a claim the\nreason no longer makes.\n\nAnd the mistake is made unrecordable rather than merely discouraged. A rejection\ncarries the path of the node it is about, so the server can refuse one that\nnames something this subject does not attest:\n\n> `demo-nav/toggle` is not attested by this subject. File the remark on the card\n> that covers it.\n\n### Attested is not the same as looked at\n\nThe capture also records the gap, because it is large. On the demo's route: **36\nnodes attested, 21 off screen, 1 covered** by the page's own fixed button. An\nattestation that stayed quiet about that would claim a coverage it does not\nhave, so the card states it and the screenshot draws the line where the viewport\nended.\n\nThe verdict buttons carry what they _do_. Three of the five are accepted by the\nledger and two are not, and nothing in the words says which — a reviewer\nchoosing between \"Known issue\" and \"Block\" is choosing between \"stops asking\"\nand \"asks every run\", which is the only difference that matters and the one they\ncould not see.\n\n\"Fit to window\" applies to the frozen page too, by `transform: scale()` and\nnever by a width: the frame has to stay exactly the viewport the page laid\nitself out in, or the replay stops being the render that was measured. A\ntransform changes what is painted and nothing about what was measured.\n\n\"Fit to window\" bounds both axes. Bounding the width alone — which is what it\ndid — fits a picture wider than the canvas and does nothing whatsoever to a\nnarrow one, and every capture on the demo route is 375 or 768 wide and 916 tall:\nthe control showed the whole render on one scenario and two thirds of it on the\nnext, for a reason that had nothing to do with what was being judged. It is not\noffered while the frozen page is on screen, because scaling that page would\nrelayout it and it would stop being the render that was measured.\n\nThe one covered node is the case only the frozen page can resolve: **lift what\nis covering it** and see what was underneath. In a screenshot those pixels have\nalready been replaced.\n\nThe control names what it will lift — `Hide button.clear-cache-btn` — and is\noffered only when something is actually covering the component. It used to read\n\"Hide 1 overlay\", which asked the reviewer what an overlay is and counted the\nwrong thing: covered _nodes_, when one button sitting on five of them is one\nthing to lift. Worse, it marked every fixed element on the page whether or not\nit covered anything, and marked nothing that covered without being fixed — so on\nmost cards it lifted something irrelevant, and on the cards that mattered it\ncould do nothing while the coverage line insisted a node was covered.\n\nWhat is lifted is now decided by probing the replay with the collector's own\nrule, which is also where the clamping bug in that rule was found: a sample\npoint outside the viewport was pulled back to the edge, so a node straddling the\nfold was reported as covered by whatever happened to sit on the fold line.\nSamples outside the viewport are skipped in both places now.\n\n## The review queue\n\nTwo mechanisms keep it from being abandoned, and neither is optional. The\ncarry-forward keeps everything whose output did not move out of the queue\nentirely. And the queue **clusters by the shape of the diff**: one border-radius\nchange produces two hundred scenarios with an identical delta, and one decision\ncovers all of them — with the cluster written into every attestation it covered,\nso \"judged\" and \"judged alongside 199 others\" stay distinguishable.\n\nThe review surface is itself a CraftTS application. A `query` owns the live\nqueue, a `mutation` records each decision, and local `state` owns navigation,\nnotes and evidence zoom. The Node server remains the authority for the ledger\nand the content-addressed evidence store. A card disappears only after that\nserver confirms the write; failures remain visible and reviewable.\n`attest review` attempts to open the local URL in the default browser and always\nprints it so headless or remote environments can open it manually.\n\nFirst-time captures are deliberately **not clustered**. With no approved digest\nthere is no delta proving that two new screenshots represent the same change.\nThe UI shows one decision per scenario, its exact viewport, captured element\nsize, colour scheme, browser version and target selector. Real identical deltas\nmay still be clustered, with every covered scenario listed before the decision.\n\nThe default shortcuts are `j`/`k` to move, `a` to accept, `n` to accept with a\nnon-empty note and `r` to reject. A rejection requires a non-empty reason. That\nreason is stored in the ledger and shown prominently if the scenario returns to\nthe review queue, so it can guide the corrective code change. The same actions\nare available as buttons.\n\n## What this does not replace\n\n`visualMatrix` stays. It is the cheap tier for out-of-flow things — modals,\npopovers, tooltips — which have no neighbourhood, and for purely pictorial axes,\nwhich have no layout consequence. Both are enumerable from the sheets alone,\nwith no page in sight.\n\n\n## Application overview\n\nFor full-page scenarios (including automatically opened dialogs), configure\n`visual.app.pages[].scenarios` in `defineReviewAttestConfig`. The default formats\nare mobile 390×844, tablet 834×1112, desktop 1440×1000 and wide 2560×1440; an\nexplicit viewport record replaces them. Use `captureVisualApp` from\n`@craft-ts/style-testing/visual-app/playwright` to execute the recipes and mocks.\n\nThe **Aperçu de l’application** view compares each page/scenario/capture/viewport\nindependently. PNG comparisons use threshold 0.1 and maxDiffPixels 10, configured\nin the project. Tolerated changes retain the last human-accepted image as their\nreference. Missing captures, changed sources or failed generation require a new\nrun. Template and matrix attestations stay separate.\n"
451
461
  },
452
462
  {
453
463
  "path": "/guide/style/contrast",
454
464
  "title": "Text contrast, proven without a browser",
455
- "body": "# Text contrast, proven without a browser\n\n`npm run style:check` reads your sheets and your templates and answers one\nquestion, for every element it can prove holds text, in every state your axes\ncan produce:\n\n> is this text readable on the background it is actually painted on?\n\nIt is WCAG 2.2 §1.4.3 level AA — `4.5:1` for normal text, `3:1` for large text\n— and it needs no browser, no screenshot and no Playwright run.\n\n::: warning What this is not\nThis proves **text contrast**, in a declared subset of CSS. It is not an\naccessibility audit, and a green run is not a claim that your application is\naccessible. Focus order, names, roles, motion, target size and everything else\nare elsewhere. Read [the coverage contract](#the-coverage-contract) before you\nput a badge on it.\n:::\n\n## Running it\n\n```bash\nnpm run style:check\n```\n\nIn a project generated with typed CSS this is already wired: it builds once so\nthe style plugin writes `.craft/style-graph.json`, then analyses that dump\ntogether with your TypeScript program.\n\nBy hand, on an existing project:\n\n```bash\nnpx craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json\n```\n\n`--json` gives a stable machine-readable report for CI. Two runs on unchanged\nsources produce byte-identical output.\n\nUnlike `--style-matrix` and `--style-debt`, this command **does** build the\nTypeScript program. It has to: a contrast proof needs to know which element\ncarries which class and what sits above it, and no style dump has ever seen a\ntemplate.\n\n## Reading a failure\n\n```text\ncontrast/fail\nroute: /checkout\ncomponent: SubmitButton\nelement: button.root\nscenario: interaction.hover=active+tone=warning\nforeground: ui.text.onAccent #ffffff (dsButton-root → --dsButton-ink (initial))\nbackground: ui.accent.warning.dark #f5b544 (button.root: dsButton-root → --dsButton-bg)\nfont: 14px / 600 (normal text)\nratio: 1.81:1\nrequired: 4.5:1\n```\n\nEvery line is there because a report missing it sends you to the wrong file:\n\n- **scenario** — the exact combination. Not \"the warning button\": the warning\n button *under the pointer*, which is often the only failing one.\n- **foreground / background** — the token name first, then the value, then the\n chain that produced it. `ui.accent.warning.dark` tells you which token to\n change; `dsButton-root → --dsButton-bg` tells you which rule put it there.\n- **font** — with the threshold it earned. See\n [large text](#which-threshold-applies).\n\nRows that resolve to the same answer are folded together and list the\nscenarios they stand for under `also in:`, so a five-tone button does not\nprint five identical lines.\n\n## The two halves, and why only one of them fails a build\n\n| | `--palette-contrast` | `--style-contrast` |\n|---|---|---|\n| reads | the dump alone | the dump **and** the templates |\n| answers | every pair your palette can express | every pair an element is actually painted in |\n| verdict | informative | **blocking** |\n\nA palette of twenty tokens has hundreds of pairs and an application renders a\nfew dozen of them. Failing a build on `ui.text.onAccent` over\n`ui.surface.page` — white on white, and a combination no element uses — trains\npeople to switch the check off, and takes the real failures with it.\n\nSo the matrix is a table you read while designing, and the analysis is the\ngate. When you pass the analysis's results to `paletteContrastMatrix`, each\npair also gets a `usedBy` list of the elements that render it; without them the\nfield is **absent** rather than empty, because \"nobody looked\" and \"the\nanalysis looked and found nowhere\" are different answers.\n\n## Which threshold applies\n\n- large if `font-size >= 24px`;\n- large if `font-size >= 18.5px` **and** the weight is at least `700`;\n- normal otherwise.\n\nTwo consequences worth knowing before you argue with a report:\n\n- `text.lg` is `1.125rem` — **18px** — so a bold title at that size is *normal*\n text and needs `4.5:1`. It misses the large threshold by half a pixel.\n- `600` is not bold. WCAG says \"bold\" without a number and CSS says bold is\n 700; reading `600` as bold would lower a threshold on an ambiguity, which is\n the wrong side to err on.\n\n`rem` becomes pixels against a 16px root. If your page sets a different root\nsize outside CraftTS, say so — nothing in the dump can know.\n\nThe ratio is **never rounded before it is compared**. `4.4999:1` fails a 4.5\nthreshold, even though the report prints it as `4.49:1`.\n\n## How colour is resolved\n\n### `color` is inherited\n\nThe analysis walks the element's ancestor chain outside-in, exactly as the\ncascade does. A paragraph that sets no colour of its own takes the one from\nits card, or from the theme wrapper above it.\n\n### The background is the first opaque thing underneath\n\nStarting at the element and walking outwards:\n\n- an element that paints nothing is transparent, and the search continues;\n- the first opaque colour wins;\n- an element that paints something the model cannot read — an image, a\n semi-transparent fill, anything behind an `opacity` — **stops** the search\n and produces `indeterminate`, because whatever is behind it is no longer\n what the text is composited against.\n\nIf nothing in the chain paints, that is `unknown-background`. It is not\n\"assume white\": a white assumption is right on one theme and wrong on the\nother.\n\n### Variables resolve the way `@property` says they do\n\n- a registered variable nobody wrote resolves to its **registered initial\n value**, not to the `var()` fallback;\n- `inherits: false` really does not cross into a child — a theme variable set\n on a wrapper reaches the button, a component variable does not.\n\nThat last one is the trap the design system is full of, and getting it wrong\nwould prove the wrong colour for every component under a themed wrapper.\n\n### The cascade is replayed, not approximated\n\nThree tie-breaks, in the browser's order:\n\n1. **Layer.** Unconditional atoms land in `components`, conditional ones in\n `variants`, so every variant beats every base rule.\n2. **Specificity**, inside `variants`. `&[data-tone='warning']:hover` has one\n more selector fragment than `&[data-tone='warning']`, so the hovered fill\n wins — whatever the source order. A media query contributes nothing.\n3. **Source order**, last, which is atomic class-name order because that is\n how the emitter sorts the layer.\n\nGetting the second wrong is the interesting failure: a solver would resolve a\ntone-plus-hover button to its resting fill and report `pass` on the state that\nfails.\n\n## Hover is an axis\n\n```ts\nwhen(tone.warning, [\n set(buttonVars.bg, ui.accent.warning),\n when(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]),\n]);\n```\n\n`interaction.hover` emits the same `:hover` rule a hand-written selector would.\nWhat it adds is that the point lands in the class's variant contract — so the\nvisual matrix captures the hovered state, and this analysis crosses the\ncolours it writes with the text that sits on them.\n\nA `:hover` typed into a string is invisible to both. That is how a button ends\nup readable at rest and unreadable under the pointer: the one state nobody\nscreenshots. The `prefer-hover-axis` lint rule refuses it.\n\nThe axis carries its own driver (`{ kind: 'selfState', state: 'hover' }`), so a\ncapture of the hovered state is something a harness can actually produce.\n`applyScenario` asks the page to move a real pointer and throws if it cannot —\ndispatching a `mouseover` event would fire listeners and leave the pseudo-class\nuntouched, producing a screenshot of the base state that passes forever.\n\nThe cost is real and it is a decision: hover doubled the demo button's matrix\nfrom 18 scenarios to 36. That is why the axis has to be in the sheet's budget.\n\n## Naming your palette\n\n```ts\nexport const ui = definePalette('ui', {\n text: { onAccent: { light: '#ffffff', dark: '#0b0d11' } },\n accent: { warning: { light: '#8a5a00', dark: '#f5b544' } },\n});\n```\n\nThe name travels with every colour, through variables and `darkOf()`, all the\nway into the report. `definePalette(spec)` without a name still works and still\ncarries the group and the token — you get `(unnamed).accent.warning`, which\npoints at the right entry and asks to be named.\n\nWrite hovered and pressed fills as **tokens**, not as a `darken()` at the use\nsite. A function hides the resulting colour from the palette, and the palette\nis where the contrast question gets settled once instead of per component.\n\n## The coverage contract\n\n### Covered in v1\n\n- opaque colours in hexadecimal or `rgb()`/`rgba()`;\n- inherited `color`;\n- `background-color`, local or seen through transparent ancestors;\n- CraftTS variables declared with `cssVars()`, their initial values, their\n conditional writes and their `var()` fallbacks;\n- constant classes from `craftStyles()`;\n- light and dark themes;\n- every finite state and size axis, `interaction.hover` included;\n- static and dynamic text, wherever the element can be proven to hold text;\n- a component evaluated once per surface it is rendered on.\n\n### Not covered in v1\n\nEach of these produces `indeterminate` with its reason — never a pass.\n\n- images and gradients behind text;\n- `canvas`, text inside SVG, generated pseudo-element content;\n- `filter`, `backdrop-filter`, `mix-blend-mode`, and any `opacity` below 1;\n- semi-transparent colours, which would need compositing;\n- CSS expressions the DSL does not model;\n- colours computed from runtime data that is not a finite set;\n- external stylesheets and inline styles outside CraftTS;\n- CJK metrics and unusual font geometry — the size in CSS pixels is not the\n size on screen, and the large-text convention assumes latin faces.\n\n### What `indeterminate` means, and why it fails by default\n\n| reason | what happened |\n|---|---|\n| `unknown-foreground` | nothing readable sets the text colour |\n| `unknown-background` | nothing in the chain paints an opaque surface |\n| `unknown-font-size` | the size is not a length this can turn into pixels |\n| `unsupported-background` | an image, a gradient, a blend, or an alpha |\n| `dynamic-style` | the class is assembled at runtime |\n| `external-style` | the styles come from outside CraftTS |\n| `incomplete-render-context` | the component is rendered somewhere unanalysed |\n\n**Indeterminate results fail the run.** `--allow-indeterminate` downgrades them\nto warnings, and you have to type it. A check whose default treats \"I could not\ntell\" as \"fine\" reports a clean bill on the half of the application it\nunderstood — and that half is exactly where the gradients and the runtime\ncolours live.\n\nA report with **no violations and open indeterminates is not a proof.** The\nsummary prints all three counts for that reason:\n\n```text\nText contrast: 41 pass, 0 fail, 3 indeterminate (44 checked).\n```\n\nZero checked is not a pass either, and the tool says so: it nearly always means\nthe dump and the program describe different applications.\n\n## Fixing a violation\n\n1. **Read the scenario.** If only the hovered or only the dark row fails, the\n fix belongs to that one rule, not to the token everything uses.\n2. **Read the token names.** A pair that fails in several places is a palette\n decision — change the token once, and `usedBy` tells you what moves.\n3. **Change the token, not the call site.** A local override is a colour the\n palette no longer describes, and the next component repeats the bug.\n4. **If the text is genuinely large**, check that the sheet says so. 18px bold\n is normal text; 22px bold is large.\n5. **Re-run.** The report is deterministic, so a diff of two JSON runs shows\n exactly what your change moved.\n\n## Clearing an indeterminate\n\nYou have three honest moves, and inventing a colour is not one of them.\n\n- **Bring the surface into the model.** A `background-color` set in raw CSS is\n the common case; `no-unmodelled-text-color` points at it.\n- **Give the text a surface it can be measured against.** Text over a hero\n image has no ratio because it has no single background — put it on a panel,\n or accept that it cannot be proven.\n- **Declare the surface uncovered.** Add its path to the\n `no-unmodelled-text-color` rule's `uncovered` option. The gap is then\n counted as a gap rather than mistaken for a proof, which is the whole point.\n\n## Migrating an existing project\n\n1. Name your palette: `definePalette('ui', spec)`. Nothing else changes.\n2. Turn hand-written `:hover` rules into `when(interaction.hover, …)` and add\n `interaction` to those sheets' budgets. `prefer-hover-axis` finds them.\n3. Add `dumpPath: '.craft/style-graph.json'` to `craftStyle()` in\n `vite.config.ts`.\n4. Replace `style:check` with a build followed by\n `craft-graph --style-contrast`.\n5. Run it with `--allow-indeterminate` **once**, to see the size of the gap.\n6. Close the gaps, or declare them uncovered, and drop the flag. Leaving it on\n permanently is the same as not having the check.\n"
465
+ "body": "# Text contrast, proven without a browser\n\n`npm run style:check` reads your sheets and your templates and answers one\nquestion, for every element it can prove holds text, in every state your axes\ncan produce:\n\n> is this text readable on the background it is actually painted on?\n\nIt is WCAG 2.2 §1.4.3 level AA — `4.5:1` for normal text, `3:1` for large text\n— and it needs no browser, no screenshot and no Playwright run.\n\n::: warning What this is not\nThis proves **text contrast**, in a declared subset of CSS. It is not an\naccessibility audit, and a green run is not a claim that your application is\naccessible. Focus order, names, roles, motion, target size and everything else\nare elsewhere. Read [the coverage contract](#the-coverage-contract) before you\nput a badge on it.\n:::\n\n## Running it\n\n```bash\nnpm run style:check\n```\n\nIn a project generated with typed CSS this is already wired: it builds once so\nthe style plugin writes `.craft/style-graph.json`, then analyses that dump\ntogether with your TypeScript program.\n\nBy hand, on an existing project:\n\n```bash\nnpx craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json\n```\n\n`--json` gives a stable machine-readable report for CI. Two runs on unchanged\nsources produce byte-identical output.\n\nUnlike `--style-matrix` and `--style-debt`, this command **does** build the\nTypeScript program. It has to: a contrast proof needs to know which element\ncarries which class and what sits above it, and no style dump has ever seen a\ntemplate.\n\n## Reading a failure\n\n```text\ncontrast/fail\nroute: /checkout\ncomponent: SubmitButton\nelement: button.root\nscenario: interaction.hover=active+tone=warning\nforeground: ui.text.onAccent #ffffff (dsButton-root → --dsButton-ink (initial))\nbackground: ui.accent.warning.dark #f5b544 (button.root: dsButton-root → --dsButton-bg)\nfont: 14px / 600 (normal text)\nratio: 1.81:1\nrequired: 4.5:1\n```\n\nEvery line is there because a report missing it sends you to the wrong file:\n\n- **scenario** — the exact combination. Not \"the warning button\": the warning\n button *under the pointer*, which is often the only failing one.\n- **foreground / background** — the token name first, then the value, then the\n chain that produced it. `ui.accent.warning.dark` tells you which token to\n change; `dsButton-root → --dsButton-bg` tells you which rule put it there.\n- **font** — with the threshold it earned. See\n [large text](#which-threshold-applies).\n\nRows that resolve to the same answer are folded together and list the\nscenarios they stand for under `also in:`, so a five-tone button does not\nprint five identical lines.\n\n## The two halves, and why only one of them fails a build\n\n| | `--palette-contrast` | `--style-contrast` |\n|---|---|---|\n| reads | the dump alone | the dump **and** the templates |\n| answers | every pair your palette can express | every pair an element is actually painted in |\n| verdict | informative | **blocking** |\n\nA palette of twenty tokens has hundreds of pairs and an application renders a\nfew dozen of them. Failing a build on `ui.text.onAccent` over\n`ui.surface.page` — white on white, and a combination no element uses — trains\npeople to switch the check off, and takes the real failures with it.\n\nSo the matrix is a table you read while designing, and the analysis is the\ngate. When you pass the analysis's results to `paletteContrastMatrix`, each\npair also gets a `usedBy` list of the elements that render it; without them the\nfield is **absent** rather than empty, because \"nobody looked\" and \"the\nanalysis looked and found nowhere\" are different answers.\n\n## Which threshold applies\n\n- large if `font-size >= 24px`;\n- large if `font-size >= 18.5px` **and** the weight is at least `700`;\n- normal otherwise.\n\nTwo consequences worth knowing before you argue with a report:\n\n- `text.lg` is `1.125rem` — **18px** — so a bold title at that size is *normal*\n text and needs `4.5:1`. It misses the large threshold by half a pixel.\n- `600` is not bold. WCAG says \"bold\" without a number and CSS says bold is\n 700; reading `600` as bold would lower a threshold on an ambiguity, which is\n the wrong side to err on.\n\n`rem` becomes pixels against a 16px root. If your page sets a different root\nsize outside CraftTS, say so — nothing in the dump can know.\n\nThe ratio is **never rounded before it is compared**. `4.4999:1` fails a 4.5\nthreshold, even though the report prints it as `4.49:1`.\n\n## How colour is resolved\n\n### `color` is inherited\n\nThe analysis walks the element's ancestor chain outside-in, exactly as the\ncascade does. A paragraph that sets no colour of its own takes the one from\nits card, or from the theme wrapper above it.\n\n### The background is the first opaque thing underneath\n\nStarting at the element and walking outwards:\n\n- an element that paints nothing is transparent, and the search continues;\n- the first opaque colour wins;\n- an element that paints something the model cannot read — an image, a\n semi-transparent fill, anything behind an `opacity` — **stops** the search\n and produces `indeterminate`, because whatever is behind it is no longer\n what the text is composited against.\n\nIf nothing in the chain paints, that is `unknown-background`. It is not\n\"assume white\": a white assumption is right on one theme and wrong on the\nother.\n\n### Variables resolve the way `@property` says they do\n\n- a registered variable nobody wrote resolves to its **registered initial\n value**, not to the `var()` fallback;\n- `inherits: false` really does not cross into a child — a theme variable set\n on a wrapper reaches the button, a component variable does not.\n\nThat last one is the trap the design system is full of, and getting it wrong\nwould prove the wrong colour for every component under a themed wrapper.\n\n### The cascade is replayed, not approximated\n\nThree tie-breaks, in the browser's order:\n\n1. **Layer.** Unconditional atoms land in `components`, conditional ones in\n `variants`, so every variant beats every base rule.\n2. **Specificity**, inside `variants`. `&[data-tone='warning']:hover` has one\n more selector fragment than `&[data-tone='warning']`, so the hovered fill\n wins — whatever the source order. A media query contributes nothing.\n3. **Source order**, last, which is atomic class-name order because that is\n how the emitter sorts the layer.\n\nGetting the second wrong is the interesting failure: a solver would resolve a\ntone-plus-hover button to its resting fill and report `pass` on the state that\nfails.\n\n## Hover is an axis\n\n```ts\nwhen(tone.warning, [\n set(buttonVars.bg, ui.accent.warning),\n when(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]),\n]);\n```\n\n`interaction.hover` emits the same `:hover` rule a hand-written selector would.\nWhat it adds is that the point lands in the class's variant contract — so the\nvisual matrix captures the hovered state, and this analysis crosses the\ncolours it writes with the text that sits on them.\n\nA `:hover` typed into a string is invisible to both. That is how a button ends\nup readable at rest and unreadable under the pointer: the one state nobody\nscreenshots. The `prefer-hover-axis` lint rule refuses it.\n\nThe axis carries its own driver (`{ kind: 'selfState', state: 'hover' }`), so a\ncapture of the hovered state is something a harness can actually produce.\n`applyScenario` asks the page to move a real pointer and throws if it cannot —\ndispatching a `mouseover` event would fire listeners and leave the pseudo-class\nuntouched, producing a screenshot of the base state that passes forever.\n\nThe cost is real and it is a decision: hover doubled the demo button's matrix\nfrom 18 scenarios to 36. That is why the axis has to be in the sheet's budget.\n\n## Naming your palette\n\n```ts\nexport const ui = definePalette('ui', {\n text: { onAccent: { light: '#ffffff', dark: '#0b0d11' } },\n accent: { warning: { light: '#8a5a00', dark: '#f5b544' } },\n});\n```\n\nThe name travels with every colour, through variables and `darkOf()`, all the\nway into the report. `definePalette(spec)` without a name still works and still\ncarries the group and the token — you get `(unnamed).accent.warning`, which\npoints at the right entry and asks to be named.\n\nWrite hovered and pressed fills as **tokens**, not as a `darken()` at the use\nsite. A function hides the resulting colour from the palette, and the palette\nis where the contrast question gets settled once instead of per component.\n\n## The coverage contract\n\n### Covered in v1\n\n- opaque colours in hexadecimal or `rgb()`/`rgba()`;\n- inherited `color`;\n- `background-color`, local or seen through transparent ancestors;\n- CraftTS variables declared with `cssVars()`, their initial values, their\n conditional writes and their `var()` fallbacks;\n- constant classes from `craftStyles()`;\n- light and dark themes;\n- every finite state and size axis, `interaction.hover` included;\n- static and dynamic text, wherever the element can be proven to hold text;\n- a component evaluated once per surface it is rendered on.\n\n### Seen but not solvable in v1\n\nWhen one of these constructs reaches the graph, it produces `indeterminate`\nwith its reason — never a pass:\n\n- images and gradients recorded as `background-image`;\n- an `opacity` below 1 on the text or an element behind it;\n- semi-transparent colours, which would need compositing;\n- a dynamic class the template graph cannot resolve;\n- an unknown foreground, background or font size;\n- a scenario or render context that exceeds the configured analysis limit.\n\n### Outside the observable boundary\n\nExternal stylesheets, inline styles outside CraftTS, `canvas`, text inside SVG,\ngenerated pseudo-element content, filters and blend modes do not necessarily\nenter the typed style dump. The analyser cannot emit an `indeterminate` for a\ndeclaration it never receives. The `typedCss` ESLint preset guards the raw\ncomponent styles it can see, but this is not a general CSS crawler.\n\nThe large-text threshold is calculated from CSS pixels and weight. The analyser\ndoes not inspect the script, font face or cap height, so it does not detect CJK\nor unusual font geometry and does not emit a CJK-specific diagnostic. Treat\nthose surfaces as outside the v1 proof unless their typography convention has\nbeen validated separately.\n\n### What `indeterminate` means, and why it fails by default\n\n| reason | what happened |\n|---|---|\n| `unknown-foreground` | nothing readable sets the text colour |\n| `unknown-background` | nothing in the chain paints an opaque surface |\n| `unknown-font-size` | the size is not a length this can turn into pixels |\n| `unsupported-background` | an image, a gradient, a blend, or an alpha |\n| `dynamic-style` | the class is assembled at runtime |\n| `incomplete-render-context` | the component is rendered somewhere unanalysed |\n\n**Indeterminate results fail the run.** `--allow-indeterminate` downgrades them\nto warnings, and you have to type it. A check whose default treats \"I could not\ntell\" as \"fine\" reports a clean bill on the half of the application it\nunderstood — and that half is exactly where the gradients and the runtime\ncolours live.\n\nA report with **no violations and open indeterminates is not a proof.** The\nsummary prints all three counts for that reason:\n\n```text\nText contrast: 41 pass, 0 fail, 3 indeterminate (44 checked).\n```\n\nZero checked fails even with `--allow-indeterminate`: it nearly always means\nthe dump and the program describe different applications, and there is no\nresult to review or waive.\n\n## Fixing a violation\n\n1. **Read the scenario.** If only the hovered or only the dark row fails, the\n fix belongs to that one rule, not to the token everything uses.\n2. **Read the token names.** A pair that fails in several places is a palette\n decision — change the token once, and `usedBy` tells you what moves.\n3. **Change the token, not the call site.** A local override is a colour the\n palette no longer describes, and the next component repeats the bug.\n4. **If the text is genuinely large**, check that the sheet says so. 18px bold\n is normal text; 22px bold is large.\n5. **Re-run.** The report is deterministic, so a diff of two JSON runs shows\n exactly what your change moved.\n\n## Clearing an indeterminate\n\nYou have two honest moves, and inventing a colour is not one of them.\n\n- **Bring the surface into the model.** A `background-color` set in raw CSS is\n the common case; `no-unmodelled-text-color` points at it.\n- **Give the text a surface it can be measured against.** Text over a hero\n image has no ratio because it has no single background — put it on a panel,\n or accept that it cannot be proven.\n\nIf a surface lives entirely outside the observable graph, it will not create an\nindeterminate to clear. You may add its path to the\n`no-unmodelled-text-color` rule's `uncovered` option as an explicit lint\nexemption. This does **not** add a row to the contrast report. Keep the\nexclusion visible in review and do not describe the strict check as covering\nit.\n\n## Migrating an existing project\n\nYou do not need to recreate the application or add Playwright. Align the\ninstalled `@craft-ts/*` packages on the same current version; the contrast CLI\nis provided by `@craft-ts/dev-tools`, while `@craft-ts/style-testing` is only\nneeded for visual scenarios and attestation.\n\n1. Name each palette: `definePalette('ui', spec)`. Nothing else changes.\n2. Turn hand-written `:hover` rules into `when(interaction.hover, …)` and add\n `interaction` to those sheets' budgets. `prefer-hover-axis` finds them.\n3. Enable `craftRules.configs.typedCss.rules` in ESLint. It finds raw hover and\n contrast properties in component styles that would otherwise be invisible.\n4. Add `dumpPath: '.craft/style-graph.json'` to `craftStyle()` in\n `vite.config.ts` and keep `virtual:craft-style.css` imported at app entry.\n5. Make `style:check` build the app, then run the strict analysis:\n\n ```bash\n craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json\n ```\n\n6. Run that command with `--allow-indeterminate` **once**, to inventory the\n visible gaps. Close them and remove the flag before making the check a CI\n gate. A lint `uncovered` exemption remains outside the proof.\n"
456
466
  },
457
467
  {
458
468
  "path": "/guide/style/define",
@@ -467,7 +477,7 @@
467
477
  {
468
478
  "path": "/guide/style/setup",
469
479
  "title": "Activating `@craft-ts/style`",
470
- "body": "# Activating `@craft-ts/style`\n\nThe typed style system is not a runtime library you import and call. It is a\n**build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates\nwhat they registered, and emits one stylesheet. Without that plugin the\nvocabulary still typechecks and still compiles — and the page renders with no\nCSS at all.\n\nThis page is the one to follow before the other four.\n\n## Install\n\n```bash\nnpm install @craft-ts/style\nnpm install --save-dev @craft-ts/style-testing\n```\n\n`@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom\nproperties, axes, sheets, obligations. `@craft-ts/style-testing` carries the\nscenario matrix and the drivers that reach each of its points; it never ships to\nthe browser, so it belongs in `devDependencies`.\n\n`@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and\n`@craft-ts/style-testing` declares `@craft-ts/style`. Both are `sideEffects:\nfalse`.\n\n## Wire the plugin\n\n\n\n`craftStyle` takes four options, all optional:\n\n| option | default | what it decides |\n| ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| `suffix` | `'.style.ts'` | the filename suffix that marks a module as a sheet |\n| `ignore` | `['node_modules', 'dist', '.git', '.nx', 'tmp']` | directory names the walk never descends into |\n| `dumpPath` | none — no dump is written | where to write the graph dump |\n| `alias` | none | module aliases for the **Node** evaluation of the sheets |\n\n`alias` exists because the sheets are evaluated by a real bundler in a separate\npass, before your app's own resolution applies. In a published project, Node\nresolution finds `@craft-ts/style` on its own and you can leave `alias` out. In\nthis monorepo the demo passes the workspace source paths — see\n[`apps/demo/vite.config.ts`](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/vite.config.ts),\nwhich is the working reference for everything on this page.\n\nThen import the emitted sheet once, at the app entry:\n\n```ts\nimport 'virtual:craft-style.css';\n```\n\nIf your `tsconfig` does not already know that id, declare it next to your other\nambient types:\n\n```ts\ndeclare module 'virtual:craft-style.css';\n```\n\n## What the plugin produces\n\nTwo artefacts, from one evaluation.\n\n**The CSS.** Every `when(...)` and `set(...)` the sheets registered, rendered as\natomic rules and `@property` registrations, deduplicated across files, and\nserved under `virtual:craft-style.css`. This is the whole stylesheet: no class\nis ever assembled in the browser, so what the browser gets is exactly what the\nemitter proved.\n\n**The dump**, when `dumpPath` is set. A JSON picture of the registry — classes,\natoms, and typed variables — written on _every_ emission, so it can never\ndescribe a sheet older than the CSS that was served alongside it. The dump is\nthe style half of the dependency graph: it is what\n[`style_impact`, `style_matrix` and `style_debt`](./testing.md#what-the-graph-adds)\nread, and what the `@craft-ts/dev-tools` style queries read.\n\nThe plugin re-derives the whole sheet when a `*.style.ts` changes rather than\npatching it. Atomic output is small and the emission is one bundle away; an\nincremental path here would be a second source of truth about what the CSS says.\n\n## What breaks without it\n\n| missing | symptom |\n| ---------------------------------- | ---------------------------------------------------------------------------- |\n| the plugin | no CSS at all — the classes exist as strings, nothing ever wrote their rules |\n| `import 'virtual:craft-style.css'` | same, and less obviously: the plugin runs but nothing pulls its output |\n| `dumpPath` | `style_matrix` and the other graph queries have nothing to read and say so |\n\nThe MCP server names the fix in its own error message: it points you back at\n`craftStyle({ dumpPath })`. If you are reading this because you saw that\nmessage, `dumpPath` is the line you are missing.\n\n## Emitting without a Vite server\n\n`@craft-ts/style/vite` exports the emitter itself, so a test or a script can get\nthe same two artefacts without standing up a dev server:\n\n\n\n`vite` is a peer of that entry point, not a dependency: a project that builds\nwith something else can still call `emitStyles` without pulling Vite's types\ninto its own program.\n\n## Next\n\n- [Define your design system](./define.md) — palette, axes, theme: where `bp`,\n `scheme` and `palette` come from.\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n"
480
+ "body": "# Activating `@craft-ts/style`\n\nThe typed style system is not a runtime library you import and call. It is a\n**build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates\nwhat they registered, and emits one stylesheet. Without that plugin the\nvocabulary still typechecks and still compiles — and the page renders with no\nCSS at all.\n\nThis page is the one to follow before the other four.\n\n## Install\n\n```bash\nnpm install @craft-ts/style\nnpm install --save-dev @craft-ts/dev-tools\n# Optional, for visual scenario matrices and attestation:\nnpm install --save-dev @craft-ts/style-testing\n```\n\n`@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom\nproperties, axes, sheets, obligations. `@craft-ts/dev-tools` carries the\n`craft-graph` contrast command and the ESLint guard rails.\n`@craft-ts/style-testing` carries the optional scenario matrix and the drivers\nthat reach each of its points; it never ships to the browser, so it belongs in\n`devDependencies` when visual scenarios or attestation are enabled.\n\n`@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and\n`@craft-ts/style-testing` declares `@craft-ts/style`. Keep all installed\n`@craft-ts/*` packages on the same release line. The runtime style package and\nthe optional testing package are both `sideEffects: false`.\n\n## Wire the plugin\n\n\n\n`craftStyle` takes four options, all optional:\n\n| option | default | what it decides |\n| ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| `suffix` | `'.style.ts'` | the filename suffix that marks a module as a sheet |\n| `ignore` | `['node_modules', 'dist', '.git', '.nx', 'tmp']` | directory names the walk never descends into |\n| `dumpPath` | none — no dump is written | where to write the graph dump |\n| `alias` | none | module aliases for the **Node** evaluation of the sheets |\n\n`alias` exists because the sheets are evaluated by a real bundler in a separate\npass, before your app's own resolution applies. In a published project, Node\nresolution finds `@craft-ts/style` on its own and you can leave `alias` out. In\nthis monorepo the demo passes the workspace source paths — see\n[`apps/demo/vite.config.ts`](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/vite.config.ts),\nwhich is the working reference for everything on this page.\n\nThen import the emitted sheet once, at the app entry:\n\n```ts\nimport 'virtual:craft-style.css';\n```\n\nIf your `tsconfig` does not already know that id, declare it next to your other\nambient types:\n\n```ts\ndeclare module 'virtual:craft-style.css';\n```\n\n## What the plugin produces\n\nTwo artefacts, from one evaluation.\n\n**The CSS.** Every `when(...)` and `set(...)` the sheets registered, rendered as\natomic rules and `@property` registrations, deduplicated across files, and\nserved under `virtual:craft-style.css`. This is the whole stylesheet: no class\nis ever assembled in the browser, so what the browser gets is exactly what the\nemitter proved.\n\n**The dump**, when `dumpPath` is set. A JSON picture of the registry — classes,\natoms, and typed variables — written on _every_ emission, so it can never\ndescribe a sheet older than the CSS that was served alongside it. The dump is\nthe style half of the dependency graph: it is what\n[`style_impact`, `style_matrix` and `style_debt`](./testing.md#what-the-graph-adds)\nread, and what the `@craft-ts/dev-tools` style queries read.\n\nThe plugin re-derives the whole sheet when a `*.style.ts` changes rather than\npatching it. Atomic output is small and the emission is one bundle away; an\nincremental path here would be a second source of truth about what the CSS says.\n\n## What breaks without it\n\n| missing | symptom |\n| ---------------------------------- | ---------------------------------------------------------------------------- |\n| the plugin | no CSS at all — the classes exist as strings, nothing ever wrote their rules |\n| `import 'virtual:craft-style.css'` | same, and less obviously: the plugin runs but nothing pulls its output |\n| `dumpPath` | `style_matrix` and the other graph queries have nothing to read and say so |\n\nThe MCP server names the fix in its own error message: it points you back at\n`craftStyle({ dumpPath })`. If you are reading this because you saw that\nmessage, `dumpPath` is the line you are missing.\n\n## Emitting without a Vite server\n\n`@craft-ts/style/vite` exports the emitter itself, so a test or a script can get\nthe same two artefacts without standing up a dev server:\n\n\n\n`vite` is a peer of that entry point, not a dependency: a project that builds\nwith something else can still call `emitStyles` without pulling Vite's types\ninto its own program.\n\n## Next\n\n- [Define your design system](./define.md) — palette, axes, theme: where `bp`,\n `scheme` and `palette` come from.\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n"
471
481
  },
472
482
  {
473
483
  "path": "/guide/style/template-obligations",
@@ -492,7 +502,7 @@
492
502
  {
493
503
  "path": "/guide/testing/architecture",
494
504
  "title": "Architecture rules",
495
- "body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## The graph vocabulary\n\nThink of the graph as a typed inventory of architectural facts, not as a\nsecond runtime. A **node** is a thing the architecture can name; an **edge** is\nan observed relationship between two nodes. The graph is intentionally more\nfine-grained than a project graph: one app can contain many services,\ncomponents, primitives and HTTP endpoints.\n\n### Node families\n\nNot every application produces every kind of node. The built-in vocabulary is\ngrouped below by the questions it helps answer:\n\n| Family | Node kinds | What they represent |\n| --- | --- | --- |\n| Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. |\n| Reactive structure | `primitive`, `property`, `source`, `template-element` | A `state`, `query`, `mutation`, `craftComputed`, `craftEffect`, `craftMethod`, `queryParams`, or an exposed member/source/template element. A primitive's `details.name` keeps its concrete primitive name. |\n| Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. |\n| Server functions | `server-function-family`, `server-function-contract`, `server-function-client`, `server-function-server`, `server-function-misnamed`, `server-function-middleware`, `server-function-middleware-misnamed`, `client-function-middleware`, `client-function-middleware-misnamed` | The client/server contract, implementation, middleware and naming checks around server functions. |\n| Protocol and extensions | `handshake`, plus adapter/contributed kinds such as `effect-service`, `effect-operation`, `effect-layer`, `data-classification`, and `external-output` | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |\n\nFor example, a page can be represented as these facts: a `route` **loads** a\n`component`; the component **contains** a `query`; a `service` **calls** the\n`GET users` `http-endpoint`; a consumer service **depends-on** a browser\nboundary; and a `mutation` **triggers** a query. These are independent,\ntyped relations that a rule can inspect directly.\n\nThe labels are deliberately semantic. A rule can ask “which service calls this\nendpoint?” or “which mutation triggers this query?” without matching file text\nor reconstructing the dependency tree itself.\n\n### Edge families\n\nThe built-in edge kinds describe different types of fact; they should not all\nbe treated as interchangeable dependency arrows:\n\n| Edge kinds | Meaning | Typical architecture question |\n| --- | --- | --- |\n| `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |\n| `depends-on`, `calls` | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |\n| `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |\n| `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |\n| Extension relations | Backend-specific facts, for example `requires-service`, `provided-by-layer`, `composes-layer`, `exposes-data`, `flows-data` | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |\n\nThe direction matters: `from --kind--> to` is the fact asserted by the\nanalyzer. A `depends-on` edge is therefore different from a `provides` edge,\nand a structural `contains` edge should not be mistaken for a runtime cycle.\nThis is why `assertNoDependencyCycles` follows `depends-on` rather than every\nedge in the graph.\n\n### What the graph is based on\n\nThe analyzer works from the TypeScript program selected by the analysis\n`tsconfig`:\n\n- **AST evidence** records syntax that is visible in the source: a route\n loading a component, a component rendering an element, or a service calling\n an HTTP client.\n- **Type evidence** records relationships resolved through TypeScript: an\n injected/yielded service, a provider, or a route proof connected to its\n target.\n- **Source proofs** keep the file, line, symbol and pattern that explain an\n edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a\n failing rule can point back to the declaration that created the fact.\n\nThe result is static and deterministic: architecture tests do not boot the\napplication, instantiate services, make HTTP requests or observe user\nbehaviour. They prove that the source still has an allowed shape. Runtime\nbehaviour belongs in [service tests](/guide/testing/services), [component\ntests](/guide/testing/components) and e2e tests.\n\n### Choosing the granularity of a rule\n\nStart at the smallest graph level that expresses the invariant, then widen only\nwhen the invariant is genuinely architectural:\n\n| Granularity | Example assertion | Best for |\n| --- | --- | --- |\n| Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules |\n| Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership |\n| Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies |\n| Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles |\n| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |\n\nThe public API mirrors those levels: use `graph.nodes(kind)` and\n`graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()`\nfor neighbourhoods, and `graph.pathsBetween()` when the rule is about\nreachability. Built-in `assert*` helpers package recurring whole-graph checks;\ncustom rules should state the product or team invariant before describing the\ntraversal.\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| -------------------------------------------------- | ------------------------------------------------ |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertVisualHappyPathArchitecture`\n\nThe visual overview contract connects routed pages, the default mobile and\ndesktop viewports, and deterministic API datasets. It consumes the config\ncreated with `defineVisualAppConfig` and fails if a routed page is absent, a\nconfigured component is unknown, or any `CraftHttpClient` /\n`CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated\n`*.happy-path.ts` file.\n\n```typescript\nimport { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';\nimport { visualTestConfig } from '../../e2e/visual-test.config';\n\nit('covers every page and HTTP endpoint in the visual happy path', () => {\n assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);\n});\n```\n\nThe assertion is separate from `assertDeclarativeArchitecture` because it\nneeds the application's visual config. Dynamic URL segments are represented by\n`*`, so a template URL such as `` `/api/users/${id}` `` is indexed as\n`/api/users/*` and uses the same key in its fixture.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive _has_ an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind).toBe(\n 'unique',\n );\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | --------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
505
+ "body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must model input-driven work as a form** — a button must not send input state directly into a mutation or async process;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInputActionForms,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## The graph vocabulary\n\nThink of the graph as a typed inventory of architectural facts, not as a\nsecond runtime. A **node** is a thing the architecture can name; an **edge** is\nan observed relationship between two nodes. The graph is intentionally more\nfine-grained than a project graph: one app can contain many services,\ncomponents, primitives and HTTP endpoints.\n\n### Node families\n\nNot every application produces every kind of node. The built-in vocabulary is\ngrouped below by the questions it helps answer:\n\n| Family | Node kinds | What they represent |\n| --- | --- | --- |\n| Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. |\n| Reactive structure | `primitive`, `property`, `source`, `template-element` | A `state`, `query`, `mutation`, `craftComputed`, `craftEffect`, `craftMethod`, `queryParams`, or an exposed member/source/template element. A primitive's `details.name` keeps its concrete primitive name. |\n| Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. |\n| Server functions | `server-function-family`, `server-function-contract`, `server-function-client`, `server-function-server`, `server-function-misnamed`, `server-function-middleware`, `server-function-middleware-misnamed`, `client-function-middleware`, `client-function-middleware-misnamed` | The client/server contract, implementation, middleware and naming checks around server functions. |\n| Protocol and extensions | `handshake`, plus adapter/contributed kinds such as `effect-service`, `effect-operation`, `effect-layer`, `data-classification`, and `external-output` | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |\n\nFor example, a page can be represented as these facts: a `route` **loads** a\n`component`; the component **contains** a `query`; a `service` **calls** the\n`GET users` `http-endpoint`; a consumer service **depends-on** a browser\nboundary; and a `mutation` **triggers** a query. These are independent,\ntyped relations that a rule can inspect directly.\n\nThe labels are deliberately semantic. A rule can ask “which service calls this\nendpoint?” or “which mutation triggers this query?” without matching file text\nor reconstructing the dependency tree itself.\n\n### Edge families\n\nThe built-in edge kinds describe different types of fact; they should not all\nbe treated as interchangeable dependency arrows:\n\n| Edge kinds | Meaning | Typical architecture question |\n| --- | --- | --- |\n| `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |\n| `depends-on`, `calls` | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |\n| `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |\n| `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |\n| Extension relations | Backend-specific facts, for example `requires-service`, `provided-by-layer`, `composes-layer`, `exposes-data`, `flows-data` | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |\n\nThe direction matters: `from --kind--> to` is the fact asserted by the\nanalyzer. A `depends-on` edge is therefore different from a `provides` edge,\nand a structural `contains` edge should not be mistaken for a runtime cycle.\nThis is why `assertNoDependencyCycles` follows `depends-on` rather than every\nedge in the graph.\n\n### What the graph is based on\n\nThe analyzer works from the TypeScript program selected by the analysis\n`tsconfig`:\n\n- **AST evidence** records syntax that is visible in the source: a route\n loading a component, a component rendering an element, or a service calling\n an HTTP client.\n- **Type evidence** records relationships resolved through TypeScript: an\n injected/yielded service, a provider, or a route proof connected to its\n target.\n- **Source proofs** keep the file, line, symbol and pattern that explain an\n edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a\n failing rule can point back to the declaration that created the fact.\n\nThe result is static and deterministic: architecture tests do not boot the\napplication, instantiate services, make HTTP requests or observe user\nbehaviour. They prove that the source still has an allowed shape. Runtime\nbehaviour belongs in [service tests](/guide/testing/services), [component\ntests](/guide/testing/components) and e2e tests.\n\n### Choosing the granularity of a rule\n\nStart at the smallest graph level that expresses the invariant, then widen only\nwhen the invariant is genuinely architectural:\n\n| Granularity | Example assertion | Best for |\n| --- | --- | --- |\n| Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules |\n| Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership |\n| Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies |\n| Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles |\n| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |\n\nThe public API mirrors those levels: use `graph.nodes(kind)` and\n`graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()`\nfor neighbourhoods, and `graph.pathsBetween()` when the rule is about\nreachability. Built-in `assert*` helpers package recurring whole-graph checks;\ncustom rules should state the product or team invariant before describing the\ntraversal.\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| -------------------------------------------------- | ------------------------------------------------ |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertInputActionForms`](/guide/testing/architecture/declarative-baseline) | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary, including when the primitive is declared in a service |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertMetricThresholds`](/guide/testing/architecture/metric-thresholds) | **opt-in:** a selected node exceeds a team-defined complexity, size or coupling threshold |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertVisualHappyPathArchitecture`\n\nThe visual overview contract connects routed pages, the default mobile and\ndesktop viewports, and deterministic API datasets. It consumes the config\ncreated with `defineVisualAppConfig` and fails if a routed page is absent, a\nconfigured component is unknown, or any `CraftHttpClient` /\n`CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated\n`*.happy-path.ts` file.\n\n```typescript\nimport { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';\nimport { visualTestConfig } from '../../e2e/visual-test.config';\n\nit('covers every page and HTTP endpoint in the visual happy path', () => {\n assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);\n});\n```\n\nThe assertion is separate from `assertDeclarativeArchitecture` because it\nneeds the application's visual config. Dynamic URL segments are represented by\n`*`, so a template URL such as `` `/api/users/${id}` `` is indexed as\n`/api/users/*` and uses the same key in its fixture.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive _has_ an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Metric thresholds\n\nEvery node of the graph carries `metrics`: cyclomatic complexity (its own and\nwith everything it contains), line count, fan-in and fan-out. No threshold\napplies by default; put the ones your team agrees on in the suite. See the\n[focused rule guide](/guide/testing/architecture/metric-thresholds) for\nbefore-and-after examples:\n\n```typescript\nimport { assertMetricThresholds } from '@craft-ts/dev-tools/architecture-graph';\n\nit('keeps services and primitives small', () => {\n assertMetricThresholds(graph.graph, {\n kinds: ['service', 'primitive'],\n max: { cyclomaticOwn: 15, fanOut: 12 },\n allow: ['src/legacy/**', 'ReportingService'],\n });\n});\n```\n\n`allow` takes node ids, labels, or path globs. A metric the graph could not\ncompute — a node without a source range — is skipped, never treated as `0`;\n`graph.diagnostics` lists the unmeasured kinds. The assertion refuses a graph\nthat carries no metrics at all, such as a JSON file written by an older\nversion. `metricThresholdViolations` returns the same findings as data.\n\nSee [Graph insights](/guide/testing/graph-insights) for how the metrics are\ncomputed, the hotspot ranking and the report.\n\n## Documentation rules\n\nThe graph reads the JSDoc of each declaration and, with the opt-in Markdown\ncollector, the pages that cite a node. `assertNodesDocumented` turns that into\na rule:\n\n```typescript\nimport { assertNodesDocumented } from '@craft-ts/dev-tools/architecture-graph';\nimport {\n analyzeDependencyGraph,\n createMarkdownDocsCollector,\n} from '@craft-ts/dev-tools/dependency-graph';\n\nconst documented = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [createMarkdownDocsCollector({ include: ['docs/**/*.md'] })],\n});\n\nit('documents every service', () => {\n assertNodesDocumented(documented, {\n kinds: ['service'],\n requireDocPage: true,\n allow: ['src/legacy/**'],\n });\n});\n```\n\nA node fails without a JSDoc summary, and with `requireDocPage` when no page\ncites it in inline code. A node without a source range is skipped: its\ndocumentation is unknown, not missing. `requireDocPage` refuses a graph built\nwithout the collector. `undocumentedNodeViolations` returns the findings as\ndata.\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind).toBe(\n 'unique',\n );\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | --------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML + report |\n| `report` | `.report.md` and `.report.json` |\n\n`--include <text>` restricts analysis to matching source paths.\n`--feature-glob`, `--churn-since` and `--coverage` shape the\n[report](/guide/testing/graph-insights#report). Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
496
506
  },
497
507
  {
498
508
  "path": "/guide/testing/architecture/computed-purity",
@@ -512,7 +522,7 @@
512
522
  {
513
523
  "path": "/guide/testing/architecture/declarative-baseline",
514
524
  "title": "Declarative architecture baseline",
515
- "body": "# Declarative architecture baseline\n\n`assertDeclarativeArchitecture` is the first architecture test to add to a\nCraft app. It is not a style check and it does not test the DOM. It reads the\nstatic Craft graph and verifies seven relationships that are easy to lose during\na refactor:\n\n\n\nThe same test protects seven different failure modes:\n\n| Rule | If it is missing, this can happen |\n| --- | --- |\n| `assertCraftUnique` | two persisted resources restore from the same storage slot |\n| `assertHttpEndpointUnique` | two services own `GET users` and evolve it differently |\n| `assertCraftComputedPure` | reading a derived value writes state or starts work |\n| `assertNoDependencyCycles` | service construction loops through `A → B → A` |\n| `assertMutationHasReactOn` | a successful write leaves the visible list stale |\n| `assertPrimitiveMethodsUsedOnce` | one exposed method silently serves several call sites and loses their context |\n| `assertNoUnusedPrimitiveMethods` | an exposed method is never called and adds noise to the primitive interface |\n\nThe following examples show the actual code shape that each rule rejects.\n\n## 1. Two persisted resources share one identity\n\nTwo feature files can both look reasonable in isolation:\n\n```typescript\n// features/users/user-list.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\n```typescript\n// features/users/user-detail.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\nThey do not create a list cache and a detail cache. They create one storage\nidentity with two call sites. Restoring the detail can overwrite the value that\nthe list expects, and the bug only appears after a reload or cache restore.\n\n`assertCraftUnique` fails with both file locations. The fix is to give the\nresources distinct identities:\n\n```typescript\ncraftUnique({ storeName: 'shop', key: 'user-list' });\ncraftUnique({ storeName: 'shop', key: 'user-detail' });\n```\n\nSee [Unique identities](./unique-identities) and\n[Persisted identities](./persisted-identities).\n\n## 2. Two services call the same HTTP endpoint\n\nThis duplication is also invisible to TypeScript:\n\n```typescript\n// users-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<User[]>(),\n}));\n```\n\n```typescript\n// admin-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<AdminUser[]>(),\n}));\n```\n\nBoth are `GET users`. One feature can add pagination or change the response\nshape while the other keeps the old assumption. The two call sites are now\ncompeting owners of one transport contract.\n\n`assertHttpEndpointUnique` forces one owner. The other service must depend on\nthat owner and derive its own view:\n\n```typescript\nconst users = yield* UsersApi();\nconst admins = craftComputed('admins', function* () {\n return (yield* users.list()).filter((user) => user.role === 'admin');\n});\n```\n\nSee [HTTP endpoint ownership](./http-endpoint-ownership).\n\n## 3. A computed value performs work while being read\n\nThe purpose of `craftComputed` is to derive a value:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nThis version changes the meaning of a read:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n yield* audit.log('recomputed');\n yield* tasks.set(normalizeTasks(yield* tasks()));\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nNow a template read can write state, call a method or trigger another graph\nbranch. Depending on recomputation order, this can produce a loop, duplicate\nwork or state that changes merely because it was displayed.\n\n`assertCraftComputedPure` rejects both direct writes and calls through another\nmethod binding. Put the work in a method, `on$`, `query` or `mutation`, and let\nthe computed only read.\n\nSee [Computed purity](./computed-purity).\n\n## 4. Two services depend on each other\n\nThe graph for this pair is enough to fail the suite:\n\n```text\nUserList ──depends-on──▶ UserMutation\nUserMutation ──depends-on──▶ UserList\n```\n\nIn source, the cycle usually comes from two factories each yielding the other:\n\n```typescript\n// features/users/user-list.ts\nfunction* userListFactory() {\n const mutation = yield* UserMutation();\n return { mutation };\n}\n\n// features/users/user-mutation.ts\nfunction* userMutationFactory() {\n const list = yield* UserList();\n return { list };\n}\n```\n\nThe surrounding `craftService(...)` declarations are omitted here; the\nimportant part is the two dependency edges created by the `yield*` calls.\n\nThe application may not fail until the route first constructs the services. Then\nit can recurse forever or expose a partially constructed value.\n\n`assertNoDependencyCycles` identifies the path. Break it by extracting a small\nshared contract, passing an input, or moving a derived value into the consumer.\nTwo features depending on a common `Auth` service are not a cycle:\n\n```text\nUserList ──▶ Auth ◀── Checkout\n```\n\nSee [Dependency cycles](./dependency-cycles).\n\n## 5. A mutation succeeds but the list never refreshes\n\nThe orphan mutation is the most user-visible failure:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n method: (input: NewTask) => input,\n loader: saveTask,\n});\n\nconst tasks = yield* query('tasks', {\n params: () => filters(),\n loader: loadTasks,\n});\n```\n\nThe server has the new task, but the query has no declared relationship with the\nmutation. The user clicks “Create”, gets a success response, and still sees the\nold list until a hard reload.\n\nDeclare the relationship on the query:\n\n```typescript\nconst tasks = yield* query(\n 'tasks',\n {\n params: () => filters(),\n loader: loadTasks,\n },\n insertReactOnMutation(createTask, {\n reload: { onMutationSuccess: true },\n }),\n);\n```\n\nThe graph records a `triggers` edge from `createTask` to `tasks`. The exact\npolicy can be a reload, an optimistic patch or another supported insertion; the\nimportant part is that it is declared where the query is defined.\n\nSee [Mutation reactions](./mutation-reactions).\n\n## What the aggregate test does — and does not do\n\nThe aggregate test is now understandable as a compact CI gate:\n\n```typescript\nit('protects the baseline graph invariants', () => {\n assertDeclarativeArchitecture(graph.graph);\n});\n```\n\nIt does **not** cover every architecture policy. Add focused assertions for:\n\n- route DI and error-screen proofs: [`assertRouteDiProofs`](./route-di-proofs);\n- route/page file boundaries: [`assertRouteComponentsInSeparateFiles`](./route-component-files);\n- folder ownership: [`assertPathBoundaries`](./path-boundaries);\n- Effect loader boundaries: [`assertPrimitiveLoaderRequirements`](./primitive-loader-requirements);\n- interactive control names: [`assertInteractiveElementNamed`](./interactive-element-names);\n- `craftEffect` network and imperative-sync constraints: [Effect rules](./craft-effect-network).\n\nKeep the aggregate assertion for the common baseline, and keep focused rules for\npolicies whose failure message should explain a product or team boundary.\n\n## Explicit exceptions\n\nSome mutations really are fire-and-forget: logout, telemetry or an export with\nno cached query. Name those exceptions instead of weakening the whole rule:\n\n```typescript\nassertDeclarativeArchitecture(graph.graph, {\n allow: ['logout', 'sendTelemetry'],\n});\n```\n\nAn `allow` entry is a documented decision. It should be narrow enough that a new\norphan mutation cannot hide inside it.\n\n## See also\n\n- [Architecture rules](/guide/testing/architecture)\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n"
525
+ "body": "# Declarative architecture baseline\n\n`assertDeclarativeArchitecture` is the first architecture test to add to a\nCraft app. It is not a style check and it does not test the DOM. It reads the\nstatic Craft graph and verifies eight relationships that are easy to lose during\na refactor:\n\n\n\nThe same test protects eight different failure modes:\n\n| Rule | If it is missing, this can happen |\n| --- | --- |\n| `assertCraftUnique` | two persisted resources restore from the same storage slot |\n| `assertHttpEndpointUnique` | two services own `GET users` and evolve it differently |\n| `assertCraftComputedPure` | reading a derived value writes state or starts work |\n| `assertNoDependencyCycles` | service construction loops through `A → B → A` |\n| `assertMutationHasReactOn` | a successful write leaves the visible list stale |\n| `assertPrimitiveMethodsUsedOnce` | one exposed method silently serves several call sites and loses their context |\n| `assertNoUnusedPrimitiveMethods` | an exposed method is never called and adds noise to the primitive interface |\n| `assertInputActionForms` | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary |\n\nThe following examples show the actual code shape that each rule rejects.\n\n## 1. Two persisted resources share one identity\n\nTwo feature files can both look reasonable in isolation:\n\n```typescript\n// features/users/user-list.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\n```typescript\n// features/users/user-detail.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\nThey do not create a list cache and a detail cache. They create one storage\nidentity with two call sites. Restoring the detail can overwrite the value that\nthe list expects, and the bug only appears after a reload or cache restore.\n\n`assertCraftUnique` fails with both file locations. The fix is to give the\nresources distinct identities:\n\n```typescript\ncraftUnique({ storeName: 'shop', key: 'user-list' });\ncraftUnique({ storeName: 'shop', key: 'user-detail' });\n```\n\nSee [Unique identities](./unique-identities) and\n[Persisted identities](./persisted-identities).\n\n## 2. Two services call the same HTTP endpoint\n\nThis duplication is also invisible to TypeScript:\n\n```typescript\n// users-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<User[]>(),\n}));\n```\n\n```typescript\n// admin-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<AdminUser[]>(),\n}));\n```\n\nBoth are `GET users`. One feature can add pagination or change the response\nshape while the other keeps the old assumption. The two call sites are now\ncompeting owners of one transport contract.\n\n`assertHttpEndpointUnique` forces one owner. The other service must depend on\nthat owner and derive its own view:\n\n```typescript\nconst users = yield* UsersApi();\nconst admins = craftComputed('admins', function* () {\n return (yield* users.list()).filter((user) => user.role === 'admin');\n});\n```\n\nSee [HTTP endpoint ownership](./http-endpoint-ownership).\n\n## 3. A computed value performs work while being read\n\nThe purpose of `craftComputed` is to derive a value:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nThis version changes the meaning of a read:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n yield* audit.log('recomputed');\n yield* tasks.set(normalizeTasks(yield* tasks()));\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nNow a template read can write state, call a method or trigger another graph\nbranch. Depending on recomputation order, this can produce a loop, duplicate\nwork or state that changes merely because it was displayed.\n\n`assertCraftComputedPure` rejects both direct writes and calls through another\nmethod binding. Put the work in a method, `on$`, `query` or `mutation`, and let\nthe computed only read.\n\nSee [Computed purity](./computed-purity).\n\n## 4. Two services depend on each other\n\nThe graph for this pair is enough to fail the suite:\n\n```text\nUserList ──depends-on──▶ UserMutation\nUserMutation ──depends-on──▶ UserList\n```\n\nIn source, the cycle usually comes from two factories each yielding the other:\n\n```typescript\n// features/users/user-list.ts\nfunction* userListFactory() {\n const mutation = yield* UserMutation();\n return { mutation };\n}\n\n// features/users/user-mutation.ts\nfunction* userMutationFactory() {\n const list = yield* UserList();\n return { list };\n}\n```\n\nThe surrounding `craftService(...)` declarations are omitted here; the\nimportant part is the two dependency edges created by the `yield*` calls.\n\nThe application may not fail until the route first constructs the services. Then\nit can recurse forever or expose a partially constructed value.\n\n`assertNoDependencyCycles` identifies the path. Break it by extracting a small\nshared contract, passing an input, or moving a derived value into the consumer.\nTwo features depending on a common `Auth` service are not a cycle:\n\n```text\nUserList ──▶ Auth ◀── Checkout\n```\n\nSee [Dependency cycles](./dependency-cycles).\n\n## 5. A mutation succeeds but the list never refreshes\n\nThe orphan mutation is the most user-visible failure:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n method: (input: NewTask) => input,\n loader: saveTask,\n});\n\nconst tasks = yield* query('tasks', {\n params: () => filters(),\n loader: loadTasks,\n});\n```\n\nThe server has the new task, but the query has no declared relationship with the\nmutation. The user clicks “Create”, gets a success response, and still sees the\nold list until a hard reload.\n\nDeclare the relationship on the query:\n\n```typescript\nconst tasks = yield* query(\n 'tasks',\n {\n params: () => filters(),\n loader: loadTasks,\n },\n insertReactOnMutation(createTask, {\n reload: { onMutationSuccess: true },\n }),\n);\n```\n\nThe graph records a `triggers` edge from `createTask` to `tasks`. The exact\npolicy can be a reload, an optimistic patch or another supported insertion; the\nimportant part is that it is declared where the query is defined.\n\nSee [Mutation reactions](./mutation-reactions).\n\n## 6. An input-driven action bypasses a form\n\nThis relationship can cross files. The component may own the input and button,\nwhile a service owns the mutation. The graph follows both sides:\n\n```typescript\n// todo-store.ts — avant\nconst title = yield* state('title', '');\nconst addTodo = yield* mutation('addTodo', {\n method: function* () {\n return { title: yield* title() };\n },\n loader: saveTodo,\n});\n```\n\n```typescript\n// todo-page.ts — avant\ninput('TodoTitleInput', { value: store.title });\nbutton('AddTodoButton', { click: function* () {\n yield* store.addTodo.mutate();\n} }, 'Add');\n```\n\nThe mutation receives no argument at the click site, but it still depends on\nthe input because its `method` reads the same state. `assertInputActionForms`\nrejects this shape. A file-local ESLint rule can catch the direct version in one\nfile, but only the architecture graph can follow the input, button, service and\nresource method across files.\n\nThe accepted shape makes the boundary explicit:\n\n```typescript\n// todo-store.ts — après\nimport type { ValidatedFormValue } from '@craft-ts/core';\n\nconst addTodo = yield* mutation('addTodo', {\n method: (title: NonNullable<ValidatedFormValue<string>>) => ({\n title: title.trim(),\n }),\n loader: saveTodo,\n});\n\n// todo-page.ts — après\nconst titleForm = yield* state(\n 'titleForm',\n '',\n insertForm(\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n insertFormSubmit(addTodo),\n ),\n);\n\nform('AddTodoForm', {\n *submit(event) {\n event.preventDefault();\n yield* titleForm.form.submit();\n },\n}, [\n input('TodoTitleInput', { type: 'text' }).pipe(\n CraftFieldDirective(titleForm.form),\n ),\n button('AddTodoButton', { type: 'submit' }, 'Add'),\n]);\n```\n\nFor an object-valued form, add `insertSelectFormTree` and bind the native input\nto `titleForm.form.selectTitle()` instead. The `insertFormAttributes` insertion\nis where validators and field attributes belong; `insertFormSubmit(addTodo)`\nconnects the validated value to the mutation. The architecture assertion\nrejects a direct button call even when a `form` and `insertFormSubmit` are also\npresent, so there is only one submit boundary. `insertForm()` alone is valid,\nbut it is insufficient for this mutation-backed example: the submit insertion\nis what passes the validated form value to `addTodo`.\n\n## What the aggregate test does — and does not do\n\nThe aggregate test is now understandable as a compact CI gate:\n\n```typescript\nit('protects the baseline graph invariants', () => {\n assertDeclarativeArchitecture(graph.graph);\n});\n```\n\nIt does **not** cover every architecture policy. Add focused assertions for:\n\n- route DI and error-screen proofs: [`assertRouteDiProofs`](./route-di-proofs);\n- route/page file boundaries: [`assertRouteComponentsInSeparateFiles`](./route-component-files);\n- folder ownership: [`assertPathBoundaries`](./path-boundaries);\n- Effect loader boundaries: [`assertPrimitiveLoaderRequirements`](./primitive-loader-requirements);\n- interactive control names: [`assertInteractiveElementNamed`](./interactive-element-names);\n- `craftEffect` network and imperative-sync constraints: [Effect rules](./craft-effect-network).\n- input-driven mutations and async processes: `assertInputActionForms` (this page).\n\nKeep the aggregate assertion for the common baseline, and keep focused rules for\npolicies whose failure message should explain a product or team boundary.\n\n## Explicit exceptions\n\nSome mutations really are fire-and-forget: logout, telemetry or an export with\nno cached query. Name those exceptions instead of weakening the whole rule:\n\n```typescript\nassertDeclarativeArchitecture(graph.graph, {\n allow: ['logout', 'sendTelemetry'],\n});\n```\n\nAn `allow` entry is a documented decision. It should be narrow enough that a new\norphan mutation cannot hide inside it.\n\n## See also\n\n- [Architecture rules](/guide/testing/architecture)\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n"
516
526
  },
517
527
  {
518
528
  "path": "/guide/testing/architecture/dependency-cycles",
@@ -539,6 +549,11 @@
539
549
  "title": "Named interactive elements",
540
550
  "body": "# Named interactive elements\n\n`assertInteractiveElementNamed` requires a unique literal Craft name on every\ninteractive element:\n\n\n\n## What it prevents\n\nThis control has no stable graph or test identity:\n\n```typescript\nbutton({ *click() { yield* increment(); } }, '+');\n```\n\nThis one is named, but two components using the same name make the app-wide\n`data-craft-name` lookup ambiguous:\n\n```typescript\nbutton('save', { *click() { yield* save(); } }, 'Save');\n```\n\nThe ambiguity matters to type-level template tests, browser tests and the live\npage tooling used by coding agents. A selector based on “the second Save button”\nis not a stable contract.\n\n## What is checked\n\nThe rule covers `button`, links, form controls and nodes with `click`, `input`,\n`change` or `submit`. Hidden inputs are excluded. The first argument must be a\nliteral string and the resulting name must be unique in the application.\n\n```typescript\nbutton('save-profile', { type: 'button', *click() { yield* save(); } }, 'Save');\n```\n\nUse a feature-qualified name when the control is likely to recur. ESLint catches\nlocal omissions; the architecture rule catches duplicates across components.\n\n## See also\n\n- [Components](/guide/components/)\n- [Live page MCP](/guide/ai/dev-page)\n- [Testing components](/guide/testing/components)\n"
541
551
  },
552
+ {
553
+ "path": "/guide/testing/architecture/metric-thresholds",
554
+ "title": "Metric thresholds",
555
+ "body": "# Metric thresholds\n\n`assertMetricThresholds` is an optional architecture rule inspired by the\ncomplexity checks in Sonar. It lets a team agree on limits for the graph nodes\nit owns, then keep those limits visible in the architecture suite. It is not\npart of `assertDeclarativeArchitecture`: no threshold is imposed by default.\n\n\n\n## Before: complexity grows in one node\n\nThis code is valid TypeScript, but its decisions accumulate in one unit:\n\n```typescript\nfunction prepareCheckout(cart: Cart, user: User) {\n if (!user.active) return rejected('inactive user');\n if (cart.items.length === 0) return rejected('empty cart');\n\n for (const item of cart.items) {\n if (item.discountable && (user.vip || item.onSale)) {\n item.discount = calculateDiscount(item);\n }\n }\n\n try {\n return persistCheckout(cart);\n } catch (error) {\n return rejected(error);\n }\n}\n```\n\nThe graph counts seven decision points here (`if` × 3, `for`, `&&`, `||`,\nplus `catch`), so the cyclomatic value is eight: one plus the decision points.\nAs this logic grows, its tests and its callers have to understand the same\nbranching unit.\n\n## After: split the responsibilities, then protect the limit\n\nThe branching can be given clearer seams. Each module can then be tested\nthrough a smaller interface, while the architecture suite keeps the nodes from\ngrowing back silently:\n\n```typescript\nfunction prepareCheckout(cart: Cart, user: User) {\n const rejection = checkoutRejection(cart, user);\n if (rejection) return rejection;\n\n applyDiscounts(cart, user);\n return persistCheckout(cart);\n}\n\nfunction checkoutRejection(cart: Cart, user: User) {\n if (!user.active) return rejected('inactive user');\n if (cart.items.length === 0) return rejected('empty cart');\n return undefined;\n}\n\nfunction applyDiscounts(cart: Cart, user: User) {\n for (const item of cart.items) {\n if (item.discountable && (user.vip || item.onSale)) {\n item.discount = calculateDiscount(item);\n }\n }\n}\n```\n\nThe exact limit is a team decision. A common starting point is to check local\ncomplexity (`cyclomaticOwn`) and keep a higher ceiling for the complete owned\nsubtree (`cyclomaticTotal`):\n\n```typescript\nassertMetricThresholds(graph.graph, {\n kinds: ['service', 'primitive'],\n max: {\n cyclomaticOwn: 12,\n cyclomaticTotal: 30,\n lines: 160,\n fanOut: 12,\n },\n});\n```\n\n## What the rule checks\n\n- `cyclomaticOwn`: decision points attributed to the node itself.\n- `cyclomaticTotal`: the node plus everything it contains.\n- `lines`, `fanIn` and `fanOut`: optional limits for size and coupling.\n\nUse `kinds` to scope the rule and `allow` for an id, label or repository-\nrelative path glob such as `src/legacy/**`. Unknown metrics are skipped and\nreported in `graph.diagnostics`; they are never treated as zero.\n\nStart with a generous threshold and lower it when the codebase has a baseline.\nThis rule is a maintainability signal, not a universal quality score: a\nthreshold failure should lead to a focused refactor or an explicit exception.\n\n## See also\n\n- [Graph insights](/guide/testing/graph-insights)\n- [Architecture rules](/guide/testing/architecture)\n"
556
+ },
542
557
  {
543
558
  "path": "/guide/testing/architecture/mutation-reactions",
544
559
  "title": "Mutations must have a read-side reaction",
@@ -612,7 +627,12 @@
612
627
  {
613
628
  "path": "/guide/testing/extensible-architecture-graph",
614
629
  "title": "Extensible architecture graph",
615
- "body": "# Extensible architecture graph\n\nThe Craft architecture graph is extensible by **TypeScript backend**, without\nmaking the graph engine import that backend. Use this when an application has\nserver-side concepts that the built-in Craft graph cannot express: services,\nlayers, message brokers, data classifications, external outputs, or another\ndependency-injection system.\n\nThe extension has two separate parts:\n\n- a type extension, which gives rules typed `kind` details;\n- a runtime collector, which reads the TypeScript program and contributes\n nodes, relations, diagnostics, and source proofs.\n\nImporting the types never activates a collector.\n\n## 1. Extend the vocabulary\n\nAdd entries to the node and relation registries with module augmentation. The\naugmentation must target the graph subpath:\n\n```typescript\nimport type {\n DependencyGraphCollector,\n DependencyGraphEdgeRegistry,\n DependencyGraphNodeRegistry,\n} from '@craft-ts/dev-tools/dependency-graph';\nimport { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph';\n\ndeclare module '@craft-ts/dev-tools/dependency-graph' {\n interface DependencyGraphNodeRegistry {\n 'repository-service': {\n runtime: 'repository';\n repositoryName: string;\n };\n }\n\n interface DependencyGraphEdgeRegistry {\n 'requires-repository': {\n operation: string;\n };\n }\n}\n```\n\nThe registry determines the types of the public graph API:\n\n```typescript\nconst repositories = graph.nodes('repository-service');\nconst name: string = repositories[0]!.details!.repositoryName;\n\nconst requirements = graph.edges('requires-repository');\nconst operation: string = requirements[0]!.details!.operation;\n```\n\nKeep a new node kind for a concept with its own semantics, renderer, or\narchitecture rules. Put incidental information in the typed `details` object\ninstead of creating a kind for every local symbol.\n\n## 2. Write a collector\n\nA collector receives the shared `ts-morph` project and the source files already\nselected by the graph's tsconfig. It returns a contribution; it does not call\narchitecture rules or mutate a renderer.\n\n```typescript\nconst repositoryCollector: DependencyGraphCollector = {\n name: 'repository-backend',\n\n collect({ rootDir, sourceFiles }) {\n const nodes = [];\n const edges = [];\n\n for (const sourceFile of sourceFiles) {\n // Inspect declarations and calls with ts-morph here.\n // Add a node or relation only when the syntax/type evidence is clear.\n void rootDir;\n void sourceFile;\n }\n\n return { nodes, edges };\n },\n};\n```\n\nEvery contributed node needs a stable `id`, a `kind`, and a human-readable\n`label`. Every relation must point to nodes in the contribution or to nodes\nalready in the graph. A conflicting identity is rejected during the merge.\n\n## 3. Attach source proofs\n\nA collector should explain why a fact exists. Add a `proof` to relations when\npossible:\n\n```typescript\nedges.push({\n from: handlerId,\n to: repositoryId,\n kind: 'requires-repository',\n evidence: 'ast',\n details: { operation: 'list' },\n proof: {\n filePath: sourceFile.getFilePath(),\n line: call.getStartLineNumber(),\n symbol: 'listUsers',\n pattern: 'repository.list()',\n },\n});\n```\n\nProofs are available through `graph.proofs(edge)` and are included in paths:\n\n```typescript\nconst paths = graph.pathsBetween(handlerId, repositoryId);\nfor (const path of paths) {\n console.log(path.nodes.map((node) => node.label));\n console.log(path.proofs);\n}\n```\n\nIf a dependency cannot be resolved statically, emit a diagnostic or an\nexplicit unknown relation. Do not publish an unresolved dependency as if it\nwere proven.\n\n## 4. Activate the collector explicitly\n\nRegister the collector in the application's graph loader:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [repositoryCollector],\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data'],\n },\n});\n\nreturn createArchitectureGraph(graph, architectureCatalog);\n```\n\nThis keeps runtime analysis separate from declaration merging. A type import\ncannot accidentally enable an expensive backend analysis or its rules.\n\n## Effect backend\n\nThe repository currently includes an Effect adapter. It recognizes Effect\n`Context.Service` declarations and server-function requirements such as:\n\n```typescript\nexport class UserRepository extends Context.Service<\n UserRepository,\n UserRepositoryShape\n>()('demo/UserRepository') {}\n\nexport const listUsers = serverFunction('demo.users.list', inputSchema, {\n exposure: 'client',\n}).handler(({ input }) =>\n Effect.gen(function* () {\n const repository = yield* UserRepository;\n return yield* repository.list(input.filter);\n }),\n);\n```\n\nThe graph exposes `effect-service`, `effect-operation`, and `effect-layer`\nnodes, plus typed `requires-service`, `provided-by-layer`, and\n`composes-layer` relations with source proofs. `Layer.succeed`, `Layer.sync`,\n`Layer.effect`, `Layer.mergeAll`, and `Layer.provide` are followed only when\ntheir relevant symbols are statically visible. Dynamic composition is marked\npartial or unknown.\n\n## Sensitive data and output policies\n\nEffect Schema annotations can seed a conservative data-flow graph:\n\n```typescript\nconst Email = Schema.String.pipe(\n Schema.annotations({ sensitivity: 'personal-data' }),\n);\n```\n\nWhen an annotated schema is used as the output of a client-exposed server\nfunction, the graph emits a `data-classification` node, an `external-output`\nnode, and an `exposes-data` relation carrying the classification and proof.\nClassification is retained when propagation is uncertain; the analyser never\nassumes that an arbitrary transform made data safe.\n\nRepositories declare middleware capabilities beside the graph configuration:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data', 'secret'],\n },\n});\n```\n\nThen enforce the policy with:\n\n```typescript\nassertSensitiveOutputsProtected(graph.graph, {\n categories: ['personal-data', 'secret'],\n});\n```\n\nThe rule reports the output, classification, expected capability, and source\nproof when no attached server middleware provides the declared protection.\nUnknown protection remains blocking unless `allowUnknown: true` is chosen\nexplicitly.\n\n## Rendering and JSON compatibility\n\nThe JSON graph remains tolerant of kinds a renderer does not know. Generic\nrenderers display the kind and label as a fallback; they do not discard the\nnode. A backend-specific renderer or architecture rule can consume the typed\ndetails through declaration merging.\n\nWhen the vocabulary changes, regenerate the committed architecture catalog and\nrun the architecture suite:\n\n```shell\nnpx craft-graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out apps/shop/architecture/catalog \\\n --format json\n\nnpx nx architecture shop\n```\n\nSee [Architecture rules](/guide/testing/architecture) for the application\nloader, catalog generation, and baseline rules.\n"
630
+ "body": "# Extensible architecture graph\n\nThe Craft architecture graph is extensible by **TypeScript backend**, without\nmaking the graph engine import that backend. Use this when an application has\nserver-side concepts that the built-in Craft graph cannot express: services,\nlayers, message brokers, data classifications, external outputs, or another\ndependency-injection system.\n\nThe extension has two separate parts:\n\n- a type extension, which gives rules typed `kind` details;\n- a runtime collector, which reads the TypeScript program and contributes\n nodes, relations, diagnostics, and source proofs.\n\nImporting the types never activates a collector.\n\n## 1. Extend the vocabulary\n\nAdd entries to the node and relation registries with module augmentation. The\naugmentation must target the graph subpath:\n\n```typescript\nimport type {\n DependencyGraphCollector,\n DependencyGraphEdgeRegistry,\n DependencyGraphNodeRegistry,\n} from '@craft-ts/dev-tools/dependency-graph';\nimport { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph';\n\ndeclare module '@craft-ts/dev-tools/dependency-graph' {\n interface DependencyGraphNodeRegistry {\n 'repository-service': {\n runtime: 'repository';\n repositoryName: string;\n };\n }\n\n interface DependencyGraphEdgeRegistry {\n 'requires-repository': {\n operation: string;\n };\n }\n}\n```\n\nThe registry determines the types of the public graph API:\n\n```typescript\nconst repositories = graph.nodes('repository-service');\nconst name: string = repositories[0]!.details!.repositoryName;\n\nconst requirements = graph.edges('requires-repository');\nconst operation: string = requirements[0]!.details!.operation;\n```\n\nKeep a new node kind for a concept with its own semantics, renderer, or\narchitecture rules. Put incidental information in the typed `details` object\ninstead of creating a kind for every local symbol.\n\n## 2. Write a collector\n\nA collector receives the shared `ts-morph` project and the source files already\nselected by the graph's tsconfig. It returns a contribution; it does not call\narchitecture rules or mutate a renderer.\n\n```typescript\nconst repositoryCollector: DependencyGraphCollector = {\n name: 'repository-backend',\n\n collect({ rootDir, sourceFiles }) {\n const nodes = [];\n const edges = [];\n\n for (const sourceFile of sourceFiles) {\n // Inspect declarations and calls with ts-morph here.\n // Add a node or relation only when the syntax/type evidence is clear.\n void rootDir;\n void sourceFile;\n }\n\n return { nodes, edges };\n },\n};\n```\n\nEvery contributed node needs a stable `id`, a `kind`, and a human-readable\n`label`. Every relation must point to nodes in the contribution or to nodes\nalready in the graph. A conflicting identity is rejected during the merge.\n\n## 3. Attach source proofs\n\nA collector should explain why a fact exists. Add a `proof` to relations when\npossible:\n\n```typescript\nedges.push({\n from: handlerId,\n to: repositoryId,\n kind: 'requires-repository',\n evidence: 'ast',\n details: { operation: 'list' },\n proof: {\n filePath: sourceFile.getFilePath(),\n line: call.getStartLineNumber(),\n symbol: 'listUsers',\n pattern: 'repository.list()',\n },\n});\n```\n\nProofs are available through `graph.proofs(edge)` and are included in paths:\n\n```typescript\nconst paths = graph.pathsBetween(handlerId, repositoryId);\nfor (const path of paths) {\n console.log(path.nodes.map((node) => node.label));\n console.log(path.proofs);\n}\n```\n\nIf a dependency cannot be resolved statically, emit a diagnostic or an\nexplicit unknown relation. Do not publish an unresolved dependency as if it\nwere proven.\n\n## 4. Activate the collector explicitly\n\nRegister the collector in the application's graph loader:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [repositoryCollector],\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data'],\n },\n});\n\nreturn createArchitectureGraph(graph, architectureCatalog);\n```\n\nThis keeps runtime analysis separate from declaration merging. A type import\ncannot accidentally enable an expensive backend analysis or its rules.\n\n## Effect backend\n\nThe repository currently includes an Effect adapter. It recognizes Effect\n`Context.Service` declarations and server-function requirements such as:\n\n```typescript\nexport class UserRepository extends Context.Service<\n UserRepository,\n UserRepositoryShape\n>()('demo/UserRepository') {}\n\nexport const listUsers = serverFunction('demo.users.list', inputSchema, {\n exposure: 'client',\n}).handler(({ input }) =>\n Effect.gen(function* () {\n const repository = yield* UserRepository;\n return yield* repository.list(input.filter);\n }),\n);\n```\n\nThe graph exposes `effect-service`, `effect-operation`, and `effect-layer`\nnodes, plus typed `requires-service`, `provided-by-layer`, and\n`composes-layer` relations with source proofs. `Layer.succeed`, `Layer.sync`,\n`Layer.effect`, `Layer.mergeAll`, and `Layer.provide` are followed only when\ntheir relevant symbols are statically visible. Dynamic composition is marked\npartial or unknown.\n\n## Sensitive data and output policies\n\nEffect Schema annotations can seed a conservative data-flow graph:\n\n```typescript\nconst Email = Schema.String.pipe(\n Schema.annotations({ sensitivity: 'personal-data' }),\n);\n```\n\nWhen an annotated schema is used as the output of a client-exposed server\nfunction, the graph emits a `data-classification` node, an `external-output`\nnode, and an `exposes-data` relation carrying the classification and proof.\nClassification is retained when propagation is uncertain; the analyser never\nassumes that an arbitrary transform made data safe.\n\nRepositories declare middleware capabilities beside the graph configuration:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data', 'secret'],\n },\n});\n```\n\nThen enforce the policy with:\n\n```typescript\nassertSensitiveOutputsProtected(graph.graph, {\n categories: ['personal-data', 'secret'],\n});\n```\n\nThe rule reports the output, classification, expected capability, and source\nproof when no attached server middleware provides the declared protection.\nUnknown protection remains blocking unless `allowUnknown: true` is chosen\nexplicitly.\n\n## Rendering and JSON compatibility\n\nThe JSON graph remains tolerant of kinds a renderer does not know. Generic\nrenderers display the kind and label as a fallback; they do not discard the\nnode. A backend-specific renderer or architecture rule can consume the typed\ndetails through declaration merging.\n\nThe format stays `version: 1` and every addition is an optional field. Nodes\ncarry `metrics` (`cyclomaticOwn`, `cyclomaticTotal`, `lines`, `fanIn`,\n`fanOut`), computed after every collector has run. A node without `endLine` has\nno complexity or line count: those fields are absent, not `0`. Fan-in and\nfan-out count the nodes of your collector too, so a typed relation shows up in\nthe [hotspots](/guide/testing/graph-insights#hotspots). `graphHash` still reads\nonly node ids and relations, so the metrics never move it.\n\nNodes may also carry `doc` (`summary`, `tags`, `rationale`) read from their\ndeclaration. The opt-in Markdown collector adds `doc-page` nodes and `documents`\nrelations; both are part of the built-in vocabulary, so a renderer or a rule can\nrely on them without augmenting the registries.\n\nWhen the vocabulary changes, regenerate the committed architecture catalog and\nrun the architecture suite:\n\n```shell\nnpx craft-graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out apps/shop/architecture/catalog \\\n --format json\n\nnpx nx architecture shop\n```\n\nSee [Architecture rules](/guide/testing/architecture) for the application\nloader, catalog generation, and baseline rules.\n"
631
+ },
632
+ {
633
+ "path": "/guide/testing/graph-insights",
634
+ "title": "Graph insights",
635
+ "body": "# Graph insights\n\nThe dependency graph behind the [architecture rules](/guide/testing/architecture)\nalso measures what it models. Complexity, size and coupling are attached to the\nnodes a CraftTS developer reasons about — a route, a service, a primitive —\nrather than to files, and they feed a report, metric thresholds and the\n[graph MCP server](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp).\n\nEverything is computed statically and deterministically from the TypeScript\nprogram. Nothing is sampled at runtime and nothing is guessed.\n\n## Metrics\n\nEach node carries a `metrics` field:\n\n| Metric | Meaning |\n| ----------------- | ------------------------------------------------------------------------------------------ |\n| `cyclomaticOwn` | `1 +` the decision points of the node itself |\n| `cyclomaticTotal` | `1 +` the decision points of the node and of everything it `contains` |\n| `lines` | Lines of the declaration |\n| `fanIn` | Distinct nodes that depend on it (`loads`, `renders`, `depends-on`, `calls`, `reads`…) |\n| `fanOut` | Distinct nodes it depends on |\n\nDecision points are `if`, `?:`, `case`, `for`, `for…of`, `for…in`, `while`,\n`do`, `catch`, `&&`, `||` and `??`. Each one is credited to the **innermost**\nnode whose source contains it. A `craftComputed` declared inside a component\nkeeps its own branches; the component counts them only in its total. Nothing is\ncounted twice.\n\n`contains` is structure, not coupling: a service owning a primitive does not\nraise its fan-out.\n\n### Unknown is not zero\n\nSome nodes have no source range of their own: an HTTP endpoint aggregates call\nsites, a synthesised route check shares its alias, an Effect layer comes from a\nseparate collector. Those nodes have `fanIn` and `fanOut` but no complexity and\nno line count — the fields are absent. The graph adds one\n`CRAFT_GRAPH_METRICS_UNKNOWN` diagnostic per unmeasured kind, and every\nconsumer below leaves unknown values out instead of treating them as simple.\n\n## God nodes and hotspots {#hotspots}\n\n**God nodes** are the nodes most depended upon: highest `fanIn` first.\n\n**Hotspots** combine complexity, centrality and change:\n\n```text\nscore = cyclomaticTotal × (1 + fanIn) × (1 + churn)\n```\n\n`churn` is the number of commits touching the node's file since a date, read\nfrom git. Without it, the score ranks complex central code. Template elements\nare left out of the report rankings by default: an element's total includes\nevery element nested in it, so a single component's markup would fill the list.\n\n```typescript\nimport { godNodes, graphHotspots } from '@craft-ts/dev-tools/graph-metrics';\n\ngraphHotspots(graph, { limit: 5, kinds: ['service', 'component'] });\n```\n\n## Report {#report}\n\n```shell\nnpx craft graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format report \\\n --feature-glob 'apps/shop/src/features/:feature/**' \\\n --churn-since '3 months ago'\n```\n\n`--format report` writes `craft-dependency-graph.report.md` and\n`craft-dependency-graph.report.json`; `--format all` writes them next to the JSON\ngraph and the HTML explorer. The report contains:\n\n- a summary: nodes and relations per kind, diagnostics per code;\n- god nodes and hotspots;\n- dependency cycles and unused primitive methods;\n- relations between features, when `--feature-glob` names the feature with a\n `:name` capture;\n- the violations of the rules `assertArchitecture` enforces, grouped by rule;\n- coverage per route, when a coverage report is applied with `--coverage`;\n- documentation per kind, when JSDoc or Markdown pages were collected.\n\nEvery section is sorted and every id is relative to the root, so two reports of\nthe same code are identical whatever the checkout path. Commit the Markdown file\nif you want architecture changes to show up in review.\n\nThe same data is available in code:\n\n```typescript\nimport { formatGraphReportMarkdown, graphReport } from '@craft-ts/dev-tools/graph-report';\nimport { architectureViolations } from '@craft-ts/dev-tools/architecture-graph';\n\nconst report = graphReport(graph, { featureGlob: 'src/features/:feature/**' });\nconst violations = architectureViolations(graph); // [{ rule, messages }]\n```\n\n## Coverage per node and per route\n\nThe graph reads test coverage; it does not produce it. Write an Istanbul report\nwith Vitest, then hand it to the graph:\n\n```shell\nnpx vitest run --coverage --coverage.reporter=json\nnpx craft graph --project apps/shop/tsconfig.graph.json --root . \\\n --format all --coverage coverage/coverage-final.json\n```\n\nEach statement is credited to the innermost node whose lines contain it, and\nthe node gets `metrics.coverage = { statements, covered }` for its own\nstatements. A route's coverage sums the nodes of its code slice — everything\nthat can change what it renders — without counting a statement twice.\n\nCoverage is never guessed:\n\n- a node without a line range, or in a file the report does not mention, has\n no `coverage` field. `CRAFT_GRAPH_COVERAGE_UNKNOWN` diagnostics count them per\n kind;\n- a route lists how many nodes of its slice are unknown next to its percentage,\n which only describes the measured part.\n\n```typescript\nimport { applyCoverage, routeCoverage } from '@craft-ts/dev-tools/graph-coverage';\n\nconst covered = applyCoverage(graph, JSON.parse(readFileSync(reportPath, 'utf8')));\nrouteCoverage(covered); // [{ label, statements, covered, unknownNodes, … }]\n```\n\n## Documentation\n\nEach node carries a `doc` field when its declaration has something to say:\n\n```typescript\n/**\n * Loads and caches the signed-in user.\n * @remarks Shared by every page.\n */\nexport const { injectUserService } = craftService(/* … */, function* () {\n // WHY: the session expires silently, so reload on focus.\n const user = yield* query(/* … */);\n});\n```\n\n- `summary` and `tags` come from the JSDoc of the declaration, or of the\n statement around it (`export const x = craftQuery(…)`).\n- `rationale` collects the `// WHY:`, `// NOTE:` and `// HACK:` comments, each\n credited to the innermost node that contains it: the comment above `user`\n belongs to the query, not to the service.\n\nMarkdown pages join the graph through an opt-in collector, from the CLI with\n`--docs 'docs/**/*.md'` (repeatable) or in code with\n`createMarkdownDocsCollector({ include })`. Each page becomes a `doc-page` node\nlabelled by its first heading. A page `documents` a node when it cites the\nnode's label in inline code — `` `UserService` `` — and that label names exactly\none route, component, service or primitive. A label shared by several nodes\nproduces a `markdown-docs/CRAFT_GRAPH_DOC_AMBIGUOUS` diagnostic and no relation.\nFenced code blocks are ignored.\n\nSee [Documentation rules](/guide/testing/architecture#documentation-rules) to\nrequire them.\n\n## Thresholds\n\nMetrics become rules with `assertMetricThresholds`, opt-in and scoped by kind.\nSee [Metric thresholds](/guide/testing/architecture/metric-thresholds) for\nbefore-and-after examples.\n\n## Explorer\n\n`craft graph --format html` (or `all`) writes a self-contained explorer. On top\nof the route view it shows:\n\n- in the details panel, the node's metrics, coverage, JSDoc, justification\n comments and the pages that document it — unknown values read \"inconnu\",\n never `0`;\n- a heat map selector (complexity, fan-in, coverage) that colours the node\n cards, with a striped pattern for unknown values;\n- \"path from\" and \"path to\" buttons that highlight the shortest chain of\n relations between two nodes, following their direction;\n- the ten hotspots in the sidebar, and their count next to the uncovered nodes\n in the header.\n\n## Agents\n\n`@craft-ts/graph-mcp` exposes the graph, the metrics, the report and the impact\nof a change to an AI agent working in your project. See\n[MCP tools](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp).\n"
616
636
  },
617
637
  {
618
638
  "path": "/guide/testing/services",
@@ -672,7 +692,7 @@
672
692
  {
673
693
  "path": "/learn-effect/07-forms-validation",
674
694
  "title": "7. Build forms and validate boundaries",
675
- "body": "# 7. Build forms and validate boundaries\n\n**Goal:** use Craft forms for interaction and Effect Schema for data that crosses\na boundary.\n\n## Forms remain Craft state\n\nA form derives from the state it edits. Use `insertForm`, validators and\n`insertFormSubmit` exactly as in the regular Craft path:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n\nconst createTask = yield* mutationEffect('createTask', {\n methodSchema: CreateTaskInput,\n method: (input) => input,\n loader: ({ params }) => createTaskEffect(params),\n});\n\nconst draft = yield* state(\n 'draft',\n { title: '', description: '' },\n insertForm(\n insertFormSchema(CreateTaskInput),\n insertFormSubmit(createTask),\n ),\n);\n\nconst form = draft.form();\n```\n\nThe exact field insertions depend on the shape of your component, but the\nownership rule does not change: form values and validity are Craft state; the\nsubmit operation is an Effect-backed mutation.\n\n## For more advanced form validation\n\nThis Effect example is enough when the main concern is validating the payload\nat the boundary. For richer form behaviour — field-level rules, conditional\nvalidators, cross-field validation, nested forms or custom typed exceptions —\nuse Craft's dedicated form API with `insertFormAttributes`, `cValidate` and\n`insertSelectFormTree`. See the [Forms guide](/guide/forms/) for this\nalternative. Effect Schema can still be kept on `methodSchema` to validate the\nfinal payload before the Effect runs.\n\n## Effect Schema at the boundary\n\nEffect Schema is not passed directly to Craft. Convert it to Standard Schema:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n```\n\nUse it for `methodSchema`, `paramsSchema` or `loaderSchema`. The decoded output\nis what the rest of the application sees. A synchronous schema is safe for\nmethod arguments and local writes; asynchronous decoding belongs in\n`loaderSchema` or in the Effect loader itself.\n\n## Submit failures\n\nValidation failures are parse exceptions. Domain failures stay in Effect's\ntyped error channel and become `exceptions().loader` on the mutation. The form\ncan therefore distinguish:\n\n- invalid input, before the Effect runs;\n- a business rejection returned by the server/domain;\n- an unexpected defect that should reach the technical error boundary.\n\nDo not put navigation, toasts or state writes inside a computed exception list.\nDrive those actions after `submit()` or from an explicit process.\n\n## What you gained\n\nCraft owns the interaction model and Effect owns the domain validation or write;\ntheir error channels remain distinct and typed.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 6. Provide Layers and route the app](/learn-effect/06-layers-routing)\n\n[8. Test the graph →](/learn-effect/08-testing)\n\n</div>\n"
695
+ "body": "# 7. Build forms and validate boundaries\n\n**Goal:** use Craft forms for interaction and Effect Schema for data that crosses\na boundary.\n\n## Forms remain Craft state\n\nA form derives from the state it edits. Use `insertForm`, validators and\n`insertFormSubmit` exactly as in the regular Craft path:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n\nconst createTask = yield* mutationEffect('createTask', {\n methodSchema: CreateTaskInput,\n method: (input) => input,\n loader: ({ params }) => createTaskEffect(params),\n});\n\nconst draft = yield* state(\n 'draft',\n { title: '', description: '' },\n insertForm(\n insertFormSchema(CreateTaskInput),\n insertFormSubmit(createTask),\n ),\n);\n\nconst form = draft.form;\n```\n\nThe exact field insertions depend on the shape of your component, but the\nownership rule does not change: form values and validity are Craft state; the\nsubmit operation is an Effect-backed mutation.\n\n## For more advanced form validation\n\nThis Effect example is enough when the main concern is validating the payload\nat the boundary. For richer form behaviour — field-level rules, conditional\nvalidators, cross-field validation, nested forms or custom typed exceptions —\nuse Craft's dedicated form API with `insertFormAttributes`, `cValidate` and\n`insertSelectFormTree`. See the [Forms guide](/guide/forms/) for this\nalternative. Effect Schema can still be kept on `methodSchema` to validate the\nfinal payload before the Effect runs.\n\n## Effect Schema at the boundary\n\nEffect Schema is not passed directly to Craft. Convert it to Standard Schema:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n```\n\nUse it for `methodSchema`, `paramsSchema` or `loaderSchema`. The decoded output\nis what the rest of the application sees. A synchronous schema is safe for\nmethod arguments and local writes; asynchronous decoding belongs in\n`loaderSchema` or in the Effect loader itself.\n\n## Submit failures\n\nValidation failures are parse exceptions. Domain failures stay in Effect's\ntyped error channel and become `exceptions().loader` on the mutation. The form\ncan therefore distinguish:\n\n- invalid input, before the Effect runs;\n- a business rejection returned by the server/domain;\n- an unexpected defect that should reach the technical error boundary.\n\nDo not put navigation, toasts or state writes inside a computed exception list.\nDrive those actions after `submit()` or from an explicit process.\n\n## What you gained\n\nCraft owns the interaction model and Effect owns the domain validation or write;\ntheir error channels remain distinct and typed.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 6. Provide Layers and route the app](/learn-effect/06-layers-routing)\n\n[8. Test the graph →](/learn-effect/08-testing)\n\n</div>\n"
676
696
  },
677
697
  {
678
698
  "path": "/learn-effect/08-testing",
@@ -722,7 +742,7 @@
722
742
  {
723
743
  "path": "/learn/08-forms",
724
744
  "title": "8. Build a form",
725
- "body": "# 8. Build a form\n\n**Goal:** a \"new task\" form with validation and a typed submit — derived from\nstate, not declared next to it.\n\n## A form is a state\n\nThere is no `FormBuilder` here. You start from the state you already know, and\n`insertForm` derives the form from it:\n\n\nRead it as: *the form is this shape, and here is what each field requires.* The\nfield tree, the validity, and the exception types are all derived from the state\ntype — you never restate them.\n\n```typescript\nconst form = taskForm.form();\nconst title = form.selectTitle();\n\ntitle()().exceptions.list; // typed list of this field's exceptions\ntitle()().exceptions.byValidator['cRequired'];\n```\n\n`insertSelectFormTree` is lazy. Calling `selectTitle()` materializes the branch\nand registers its validators. Use the returned selected field for DOM binding;\nreading the raw `form.title` field does not activate the branch insertions.\n\n::: warning `insertNoopTypingAnchor`\nIt adds no behaviour. It is a TypeScript anchor that the inference needs to type\nthe field and its exceptions. Every `insertSelectFormTree` needs one — it's a\nknown wart, not a step you can skip.\n:::\n\n## Validators\n\nBuilt-ins cover the usual ground: `cRequired`, `cEmail`, `cMin` / `cMax`,\n`cMinLength` / `cMaxLength`, `cPattern`. Custom ones use `cValidate`, and\n`cAsyncValidate` for server-side checks. Details on\n[Validation](/guide/forms/validation).\n\nFor rules that cover the complete value, add one Standard Schema insertion:\n\n```typescript\ninsertForm(\n insertFormSchema(taskSchema),\n /* field insertions */\n);\n```\n\nSchema issues are projected onto fields by path. The form keeps its input value;\nif the schema transforms values, apply that schema again as the mutation's\n`methodSchema` at submit time.\n\nAttributes are derived too, so conditional UI is a function, not an effect:\n\n```typescript\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n disable: () => createTask.isLoading(),\n hidden: () => !showAdvanced(),\n}));\n```\n\n## Submitting\n\nSubmission is wired to the mutation you wrote in step 6 — that is the whole\ndeclaration:\n\n```typescript\ninsertFormSubmit(createTask);\n```\n\n```typescript\nform({ submit: () => taskForm.form().submit() }, [\n /* fields */\n]);\n```\n\nThe form now knows when it is submitting (`form().submitting()`), whether a\nsubmit was attempted (`form().hasAttemptedSubmit()`), and — the point — **which\nexceptions submission can produce**, inferred from the mutation:\n\n```typescript\ntaskForm.form().submitExceptions();\n```\n\nIf your mutation declares a `TITLE_ALREADY_EXISTS` exception, that code is in the\nunion. Rename it and the compiler tells you where you were handling it.\n\n## Reshaping submit exceptions\n\nServer codes are rarely what the UI wants to show. Refine them in an ordered\npipeline:\n\n```typescript\ninsertFormSubmit(createTask, {\n exceptions: [\n ({ omit }) => omit(['TITLE_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const clash = submitCraftResource.exceptions()?.loader\n ?.TITLE_ALREADY_EXISTS;\n if (!clash) return undefined;\n return craftException({ _tag: 'PICK_ANOTHER_TITLE' }, clash.payload);\n },\n ],\n});\n```\n\nReturning an array replaces the list; returning one exception appends it.\n\n::: warning `success` is not a \"then\" callback\nThe config also accepts `success`, but it runs **inside the derivation of the\nsubmit exception list** and its return value is appended to that list. It exists\nto raise an exception the server reported with a 200 — not to run side effects.\nResetting the form, navigating or showing a toast from there means mutating\nstate inside a computation, and it re-runs whenever the exceptions recompute.\nDrive those from your own code after `submit()`, or from the mutation.\n:::\n\n## What you gained\n\nA form whose validity, field tree and error types are consequences of your state\nand your mutation — so they cannot drift out of sync with them.\n\n::: details Nested and parallel forms\nSub-forms with `insertSubFormField`, several independent forms over the same\nstate, and the full validator reference are on [Forms](/guide/forms/).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 7. Put state in the URL](/learn/07-url-state)\n\n[9. Wire up routing →](/learn/09-routing)\n\n</div>\n"
745
+ "body": "# 8. Build a form\n\n**Goal:** a \"new task\" form with validation and a typed submit — derived from\nstate, not declared next to it.\n\n## A form is a state\n\nThere is no `FormBuilder` here. You start from the state you already know, and\n`insertForm` derives the form from it:\n\n\nRead it as: *the form is this shape, and here is what each field requires.* The\nfield tree, the validity, and the exception types are all derived from the state\ntype — you never restate them.\n\n```typescript\nconst form = taskForm.form;\nconst title = form.selectTitle();\n\ntitle()().exceptions.list; // typed list of this field's exceptions\ntitle()().exceptions.byValidator['cRequired'];\n```\n\n`insertSelectFormTree` is lazy. Calling `selectTitle()` materializes the branch\nand registers its validators. Use the returned selected field for DOM binding;\nreading the raw `form.title` field does not activate the branch insertions.\n\n::: warning `insertNoopTypingAnchor`\nIt adds no behaviour. It is a TypeScript anchor that the inference needs to type\nthe field and its exceptions. Every `insertSelectFormTree` needs one — it's a\nknown wart, not a step you can skip.\n:::\n\n## Validators\n\nBuilt-ins cover the usual ground: `cRequired`, `cEmail`, `cMin` / `cMax`,\n`cMinLength` / `cMaxLength`, `cPattern`. Custom ones use `cValidate`, and\n`cAsyncValidate` for server-side checks. Details on\n[Validation](/guide/forms/validation).\n\nFor rules that cover the complete value, add one Standard Schema insertion:\n\n```typescript\ninsertForm(\n insertFormSchema(taskSchema),\n /* field insertions */\n);\n```\n\nSchema issues are projected onto fields by path. The form keeps its input value;\nif the schema transforms values, apply that schema again as the mutation's\n`methodSchema` at submit time.\n\nAttributes are derived too, so conditional UI is a function, not an effect:\n\n```typescript\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n disable: () => createTask.isLoading(),\n hidden: () => !showAdvanced(),\n}));\n```\n\n## Submitting\n\nSubmission is wired to the mutation you wrote in step 6 — that is the whole\ndeclaration:\n\n```typescript\ninsertFormSubmit(createTask);\n```\n\n```typescript\nform('TaskForm', {\n *submit(event) {\n event.preventDefault();\n yield* taskForm.form.submit();\n },\n}, [\n /* fields */\n]);\n```\n\nThe form now knows when it is submitting (`form.submitting()`), whether a\nsubmit was attempted (`form.hasAttemptedSubmit()`), and — the point — **which\nexceptions submission can produce**, inferred from the mutation:\n\n```typescript\ntaskForm.form.submitExceptions();\n```\n\nIf your mutation declares a `TITLE_ALREADY_EXISTS` exception, that code is in the\nunion. Rename it and the compiler tells you where you were handling it.\n\n## Reshaping submit exceptions\n\nServer codes are rarely what the UI wants to show. Refine them in an ordered\npipeline:\n\n```typescript\ninsertFormSubmit(createTask, {\n exceptions: [\n ({ omit }) => omit(['TITLE_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const clash = submitCraftResource.exceptions()?.loader\n ?.TITLE_ALREADY_EXISTS;\n if (!clash) return undefined;\n return craftException({ _tag: 'PICK_ANOTHER_TITLE' }, clash.payload);\n },\n ],\n});\n```\n\nReturning an array replaces the list; returning one exception appends it.\n\n::: warning `success` is not a \"then\" callback\nThe config also accepts `success`, but it runs **inside the derivation of the\nsubmit exception list** and its return value is appended to that list. It exists\nto raise an exception the server reported with a 200 — not to run side effects.\nResetting the form, navigating or showing a toast from there means mutating\nstate inside a computation, and it re-runs whenever the exceptions recompute.\nDrive those from your own code after `submit()`, or from the mutation.\n:::\n\n## What you gained\n\nA form whose validity, field tree and error types are consequences of your state\nand your mutation — so they cannot drift out of sync with them.\n\n::: details Nested and parallel forms\nSub-forms with `insertSubFormField`, several independent forms over the same\nstate, and the full validator reference are on [Forms](/guide/forms/).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 7. Put state in the URL](/learn/07-url-state)\n\n[9. Wire up routing →](/learn/09-routing)\n\n</div>\n"
726
746
  },
727
747
  {
728
748
  "path": "/learn/09-routing",
@@ -747,7 +767,7 @@
747
767
  {
748
768
  "path": "/reference",
749
769
  "title": "API index",
750
- "body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
770
+ "body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertInputActionForms`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, `assertMetricThresholds` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
751
771
  },
752
772
  {
753
773
  "path": "/resources/ai-agents",