@craft-ts/mcp 0.8.6 → 0.8.8

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[Styling a component](/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## Configure the webhook\n\n`provideSendContextToAi` currently accepts the browser-accessible `endpoint`.\nOmit it to keep the experience copy-only, or pass a URL to enable sending:\n\n\n\nTreat that URL as public application configuration. Put authentication,\nredaction, and any tenant-specific policy in a protected same-origin proxy or\nagent gateway; never put credentials in the browser bundle.\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",
@@ -87,7 +97,7 @@
87
97
  {
88
98
  "path": "/guide/app/service-scopes",
89
99
  "title": "Service scopes",
90
- "body": "# Service scopes\n\n`scope` decides how many instances of a `craftService` exist and who has to\nprovide it. It is the one decision to make when declaring a service.\n\n::: tip Short version\nDefault to `function`. Move to `toProvide` the day a child component needs the\nsame instance. Use `global` only for genuinely app-wide state.\n:::\n\n## Supported Scopes\n\n### `global`\n\n- singleton provided at root\n- ideal for app-wide services and shared state\n- no explicit `provideX()` helper\n\n### `toProvide`\n\n- requires `provideX()` where the service is mounted\n- useful for feature-local service trees\n- works well with tests that need explicit providers\n\n### `manuallyProvidedAtRoot`\n\n- explicit provider helper, but designed to be mounted at root\n- also exposes `XToProvide` for public provider composition\n- allows this scope to be yielded by global services, which is not possible with `toProvide` (it still requires explicit setup when testing with `setupCraftServiceTestingByRegister`).\n\n### `function`\n\n- creates a fresh instance on each injection\n- useful for reusable factories with bindings and inputs\n\n### `abstract`\n\n- declares a contract without implementation\n- exposes a requirement token to force a concrete implementation later\n\n## Recommendations For Choosing a Scope\n\n- Prefer `function` for a service owned by a single component. It avoids an explicit provider and makes it clear the instance is not meant to be shared with other components or child components.\n- Move to `toProvide` when the same instance must be shared with child components, or across several components through a common parent or route. In that case, provide it at the component boundary, a parent component, or the route.\n- Be careful with `toProvide`: a missing provider is a runtime failure unless the route DI check is armed. The [route DI check](/guide/routing/setup) and [architecture tests](/guide/testing/architecture#assertroutediproofs) keep that proof in place.\n- Use `global` when the instance is intentionally shared application-wide.\n- For startup-only logic that should run when the app boots but is not injected elsewhere, prefer `function` together with `provideAppInitializer(...)`. If the same instance also needs to be injected by other services, use `global` instead.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Route providers](/guide/routing/route-providers) — providing a service from a route\n- [Testing services](/guide/testing/services)\n"
100
+ "body": "# Service scopes\n\n`scope` decides how many instances of a `craftService` exist and who has to\nprovide it. It is the one decision to make when declaring a service.\n\n::: tip Short version\nDefault to `function`. Move to `toProvide` the day a child component needs the\nsame instance. Use `global` only for genuinely app-wide state.\n:::\n\n## Supported Scopes\n\n### `global`\n\n- singleton provided at root\n- ideal for app-wide services and shared state\n- no explicit `provideX()` helper\n\n### `toProvide`\n\n- requires `provideX()` where the service is mounted\n- useful for feature-local service trees\n- works well with tests that need explicit providers\n\n### `manuallyProvidedAtRoot`\n\n- explicit provider helper, but designed to be mounted at root\n- exposes the generated `provideX()` helper for explicit root composition\n- allows this scope to be yielded by global services, which is not possible with `toProvide` (it still requires explicit setup when testing with `setupCraftServiceTestingByRegister`).\n\n### `function`\n\n- creates a fresh instance on each injection\n- useful for reusable factories with bindings and inputs\n\n### `abstract`\n\n- declares a contract without implementation\n- exposes a requirement token to force a concrete implementation later\n\n## Recommendations For Choosing a Scope\n\n- Prefer `function` for a service owned by a single component. It avoids an explicit provider and makes it clear the instance is not meant to be shared with other components or child components.\n- Move to `toProvide` when the same instance must be shared with child components, or across several components through a common parent or route. In that case, provide it at the component boundary, a parent component, or the route.\n- Be careful with `toProvide`: a missing provider is a runtime failure unless the route DI check is armed. The [route DI check](/guide/routing/setup) and [architecture tests](/guide/testing/architecture#assertroutediproofs) keep that proof in place.\n- Use `global` when the instance is intentionally shared application-wide.\n- For startup-only logic that should run when the app boots but is not injected elsewhere, prefer `function` together with `provideAppInitializer(...)`. If the same instance also needs to be injected by other services, use `global` instead.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Route providers](/guide/routing/route-providers) — providing a service from a route\n- [Testing services](/guide/testing/services)\n"
91
101
  },
92
102
  {
93
103
  "path": "/guide/app/target-wrapper",
@@ -97,7 +107,7 @@
97
107
  {
98
108
  "path": "/guide/components",
99
109
  "title": "Components",
100
- "body": "# Components\n\nA Craft component is a **function**, not a class. No decorator, no separate\ntemplate file, no host element wrapped around your markup.\n\n**Use it for** application components. [`loadCraftComponent`](/guide/routing/setup)\nmounts a Craft component on a route.\n\n## Install\n\nThe component renderer is published as a separate package and is currently on\nthe `beta` channel:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\n```\n\nSee [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component).\n\n## The shape\n\n```typescript\ncraftComponent(name, meta, factory, template);\n```\n\n| Argument | What it is |\n| ---------- | ----------------------------------------------------------------- |\n| `name` | the component's name — used for host tags, snapshots, diagnostics |\n| `meta` | `providers`, `styles`, `host`, `contentStyles` |\n| `factory` | the **logic**: builds and returns the context |\n| `template` | receives that context, returns nodes |\n\n\n\n\nThe split matters: the factory produces a context **without touching the DOM**,\nand the template renders a context **without running the factory**. That is what\nmakes the two [testable independently](/guide/testing/components).\n\n## The logic factory\n\nA `function*` when it needs dependencies — every `yield*` is tracked and folds\ninto the component's dependency type:\n\n```typescript\nfunction* () {\n const tasks = yield* TaskList();\n return { tasks };\n}\n```\n\nA plain arrow when it needs none:\n\n```typescript\n() => ({});\n```\n\nWhatever it returns is the context the template receives. Nothing else is\nexposed.\n\n## Inputs and outputs\n\nThey are **parameters of the factory**, typed with `Input<T>` and\n`Output<Handler>`:\n\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nRendering a child is a function call, so there is no binding layer to get wrong:\n\n```typescript\nUserCard({ user: currentUser, onRemove: removeUser });\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\n## The template\n\nNodes are built with hyperscript helpers — `div`, `ul`, `button`, and `h(tag, …)`\nfor anything without one. Pass a yieldable reader to a binding. Use a generator\nwhen the binding must format or call a method:\n\n```typescript\n({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n h1(`Tasks — static`); // static text needs no reader\n];\n```\n\nThe same binding boundary applies to attributes, DOM properties, classes,\nstyles, and host props. Prefer exposing a derived reader on the primitive\n(`tasks.isEmpty`) and passing it (`disabled: tasks.isEmpty`) over wrapping a\nsynchronous call.\n\nSee [Fine-grained reactivity](/guide/components/fine-grained-reactivity) for\nthe complete rendering model, structural scopes, observability expectations,\nand migration checklist.\n\nSee [Progressive `forNode` rendering](/guide/components/schedule-for) when a\nlarge collection needs frame-based scheduling.\n\nKeep render callbacks pure. They may read signals and calculate values, but\nmust not call `set`, `update`, or `mutate`. Perform writes from DOM events,\noutputs, mutations, or explicit business effects. Enable\n`craft-ts/no-render-writes` to diagnose common violations.\n\nControl flow is made of functions rather than syntax — `forNode`, `ifNode`,\n`matchNode`, `deferNode`. The relationship between these blocks, and why a raw\nternary is the wrong tool for **structure**, is in\n[Learn step 2](/learn/02-derive#control-flow).\n\n## The meta\n\n```typescript\ncraftComponent(\n 'Card',\n {\n providers: [provideCardStore()],\n styles: ':scope { padding: 1rem } .title { font-weight: 700 }',\n host: { class: 'card-host' },\n },\n /* … */\n);\n```\n\n- **`providers`** — the component's own DI scope, evaluated before the template.\n- **`styles`** — scoped with CSS `@scope`; `:scope` is this component's root. See\n [Encapsulated styles](/guide/components/styles).\n- **`host`** — default properties for the root element.\n- **`contentStyles`** — styles offered to projected content, per slot. See\n [Content projection](/guide/components/content-projection).\n\n## Composing behaviour\n\n`.pipe(...)` attaches directives, which decorate **both** the logic factory and\nthe template, left to right:\n\n```typescript\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nThe same mechanism carries `withProviders(...)` and the exception handlers below.\nSee [Directives and `.pipe(...)`](/guide/components/directives).\n\n## Mounting the root\n\nThe app root is a Craft component too:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>` (or the element you pass as\n`host`).\n\n## Pitfalls\n\n**Reading a reader outside a binding.** `h1(tasks().length)` evaluates once at\nbuild time. Pass the reader (`p(tasks.remaining)`) or use a generator:\n`h1(function* () { return yield* tasks.remaining(); })`.\n\n**Forgetting `track` in `forNode`.** Without a stable identity the renderer cannot\nreuse, move or remove the right node.\n\n**Exceptions from the factory or providers don't vanish.** They become the\ncomponent's initialization exceptions and flow up to the route unless handled\nwith `.pipe(catchNode.exhaustive(...))` — see\n[Exceptions as values](/guide/concepts/exceptions).\n\n**Naming mismatch.** The first argument must match the exported binding; the\n`craft-component-name-match` rule enforces it.\n\n## See Also\n\n- [Learn: your first state](/learn/01-first-state) — the guided version\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Accessibility](/guide/components/accessibility)\n- [Testing components](/guide/testing/components)\n"
110
+ "body": "# Components\n\nA Craft component is a **function**, not a class. No decorator, no separate\ntemplate file, no host element wrapped around your markup.\n\n**Use it for** application components. [`loadCraftComponent`](/guide/routing/setup)\nmounts a Craft component on a route.\n\n## Install\n\nThe component renderer is published as a separate package and is currently on\nthe `beta` channel:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\n```\n\nSee [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component).\n\n## The shape\n\n```typescript\ncraftComponent(name, meta, factory, template);\n```\n\n| Argument | What it is |\n| ---------- | ----------------------------------------------------------------- |\n| `name` | the component's name — used for host tags, snapshots, diagnostics |\n| `meta` | `providers`, `host` |\n| `factory` | the **logic**: builds and returns the context |\n| `template` | receives that context, returns nodes |\n\n\n\n\nThe split matters: the factory produces a context **without touching the DOM**,\nand the template renders a context **without running the factory**. That is what\nmakes the two [testable independently](/guide/testing/components).\n\n## The logic factory\n\nA `function*` when it needs dependencies — every `yield*` is tracked and folds\ninto the component's dependency type:\n\n```typescript\nfunction* () {\n const tasks = yield* TaskList();\n return { tasks };\n}\n```\n\nA plain arrow when it needs none:\n\n```typescript\n() => ({});\n```\n\nWhatever it returns is the context the template receives. Nothing else is\nexposed.\n\n## Inputs and outputs\n\nThey are **parameters of the factory**, typed with `Input<T>` and\n`Output<Handler>`:\n\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nRendering a child is a function call, so there is no binding layer to get wrong:\n\n```typescript\nUserCard({ user: currentUser, onRemove: removeUser });\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\n## The template\n\nNodes are built with hyperscript helpers — `div`, `ul`, `button`, and `h(tag, …)`\nfor anything without one. Pass a yieldable reader to a binding. Use a generator\nwhen the binding must format or call a method:\n\n```typescript\n({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n h1(`Tasks — static`); // static text needs no reader\n];\n```\n\nThe same binding boundary applies to attributes, DOM properties, classes,\nstyles, and host props. Prefer exposing a derived reader on the primitive\n(`tasks.isEmpty`) and passing it (`disabled: tasks.isEmpty`) over wrapping a\nsynchronous call.\n\nSee [Fine-grained reactivity](/guide/components/fine-grained-reactivity) for\nthe complete rendering model, structural scopes, observability expectations,\nand migration checklist.\n\nSee [Progressive `forNode` rendering](/guide/components/schedule-for) when a\nlarge collection needs frame-based scheduling.\n\nKeep render callbacks pure. They may read signals and calculate values, but\nmust not call `set`, `update`, or `mutate`. Perform writes from DOM events,\noutputs, mutations, or explicit business effects. Enable\n`craft-ts/no-render-writes` to diagnose common violations.\n\nControl flow is made of functions rather than syntax — `forNode`, `ifNode`,\n`matchNode`, `deferNode`. The relationship between these blocks, and why a raw\nternary is the wrong tool for **structure**, is in\n[Learn step 2](/learn/02-derive#control-flow).\n\n## The meta\n\n```typescript\nimport { card } from './card.style';\n\ncraftComponent(\n 'Card',\n {\n providers: [provideCardStore()],\n host: { class: card.root },\n },\n /* … */\n);\n```\n\n- **`providers`** — the component's own DI scope, evaluated before the template.\n- **`host`** — default properties for the root element. Its `class` comes from\n a sheet, like every class.\n\nThe meta carries no CSS. A component's look lives in a `*.style.ts` sheet beside\nit — see [Styling a component: the only way](/guide/components/styles).\n`styles`, `stylesUrl` and `contentStyles` still exist on the type, deprecated,\nand `craft-ts/no-component-css` refuses them.\n\n## Composing behaviour\n\n`.pipe(...)` attaches directives, which decorate **both** the logic factory and\nthe template, left to right:\n\n```typescript\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nThe same mechanism carries `withProviders(...)` and the exception handlers below.\nSee [Directives and `.pipe(...)`](/guide/components/directives).\n\n## Mounting the root\n\nThe app root is a Craft component too:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>` (or the element you pass as\n`host`).\n\n## Pitfalls\n\n**Reading a reader outside a binding.** `h1(tasks().length)` evaluates once at\nbuild time. Pass the reader (`p(tasks.remaining)`) or use a generator:\n`h1(function* () { return yield* tasks.remaining(); })`.\n\n**Forgetting `track` in `forNode`.** Without a stable identity the renderer cannot\nreuse, move or remove the right node.\n\n**Exceptions from the factory or providers don't vanish.** They become the\ncomponent's initialization exceptions and flow up to the route unless handled\nwith `.pipe(catchNode.exhaustive(...))` — see\n[Exceptions as values](/guide/concepts/exceptions).\n\n**Naming mismatch.** The first argument must match the exported binding; the\n`craft-component-name-match` rule enforces it.\n\n## See Also\n\n- [Learn: your first state](/learn/01-first-state) — the guided version\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Accessibility](/guide/components/accessibility)\n- [Testing components](/guide/testing/components)\n"
101
111
  },
102
112
  {
103
113
  "path": "/guide/components/accessibility",
@@ -107,27 +117,27 @@
107
117
  {
108
118
  "path": "/guide/components/content-projection",
109
119
  "title": "Content projection",
110
- "body": "# Content projection\n\nProjection is a **rendering context, not a category of component**. The same\n`craftComponent` can be rendered directly or supplied into a compatible logical\nslot — its definition doesn't change either way.\n\n**Use it when** a component composes content it doesn't own: a card with a\ncaller-supplied body, a toolbar filled with actions, a dialog with its buttons.\n**Not when** the child is fixed — just render it.\n\nBoth forms go through one primitive:\n\n```ts\nrenderContent(value);\n```\n\nIt accepts either deferred DOM content (`RenderableContent`) or a component unit\nexposing a logical contract. There is **no runtime registry** like\n`contentChildren`, and no special projection component.\n\n## The common case — free DOM content\n\n`ContentSlot` describes optional or free-form DOM content. `RequiredContent`\nadds a structural contract that TypeScript checks.\n\n```typescript\nimport {\n content,\n craftComponent,\n div,\n renderContent,\n section,\n type ContentSlot,\n type RequiredContent,\n} from '@craft-ts/component';\n\ntype CardInput = {\n readonly header?: ContentSlot;\n readonly body: RequiredContent<{\n readonly selector: {\n readonly tag: 'div';\n readonly class: 'card-body';\n readonly 'data-slot': 'body';\n };\n }>;\n};\n\nconst Card = craftComponent(\n 'Card',\n {},\n (input: CardInput) => input,\n ({ header, body }) =>\n section([\n header ? renderContent('header', header) : 'Default title',\n renderContent('body', body),\n ]),\n);\n\nCard({\n header: content(() => div('Title supplied by the caller')),\n body: content(() =>\n div({ class: 'card-body', 'data-slot': 'body' }, 'Card content'),\n ),\n});\n```\n\n\n\nThe selector is analysed **statically**. This is rejected, because it does not\ncontain `div.card-body[data-slot=\"body\"]`:\n\n```ts\nCard({\n // @ts-expect-error the content does not satisfy the slot's DOM contract.\n body: content(() => div({ class: 'wrong-class' })),\n});\n```\n\nContent can be built from arrays, conditions, loops and templates — the analysis\nlooks for the selector in every rendered branch:\n\n```ts\nconst body = content(() => [\n showIntro() ? div({ class: 'card-body' }, 'Introduction') : undefined,\n forNode(rows(), { track: (row) => row.id }, (row) =>\n div({ class: 'card-body' }, row.label),\n ),\n renderTemplate(cardRowTemplate, { $implicit: selectedRow() }),\n]);\n\nCard({ body });\n```\n\nThe constraint creates no wrapper and adds no runtime validation. DOM contracts\nand logical contracts are independent:\n\n```text\nRequiredContent<Requirement> → the shape of the DOM supplied\nProjectionOf<Component> → the logical capabilities of a component\n```\n\n## Logical projection by contract\n\nA component becomes projectable when its logic factory returns a `contract`\nproperty, built and checked with `satisfies`.\n\n\n\n\n`ProjectionContractOf<Component>` extracts the type of `logicOutput.contract`.\n`ProjectionOf<Component>` adds the stable key the renderer expects. For generic\nconsumers, `ProjectionSlot<Contract>` directly describes a collection of\ncompatible units.\n\nProjection therefore depends on **neither** the component's name, **nor** a\n`projection` metadata field, **nor** a runtime registry.\n\n## Explicit collections, order and stable keys\n\nThe consuming component receives a typed collection explicitly. Each unit must\nsupply a **stable key**, which `forNode` uses to reuse, move or remove the right\nprojection.\n\n```ts\nimport {\n craftComponent,\n div,\n forNode,\n renderContent,\n type ProjectionOf,\n} from '@craft-ts/component';\n\nconst Toolbar = craftComponent(\n 'Toolbar',\n {},\n (input: {\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ actions }) =>\n div(\n { role: 'toolbar' },\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n);\n\nToolbar({\n actions: [\n ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }),\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }),\n ],\n});\n```\n\nThe same `ToolbarAction` stays usable on its own:\n\n```ts\nconst Page = craftComponent(\n 'Page',\n {},\n () => ({}),\n () => [\n ToolbarAction({\n key: 'standalone',\n content: () => 'Direct action',\n trigger: save,\n }),\n Toolbar({\n actions: [\n ToolbarAction({\n key: 'projected',\n content: () => 'Projected action',\n trigger: save,\n }),\n ],\n }),\n ],\n);\n```\n\n## Styling projected content\n\n`contentStyles` is indexed by the content slot names the component declares. An\nunknown slot name is a type error.\n\n\n\n\nThe **caller** decides explicitly whether its content accepts those styles:\n\n```ts\nStyledCard({\n body: content(() => div('Styled content'), {\n allowContainerStyles: true,\n }),\n});\n\n// without the flag, the content renders but stays isolated\nStyledCard({\n body: content(() => div('Rendered without the container styles')),\n});\n```\n\nExposed styles apply to ordinary DOM nodes in the fragment. They never cross the\nboundary of a nested Craft component:\n\n```ts\nStyledCard({\n body: content(\n () => [\n div('This node can receive contentStyles.body'),\n NestedCraftComponent({}), // independent style boundary\n ],\n { allowContainerStyles: true },\n ),\n});\n```\n\n## Pitfalls\n\n**Forgetting the stable key.** Without it the renderer cannot tell one projected\nunit from another across updates, and reuse breaks.\n\n**Expecting a plain component to satisfy a contract slot.** It stays perfectly\nusable as a direct child, but the slot rejects it:\n\n```ts\nconst PlainCard = craftComponent(\n 'PlainCard',\n {},\n () => ({}),\n () => 'Card with no contract',\n);\n\nToolbar({\n actions: [\n // @ts-expect-error PlainCard does not expose ToolbarActionContract.\n PlainCard({}),\n ],\n});\n```\n\nAn incomplete contract is rejected where it is declared:\n\n```ts\nconst invalidContract = {\n kind: 'toolbar-action',\n // @ts-expect-error trigger and disabled are required.\n} satisfies ToolbarActionContract;\n```\n\n**Styling a slot that isn't one.** `contentStyles` can only reference declared\ncontent slots:\n\n\n\n\n::: details Combining optional content and contractual actions — a dialog\nA component can mix optional DOM content with several logical slots in one\nexplicit collection:\n\n```ts\nconst Dialog = craftComponent(\n 'Dialog',\n {},\n (input: {\n readonly body?: ContentSlot;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ body, actions }) =>\n section({ role: 'dialog' }, [\n body ? renderContent(body) : [],\n footer(\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n ]),\n);\n\nDialog({\n body: content(() =>\n div(['Delete the account', 'This action cannot be undone.']),\n ),\n actions: [\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: closeDialog }),\n ToolbarAction({ key: 'delete', content: () => 'Delete', trigger: deleteAccount }),\n ],\n});\n```\n\n`closeDialog` and `deleteAccount` are captured by the caller's closures.\nProjection preserves the lexical context **and the injector** of wherever the\nunit or the content was declared.\n:::\n\n::: details Conditions, reactivity and cleanup\nProjections are ordinary Craft nodes, so they can sit inside conditions and\ntemplates while keeping their identity by key within a collection. Here `visible`\nis a callable reactive value supplied by the caller:\n\n```ts\nconst OptionalToolbar = craftComponent(\n 'OptionalToolbar',\n {},\n (input: {\n readonly visible: () => boolean;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ visible, actions }) =>\n visible()\n ? forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n )\n : [],\n);\n```\n\nOn update the renderer adds, removes and moves projections by key. On teardown\nthe projected content, its effects and its styles are cleaned up with the rest\nof the tree.\n:::\n\n## API summary\n\n- `content(renderer, options?)` — create deferred DOM content\n- `renderContent(value)` and `renderContent(slotName, value)` — render it\n- `RenderableContent`, `ContentSlot` — free-form slots\n- `RequiredContent<Requirement>` — static DOM contracts\n- `ProjectionContractOf<Component>` — extract a logical contract\n- `ProjectionOf<Component>`, `ProjectionSlot<Contract>` — type projectable\n collections\n\nThe older fragment and slot primitives are no longer part of the public API.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Encapsulated styles](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
120
+ "body": "# Content projection\n\nProjection is a **rendering context, not a category of component**. The same\n`craftComponent` can be rendered directly or supplied into a compatible logical\nslot — its definition doesn't change either way.\n\n**Use it when** a component composes content it doesn't own: a card with a\ncaller-supplied body, a toolbar filled with actions, a dialog with its buttons.\n**Not when** the child is fixed — just render it.\n\nBoth forms go through one primitive:\n\n```ts\nrenderContent(value);\n```\n\nIt accepts either deferred DOM content (`RenderableContent`) or a component unit\nexposing a logical contract. There is **no runtime registry** like\n`contentChildren`, and no special projection component.\n\n## The common case — free DOM content\n\n`ContentSlot` describes optional or free-form DOM content. `RequiredContent`\nadds a structural contract that TypeScript checks.\n\n```typescript\nimport {\n content,\n craftComponent,\n div,\n renderContent,\n section,\n type ContentSlot,\n type RequiredContent,\n} from '@craft-ts/component';\n\ntype CardInput = {\n readonly header?: ContentSlot;\n readonly body: RequiredContent<{\n readonly selector: {\n readonly tag: 'div';\n readonly 'data-slot': 'body';\n };\n }>;\n};\n\nconst Card = craftComponent(\n 'Card',\n {},\n (input: CardInput) => input,\n ({ header, body }) =>\n section([\n header ? renderContent('header', header) : 'Default title',\n renderContent('body', body),\n ]),\n);\n\nCard({\n header: content(() => div('Title supplied by the caller')),\n body: content(() => div({ 'data-slot': 'body' }, 'Card content')),\n});\n```\n\n\n\nThe selector is analysed **statically**. This is rejected, because it does not\ncontain `div[data-slot=\"body\"]`:\n\n```ts\nCard({\n // @ts-expect-error the content does not satisfy the slot's DOM contract.\n body: content(() => div({ 'data-slot': 'footer' })),\n});\n```\n\nThe contract names an attribute, not a class. A class comes from a sheet and is\na list of atoms — not a name a selector can require — while a `data-*`\nattribute is a stable, declared part of the markup.\n\nContent can be built from arrays, conditions, loops and templates — the analysis\nlooks for the selector in every rendered branch:\n\n```ts\nconst body = content(() => [\n showIntro() ? div({ 'data-slot': 'body' }, 'Introduction') : undefined,\n forNode(rows(), { track: (row) => row.id }, (row) =>\n div({ 'data-slot': 'body' }, row.label),\n ),\n renderTemplate(cardRowTemplate, { $implicit: selectedRow() }),\n]);\n\nCard({ body });\n```\n\nThe constraint creates no wrapper and adds no runtime validation. DOM contracts\nand logical contracts are independent:\n\n```text\nRequiredContent<Requirement> → the shape of the DOM supplied\nProjectionOf<Component> → the logical capabilities of a component\n```\n\n## Logical projection by contract\n\nA component becomes projectable when its logic factory returns a `contract`\nproperty, built and checked with `satisfies`.\n\n\n\n\n`ProjectionContractOf<Component>` extracts the type of `logicOutput.contract`.\n`ProjectionOf<Component>` adds the stable key the renderer expects. For generic\nconsumers, `ProjectionSlot<Contract>` directly describes a collection of\ncompatible units.\n\nProjection therefore depends on **neither** the component's name, **nor** a\n`projection` metadata field, **nor** a runtime registry.\n\n## Explicit collections, order and stable keys\n\nThe consuming component receives a typed collection explicitly. Each unit must\nsupply a **stable key**, which `forNode` uses to reuse, move or remove the right\nprojection.\n\n```ts\nimport {\n craftComponent,\n div,\n forNode,\n renderContent,\n type ProjectionOf,\n} from '@craft-ts/component';\n\nconst Toolbar = craftComponent(\n 'Toolbar',\n {},\n (input: {\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ actions }) =>\n div(\n { role: 'toolbar' },\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n);\n\nToolbar({\n actions: [\n ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }),\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }),\n ],\n});\n```\n\nThe same `ToolbarAction` stays usable on its own:\n\n```ts\nconst Page = craftComponent(\n 'Page',\n {},\n () => ({}),\n () => [\n ToolbarAction({\n key: 'standalone',\n content: () => 'Direct action',\n trigger: save,\n }),\n Toolbar({\n actions: [\n ToolbarAction({\n key: 'projected',\n content: () => 'Projected action',\n trigger: save,\n }),\n ],\n }),\n ],\n);\n```\n\n## Styling projected content\n\nThe component styles **its own frame** around the slot; the content is styled\nby whoever writes it, with their own sheet. What the frame offers its content\ntravels the way everything crosses a component boundary in `@craft-ts/style`:\ninherited properties — `color`, fonts — and variables declared with\n`{ inherits: true }` that the content's sheet chooses to read.\n\n\n\n\n\nNothing reaches into the content: a caller that does not read\n`styledCardVars.accent` is unaffected by it, and a nested Craft component keeps\nits own classes. The deprecated `contentStyles` meta — CSS strings pushed into a\nslot — is refused by `no-component-css`.\n\n## Pitfalls\n\n**Forgetting the stable key.** Without it the renderer cannot tell one projected\nunit from another across updates, and reuse breaks.\n\n**Expecting a plain component to satisfy a contract slot.** It stays perfectly\nusable as a direct child, but the slot rejects it:\n\n```ts\nconst PlainCard = craftComponent(\n 'PlainCard',\n {},\n () => ({}),\n () => 'Card with no contract',\n);\n\nToolbar({\n actions: [\n // @ts-expect-error PlainCard does not expose ToolbarActionContract.\n PlainCard({}),\n ],\n});\n```\n\nAn incomplete contract is rejected where it is declared:\n\n```ts\nconst invalidContract = {\n kind: 'toolbar-action',\n // @ts-expect-error trigger and disabled are required.\n} satisfies ToolbarActionContract;\n```\n\n::: details Combining optional content and contractual actions — a dialog\nA component can mix optional DOM content with several logical slots in one\nexplicit collection:\n\n```ts\nconst Dialog = craftComponent(\n 'Dialog',\n {},\n (input: {\n readonly body?: ContentSlot;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ body, actions }) =>\n section({ role: 'dialog' }, [\n body ? renderContent(body) : [],\n footer(\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n ]),\n);\n\nDialog({\n body: content(() =>\n div(['Delete the account', 'This action cannot be undone.']),\n ),\n actions: [\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: closeDialog }),\n ToolbarAction({ key: 'delete', content: () => 'Delete', trigger: deleteAccount }),\n ],\n});\n```\n\n`closeDialog` and `deleteAccount` are captured by the caller's closures.\nProjection preserves the lexical context **and the injector** of wherever the\nunit or the content was declared.\n:::\n\n::: details Conditions, reactivity and cleanup\nProjections are ordinary Craft nodes, so they can sit inside conditions and\ntemplates while keeping their identity by key within a collection. Here `visible`\nis a callable reactive value supplied by the caller:\n\n```ts\nconst OptionalToolbar = craftComponent(\n 'OptionalToolbar',\n {},\n (input: {\n readonly visible: () => boolean;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ visible, actions }) =>\n visible()\n ? forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n )\n : [],\n);\n```\n\nOn update the renderer adds, removes and moves projections by key. On teardown\nthe projected content, its effects and its styles are cleaned up with the rest\nof the tree.\n:::\n\n## API summary\n\n- `content(renderer, options?)` — create deferred DOM content\n- `renderContent(value)` and `renderContent(slotName, value)` — render it\n- `RenderableContent`, `ContentSlot` — free-form slots\n- `RequiredContent<Requirement>` — static DOM contracts\n- `ProjectionContractOf<Component>` — extract a logical contract\n- `ProjectionOf<Component>`, `ProjectionSlot<Contract>` — type projectable\n collections\n\nThe older fragment and slot primitives are no longer part of the public API.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Styling a component](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
111
121
  },
112
122
  {
113
123
  "path": "/guide/components/css-variables",
114
- "title": "Typed CSS variables and design tokens",
115
- "body": "# Typed CSS variables and design tokens\n\n::: warning Two things named `cssVars`\nThis page is `meta.cssVars` on `craftComponent`: one component's **per-instance**\nstyling API, applied as custom properties on that instance's root.\n\n`cssVars(prefix, specs)` from\n[`@craft-ts/style`](../style/define.md#the-theme) is a different mechanism under\nthe same name: it declares **design-system-wide** custom properties, registered\nthrough `@property` and typed by kind, shared by every component that reads\nthem.\n\nNothing is being renamed — both are legitimate and the migration cost would be\nreal. Use this one to let a caller restyle one instance; use the other one to\ndeclare a theme.\n:::\n\nCSS custom properties are the public styling API of a Craft component. Craft\nextracts a contract from inline `meta.styles`, propagates unsatisfied variables\nthrough component templates, and applies supplied values to the component root.\nThe browser's native inheritance then carries them to descendants.\n\n## Required and optional variables\n\nAn unguarded use is required. A declaration or inline fallback is optional:\n\n\n\n`--card-ink` is required, while `--card-bg` and `--card-radius` are optional.\nStyles supplied through `cssVars` are written as custom properties on the\ncomponent root; different instances can therefore use different values while\nsharing one scoped stylesheet.\n\n## External stylesheets\n\nAn imported stylesheet is typed as `string`, so TypeScript cannot inspect it.\nDeclare its contract explicitly with `required()`:\n\n```typescript\ncraftComponent(\n 'ExternalCard',\n {\n stylesUrl: styles,\n cssVars: {\n '--external-card-ink': required<string>(),\n '--external-card-gap': '1rem',\n },\n },\n () => ({}),\n template,\n);\n```\n\nThe `craft-css-vars-contract` lint rule resolves the CSS import and checks that\nthe explicit contract and file remain synchronized.\n\n## Child-variable dispositions\n\nAt a child call site, every variable can be handled deliberately:\n\n```ts\nBadge({ cssVars: { '--badge-ink': 'navy' } });\nBadge({ cssVars: { '--badge-ink': inherit } });\nBadge({ cssVars: { '--badge-ink': omit } });\nBadge({ cssVars: { '--badge-ink': forward('navy') } });\nBadge({ cssVars: { '--badge-bg': forward() } });\n```\n\n- A value supplies the child directly.\n- `inherit` uses a declaration in the current component's own styles and emits\n no inline value.\n- `omit` intentionally stops propagation and emits nothing.\n- `forward(value)` gives the parent API a default that callers can override.\n- `forward()` re-exposes an optional value without adding a default.\n\nUse `assertCssVarsSatisfied(routes)` next to the other route proofs. It rejects\na routed root when a required variable has propagated all the way to a mount\nthat has no component call site.\n\n## `@property`: validation versus requiredness\n\nCraft reads authored `@property` blocks; it does not generate them. A registered\nproperty with a non-wildcard syntax needs an `initial-value`, so it is optional\nby construction:\n\n```css\n@property --meter-value {\n syntax: '<number>';\n inherits: true;\n initial-value: 0;\n}\n```\n\nRegistration provides browser validation, animation support, and an initial\nvalue. The tradeoff is that it gives up the compile-time “nobody supplied this”\nerror. `inherits: false` cannot be used for a variable supplied or forwarded by\na parent.\n\n`@property` is document-global even when its values cascade normally. A\ncomponent may therefore register only variables in its own namespace\n(`Meter` → `--meter-*`). Register shared design tokens once in the application's\nglobal stylesheet, whose lifetime matches the document.\n\n## Scope safety\n\nCraft rejects component CSS that can silently become global:\n\n- `@import`, `:root`, `html`, and `body`;\n- unprefixed `@keyframes`, `@counter-style`, font palettes, or font families;\n- `@property` registrations outside the component namespace;\n- `!important` in component styles.\n\nPrivate global names use the exact component scope, for example\n`@keyframes Spinner-spin`. Craft validates these names rather than rewriting\nCSS declaration values at runtime.\n"
124
+ "title": "Typed CSS variables",
125
+ "body": "# Typed CSS variables\n\nA component's styling API is a set of **typed custom properties** declared with\n[`cssVars`](../style/define.md#the-theme) in its sheet. Each one has a kind — a\ncolour, a length, a percentage — and a typed initial value, registered with\n`@property`. None is \"required\": a variable nobody sets keeps its initial value,\nand one nobody reads is reported by the architecture rule `no-dangling-css-vars`.\n\n\n\n\n\n## Per instance: a variant sets what it changes\n\nA caller does not hand a component raw values. It picks a variant — here\n`data-cardLook` — and the sheet sets the variables that variant changes with\n`set(...)`. The others keep their initial value. The set of looks is therefore\nclosed and enumerable, which is what lets the [visual matrix](/guide/style/variants)\ncapture every one of them.\n\n## Inherited: a parent sets, descendants read\n\n`{ inherits: true }` is for a variable set once on a wrapper and read below it —\na theme, or a card that tints whatever it contains. The default, `false`, is for\na variable an element both sets and reads on itself.\n\n## Forwarded: a parent re-exposes a child's variable\n\nA parent that wants its own API writes `set(child, parent)`: the panel above\ndeclares `panelVars.ink` and forwards it to `cardVars.ink`. A caller overrides\nthe panel's variable in its own sheet, and the card follows without the panel\nknowing how the card is built.\n\n## At runtime: `assign`\n\nA value known only at runtime — a progress, a position, a colour picked by the\nuser — is written on the element with `assign(variable, value)`, the only thing\n`style:` accepts. The sheet reads it like any other variable. Because the\nvariable is registered with its kind, the browser can interpolate it: a\n`transition` on `width` driven by a percentage variable animates.\n\n## `@property` is emitted, not written\n\nEvery variable declared with `cssVars` is emitted as an `@property` block by the\nbuild plugin, with its syntax, its `inherits` flag and its initial value. You\nnever write one by hand. Two rules follow from the registration:\n\n- an initial value must be computationally independent — `unit.px(16)`, not\n `unit.rem(1)` — or the browser drops the whole registration; the architecture\n suite catches it;\n- a prefix belongs to one sheet: `cssVars` throws when two sheets declare the\n same prefix.\n\n## `meta.cssVars`\n\nThe `cssVars` field of `craftComponent`'s meta — a contract extracted from a\nCSS string, with `required()`, `inherit`, `omit` and `forward()` at the call\nsite — belongs to the component CSS that `no-component-css` refuses. It is\n`@deprecated`, kept only so that an [attested bypass](/guide/components/styles#the-one-real-exception)\nstays possible.\n"
116
126
  },
117
127
  {
118
128
  "path": "/guide/components/customization",
119
129
  "title": "Customizing components and directives",
120
- "body": "# Customizing components and directives\n\nCraft splits customization into three layers, and which one you reach for\ndepends on how far the change should travel:\n\n| Layer | Changes |\n| --------------------- | ------------------------------------- |\n| Root-element `host` | The component's own root defaults |\n| Encapsulated `styles` | Its internal appearance |\n| Composable directives | Behaviour, reusable across components |\n\n**Start with `host`** for one component's defaults, and move to a directive only\nwhen the same customization needs to apply somewhere else too.\n\n## Customizing the root element\n\nThe component meta `host` properties define defaults for the component’s root\nelement. The caller can extend or override them:\n\n\n\n\nClasses, attributes, styles, and events recognized as host properties are\napplied to the component root. Other properties remain factory props.\n\nValues can be reactive:\n\n```ts\nconst { active } = state('active', false, ({ set }) => ({ set }));\n\nCard({\n class: () => (active() ? 'is-active' : 'is-idle'),\n style: () => ({ opacity: active() ? 1 : 0.6 }),\n});\n```\n\n## Customizing with styles\n\nStyles declared in `meta.styles` are shared across instances and encapsulated\nwith `@scope`. The template root is written as `:scope`:\n\n```typescript\nconst Panel = craftComponent(\n 'Panel',\n {\n styles: `\n :scope { padding: 1rem; border: 1px solid #ddd; }\n .title { font-weight: 700; }\n button { cursor: pointer; }\n `,\n },\n () => ({}),\n () => div([h2({ class: 'title' }, 'Panel'), button('Save')]),\n);\n```\n\n\n\nStyles do not leak into descendant components. Global rules such as\n`@keyframes` and `@font-face` cannot be nested in `@scope`, so their private\nnames must start with the component scope. `@import` and document-root selectors\nare rejected. `@media`, `@supports`, and `@container` remain composable inside\nthe scope. For the typed styling API, see\n[Typed CSS variables and design tokens](/guide/components/css-variables).\n\n## Adding reusable customization with a directive\n\nA directive transforms a component’s factory and template. It is applied from\nleft to right with `.pipe(...)`:\n\n```ts\nconst Highlight = craftDirective(\n 'Highlight',\n {\n styles: '.highlight { background: #fff3bf; }',\n },\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) => baseTemplate(context, { class: 'highlight' }),\n);\n\nconst HighlightedPanel = Panel.pipe(Highlight);\n```\n\nA directive can also add context and public props:\n\n```ts\nconst WithPermission = craftDirective(\n 'WithPermission',\n {},\n (baseLogic) => (user: Input<User>) => ({\n ...baseLogic(user),\n canEdit: () => user().permissions.includes('edit'),\n }),\n (baseTemplate) => (context) =>\n context.canEdit() ? baseTemplate(context) : [],\n);\n\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nDirective styles are registered in the scope of the component that owns them.\nThe same directive can therefore be reused by several components without\nintroducing an HTML wrapper.\n\n## Composing providers and exception handlers\n\n`withProviders` configures the provider scope of a component before it is\ninvoked. `catchTag.exhaustive` is a logic boundary: each handler is a\ngenerator that can call a service or perform another logic operation. It must\nnot return template children. Use `catchNode.exhaustive` or\n`matchNode.exhaustive` when the exception should produce DOM.\n\n```ts\nimport { abstract, craftException, craftService } from '@craft-ts/core';\nimport {\n catchTag,\n craftComponent,\n p,\n withProviders,\n} from '@craft-ts/component';\n\nconst noAccess = craftException({ _tag: 'NO_ACCESS' });\nconst { RestrictedData, provideRestrictedData } = craftService(\n { name: 'restrictedData', scope: 'abstract' },\n abstract<string | typeof noAccess>(),\n);\n\nconst MyRestrictedCraftComponent = craftComponent(\n 'MyRestrictedCraftComponent',\n {},\n function* () {\n return { value: yield* RestrictedData() };\n },\n ({ value }) => p(`Private data: ${value}`),\n);\n\nconst Restricted = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* () {\n // yield* ToastService.show(() => 'No access');\n },\n }),\n);\n\nRestricted();\n```\n\nProviders are evaluated before the component template. If a provider reads a\nsignal, changing that signal recreates the composed rendering, including the\nprovider scope. The handler generator runs for the exception state. Since\n`catchTag` does not render a template, use `catchNode` or `matchNode` for a\nvisual fallback.\n\nThe component adapter reuses the exhaustive `catchTag` rules from the core and\nthe composed component carries the exception codes produced by its initializer\nand providers. The providers also participate in the normal Craft DI graph, so\nthey can satisfy dependencies used by the component and its children. The\nvariadic component `.pipe(...)` overload is currently kept permissive to avoid\nexcessive TypeScript instantiation depth; runtime dispatch still rejects an\nunhandled exception code.\n\n## Choosing an exception utility\n\nCraft exposes three complementary utilities. The important distinction is\nwhether the exception is handled in logic or rendered in a template:\n\n- `catchTag.exhaustive` handles component initialization exceptions in logic;\n- `catchNode.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchNode.exhaustive` renders a fallback from an exception value or signal.\n\n### `catchTag.exhaustive`: logic only\n\nHandlers are generator functions. They can call services and yield other Craft\noperations, but they cannot return `p(...)`, an element, or any other template\nchildren. A DOM fallback belongs to `catchNode` or `matchNode`.\n\n```ts\nconst SafeComponent = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* (exception) {\n yield* ToastService.show(() => `Access denied: ${exception._tag}`);\n },\n }),\n);\n```\n\n### `catchNode.exhaustive`: preserve a source block\n\nApply it to a rendered VNode when the source subtree may throw. The source is\nkept and the fallback is inserted at the requested position. Applying it to a\ncomponent in `.pipe(...)` also creates a residual component boundary and\nremoves the handled codes from the component and route contracts.\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive(\n {\n UserNotFoundException: () => p('User not found'),\n },\n { position: 'after' },\n ),\n);\n```\n\nFor a template boundary, the source block remains visible by default. When\n`catchNode` is piped onto a component and the exception comes from its\ncomposed scope, a function handler keeps the existing component behavior and\nreplaces the source. A handler can keep that source visible by using the object\nform and setting `showSource: true`:\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive({\n UserNotFoundException: {\n render: () => p('User not found'),\n showSource: true,\n position: 'after',\n },\n }),\n);\n```\n\nWith `showSource: true`, the source and fallback are both rendered. Use\n`showSource: false` to hide the source explicitly. `position` can be set on\neach handler (`before` or `after`); the second argument remains available as a\ndefault for handlers that do not specify their own position. Existing function\nhandlers keep their previous behavior. If the component factory or a provider\nfails before the template is created, there is no source block to preserve, so\nthe fallback is rendered alone.\n\n### `matchNode.exhaustive`: render a resource exception\n\nUse it when a query, mutation, or another primitive exposes an exception as a\nsignal instead of throwing from the template subtree. The block renders no\nchildren while the source is empty and switches reactively to the matching\nhandler when an exception appears.\n\n```ts\nmatchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', {\n UserNotFoundException: () => p('User not found'),\n UserConsentMissingException: () => p('Consent is required'),\n});\n```\n\n## What Craft handles directly\n\nCraft supports compositions that are not native properties of a standard\nthe host component or directive:\n\n- a Craft directive can declare `meta.styles` and contribute to the stylesheet\n of the component using it; Craft keeps the association with the component;\n- directive styles remain encapsulated with `@scope`, without rewriting\n selectors or adding a wrapper;\n- multiple directives can compose their logic, template, host classes, and\n styles through `.pipe(...)`;\n- styles are deduplicated and reference-counted across instances, then removed\n when the last instance is destroyed.\n\nThe directive runtime owns stylesheet injection, scoping, and cleanup, so those\nresponsibilities do not leak into application code.\n\n## Choosing the right level\n\n- `host`: identity, attributes, classes, or behavior of the root element;\n- `styles`: local, reusable component appearance; the stylesheet is shared\n across instances, while its rules remain limited to the component roots;\n- `craftDirective`: behavior or customization reusable across components;\n- the factory: component-specific state and dependencies.\n\n### Understanding style scope\n\nInside `meta.styles`, `:scope` targets every root produced by the template:\n\n\n\n\nCraft puts an internal token on the roots and generates a scope equivalent to:\n\n```css\n@scope ([data-craft-root~=\"Card\"]) to ([data-craft-root] *) {\n /* Card rules */\n}\n```\n\nIn practice:\n\n- `:scope` targets the root itself;\n- `.title` targets `Card` descendants;\n- when a child Craft component is encountered, its root becomes a boundary:\n parent rules can reach the root, but not its internal\n DOM;\n- ordinary elements do not become boundaries and do not receive an additional\n token;\n- a template returning multiple roots scopes each root, but cannot express a\n relationship between sibling roots such as `header + main`;\n- a root that is directly another Craft component can carry multiple tokens.\n The containing component can then reach into the child component: this is a\n known limitation of the current model.\n\nScoping is structural, not based on selector rewriting: modern selectors such\nas `:is()`, `:where()`, `&`, and nested rules are not transformed by Craft.\n`@media`, `@supports`, and `@container` remain inside the scope; rules that\ncannot be nested there, such as `@keyframes`, `@font-face`, `@import`, and\n`@namespace`, are hoisted outside the `@scope` block.\n\nDirective styles use the scope of their owning component because a directive\ndoes not introduce a separate root node. A directive can add `.highlight` or\nmodify `:scope`, but `:scope` then refers to the host component’s roots, not to\na directive wrapper.\n\nNames passed to `craftComponent` and `craftDirective` must be unique and match\ntheir declaration names. The dedicated ESLint rules detect missing or\ninconsistent names.\n\n## See Also\n\n- [Encapsulated styles](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Content projection](/guide/components/content-projection)\n"
130
+ "body": "# Customizing components and directives\n\nCraft splits customization into three layers, and which one you reach for\ndepends on how far the change should travel:\n\n| Layer | Changes |\n| --------------------- | ------------------------------------- |\n| Root-element `host` | The component's own root defaults |\n| A `*.style.ts` sheet | Its appearance |\n| Composable directives | Behaviour, reusable across components |\n\n**Start with `host`** for one component's defaults, and move to a directive only\nwhen the same customization needs to apply somewhere else too.\n\n## Customizing the root element\n\nThe component meta `host` properties define defaults for the component’s root\nelement. The caller can extend or override them:\n\n\n\n\n\nClasses, attributes, styles, and events recognized as host properties are\napplied to the component root. Other properties remain factory props. A caller's\n`class` is **added** to the host's, so the two classes must not write the same\nproperty — `featured` writes the border, which `root` leaves alone. Everything\nelse, `attrs` included, replaces the host's value.\n\nValues can be reactive. The class stays constant; what moves is an attribute the\nsheet reads as an axis:\n\n```ts\nCard({\n class: cardSheet.featured,\n 'data-cardActive': function* () {\n return String(yield* active());\n },\n});\n```\n\n## Customizing the appearance\n\nA component's look lives in a sheet beside it and nowhere else — see\n[Styling a component: the only way](/guide/components/styles). The template\nbinds the sheet's classes:\n\n```typescript\nimport { panel } from './panel.style';\n\nconst Panel = craftComponent(\n 'Panel',\n {},\n () => ({}),\n () =>\n div({ class: panel.root }, [\n h2({ class: panel.title }, 'Panel'),\n button('save', { class: panel.action, type: 'button' }, 'Save'),\n ]),\n);\n```\n\nA sheet's classes are atomic: they apply where they are bound and nowhere else,\nso there is no scope to manage and nothing leaks into a child component.\n\n## Adding reusable customization with a directive\n\nA directive transforms a component’s factory and template. It is applied from\nleft to right with `.pipe(...)`:\n\n```ts\nimport { highlight } from './highlight.style';\n\nconst Highlight = craftDirective(\n 'Highlight',\n {},\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) =>\n baseTemplate(context, { class: highlight.root }),\n);\n\nconst HighlightedPanel = Panel.pipe(Highlight);\n```\n\nA directive can also add context and public props:\n\n```ts\nconst WithPermission = craftDirective(\n 'WithPermission',\n {},\n (baseLogic) => (user: Input<User>) => ({\n ...baseLogic(user),\n canEdit: () => user().permissions.includes('edit'),\n }),\n (baseTemplate) => (context) =>\n context.canEdit() ? baseTemplate(context) : [],\n);\n\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nA directive brings its own sheet and adds its class to the host's, so the same\ndirective can be reused by several components without introducing an HTML\nwrapper.\n\n## Composing providers and exception handlers\n\n`withProviders` configures the provider scope of a component before it is\ninvoked. `catchTag.exhaustive` is a logic boundary: each handler is a\ngenerator that can call a service or perform another logic operation. It must\nnot return template children. Use `catchNode.exhaustive` or\n`matchNode.exhaustive` when the exception should produce DOM.\n\n```ts\nimport { abstract, craftException, craftService } from '@craft-ts/core';\nimport {\n catchTag,\n craftComponent,\n p,\n withProviders,\n} from '@craft-ts/component';\n\nconst noAccess = craftException({ _tag: 'NO_ACCESS' });\nconst { RestrictedData, provideRestrictedData } = craftService(\n { name: 'restrictedData', scope: 'abstract' },\n abstract<string | typeof noAccess>(),\n);\n\nconst MyRestrictedCraftComponent = craftComponent(\n 'MyRestrictedCraftComponent',\n {},\n function* () {\n return { value: yield* RestrictedData() };\n },\n ({ value }) => p(`Private data: ${value}`),\n);\n\nconst Restricted = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* () {\n // yield* ToastService.show(() => 'No access');\n },\n }),\n);\n\nRestricted();\n```\n\nProviders are evaluated before the component template. If a provider reads a\nsignal, changing that signal recreates the composed rendering, including the\nprovider scope. The handler generator runs for the exception state. Since\n`catchTag` does not render a template, use `catchNode` or `matchNode` for a\nvisual fallback.\n\nThe component adapter reuses the exhaustive `catchTag` rules from the core and\nthe composed component carries the exception codes produced by its initializer\nand providers. The providers also participate in the normal Craft DI graph, so\nthey can satisfy dependencies used by the component and its children. The\nvariadic component `.pipe(...)` overload is currently kept permissive to avoid\nexcessive TypeScript instantiation depth; runtime dispatch still rejects an\nunhandled exception code.\n\n## Choosing an exception utility\n\nCraft exposes three complementary utilities. The important distinction is\nwhether the exception is handled in logic or rendered in a template:\n\n- `catchTag.exhaustive` handles component initialization exceptions in logic;\n- `catchNode.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchNode.exhaustive` renders a fallback from an exception value or signal.\n\n### `catchTag.exhaustive`: logic only\n\nHandlers are generator functions. They can call services and yield other Craft\noperations, but they cannot return `p(...)`, an element, or any other template\nchildren. A DOM fallback belongs to `catchNode` or `matchNode`.\n\n```ts\nconst SafeComponent = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* (exception) {\n yield* ToastService.show(() => `Access denied: ${exception._tag}`);\n },\n }),\n);\n```\n\n### `catchNode.exhaustive`: preserve a source block\n\nApply it to a rendered VNode when the source subtree may throw. The source is\nkept and the fallback is inserted at the requested position. Applying it to a\ncomponent in `.pipe(...)` also creates a residual component boundary and\nremoves the handled codes from the component and route contracts.\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive(\n {\n UserNotFoundException: () => p('User not found'),\n },\n { position: 'after' },\n ),\n);\n```\n\nFor a template boundary, the source block remains visible by default. When\n`catchNode` is piped onto a component and the exception comes from its\ncomposed scope, a function handler keeps the existing component behavior and\nreplaces the source. A handler can keep that source visible by using the object\nform and setting `showSource: true`:\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive({\n UserNotFoundException: {\n render: () => p('User not found'),\n showSource: true,\n position: 'after',\n },\n }),\n);\n```\n\nWith `showSource: true`, the source and fallback are both rendered. Use\n`showSource: false` to hide the source explicitly. `position` can be set on\neach handler (`before` or `after`); the second argument remains available as a\ndefault for handlers that do not specify their own position. Existing function\nhandlers keep their previous behavior. If the component factory or a provider\nfails before the template is created, there is no source block to preserve, so\nthe fallback is rendered alone.\n\n### `matchNode.exhaustive`: render a resource exception\n\nUse it when a query, mutation, or another primitive exposes an exception as a\nsignal instead of throwing from the template subtree. The block renders no\nchildren while the source is empty and switches reactively to the matching\nhandler when an exception appears.\n\n```ts\nmatchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', {\n UserNotFoundException: () => p('User not found'),\n UserConsentMissingException: () => p('Consent is required'),\n});\n```\n\n## What Craft handles directly\n\nCraft supports compositions that are not native properties of a standard\nthe host component or directive:\n\n- a Craft directive can add its own classes to the root of the component using\n it, without a wrapper;\n- multiple directives can compose their logic, template and host classes\n through `.pipe(...)`;\n- the CSS itself is emitted once, at build time, by the `@craft-ts/style`\n plugin: nothing is injected or reference-counted at runtime.\n\n## Choosing the right level\n\n- `host`: identity, attributes, classes, or behavior of the root element;\n- a `*.style.ts` sheet: the component's appearance, its variants as axes, its\n runtime values as typed variables;\n- `craftDirective`: behavior or customization reusable across components;\n- the factory: component-specific state and dependencies.\n\n### How a parent reaches a child\n\nA parent never styles a child's internals. It has two doors, both visible in\nthe child's contract: a class it passes to the child's host, and a variable\ndeclared with `{ inherits: true }` that the child's sheet reads.\n\n\n\n\n\nThe card sets `data-cardActive`, its sheet writes `cardVars.ink`, and the title,\na separate component, reads it. Nothing in the card knows how the title is\nbuilt.\n\nNames passed to `craftComponent` and `craftDirective` must be unique and match\ntheir declaration names. The dedicated ESLint rules detect missing or\ninconsistent names.\n\n## See Also\n\n- [Styling a component](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Content projection](/guide/components/content-projection)\n"
121
131
  },
122
132
  {
123
133
  "path": "/guide/components/directives",
124
134
  "title": "Directives and `.pipe(...)`",
125
- "body": "# Directives and `.pipe(...)`\n\nA Craft directive decorates **both** a component's logic factory and its\ntemplate — so behaviour and markup travel together, and compose.\n\n**Use one when** the same behaviour must be added to several components:\na tooltip, a highlight, focus management, analytics on interaction.\n**Not when** the behaviour belongs to one component — put it in that component's\nfactory.\n\nDirectives are applied from left to right.\n\n```ts\nimport {\n button,\n craftComponent,\n craftDirective,\n div,\n p,\n type HostRequiredLogic,\n type HostTemplate,\n type Input,\n} from '@craft-ts/component';\n```\n\n## `InteractivePermissions`\n\nThe examples below use a directive that adds a `permissions` object to the\ncomponent context. Its configuration is internal to the directive; the\ncomponent caller only provides the original `user` input.\n\n\n\n## Basic composition\n\nA directive transforms the existing logic and template:\n\n```ts\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions);\n```\n\nThe result of `InteractivePermissions` becomes the logic actually executed by\n`Card`:\n\n```text\ncomponent inputs\n ↓\noriginal logic\n ↓\nlogic added by the directive\n ↓\nfinal context\n ↓\nfinal template\n```\n\n## Directive configuration input\n\nA fixed configuration can be supplied when the directive is created:\n\n```ts\nconst hasPermission = (permission: Permission) =>\n craftDirective(\n 'hasPermission',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permissions: {\n canAccess: () => user().permissions.includes(permission),\n },\n };\n },\n\n (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>\n context.permissions.canAccess() ? baseTemplate(context) : [],\n );\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermission('edit'));\n```\n\n`edit` is internal configuration. The caller of `Card` does not provide it.\n\n## Input supplied by the component caller\n\nA directive can also add a public input to the component:\n\n```ts\nconst hasPermissionInput = craftDirective(\n 'hasPermissionInput',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) =>\n (user: Input<User>, permission: Input<Permission>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permission,\n permissions: {\n canAccess: () => user().permissions.includes(permission()),\n },\n };\n },\n\n (\n baseTemplate: HostTemplate<{\n user: Input<User>;\n permission: Input<Permission>;\n permissions: {\n canAccess: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canAccess() ? baseTemplate(context) : []),\n);\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermissionInput);\n\nCard({\n user: () => currentUser,\n permission: () => 'edit',\n});\n```\n\nThe directive adds `permission` to the final logic and to `Card`'s public\nprops. The renderer passes factory arguments in prop order, following the\nexisting convention for functional component factories.\n\n## Structural directive\n\nA structural directive decides whether the template produces nodes:\n\n\n\nWhen `when()` becomes false, the renderer removes the template output. When it\nbecomes true again, the template is rendered again.\n\nA structural directive can consume context added by a previous directive:\n\n```ts\nconst onlyEditable = craftDirective(\n 'onlyEditable',\n {},\n (\n baseLogic: HostRequiredLogic<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) => baseLogic,\n\n (\n baseTemplate: HostTemplate<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canEdit() ? baseTemplate(context) : []),\n);\n\nconst EditableCard = craftComponent(\n 'EditableCard',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions, onlyEditable);\n```\n\nThe context flows from left to right:\n\n```text\noriginal logic\n → InteractivePermissions\n → { user, permissions }\n → onlyEditable\n → template or []\n```\n\n## Directives on elements\n\nA component template can also apply a structural directive to a hyperscript\nnode:\n\n```ts\nconst message = p('Message').pipe(whenDirective);\n```\n\nThe component context is passed to the decorated template. Craft structural\ndirectives can therefore transform Craft output without introducing an\nintermediate component.\n\nFunctional DOM directives can also receive their configuration directly and be\napplied with `.pipe(...)`. The configuration is owned by the directive instead\nof becoming a DOM attribute:\n\n```ts\na({}, 'Tasks').pipe(CraftRouterLink(link));\n```\n\nA field configured with `insertSelectFormTree` must be selected before it is\nbound, so its lazy insertions (including validators) are registered:\n\n```ts\ninput({ type: 'email' }).pipe(\n CraftFieldDirective(loginForm.form.selectEmail()),\n);\n```\n\n## Composition rules\n\n- Create a configurable directive with `craftDirective(...)`, then pass it to\n `.pipe(...)`.\n- A directive can add public inputs; they appear in the final component props.\n- A directive placed after another receives the already decorated logic and\n template, so it can consume context added by the previous directive.\n- Generator factories continue to be executed by the Craft runtime. Dependencies\n from both the original and decorated factories remain part of the component\n dependency contract.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Encapsulated styles](/guide/components/styles)\n- [Testing components](/guide/testing/components)\n"
135
+ "body": "# Directives and `.pipe(...)`\n\nA Craft directive decorates **both** a component's logic factory and its\ntemplate — so behaviour and markup travel together, and compose.\n\n**Use one when** the same behaviour must be added to several components:\na tooltip, a highlight, focus management, analytics on interaction.\n**Not when** the behaviour belongs to one component — put it in that component's\nfactory.\n\nDirectives are applied from left to right.\n\n## Event actions and DOM modifiers\n\n`eventAction(...)` is an element directive. Use it when an element must adjust\na DOM event before invoking one action. The action stays on the element; no\n`craftMethod` wrapper is needed:\n\n```ts\nimport { button, eventAction } from '@craft-ts/component';\n\nbutton(\n 'navToggle',\n {\n type: 'button',\n 'aria-expanded': navOpen,\n },\n navOpen.navToggleLabel,\n).pipe(\n eventAction({\n click: { action: navOpen.toggle, stopPropagation: true },\n }),\n);\n```\n\nEach event entry requires `action` and can set `preventDefault`,\n`stopPropagation`, or `stopImmediatePropagation` to `true`. The modifiers run\nbefore the action in the same DOM listener. The action remains in Craft's normal\nevent pipeline, including event hooks and generator callbacks. Use the event\nname as the key, such as `click`, `submit`, or `keydown`. Do not also put that\nevent in the element's props; `eventAction` rejects duplicate handlers.\n\nThe recommended ESLint rule `craft-ts/no-event-only-craft-method` and the\ndefault architecture rule `no-event-only-craft-method` report a `craftMethod`\nthat only modifies an event and delegates to one action, even when that method\nis declared in a different file from the element.\n\n```ts\nimport {\n button,\n craftComponent,\n craftDirective,\n div,\n p,\n type HostRequiredLogic,\n type HostTemplate,\n type Input,\n} from '@craft-ts/component';\n```\n\n## `InteractivePermissions`\n\nThe examples below use a directive that adds a `permissions` object to the\ncomponent context. Its configuration is internal to the directive; the\ncomponent caller only provides the original `user` input.\n\n\n\n## Basic composition\n\nA directive transforms the existing logic and template:\n\n```ts\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions);\n```\n\nThe result of `InteractivePermissions` becomes the logic actually executed by\n`Card`:\n\n```text\ncomponent inputs\n ↓\noriginal logic\n ↓\nlogic added by the directive\n ↓\nfinal context\n ↓\nfinal template\n```\n\n## Directive configuration input\n\nA fixed configuration can be supplied when the directive is created:\n\n```ts\nconst hasPermission = (permission: Permission) =>\n craftDirective(\n 'hasPermission',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permissions: {\n canAccess: () => user().permissions.includes(permission),\n },\n };\n },\n\n (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>\n context.permissions.canAccess() ? baseTemplate(context) : [],\n );\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermission('edit'));\n```\n\n`edit` is internal configuration. The caller of `Card` does not provide it.\n\n## Input supplied by the component caller\n\nA directive can also add a public input to the component:\n\n```ts\nconst hasPermissionInput = craftDirective(\n 'hasPermissionInput',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) =>\n (user: Input<User>, permission: Input<Permission>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permission,\n permissions: {\n canAccess: () => user().permissions.includes(permission()),\n },\n };\n },\n\n (\n baseTemplate: HostTemplate<{\n user: Input<User>;\n permission: Input<Permission>;\n permissions: {\n canAccess: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canAccess() ? baseTemplate(context) : []),\n);\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermissionInput);\n\nCard({\n user: () => currentUser,\n permission: () => 'edit',\n});\n```\n\nThe directive adds `permission` to the final logic and to `Card`'s public\nprops. The renderer passes factory arguments in prop order, following the\nexisting convention for functional component factories.\n\n## Structural directive\n\nA structural directive decides whether the template produces nodes:\n\n\n\nWhen `when()` becomes false, the renderer removes the template output. When it\nbecomes true again, the template is rendered again.\n\nA structural directive can consume context added by a previous directive:\n\n```ts\nconst onlyEditable = craftDirective(\n 'onlyEditable',\n {},\n (\n baseLogic: HostRequiredLogic<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) => baseLogic,\n\n (\n baseTemplate: HostTemplate<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canEdit() ? baseTemplate(context) : []),\n);\n\nconst EditableCard = craftComponent(\n 'EditableCard',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions, onlyEditable);\n```\n\nThe context flows from left to right:\n\n```text\noriginal logic\n → InteractivePermissions\n → { user, permissions }\n → onlyEditable\n → template or []\n```\n\n## Directives on elements\n\nA component template can also apply a structural directive to a hyperscript\nnode:\n\n```ts\nconst message = p('Message').pipe(whenDirective);\n```\n\nThe component context is passed to the decorated template. Craft structural\ndirectives can therefore transform Craft output without introducing an\nintermediate component.\n\nFunctional DOM directives can also receive their configuration directly and be\napplied with `.pipe(...)`. The configuration is owned by the directive instead\nof becoming a DOM attribute:\n\n```ts\na({}, 'Tasks').pipe(CraftRouterLink(link));\n```\n\nA field configured with `insertSelectFormTree` must be selected before it is\nbound, so its lazy insertions (including validators) are registered:\n\n```ts\ninput({ type: 'email' }).pipe(\n CraftFieldDirective(loginForm.form.selectEmail()),\n);\n```\n\n## Composition rules\n\n- Create a configurable directive with `craftDirective(...)`, then pass it to\n `.pipe(...)`.\n- A directive can add public inputs; they appear in the final component props.\n- A directive placed after another receives the already decorated logic and\n template, so it can consume context added by the previous directive.\n- Generator factories continue to be executed by the Craft runtime. Dependencies\n from both the original and decorated factories remain part of the component\n dependency contract.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Styling a component](/guide/components/styles)\n- [Testing components](/guide/testing/components)\n"
126
136
  },
127
137
  {
128
138
  "path": "/guide/components/fine-grained-reactivity",
129
139
  "title": "Fine-grained reactivity",
130
- "body": "# Fine-grained reactivity\n\nCraft templates are reactive at the **binding** level. When a signal changes,\nCraft updates the text node, DOM property, class, style, or host binding that\nread it. It does not need to execute the surrounding component template again.\n\n```ts\n({ counter }) =>\n div([\n h2('Counter'),\n p({ class: 'value' }, counter),\n button({ click: counter.increment }, '+'),\n ]);\n```\n\nHere, `counter` is passed to `p` as a yieldable reader. The renderer drives the\nread for that text binding. Incrementing the counter evaluates that binding and\npatches its text node; the `div`, heading, button, and component template remain\nuntouched.\n\n## The binding is the reactive boundary\n\nA function in a rendered position declares a binding. The callback only reads\nvalues already derived by the primitive layer; comparisons, formatting, and UI\ndecisions stay out of the template:\n\n```ts\np(items.totalLabel);\n\nbutton(\n {\n disabled: items.isEmpty,\n title: items.clearTitle,\n },\n 'Clear',\n);\n\ndiv({\n class: items.emptyClass,\n style: items.emptyStyle,\n});\n```\n\n`totalLabel`, `isEmpty`, `clearTitle`, `emptyClass`, and `emptyStyle` are named\nderived values exposed by the state, query, insertion, or component context.\nPass the reader. If only an item-related dependency changes, Craft evaluates\nonly the affected bindings. A sibling binding depending on another reader does\nnot run.\n\nStatic values do not need callbacks:\n\n```ts\nh2('Shopping cart');\nbutton({ type: 'button' }, 'Clear');\n```\n\n## Do not read reactive values while building the template\n\nA direct read happens while the component constructs its VNodes. It cannot be\nassigned to one precise DOM binding and becomes a structural template\ndependency instead:\n\n```ts\n// Avoid: these reads happen in the component template.\np(items.totalLabel());\nbutton({ disabled: items.isEmpty() }, 'Clear');\ndiv({ class: items.emptyClass() });\n```\n\nMove each read into the binding that consumes it:\n\n```ts\np(items.totalLabel);\nbutton({ disabled: items.isEmpty }, 'Clear');\ndiv({ class: items.emptyClass });\n```\n\nThis is also the rule for component inputs. Pass a yieldable reader directly\nwhen the child must observe a changing value. When the child needs fields of\nan object, explicitly adapt the input with `deepYieldable`:\n\n```ts\nUserCard({ user: selectedUser });\n```\n\nThe reader is lazy: constructing the parent template does not read\n`selectedUser`. Craft installs it as the source of the child's `user` input.\nThe child then decides which granular binding observes it:\n\n```ts\nconst UserCard = craftComponent(\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => h2(user.displayName),\n);\n```\n\nWhen the `h2` binding first evaluates, `yield* user()` invokes the reader,\nwhich reads `selectedUser`. That text binding becomes the signal consumer.\nWhen the selected user changes, only the binding evaluates again and patches\nthe existing `h2`; neither the parent template nor the child component template\nruns again.\n\nReading the input eagerly in the child would move the dependency back to the\ncomponent boundary and is rejected by `require-reactive-template-bindings`:\n\n```ts\n// Avoid: resolving the input while the child template is built.\nh2(craftUse(user()).displayName);\n```\n\n## Structure has its own reactive scopes\n\nBindings update an existing node. Blocks own changes to the shape of the tree:\n\n```ts\nifNode(\n hasItems,\n () => CartItems({ items: () => items() }),\n () => p('Your cart is empty.'),\n);\n\nforNode(items, { track: (item) => item.id }, (item) => p(item.name));\n```\n\n`ifNode`, `forNode`, `matchNode.exhaustive`, and `deferNode` isolate their own\nstructural work. A branch or list can change without making the parent\ncomponent rebuild unrelated siblings. Use these helpers for structure and\nbinding callbacks for values on existing nodes.\n\n### Progressive `forNode` rendering\n\nSee the dedicated [Progressive `forNode` rendering](/guide/components/schedule-for)\nguide for the complete usage and trade-offs.\n\n`forNode` is synchronous by default. For a large collection, opt into frame-based\nbatching on the `forNode` node itself:\n\n```ts\nforNode(items, { track: (item) => item.id }, (item) => p(item.name)).pipe(\n scheduleFor({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nUse `frame` when the list should begin appearing quickly while leaving room for\ninput and painting between batches. `enabled: false` restores synchronous\nrendering, regardless of the selected strategy. The first delivery supports\n`sync` and `frame`; `idle` is reserved for a later scheduler implementation.\n\nScheduling improves perceived responsiveness, but it does not reduce the total\nwork needed to create or update the list. For very large or continuously\nscrolling collections, virtualisation is still preferable because it reduces\nthe number of DOM nodes and bindings that exist at once.\n\n## Keep bindings pure and free of logic\n\nA binding reads a value already derived by the primitive layer. It does not\nformat data, make business decisions, or write state:\n\n```ts\n// Correct: the primitive exposes the render-ready reader.\np(cart.formattedTotal);\n\n// Incorrect: rendering changes application state.\np(function* () {\n yield* counter.update((value) => value + 1);\n return yield* counter();\n});\n```\n\nPerform writes from DOM events, outputs, mutations, or explicit business\neffects. Purity makes a binding safe to evaluate whenever one of its\ndependencies changes.\n\n## Enforce the model with ESLint\n\nEnable both renderer rules with type-aware ESLint configuration:\n\n```js\nexport default [\n {\n files: ['**/*.ts'],\n languageOptions: {\n parserOptions: { projectService: true },\n },\n rules: {\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-render-writes': 'error',\n },\n },\n];\n```\n\n- `require-reactive-template-bindings` rejects direct reads of signals,\n Craft values, and component inputs during VNode construction.\n- `no-render-writes` rejects detectable `set`, `update`, and `mutate` calls from\n templates and binding callbacks while allowing event and output handlers.\n\nSee the [ESLint rules reference](/guide/routing/eslint-rules) for the complete\nconfiguration.\n\n## What you should observe\n\nAfter a binding dependency changes:\n\n- the affected DOM value changes;\n- the node keeps its identity;\n- unrelated bindings do not evaluate;\n- the component template does not emit a new `component / update` trace.\n\nThe current template trace reports component and structural renders, not each\nindividual text or property effect. The absence of a component update therefore\nconfirms that the change stayed below the component boundary; a DOM assertion\nconfirms that the expected binding was patched.\n\nEffects are owned by their rendered nodes. Removing a branch, list item, or\ncomponent destroys its binding effects, so their dependencies are released with\nthe DOM they served.\n\n## Migration checklist\n\n1. Move comparisons, formatting, and display decisions into named derived\n primitive values such as `items.isEmpty`.\n2. Pass yieldable readers to text bindings (`p(counter)`), or use a generator\n when the binding must format: `p(function* () { return \\`Count: ${yield* counter()}\\`; })`.\n3. Pass yieldable readers to DOM properties such as `value`, `disabled`, and\n `title`.\n4. Return complete reactive class and style readers from the primitive.\n5. Pass changing component inputs as yieldable readers.\n6. Express structural changes with `ifNode`, `forNode`,\n `matchNode.exhaustive`, or `deferNode`.\n7. Enable the two ESLint rules and remove every direct reactive template read.\n\nContinue with [Components](/guide/components/) for the complete\n`craftComponent` model or [Observability](/guide/advanced/observability) to\ninspect rendering and correlated interactions.\n"
140
+ "body": "# Fine-grained reactivity\n\nCraft templates are reactive at the **binding** level. When a signal changes,\nCraft updates the text node, DOM property, class, style, or host binding that\nread it. It does not need to execute the surrounding component template again.\n\n```ts\n// `counterSheet` is the component's sheet, from counter.style.ts.\n({ counter }) =>\n div([\n h2('Counter'),\n p({ class: counterSheet.value }, counter),\n button({ click: counter.increment }, '+'),\n ]);\n```\n\nHere, `counter` is passed to `p` as a yieldable reader. The renderer drives the\nread for that text binding. Incrementing the counter evaluates that binding and\npatches its text node; the `div`, heading, button, and component template remain\nuntouched.\n\n## The binding is the reactive boundary\n\nA function in a rendered position declares a binding. The callback only reads\nvalues already derived by the primitive layer; comparisons, formatting, and UI\ndecisions stay out of the template:\n\n```ts\np(items.totalLabel);\n\nbutton(\n {\n disabled: items.isEmpty,\n title: items.clearTitle,\n },\n 'Clear',\n);\n\ndiv({\n class: items.emptyClass,\n style: items.emptyStyle,\n});\n```\n\n`totalLabel`, `isEmpty`, `clearTitle`, `emptyClass`, and `emptyStyle` are named\nderived values exposed by the state, query, insertion, or component context.\nPass the reader. If only an item-related dependency changes, Craft evaluates\nonly the affected bindings. A sibling binding depending on another reader does\nnot run.\n\nStatic values do not need callbacks:\n\n```ts\nh2('Shopping cart');\nbutton({ type: 'button' }, 'Clear');\n```\n\n## Do not read reactive values while building the template\n\nA direct read happens while the component constructs its VNodes. It cannot be\nassigned to one precise DOM binding and becomes a structural template\ndependency instead:\n\n```ts\n// Avoid: these reads happen in the component template.\np(items.totalLabel());\nbutton({ disabled: items.isEmpty() }, 'Clear');\ndiv({ class: items.emptyClass() });\n```\n\nMove each read into the binding that consumes it:\n\n```ts\np(items.totalLabel);\nbutton({ disabled: items.isEmpty }, 'Clear');\ndiv({ class: items.emptyClass });\n```\n\nThis is also the rule for component inputs. Pass a yieldable reader directly\nwhen the child must observe a changing value. When the child needs fields of\nan object, explicitly adapt the input with `deepYieldable`:\n\n```ts\nUserCard({ user: selectedUser });\n```\n\nThe reader is lazy: constructing the parent template does not read\n`selectedUser`. Craft installs it as the source of the child's `user` input.\nThe child then decides which granular binding observes it:\n\n```ts\nconst UserCard = craftComponent(\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => h2(user.displayName),\n);\n```\n\nWhen the `h2` binding first evaluates, `yield* user()` invokes the reader,\nwhich reads `selectedUser`. That text binding becomes the signal consumer.\nWhen the selected user changes, only the binding evaluates again and patches\nthe existing `h2`; neither the parent template nor the child component template\nruns again.\n\nReading the input eagerly in the child would move the dependency back to the\ncomponent boundary and is rejected by `require-reactive-template-bindings`:\n\n```ts\n// Avoid: resolving the input while the child template is built.\nh2(craftUse(user()).displayName);\n```\n\n## Structure has its own reactive scopes\n\nBindings update an existing node. Blocks own changes to the shape of the tree:\n\n```ts\nifNode(\n hasItems,\n () => CartItems({ items: () => items() }),\n () => p('Your cart is empty.'),\n);\n\nforNode(items, { track: (item) => item.id }, (item) => p(item.name));\n```\n\n`ifNode`, `forNode`, `matchNode.exhaustive`, and `deferNode` isolate their own\nstructural work. A branch or list can change without making the parent\ncomponent rebuild unrelated siblings. Use these helpers for structure and\nbinding callbacks for values on existing nodes.\n\n### Progressive `forNode` rendering\n\nSee the dedicated [Progressive `forNode` rendering](/guide/components/schedule-for)\nguide for the complete usage and trade-offs.\n\n`forNode` is synchronous by default. For a large collection, opt into frame-based\nbatching on the `forNode` node itself:\n\n```ts\nforNode(items, { track: (item) => item.id }, (item) => p(item.name)).pipe(\n scheduleFor({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nUse `frame` when the list should begin appearing quickly while leaving room for\ninput and painting between batches. `enabled: false` restores synchronous\nrendering, regardless of the selected strategy. The first delivery supports\n`sync` and `frame`; `idle` is reserved for a later scheduler implementation.\n\nScheduling improves perceived responsiveness, but it does not reduce the total\nwork needed to create or update the list. For very large or continuously\nscrolling collections, virtualisation is still preferable because it reduces\nthe number of DOM nodes and bindings that exist at once.\n\n## Keep bindings pure and free of logic\n\nA binding reads a value already derived by the primitive layer. It does not\nformat data, make business decisions, or write state:\n\n```ts\n// Correct: the primitive exposes the render-ready reader.\np(cart.formattedTotal);\n\n// Incorrect: rendering changes application state.\np(function* () {\n yield* counter.update((value) => value + 1);\n return yield* counter();\n});\n```\n\nPerform writes from DOM events, outputs, mutations, or explicit business\neffects. Purity makes a binding safe to evaluate whenever one of its\ndependencies changes.\n\n## Enforce the model with ESLint\n\nEnable both renderer rules with type-aware ESLint configuration:\n\n```js\nexport default [\n {\n files: ['**/*.ts'],\n languageOptions: {\n parserOptions: { projectService: true },\n },\n rules: {\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-render-writes': 'error',\n },\n },\n];\n```\n\n- `require-reactive-template-bindings` rejects direct reads of signals,\n Craft values, and component inputs during VNode construction.\n- `no-render-writes` rejects detectable `set`, `update`, and `mutate` calls from\n templates and binding callbacks while allowing event and output handlers.\n\nSee the [ESLint rules reference](/guide/routing/eslint-rules) for the complete\nconfiguration.\n\n## What you should observe\n\nAfter a binding dependency changes:\n\n- the affected DOM value changes;\n- the node keeps its identity;\n- unrelated bindings do not evaluate;\n- the component template does not emit a new `component / update` trace.\n\nThe current template trace reports component and structural renders, not each\nindividual text or property effect. The absence of a component update therefore\nconfirms that the change stayed below the component boundary; a DOM assertion\nconfirms that the expected binding was patched.\n\nEffects are owned by their rendered nodes. Removing a branch, list item, or\ncomponent destroys its binding effects, so their dependencies are released with\nthe DOM they served.\n\n## Migration checklist\n\n1. Move comparisons, formatting, and display decisions into named derived\n primitive values such as `items.isEmpty`.\n2. Pass yieldable readers to text bindings (`p(counter)`), or use a generator\n when the binding must format: `p(function* () { return \\`Count: ${yield* counter()}\\`; })`.\n3. Pass yieldable readers to DOM properties such as `value`, `disabled`, and\n `title`.\n4. Return complete reactive class and style readers from the primitive.\n5. Pass changing component inputs as yieldable readers.\n6. Express structural changes with `ifNode`, `forNode`,\n `matchNode.exhaustive`, or `deferNode`.\n7. Enable the two ESLint rules and remove every direct reactive template read.\n\nContinue with [Components](/guide/components/) for the complete\n`craftComponent` model or [Observability](/guide/advanced/observability) to\ninspect rendering and correlated interactions.\n"
131
141
  },
132
142
  {
133
143
  "path": "/guide/components/pending-node",
@@ -141,8 +151,8 @@
141
151
  },
142
152
  {
143
153
  "path": "/guide/components/styles",
144
- "title": "Encapsulated styles",
145
- "body": "# Encapsulated styles\n\n::: tip Two style systems, and which to pick\nThis page is `meta.styles`: a **string** of CSS shipped with the component and\nscoped with `@scope`. It is the shortest path to a component's own appearance,\nand it needs no build step.\n\n[`@craft-ts/style`](../style/) is the other one: values are typed objects, the\nCSS is emitted at build time, and the exhaustive set of a component's visual\nstates becomes something you can enumerate and test. It costs a Vite plugin and\na design system to declare.\n\nPick this page for a component whose look is settled and local. Pick\n`@craft-ts/style` when the variants are a matrix you need to prove you covered.\nThey coexist: a component can carry `meta.styles` and bind a sheet class.\n:::\n\nStyles declared in `craftComponent(name, meta, factory, template)` are shared by\nevery instance of the component and encapsulated with CSS `@scope`. The registry\nkeeps a single sheet per component and removes it when the last instance is\ndestroyed.\n\n**Use it for** a component's own appearance.\n**Not for** application-wide styles — those belong in your global stylesheet;\nscoping them here just makes them harder to find.\n\n## The common case\n\n```ts\nconst Card = craftComponent(\n 'Card',\n { styles: ':scope { padding: 1rem } .title { font-weight: 700 }' },\n () => ({}),\n () => div([h2({ class: 'title' }, 'Title')]),\n);\n```\n\nThe template root is written `:scope`. Craft adds **no host element and no\nwrapper** — roots carry an internal `data-craft-root` attribute, which you must\nnever set yourself.\n\n## Composing styles from a directive\n\nA directive's styles compose with the component's:\n\n```ts\nconst Highlight = craftDirective(\n 'Highlight',\n { styles: '.highlight { background: yellow }' },\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) => baseTemplate(context, { class: 'highlight' }),\n);\n```\n\n## Pitfalls\n\n**`@scope` adds no specificity.** Adopted sheets are ordered after the document's\nsheets, and the `<style>` fallback is inserted in the `head`. Nested-scope\nproximity can therefore change the cascade compared with a global stylesheet —\nif a rule stops winning after you scope it, this is why.\n\n**Sibling roots can't see each other.** Multi-root templates are allowed, but\nrelationships between sibling roots (`header + main`, say) are not expressible\nthrough this encapsulation.\n\n**A root that is itself a Craft component carries several tokens**, so the\nenclosing component can reach inside it. This is a known limit of the current\nimplementation.\n\n**Names must be unique.** The `craft-component-name-match` and\n`craft-directive-name-match` rules also check that the name matches the\ndeclaration.\n\n**Hoisted rules are still global.** Craft rejects `@import`, document-root\nselectors, and private at-rules whose names are not prefixed by the component\nscope. A `Spinner` animation is named `@keyframes Spinner-spin`. Component\n`@property` registrations keep their public custom-property name, but that name\nmust belong to the component namespace.\n\n## See Also\n\n- [Customization](/guide/components/customization) — the three layers\n- [Typed CSS variables and design tokens](/guide/components/css-variables)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
154
+ "title": "Styling a component: the only way",
155
+ "body": "# Styling a component: the only way\n\nA component is styled through [`@craft-ts/style`](../style/), and through\nnothing else. Its visual rules live in a `*.style.ts` sheet beside it; the\ntemplate binds the sheet's classes, sets `data-*` attributes for its variants,\nand writes typed variables for what changes at runtime. There is no CSS string\non the meta, no `.css` import, no class assembled at render time and no raw\n`style`.\n\nThat is not a preference. A class built in the browser, or a rule shipped as a\nstring, is a visual state nothing recorded: the [visual matrix](/guide/style/testing)\nenumerates what the sheets declare, the [static contrast check](/guide/style/contrast)\nmeasures what the sheets write, and anything outside them is invisible to both.\nESLint and the architecture suite therefore refuse every other route, and the\none real exception is written down, with its reason, for someone to decide on.\n\n## The shape\n\nThe sheet declares the classes, the axis a variant moves along, and the\nvariables a template may write:\n\n\n\nThe component imports it and binds **one constant class per element**:\n\n\n\n- `class` is always a sheet key (`card.root`), an array of them, or a typed\n input carrying one. Never a string, a template literal or a conditional.\n- The variant is an **attribute**. `data-cardTone` is on the element, the sheet\n reads it through `when(cardTone.danger, …)`, and a `null` removes it.\n- `style` accepts `assign(variable, value)` and nothing else — one call, several\n spread into an object, or a function returning them.\n\n## Where each thing goes\n\n| You want | Write |\n| ------------------------------------------ | --------------------------------------------------------------------------------------- |\n| the component's own look | `craftStyles('name', { root: [...] })` in `name.style.ts` |\n| a variant (tone, size, selected) | `defineStateAxis(...)`, then `when(axis.point, [...])`; the template sets `data-*` |\n| a state the platform already announces | `ariaCurrent`, `ariaPressed`, `ariaInvalid`, `interaction.hover` / `.focus` / `.disabled` |\n| a value known only at runtime | `cssVars(...)` in the sheet, `assign(...)` in the template |\n| a child that follows its parent's state | a variable declared with `{ inherits: true }`, set by the parent, read by the child |\n| page defaults (`body`, links, the theme) | [`craftGlobalStyles`](/guide/style/foundation) |\n| a web font | [`defineFont`](/guide/style/foundation) |\n| `::before`, `@keyframes`, transitions | [`pseudo.*`, `keyframes`, `animate`](/guide/style/pseudo-elements) |\n\nThe reset and the good defaults — focus ring, reduced motion, colour scheme —\ncome from `@craft-ts/style` itself. An app has no `styles.css` to write.\n\n## What refuses the other routes\n\nPer file, in `craftRules.configs.recommended`\n([details](/guide/routing/eslint-rules)):\n\n- `no-raw-class` — a `class` that does not trace back to a sheet imported from a\n `*.style` module;\n- `no-inline-style` — a `style` that is not `assign(...)`;\n- `no-component-css` — `meta.styles`, `meta.stylesUrl`, `meta.contentStyles`, and\n any `.css` import other than `virtual:craft-style.css`;\n- `style-file-boundary` — a sheet importing anything but style vocabulary;\n- `no-raw-css-value`, `no-free-has` — a raw value or a hand-written `:has()`\n inside a sheet.\n\nAcross the application, in the base architecture rules\n([details](/guide/testing/architecture)):\n\n- `style-only-design-system` — an element whose class reaches no sheet the build\n emits;\n- `no-global-stylesheet` — an entry file importing a `.css`, or `index.html`\n linking a stylesheet;\n- `style-obligations-discharged`, `no-dangling-css-vars` — a `requires` nobody\n provides, a variable read and never declared.\n\n`styles`, `stylesUrl`, `contentStyles` and `cssVars` still exist on the meta's\ntype, marked `@deprecated`. They are kept so that the exception below stays\npossible, not as an alternative.\n\n## The one real exception\n\nContent you do not author — HTML rendered from markdown, a third-party widget\nthat ships its own stylesheet — cannot be styled through a sheet. It is the only\ncase, and it takes an explicit, reasoned bypass:\n\n```ts\n// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet\nimport 'vendor-date-picker/dist/picker.css';\n```\n\n`no-forbidden-eslint-disable` refuses the directive without its reason. On the\narchitecture side, the bypass is a waiver in `architecture/waivers.ts`:\n\n```ts\n{\n rule: 'no-global-stylesheet',\n target: 'file:src/main.ts',\n reason: 'The vendor date picker ships its stylesheet.',\n}\n```\n\nA waiver names one target, not a rule wholesale, and one that no longer waives\nanything fails the check. Both kinds of bypass appear in the **Bypasses** view of\n[Review Attest](/guide/style/attestation), one subject per directive or waiver,\nto be accepted or rejected like any other evidence.\n\n## See Also\n\n- [`@craft-ts/style`](/guide/style/) — the design system, from tokens to the matrix\n- [Axes and the visual matrix](/guide/style/variants)\n- [Customization](/guide/components/customization) — host properties and caller overrides\n"
146
156
  },
147
157
  {
148
158
  "path": "/guide/components/template-migrator",
@@ -187,7 +197,7 @@
187
197
  {
188
198
  "path": "/guide/create-project",
189
199
  "title": "Create a CraftTS project",
190
- "body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### `npm run style:check`\n\nWith typed CSS enabled, the project gets one more, and it is the only one that\nneeds explaining:\n\n```bash\nnpm run style:check\n```\n\nIt builds once — which is how the style plugin writes\n`.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for\nevery element the graph can show holds text, in every state your axes can\nproduce, with no browser involved. It is in the generated CI workflow.\n\nThe starter is set up to pass it out of the box: the palette is named, so a\nfailure can say `ui.accent.dangerHover` rather than a hexadecimal string, and\nthe generated link writes its hovered colour through `interaction.hover`\nrather than a hand-written selector, so the hovered state is a state the check\ncan actually measure.\n\nTwo things to know before your first failure:\n\n- **A result the analysis cannot prove fails the run.** `--allow-indeterminate`\n turns those into warnings and you have to type it. A check whose default\n treats \"I could not tell\" as \"fine\" reports a clean bill on the part of the\n application it did not understand.\n- **A clean run is a contrast proof, not an accessibility audit.**\n\n[Text contrast](./style/contrast.md) has the full coverage contract: what is\nproven, what comes back as `indeterminate`, and how to close a gap honestly.\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
200
+ "body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nEvery project styles through `@craft-ts/style`, whatever the options: there is\nno plain-CSS starter and no `src/styles.css`. The element defaults and the app\nshell live in `src/app/app.style.ts`; `--design-system=basic` adds the starter\ndesign system in `src/app/ui/ui.style.ts`. `--no-typed-css` is no longer\naccepted.\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### `npm run style:check`\n\nWith typed CSS enabled, the project gets one more, and it is the only one that\nneeds explaining:\n\n```bash\nnpm run style:check\n```\n\nIt builds once — which is how the style plugin writes\n`.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for\nevery element the graph can show holds text, in every state your axes can\nproduce, with no browser involved. It is in the generated CI workflow.\n\nThe starter is set up to pass it out of the box: the palette is named, so a\nfailure can say `ui.accent.dangerHover` rather than a hexadecimal string, and\nthe generated link writes its hovered colour through `interaction.hover`\nrather than a hand-written selector, so the hovered state is a state the check\ncan actually measure.\n\nTwo things to know before your first failure:\n\n- **A result the analysis cannot prove fails the run.** `--allow-indeterminate`\n turns those into warnings and you have to type it. A check whose default\n treats \"I could not tell\" as \"fine\" reports a clean bill on the part of the\n application it did not understand.\n- **A clean run is a contrast proof, not an accessibility audit.**\n\n[Text contrast](./style/contrast.md) has the full coverage contract: what is\nproven, what comes back as `indeterminate`, and how to close a gap honestly.\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
191
201
  },
192
202
  {
193
203
  "path": "/guide/deployment",
@@ -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/no-external-state-transition': '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/no-external-state-transition`: rejects generic `replace`, `set`, `update`, or `patch` calls on a value returned by Craft `state(...)` outside its state insertion. Put the transition behind a named state method that accepts intent and computes the next value internally.\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-event-only-craft-method`: errors by default when a `craftMethod` only calls `preventDefault()`, `stopPropagation()`, or `stopImmediatePropagation()` and then delegates to one action. Bind the action with [`eventAction(...)`](/guide/components/directives#event-actions-and-dom-modifiers) on the element. The default architecture check enforces the same rule across files.\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`: requires every `class:` binding — on an element, in `attrs`, on a component `host` — to trace back to a sheet imported from a `*.style` module: `sheet.key`, a `const` bound to one, an array of them, a typed input (a parameter or a member of one), or a function that only returns one. A string, a template literal, a conditional or an object of booleans is refused. 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; and a sheet declared outside a `*.style.ts` is never evaluated by the build, so its class has no CSS. Make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-inline-style`: restricts `style:` to `assign(...)` from `@craft-ts/style` — or an array of them, an object spreading only them, a conditional whose branches are all of them, or a function that only returns them. What varies at runtime is a typed variable (`cssVars` + `assign`), read by a sheet; `attrs.style` is always refused\n- `craft-ts/no-component-css`: forbids `meta.styles`, `meta.stylesUrl` and `meta.contentStyles` on `craftComponent` / `craftDirective`, and every `.css` import except `virtual:craft-style.css`. Global rules go in [`craftGlobalStyles`](/guide/style/foundation), fonts in `defineFont`\n- `craft-ts/no-forbidden-eslint-disable`: requires a reason on every directive that disables a design-system rule — `// eslint-disable-next-line craft-ts/no-raw-class -- markdown output carries its own classes`. The reason is what the reviewer decides on in Review Attest. It also forbids disabling the rules listed in `.craft/eslint-disable-policy.json`. A blanket `eslint-disable` silences this rule too, so it cannot be reported here; Review Attest lists it\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. Part of the `legacyComponentCss` preset (see below)\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 superseded by the `craft.base` layer of [`@craft-ts/style`](/guide/style/foundation),\n which lays both once for the document; they are no longer in `recommended`\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 design system is the only way to style a component. The style rules —\n`no-raw-class`, `no-inline-style`, `no-component-css`, `no-raw-css-value`,\n`no-free-has`, `style-file-boundary` — are in `craftRules.configs.recommended`\nat `'error'`, in **every** file: none of them waits for a file to import\n`@craft-ts/style`.\n\nTo migrate a project in steps, turn the three binding rules off in **its own**\nESLint config, with a `TODO` comment the migration removes, and keep\nthe rules that read the legacy component CSS on in the meantime:\n\n```js\n{\n // TODO: remove once this project is migrated to @craft-ts/style.\n files: ['**/src/**/*.ts'],\n rules: {\n ...craftRules.configs.legacyComponentCss.rules,\n 'craft-ts/no-raw-class': 'off',\n 'craft-ts/no-inline-style': 'off',\n 'craft-ts/no-component-css': 'off',\n },\n},\n```\n\n`legacyComponentCss` groups the rules that read a component's CSS text —\n`craft-css-vars-contract`, `craft-styles-scope-safe`, `craft-css-var-naming`,\n`craft-css-token-registry`, `no-hardcoded-design-values`,\n`no-important-in-component-styles`, `require-focus-visible`,\n`require-reduced-motion`. They left `recommended` because `no-component-css`\nleaves them nothing to read.\n\nA genuine bypass — a third-party widget that ships its own CSS, HTML rendered\nfrom markdown — stays possible, one line at a time, with a reason:\n`// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet`.\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",
@@ -347,12 +357,12 @@
347
357
  {
348
358
  "path": "/guide/routing/pending-ui",
349
359
  "title": "Non-blocking navigation",
350
- "body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Token | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CRAFT_PENDING_COMPONENT` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CRAFT_LOADING_TEXT` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CRAFT_STAY_MS` / `CRAFT_BLANK_MS` / `CRAFT_PENDING_MIN_MS` | `300` / `300` / `0` |\n| `withErrorComponent` | `CRAFT_ERROR_COMPONENT` | `null` |\n| `withRouteLoadError` | `CRAFT_ROUTE_LOAD_ERROR_COMPONENT` / `CRAFT_ROUTE_LOAD_RETRY` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CRAFT_VIEW_TRANSITIONS_ENABLED` / `CRAFT_VIEW_TRANSITION_SKIP_BLANK` | `false` / `false` |\n| `withA11yNavigationFocus` | `CRAFT_A11Y_NAVIGATION_FOCUS` | `false` |\n\nThe default pending component renders `CRAFT_LOADING_TEXT`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren`; its routed\ncomponents carry their own per-route DI checks.\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton receives `photoId` as a route-bound input and reads the payload through the\n**route-generated typed helper**:\n\n```ts\nimport { input } from '@angular/core';\n\nexport default class PhotoSkeleton {\n protected readonly photoId = input.required<string>();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …). It is verified independently with the per-component, O(1)\n[`RouteCheckedDI`](/guide/routing/setup) check:\n\n```ts\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the route uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the\nauto-provided service names from the route's path params + payload.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
360
+ "body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Service helper(s) | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CraftPendingComponent` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CraftLoadingText` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CraftStayMs` / `CraftBlankMs` / `CraftPendingMinMs` | `300` / `300` / `0` |\n| `withErrorComponent` | `CraftErrorComponent` | `null` |\n| `withRouteLoadError` | `CraftRouteLoadErrorConfig` / `CraftRouteLoadRetry` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CraftViewTransitionsEnabled` / `CraftViewTransitionSkipBlank` | `false` / `false` |\n| `withA11yNavigationFocus` | `CraftA11yNavigationFocus` | `false` |\n\nThe default pending component renders `CraftLoadingText`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren`; its routed\ncomponents carry their own per-route DI checks.\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton receives `photoId` as a route-bound input and reads the payload through the\n**route-generated typed helper**:\n\n```ts\nimport { input } from '@angular/core';\n\nexport default class PhotoSkeleton {\n protected readonly photoId = input.required<string>();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …). It is verified independently with the per-component, O(1)\n[`RouteCheckedDI`](/guide/routing/setup) check:\n\n```ts\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the route uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the\nauto-provided service names from the route's path params + payload.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
351
361
  },
352
362
  {
353
363
  "path": "/guide/routing/route-load-errors",
354
364
  "title": "Route load errors",
355
- "body": "# Route load errors\n\nThis is the failure mode nothing else covers: the route is valid, the guards\npassed, and the **JavaScript chunk itself** never arrives — a stale hash after a\ndeploy, a flaky network, an offline user.\n\n**Use it when** your app is lazy-loaded and deployed more than once. Which is to\nsay: use it.\n\n`withRouteLoadError(...)` handles failures that happen before Craft can mount the target route:\nlazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale\ndeployments, CDN errors, or offline transitions.\n\nThis is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business\nexceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is\ntrying to fetch the JavaScript needed to activate the route.\n\n## Register the route-load error screen\n\nPass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and\nother craft loading features:\n\n```ts\nimport {\n provideCraftRouter,\n withRouteLoadError,\n withErrorComponent,\n} from '@craft-ts/core';\n\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 1,\n delayMs: 250,\n },\n }),\n);\n```\n\nThe component must be eager. Do not configure the route-load error screen with `loadComponent`: the\nfailure case is precisely that lazy JavaScript may be unavailable.\n\n## Runtime behaviour\n\nWhen a lazy route load fails, Craft:\n\n1. runs the configured retry strategy;\n2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`;\n3. renders the configured route-load error component;\n4. keeps the browser URL on the original target URL.\n\nThe last point matters. Internally, Craft activates a technical recovery route so there is\nsomething safe to render, but `browserUrl` keeps the visible URL as the intended route:\n\n```text\n/mutation/123\n→ lazy chunk fails\n→ retry fails\n→ route-load error screen is shown\n→ browser URL stays /mutation/123\n→ F5 reloads /mutation/123 and retries the real route\n```\n\n::: info No dedicated loading UI during JavaScript fetches yet\nWhile Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the\nconfigured retry strategy, Craft does not currently display the route's `pendingComponent` or another\ndedicated loading component. The pending component starts only after the JavaScript has loaded and the\nroute has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running.\n\nExtending the pending timeline to cover slow chunk downloads and retries is planned as a future\nevolution. Until then, the previous route may remain visible while the JavaScript request is pending;\nthe route-load error component appears only after all configured retries fail.\n:::\n\n::: warning Browser-cached module failures\nBrowsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each\nCraft lazy route import with the loader's `withRetry` helper:\n\n```ts\nloadComponent: ({ withRetry }) => withRetry(import('./detail')),\nloadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n```\n\nThe initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed\nproduction chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser\nerror and adds `__craft_route_retry` only to the failed request. A successful retry module is kept\nfor the lifetime of the application and reused by later route activations.\n\nThis recovery depends on the browser including the failed module URL in the dynamic-import error.\nWhen it does not, `reload()` remains the reliable recovery path. Do not write\n`import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from\nbeing statically discovered.\n:::\n\n## Build the error component\n\nThe component can inject both the active technical exception and the recovery API:\n\n```ts\nimport { button, craftComponent, div, h2, p } from '@craft-ts/component';\nimport {\n CraftRouteLoadError,\n CraftRouteLoadRecovery,\n provideHostName,\n} from '@craft-ts/core';\n\nexport const MyRouteLoadErrorScreen = craftComponent(\n 'MyRouteLoadErrorScreen',\n {\n providers: [provideHostName('component:MyRouteLoadErrorScreen')],\n styles: `\n :scope { padding: 2rem; border: 1px solid #f97316; border-radius: 8px }\n .actions { display: flex; gap: .75rem; margin-top: 1rem }\n `,\n },\n function* () {\n return {\n error: yield* CraftRouteLoadError(),\n recovery: yield* CraftRouteLoadRecovery(),\n };\n },\n ({ error, recovery }) => {\n const current = error();\n\n return div([\n h2('Route could not be loaded'),\n p(\n current\n ? `Failed to load ${current.payload.phase} for route \"${current.payload.routePath}\" after ${current.payload.attempt} attempts.`\n : 'The requested route chunk could not be loaded.',\n ),\n div({ class: 'actions' }, [\n button({ click: () => void recovery.retry() }, 'Retry route load'),\n button({ click: () => recovery.reload() }, 'Reload app'),\n ]),\n ]);\n },\n);\n```\n\n`CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes:\n\n- `phase`: `'component'` or `'children'`;\n- `routePath`: the route definition path that failed;\n- `targetUrl`: the URL the user tried to reach;\n- `cause`: the final error thrown by the loader/retry strategy;\n- `attempt`: the number of load attempts made.\n\n`injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the\nbrowser.\n\n## Configure retry globally\n\nThe default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 2,\n delayMs: 500,\n },\n});\n```\n\n`attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most\nthree loader calls total: the initial call plus two retries.\n\nUse callbacks when retry behaviour depends on the error:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 3,\n shouldRetry: (error, context) => {\n // Only retry dynamic import / chunk loading failures.\n if (!(error instanceof TypeError)) return false;\n\n // Stop earlier for a route where retrying is known to be useless.\n return context.routePath !== 'admin';\n },\n delayMs: (_error, context) => {\n // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, …\n return 250 * (context.attempt - 1);\n },\n },\n});\n```\n\nThe retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and\n`error`. The `attempt` value is the load attempt about to run. After the first failed load, the\nfirst retry callback receives `attempt: 2` and `error` set to the initial failure.\n\nFor custom logic, pass a retry strategy:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n async execute(loader, context) {\n console.warn('route load failed, retrying', context);\n return loader();\n },\n },\n});\n```\n\nThe strategy can also be an injectable class implementing `CraftRouteLoadRetry`.\n\n## Override per route\n\nBoth the retry strategy and the rendered component are regular DI providers. Override them on a\nspecific route when the failure should have local behaviour:\n\n```ts\nimport {\n provideRouteLoadErrorComponent,\n provideRouteLoadRetry,\n} from '@craft-ts/core';\n\ncraftRoute('admin', {\n providers: [\n provideRouteLoadRetry({\n attempts: 3,\n delayMs: 1_000,\n }),\n provideRouteLoadErrorComponent({\n component: AdminRouteLoadErrorScreen,\n componentDeps:\n {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen,\n }),\n ],\n loadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n});\n```\n\nThe local component receives the same `injectCraftRouteLoadError()` and\n`injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector.\n\n## DI checks\n\nRoute-load error components participate in the same generated DI checks as other error surfaces.\nThe ESLint rule `craft-ts/require-exception-component-di-check` generates\n`RouteExceptionComponentCheckedDI` checks for:\n\n- global `withRouteLoadError(...)` components;\n- route-local `provideRouteLoadErrorComponent(...)` components.\n\nRun ESLint with `--fix` after adding or changing a route-load error component:\n\n```bash\nnpx nx lint your-app --fix\n```\n\nDo not hand-maintain the generated `_Check*DI` blocks.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs)\n(`assertRouteDiProofs`) fail if a registered route-load error screen has no\narmed `RouteExceptionComponentCheckedDI`.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports\n- [Global error component](/guide/routing/global-error-component)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
365
+ "body": "# Route load errors\n\nThis is the failure mode nothing else covers: the route is valid, the guards\npassed, and the **JavaScript chunk itself** never arrives — a stale hash after a\ndeploy, a flaky network, an offline user.\n\n**Use it when** your app is lazy-loaded and deployed more than once. Which is to\nsay: use it.\n\n`withRouteLoadError(...)` handles failures that happen before Craft can mount the target route:\nlazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale\ndeployments, CDN errors, or offline transitions.\n\nThis is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business\nexceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is\ntrying to fetch the JavaScript needed to activate the route.\n\n## Register the route-load error screen\n\nPass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and\nother craft loading features:\n\n```ts\nimport {\n provideCraftRouter,\n withRouteLoadError,\n withErrorComponent,\n} from '@craft-ts/core';\n\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 1,\n delayMs: 250,\n },\n }),\n);\n```\n\nThe component must be eager. Do not configure the route-load error screen with `loadComponent`: the\nfailure case is precisely that lazy JavaScript may be unavailable.\n\n## Runtime behaviour\n\nWhen a lazy route load fails, Craft:\n\n1. runs the configured retry strategy;\n2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`;\n3. renders the configured route-load error component;\n4. keeps the browser URL on the original target URL.\n\nThe last point matters. Internally, Craft activates a technical recovery route so there is\nsomething safe to render, but `browserUrl` keeps the visible URL as the intended route:\n\n```text\n/mutation/123\n→ lazy chunk fails\n→ retry fails\n→ route-load error screen is shown\n→ browser URL stays /mutation/123\n→ F5 reloads /mutation/123 and retries the real route\n```\n\n::: info No dedicated loading UI during JavaScript fetches yet\nWhile Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the\nconfigured retry strategy, Craft does not currently display the route's `pendingComponent` or another\ndedicated loading component. The pending component starts only after the JavaScript has loaded and the\nroute has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running.\n\nExtending the pending timeline to cover slow chunk downloads and retries is planned as a future\nevolution. Until then, the previous route may remain visible while the JavaScript request is pending;\nthe route-load error component appears only after all configured retries fail.\n:::\n\n::: warning Browser-cached module failures\nBrowsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each\nCraft lazy route import with the loader's `withRetry` helper:\n\n```ts\nloadComponent: ({ withRetry }) => withRetry(import('./detail')),\nloadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n```\n\nThe initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed\nproduction chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser\nerror and adds `__craft_route_retry` only to the failed request. A successful retry module is kept\nfor the lifetime of the application and reused by later route activations.\n\nThis recovery depends on the browser including the failed module URL in the dynamic-import error.\nWhen it does not, `reload()` remains the reliable recovery path. Do not write\n`import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from\nbeing statically discovered.\n:::\n\n## Build the error component\n\nThe component can inject both the active technical exception and the recovery API:\n\n```ts\nimport { button, craftComponent, div, h2, p } from '@craft-ts/component';\nimport {\n CraftRouteLoadError,\n CraftRouteLoadRecovery,\n provideHostName,\n} from '@craft-ts/core';\n// A sheet beside the screen: its frame, and the row of actions.\nimport { loadError } from './route-load-error.style';\n\nexport const MyRouteLoadErrorScreen = craftComponent(\n 'MyRouteLoadErrorScreen',\n {\n providers: [provideHostName('component:MyRouteLoadErrorScreen')],\n },\n function* () {\n return {\n error: yield* CraftRouteLoadError(),\n recovery: yield* CraftRouteLoadRecovery(),\n };\n },\n ({ error, recovery }) => {\n const current = error();\n\n return div({ class: loadError.root }, [\n h2('Route could not be loaded'),\n p(\n current\n ? `Failed to load ${current.payload.phase} for route \"${current.payload.routePath}\" after ${current.payload.attempt} attempts.`\n : 'The requested route chunk could not be loaded.',\n ),\n div({ class: loadError.actions }, [\n button({ click: () => void recovery.retry() }, 'Retry route load'),\n button({ click: () => recovery.reload() }, 'Reload app'),\n ]),\n ]);\n },\n);\n```\n\n`CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes:\n\n- `phase`: `'component'` or `'children'`;\n- `routePath`: the route definition path that failed;\n- `targetUrl`: the URL the user tried to reach;\n- `cause`: the final error thrown by the loader/retry strategy;\n- `attempt`: the number of load attempts made.\n\n`injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the\nbrowser.\n\n## Configure retry globally\n\nThe default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 2,\n delayMs: 500,\n },\n});\n```\n\n`attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most\nthree loader calls total: the initial call plus two retries.\n\nUse callbacks when retry behaviour depends on the error:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 3,\n shouldRetry: (error, context) => {\n // Only retry dynamic import / chunk loading failures.\n if (!(error instanceof TypeError)) return false;\n\n // Stop earlier for a route where retrying is known to be useless.\n return context.routePath !== 'admin';\n },\n delayMs: (_error, context) => {\n // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, …\n return 250 * (context.attempt - 1);\n },\n },\n});\n```\n\nThe retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and\n`error`. The `attempt` value is the load attempt about to run. After the first failed load, the\nfirst retry callback receives `attempt: 2` and `error` set to the initial failure.\n\nFor custom logic, pass a retry strategy:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n async execute(loader, context) {\n console.warn('route load failed, retrying', context);\n return loader();\n },\n },\n});\n```\n\nThe strategy can also be an injectable class implementing `CraftRouteLoadRetry`.\n\n## Override per route\n\nBoth the retry strategy and the rendered component are regular DI providers. Override them on a\nspecific route when the failure should have local behaviour:\n\n```ts\nimport {\n provideRouteLoadErrorComponent,\n provideRouteLoadRetry,\n} from '@craft-ts/core';\n\ncraftRoute('admin', {\n providers: [\n provideRouteLoadRetry({\n attempts: 3,\n delayMs: 1_000,\n }),\n provideRouteLoadErrorComponent({\n component: AdminRouteLoadErrorScreen,\n componentDeps:\n {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen,\n }),\n ],\n loadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n});\n```\n\nThe local component receives the same `injectCraftRouteLoadError()` and\n`injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector.\n\n## DI checks\n\nRoute-load error components participate in the same generated DI checks as other error surfaces.\nThe ESLint rule `craft-ts/require-exception-component-di-check` generates\n`RouteExceptionComponentCheckedDI` checks for:\n\n- global `withRouteLoadError(...)` components;\n- route-local `provideRouteLoadErrorComponent(...)` components.\n\nRun ESLint with `--fix` after adding or changing a route-load error component:\n\n```bash\nnpx nx lint your-app --fix\n```\n\nDo not hand-maintain the generated `_Check*DI` blocks.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs)\n(`assertRouteDiProofs`) fail if a registered route-load error screen has no\narmed `RouteExceptionComponentCheckedDI`.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports\n- [Global error component](/guide/routing/global-error-component)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
356
366
  },
357
367
  {
358
368
  "path": "/guide/routing/route-providers",
@@ -387,17 +397,17 @@
387
397
  {
388
398
  "path": "/guide/state/local-state",
389
399
  "title": "Local state",
390
- "body": "# Local state\n\n`state` holds a value you own, in memory, as a signal — with its methods and\nderived values attached to it rather than scattered around it.\n\n**Use it when** the value's home is your application: a form draft, a selection,\na toggle, a counter.\n**Not when** the value lives on a server ([`query`](/guide/state/server-state)),\nin the URL ([`queryParams`](/guide/state/url-state)), or is the result of an\nasync action ([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst counter = yield* state('counter', 0, ({ state, update, set }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n reset: () => set(0),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n\nyield* counter(); // 0\nyield* counter.increment();\nyield* counter.isEven(); // false\nyield* counter.reset();\n```\n\nThe insertion context gives you `state` (the current value as a yieldable\nreader), `set` and `update`. Non-generator methods may return `update(...)`\ndirectly — the insertion wrapper consumes the write. `isEven` yields `state()`\nbecause the computed does not own that reader. In a template, pass the reader\nor the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a\nsynchronous boundary, `craftUse(counter.increment())`.\n\n::: tip New to the shape?\nThe name, the destructuring, the `yield*` driver and the single-use rule are\nthe same for all five primitives — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n:::\n\n## Deriving from another reader\n\nThe initial value can be a Craft reader, in which case the state follows it:\n\n```typescript\nconst origin = yield* state('origin', 5);\n\nconst doubled = yield* state(\n 'doubled',\n craftComputed('originDoubled', function* () {\n return (yield* origin()) * 2;\n }),\n);\n\nyield* doubled(); // 10\n```\n\n## Composing several insertions\n\nOne insertion function gets crowded. Split it and compose with `insertStatePipe`:\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((current) => current + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n\nyield* counter.increment();\nyield* counter.isOdd(); // true\n```\n\nEach function receives the same context and contributes its own slice. See\n[Insertions](/guide/concepts/insertions).\n\n## Driving it from events\n\nBind a method to a [`source$`](/guide/reactivity/source) with\n[`on$`](/guide/reactivity/on) when the trigger is an event rather than a call:\n\n```typescript\nconst increment = source$<void>('increment');\nconst reset = source$<void>('reset');\n\nconst myState = yield* state('myState', 0, ({ update, set }) => ({\n onIncrement: on$(increment, () => update((v) => v + 1)),\n onReset: on$(reset, () => set(0)),\n}));\n\nincrement.emit(); // after yield* / craftUse, myState is 1\nreset.emit(); // after yield* / craftUse, myState is 0\n```\n\nLike every craft primitive, a source is **named**, and the name must match the\nvariable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule\nenforces it and autofixes it.\n\nNote that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods\nbound to a source work internally only.\n\n## Yielding dependencies\n\nAn insertion can be a `function*`, so it can pull in services:\n\n```typescript\nyield* state('counter', 0, function* ({ state }) {\n const log = yield* Console.log;\n return {\n logValue: function* () {\n yield* log(`State value: ${yield* state()}`);\n },\n };\n });\n```\n\nPrefer yielding a craft service over reaching into a runtime container — yielding is\nwhat makes the dependency visible to the route DI check and to test registers.\n\n## Pitfalls\n\n**Don't duplicate derived state.** If a value is a function of another, it is a\n`craftComputed` inside an insertion that `yield*`s its readers, or a `state`\nwhose second argument is that source — not a second `state` kept in sync by an\neffect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync)\nfails the architecture suite when an effect writes another primitive.\n\n**Keep slices granular.** One `state` per coherent concern. A single object\nholding five unrelated things makes every consumer depend on all five.\n\n::: details Advanced — scoping providers to one state\nUse the object form with `$self` when a state needs its own provider scope:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n {\n $self: function* () {\n return yield* CounterPreferences.initialValue();\n },\n providers: [provideCounterPreferences(), provideCounterAnalytics()],\n },\n ({ update }) => ({\n increment: function* () {\n yield* CounterAnalytics.track('increment');\n return yield* update((value) => value + 1);\n },\n }),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods also provide `injectStateMethodRuntimeContext()`, which\nrecovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers,\nWebMCP tools, and other advanced patterns — everyday insertions already\nreceive those methods as arguments. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Insertions](/guide/concepts/insertions)\n- [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary\n"
400
+ "body": "# Local state\n\n`state` holds a value you own, in memory, as a signal — with its methods and\nderived values attached to it rather than scattered around it.\n\n**Use it when** the value's home is your application: a form draft, a selection,\na toggle, a counter.\n**Not when** the value lives on a server ([`query`](/guide/state/server-state)),\nin the URL ([`queryParams`](/guide/state/url-state)), or is the result of an\nasync action ([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst counter = yield* state('counter', 0, ({ state, update, set }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n reset: () => set(0),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n\nyield* counter(); // 0\nyield* counter.increment();\nyield* counter.isEven(); // false\nyield* counter.reset();\n```\n\nThe insertion context gives you `state` (the current value as a yieldable\nreader), `set` and `update`. Non-generator methods may return `update(...)`\ndirectly — the insertion wrapper consumes the write. `isEven` yields `state()`\nbecause the computed does not own that reader. In a template, pass the reader\nor the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a\nsynchronous boundary, `craftUse(counter.increment())`.\n\n::: tip New to the shape?\nThe name, the destructuring, the `yield*` driver and the single-use rule are\nthe same for all five primitives — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n:::\n\n## Deriving from another reader\n\nThe initial value can be a Craft reader, in which case the state follows it:\n\n```typescript\nconst origin = yield* state('origin', 5);\n\nconst doubled = yield* state(\n 'doubled',\n craftComputed('originDoubled', function* () {\n return (yield* origin()) * 2;\n }),\n);\n\nyield* doubled(); // 10\n```\n\n## Composing several insertions\n\nOne insertion function gets crowded. Split it and compose with `insertStatePipe`:\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((current) => current + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n\nyield* counter.increment();\nyield* counter.isOdd(); // true\n```\n\nEach function receives the same context and contributes its own slice. See\n[Insertions](/guide/concepts/insertions).\n\n## Keep transitions with their state\n\nPass an intent to the state that owns a value. Do not read the value in a\ncaller, calculate a replacement there, and pass the replacement back through\na generic method such as `replace` or `update`.\n\n```typescript\nconst todos = yield* state('todos', initialTodos, ({ update }) => ({\n move: (todoId: string, direction: 'up' | 'down') => update((current) => {\n const from = current.findIndex((todo) => todo.id === todoId);\n const to = from + (direction === 'up' ? -1 : 1);\n if (from < 0 || to < 0 || to >= current.length) return current;\n const next = [...current];\n const [todo] = next.splice(from, 1);\n if (!todo) return current;\n next.splice(to, 0, todo);\n return next;\n }),\n}));\n\n// The caller supplies only the intent.\nyield* todos.move(todoId, 'up');\n```\n\nThe recommended and Effect ESLint presets enable\n`craft-ts/no-external-state-transition`. It flags calls such as\n`todos.replace(nextTodos)` or `todos.update(transition)` outside the state\ninsertion. Named state commands remain available to callers; simple values\nsuch as an input's `setValue(value)` are allowed. Prefer not to expose generic\nwhole-state mutators from domain states.\n\nThe rule checks generic mutator method names on values created by Craft's\n`state(...)` primitive. It does not infer whether an arbitrarily named method\nsuch as `replaceTodos(nextTodos)` is a generic replacement; the state interface\nshould make its command semantics clear.\n\n## Driving it from events\n\nBind a method to a [`source$`](/guide/reactivity/source) with\n[`on$`](/guide/reactivity/on) when the trigger is an event rather than a call:\n\n```typescript\nconst increment = source$<void>('increment');\nconst reset = source$<void>('reset');\n\nconst myState = yield* state('myState', 0, ({ update, set }) => ({\n onIncrement: on$(increment, () => update((v) => v + 1)),\n onReset: on$(reset, () => set(0)),\n}));\n\nincrement.emit(); // after yield* / craftUse, myState is 1\nreset.emit(); // after yield* / craftUse, myState is 0\n```\n\nLike every craft primitive, a source is **named**, and the name must match the\nvariable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule\nenforces it and autofixes it.\n\nNote that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods\nbound to a source work internally only.\n\n## Yielding dependencies\n\nAn insertion can be a `function*`, so it can pull in services:\n\n```typescript\nyield* state('counter', 0, function* ({ state }) {\n const log = yield* Console.log;\n return {\n logValue: function* () {\n yield* log(`State value: ${yield* state()}`);\n },\n };\n });\n```\n\nPrefer yielding a craft service over reaching into a runtime container — yielding is\nwhat makes the dependency visible to the route DI check and to test registers.\n\n## Pitfalls\n\n**Don't duplicate derived state.** If a value is a function of another, it is a\n`craftComputed` inside an insertion that `yield*`s its readers, or a `state`\nwhose second argument is that source — not a second `state` kept in sync by an\neffect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync)\nfails the architecture suite when an effect writes another primitive.\n\n**Keep slices granular.** One `state` per coherent concern. A single object\nholding five unrelated things makes every consumer depend on all five.\n\n::: details Advanced — scoping providers to one state\nUse the object form with `$self` when a state needs its own provider scope:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n {\n $self: function* () {\n return yield* CounterPreferences.initialValue();\n },\n providers: [provideCounterPreferences(), provideCounterAnalytics()],\n },\n ({ update }) => ({\n increment: function* () {\n yield* CounterAnalytics.track('increment');\n return yield* update((value) => value + 1);\n },\n }),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods also provide `injectStateMethodRuntimeContext()`, which\nrecovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers,\nWebMCP tools, and other advanced patterns — everyday insertions already\nreceive those methods as arguments. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Insertions](/guide/concepts/insertions)\n- [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary\n"
391
401
  },
392
402
  {
393
403
  "path": "/guide/state/mutations",
394
404
  "title": "Mutations",
395
- "body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone. A loader must not be `async` or\nreturn a native `Promise`; use `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\nInside `craftMutations(...)`, `providers` stays on each `mutation(name, ...)`\nconfig, not on the wrapper:\n\n```typescript\nconst userFeature = craft(\n { name: 'userFeature', providedIn: 'root' },\n craftMutations(() => ({\n saveUser: mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n }).saveUser,\n })),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
405
+ "body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone. A loader must not be `async` or\nreturn a native `Promise`; use `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
396
406
  },
397
407
  {
398
408
  "path": "/guide/state/pagination-placeholder",
399
409
  "title": "Pagination placeholders",
400
- "body": "# Pagination placeholders\n\n`insertPaginationPlaceholderData` keeps the previous page on screen while the\nnext one loads, so paging through a list never flashes an empty state.\n\n**Use it when** a query is paginated with an `identifier` per page.\n**Not when** you just want to avoid a flicker on a non-paginated query — a query\nalready keeps its previous value while loading, with no configuration\n([query](/guide/state/server-state)).\n\n```typescript\nimport { insertPaginationPlaceholderData } from '@craft-ts/core';\n```\n\n## The common case\n\nIt is a **higher-order insertion**: call it with a config and pass the result to\n`query`. `config.initialValue` is both the default value and the page type —\nwhich is why `currentPageData` is a `Signal<T>` that is **never `undefined`**.\n\n```typescript\nconst pagination = yield* state('pagination', 1);\n\nconst { userQuery } = yield* query(\n 'userQuery',\n {\n params: pagination,\n identifier: (params) => '' + params,\n loader: function* ({ params }) {\n const response = yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users?page=${params}`,\n success: response<User[]>(),\n }));\n return response.json();\n },\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n);\n\n// Access the current page data (or placeholder data during loading)\nconst data = userQuery.currentPageData();\n\n// Check the loading status of the current page\nconst status = userQuery.currentPageStatus();\n\n// Determine if placeholder data is being shown\nconst isPlaceholder = userQuery.isPlaceHolderData();\n\n// Get the current page identifier\nconst identifier = userQuery.currentIdentifier();\n```\n\n## Returned Properties\n\n| Property | Type | Description |\n| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `currentPageData` | `Signal<T>` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). |\n| `currentPageStatus` | `Signal<ResourceStatus>` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) |\n| `isPlaceHolderData` | `Signal<boolean>` | `true` when showing previous page data as a placeholder |\n| `currentIdentifier` | `Signal<string>` | The identifier of the current page |\n\n## Custom Outputs (`build` callback)\n\nPass an optional second argument to attach your own computed values or methods next to\nthe pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the\n**current page** (the displayed data), so mutations only affect the page the user is\nlooking at — other cached pages are left untouched.\n\n```typescript\nconst { usersQuery } = query(\n 'usersQuery',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertPaginationPlaceholderData(\n { initialValue: [] as Data[] },\n ({ state, settledState, set }) => ({\n // a computed derived from the current page\n totalOfUnCompletedData: craftComputed(function* () {\n return (yield* state()).filter((d) => !d.completed).length;\n }),\n settledCount: craftComputed(function* () {\n return (yield* settledState()).length;\n }),\n markAsCompleted: function* (id: string) {\n const current = yield* state();\n return yield* set(\n current.map((d) => (d.id === id ? { ...d, completed: true } : d)),\n );\n },\n }),\n ),\n);\n\nyield* usersQuery.totalOfUnCompletedData(); // number\nyield* usersQuery.markAsCompleted('42');\n```\n\nThe `build` context exposes:\n\n| Helper | Type | Description |\n| -------- | --------------------------------------- | ---------------------------------------------------- |\n| `state` | yieldable reader for `T` | The current page data (or `initialValue`) |\n| `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition |\n| `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) |\n| `update` | yieldable write returning `T` | Update the current page data from its previous value |\n| `patch` | yieldable write returning `T` | Patch the current page data with a partial value |\n\nThe pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`,\n`currentIdentifier`) are also available in the `build` context.\n\n::: details A full paginated component\n\n```typescript\nimport { button, craftComponent, div, forNode, ifNode, span } from '@craft-ts/component';\nimport { craftComputed, query, state } from '@craft-ts/core';\n\nexport const UsersList = craftComponent(\n 'UsersList',\n {},\n function* () {\n const page = yield* state('page', 1, ({ state, update, set }) => ({\n next: () => update((value) => value + 1),\n previous: function* () {\n const current = yield* state();\n return yield* set(Math.max(1, current - 1));\n },\n isFirst: craftComputed(function* () {\n return (yield* state()) === 1;\n }),\n label: craftComputed(function* () {\n return `Page ${yield* state()}`;\n }),\n }));\n\n const userQuery = yield* query(\n 'userQuery',\n {\n params: page,\n identifier: (page) => `page-${page}`,\n loader: async ({ params }) =>\n (await fetch(`/api/users?page=${params}`)).json() as Promise<User[]>,\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n );\n\n return { page, userQuery };\n },\n ({ page, userQuery }) => [\n div(\n {\n class: function* () {\n return (yield* userQuery.isPlaceHolderData())\n ? 'users-list loading'\n : 'users-list';\n },\n },\n forNode(\n userQuery.currentPageData,\n { track: (user) => user.id },\n (user) => UserCard({ user }),\n ),\n ),\n\n div({ class: 'pagination' }, [\n button({ click: page.previous, disabled: page.isFirst }, 'Previous'),\n span(page.label),\n button({ click: page.next }, 'Next'),\n ]),\n\n ifNode(userQuery.isPlaceHolderData, () =>\n div({ class: 'loading-indicator' }, 'Loading new page…'),\n ),\n ],\n);\n```\n\n:::\n\n## How it works\n\n1. When the page parameters change, the insertion checks whether the new page's\n data is already cached.\n2. If the new page is loading and has no data yet, it serves the previous page's\n data as a placeholder.\n3. `isPlaceHolderData` tells you that is what is on screen — use it to dim the\n list or show a spinner.\n4. Once the real data arrives, it switches over automatically.\n\n## Pitfalls\n\n**It needs an `identifier`.** Without one page identity, there is no \"previous\npage\" to fall back to.\n\n**`initialValue` defines the page type.** Passing `[]` untyped collapses\n`currentPageData` to `never[]` — write `[] as User[]`.\n\n**Mutating through the `build` helpers only affects the current page.** Other\ncached pages are untouched, which is usually what you want, but means a global\nchange needs a reload.\n\n## See Also\n\n- [query](/guide/state/server-state) — the base primitive\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Insertions](/guide/concepts/insertions)\n"
410
+ "body": "# Pagination placeholders\n\n`insertPaginationPlaceholderData` keeps the previous page on screen while the\nnext one loads, so paging through a list never flashes an empty state.\n\n**Use it when** a query is paginated with an `identifier` per page.\n**Not when** you just want to avoid a flicker on a non-paginated query — a query\nalready keeps its previous value while loading, with no configuration\n([query](/guide/state/server-state)).\n\n```typescript\nimport { insertPaginationPlaceholderData } from '@craft-ts/core';\n```\n\n## The common case\n\nIt is a **higher-order insertion**: call it with a config and pass the result to\n`query`. `config.initialValue` is both the default value and the page type —\nwhich is why `currentPageData` is a `Signal<T>` that is **never `undefined`**.\n\n```typescript\nconst pagination = yield* state('pagination', 1);\n\nconst { userQuery } = yield* query(\n 'userQuery',\n {\n params: pagination,\n identifier: (params) => '' + params,\n loader: function* ({ params }) {\n const response = yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users?page=${params}`,\n success: response<User[]>(),\n }));\n return response.json();\n },\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n);\n\n// Access the current page data (or placeholder data during loading)\nconst data = userQuery.currentPageData();\n\n// Check the loading status of the current page\nconst status = userQuery.currentPageStatus();\n\n// Determine if placeholder data is being shown\nconst isPlaceholder = userQuery.isPlaceHolderData();\n\n// Get the current page identifier\nconst identifier = userQuery.currentIdentifier();\n```\n\n## Returned Properties\n\n| Property | Type | Description |\n| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `currentPageData` | `Signal<T>` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). |\n| `currentPageStatus` | `Signal<ResourceStatus>` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) |\n| `isPlaceHolderData` | `Signal<boolean>` | `true` when showing previous page data as a placeholder |\n| `currentIdentifier` | `Signal<string>` | The identifier of the current page |\n\n## Custom Outputs (`build` callback)\n\nPass an optional second argument to attach your own computed values or methods next to\nthe pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the\n**current page** (the displayed data), so mutations only affect the page the user is\nlooking at — other cached pages are left untouched.\n\n```typescript\nconst { usersQuery } = query(\n 'usersQuery',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertPaginationPlaceholderData(\n { initialValue: [] as Data[] },\n ({ state, settledState, set }) => ({\n // a computed derived from the current page\n totalOfUnCompletedData: craftComputed(function* () {\n return (yield* state()).filter((d) => !d.completed).length;\n }),\n settledCount: craftComputed(function* () {\n return (yield* settledState()).length;\n }),\n markAsCompleted: function* (id: string) {\n const current = yield* state();\n return yield* set(\n current.map((d) => (d.id === id ? { ...d, completed: true } : d)),\n );\n },\n }),\n ),\n);\n\nyield* usersQuery.totalOfUnCompletedData(); // number\nyield* usersQuery.markAsCompleted('42');\n```\n\nThe `build` context exposes:\n\n| Helper | Type | Description |\n| -------- | --------------------------------------- | ---------------------------------------------------- |\n| `state` | yieldable reader for `T` | The current page data (or `initialValue`) |\n| `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition |\n| `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) |\n| `update` | yieldable write returning `T` | Update the current page data from its previous value |\n| `patch` | yieldable write returning `T` | Patch the current page data with a partial value |\n\nThe pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`,\n`currentIdentifier`) are also available in the `build` context.\n\n::: details A full paginated component\n\n```typescript\nimport { button, craftComponent, div, forNode, ifNode, span } from '@craft-ts/component';\nimport { craftComputed, query, state } from '@craft-ts/core';\n\nexport const UsersList = craftComponent(\n 'UsersList',\n {},\n function* () {\n const page = yield* state('page', 1, ({ state, update, set }) => ({\n next: () => update((value) => value + 1),\n previous: function* () {\n const current = yield* state();\n return yield* set(Math.max(1, current - 1));\n },\n isFirst: craftComputed(function* () {\n return (yield* state()) === 1;\n }),\n label: craftComputed(function* () {\n return `Page ${yield* state()}`;\n }),\n }));\n\n const userQuery = yield* query(\n 'userQuery',\n {\n params: page,\n identifier: (page) => `page-${page}`,\n loader: async ({ params }) =>\n (await fetch(`/api/users?page=${params}`)).json() as Promise<User[]>,\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n );\n\n return { page, userQuery };\n },\n ({ page, userQuery }) => [\n div(\n {\n class: function* () {\n return (yield* userQuery.isPlaceHolderData())\n ? 'users-list loading'\n : 'users-list';\n },\n },\n forNode(\n userQuery.currentPageData,\n { track: (user) => user.id },\n (user) => UserCard({ user }),\n ),\n ),\n\n // `pagerSheet` is the page's sheet, from users-page.style.ts.\n div({ class: pagerSheet.bar }, [\n button({ click: page.previous, disabled: page.isFirst }, 'Previous'),\n span(page.label),\n button({ click: page.next }, 'Next'),\n ]),\n\n ifNode(userQuery.isPlaceHolderData, () =>\n div({ class: pagerSheet.loading }, 'Loading new page…'),\n ),\n ],\n);\n```\n\n:::\n\n## How it works\n\n1. When the page parameters change, the insertion checks whether the new page's\n data is already cached.\n2. If the new page is loading and has no data yet, it serves the previous page's\n data as a placeholder.\n3. `isPlaceHolderData` tells you that is what is on screen — use it to dim the\n list or show a spinner.\n4. Once the real data arrives, it switches over automatically.\n\n## Pitfalls\n\n**It needs an `identifier`.** Without one page identity, there is no \"previous\npage\" to fall back to.\n\n**`initialValue` defines the page type.** Passing `[]` untyped collapses\n`currentPageData` to `never[]` — write `[] as User[]`.\n\n**Mutating through the `build` helpers only affects the current page.** Other\ncached pages are untouched, which is usually what you want, but means a global\nchange needs a reload.\n\n## See Also\n\n- [query](/guide/state/server-state) — the base primitive\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Insertions](/guide/concepts/insertions)\n"
401
411
  },
402
412
  {
403
413
  "path": "/guide/state/persistence",
@@ -447,32 +457,42 @@
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",
459
469
  "title": "Defining your design system",
460
- "body": "# Defining your design system\n\nThe other pages in this section spend `bp`, `scheme`, `palette`, `v`, `space`\nand `unit` as if they were already in scope. They are not built in — except\n`scheme`, which is. This page is where the rest come from.\n\nEverything here goes in one sheet, conventionally `foundation.style.ts`. A\n`*.style.ts` may import vocabulary and nothing else — the `style-file-boundary`\nrule enforces it — which is exactly what makes it safe for the build plugin to\nimport the file in Node: there is no application code in it to run.\n\n\n\n## The palette\n\n`definePalette` takes a group-of-tokens shape where every token carries **both**\nof its values at once:\n\n\n\n`@craft-ts/style` already exports a `palette` built the same way — the same four\ngroups, with neutral defaults. Spend it as-is to get moving, and call\n`definePalette` when you want your own colours; the pages that follow use the\nname `palette` for whichever one is in scope.\n\nA token is not a colour string; it is a pair plus a role, and the role comes from\nthe group it sits in (`surface`, `text`, `border`, `accent`). `darkOf(token)` is\nhow a sheet reaches the other side of the pair, and the role travels with it —\nthe dark side of a surface is still a surface.\n\nComponents never read the palette directly. They read a **theme variable**, and\nthe theme is the single place that decides what a variable holds in light and in\ndark. That indirection is what makes dark mode one rule instead of one rule per\ncomponent.\n\n## Axes\n\nA component may only vary along an axis you declared. There are four ways to\ndeclare one, and the choice is about what drives the variation.\n\n### `defineBreakpoints` — the viewport\n\n\n\nBreakpoints are an **ordered** axis: two points can be compared, which is what\nlets the matrix reduce by interval instead of by product, and what makes a rule\nthat can never apply detectable. `above(...)` and `below(...)` turn a point into\nan explicit bound.\n\n### `defineStateAxis` — an attribute you set\n\n\n\nEach point carries the driver that reaches it — here `data-tone='danger'` — so a\nscenario the matrix enumerates is a scenario a test can actually produce. The\nattribute-_value_ form, rather than one attribute per state, is what makes the\nstates mutually exclusive by construction: an element cannot be two of them at\nonce, so the matrix does not have to be told.\n\n### `defineAxis` — a state axis with a write constraint\n\n\n\n`onlyVarsOfKind(kind.color)` says this axis may write `<color>` custom\nproperties and nothing else. An axis that can only write colours cannot move a\nbox, so it crosses **additively** with the axes that do rather than multiplying\nthem. The constraint is checked where it is cheap — at the `when` call site —\ninstead of by reading the emitted CSS afterwards.\n\n`defineStateAxis` is `defineAxis` without the options object; it stays a\nseparate name because the unconstrained case is the common one and reads better\nwithout them.\n\n### `defineContainer` — the size of a box, not of the window\n\n\n\nA container axis answers \"how wide is _my_ box\", which nobody above the container\ncan change. So it is closed at the element that declares the container: the\nmatrix prunes it there rather than letting every ancestor inherit scenarios it\nhas no way to affect.\n\n### The standard axes\n\n`scheme`, `motion`, `forcedColors`, `contrast`, `scrollState` and `descendant`\nship with `@craft-ts/style` and need no declaration — they are driven by the\nuser agent or by the element's own state, not by an attribute you own. `scheme`\nis the one the theme below uses.\n\n### `axisPoint` — the escape hatch\n\n`axisPoint(axis, point, open, driver, extra)` builds a point by hand, for a\nselector none of the four constructors produce. You give up nothing type-side,\nbut you take on the part the constructors were doing for you: the `driver` must\nreally reach the `open` selector, and nothing checks that for you.\n\n## The theme\n\n`cssVars(prefix, specs)` declares the typed custom properties. Each is registered\nthrough `@property`, so the browser validates it: assigning a length where a\ncolour belongs paints nothing rather than painting wrong.\n\n\n\nTwo things on that block are worth stopping on.\n\n**`inherits: true` belongs to theme variables and to nothing else.** The default\nis `false`, and that is the right default for a variable an element sets on\nitself and reads on itself — it bounds invalidation to that element. A theme\nvariable is the opposite case: set once on a wrapper, read by everything below.\nA non-inheriting theme hands every descendant the initial value instead, which\nlooks exactly like dark mode not working, with no error anywhere.\n\n**`kind.length(unit.px(16))`, not `unit.rem(1)`.** `@property` requires a\ncomputationally independent initial value; a relative one makes the browser drop\nthe registration entirely, and silently. The theme writes the `rem` value in the\nrule below.\n\n::: tip `cssVars` here is not `meta.cssVars`\n`cssVars(prefix, specs)` from `@craft-ts/style` declares registered custom\nproperties for a design system. `meta.cssVars` on `craftComponent` declares one\ncomponent's per-instance styling API. Same word, two mechanisms — see\n[Typed CSS variables](../components/css-variables.md) for the other one.\n:::\n\n## `seal` — closing a tree\n\n`seal(node)` is the only place a [context obligation](./obligations.md) becomes\nan error. Up to that point an unanswered requirement keeps travelling, because an\nancestor still has the right to answer it. `seal` is you saying: from here up,\nnobody else will.\n\nPut it at the root of a route, not around the component that raised the\nrequirement — that is the whole point of letting obligations travel.\n\n## Next\n\n- [Tokens and typed variables](./tokens.md) — spending what you just declared.\n- [Axes and the visual matrix](./variants.md) — `when`, and the matrix the axes\n above generate.\n- [Context obligations](./obligations.md) — `requires`, `provides`, and `seal`.\n"
470
+ "body": "# Defining your design system\n\nThe other pages in this section spend `bp`, `scheme`, `palette`, `v`, `space`\nand `unit` as if they were already in scope. They are not built in — except\n`scheme`, which is. This page is where the rest come from.\n\nEverything here goes in one sheet, conventionally `foundation.style.ts`. A\n`*.style.ts` may import vocabulary and nothing else — the `style-file-boundary`\nrule enforces it — which is exactly what makes it safe for the build plugin to\nimport the file in Node: there is no application code in it to run.\n\n\n\n## The palette\n\n`definePalette` takes a group-of-tokens shape where every token carries **both**\nof its values at once:\n\n\n\n`@craft-ts/style` already exports a `palette` built the same way — the same four\ngroups, with neutral defaults. Spend it as-is to get moving, and call\n`definePalette` when you want your own colours; the pages that follow use the\nname `palette` for whichever one is in scope.\n\nA token is not a colour string; it is a pair plus a role, and the role comes from\nthe group it sits in (`surface`, `text`, `border`, `accent`). `darkOf(token)` is\nhow a sheet reaches the other side of the pair, and the role travels with it —\nthe dark side of a surface is still a surface.\n\nComponents never read the palette directly. They read a **theme variable**, and\nthe theme is the single place that decides what a variable holds in light and in\ndark. That indirection is what makes dark mode one rule instead of one rule per\ncomponent.\n\n## Axes\n\nA component may only vary along an axis you declared. There are four ways to\ndeclare one, and the choice is about what drives the variation.\n\n### `defineBreakpoints` — the viewport\n\n\n\nBreakpoints are an **ordered** axis: two points can be compared, which is what\nlets the matrix reduce by interval instead of by product, and what makes a rule\nthat can never apply detectable. `above(...)` and `below(...)` turn a point into\nan explicit bound.\n\n### `defineStateAxis` — an attribute you set\n\n\n\nEach point carries the driver that reaches it — here `data-tone='danger'` — so a\nscenario the matrix enumerates is a scenario a test can actually produce. The\nattribute-_value_ form, rather than one attribute per state, is what makes the\nstates mutually exclusive by construction: an element cannot be two of them at\nonce, so the matrix does not have to be told.\n\n### `defineAxis` — a state axis with a write constraint\n\n\n\n`onlyVarsOfKind(kind.color)` says this axis may write `<color>` custom\nproperties and nothing else. An axis that can only write colours cannot move a\nbox, so it crosses **additively** with the axes that do rather than multiplying\nthem. The constraint is checked where it is cheap — at the `when` call site —\ninstead of by reading the emitted CSS afterwards.\n\n`defineStateAxis` is `defineAxis` without the options object; it stays a\nseparate name because the unconstrained case is the common one and reads better\nwithout them.\n\n### `defineContainer` — the size of a box, not of the window\n\n\n\nA container axis answers \"how wide is _my_ box\", which nobody above the container\ncan change. So it is closed at the element that declares the container: the\nmatrix prunes it there rather than letting every ancestor inherit scenarios it\nhas no way to affect.\n\n### The standard axes\n\n`scheme`, `motion`, `forcedColors`, `contrast`, `scrollState` and `descendant`\nship with `@craft-ts/style` and need no declaration — they are driven by the\nuser agent or by the element's own state, not by an attribute you own. `scheme`\nis the one the theme below uses.\n\n`interaction` (`hover`, `focus`, `active`, `disabled`) reads the element's own\npseudo-classes. Three more read an ARIA attribute instead of a `data-*` one, so\nthe look and what assistive technology announces cannot disagree:\n\n| Axis | Opens on | Typical element |\n| --------------------- | ------------------------- | ----------------------------- |\n| `ariaCurrent.page` | `[aria-current='page']` | the router's active link |\n| `ariaCurrent.true` | `[aria-current='true']` | the current item of a list |\n| `ariaPressed.pressed` | `[aria-pressed='true']` | a toggle button that is on |\n| `ariaInvalid.true` | `[aria-invalid='true']` | a field whose value was refused |\n\nSet the attribute for accessibility; the sheet follows it. A second `data-*`\nattribute for the same state would be one more thing to keep in sync.\n\n### `axisPoint` — the escape hatch\n\n`axisPoint(axis, point, open, driver, extra)` builds a point by hand, for a\nselector none of the four constructors produce. You give up nothing type-side,\nbut you take on the part the constructors were doing for you: the `driver` must\nreally reach the `open` selector, and nothing checks that for you.\n\n## The theme\n\n`cssVars(prefix, specs)` declares the typed custom properties. Each is registered\nthrough `@property`, so the browser validates it: assigning a length where a\ncolour belongs paints nothing rather than painting wrong.\n\n\n\nTwo things on that block are worth stopping on.\n\n**`inherits: true` belongs to theme variables and to nothing else.** The default\nis `false`, and that is the right default for a variable an element sets on\nitself and reads on itself — it bounds invalidation to that element. A theme\nvariable is the opposite case: set once on a wrapper, read by everything below.\nA non-inheriting theme hands every descendant the initial value instead, which\nlooks exactly like dark mode not working, with no error anywhere.\n\n**`kind.length(unit.px(16))`, not `unit.rem(1)`.** `@property` requires a\ncomputationally independent initial value; a relative one makes the browser drop\nthe registration entirely, and silently. The theme writes the `rem` value in the\nrule below.\n\n::: tip One `cssVars` for the theme and for a component\nThe same `cssVars` declares a design system's theme and a single component's\nstyling API — per-instance variants, inheritance, forwarding, runtime values.\nSee [Typed CSS variables](../components/css-variables.md). The deprecated\n`meta.cssVars` on `craftComponent` is a different, CSS-string mechanism.\n:::\n\n## `seal` — closing a tree\n\n`seal(node)` is the only place a [context obligation](./obligations.md) becomes\nan error. Up to that point an unanswered requirement keeps travelling, because an\nancestor still has the right to answer it. `seal` is you saying: from here up,\nnobody else will.\n\nPut it at the root of a route, not around the component that raised the\nrequirement — that is the whole point of letting obligations travel.\n\n## Next\n\n- [Tokens and typed variables](./tokens.md) — spending what you just declared.\n- [Axes and the visual matrix](./variants.md) — `when`, and the matrix the axes\n above generate.\n- [Context obligations](./obligations.md) — `requires`, `provides`, and `seal`.\n"
471
+ },
472
+ {
473
+ "path": "/guide/style/foundation",
474
+ "title": "Global foundation and fonts",
475
+ "body": "# Global foundation and fonts\n\nAn app built on `@craft-ts/style` has no `styles.css`. What used to go there\nfalls into three layers, and craft-ts writes the first two for you:\n\n| layer | written by | what it holds |\n| -------------- | ---------------------------- | ------------------------------------------------------------------------ |\n| `craft.reset` | craft-ts, on by default | a modern reset |\n| `craft.base` | craft-ts, on by default | colour scheme, focus ring, reduced motion, selection, form accent |\n| `craft.global` | your app, `craftGlobalStyles` | your theme variables on `:root`, element defaults (`body`, `a`, …) |\n\nThey come before the component layers. The full order is fixed by the emitter,\nwhatever order your modules are imported in:\n\n```css\n@layer craft.reset, craft.base, craft.tokens, craft.global,\n craft.components, craft.variants, craft.overrides;\n```\n\nEvery layer is named `craft.*`. A third-party stylesheet that arrives unlayered\nwins over all of them — that is how CSS treats unlayered styles — which is\nexactly why one should be rare, deliberate and attested.\n\n## The reset\n\nOn by default. It sets `box-sizing: border-box` everywhere, removes default\nmargins, makes media blocks that never overflow (`max-inline-size: 100%`), lets\nform controls inherit the document font, balances headings and avoids orphans\nin paragraphs (`text-wrap`), wraps long words (`overflow-wrap: anywhere`) and\nstops mobile browsers from inflating text.\n\nIt is written with the typed vocabulary, like any sheet\n([`libs/style/src/lib/global/reset.ts`](https://github.com/craft-ts/craft-ts/blob/main/libs/style/src/lib/global/reset.ts)).\nTurning it off is a deliberate choice:\n\n```ts\ncraftStyle({ reset: false });\n```\n\n## The base\n\nAlso on by default (`base: false` to opt out). It states once, for the whole\ndocument, what components used to have to remember one by one:\n\n- `color-scheme` follows the `scheme` axis, so scrollbars and form controls\n turn dark with the page;\n- every `:focus-visible` gets a visible ring;\n- scrolling is smooth only for users who did not ask for less motion, and\n under `prefers-reduced-motion` **every** animation and transition collapses\n to an instant. The guard is `!important` in the earliest layer, the one\n place that beats every later layer, so no component can forget it;\n- `accent-color` and `::selection` come from the theme.\n\nThe colours and sizes are typed theme variables, exported as `craftBase`:\n`accent`, `focusRing`, `focusWidth`, `focusOffset`, `selectionBg`,\n`selectionInk`. Re-theme them from your own global styles (below).\n\n## Your app's globals\n\n\n\n`root` goes on `:root`, `elements` on tag names — the only selectors accepted.\nAnything narrower than an element belongs to a component sheet. The items are\nthe same as in a sheet: generated properties, `set(...)`, `when(...)`, and\n`pseudo.*`. `when` on a state axis becomes an attribute on `:root`, which is\nhow a theme toggle is written (`when(theme.dark, [...])` →\n`:root[data-theme='dark']`).\n\n`no-raw-css-value` applies here as everywhere else: no literal reaches a\nhelper.\n\n## Fonts\n\n\n\n`defineFont` replaces the three things a `styles.css` used to carry:\n\n- **the `@import url(fonts.googleapis…)`** — the plugin injects a `preconnect`\n to both Google origins, then the stylesheet preloaded and applied, into\n `<head>` of `index.html`. An `@import` inside the CSS could only be\n discovered once the CSS itself had downloaded;\n- **the `@font-face` blocks** — `localFont({ files: [...] })` emits them, and\n preloads the `.woff2` files;\n- **the `* { font-family: … !important }`** — the returned token is a family\n stack, used once with `fontFamily(bodyFont)` on `body` and inherited from\n there. Form controls inherit it through the reset.\n\nA server renderer that writes `<head>` itself reads the same tags as HTML:\n\n```ts\nimport head from 'virtual:craft-style-head';\n```\n\n### Fallback without layout shift\n\nWhile the web font loads, the fallback font is shown, and the text jumps when\nthe real one arrives. `adjustFallback` builds a `\"<family> Fallback\"` face from\na local font, resized so both occupy the same space:\n\n```ts\ndefineFont('body', {\n family: 'Chivo',\n source: googleFont({ weights: [400, 700] }),\n fallback: 'system-ui',\n adjustFallback: {\n // The web font's metrics, in font units. Capsize publishes them for every\n // Google font (@capsizecss/metrics).\n metrics: {\n unitsPerEm: 1000,\n ascent: 954,\n descent: -250,\n lineGap: 0,\n xWidthAvg: 505,\n },\n // Optional, Arial by default: `face: { local: 'Helvetica', metrics }`.\n },\n});\n```\n\n`size-adjust` matches the average glyph width, so lines break at the same\nplace; `ascent-override`, `descent-override` and `line-gap-override` keep the\nline height.\n\n## What this replaces in the ESLint rules\n\n`require-focus-visible` and `require-reduced-motion` used to read each\ncomponent's CSS text to check that a focus ring and a reduced-motion branch\nwere there. The base layer guarantees both for the whole document, so a\ncomponent no longer has anything to prove.\n"
461
476
  },
462
477
  {
463
478
  "path": "/guide/style/obligations",
464
479
  "title": "Context obligations",
465
480
  "body": "# Context obligations\n\nLevel 3. **Adopted per whole route**, and that granularity is the whole point of\nthis page.\n\n## What it is for\n\nA sticky element needs a scroll port. A scroll-state query needs an element\ndeclaring the container. Get either wrong and nothing errors — the component\nsimply never appears, or sticks to the wrong box, and you find out in a\nscreenshot weeks later.\n\n```ts\nimport {\n craftStyles,\n display,\n insetBlockEnd,\n position,\n provides,\n requires,\n scrollPort,\n space,\n} from '@craft-ts/style';\n\nexport const backToTop = craftStyles('backToTop', {\n anchor: [\n requires(scrollPort.block),\n position.sticky,\n insetBlockEnd(space(4)),\n ],\n});\n\nexport const shell = craftStyles('appShell', {\n main: [provides(scrollPort.block), display.block],\n});\n```\n\n`requires` is attached to the **class**, not the sheet, so the error names a\nrule rather than a file. And `provides(...)` returns the CSS effect **and** the\ndischarge in the same object: since `overflow` is not in the property table,\nthis is the only road to `overflow-block: auto`. Claiming to provide without\nlaying the CSS is not something anyone can write.\n\n## Where it becomes an error\n\nNowhere, until a component seals:\n\n```ts\nimport { craftComponent } from '@craft-ts/component';\n\ncraftComponent('AppShell', { seals: [true] }, factory, template);\n```\n\nUntil then the requirement **travels** — an ancestor still has the right to\nanswer it, and complaining early would be wrong. Sealing says \"from here up,\nnobody will\".\n\nRemove the provider and the typecheck fails:\n\n> `ERROR_unmet_context_requirement: \"'scrollPort.block' is required by this\nsubtree and nothing above it provides one. declare it on the layout component\nthat owns the scrollable area. An overflow on the direct parent would create a\nsecond scroll port, and the sticky element would stick to the wrong container.\"`\n\nWhat is missing, where to put it, and what the obvious wrong fix would do.\n\n## Why the granularity is the route\n\nThe requirement is carried by the type of the render tree. It crosses a\ncomponent boundary only where the tree is typed all the way through. One\ncomponent in the path that hands back a loosely typed subtree, and the demand\nstops travelling — silently, because nothing is wrong with _that_ component.\n\nSo level 3 is not something you get for the components you migrated. You get it\nfor a route once the route is migrated, and not before. The dependency graph\nreports which components are not covered rather than reporting a clean bill:\n\n```ts\nimport { extractionGaps, undischargedObligations } from '@craft-ts/dev-tools';\n\nextractionGaps(graph); // components no sheet is known to style\nundischargedObligations(graph); // required somewhere, discharged nowhere\n```\n\n## The marked way out\n\n```ts\nimport { scrollPort, unsafeAssume } from '@craft-ts/style';\n\nunsafeAssume(scrollPort.block, 'the host page owns the scroll port');\n```\n\nDischarges without laying the CSS, for the cases the model cannot see — a shell\nowned by someone else. It propagates `unproven`, so the graph counts it as debt.\nAn escape hatch that did not bubble up would be a design bug, not a convenience.\n"
466
481
  },
482
+ {
483
+ "path": "/guide/style/pseudo-elements",
484
+ "title": "Pseudo-elements and animations",
485
+ "body": "# Pseudo-elements and animations\n\nA component sheet can style what used to need a hand-written selector —\n`::before`, `::placeholder`, `@keyframes`, `transition` — with the same typed\nvocabulary as the rest of the sheet.\n\n## Pseudo-elements\n\n\n\n`pseudo.before([...])` is an item of a class, and nests like `when(...)`. The\nemitter always puts the pseudo-element **last** in the selector, where CSS\nrequires it, whatever the nesting order: `when(status.failed, [pseudo.before(...)])`\nand `pseudo.before([when(status.failed, ...)])` both give\n`.x[data-status='failed']::before`.\n\nAvailable: `pseudo.before`, `pseudo.after`, `pseudo.placeholder`,\n`pseudo.marker`, `pseudo.selection`, `pseudo.backdrop`.\n\n### `content` is required, and typed\n\n`::before` and `::after` are not generated without a `content`, and nothing\nthey declare applies. Rather than a decoration that silently disappears, it is\na type error:\n\n```ts\npseudo.before([display.block]);\n// ~~~~~~~~~ ERROR_a_generated_pseudo_element_needs_content\n```\n\nThe content itself comes from `pseudo.content`:\n\n| helper | CSS |\n| ---------------------------------------- | ---------------------- |\n| `pseudo.content.empty` | `content: \"\"` |\n| `pseudo.content.none` | `content: none` |\n| `pseudo.content.text(cssString('→'))` | `content: \"→\"` |\n| `pseudo.content.counter(ident('step'))` | `content: counter(step)` |\n\nThe check reads the top level of the block: a `content` placed only under a\n`when(...)` leaves the base scenario without one, which is the same bug.\n\n### Pseudo-classes are axes\n\nThere is no `pseudo.hover`. `:hover`, `:focus-visible` or `:disabled` are\n**states**, and a state is an axis (`interaction.hover`, `defineStateAxis`).\nThat is what puts it in the variant contract, the visual matrix and the\ncontrast proof — a hand-written `:hover` would be invisible to all three.\n\n## Keyframes and animations\n\n\n\n`keyframes(name, steps)` returns a **token**; `animate(token, options)` plays\nit. An animation cannot point at keyframes that do not exist. The steps are\n`from`, `to` or percentages, and each step holds declarations from the\ngenerated table.\n\n`animate` writes longhands (`animation-name`, `animation-duration`, …), so a\nvariant can change the duration alone. It is not called `animation` because\nthat name is the generated shorthand helper.\n\n## Transitions\n\n\n\nThe properties are named from the table (`prop.backgroundColor`). There is no\n`all`: `transition: all` animates whatever a later variant happens to change,\nlayout included, and nobody decided that.\n\n`easing` holds the keywords (`linear`, `ease`, `easeIn`, `easeOut`,\n`easeInOut`, `stepStart`, `stepEnd`) and two constructors,\n`easing.cubicBezier(x1, y1, x2, y2)` and `easing.steps(count, position)`.\n\n## Reduced motion\n\nYou write none of it. Under `prefers-reduced-motion: reduce`, the\n[global foundation](./foundation.md) collapses every animation and transition\nof the document to an instant.\n"
486
+ },
467
487
  {
468
488
  "path": "/guide/style/setup",
469
489
  "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"
490
+ "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 six 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| `reset` | `true` | ship the craft-ts reset — see [Global foundation](./foundation.md) |\n| `base` | `true` | ship colour scheme, focus ring, reduced motion, selection |\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
491
  },
472
492
  {
473
493
  "path": "/guide/style/template-obligations",
474
494
  "title": "Template obligations",
475
- "body": "# Template obligations\n\nTests and visual captures describe things that were observed. A component\ntemplate describes something earlier: what the component promises to display\nand what actions it exposes. CraftTS can derive those promises directly from\nthe dependency graph and record a human judgement about each one.\n\n```sh\ncraft-ts attest status \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nNo test or browser report is required. The command reads each\n`craftComponent` template and derives two kinds of obligation.\n\n## Render and command\n\nA **render** obligation starts at a reactive binding in the template and follows\nthe graph to the state, computed value, query, or property that produces it.\n\n```ts\n({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]);\n```\n\nThis template promises to render `user.isAdmin` and `total`. Two occurrences of\nthe same target are one promise, not two.\n\nA **command** obligation starts at a handler on an interactive element and\nfollows the call chain it triggers.\n\n```ts\n({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove');\n```\n\nThis template promises that the named button invokes `users.remove`. The\nelement tag and its literal name are part of the promise, so moving the action\nto a different control asks for a new judgement.\n\nComputed or dynamic accesses that cannot be addressed are printed as\n`template-obligation-unresolved` diagnostics. They are known extraction gaps;\nthey are never silently treated as if the template made no promise.\n\nThe derived obligation keeps two presentation forms. Its `statement` is a\ncanonical English sentence and remains available in the API, CLI and agency\nhandoffs. The review application uses the accompanying structured statement\nparts to render that same promise in the selected language. Neither form is\npart of the attested evidence hash, so wording changes do not invalidate a\ndecision.\n\n## What `renewed` means\n\nEvery obligation has two independent keys:\n\n| key | meaning |\n| ---------------- | ------------------------------------------------------------------------------------- |\n| code fingerprint | the transitive code slice behind the bound or invoked target |\n| evidence | the canonical shape of the promise: direction, element, name, target, and target kind |\n\nWhen implementation code changes but the template still promises the same\nthing, the state is `renewed`. The previous judgement carries forward without\nasking a person to review it again. When the template binds or invokes a\ndifferent target, the evidence changes and the state is `review`.\n\nAn attested obligation is **not a passing test**. It says that a person confirmed\nthe promise was intentional. It does not prove that the implementation fulfils\nthat promise at runtime.\n\n## Removing a promise is a decision\n\nIf an attested template obligation disappears, `status --kind template` exits\nwith a failure until the removal is signed. The ledger line is retained and\nmarked with why the promise went away:\n\n```sh\ncraft-ts attest retire \\\n --kind template \\\n --subject 'template:component:src/card.ts:Card#command:property:src/card.ts:save' \\\n --reason superseded \\\n --note 'Saving is automatic now.'\n```\n\nThe reasons are:\n\n- `superseded`: the product now fulfils the need another way;\n- `defect`: the former promise was wrong;\n- `derivation`: the extractor produced an obligation it should not have. Track\n this count as a quality signal for the extractor.\n\nThe note is mandatory because absence is otherwise indistinguishable from an\naccidental deletion. If a retired obligation later reappears, it returns to the\nreview queue; the next human verdict clears the retirement.\n\n## Measured rename noise\n\nThe target identity remains part of the evidence because the implementation\nmeasurement stayed inside its review budget. Replaying the latest 20 commits\nthat touched `apps/demo` produced a median of **0 actionable obligation changes\nper commit** (one commit removed four obligations, one added three, and the\nother eighteen changed none), with **0 changes attributable to a pure target\nrename**. This is below the threshold of three, so mass renames can continue to\nbe handled by review clustering without weakening the promise recorded in the\nevidence.\n"
495
+ "body": "# Template obligations\n\nTests and visual captures describe things that were observed. A component\ntemplate describes something earlier: what the component promises to display\nand what actions it exposes. CraftTS can derive those promises directly from\nthe dependency graph and record a human judgement about each one.\n\n```sh\ncraft-ts attest status \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nNo test or browser report is required. The command reads each\n`craftComponent` template and derives two kinds of obligation.\n\n## Render and command\n\nA **render** obligation starts at a reactive binding in the template and follows\nthe graph to the state, computed value, query, or property that produces it.\n\n```ts\n({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]);\n```\n\nThis template promises to render `user.isAdmin` and `total`. Two occurrences of\nthe same target are one promise, not two.\n\nA **command** obligation starts at a handler on an interactive element and\nfollows the call chain it triggers.\n\n```ts\n({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove');\n```\n\nThis template promises that the named button invokes `users.remove`. The\nelement tag and its literal name are part of the promise, so moving the action\nto a different control asks for a new judgement.\n\nFor a command that resolves to a `craftMethod`, the obligation also records\nthe method's top-level call statements in source order. Calls inside a branch or a\nnested callback are omitted because they are not guaranteed on every click.\nThe ordered calls are part of the readable evidence, so changing them asks for\na new judgement. The review card shows this sequence below the promise.\n\nThe review queue contains the promise and its short effect list, but no source\ncode. When a template review card opens, the review app requests its button\nand method snippets from `/api/template-detail` and shows both by default.\n\nComputed or dynamic accesses that cannot be addressed are printed as\n`template-obligation-unresolved` diagnostics. They are known extraction gaps;\nthey are never silently treated as if the template made no promise.\n\nThe derived obligation keeps two presentation forms. Its `statement` is a\ncanonical English sentence and remains available in the API, CLI and agency\nhandoffs. The review application uses the accompanying structured statement\nparts to render that same promise in the selected language. Neither form is\npart of the attested evidence hash, so wording changes do not invalidate a\ndecision.\n\n## What `renewed` means\n\nEvery obligation has two independent keys:\n\n| key | meaning |\n| ---------------- | ------------------------------------------------------------------------------------------------------------- |\n| code fingerprint | the transitive code slice behind the bound or invoked target |\n| evidence | the canonical shape of the promise: direction, element, name, target, target kind, and direct command effects |\n\nWhen implementation code changes but the template still promises the same\nthing, the state is `renewed`. The previous judgement carries forward without\nasking a person to review it again. When the template binds or invokes a\ndifferent target, or a command's direct effects change, the evidence changes\nand the state is `review`.\n\nAn attested obligation is **not a passing test**. It says that a person confirmed\nthe promise was intentional. It does not prove that the implementation fulfils\nthat promise at runtime.\n\n## Removing a promise is a decision\n\nIf an attested template obligation disappears, `status --kind template` exits\nwith a failure until the removal is signed. The ledger line is retained and\nmarked with why the promise went away:\n\n```sh\ncraft-ts attest retire \\\n --kind template \\\n --subject 'template:component:src/card.ts:Card#command:property:src/card.ts:save' \\\n --reason superseded \\\n --note 'Saving is automatic now.'\n```\n\nThe reasons are:\n\n- `superseded`: the product now fulfils the need another way;\n- `defect`: the former promise was wrong;\n- `derivation`: the extractor produced an obligation it should not have. Track\n this count as a quality signal for the extractor.\n\nThe note is mandatory because absence is otherwise indistinguishable from an\naccidental deletion. If a retired obligation later reappears, it returns to the\nreview queue; the next human verdict clears the retirement.\n\n## Measured rename noise\n\nThe target identity remains part of the evidence because the implementation\nmeasurement stayed inside its review budget. Replaying the latest 20 commits\nthat touched `apps/demo` produced a median of **0 actionable obligation changes\nper commit** (one commit removed four obligations, one added three, and the\nother eighteen changed none), with **0 changes attributable to a pure target\nrename**. This is below the threshold of three, so mass renames can continue to\nbe handled by review clustering without weakening the promise recorded in the\nevidence.\n"
476
496
  },
477
497
  {
478
498
  "path": "/guide/style/testing",
@@ -482,17 +502,17 @@
482
502
  {
483
503
  "path": "/guide/style/tokens",
484
504
  "title": "Tokens and typed variables",
485
- "body": "# Tokens and typed variables\n\nLevel 1. Useful from the first component, and it does not require anything else\nin the app to change.\n\n## No value is a string\n\nEvery value is a **nominal object**, not a branded string:\n\n```ts\nimport { bg, p, palette, space, unit } from '@craft-ts/style';\n\np(space(4)); // ✅\np(unit.rem(1.5)); // ✅\np('12px'); // ❌ a length is not a string\np(`${4}px`); // ❌ not even one of the right shape\nbg('red'); // ❌ a colour is not a keyword\np(palette.text.strong); // ❌ a colour is not a length\n```\n\nThe shape matters more than it looks. With `string & { __length?: true }` —\nan _optional_ phantom on a primitive base — `'blabla'` stays assignable, every\ntest stays green, and the guarantee written on this page is false.\n\n## The scales are closed\n\n`space(7)` does not compile. When a step is missing, add it to the scale; there\nis no `[17px]` arbitrary-value syntax on purpose.\n\nThe one way out is marked:\n\n```ts\nimport { unsafeLength } from '@craft-ts/style';\n\nunsafeLength('13px', 'aligns with a legacy image');\n```\n\nIt compiles, and it propagates `unproven` to the dependency graph, where the\ndebt is counted. Without this door a blocked agent bypasses the design system\nentirely; with it unmarked, it bypasses it in silence.\n\n## The property table is generated\n\n477 properties, generated from MDN data — which is what guarantees no keyword\nwas invented. A closed keyword set is a namespace, never a string:\n\n```ts\nimport { display, position } from '@craft-ts/style';\n\ndisplay.inlineFlex; // ✅\ndisplay.inlineFlexx; // ❌ Property 'inlineFlexx' does not exist\nposition('sticky'); // ❌ a keyword is not something you pass in\n```\n\nTwo consequences worth knowing:\n\n- **`overflow` is not in the table.** The only road to `overflow-block: auto` is\n `provides(scrollPort.block)` — see [obligations](./obligations.md). The wrong\n fix is not discouraged, it cannot be written.\n- **124 helpers are narrower than CSS.** A grammar alternative the generator\n cannot close is dropped rather than approximated, so a helper may refuse a\n form CSS would accept. It can never produce CSS a browser rejects. The list is\n exported as `NARROWED_PROPERTIES`.\n\n## Typed custom properties\n\n```ts\nimport { color, cssVars, kind, p, palette, unit } from '@craft-ts/style';\n\nexport const v = cssVars('badge', {\n ink: kind.color(palette.text.strong),\n pad: kind.length(unit.px(16)),\n});\n\ncolor(v.ink); // ✅ the token carries its kind's brand\np(v.ink); // ❌ a <color> variable is not a length\nv.ink.or(space(4)); // ❌ the fallback is typed against the same kind\n```\n\nTwo rules the browser enforces and the types cannot:\n\n**A registered `initial-value` must be computationally independent.**\n`initial-value: 1rem` makes the whole `@property` rule invalid and the browser\ndrops it _silently_ — the variable stops being registered, `var(--x)` resolves to\nnothing, and whatever reads it computes to zero. `cssVars` refuses a relative\nunit there and names the fix.\n\n**`inherits: false` is the right default, and wrong for a theme.** A variable an\nelement sets and reads on itself should not inherit: it bounds invalidation. A\ntheme variable is the opposite — set once on a wrapper, read by everything\nbelow — so pass `{ inherits: true }`. A non-inheriting theme hands every\ndescendant the initial value, which looks exactly like dark mode not working,\nwith no error anywhere.\n"
505
+ "body": "# Tokens and typed variables\n\nLevel 1. Useful from the first component, and it does not require anything else\nin the app to change.\n\n## No value is a string\n\nEvery value is a **nominal object**, not a branded string:\n\n```ts\nimport { bg, p, palette, space, unit } from '@craft-ts/style';\n\np(space(4)); // ✅\np(unit.rem(1.5)); // ✅\np('12px'); // ❌ a length is not a string\np(`${4}px`); // ❌ not even one of the right shape\nbg('red'); // ❌ a colour is not a keyword\np(palette.text.strong); // ❌ a colour is not a length\n```\n\nThe shape matters more than it looks. With `string & { __length?: true }` —\nan _optional_ phantom on a primitive base — `'blabla'` stays assignable, every\ntest stays green, and the guarantee written on this page is false.\n\n## The scales are closed\n\n`space(7)` does not compile. When a step is missing, add it to the scale; there\nis no `[17px]` arbitrary-value syntax on purpose.\n\nThe one way out is marked:\n\n```ts\nimport { unsafeLength } from '@craft-ts/style';\n\nunsafeLength('13px', 'aligns with a legacy image');\n```\n\nIt compiles, and it propagates `unproven` to the dependency graph, where the\ndebt is counted. Without this door a blocked agent bypasses the design system\nentirely; with it unmarked, it bypasses it in silence.\n\n## The property table is generated\n\n477 properties, generated from MDN data — which is what guarantees no keyword\nwas invented. A closed keyword set is a namespace, never a string:\n\n```ts\nimport { display, position } from '@craft-ts/style';\n\ndisplay.inlineFlex; // ✅\ndisplay.inlineFlexx; // ❌ Property 'inlineFlexx' does not exist\nposition('sticky'); // ❌ a keyword is not something you pass in\n```\n\nTwo consequences worth knowing:\n\n- **`overflow` is not in the table.** The only road to `overflow-block: auto` is\n `provides(scrollPort.block)` — see [obligations](./obligations.md). The wrong\n fix is not discouraged, it cannot be written.\n- **124 helpers are narrower than CSS.** A grammar alternative the generator\n cannot close is dropped rather than approximated, so a helper may refuse a\n form CSS would accept. It can never produce CSS a browser rejects. The list is\n exported as `NARROWED_PROPERTIES`.\n\nA few values the table cannot type on its own have constructors:\n\n- `gradient.linear`, `.radial`, `.repeatingLinear`, `.repeatingConic`, written\n with `bgImage(...)` — several images make several layers, and `bgSize` /\n `bgPosition` take one inline and one block value;\n- `uaScheme.light` / `.dark` for `color-scheme`, so native controls follow the\n theme;\n- `spanAllColumns` for `grid-column: 1 / -1`;\n- `pseudo.content.attr(ident('data-x'))` for a `::before` / `::after` whose text\n is an attribute the template writes.\n\n## Typed custom properties\n\n```ts\nimport { color, cssVars, kind, p, palette, unit } from '@craft-ts/style';\n\nexport const v = cssVars('badge', {\n ink: kind.color(palette.text.strong),\n pad: kind.length(unit.px(16)),\n});\n\ncolor(v.ink); // ✅ the token carries its kind's brand\np(v.ink); // ❌ a <color> variable is not a length\nv.ink.or(space(4)); // ❌ the fallback is typed against the same kind\n```\n\nTwo rules the browser enforces and the types cannot:\n\n**A registered `initial-value` must be computationally independent.**\n`initial-value: 1rem` makes the whole `@property` rule invalid and the browser\ndrops it _silently_ — the variable stops being registered, `var(--x)` resolves to\nnothing, and whatever reads it computes to zero. `cssVars` refuses a relative\nunit there and names the fix.\n\n**`inherits: false` is the right default, and wrong for a theme.** A variable an\nelement sets and reads on itself should not inherit: it bounds invalidation. A\ntheme variable is the opposite — set once on a wrapper, read by everything\nbelow — so pass `{ inherits: true }`. A non-inheriting theme hands every\ndescendant the initial value, which looks exactly like dark mode not working,\nwith no error anywhere.\n"
486
506
  },
487
507
  {
488
508
  "path": "/guide/style/variants",
489
509
  "title": "Axes and the visual matrix",
490
- "body": "# Axes and the visual matrix\n\nLevel 2. Adopted per component, and it is what turns \"I think that is all the\nstates\" into a list.\n\n## A variant is an axis, not a class name\n\n```ts\nimport {\n bg,\n craftStyles,\n defineStateAxis,\n palette,\n set,\n when,\n} from '@craft-ts/style';\n// `v` is your own sheet's typed variables, `bp` your own breakpoints — see\n// [Defining a design system](./define.md).\nimport { bp, v } from './foundation.style';\n\nexport const tone = defineStateAxis('tone', ['neutral', 'danger']);\n\nexport const badge = craftStyles('badge', {\n root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])],\n});\n```\n\nThe template sets **one static class** and a `data-tone` attribute. Nothing\nconcatenates a class at render time, which is what makes the set of states\nenumerable. The `no-raw-class` rule enforces it in files that use the package.\n\nConjunction is nesting, and only nesting:\n\n```ts\nimport { fontWeight, scheme, when } from '@craft-ts/style';\n\nwhen(scheme.dark, [when(bp.md, [fontWeight.bold])]);\n```\n\nOne way to write each thing, so two identical components cannot produce two\ndifferent contracts.\n\n## Only the points you actually cross\n\n`bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes\n**two** cells, not four. The contract records what the sheet uses, never what the\naxis offers.\n\nAn interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` —\nthrows when the sheet is registered, which under the build plugin is a build\nfailure.\n\n## Hover, and every pseudo-class after it\n\n```ts\nwhen(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]);\n```\n\n`interaction.hover` emits the same `&:hover` rule you would write by hand.\nWhat it adds is that the point enters the class's contract — so the matrix\nenumerates the hovered state, and the\n[static contrast check](./contrast.md) crosses the colours it writes with the\ntext on top of them. A `:hover` typed into a string emits identical CSS and is\ninvisible to both, which is how a button ends up readable at rest and\nunreadable under the pointer. `prefer-hover-axis` refuses it.\n\nIt is a real axis with a real price: it doubles the sheet's matrix, so it has\nto be in the budget below. Its driver is `{ kind: 'selfState', state: 'hover' }`,\nand `applyScenario` honours it by asking the page to move a pointer — a\ndispatched `mouseover` sets no pseudo-class and would capture the base state\nwhile looking correct.\n\n## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size, interaction] })\n```\n\nAn axis outside the budget is a compile error naming it. Without this, an axis\nadded deep in a leaf shows up as a doubled capture bill three levels up and\nnobody decided that. A declared axis that goes unused is reported, not rejected.\n\n## The matrix\n\n```ts\nimport { visualMatrix, branch } from '@craft-ts/style-testing';\n\nvisualMatrix(card);\n// [{ id: 'base', … }, { id: 'viewport=md', … }]\n```\n\nIt takes **sheets**, not a component: a component's classes are only knowable by\nrendering it, and a matrix that silently missed a child's sheet would be the\nworst possible outcome.\n\nIdentifiers name only the axes away from `base`, so adding an axis elsewhere in\nthe app does not invalidate every baseline in the suite.\n\nTwo reductions are applied, and both are exactly true rather than probably true:\n\n- **A branch adds, it does not multiply.** The two sides of an `ifNode` are\n never on screen together, so declare it — `branch('footer', footerSheet)` —\n and the absent side stops carrying the footer's axes.\n- **A container axis stops at its owner.** An ancestor cannot change how wide\n that box is, so only the component naming the container keeps the axis.\n\nNothing else is reduced. A coverage that claims to be complete without being\ncomplete is worse than no coverage.\n"
510
+ "body": "# Axes and the visual matrix\n\nLevel 2. Adopted per component, and it is what turns \"I think that is all the\nstates\" into a list.\n\n## A variant is an axis, not a class name\n\n```ts\nimport {\n bg,\n craftStyles,\n defineStateAxis,\n palette,\n set,\n when,\n} from '@craft-ts/style';\n// `v` is your own sheet's typed variables, `bp` your own breakpoints — see\n// [Defining a design system](./define.md).\nimport { bp, v } from './foundation.style';\n\nexport const tone = defineStateAxis('tone', ['neutral', 'danger']);\n\nexport const badge = craftStyles('badge', {\n root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])],\n});\n```\n\nThe template sets **one static class** and a `data-tone` attribute. Nothing\nconcatenates a class at render time, which is what makes the set of states\nenumerable. The `no-raw-class` rule enforces it in every file.\n\nConjunction is nesting, and only nesting:\n\n```ts\nimport { fontWeight, scheme, when } from '@craft-ts/style';\n\nwhen(scheme.dark, [when(bp.md, [fontWeight.bold])]);\n```\n\nOne way to write each thing, so two identical components cannot produce two\ndifferent contracts.\n\n## Only the points you actually cross\n\n`bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes\n**two** cells, not four. The contract records what the sheet uses, never what the\naxis offers.\n\nAn interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` —\nthrows when the sheet is registered, which under the build plugin is a build\nfailure.\n\n## Hover, and every pseudo-class after it\n\n```ts\nwhen(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]);\n```\n\n`interaction.hover` emits the same `&:hover` rule you would write by hand.\nWhat it adds is that the point enters the class's contract — so the matrix\nenumerates the hovered state, and the\n[static contrast check](./contrast.md) crosses the colours it writes with the\ntext on top of them. A `:hover` typed into a string emits identical CSS and is\ninvisible to both, which is how a button ends up readable at rest and\nunreadable under the pointer. `prefer-hover-axis` refuses it.\n\nIt is a real axis with a real price: it doubles the sheet's matrix, so it has\nto be in the budget below. Its driver is `{ kind: 'selfState', state: 'hover' }`,\nand `applyScenario` honours it by asking the page to move a pointer — a\ndispatched `mouseover` sets no pseudo-class and would capture the base state\nwhile looking correct.\n\n## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size, interaction] })\n```\n\nAn axis outside the budget is a compile error naming it. Without this, an axis\nadded deep in a leaf shows up as a doubled capture bill three levels up and\nnobody decided that. A declared axis that goes unused is reported, not rejected.\n\n## The matrix\n\n```ts\nimport { visualMatrix, branch } from '@craft-ts/style-testing';\n\nvisualMatrix(card);\n// [{ id: 'base', … }, { id: 'viewport=md', … }]\n```\n\nIt takes **sheets**, not a component: a component's classes are only knowable by\nrendering it, and a matrix that silently missed a child's sheet would be the\nworst possible outcome.\n\nIdentifiers name only the axes away from `base`, so adding an axis elsewhere in\nthe app does not invalidate every baseline in the suite.\n\nTwo reductions are applied, and both are exactly true rather than probably true:\n\n- **A branch adds, it does not multiply.** The two sides of an `ifNode` are\n never on screen together, so declare it — `branch('footer', footerSheet)` —\n and the absent side stops carrying the footer's axes.\n- **A container axis stops at its owner.** An ancestor cannot change how wide\n that box is, so only the component naming the container keeps the axis.\n\nNothing else is reduced. A coverage that claims to be complete without being\ncomplete is worse than no coverage.\n"
491
511
  },
492
512
  {
493
513
  "path": "/guide/testing/architecture",
494
514
  "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"
515
+ "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\nThe default baseline also rejects event-only `craftMethod` wrappers through\n`assertNoEventOnlyCraftMethods`. It scans every TypeScript source file in the\napplication's graph project, so moving the wrapper to another file does not\navoid the rule. Use [`eventAction(...)`](/guide/components/directives#event-actions-and-dom-modifiers)\non the element to apply DOM event modifiers and invoke the action directly.\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 assertNoEventOnlyCraftMethods,\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 mergeStyleDump,\n} from '@craft-ts/dev-tools';\nimport { loadStyleDump } from '@craft-ts/style/vite';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport async 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 // The style half: every *.style.ts, evaluated by the same code the build\n // runs. The catalog stays built from the code graph alone.\n const styleDump = await loadStyleDump(join(workspaceRoot, 'apps/your-app/src'));\n return createArchitectureGraph(\n mergeStyleDump(graph, styleDump),\n architectureCatalog,\n );\n}\n```\n\nLoad it once in a `beforeAll(async () => { graph = await loadArchitectureGraph(); })`.\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).\nTo propose project-specific source folders, see the\n[folder layout organizer guide](/guide/testing/folder-layout).\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\nimport { architectureWaiverList } from './waivers';\n\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, {\n allow: ['logout'],\n waivers: architectureWaiverList,\n });\n});\n```\n\nThe aggregate includes four **style rules**. They make `@craft-ts/style` the\nonly way to style a component, and they need the style dump merged into the\ngraph (step 4):\n\n| rule | fails when |\n| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `style-only-design-system` | an element's class does not reach a sheet of a `*.style.ts` (a string, a computed class, a sheet declared elsewhere), or a component carries `meta.styles` or imports a `.css` |\n| `style-obligations-discharged` | a sheet `requires(...)` an obligation nothing `provides(...)` |\n| `no-dangling-css-vars` | a variable is read and never declared, or declared and never read (a read from `craftGlobalStyles` counts) |\n| `no-global-stylesheet` | an entry file imports a `.css`, or `index.html` links a stylesheet — the only one is `virtual:craft-style.css` |\n\nA component of pure composition, with no class at all, is not at fault. A class\npassed through an input typed `CraftClass` is accepted.\n\n### Waivers\n\nA deliberate bypass — a third-party widget, HTML rendered from markdown — is a\n**waiver**: a rule, a target, and the reason. Declare them in\n`architecture/waivers.ts`, typed against the catalog, so a target that does not\nexist does not compile:\n\n```typescript\nimport { defineArchitectureWaivers } from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nexport const architectureWaiverList = defineArchitectureWaivers(\n architectureCatalog,\n [\n {\n rule: 'style-only-design-system',\n target: 'MarkdownArticle',\n reason: 'The HTML rendered from markdown carries its own classes.',\n },\n ],\n);\n```\n\nThe target is a component name, `file:<path>`, `obligation:<id>`,\n`css-var:<name>`, or `'*'` for the whole rule — the only form a rule that\nchecks the whole graph (`no-dependency-cycles`, …) accepts, and the one a\nproject uses while it migrates. Two things keep the list honest: an **empty\nreason** is refused, and a waiver that no longer waives anything is **stale**\nand fails the check. Review Attest lists every waiver for a decision.\n\n`craft-architecture-check` reads the same file (statically, without running the\napp) and takes the dump the build wrote with `--style-dump <path>`.\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
516
  },
497
517
  {
498
518
  "path": "/guide/testing/architecture/computed-purity",
@@ -512,7 +532,7 @@
512
532
  {
513
533
  "path": "/guide/testing/architecture/declarative-baseline",
514
534
  "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"
535
+ "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
536
  },
517
537
  {
518
538
  "path": "/guide/testing/architecture/dependency-cycles",
@@ -539,6 +559,11 @@
539
559
  "title": "Named interactive elements",
540
560
  "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
561
  },
562
+ {
563
+ "path": "/guide/testing/architecture/metric-thresholds",
564
+ "title": "Metric thresholds",
565
+ "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"
566
+ },
542
567
  {
543
568
  "path": "/guide/testing/architecture/mutation-reactions",
544
569
  "title": "Mutations must have a read-side reaction",
@@ -602,7 +627,7 @@
602
627
  {
603
628
  "path": "/guide/testing/components",
604
629
  "title": "Testing components",
605
- "body": "# Testing components\n\nCraft components are tested in two independent halves: the **logic factory**\n(plain values, no DOM) and the **template** (real DOM, explicit locators). You\ncan test one without paying for the other.\n\n**Use the logic test** for what the factory computes and exposes.\n**Use the template test** for what actually renders, and for interaction.\n\nThe utilities live in a dedicated submodule:\n\n```ts\nimport {\n setupCraftComponentLogicTest,\n setupCraftComponentTemplateTest,\n setupCraftDirectiveLogicTest,\n setupCraftDirectiveTemplateTest,\n} from '@craft-ts/component/testing';\n```\n\nThey deliberately separate the factory from rendering. Each utility also\nexposes a `.byRegister(...)` form, which makes the services used by the tested\ncode explicit.\n\n## Component logic\n\nThe logic test executes only the factory and returns its context together with\nthe installed mocks:\n\n```ts\nconst { context, mocks, destroy } =\n await setupCraftComponentLogicTest.byRegister(FullDemoCraft, {\n register: {\n TodoStore: {\n todos: {\n status: () => 'resolved',\n value: () => [],\n },\n },\n },\n });\n\nexpect(context.store.todos.value()).toEqual([]);\nexpect(mocks.TodoStore).toBeDefined();\ndestroy();\n```\n\nFactory arguments can be provided through `args` when the component declares\ninputs:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(StatusComponent, {\n args: [statusInput],\n register: {},\n});\n```\n\n## Component template\n\nThe template test receives an already-built context. The component logic is not\nexecuted:\n\n```ts\nconst test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {\n context: { status: () => 'resolved' },\n register: {},\n});\n\nexpect(test.nativeElement.textContent).toContain('Loaded');\ntest.detectChanges();\ntest.updateContext({ status: () => 'error' });\nexpect(test.nativeElement.textContent).toContain('Error');\ntest.destroy();\n```\n\nThe result exposes `nativeElement`, `element`, `mocks`, `detectChanges`,\n`updateContext`, and `destroy`. Craft styles, child components, Craft\ndirectives, and reactivity are rendered by the normal renderer.\n\n### Explicit DOM locators\n\nTemplate tests also expose `locator(tag, criteria)`. The tag determines the\nDOM element type, while `class`, `data-*`, and `aria-*` criteria are matched\nagainst the rendered element:\n\n\n\n\nThe notation `tag('name', props, children)` is generic: `tag` means the HTML\nhelper for the element you want. There is no separate `tag` function. For a\nbutton, write the three arguments explicitly:\n\n```ts\nconst saveButton = button(\n 'save', // name: stable local name\n { class: 'save' }, // props: DOM properties and attributes\n 'Save', // children: rendered content\n);\n```\n\nThe same pattern works with every built-in helper:\n\n```ts\nimport { input } from '@craft-ts/component';\n\nconst searchInput = input('search', { 'aria-label': 'Search' }, []);\n```\n\nThe name is rendered as `data-craft-name=\"save\"` and can be used as a\ncomplementary named locator when a class is not sufficiently discriminating.\n\n### Locating branded content\n\nWhen an element directly renders a branded Craft value, use the brand name as\nthe `content` criterion. The locator does not inspect the rendered value, so\nthis also works for non-text values and remains independent of formatting:\n\n```typescript\nimport { craftSignal as signal } from '@craft-ts/core';\nimport { span, craftComponent } from '@craft-ts/component';\nimport { markYieldableValue, state } from '@craft-ts/core';\n\nconst Status = craftComponent(\n 'Status',\n {},\n function* () {\n const brandedStatus = yield* state('brandedStatus', 'ready');\n return { brandedStatus };\n },\n ({ brandedStatus }) => span(brandedStatus),\n);\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Status, {\n context: {\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nexpect(brandedStatusElement.textContent).toBe('ready');\ntest.destroy();\n```\n\n\n\nThis template has no `ifNode`, `forNode`, or `deferNode`, so\n`brandedStatusElement` is an `HTMLSpanElement`, never `undefined`; optional\nchaining is not needed here.\n\nThe brand name is part of the template type. An unknown value such as\n`{ content: 'missing' }` is rejected by TypeScript. The return type is the\ninferred DOM type when the element is always rendered. Under `ifNode`, `forNode`,\nor `deferNode`, it is `MaybeDefined<HTMLSpanElement>` (equivalent to\n`HTMLSpanElement | undefined`), so callers must handle the absent branch.\n\nUse static, discriminating markers for locators. A literal class or attribute\ndeclared in the template is a stable proof; a value produced by a binding is\nnot. Attributes declared through `attrs` are queried using their rendered\nattribute name:\n\n```ts\ninput({ attrs: { 'aria-label': 'Search' } });\ntest.locator('input', { 'aria-label': 'Search' });\n```\n\nThe locator searches the complete rendered subtree, including Craft child\ncomponents. A branch that is currently absent returns `undefined`; a runtime\nresult with more than one matching element throws an explicit cardinality\nerror. Call the locator again after `updateContext` and `detectChanges` when a\nconditional branch changes.\n\nWhen a class is not sufficiently discriminating, keep using the existing\nnamed locators (`tag('name', props, children)`) and query their\n`data-craft-name` marker. A future collection API will cover repeated targets;\nthe singular locator should remain reserved for one expected element.\n\nTo verify that a DOM property is connected to the correct context member, add a\ncontract assertion next to the template test:\n\n\n\n\nTypeScript performs this check. It fails if the branded `counter.disabled` read\nis no longer exposed by the rendered template. It does not replace the\nrendering test; it verifies the template contract without a DOM.\n\n## Context and service dependencies\n\nThe `context` is a factory value and is not a registry dependency. In this\nexample, `store` is provided directly to the template:\n\n```ts\nawait setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {\n context: { store: todoStoreMock },\n register: {},\n});\n```\n\nConversely, if `StatusComponent` or a child component uses a\n`FormatterService`, the template registry contains `FormatterService`, never\nthe child component:\n\n```ts\nregister: {\n FormatterService: formatterMock,\n}\n```\n\nThe `CraftComponentLogicDepsOf<Component>` and\n`CraftComponentTemplateDepsOf<Component>` projections keep these two graphs\nseparate. A template registry therefore accepts only services; child components\nare never entries in `register`.\n\n## Registry values and providers\n\nResolution follows the same rules as service tests:\n\n- an object is a mock and is available in `mocks`;\n- `'real'` keeps the real service;\n- `'notReached'` documents a branch removed by a parent mock;\n- `'provided'` requests the value provided by the parent injector;\n- a `provideX(...)` provider explicitly configures a service.\n\nProviders declared in `meta.providers` are available in the component scope.\nUpstream providers go in `providers`:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(Component, {\n providers: [provideApiService({ baseUrl: '/test' })],\n register: {\n ApiService: 'provided',\n },\n});\n```\n\n`appStart` decisions (`'run'` or `'ignore'`) are available in the options when\nthe tested graph contains a service with `appStart: true`.\n\n## Testing a directive\n\nDirective logic receives its `baseLogic` and arguments explicitly:\n\n```ts\nconst { context } = await setupCraftDirectiveLogicTest.byRegister(\n hasPermissionInput,\n {\n baseLogic,\n args: [userInput, permissionInput],\n register: {},\n },\n);\n```\n\nFor the template, provide `baseTemplate` and the final context:\n\n```ts\nconst test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {\n baseTemplate: (context) => p(context.message()),\n context: { when: () => true, message: () => 'ready' },\n register: {},\n});\n\ntest.updateContext({ when: () => false, message: () => 'hidden' });\ntest.destroy();\n```\n\nStructural directives follow the same path and can verify that rendering is\nreplaced with `[]`. Calling `destroy()` cleans up views, injectors, listeners,\nand acquired styles.\n\n## Type-level tests\n\nThe template's contract can also be checked **without rendering anything** —\nthat an element only appears under a condition, that a binding is really the one\nyou think, that a list item renders its label. That is its own page:\n**[Type-level tests](/guide/testing/type-level)**.\n\n## See Also\n\n- [Testing services](/guide/testing/services)\n- [Browser boundaries](/guide/testing/browser-boundaries)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Routing setup](/guide/routing/setup) — where `GenDeps_*` comes from\n"
630
+ "body": "# Testing components\n\nCraft components are tested in two independent halves: the **logic factory**\n(plain values, no DOM) and the **template** (real DOM, explicit locators). You\ncan test one without paying for the other.\n\n**Use the logic test** for what the factory computes and exposes.\n**Use the template test** for what actually renders, and for interaction.\n\nThe utilities live in a dedicated submodule:\n\n```ts\nimport {\n setupCraftComponentLogicTest,\n setupCraftComponentTemplateTest,\n setupCraftDirectiveLogicTest,\n setupCraftDirectiveTemplateTest,\n} from '@craft-ts/component/testing';\n```\n\nThey deliberately separate the factory from rendering. Each utility also\nexposes a `.byRegister(...)` form, which makes the services used by the tested\ncode explicit.\n\n## Component logic\n\nThe logic test executes only the factory and returns its context together with\nthe installed mocks:\n\n```ts\nconst { context, mocks, destroy } =\n await setupCraftComponentLogicTest.byRegister(FullDemoCraft, {\n register: {\n TodoStore: {\n todos: {\n status: () => 'resolved',\n value: () => [],\n },\n },\n },\n });\n\nexpect(context.store.todos.value()).toEqual([]);\nexpect(mocks.TodoStore).toBeDefined();\ndestroy();\n```\n\nFactory arguments can be provided through `args` when the component declares\ninputs:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(StatusComponent, {\n args: [statusInput],\n register: {},\n});\n```\n\n## Component template\n\nThe template test receives an already-built context. The component logic is not\nexecuted:\n\n```ts\nconst test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {\n context: { status: () => 'resolved' },\n register: {},\n});\n\nexpect(test.nativeElement.textContent).toContain('Loaded');\ntest.detectChanges();\ntest.updateContext({ status: () => 'error' });\nexpect(test.nativeElement.textContent).toContain('Error');\ntest.destroy();\n```\n\nThe result exposes `nativeElement`, `element`, `mocks`, `detectChanges`,\n`updateContext`, and `destroy`. Craft styles, child components, Craft\ndirectives, and reactivity are rendered by the normal renderer.\n\n### Explicit DOM locators\n\nTemplate tests also expose `locator(tag, criteria)`. The tag determines the\nDOM element type, while `class`, `data-*`, and `aria-*` criteria are matched\nagainst the rendered element. Prefer `data-*` and `aria-*`: a class comes from a\nsheet and is a list of atoms, not a name a test should depend on.\n\n\n\n\n\nThe notation `tag('name', props, children)` is generic: `tag` means the HTML\nhelper for the element you want. There is no separate `tag` function. For a\nbutton, write the three arguments explicitly:\n\n```ts\nconst saveButton = button(\n 'save', // name: stable local name\n { type: 'button' }, // props: DOM properties and attributes\n 'Save', // children: rendered content\n);\n```\n\nThe same pattern works with every built-in helper:\n\n```ts\nimport { input } from '@craft-ts/component';\n\nconst searchInput = input('search', { 'aria-label': 'Search' }, []);\n```\n\nThe name is rendered as `data-craft-name=\"save\"` and can be used as a\ncomplementary named locator when a class is not sufficiently discriminating.\n\n### Locating branded content\n\nWhen an element directly renders a branded Craft value, use the brand name as\nthe `content` criterion. The locator does not inspect the rendered value, so\nthis also works for non-text values and remains independent of formatting:\n\n```typescript\nimport { craftSignal as signal } from '@craft-ts/core';\nimport { span, craftComponent } from '@craft-ts/component';\nimport { markYieldableValue, state } from '@craft-ts/core';\n\nconst Status = craftComponent(\n 'Status',\n {},\n function* () {\n const brandedStatus = yield* state('brandedStatus', 'ready');\n return { brandedStatus };\n },\n ({ brandedStatus }) => span(brandedStatus),\n);\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Status, {\n context: {\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nexpect(brandedStatusElement.textContent).toBe('ready');\ntest.destroy();\n```\n\n\n\nThis template has no `ifNode`, `forNode`, or `deferNode`, so\n`brandedStatusElement` is an `HTMLSpanElement`, never `undefined`; optional\nchaining is not needed here.\n\nThe brand name is part of the template type. An unknown value such as\n`{ content: 'missing' }` is rejected by TypeScript. The return type is the\ninferred DOM type when the element is always rendered. Under `ifNode`, `forNode`,\nor `deferNode`, it is `MaybeDefined<HTMLSpanElement>` (equivalent to\n`HTMLSpanElement | undefined`), so callers must handle the absent branch.\n\nUse static, discriminating markers for locators. A literal class or attribute\ndeclared in the template is a stable proof; a value produced by a binding is\nnot. Attributes declared through `attrs` are queried using their rendered\nattribute name:\n\n```ts\ninput({ attrs: { 'aria-label': 'Search' } });\ntest.locator('input', { 'aria-label': 'Search' });\n```\n\nThe locator searches the complete rendered subtree, including Craft child\ncomponents. A branch that is currently absent returns `undefined`; a runtime\nresult with more than one matching element throws an explicit cardinality\nerror. Call the locator again after `updateContext` and `detectChanges` when a\nconditional branch changes.\n\nWhen a class is not sufficiently discriminating, keep using the existing\nnamed locators (`tag('name', props, children)`) and query their\n`data-craft-name` marker. A future collection API will cover repeated targets;\nthe singular locator should remain reserved for one expected element.\n\nTo verify that a DOM property is connected to the correct context member, add a\ncontract assertion next to the template test:\n\n\n\n\nTypeScript performs this check. It fails if the branded `counter.disabled` read\nis no longer exposed by the rendered template. It does not replace the\nrendering test; it verifies the template contract without a DOM.\n\n## Context and service dependencies\n\nThe `context` is a factory value and is not a registry dependency. In this\nexample, `store` is provided directly to the template:\n\n```ts\nawait setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {\n context: { store: todoStoreMock },\n register: {},\n});\n```\n\nConversely, if `StatusComponent` or a child component uses a\n`FormatterService`, the template registry contains `FormatterService`, never\nthe child component:\n\n```ts\nregister: {\n FormatterService: formatterMock,\n}\n```\n\nThe `CraftComponentLogicDepsOf<Component>` and\n`CraftComponentTemplateDepsOf<Component>` projections keep these two graphs\nseparate. A template registry therefore accepts only services; child components\nare never entries in `register`.\n\n## Registry values and providers\n\nResolution follows the same rules as service tests:\n\n- an object is a mock and is available in `mocks`;\n- `'real'` keeps the real service;\n- `'notReached'` documents a branch removed by a parent mock;\n- `'provided'` requests the value provided by the parent injector;\n- a `provideX(...)` provider explicitly configures a service.\n\nProviders declared in `meta.providers` are available in the component scope.\nUpstream providers go in `providers`:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(Component, {\n providers: [provideApiService({ baseUrl: '/test' })],\n register: {\n ApiService: 'provided',\n },\n});\n```\n\n`appStart` decisions (`'run'` or `'ignore'`) are available in the options when\nthe tested graph contains a service with `appStart: true`.\n\n## Testing a directive\n\nDirective logic receives its `baseLogic` and arguments explicitly:\n\n```ts\nconst { context } = await setupCraftDirectiveLogicTest.byRegister(\n hasPermissionInput,\n {\n baseLogic,\n args: [userInput, permissionInput],\n register: {},\n },\n);\n```\n\nFor the template, provide `baseTemplate` and the final context:\n\n```ts\nconst test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {\n baseTemplate: (context) => p(context.message()),\n context: { when: () => true, message: () => 'ready' },\n register: {},\n});\n\ntest.updateContext({ when: () => false, message: () => 'hidden' });\ntest.destroy();\n```\n\nStructural directives follow the same path and can verify that rendering is\nreplaced with `[]`. Calling `destroy()` cleans up views, injectors, listeners,\nand acquired styles.\n\n## Type-level tests\n\nThe template's contract can also be checked **without rendering anything** —\nthat an element only appears under a condition, that a binding is really the one\nyou think, that a list item renders its label. That is its own page:\n**[Type-level tests](/guide/testing/type-level)**.\n\n## See Also\n\n- [Testing services](/guide/testing/services)\n- [Browser boundaries](/guide/testing/browser-boundaries)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Routing setup](/guide/routing/setup) — where `GenDeps_*` comes from\n"
606
631
  },
607
632
  {
608
633
  "path": "/guide/testing/craft-graph-vs-nx",
@@ -612,7 +637,17 @@
612
637
  {
613
638
  "path": "/guide/testing/extensible-architecture-graph",
614
639
  "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"
640
+ "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"
641
+ },
642
+ {
643
+ "path": "/guide/testing/folder-layout",
644
+ "title": "Folder layout organizer",
645
+ "body": "# Folder layout organizer\n\n`craft organize` analyzes a fresh Craft dependency graph and proposes a folder\nlayout. It does not move files. The default placement follows route ownership\nand application-shell reachability; project-specific rules can add known\narchitectural constraints without changing those defaults for other projects.\n\n## Tune an organizer run\n\nThe programmatic entry point is `organizeProject`, exported by\n`@craft-ts/dev-tools` and `@craft-ts/dev-tools/folder-layout`:\n\n```ts\nimport { organizeProject } from '@craft-ts/dev-tools/folder-layout';\n\norganizeProject({\n project: 'apps/shop/tsconfig.graph.json',\n graph: 'craft-dependency-graph.json',\n out: 'apps/shop/folder-layout',\n weights: { calls: 4 },\n thresholds: { maxDepth: 2 },\n placementRules: [\n {\n id: 'app-start-services',\n when: {\n nodeKind: 'service',\n nodeDetails: { appStart: true },\n },\n scope: 'core',\n folder: 'core/app-start',\n reason: 'Runs during application bootstrap.',\n },\n ],\n});\n```\n\n`weights` adjusts the relative influence of graph relations. `thresholds`\ncontrols the maximum proposed feature depth and the fan-in/fan-out values that\nmark hubs. Omitted values keep their defaults.\n\n## Keep project rules in JSON\n\nThe CLI accepts the same `weights`, `thresholds`, and `placementRules` in a\nJSON file passed with `--config`:\n\n```json\n{\n \"placementRules\": [\n {\n \"id\": \"app-start-services\",\n \"when\": {\n \"nodeKind\": \"service\",\n \"nodeDetails\": { \"appStart\": true }\n },\n \"scope\": \"core\",\n \"folder\": \"core/app-start\",\n \"reason\": \"Runs during application bootstrap.\"\n }\n ]\n}\n```\n\n```bash\ncraft organize \\\n --project apps/shop/tsconfig.graph.json \\\n --graph craft-dependency-graph.json \\\n --config apps/shop/organizer.config.json \\\n --out apps/shop/folder-layout\n```\n\nThe organizer evaluates rules in array order, before its default ownership\nclassification. A rule matches a file when at least one Craft graph node in\nthat file has the selected `nodeKind` and all selected `nodeDetails` values.\nThe first matching rule sets the file's scope and destination. `folder` is\nrelative to the organizer's target root, which defaults to the app's `src/`\nfolder when present.\n\nRules need a unique `id`, a `nodeKind`, optional `nodeDetails`, a scope\n(`feature-local`, `parent-shared`, `global-shared`, `core`, or `unresolved`), a\nsafe relative `folder`, and a human-readable `reason`. In TypeScript,\n`nodeKind` narrows `nodeDetails`; for `service`, keys and values are checked\nagainst the service metadata (`appStart` and `browserBoundary` are booleans).\nThe JSON CLI validates known service detail keys and value types at runtime.\nThe reason appears with the file's proposal. Resolved rules are written to\n`folder-layout-analysis.json` and contribute to the proposal's `configHash`, so\nchanging policy produces a distinct review artifact. A matching explicit rule\nhas confidence `1`; destination collisions still require review.\n\n### File-level behavior\n\nThe organizer proposes moves for whole files. If one service in a file matches\na rule, the entire file receives that placement. Keep declarations with\ndifferent folder policies in separate files. If several rules match a file,\nthe first rule wins; put narrower matchers first.\n\n## Demo policy: app-start services\n\nThe dependency graph records `appStart: true` on Craft service nodes. The demo\nuses that fact in `apps/demo/organizer.config.json` to place matching source\nfiles under `src/core/app-start/`. This policy is passed only by the demo's\n`attest:demo:folder-layout` command, so it does not change the organizer's\ndefaults for other applications.\n\nTo regenerate the proposal:\n\n```bash\nnpm run attest:demo:folder-layout\n```\n\nThe command refreshes the read-only analysis and proposal artifacts. Apply a\nproposal separately with `craft organize apply` after reviewing it.\n"
646
+ },
647
+ {
648
+ "path": "/guide/testing/graph-insights",
649
+ "title": "Graph insights",
650
+ "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 = query(…)`).\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
651
  },
617
652
  {
618
653
  "path": "/guide/testing/services",
@@ -672,7 +707,7 @@
672
707
  {
673
708
  "path": "/learn-effect/07-forms-validation",
674
709
  "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"
710
+ "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
711
  },
677
712
  {
678
713
  "path": "/learn-effect/08-testing",
@@ -687,12 +722,12 @@
687
722
  {
688
723
  "path": "/learn/01-first-state",
689
724
  "title": "1. Your first state",
690
- "body": "# 1. Your first state\n\n**Goal:** get a reactive value on screen, and meet the two building blocks you\nwill use in every step — `craftComponent` and a primitive.\n\n## Install\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nThe packages are currently published on the `beta` channel. The component\npackage contains the functional renderer, while `core` contains the reactive\nprimitives used by the component factory.\n\n## A component with state\n\nA Craft component is a **function**, not a class. It takes a name, meta, a logic\nfactory, and a template:\n\n\n\nFour arguments, and each has one job:\n\n| Argument | What it is |\n| ------------ | ----------------------------------------------------------- |\n| `'Tasks'` | the component's name — used by the tooling and by host tags |\n| `{}` | meta: providers, styles, host properties (empty for now) |\n| `function*` | the **logic factory** — builds and returns the context |\n| `({ … }) =>` | the **template** — receives that context, returns nodes |\n\nThere is no class, no decorator, no separate HTML file, and no host element\nwrapped around your markup.\n\n## Inputs and outputs\n\nA component's inputs and outputs are just **parameters of the logic factory**,\ntyped with `Input<T>` and `Output<Handler>`:\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. Project nested fields with `deepYieldable` so `user.name` stays a\nreader. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nAt the call site you pass the reader itself, not a getter:\n\n```typescript\nUserCard({\n user: currentUser,\n onRemove: removeUser,\n});\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\nBecause it's a function call, there is no template-binding layer between caller\nand component: a wrong input name or type is a plain TypeScript error.\n\n## Styling the component\n\nStyles go in the meta, and `:scope` is the component's own root:\n\n```typescript\ncraftComponent(\n 'Tasks',\n {\n styles: `\n :scope { display: grid; gap: .5rem }\n .done { text-decoration: line-through }\n `,\n },\n /* … */\n);\n```\n\n`:scope` refers to **the root of this component**. Component styles are scoped\nwith CSS `@scope`, so the rule cannot leak into unrelated components — and Craft\nadds no host element or wrapper around your markup to achieve it. See\n[Encapsulated styles](/guide/components/styles).\n\n## Mounting the root\n\nThe app's root is a Craft component too. `provideCraftRootComponent(App)`\ndesignates it, and the Craft host bootstraps the application:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app/app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>`.\n\n## The two rules of a primitive\n\n**1. A primitive is named.** `state('tasks', …)` — the first argument is always\nthe name. It is not decoration: it tags the primitive's injector (`state:tasks`)\nand is what identifies this piece of state in logs, snapshots and observability.\n\n**2. It resolves to the state reference itself**:\n\n```typescript\nconst tasks = yield * state('tasks', []);\n```\n\n`tasks` is a yieldable reader: `yield* tasks()` in a generator, `craftUse(tasks())`\nat a synchronous boundary, or pass `tasks` directly to a template binding.\n\n## What is `yield*` doing there?\n\nThe factory is a generator, and `yield*` is how **this** factory drives\neverything it does not own — primitives and services alike. The same rule\napplies later to every computed and method: each entity yields its own\ndependencies so they show up on **its** graph.\n\nFor now, treat it as \"the way to use a primitive inside a factory\".\n[Step 4](/learn/04-compose) explains what it buys you.\n\n## The template\n\nThe template is a plain function returning nodes built with hyperscript helpers\n— `div`, `ul`, `li`, `button`, and one `h(tag, …)` escape hatch for anything\nwithout a helper:\n\n```typescript\n({ tasks }) => [\n h1('Tasks'),\n ul(\n forNode(tasks, { track: (task) => task.id }, (task) => li(task.title)),\n ),\n];\n```\n\nPass the reader (`tasks`) to the binding that consumes it. The renderer drives\nthe read; wrapping `() => tasks()` is a synchronous call the yield rules reject.\nUse `forNode(...)` when the collection controls a node per item. No `*ngFor`, no\nchange detection to think about.\n\n## Writing to it\n\nRight now the state is read-only from the outside. Give it a writer:\n\n```typescript\nconst tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set }));\n\nyield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]);\n```\n\nThat third argument is an **insertion** — the mechanism you'll use in every step\nfrom here on. Step 2 is entirely about it.\n\n## What you gained\n\nA component and a reactive value, both declared as functions, both named, both\nvisible to the tooling — with no class, no constructor and no subscription.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn/)\n\n[2. Derive instead of duplicate →](/learn/02-derive)\n\n</div>\n"
725
+ "body": "# 1. Your first state\n\n**Goal:** get a reactive value on screen, and meet the two building blocks you\nwill use in every step — `craftComponent` and a primitive.\n\n## Install\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nThe packages are currently published on the `beta` channel. The component\npackage contains the functional renderer, while `core` contains the reactive\nprimitives used by the component factory.\n\n## A component with state\n\nA Craft component is a **function**, not a class. It takes a name, meta, a logic\nfactory, and a template:\n\n\n\nFour arguments, and each has one job:\n\n| Argument | What it is |\n| ------------ | ----------------------------------------------------------- |\n| `'Tasks'` | the component's name — used by the tooling and by host tags |\n| `{}` | meta: providers, styles, host properties (empty for now) |\n| `function*` | the **logic factory** — builds and returns the context |\n| `({ … }) =>` | the **template** — receives that context, returns nodes |\n\nThere is no class, no decorator, no separate HTML file, and no host element\nwrapped around your markup.\n\n## Inputs and outputs\n\nA component's inputs and outputs are just **parameters of the logic factory**,\ntyped with `Input<T>` and `Output<Handler>`:\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. Project nested fields with `deepYieldable` so `user.name` stays a\nreader. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nAt the call site you pass the reader itself, not a getter:\n\n```typescript\nUserCard({\n user: currentUser,\n onRemove: removeUser,\n});\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\nBecause it's a function call, there is no template-binding layer between caller\nand component: a wrong input name or type is a plain TypeScript error.\n\n## Styling the component\n\nStyles go in a sheet beside the component — `tasks.style.ts` — written with\n`@craft-ts/style`:\n\n```typescript\nimport {\n craftStyles,\n defineStateAxis,\n display,\n gap,\n space,\n textDecorationLine,\n when,\n} from '@craft-ts/style';\n\nexport const taskState = defineStateAxis('task', ['done']);\n\nexport const tasks = craftStyles('tasks', {\n root: [display.grid, gap(space(2))],\n item: [when(taskState.done, [textDecorationLine.lineThrough])],\n});\n```\n\nThe template binds one constant class per element and says which state it is\nin with an attribute — `li({ class: tasks.item, 'data-task': 'done' }, …)`.\nA class is never built at render time: that is how every visual state stays\nlisted in the sheet. The build emits the CSS once; nothing is injected when the\ncomponent mounts. See [Styling a component](/guide/components/styles).\n\n## Mounting the root\n\nThe app's root is a Craft component too. `provideCraftRootComponent(App)`\ndesignates it, and the Craft host bootstraps the application:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app/app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>`.\n\n## The two rules of a primitive\n\n**1. A primitive is named.** `state('tasks', …)` — the first argument is always\nthe name. It is not decoration: it tags the primitive's injector (`state:tasks`)\nand is what identifies this piece of state in logs, snapshots and observability.\n\n**2. It resolves to the state reference itself**:\n\n```typescript\nconst tasks = yield * state('tasks', []);\n```\n\n`tasks` is a yieldable reader: `yield* tasks()` in a generator, `craftUse(tasks())`\nat a synchronous boundary, or pass `tasks` directly to a template binding.\n\n## What is `yield*` doing there?\n\nThe factory is a generator, and `yield*` is how **this** factory drives\neverything it does not own — primitives and services alike. The same rule\napplies later to every computed and method: each entity yields its own\ndependencies so they show up on **its** graph.\n\nFor now, treat it as \"the way to use a primitive inside a factory\".\n[Step 4](/learn/04-compose) explains what it buys you.\n\n## The template\n\nThe template is a plain function returning nodes built with hyperscript helpers\n— `div`, `ul`, `li`, `button`, and one `h(tag, …)` escape hatch for anything\nwithout a helper:\n\n```typescript\n({ tasks }) => [\n h1('Tasks'),\n ul(\n forNode(tasks, { track: (task) => task.id }, (task) => li(task.title)),\n ),\n];\n```\n\nPass the reader (`tasks`) to the binding that consumes it. The renderer drives\nthe read; wrapping `() => tasks()` is a synchronous call the yield rules reject.\nUse `forNode(...)` when the collection controls a node per item. No `*ngFor`, no\nchange detection to think about.\n\n## Writing to it\n\nRight now the state is read-only from the outside. Give it a writer:\n\n```typescript\nconst tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set }));\n\nyield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]);\n```\n\nThat third argument is an **insertion** — the mechanism you'll use in every step\nfrom here on. Step 2 is entirely about it.\n\n## What you gained\n\nA component and a reactive value, both declared as functions, both named, both\nvisible to the tooling — with no class, no constructor and no subscription.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn/)\n\n[2. Derive instead of duplicate →](/learn/02-derive)\n\n</div>\n"
691
726
  },
692
727
  {
693
728
  "path": "/learn/02-derive",
694
729
  "title": "2. Derive instead of duplicate",
695
- "body": "# 2. Derive instead of duplicate\n\n**Goal:** attach methods and derived values to your state, instead of scattering\nthem across the component.\n\n## The insertion argument\n\nThe last argument of a primitive is an **insertion**: a function that receives\nthe primitive's internals and returns whatever you want exposed on it.\n\n\n\nEverything you return is now on the ref:\n\n```typescript\nyield* tasks(); // the array\nyield* tasks.add('Learn insertions');\nyield* tasks.remaining(); // 1\n```\n\nThe context gives you `state` (the current value as a yieldable reader), `set`\nand `update`. Non-generator insertion methods may return `update(...)` directly\n— the wrapper consumes the write. `remaining` is a `craftComputed`: it does not\nown `state()`, so it yields it. That is how the computed's own dependency graph\nrecords the read.\n\n## The whole component\n\n```typescript\nimport {\n button,\n craftComponent,\n forNode,\n h1,\n input,\n li,\n ul,\n} from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* state('tasks', [] as Task[], /* … as above … */);\n return { tasks };\n },\n ({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n\n input({\n type: 'text',\n placeholder: 'New task…',\n *keydown(event) {\n if (event.key !== 'Enter') return;\n const field = event.target as HTMLInputElement;\n yield* tasks.add(field.value);\n field.value = '';\n },\n }),\n\n ul(\n forNode(\n tasks,\n { track: (task) => task.id, empty: () => li('Nothing to do 🎉') },\n (task) =>\n li([\n input({\n type: 'checkbox',\n checked: task.done,\n *change() {\n yield* tasks.toggle(task.id);\n },\n }),\n task.title,\n button({\n *click() {\n yield* tasks.remove(task.id);\n },\n }, '×'),\n ]),\n ),\n ),\n ],\n);\n```\n\nTwo template things worth noting. `forNode(source, options, render)` takes a\n`track` — the stable identity the renderer uses to reuse, move and remove\nnodes — and an optional `empty` branch. Pass the reader itself (`tasks`) rather\nthan `() => tasks()`. When a binding must format or call a method, use a\ngenerator and `yield*`.\n\nThe logic factory is now three lines. That's the point: **behaviour lives on the\nstate, not around it.**\n\n## Control flow\n\nCraft templates are TypeScript, so control flow is made of functions rather than\nsyntax. Each block is a typed function with an explicit contract:\n\n| Block | Purpose |\n| --- | --- |\n| `forNode` | Renders a collection with stable tracking and an optional empty branch |\n| `ifNode` | Preserves a conditional branch in the render contract |\n| `matchNode.exhaustive` | Matches every member of a discriminated union |\n| `deferNode` | Loads a branch lazily |\n\n`matchNode.exhaustive` matches on a **discriminant key** of a union and the\nhandler map must cover every member — a missing case is a compile error.\n\n```typescript\nmatchNode.exhaustive(() => tasksQuery.exceptions().loader, '_tag', {\n TASK_NOT_FOUND: () => p('This task no longer exists.'),\n TASK_FORBIDDEN: () => p('You do not have access to it.'),\n});\n```\n\n### Why not a plain ternary or `switch`?\n\nBecause a raw TypeScript conditional **collapses**. The template type ends up\nholding the *result* of the branch, not the fact that a branch existed:\n\n```typescript\n// works at runtime, but the contract is now opaque\ntasks.isEmpty() ? p('Nothing to do') : ul(/* … */);\n```\n\n`ifNode` and `matchNode` keep the condition **and both branches** in the node\ncontract. That is what lets you assert, at compile time, that an element renders\n*only* when a condition holds, or that a label renders for every item of a\nnon-empty list — see [Type-level tests](/guide/testing/type-level). With a\nternary those assertions have nothing to inspect.\n\nThe renderer also uses the block structure to update surgically instead of\nrebuilding the subtree.\n\n::: tip When a ternary is fine\nFor a leaf value — a class name, a piece of text, an attribute — a ternary is\nthe right tool. The rule concerns **structure**: whenever a branch decides\nwhether an element exists, reach for `ifNode` or `matchNode`.\n:::\n\n`ifNode` takes a **named** reactive value as its condition (a primitive ref, or\na value marked with `markYieldableValue`), because that name is what the\nvisibility contract records.\n\n## Reusing behaviour across components\n\nAn insertion factors logic out of a **primitive**. Its counterpart for\n**components** is a directive: `craftDirective` decorates both a component's\nlogic factory and its template, and you attach it with `.pipe(...)`:\n\n```typescript\nexport const Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => div(user.name),\n).pipe(InteractivePermissions);\n```\n\nThe directive can add to the context the template receives — here a\n`permissions` object the component never had to declare — and directives compose\nleft to right. That is how a tooltip, focus management or interaction analytics\nget added to several components without any of them knowing about it.\n\nThe full pattern — writing a directive, what it can require from its host, and\nhow styles compose — is on\n[Directives and `.pipe(...)`](/guide/components/directives). See also\n[Customization](/guide/components/customization) for the three layers of\ncomponent customization, and [Encapsulated styles](/guide/components/styles).\n\n## Every exception a component picks up must be handled\n\nIf a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's contract. It has to\nbe dealt with, and the compiler is the one that says so:\n\n```typescript\nexport const Restricted = MyComponent.pipe(\n catchNode.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\n`catchNode.exhaustive` is the one you want most of the time: it renders a\n**fallback**. When the failure happens in the factory or a provider — before the\ntemplate exists — the fallback simply renders alone.\n\nHandle it here and the code disappears from the contract. Leave it and it flows\nup to the route, where `handleExceptions` **must** cover it — a reachable code\nwith no handler doesn't compile, and neither does a handler for a code nothing\ncan produce.\n\n::: warning Where the error actually lands today\nThe compile-time enforcement is at the **route**\n(`assertExhaustiveRouteExceptions`). The component `.pipe(...)` overload is\ncurrently kept permissive to avoid excessive TypeScript instantiation depth, so\nan unhandled code there is caught by runtime dispatch instead. Practical\nconsequence: a component rendered outside any route gets no compile-time\nreminder — handle its codes explicitly.\n\nThe whole rule is on [An unhandled exception doesn't just\ndisappear](/guide/concepts/exceptions).\n:::\n\n`matchNode.exhaustive` is the sibling for rendering from an exception *value*\nor signal. Reach for `catchTag.exhaustive` only when the reaction is pure\nlogic — a toast, a log — and produces no DOM.\n\n## Several insertions at once\n\nOne insertion function gets crowded fast. Split it and compose with `insertStatePipe`:\n\n\n\nEach function in the pipe receives the same context and contributes its own\nslice. This is what makes behaviour **reusable**: an insertion is just a\nfunction, so it can be extracted, parameterised and shared.\n\n::: tip That's what \"insertions\" are\nThe library ships ready-made ones — storage persistence, optimistic updates,\npagination placeholders, forms. They are the exact same shape as the functions\nyou just wrote. See [Insertions](/guide/concepts/insertions).\n:::\n\n## What you gained\n\nState that carries its own behaviour, a template that only renders, and a\ncomposition mechanism that scales past the first three methods.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Your first state](/learn/01-first-state)\n\n[3. Move logic out of the component →](/learn/03-service)\n\n</div>\n"
730
+ "body": "# 2. Derive instead of duplicate\n\n**Goal:** attach methods and derived values to your state, instead of scattering\nthem across the component.\n\n## The insertion argument\n\nThe last argument of a primitive is an **insertion**: a function that receives\nthe primitive's internals and returns whatever you want exposed on it.\n\n\n\nEverything you return is now on the ref:\n\n```typescript\nyield* tasks(); // the array\nyield* tasks.add('Learn insertions');\nyield* tasks.remaining(); // 1\n```\n\nThe context gives you `state` (the current value as a yieldable reader), `set`\nand `update`. Non-generator insertion methods may return `update(...)` directly\n— the wrapper consumes the write. `remaining` is a `craftComputed`: it does not\nown `state()`, so it yields it. That is how the computed's own dependency graph\nrecords the read.\n\n## The whole component\n\n```typescript\nimport {\n button,\n craftComponent,\n forNode,\n h1,\n input,\n li,\n ul,\n} from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* state('tasks', [] as Task[], /* … as above … */);\n return { tasks };\n },\n ({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n\n input({\n type: 'text',\n placeholder: 'New task…',\n *keydown(event) {\n if (event.key !== 'Enter') return;\n const field = event.target as HTMLInputElement;\n yield* tasks.add(field.value);\n field.value = '';\n },\n }),\n\n ul(\n forNode(\n tasks,\n { track: (task) => task.id, empty: () => li('Nothing to do 🎉') },\n (task) =>\n li([\n input({\n type: 'checkbox',\n checked: task.done,\n *change() {\n yield* tasks.toggle(task.id);\n },\n }),\n task.title,\n button({\n *click() {\n yield* tasks.remove(task.id);\n },\n }, '×'),\n ]),\n ),\n ),\n ],\n);\n```\n\nTwo template things worth noting. `forNode(source, options, render)` takes a\n`track` — the stable identity the renderer uses to reuse, move and remove\nnodes — and an optional `empty` branch. Pass the reader itself (`tasks`) rather\nthan `() => tasks()`. When a binding must format or call a method, use a\ngenerator and `yield*`.\n\nThe logic factory is now three lines. That's the point: **behaviour lives on the\nstate, not around it.**\n\n## Control flow\n\nCraft templates are TypeScript, so control flow is made of functions rather than\nsyntax. Each block is a typed function with an explicit contract:\n\n| Block | Purpose |\n| --- | --- |\n| `forNode` | Renders a collection with stable tracking and an optional empty branch |\n| `ifNode` | Preserves a conditional branch in the render contract |\n| `matchNode.exhaustive` | Matches every member of a discriminated union |\n| `deferNode` | Loads a branch lazily |\n\n`matchNode.exhaustive` matches on a **discriminant key** of a union and the\nhandler map must cover every member — a missing case is a compile error.\n\n```typescript\nmatchNode.exhaustive(() => tasksQuery.exceptions().loader, '_tag', {\n TASK_NOT_FOUND: () => p('This task no longer exists.'),\n TASK_FORBIDDEN: () => p('You do not have access to it.'),\n});\n```\n\n### Why not a plain ternary or `switch`?\n\nBecause a raw TypeScript conditional **collapses**. The template type ends up\nholding the *result* of the branch, not the fact that a branch existed:\n\n```typescript\n// works at runtime, but the contract is now opaque\ntasks.isEmpty() ? p('Nothing to do') : ul(/* … */);\n```\n\n`ifNode` and `matchNode` keep the condition **and both branches** in the node\ncontract. That is what lets you assert, at compile time, that an element renders\n*only* when a condition holds, or that a label renders for every item of a\nnon-empty list — see [Type-level tests](/guide/testing/type-level). With a\nternary those assertions have nothing to inspect.\n\nThe renderer also uses the block structure to update surgically instead of\nrebuilding the subtree.\n\n::: tip When a ternary is fine\nFor a leaf value — a class name, a piece of text, an attribute — a ternary is\nthe right tool. The rule concerns **structure**: whenever a branch decides\nwhether an element exists, reach for `ifNode` or `matchNode`.\n:::\n\n`ifNode` takes a **named** reactive value as its condition (a primitive ref, or\na value marked with `markYieldableValue`), because that name is what the\nvisibility contract records.\n\n## Reusing behaviour across components\n\nAn insertion factors logic out of a **primitive**. Its counterpart for\n**components** is a directive: `craftDirective` decorates both a component's\nlogic factory and its template, and you attach it with `.pipe(...)`:\n\n```typescript\nexport const Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => div(user.name),\n).pipe(InteractivePermissions);\n```\n\nThe directive can add to the context the template receives — here a\n`permissions` object the component never had to declare — and directives compose\nleft to right. That is how a tooltip, focus management or interaction analytics\nget added to several components without any of them knowing about it.\n\nThe full pattern — writing a directive, what it can require from its host, and\nhow styles compose — is on\n[Directives and `.pipe(...)`](/guide/components/directives). See also\n[Customization](/guide/components/customization) for the three layers of\ncomponent customization, and [Styling a component](/guide/components/styles).\n\n## Every exception a component picks up must be handled\n\nIf a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's contract. It has to\nbe dealt with, and the compiler is the one that says so:\n\n```typescript\nexport const Restricted = MyComponent.pipe(\n catchNode.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\n`catchNode.exhaustive` is the one you want most of the time: it renders a\n**fallback**. When the failure happens in the factory or a provider — before the\ntemplate exists — the fallback simply renders alone.\n\nHandle it here and the code disappears from the contract. Leave it and it flows\nup to the route, where `handleExceptions` **must** cover it — a reachable code\nwith no handler doesn't compile, and neither does a handler for a code nothing\ncan produce.\n\n::: warning Where the error actually lands today\nThe compile-time enforcement is at the **route**\n(`assertExhaustiveRouteExceptions`). The component `.pipe(...)` overload is\ncurrently kept permissive to avoid excessive TypeScript instantiation depth, so\nan unhandled code there is caught by runtime dispatch instead. Practical\nconsequence: a component rendered outside any route gets no compile-time\nreminder — handle its codes explicitly.\n\nThe whole rule is on [An unhandled exception doesn't just\ndisappear](/guide/concepts/exceptions).\n:::\n\n`matchNode.exhaustive` is the sibling for rendering from an exception *value*\nor signal. Reach for `catchTag.exhaustive` only when the reaction is pure\nlogic — a toast, a log — and produces no DOM.\n\n## Several insertions at once\n\nOne insertion function gets crowded fast. Split it and compose with `insertStatePipe`:\n\n\n\nEach function in the pipe receives the same context and contributes its own\nslice. This is what makes behaviour **reusable**: an insertion is just a\nfunction, so it can be extracted, parameterised and shared.\n\n::: tip That's what \"insertions\" are\nThe library ships ready-made ones — storage persistence, optimistic updates,\npagination placeholders, forms. They are the exact same shape as the functions\nyou just wrote. See [Insertions](/guide/concepts/insertions).\n:::\n\n## What you gained\n\nState that carries its own behaviour, a template that only renders, and a\ncomposition mechanism that scales past the first three methods.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Your first state](/learn/01-first-state)\n\n[3. Move logic out of the component →](/learn/03-service)\n\n</div>\n"
696
731
  },
697
732
  {
698
733
  "path": "/learn/03-service",
@@ -722,7 +757,7 @@
722
757
  {
723
758
  "path": "/learn/08-forms",
724
759
  "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"
760
+ "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
761
  },
727
762
  {
728
763
  "path": "/learn/09-routing",
@@ -747,7 +782,7 @@
747
782
  {
748
783
  "path": "/reference",
749
784
  "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"
785
+ "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| `CraftSsrPolicy`, `provideCraftSsrPolicy` | 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
786
  },
752
787
  {
753
788
  "path": "/resources/ai-agents",