@craft-ts/mcp 0.7.0-beta.13 → 0.7.0-beta.16

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,17 +7,17 @@
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## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\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### 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 `each`](/guide/components/schedule-each) ·\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### 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"
11
11
  },
12
12
  {
13
13
  "path": "/guide/advanced/effect",
14
14
  "title": "Using Effect with CraftTS",
15
- "body": "# Using Effect with CraftTS\n\nEffect belongs in CraftTS when the problem is a **domain program**: composing\nservices, modelling typed failures, controlling resources, or running an\noperation that crosses an I/O boundary. CraftTS remains responsible for\ncomponents, fine-grained rendering, reactive state and resource lifecycles.\n\nThe integration is deliberately a boundary, not a second UI runtime:\n\n```text\nCraft component / template\n ↓\nCraft primitive or generator\n ↓\nEffect<A, E, R>\n ↓\nLayer<R> provided by the Craft injector\n```\n\nIf a value is only local UI state, keep it in Craft. If it is a domain operation\nwith typed errors or services, define it as an Effect and adapt it at the Craft\nboundary.\n\n## The short decision\n\n| Need | Use | Why |\n| ------------------------------------------------ | -------------------------------- | ---------------------------------------------------------------- |\n| Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state |\n| Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns |\n| Derive a reactive value with an Effect | `computedEffect` | reruns an Effect factory when Craft dependencies change |\n| Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions |\n| Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process |\n| Provide Effect services | `provideLayer` | app and route injectors own Layer scope |\n| Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members |\n| Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions |\n| Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect |\n\nThere is intentionally no `stateEffect`. A reactive value is not made better by\nbeing an Effect. Use `state` for the value, and use Effect for the computation\nthat loads or changes it.\n\n## Install the packages\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\n```\n\nKeep the three Craft packages on the same version. `@craft-ts/effect` declares\n`effect` as a peer dependency.\n\n## Install the bridge once\n\nThe bridge teaches Craft's generator driver how to execute a yielded Effect.\nInstall it at application bootstrap:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn a test, install it in `beforeEach` and call the returned disposer in\n`afterEach`. Do not install a new bridge in every loader or component.\n\n## Keep components in Craft\n\nA Craft component still has a generator factory and a typed template. The\ncomponent should call a domain operation, not resolve its repository or start a\nfiber from a click handler:\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile } from './profile-domain';\n\nexport const Profile = craftComponent(\n 'Profile',\n {},\n function* () {\n const profile = yield* queryEffect('profile', {\n params: () => 'user-ada',\n loader: ({ params }) => loadUserProfile(params),\n });\n\n return { profile };\n },\n ({ profile }) => [\n p(function* () {\n const user = yield* profile.value();\n return user?.name ?? 'Loading…';\n }),\n button(\n 'reload',\n {\n *click() {\n yield* profile.reload();\n },\n },\n 'Reload',\n ),\n ],\n);\n```\n\nThe template consumes Craft readers. It does not subscribe to an Effect, call\n`Effect.runPromise`, or convert a Promise into a signal manually.\n\n## Define the domain in Effect\n\nUse Effect for domain contracts and implementations. Tagged errors are values in\nthe `E` channel:\n\n```typescript\nimport { Context, Data, Effect, Layer } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport type UserRepository = {\n readonly byId: (userId: string) => Effect.Effect<User, UserNotFound>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({\n byId: (userId) => findUserInDatabase(userId),\n}));\n\nexport function loadUserProfile(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n return yield* repository.byId(userId);\n });\n}\n```\n\nThe resulting program carries its success value, its typed failures and its\nrequirements. A Craft component only needs `loadUserProfile`; it does not need\nto know which Layer implements `UserRepositoryService`.\n\n## Choose the right adapter\n\n### `queryEffect`: Effect-backed reads\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) => listUsers(params),\n });\n```\n\nUse it when the result is server or domain state. Craft owns `status`, loading,\nprevious value, cancellation and reloading. The loader returns\n`Effect<Value, Error, Requirements>`.\n\nThe `params` factory and `method` are synchronous. They may read Craft\ndependencies, but must not create an Effect or read an Effect service. The\nloader is the only Effect-aware callback:\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: function* () {\n const input = yield* searchInput();\n return resolveSearchParams(input);\n },\n loader: ({ params }) => listUsers(params),\n });\n```\n\nThe Effect ESLint rule enforces this boundary. For an asynchronous derived\ninput, use `computedEffect` and feed its resolved Craft value to a synchronous\n`params` function.\n\n### `mutationEffect`: Effect-backed writes\n\n```typescript\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n method: (input: UserInput) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nTrigger it with `yield* saveUser.mutate(input)`. Use the normal Craft\n`insertReactOnMutation` insertion to reload a query or apply an optimistic patch.\nThe mutation `method` only maps its arguments to synchronous params. The\n`loader` is the only Effect-aware callback.\n\n### `asyncProcessEffect`: explicit commands\n\n```typescript\nconst exportUsers =\n yield *\n asyncProcessEffect('exportUsers', {\n method: (filter: Filter) => filter,\n loader: ({ params }) => exportUsersEffect(params),\n });\n\nyield * exportUsers.method(currentFilter);\n```\n\nThe `asyncProcessEffect` method follows the same rule: it returns plain params;\nthe loader owns the asynchronous Effect program.\n\nUse it for an operation with a lifecycle but without a query cache or mutation\nrelationship.\n\n### `runEffect`: the low-level form\n\nUse `runEffect` when an Effect is yielded directly by a guard, resolver or Craft\nprogram and you need its typed errors to be visible to Craft:\n\n```typescript\nimport { runEffect } from '@craft-ts/effect';\n\nconst user = yield * runEffect(loadUserProfile(userId));\n```\n\nThe adapter is the right choice for most component resources. A bare\n`yield* someEffect` may execute at runtime, but it does not advertise the\nEffect's `E` channel to Craft's route-exception analysis. `runEffect` does.\n\n## Provide services with `Layer`\n\n`provideLayer` attaches a built Effect context to a Craft injector:\n\n```typescript\nexport const appConfig = craftAppConfig({\n providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))],\n});\n```\n\nUse one merged Layer per injector level. A route can add a narrower Layer:\n\n```typescript\nconst routeProviders = [provideLayer(TeamContextLive)] as const;\n\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(() => import('./team'), routeProviders),\n },\n]);\n```\n\nThe parent context is reused and the child Layer is added for that route. Its\nEffect scope is closed with the route injector.\n\nFor compile-time coverage, compare the program's `Effect.Services<...>` with\nthe values provided by the app and route:\n\n```typescript\ntype Check = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices | ProvidedEffectServicesOf<typeof routeProviders>\n>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nSee [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for\nthe full `AppProvidedDependencyValuesOf` setup.\n\n## Understand the error mapping\n\nThe bridge keeps Effect's distinctions intact:\n\n| Effect outcome | Craft outcome | Handle it with |\n| -------------------------- | ------------------------------------- | ---------------------------------------- |\n| `Effect.succeed(value)` | resource value / generator result | normal rendering |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchBlock`, `catchTag`, route handlers |\n| `Effect.die(defect)` | technical error | error boundary / monitoring |\n| interruption | cancellation | normally no user-facing handler |\n\nUse exhaustive matching for business errors:\n\n```typescript\nmatchBlock.exhaustive(resource.exception, '_tag', {\n UserNotFound: () => p('No user was found.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nThe error union is only visible to the compiler when the Effect crosses through\n`queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`.\n\n## Use Effect Schema at data boundaries\n\n`@craft-ts/core` accepts Standard Schema. Effect Schema participates through one\nconversion call:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst UserInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n name: Schema.String,\n email: Schema.String,\n }),\n);\n\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n methodSchema: UserInput,\n method: (input) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nThis schema interop does not require `@craft-ts/effect`; it follows the Standard\nSchema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema)\nfor async decoding and loader result validation.\n\n## Select an Effect service from Craft\n\nMost components should consume a domain operation. A Craft service or adapter\nthat really needs an Effect service can select only the members it uses:\n\n```typescript\nconst { byId } =\n yield * effectService(UserRepositoryService, ({ byId }) => ({ byId }));\n```\n\nThe selection narrows the graph and keeps generic member signatures intact. It\ndoes not replace `Layer`; the service still comes from the nearest\n`provideLayer(...)`.\n\n## Testing\n\nUse `mockEffectService` for a focused Layer:\n\n```typescript\nconst repository = mockEffectService(UserRepositoryService, {\n byId: () => Effect.succeed(expectedUser),\n});\n```\n\nCombine it with Craft's register-based tests. The Effect mock covers the Effect\nservice; the Craft register covers every Craft dependency and boundary. An\nunstubbed member fails with `UnstubbedEffectMember` instead of silently returning\nan incomplete value.\n\nSee [testing with Effect](/learn-effect/08-testing) and [browser\nboundaries](/guide/testing/browser-boundaries).\n\n## Package map\n\n| Package | Responsibility |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------- |\n| `@craft-ts/component` | functional Craft components and typed templates |\n| `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry |\n| `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers |\n| `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime |\n| `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment |\n| `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks |\n\nInstall only the packages needed by the layer you are building. For example,\nEffect Schema validation can be used with `@craft-ts/core` alone; the bridge and\nEffect-aware resource adapters require `@craft-ts/effect`.\n\n## Server functions: current POC\n\nThe current server-function integration is a **proof of concept**, not a final\nAPI. It currently combines:\n\n- `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`;\n- `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`;\n- `Effect`/`Layer` on the server;\n- a local HTTP transport and `@effect/platform-node` in the demo.\n\nThe client must import only the server function's type, while the server owns\nthe implementation and server-only Layers. Authentication and authorization\nmust be checked again on the server; a client Layer is never a security boundary.\n\nSee the [server functions POC chapter](/learn-effect/09-server-functions) and\nthe [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function).\nExpect the transport, file conventions, middleware API and production\nintegration to change before this becomes a stable feature.\n\n## Common mistakes\n\n- **Putting every value in Effect:** keep local UI state in `state` and URL state\n in `queryParams`.\n- **Subscribing in a component:** return an Effect from a resource adapter and\n let Craft own loading and cancellation.\n- **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI\n can handle it exhaustively.\n- **Providing a Layer inside a loader:** provide it at app or route scope so its\n lifetime and requirements are visible.\n- **Trusting client context in a server function:** treat it as a claim and\n verify it on the server.\n- **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft\n sees the typed exception union.\n\n## See also\n\n- [Learn CraftTS with Effect](/learn-effect/)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Exceptions as values](/guide/concepts/exceptions)\n- [Program operators](/guide/advanced/program-operators)\n- [Effect Schema](/guide/state/schema-validation#effect-schema)\n- [Effect integration tests](/learn-effect/08-testing)\n"
15
+ "body": "# Using Effect with CraftTS\n\nEffect belongs in CraftTS when the problem is a **domain program**: composing\nservices, modelling typed failures, controlling resources, or running an\noperation that crosses an I/O boundary. CraftTS remains responsible for\ncomponents, fine-grained rendering, reactive state and resource lifecycles.\n\nThe integration is deliberately a boundary, not a second UI runtime:\n\n```text\nCraft component / template\n ↓\nCraft primitive or generator\n ↓\nEffect<A, E, R>\n ↓\nLayer<R> provided by the Craft injector\n```\n\nIf a value is only local UI state, keep it in Craft. If it is a domain operation\nwith typed errors or services, define it as an Effect and adapt it at the Craft\nboundary.\n\n## The short decision\n\n| Need | Use | Why |\n| ------------------------------------------------ | -------------------------------- | ---------------------------------------------------------------- |\n| Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state |\n| Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns |\n| Derive a reactive value from a synchronous Effect | `computedEffect` | runs a `SyncOp` Effect in place — a value, not a resource |\n| Expose a synchronous Effect as a callable method | `methodEffect` | the Effect counterpart of `craftMethod` |\n| Run a synchronous Effect in a lower-level position | `syncEffect` | `params`, a `craftMethod`, a `state` updater |\n| Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions |\n| Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process |\n| Provide Effect services | `provideLayer` | app and route injectors own Layer scope |\n| Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members |\n| Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions |\n| Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect |\n\nThere is intentionally no `stateEffect`. A reactive value is not made better by\nbeing an Effect. Use `state` for the value, and use Effect for the computation\nthat loads or changes it.\n\n## Install the packages\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\n```\n\nKeep the three Craft packages on the same version. `@craft-ts/effect` declares\n`effect` as a peer dependency.\n\n## Install the bridge once\n\nThe bridge teaches Craft's generator driver how to execute a yielded Effect.\nInstall it at application bootstrap:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn a test, install it in `beforeEach` and call the returned disposer in\n`afterEach`. Do not install a new bridge in every loader or component.\n\n## Keep components in Craft\n\nA Craft component still has a generator factory and a typed template. The\ncomponent should call a domain operation, not resolve its repository or start a\nfiber from a click handler:\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile } from './profile-domain';\n\nexport const Profile = craftComponent(\n 'Profile',\n {},\n function* () {\n const profile = yield* queryEffect('profile', {\n params: () => 'user-ada',\n loader: ({ params }) => loadUserProfile(params),\n });\n\n return { profile };\n },\n ({ profile }) => [\n p(function* () {\n const user = yield* profile.value();\n return user?.name ?? 'Loading…';\n }),\n button(\n 'reload',\n {\n *click() {\n yield* profile.reload();\n },\n },\n 'Reload',\n ),\n ],\n);\n```\n\nThe template consumes Craft readers. It does not subscribe to an Effect, call\n`Effect.runPromise`, or convert a Promise into a signal manually.\n\n## Define the domain in Effect\n\nUse Effect for domain contracts and implementations. Tagged errors are values in\nthe `E` channel:\n\n```typescript\nimport { Context, Data, Effect, Layer } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport type UserRepository = {\n readonly byId: (userId: string) => Effect.Effect<User, UserNotFound>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({\n byId: (userId) => findUserInDatabase(userId),\n}));\n\nexport function loadUserProfile(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n return yield* repository.byId(userId);\n });\n}\n```\n\nThe resulting program carries its success value, its typed failures and its\nrequirements. A Craft component only needs `loadUserProfile`; it does not need\nto know which Layer implements `UserRepositoryService`.\n\n## Choose the right adapter\n\n### `queryEffect`: Effect-backed reads\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) => listUsers(params),\n });\n```\n\nUse it when the result is server or domain state. Craft owns `status`, loading,\nprevious value, cancellation and reloading. The loader returns\n`Effect<Value, Error, Requirements>`.\n\nThe `params` factory and `method` are synchronous. They may read Craft\ndependencies, but must not create an Effect or read an Effect service. The\nloader is the only Effect-aware callback:\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: function* () {\n const input = yield* searchInput();\n return resolveSearchParams(input);\n },\n loader: ({ params }) => listUsers(params),\n });\n```\n\nThe Effect ESLint rule enforces this boundary. A **declared-synchronous** Effect\nis allowed here through `syncEffect(...)`; for an input that has to suspend, use\na `queryEffect` and feed its settled value to this one.\n\n### `mutationEffect`: Effect-backed writes\n\n```typescript\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n method: (input: UserInput) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nTrigger it with `yield* saveUser.mutate(input)`. Use the normal Craft\n`insertReactOnMutation` insertion to reload a query or apply an optimistic patch.\nThe mutation `method` only maps its arguments to synchronous params. The\n`loader` is the only Effect-aware callback.\n\n### `asyncProcessEffect`: explicit commands\n\n```typescript\nconst exportUsers =\n yield *\n asyncProcessEffect('exportUsers', {\n method: (filter: Filter) => filter,\n loader: ({ params }) => exportUsersEffect(params),\n });\n\nyield * exportUsers.method(currentFilter);\n```\n\nThe `asyncProcessEffect` method follows the same rule: it returns plain params;\nthe loader owns the asynchronous Effect program.\n\nUse it for an operation with a lifecycle but without a query cache or mutation\nrelationship.\n\n### `methodEffect`: synchronous callable methods\n\nUse `methodEffect` when a domain operation is a synchronous Effect and should be\nexposed as a callable method rather than as a resource:\n\n```typescript\nconst formatPrice = methodEffect('formatPrice', (cents: number) =>\n Effect.gen(function* () {\n yield* SyncOp;\n return `${(cents / 100).toFixed(2)} €`;\n }),\n);\n\nformatPrice(1499); // '14.99 €'\n```\n\nIt is the Effect-aware convenience form of `craftMethod`. The `SyncOp`\nrequirement is mandatory because the method returns immediately. For an Effect\nthat can suspend, use `asyncProcessEffect`, `mutationEffect`, or\n`queryEffect`.\n\n### `computedEffect`: a derived value, not a resource\n\n`computedEffect` runs a **synchronous** Effect in place and hands back a value.\nIt is the adapter for a derivation — a formatted price, a validity flag — where\n`queryEffect` would wrap the answer in a resource with a loading state nothing\ncan ever be in:\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst totalLabel = computedEffect('totalLabel', function* () {\n const lines = yield* cartLines();\n return cartTotalLabel(lines); // returns the Effect, never runs it\n});\n```\n\nThe factory reads Craft dependencies with `yield*` and **returns** an Effect;\nthe adapter runs it in place against the nearest `provideLayer(...)`. Read the\nresult like any `craftComputed` — no `value`, no `isLoading`, no\n`pendingNode`.\n\nThe Effect it returns must be declared synchronous — `Effect<A, E, SyncOp>` —\nfor the same reason `syncEffect` requires it: a computation is asked for its\nvalue now and cannot suspend to produce it, so one whose `R` does not carry\n`SyncOp` is refused at the call site. See [Run a synchronous member from a\ncomputed](#run-a-synchronous-member-from-a-computed) for what `SyncOp` is and\nthe three mechanisms that check the claim.\n\nUse `syncEffect` instead when the synchronous Effect is not the whole\nderivation — inside a `craftMethod`, a `params`, or a `state` updater.\n\n### `runEffect`: the low-level form\n\nUse `runEffect` when an Effect is yielded directly by a guard, resolver or Craft\nprogram and you need its typed errors to be visible to Craft:\n\n```typescript\nimport { runEffect } from '@craft-ts/effect';\n\nconst user = yield * runEffect(loadUserProfile(userId));\n```\n\nThe adapter is the right choice for most component resources. A bare\n`yield* someEffect` may execute at runtime, but it does not advertise the\nEffect's `E` channel to Craft's route-exception analysis. `runEffect` does.\n\n## Provide services with `Layer`\n\n`provideLayer` attaches a built Effect context to a Craft injector:\n\n```typescript\nexport const appConfig = craftAppConfig({\n providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))],\n});\n```\n\nUse one merged Layer per injector level. A route can add a narrower Layer:\n\n```typescript\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(\n () => import('./team'),\n [provideLayer(TeamContextLive)] as const,\n ),\n },\n]);\n```\n\nThe parent context is reused and the child Layer is added for that route. Its\nEffect scope is closed with the route injector.\n\nFor compile-time coverage, compare the program's `Effect.Services<...>` with\nthe values provided by the app and route:\n\n```typescript\ntype Check = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices |\n ProvidedEffectServicesOfRoute<typeof routes._routes, 'team'>\n>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nSee [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for\nthe full `AppProvidedDependencyValuesOf` setup.\n\n## Understand the error mapping\n\nThe bridge keeps Effect's distinctions intact:\n\n| Effect outcome | Craft outcome | Handle it with |\n| -------------------------- | ------------------------------------- | ---------------------------------------- |\n| `Effect.succeed(value)` | resource value / generator result | normal rendering |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchNode`, `catchTag`, route handlers |\n| `Effect.die(defect)` | technical error | error boundary / monitoring |\n| interruption | cancellation | normally no user-facing handler |\n\nUse exhaustive matching for business errors:\n\n```typescript\nmatchNode.exhaustive(resource.exception, '_tag', {\n UserNotFound: () => p('No user was found.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nThe error union is only visible to the compiler when the Effect crosses through\n`queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`.\n\n## Use Effect Schema at data boundaries\n\n`@craft-ts/core` accepts Standard Schema. Effect Schema participates through one\nconversion call:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst UserInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n name: Schema.String,\n email: Schema.String,\n }),\n);\n\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n methodSchema: UserInput,\n method: (input) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nThis schema interop does not require `@craft-ts/effect`; it follows the Standard\nSchema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema)\nfor async decoding and loader result validation.\n\n## Select an Effect service from Craft\n\nMost components should consume a domain operation. A Craft service or adapter\nthat really needs an Effect service can select only the members it uses:\n\n```typescript\nconst { byId } =\n yield * effectService(UserRepositoryService, ({ byId }) => ({ byId }));\n```\n\nThe selection narrows the graph and keeps generic member signatures intact. It\ndoes not replace `Layer`; the service still comes from the nearest\n`provideLayer(...)`.\n\n## Run a synchronous member from a computed\n\n`params`, `craftComputed(...)` and `craftMethod(...)` run on Craft's synchronous\ndriver: they complete on one tick and cannot wait. `Effect<A, E, R>` does not say\nwhether running an Effect will suspend, and a service member makes it worse — a\n`Layer` closes over its dependencies at construction, so a network call and a\npure calculation both surface as `R = never`.\n\nDeclare the difference in `R`, the one channel Effect accumulates:\n\n```typescript\nexport type CartPricingShape = {\n readonly fetchCatalog: (skus: readonly string[]) => Effect.Effect<Catalog>;\n readonly lineTotal: (line: CartLine) => Effect.Effect<number, never, SyncOp>;\n};\n```\n\n`SyncOp` is a phantom requirement: nothing provides it, it costs nothing at\nruntime, and `Effect<A, E, never>` is assignable to `Effect<A, E, SyncOp>` — so\ndeclaring it in the shape is enough, the implementation needs no ceremony. Where\n`R` is inferred (a standalone `Effect.gen` calling nothing already marked), add\n`yield* SyncOp` to the body.\n\nRun it with `syncEffect(...)`, which resolves in place instead of suspending:\n\n```typescript\nconst totalLabel = craftComputed('totalLabel', function* () {\n const cents = yield* syncEffect(cartTotal(yield* lines()));\n return yield* syncEffect(formatPrice(cents));\n});\n```\n\nRequirements other than `SyncOp` travel through untouched — the level in force\nsatisfies them exactly as it does for a loader. The only thing checked at the\ntype level is that `SyncOp` is among them.\n\nThe declaration is a claim, and three independent mechanisms check it: the type\nrefuses an undeclared Effect at the call; `craft-ts/sync-effect-body` reads the\nbody, every branch at once, and rejects one that yields something async; and at\nruntime `syncEffect` goes through `Effect.runSyncExitWith`, which cannot suspend\n— a broken promise throws `CraftEffectNotSynchronous` at the first call rather\nthan freezing the UI.\n\nFull walkthrough: [Declare a synchronous member](/learn-effect/03-effect-domain#declare-a-synchronous-member).\n\n## Testing\n\nUse `mockEffectService` for a focused Layer:\n\n```typescript\nconst repository = mockEffectService(UserRepositoryService, {\n byId: () => Effect.succeed(expectedUser),\n});\n```\n\nCombine it with Craft's register-based tests. The Effect mock covers the Effect\nservice; the Craft register covers every Craft dependency and boundary. An\nunstubbed member fails with `UnstubbedEffectMember` instead of silently returning\nan incomplete value.\n\nSee [testing with Effect](/learn-effect/08-testing) and [browser\nboundaries](/guide/testing/browser-boundaries).\n\n## Package map\n\n| Package | Responsibility |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------- |\n| `@craft-ts/component` | functional Craft components and typed templates |\n| `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry |\n| `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers |\n| `@craft-ts/i18n-effect` | the Effect adapter over an `@craft-ts/i18n` runtime: `provideI18nRuntime`, `translateEffect`, `I18nEffectService` — see [i18n with Effect](/guide/i18n/effect) |\n| `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime |\n| `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment |\n| `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks |\n\nInstall only the packages needed by the layer you are building. For example,\nEffect Schema validation can be used with `@craft-ts/core` alone; the bridge and\nEffect-aware resource adapters require `@craft-ts/effect`.\n\n## Server functions: current POC\n\nThe current server-function integration is a **proof of concept**, not a final\nAPI. It currently combines:\n\n- `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`;\n- `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`;\n- `Effect`/`Layer` on the server;\n- a local HTTP transport and `@effect/platform-node` in the demo.\n\nThe client must import only the server function's type, while the server owns\nthe implementation and server-only Layers. Authentication and authorization\nmust be checked again on the server; a client Layer is never a security boundary.\n\nSee the [server functions POC chapter](/learn-effect/09-server-functions) and\nthe [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function).\nExpect the transport, file conventions, middleware API and production\nintegration to change before this becomes a stable feature.\n\n## Common mistakes\n\n- **Putting every value in Effect:** keep local UI state in `state` and URL state\n in `queryParams`.\n- **Subscribing in a component:** return an Effect from a resource adapter and\n let Craft own loading and cancellation.\n- **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI\n can handle it exhaustively.\n- **Providing a Layer inside a loader:** provide it at app or route scope so its\n lifetime and requirements are visible.\n- **Trusting client context in a server function:** treat it as a claim and\n verify it on the server.\n- **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft\n sees the typed exception union.\n- **Declaring a member `SyncOp` to get it into a computed:** the marker states a\n fact, it does not create one. If the member can suspend, move the work to a\n loader — the runtime will refuse it anyway.\n\n## See also\n\n- [Learn CraftTS with Effect](/learn-effect/)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Exceptions as values](/guide/concepts/exceptions)\n- [Program operators](/guide/advanced/program-operators)\n- [Effect Schema](/guide/state/schema-validation#effect-schema)\n- [Effect integration tests](/learn-effect/08-testing)\n"
16
16
  },
17
17
  {
18
18
  "path": "/guide/advanced/observability",
19
19
  "title": "Observability",
20
- "body": "# Observability\n\nBecause every dependency is resolved through one system, that system is also the\nplace to cross-cut them all — logging, timing, correlation ids, snapshots — with\nno change to the business code.\n\n**Use it when** you need to see what your app is doing in production, or to\nconnect craft to your monitoring stack.\n**Start with `Console`**: it is yieldable, so overriding it once redirects every\nlog in the app.\n\nThe same DI system that powers `craftService` also lets you cross-cut every crafted function with side effects — logging, snapshots, correlation tracking, timing, error reporting — without touching the business code.\n\n## Mental Model\n\n`craft-ts` distinguishes two kinds of failures:\n\n- **Expected errors**: handled explicitly with [`craftException`](/guide/app/craft-service) in your business code.\n- **Unexpected errors**: bugs. They should never happen — and if they do, they should never happen _again_.\n\nUnexpected errors are exactly where observability shines. Since they are supposed to be impossible, you want to capture the maximum amount of context the moment one is thrown: stack, app state, correlation chain, etc. That context can then be shipped to a log server, an alerting pipeline, or directly to an AI webhook for triage.\n\nThe three pillars `craft-ts` exposes for that are:\n\n- [`provideFnWrapper`](#providefnwrapper) — wrap every crafted function with cross-cutting behavior\n- [`provideTemplateTrace`](#providetemplatetrace) — observe effective component and template renders\n- [`provideCraftRouterTrace`](#providecraftroutertrace) — observe navigation events and Craft route stages\n- [`provideCraftHttpTrace`](#providecrafthttptrace) — wrap every `CraftHttpClient` request\n- [`provideTakeAppSnapshot`](#providetakeappsnapshot) — capture all active state when something goes wrong\n- [`provideCraftDomEventHook`](#craft-dom-event-hooks) — observe or wrap every DOM action declared in a Craft template\n- [`provideCorrelationIdTracking`](#providecorrelationidtracking) — link a user gesture to every async operation it triggered\n\n## `provideFnWrapper`\n\n`provideFnWrapper` lets you wrap **every** generator-based function executed by `craft-ts` (services, methods, async processes, queries, mutations, effects…). It is the single best entry point to add cross-cutting side effects.\n\nBasic use case — log any unexpected error to the console:\n\n```ts\nimport { craftAppConfig, provideFnWrapper, Console } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n ],\n});\n```\n\nYou can register multiple wrappers — they compose. The first registered is the outermost.\n\n## `provideTemplateTrace`\n\n`provideTemplateTrace` is the render-specific counterpart to\n`provideFnWrapper`. It runs synchronously around the children produced by an\neffective render, including component templates, reactive updates, blocks,\nprojections, deferred branches, and nested callbacks.\n\n```ts\nimport { provideTemplateTrace } from '@craft-ts/core';\n\nprovideTemplateTrace((context, next) => {\n const start = performance.now();\n try {\n return next();\n } finally {\n console.debug(\n context.phase,\n context.componentName,\n performance.now() - start,\n );\n }\n});\n```\n\nThe context contains the render unit (`component`, `block`, `projection`,\n`defer`, or `callback`), its phase (`create`, `initialRender`, `update`, or\n`destroy`), the optional component/unit names, and the owning component's\n`renderCount`. Wrappers compose in registration order and execute in the\ncurrent render injector, so component-scoped providers remain injectable.\n\nThe wrapper can return different children or return an empty children value\nwithout calling `next()` to replace or block a render. Errors propagate to the\nnormal Craft render error boundary.\n\n## `provideCraftRouterTrace`\n\n`provideCraftRouterTrace` traces both the Router event stream and the\nCraft outlet's non-blocking route chain. The latter exposes `match`, `guard`,\nand `resolve` stages, including reactive guard re-evaluation.\n\n```ts\nimport { provideCraftRouterTrace } from '@craft-ts/core';\n\nprovideCraftRouterTrace((context, next) => {\n console.log('[router:start]', context);\n const result = next();\n console.log('[router:end]', context);\n return result;\n});\n```\n\nMultiple wrappers compose in registration order. The wrapper must call\n`next()` to preserve the navigation or route-chain work.\n\n## `provideCraftHttpTrace`\n\n`provideCraftHttpTrace` wraps the actual thenable request produced by\n`CraftHttpClient`, after its method, URL, params, and payload have been built.\nIt is therefore useful for timing, request logging, redaction, and error\nreporting without changing feature code.\n\n```ts\nimport { provideCraftHttpTrace } from '@craft-ts/core';\n\nprovideCraftHttpTrace(async (context, next) => {\n const start = performance.now();\n try {\n return await next();\n } finally {\n console.log(context.method, context.url, performance.now() - start);\n }\n});\n```\n\n### Important: injection inside `provideFnWrapper` is not type-safe\n\nThe wrapper body runs in **the injection context where the error was raised**, not where the wrapper was declared. That makes it extremely practical: you can yield browser boundaries, inject host-tagged metadata, read the offending service's correlation id, etc.\n\nBut it has two consequences:\n\n- injections inside the wrapper are **not type-safe** — `craft-ts` cannot prove statically that the dependency you ask for is actually provided where the wrapper runs\n- the wrapper is therefore a **risky** place to do business work\n\n::: tip\nUse `provideFnWrapper` mostly for **side effects** — logging, metrics, snapshots, correlation propagation. Avoid pulling business state through it.\n:::\n\nWhen the wrapped function is an insertion method, the wrapper can inject the\nmatching runtime context — `injectQueryMethodRuntimeContext()`,\n`injectStateMethodRuntimeContext()`, and the siblings for `mutation`,\n`queryParams`, and `asyncProcess` — and call `get` / `set` / `update` /\n`patch` on the owning primitive. That is how registries, WebMCP tools, and\nother advanced patterns seed or replace a query result, a mutation value, a\n`state`, and so on. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n### Example: timing every craft function\n\n```ts\nimport { craftAppConfig, provideFnWrapper, HostTag } from '@craft-ts/core';\n\nprovideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n const start = performance.now();\n try {\n return yield* factory.apply(thisArg, args);\n } finally {\n const name = yield* HostTag();\n console.log(`${name} took ${performance.now() - start}ms`);\n }\n },\n);\n```\n\n## `provideTakeAppSnapshot`\n\n`provideTakeAppSnapshot` captures the list of all **active states** in the app the moment an unexpected error occurs.\n\nThis is one of the most valuable pieces of context you can ship to a log server or AI webhook: you get not just the stack, but the full picture of what the app was holding when it broke.\n\n```ts\nimport { craftAppConfig, provideTakeAppSnapshot } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideTakeAppSnapshot((reports) => {\n // reports: SnapshotReport[]\n // — one entry per active state, with its source, ancestry, and current value\n console.warn('App snapshot:', reports);\n\n // In production you would forward this to a log server or AI webhook:\n // fetch('/api/incident', { method: 'POST', body: JSON.stringify({ reports }) });\n }),\n ],\n});\n```\n\nEach `SnapshotReport` contains:\n\n- `source` — the source tag of the state\n- `from` — the ancestry chain that produced it\n- `state` — the actual current value\n\nUnder the hood, `provideTakeAppSnapshot` registers its own `provideFnWrapper` that triggers the snapshot collection whenever an unexpected error bubbles up. CraftTS control-flow throws such as `CraftGenShortCircuit` and `CraftNotSettled` are deliberately excluded: they are consumed by `catchBlock` and `pendingBlock` boundaries during normal rendering. An unhandled boundary error remains observable and still triggers a snapshot. You do not need to call it manually.\n\n## Craft DOM event hooks\n\nEvery DOM event bound from a Craft template goes through the\n`CRAFT_DOM_EVENT_HOOK` token. Hooks run in the injector of the component that\ndeclared the element, and compose in registration order. A hook must call\n`next()` to preserve the component action.\n\n\n\n\nThe hook receives the native event, its normalized name, the element, the\ncomponent name, and a descriptive `interactionName` such as\n`SavePanel:button:save:click`. This is the extension point for analytics,\nauthorization, tracing, or correlation IDs. A hook can also stop an action by\nnot calling `next()`.\n\n## `provideCorrelationIdTracking`\n\n`provideCorrelationIdTracking` ties every async operation back to the **user gesture** that triggered it.\n\nWhen a Craft template action runs, a fresh correlation id is generated from its\nlocation (`SavePanel:button:save:click:uuid`, for example). Navigation back and\nforward still generate `nav-back:uuid` and `nav-forward:uuid`. Every generator\ninvoked downstream — directly or transitively, sync or async — captures that\nid at invocation time.\n\n```ts\nimport { craftAppConfig, provideCorrelationIdTracking } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [provideCorrelationIdTracking()],\n});\n```\n\nOnce enabled, the correlation id is attached to the metadata of browser boundaries like `Console`, so a single `yield* Console.error(...)` carries:\n\n- `startCorrelationId` — the id captured when the current generator was invoked\n- `lastCorrelationId` — the most recent id observed in the app\n- `mayCorrelatedIds` — the chain of ids the operation can be linked to\n\nThis lets you reconstruct, from logs alone, the full causal chain between _\"user clicked Save\"_ and _\"the third sub-request returned 500 four seconds later\"_.\n\nCombined with `provideTakeAppSnapshot`, you get on every unexpected error:\n\n- the stack\n- the snapshot of all active states\n- the correlation id chain back to the originating user gesture\n\n## Putting It All Together\n\nWire all three in your `appConfig`:\n\n```ts\nimport {\n craftAppConfig,\n Console,\n provideFnWrapper,\n provideTakeAppSnapshot,\n provideCorrelationIdTracking,\n} from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n provideCorrelationIdTracking(),\n provideTakeAppSnapshot((reports) => {\n // forward to your log server or AI webhook\n console.warn('App snapshot:', reports);\n }),\n ],\n});\n```\n\nYou now have, on any unexpected error: a console error in dev, a full app snapshot, and the correlation chain back to the originating user action — all without a single line of instrumentation inside your business code.\n\n## See Also\n\n- [`craftService`](/guide/app/craft-service)\n- [`Browser Boundaries`](/guide/testing/browser-boundaries) — `Console`, `LocalStorage`, etc., used inside wrappers\n"
20
+ "body": "# Observability\n\nBecause every dependency is resolved through one system, that system is also the\nplace to cross-cut them all — logging, timing, correlation ids, snapshots — with\nno change to the business code.\n\n**Use it when** you need to see what your app is doing in production, or to\nconnect craft to your monitoring stack.\n**Start with `Console`**: it is yieldable, so overriding it once redirects every\nlog in the app.\n\nThe same DI system that powers `craftService` also lets you cross-cut every crafted function with side effects — logging, snapshots, correlation tracking, timing, error reporting — without touching the business code.\n\n## Mental Model\n\n`craft-ts` distinguishes two kinds of failures:\n\n- **Expected errors**: handled explicitly with [`craftException`](/guide/app/craft-service) in your business code.\n- **Unexpected errors**: bugs. They should never happen — and if they do, they should never happen _again_.\n\nUnexpected errors are exactly where observability shines. Since they are supposed to be impossible, you want to capture the maximum amount of context the moment one is thrown: stack, app state, correlation chain, etc. That context can then be shipped to a log server, an alerting pipeline, or directly to an AI webhook for triage.\n\nThe three pillars `craft-ts` exposes for that are:\n\n- [`provideFnWrapper`](#providefnwrapper) — wrap every crafted function with cross-cutting behavior\n- [`provideTemplateTrace`](#providetemplatetrace) — observe effective component and template renders\n- [`provideCraftRouterTrace`](#providecraftroutertrace) — observe navigation events and Craft route stages\n- [`provideCraftHttpTrace`](#providecrafthttptrace) — wrap every `CraftHttpClient` request\n- [`provideTakeAppSnapshot`](#providetakeappsnapshot) — capture all active state when something goes wrong\n- [`provideCraftDomEventHook`](#craft-dom-event-hooks) — observe or wrap every DOM action declared in a Craft template\n- [`provideCorrelationIdTracking`](#providecorrelationidtracking) — link a user gesture to every async operation it triggered\n\n## `provideFnWrapper`\n\n`provideFnWrapper` lets you wrap **every** generator-based function executed by `craft-ts` (services, methods, async processes, queries, mutations, effects…). It is the single best entry point to add cross-cutting side effects.\n\nBasic use case — log any unexpected error to the console:\n\n```ts\nimport { craftAppConfig, provideFnWrapper, Console } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n ],\n});\n```\n\nYou can register multiple wrappers — they compose. The first registered is the outermost.\n\n## `provideTemplateTrace`\n\n`provideTemplateTrace` is the render-specific counterpart to\n`provideFnWrapper`. It runs synchronously around the children produced by an\neffective render, including component templates, reactive updates, blocks,\nprojections, deferred branches, and nested callbacks.\n\n```ts\nimport { provideTemplateTrace } from '@craft-ts/core';\n\nprovideTemplateTrace((context, next) => {\n const start = performance.now();\n try {\n return next();\n } finally {\n console.debug(\n context.phase,\n context.componentName,\n performance.now() - start,\n );\n }\n});\n```\n\nThe context contains the render unit (`component`, `block`, `projection`,\n`deferNode`, or `callback`), its phase (`create`, `initialRender`, `update`, or\n`destroy`), the optional component/unit names, and the owning component's\n`renderCount`. Wrappers compose in registration order and execute in the\ncurrent render injector, so component-scoped providers remain injectable.\n\nThe wrapper can return different children or return an empty children value\nwithout calling `next()` to replace or block a render. Errors propagate to the\nnormal Craft render error boundary.\n\n## `provideCraftRouterTrace`\n\n`provideCraftRouterTrace` traces both the Router event stream and the\nCraft outlet's non-blocking route chain. The latter exposes `match`, `guard`,\nand `resolve` stages, including reactive guard re-evaluation.\n\n```ts\nimport { provideCraftRouterTrace } from '@craft-ts/core';\n\nprovideCraftRouterTrace((context, next) => {\n console.log('[router:start]', context);\n const result = next();\n console.log('[router:end]', context);\n return result;\n});\n```\n\nMultiple wrappers compose in registration order. The wrapper must call\n`next()` to preserve the navigation or route-chain work.\n\n## `provideCraftHttpTrace`\n\n`provideCraftHttpTrace` wraps the actual thenable request produced by\n`CraftHttpClient`, after its method, URL, params, and payload have been built.\nIt is therefore useful for timing, request logging, redaction, and error\nreporting without changing feature code.\n\n```ts\nimport { provideCraftHttpTrace } from '@craft-ts/core';\n\nprovideCraftHttpTrace(async (context, next) => {\n const start = performance.now();\n try {\n return await next();\n } finally {\n console.log(context.method, context.url, performance.now() - start);\n }\n});\n```\n\n### Important: injection inside `provideFnWrapper` is not type-safe\n\nThe wrapper body runs in **the injection context where the error was raised**, not where the wrapper was declared. That makes it extremely practical: you can yield browser boundaries, inject host-tagged metadata, read the offending service's correlation id, etc.\n\nBut it has two consequences:\n\n- injections inside the wrapper are **not type-safe** — `craft-ts` cannot prove statically that the dependency you ask for is actually provided where the wrapper runs\n- the wrapper is therefore a **risky** place to do business work\n\n::: tip\nUse `provideFnWrapper` mostly for **side effects** — logging, metrics, snapshots, correlation propagation. Avoid pulling business state through it.\n:::\n\nWhen the wrapped function is an insertion method, the wrapper can inject the\nmatching runtime context — `injectQueryMethodRuntimeContext()`,\n`injectStateMethodRuntimeContext()`, and the siblings for `mutation`,\n`queryParams`, and `asyncProcess` — and call `get` / `set` / `update` /\n`patch` on the owning primitive. That is how registries, WebMCP tools, and\nother advanced patterns seed or replace a query result, a mutation value, a\n`state`, and so on. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n### Example: timing every craft function\n\n```ts\nimport { craftAppConfig, provideFnWrapper, HostTag } from '@craft-ts/core';\n\nprovideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n const start = performance.now();\n try {\n return yield* factory.apply(thisArg, args);\n } finally {\n const name = yield* HostTag();\n console.log(`${name} took ${performance.now() - start}ms`);\n }\n },\n);\n```\n\n## `provideTakeAppSnapshot`\n\n`provideTakeAppSnapshot` captures the list of all **active states** in the app the moment an unexpected error occurs.\n\nThis is one of the most valuable pieces of context you can ship to a log server or AI webhook: you get not just the stack, but the full picture of what the app was holding when it broke.\n\n```ts\nimport { craftAppConfig, provideTakeAppSnapshot } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideTakeAppSnapshot((reports) => {\n // reports: SnapshotReport[]\n // — one entry per active state, with its source, ancestry, and current value\n console.warn('App snapshot:', reports);\n\n // In production you would forward this to a log server or AI webhook:\n // fetch('/api/incident', { method: 'POST', body: JSON.stringify({ reports }) });\n }),\n ],\n});\n```\n\nEach `SnapshotReport` contains:\n\n- `source` — the source tag of the state\n- `from` — the ancestry chain that produced it\n- `state` — the actual current value\n\nUnder the hood, `provideTakeAppSnapshot` registers its own `provideFnWrapper` that triggers the snapshot collection whenever an unexpected error bubbles up. CraftTS control-flow throws such as `CraftGenShortCircuit` and `CraftNotSettled` are deliberately excluded: they are consumed by `catchNode` and `pendingNode` boundaries during normal rendering. An unhandled boundary error remains observable and still triggers a snapshot. You do not need to call it manually.\n\n## Craft DOM event hooks\n\nEvery DOM event bound from a Craft template goes through the\n`CRAFT_DOM_EVENT_HOOK` token. Hooks run in the injector of the component that\ndeclared the element, and compose in registration order. A hook must call\n`next()` to preserve the component action.\n\n\n\n\nThe hook receives the native event, its normalized name, the element, the\ncomponent name, and a descriptive `interactionName` such as\n`SavePanel:button:save:click`. This is the extension point for analytics,\nauthorization, tracing, or correlation IDs. A hook can also stop an action by\nnot calling `next()`.\n\n## `provideCorrelationIdTracking`\n\n`provideCorrelationIdTracking` ties every async operation back to the **user gesture** that triggered it.\n\nWhen a Craft template action runs, a fresh correlation id is generated from its\nlocation (`SavePanel:button:save:click:uuid`, for example). Navigation back and\nforward still generate `nav-back:uuid` and `nav-forward:uuid`. Every generator\ninvoked downstream — directly or transitively, sync or async — captures that\nid at invocation time.\n\n```ts\nimport { craftAppConfig, provideCorrelationIdTracking } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [provideCorrelationIdTracking()],\n});\n```\n\nOnce enabled, the correlation id is attached to the metadata of browser boundaries like `Console`, so a single `yield* Console.error(...)` carries:\n\n- `startCorrelationId` — the id captured when the current generator was invoked\n- `lastCorrelationId` — the most recent id observed in the app\n- `mayCorrelatedIds` — the chain of ids the operation can be linked to\n\nThis lets you reconstruct, from logs alone, the full causal chain between _\"user clicked Save\"_ and _\"the third sub-request returned 500 four seconds later\"_.\n\nCombined with `provideTakeAppSnapshot`, you get on every unexpected error:\n\n- the stack\n- the snapshot of all active states\n- the correlation id chain back to the originating user gesture\n\n## Putting It All Together\n\nWire all three in your `appConfig`:\n\n```ts\nimport {\n craftAppConfig,\n Console,\n provideFnWrapper,\n provideTakeAppSnapshot,\n provideCorrelationIdTracking,\n} from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n provideCorrelationIdTracking(),\n provideTakeAppSnapshot((reports) => {\n // forward to your log server or AI webhook\n console.warn('App snapshot:', reports);\n }),\n ],\n});\n```\n\nYou now have, on any unexpected error: a console error in dev, a full app snapshot, and the correlation chain back to the originating user action — all without a single line of instrumentation inside your business code.\n\n## See Also\n\n- [`craftService`](/guide/app/craft-service)\n- [`Browser Boundaries`](/guide/testing/browser-boundaries) — `Console`, `LocalStorage`, etc., used inside wrappers\n"
21
21
  },
22
22
  {
23
23
  "path": "/guide/advanced/pattern-matching",
@@ -32,7 +32,7 @@
32
32
  {
33
33
  "path": "/guide/advanced/ssr-hydration",
34
34
  "title": "SSR and hydration",
35
- "body": "# SSR and hydration\n\nCraft can render a complete application to deterministic HTML on the server,\ntransfer its serializable state, and then attach the browser runtime to the\nexisting DOM. This runtime path does not require the Craft compiler.\n\n**Use it when** the first response must contain useful HTML without rebuilding\nthe same component tree during browser startup.\n\n## Render one isolated request\n\n`renderCraft` creates a new platform, injector, primitive registry, in-memory\nhistory, and storage pair for every call. Do not reuse its injector between\nrequests.\n\n```ts\nimport { renderCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst controller = new AbortController();\nconst rendered = await renderCraft({\n config: appConfig,\n url: '/dashboard?page=2',\n signal: controller.signal,\n timeoutMs: 5_000,\n});\n\nreturn new Response(\n `<!doctype html><html><body>${rendered.html}</body></html>`,\n {\n headers: { 'content-type': 'text/html; charset=utf-8' },\n },\n);\n```\n\nThe result separates `rootHtml`, collected `styles`, and the transfer\n`snapshot`; `html` combines all three. `renderToString(component, options)` is\nthe smaller API when no application config is needed.\n\nAll app initializers are run before the server render. Aborting the request\nrejects pending SSR work with the signal reason. A blocking render that exceeds\n`timeoutMs` rejects with `CraftSsrTimeoutError` and lists the pending sources.\nA route policy may set a shorter `timeoutMs` for the sources it blocks.\n\n## Hydrate the existing DOM\n\nServe the generated `<craft-root>`, style element, and transfer script without\nchanging them. The browser entry point then uses the same app config. `startCraft`\nchooses hydration when the SSR marker is present and falls back to a normal\nclient mount when the page was not rendered by Craft SSR:\n\n```ts\nimport { startCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst app = startCraft({ config: appConfig });\n```\n\nHydration restores the snapshot before creating primitives, claims elements,\ntext markers, and block boundaries by their structural keys, and attaches\nbindings and listeners. A resolved transferred query therefore does not issue\nthe same initial request again. The transfer script and server style element\nare removed after a successful first pass; the normal client style registry\nthen owns the styles.\n\nCall `app.destroy()` when the application host is removed. Pass `host`,\n`snapshot`, or `onMismatch` when the defaults are not appropriate.\n\nUse `hydrateCraft` directly when the application needs to force hydration or\npass hydration-specific options.\n\n## Choose what SSR does with pending data\n\nThe boundary that owns the pending UI owns its SSR policy. A query still only\ndescribes data and its loader.\n\n```ts\ndiv(UserList()).pipe(\n pendingBlock({\n ssr: 'block',\n fallback: () => UserListSkeleton(),\n }),\n);\n```\n\nThe three modes are:\n\n| Mode | Server action | Initial HTML |\n| ---------- | -------------------------------------- | ---------------------------------------- |\n| `block` | Starts and awaits the suspended source | Resolved content and query snapshot |\n| `fallback` | Does not await the source | Boundary fallback |\n| `client` | Does not start the source | Explicit browser-owned shell or fallback |\n\n`client` requires an explicit fallback in the catch-all form. An exhaustive\nboundary already supplies explicit source fallbacks.\n\nA route can provide the page default:\n\n```ts\ncraftRoute('dashboard', {\n path: 'dashboard',\n loadComponent: () => import('./dashboard'),\n ssr: { mode: 'block' },\n});\n```\n\nThe nearest local `pendingBlock` wins over the route policy. A read that\nsuspends without either policy fails with\n`CraftUnhandledSsrResolutionError`; Craft never silently skips the loader or\nwaits forever. Reloading queries keep rendering their previous value and do\nnot suspend.\n\n## Structural identity and local recovery\n\nHydration keys come from the component and template path, not from a global\ncounter or random id. Static children use their logical position, blocks use a\nstable boundary segment, and `each` entries use the declared business key.\nServer and client must therefore execute the same template structure and use\nstable `each` keys.\n\nIf a key is absent, a tag differs, text changed, or a dynamic branch no longer\nmatches, Craft recreates that local subtree and keeps compatible siblings. In\ndevelopment it also reports a `HydrationMismatchError` containing the key,\nexpected node, actual node, and reason.\n\n## Transfer snapshot rules\n\nOnly values composed of JSON primitives, plain objects, and arrays are\ntransferred. Functions, `bigint`, class instances, and cycles fail explicitly.\nUnreadable or absent primitive values are omitted. Query entries include their\nstatus, resolved value when present, and a plain `{ name, message }` error when\nthe runtime exposes one.\n\n`serializeCraftTransferSnapshot` escapes `<`, `>`, `&`, U+2028, and U+2029, so\nthe JSON cannot close its `application/json` script element. Treat the snapshot\nas application data nevertheless: do not place secrets in state that reaches\nthe browser.\n\n## Current scope\n\nThis first runtime release covers full-page hydration, deterministic HTML,\nCSS collection, state/query transfer, async boundary policies, keyed `each`\nrecovery, and local mismatch remounts. Streaming, resumability, islands,\ncross-boundary event replay, compiler-generated renderers, and a direct\nserver-function transport are later work.\n"
35
+ "body": "# SSR and hydration\n\nCraft can render a complete application to deterministic HTML on the server,\ntransfer its serializable state, and then attach the browser runtime to the\nexisting DOM. This runtime path does not require the Craft compiler.\n\n**Use it when** the first response must contain useful HTML without rebuilding\nthe same component tree during browser startup.\n\n## Render one isolated request\n\n`renderCraft` creates a new platform, injector, primitive registry, in-memory\nhistory, and storage pair for every call. Do not reuse its injector between\nrequests.\n\n```ts\nimport { renderCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst controller = new AbortController();\nconst rendered = await renderCraft({\n config: appConfig,\n url: '/dashboard?page=2',\n signal: controller.signal,\n timeoutMs: 5_000,\n});\n\nreturn new Response(\n `<!doctype html><html><body>${rendered.html}</body></html>`,\n {\n headers: { 'content-type': 'text/html; charset=utf-8' },\n },\n);\n```\n\nThe result separates `rootHtml`, collected `styles`, and the transfer\n`snapshot`; `html` combines all three. `renderToString(component, options)` is\nthe smaller API when no application config is needed.\n\nAll app initializers are run before the server render. Aborting the request\nrejects pending SSR work with the signal reason. A blocking render that exceeds\n`timeoutMs` rejects with `CraftSsrTimeoutError` and lists the pending sources.\nA route policy may set a shorter `timeoutMs` for the sources it blocks.\n\n## Hydrate the existing DOM\n\nServe the generated `<craft-root>`, style element, and transfer script without\nchanging them. The browser entry point then uses the same app config. `startCraft`\nchooses hydration when the SSR marker is present and falls back to a normal\nclient mount when the page was not rendered by Craft SSR:\n\n```ts\nimport { startCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst app = startCraft({ config: appConfig });\n```\n\nHydration restores the snapshot before creating primitives, claims elements,\ntext markers, and block boundaries by their structural keys, and attaches\nbindings and listeners. A resolved transferred query therefore does not issue\nthe same initial request again. The transfer script and server style element\nare removed after a successful first pass; the normal client style registry\nthen owns the styles.\n\nCall `app.destroy()` when the application host is removed. Pass `host`,\n`snapshot`, or `onMismatch` when the defaults are not appropriate.\n\nUse `hydrateCraft` directly when the application needs to force hydration or\npass hydration-specific options.\n\n## Choose what SSR does with pending data\n\nThe boundary that owns the pending UI owns its SSR policy. A query still only\ndescribes data and its loader.\n\n```ts\ndiv(UserList()).pipe(\n pendingNode({\n ssr: 'block',\n fallback: () => UserListSkeleton(),\n }),\n);\n```\n\nThe three modes are:\n\n| Mode | Server action | Initial HTML |\n| ---------- | -------------------------------------- | ---------------------------------------- |\n| `block` | Starts and awaits the suspended source | Resolved content and query snapshot |\n| `fallback` | Does not await the source | Boundary fallback |\n| `client` | Does not start the source | Explicit browser-owned shell or fallback |\n\n`client` requires an explicit fallback in the catch-all form. An exhaustive\nboundary already supplies explicit source fallbacks.\n\nA route can provide the page default:\n\n```ts\ncraftRoute('dashboard', {\n path: 'dashboard',\n loadComponent: () => import('./dashboard'),\n ssr: { mode: 'block' },\n});\n```\n\nThe nearest local `pendingNode` wins over the route policy. A read that\nsuspends without either policy fails with\n`CraftUnhandledSsrResolutionError`; Craft never silently skips the loader or\nwaits forever. Reloading queries keep rendering their previous value and do\nnot suspend.\n\n## Structural identity and local recovery\n\nHydration keys come from the component and template path, not from a global\ncounter or random id. Static children use their logical position, blocks use a\nstable boundary segment, and `forNode` entries use the declared business key.\nServer and client must therefore execute the same template structure and use\nstable `forNode` keys.\n\nIf a key is absent, a tag differs, text changed, or a dynamic branch no longer\nmatches, Craft recreates that local subtree and keeps compatible siblings. In\ndevelopment it also reports a `HydrationMismatchError` containing the key,\nexpected node, actual node, and reason.\n\n## Transfer snapshot rules\n\nOnly values composed of JSON primitives, plain objects, and arrays are\ntransferred. Functions, `bigint`, class instances, and cycles fail explicitly.\nUnreadable or absent primitive values are omitted. Query entries include their\nstatus, resolved value when present, and a plain `{ name, message }` error when\nthe runtime exposes one.\n\n`serializeCraftTransferSnapshot` escapes `<`, `>`, `&`, U+2028, and U+2029, so\nthe JSON cannot close its `application/json` script element. Treat the snapshot\nas application data nevertheless: do not place secrets in state that reaches\nthe browser.\n\n## Current scope\n\nThis first runtime release covers full-page hydration, deterministic HTML,\nCSS collection, state/query transfer, async boundary policies, keyed `forNode`\nrecovery, and local mismatch remounts. Streaming, resumability, islands,\ncross-boundary event replay, compiler-generated renderers, and a direct\nserver-function transport are later work.\n"
36
36
  },
37
37
  {
38
38
  "path": "/guide/advanced/temporal-runtime",
@@ -42,7 +42,7 @@
42
42
  {
43
43
  "path": "/guide/ai/dev-page",
44
44
  "title": "Live page MCP",
45
- "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`each`). 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 `each` 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"
45
+ "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
46
  },
47
47
  {
48
48
  "path": "/guide/app/abstract-services",
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "path": "/guide/app/register",
74
74
  "title": "craftRegisterFor",
75
- "body": "# craftRegisterFor\n\n`craftRegisterFor` exposes, within a Craft injection scope, the services,\ncomponents and directives that are **currently alive** in it.\n\n**Use it when** a parent must drive several children without each child having to\npush a bespoke API upwards: counters, audio players, selected items, validation\nacross a form section.\n**Not when** one known child is involved — pass it a service or an input\ninstead. A registry trades explicitness for reach.\n\n## Declaring a registry\n\nThe registry is typed from the Craft targets it accepts:\n\n```ts\nimport { craftComputed, craftRegisterFor } from '@craft-ts/core';\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n```\n\nThe first argument is the registry's mandatory name. It generates the two public\nhelpers `RegisterForCounter` and `provideRegisterForCounter`, a convention that\nlets several registries coexist in one scope without name collisions.\n\nWith a single target, the array can be omitted:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor(\n 'Counter',\n Counter,\n ({ Counter }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n }),\n);\n\nconst counters = yield* RegisterForCounter();\nconst total = craftComputed('total', function* () {\n return (yield* counters())?.length ?? 0;\n});\n```\n\nIf a projection uses several groups, every target must be declared:\n\n```ts\ncraftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n }),\n);\n```\n\nThen add the providers returned by `provideRegisterForCounter()` to the scope\nthat should observe the instances:\n\n```ts\nexport const RegisterForDemo = craftComponent({\n name: 'RegisterForDemo',\n providers: [provideRegisterForCounter()],\n // ...\n});\n```\n\nBy default the registry also includes `global` services resolved under that\nscope. To restrict observation to services whose scope matches the parent:\n\n```ts\ncraftRegisterFor('Counter', [Counter], { includeGlobal: false });\n```\n\nThe first declared target is reachable through `RegisterForCounter()` directly;\nadditional targets get their own property, e.g.\n`RegisterForCounter.CounterChild()`.\n\n## The common case — driving child components\n\nEach child creates a `toProvide` service, and the parent providing the registry\nobserves them:\n\n```ts\nconst { Counter, provideCounter } = craftService(\n { name: 'Counter', providedIn: 'toProvide' },\n function* () {\n const counter = yield* state(\n 'counter',\n 0,\n ({ update }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n }),\n );\n\n return counter;\n },\n);\n\nconst CounterChild = craftComponent(\n 'CounterChild',\n { providers: [provideCounter()] },\n function* () {\n return yield* Counter();\n },\n ({ counter }) => div(counter),\n);\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n\nconst CounterBoard = craftComponent(\n 'CounterBoard',\n { providers: [provideRegisterForCounter()] },\n function* () {\n const counters = yield* RegisterForCounter();\n const children = yield* RegisterForCounter.CounterChild();\n\n return {\n incrementAll: function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n },\n childCount: craftComputed('childCount', function* () {\n return (yield* children())?.length ?? 0;\n }),\n };\n },\n ({ incrementAll, childCount }) =>\n section([\n button({ click: incrementAll }, 'Increment every child'),\n p(function* () {\n return `Active children: ${yield* childCount()}`;\n }),\n each([1, 2, 3], () => CounterChild({})),\n ]),\n);\n```\n\nWhen a child is added, its `Counter` appears in the group. When it leaves the\nDOM, the group updates on its own.\n\n## Reading a group\n\nGroups are yieldable from a Craft factory. Their signal is `undefined` while no\ninstance is registered, and returns to `undefined` when the last one is\ndestroyed:\n\n```ts\nconst counters = yield* RegisterForCounter();\n\nconst incrementAll = function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n};\n```\n\nEach entry carries:\n\n- `ref` — the value produced by the service, or the context returned by the\n component/directive factory;\n- `hostName` — the name of the host scope that created the entry.\n\nThe signal is live: the parent never re-subscribes when a child appears or\ndisappears.\n\n## Partial exposure\n\nAs with `craftService`, a group can expose only the façade the parent needs. The\nfirst argument stays `undefined` to keep the yieldable-helper syntax, and\n`$self` is the group's full signal:\n\n```ts\nconst childComponents = yield* RegisterForCounter.CounterChild(\n undefined,\n ({ $self }) => ({\n total: craftComputed(function* () {\n return (yield* $self())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nThe parent then keeps only `total`, `incrementAll` and `decrementAll`. The\ndependency stays precise — the computed values read the group's signal, and\ninstances are still added and removed automatically.\n\n## Derived registry properties\n\nTo share common projections, the second parameter of `craftRegisterFor` receives\nthe groups' signals directly:\n\n```ts\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n totalCounter: craftComputed('totalCounter', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nEach derived property becomes a yieldable helper:\n\n```ts\nconst totalCounter = yield* RegisterForCounter.totalCounter();\nconst incrementAll = yield* RegisterForCounter.incrementAllCounterChild();\n\nconsole.log(yield* totalCounter());\nyield* incrementAll();\n```\n\nFor a single-target registry, the main call also returns the signal enriched\nwith those derived properties — so the value stays callable for the raw entries\nwhile exposing `total` and the added methods:\n\n```ts\nconst childComponents = yield* RegisterForCounterChild();\n\nconst entries = yield* childComponents();\nconst total = yield* childComponents.total();\nyield* childComponents.incrementAllChildCounter();\nyield* childComponents.decrementAllChildCounter();\n```\n\nIn a Craft template, pass a method straight to an event and call signals inside\na reactive callback:\n\n```ts\nbutton({ click: childComponents.incrementAllChildCounter }, 'Increment all');\nspan(function* () {\n return `Children: ${yield* childComponents.total()}`;\n});\n```\n\nThe main group, the additional groups and the derived properties can all be used\ntogether. Derived properties are computed once per registry injector and keep\nthe reactive signals the groups provide.\n\n## Registering a directive\n\nCraft directives can be targets too:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor('Counter', [\n CounterChild,\n CounterDebugDirective,\n]);\n\nconst debugEntries = yield* RegisterForCounter.CounterDebugDirective();\ndebugEntries()?.forEach(({ hostName, ref }) => {\n console.debug('directive active', hostName, ref);\n});\n```\n\nA functional directive has no class instance, so `ref` is the factory context of\nthe decorated component. Its `hostName` remains specific to the directive and\nits instance, which is what lets you tell several identical directives apart on\nthe same screen.\n\n## Lifecycle and references\n\nServices are registered when their yield resolves. The runtime attaches their\nremoval to the destruction of the injector that carries them.\n\nCraft components and directives are functional factories with no class instance,\nso `ref` is their factory context. For a directive used with `.pipe(...)`, the\nfinal component's context is exposed, because that is the execution scope the\ndirective shares.\n\nEvery Craft component automatically gets a host tag of the form\n`component:<ComponentName>#<id>`, so `provideHostName` is not needed in a\ncomponent's providers — it stays useful only to override that automatic name.\nDirectives applied to an element get their own `hostName`, generated from the\ndirective name and an instance id. These names distinguish two identical\ninstances and are usable for diagnostics and observability.\n\nEntries are removed automatically in every case: destruction of the\ncomponent/directive, destruction of its DI scope, or replacement of a\ncomposition.\n\n## Pitfalls\n\n::: warning An empty registry is not an error\nCompilation checks that the target you pass to `craftRegisterFor` is a valid\nCraft service, component or directive — but it cannot check that an instance\nwill ever be created. If no registered target exists in the executed code, there\nis no compile error and no runtime error: the signal is simply `undefined`.\n:::\n\n::: warning Craft targets only\n`craftRegisterFor` does not detect arbitrary classes. It targets\n`craftService`, `craftComponent` and `craftDirective`, whose scope and lifecycle\nthe runtime knows.\n:::\n\n**Declaring the same target twice** in the list is not supported — each target\nappears once.\n\n**Treating the group signal as always populated.** It is `undefined` before the\nfirst instance and after the last one; the `?.` is not optional.\n\n::: details Extending the mechanism — target and yield wrappers\nThe registry rests on two separate pieces:\n\n1. a **yield wrapper** observes services as they are actually resolved;\n2. the component/directive **runtime** reports their creation and ties cleanup\n to their lifecycle.\n\nThe first is `provideCraftTargetWrapper`, documented on\n[Target wrapper](/guide/app/target-wrapper). The second is\n`provideServiceYieldWrapper`, the low-level hook `craftRegisterFor` uses to wrap\nevery Craft service resolution in the scope where the yield runs — deliberately\nclose to `provideFnWrapper`, but limited to service yields:\n\n```ts\nimport {\n provideServiceYieldWrapper,\n type ServiceYieldContext,\n} from '@craft-ts/core';\n\nfunction* reportServiceYield(\n context: ServiceYieldContext,\n next: () => Generator<unknown, unknown, unknown>,\n) {\n const startedAt = performance.now();\n const value = yield* next();\n\n console.debug('service resolved', {\n name: context.name,\n hostScope: context.hostScope,\n duration: performance.now() - startedAt,\n });\n\n return value;\n}\n\nexport const providers = [\n provideServiceYieldWrapper(\n 'Warning: the wrapper runs in the current Craft injection context.',\n reportServiceYield,\n ),\n];\n```\n\n`context.resolve()` resolves the real service; `next()` keeps the wrapper chain\nintact. Wrappers compose in registration order — the first is the outermost. The\ncontext provides `name`, `scope`, `hostScope`, `injector` and `resolve`. Like\n`provideFnWrapper`, this hook suits cross-cutting concerns — registries,\nmetrics, traces, diagnostics — not business logic.\n\nA new tool can reuse `provideServiceYieldWrapper` to observe services without\n`craftRegisterFor` at all. For functional Craft targets the runtime also exposes\nits internal registration primitives, so another specialised view can be built —\nbut `craftRegisterFor` stays the recommended application-level API.\n:::\n\n## See Also\n\n- [Target wrapper](/guide/app/target-wrapper) — the extension point underneath\n- [craftService](/guide/app/craft-service)\n- [Customization](/guide/components/customization)\n"
75
+ "body": "# craftRegisterFor\n\n`craftRegisterFor` exposes, within a Craft injection scope, the services,\ncomponents and directives that are **currently alive** in it.\n\n**Use it when** a parent must drive several children without each child having to\npush a bespoke API upwards: counters, audio players, selected items, validation\nacross a form section.\n**Not when** one known child is involved — pass it a service or an input\ninstead. A registry trades explicitness for reach.\n\n## Declaring a registry\n\nThe registry is typed from the Craft targets it accepts:\n\n```ts\nimport { craftComputed, craftRegisterFor } from '@craft-ts/core';\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n```\n\nThe first argument is the registry's mandatory name. It generates the two public\nhelpers `RegisterForCounter` and `provideRegisterForCounter`, a convention that\nlets several registries coexist in one scope without name collisions.\n\nWith a single target, the array can be omitted:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor(\n 'Counter',\n Counter,\n ({ Counter }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n }),\n);\n\nconst counters = yield* RegisterForCounter();\nconst total = craftComputed('total', function* () {\n return (yield* counters())?.length ?? 0;\n});\n```\n\nIf a projection uses several groups, every target must be declared:\n\n```ts\ncraftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n }),\n);\n```\n\nThen add the providers returned by `provideRegisterForCounter()` to the scope\nthat should observe the instances:\n\n```ts\nexport const RegisterForDemo = craftComponent({\n name: 'RegisterForDemo',\n providers: [provideRegisterForCounter()],\n // ...\n});\n```\n\nBy default the registry also includes `global` services resolved under that\nscope. To restrict observation to services whose scope matches the parent:\n\n```ts\ncraftRegisterFor('Counter', [Counter], { includeGlobal: false });\n```\n\nThe first declared target is reachable through `RegisterForCounter()` directly;\nadditional targets get their own property, e.g.\n`RegisterForCounter.CounterChild()`.\n\n## The common case — driving child components\n\nEach child creates a `toProvide` service, and the parent providing the registry\nobserves them:\n\n```ts\nconst { Counter, provideCounter } = craftService(\n { name: 'Counter', providedIn: 'toProvide' },\n function* () {\n const counter = yield* state(\n 'counter',\n 0,\n ({ update }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n }),\n );\n\n return counter;\n },\n);\n\nconst CounterChild = craftComponent(\n 'CounterChild',\n { providers: [provideCounter()] },\n function* () {\n return yield* Counter();\n },\n ({ counter }) => div(counter),\n);\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n\nconst CounterBoard = craftComponent(\n 'CounterBoard',\n { providers: [provideRegisterForCounter()] },\n function* () {\n const counters = yield* RegisterForCounter();\n const children = yield* RegisterForCounter.CounterChild();\n\n return {\n incrementAll: function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n },\n childCount: craftComputed('childCount', function* () {\n return (yield* children())?.length ?? 0;\n }),\n };\n },\n ({ incrementAll, childCount }) =>\n section([\n button({ click: incrementAll }, 'Increment every child'),\n p(function* () {\n return `Active children: ${yield* childCount()}`;\n }),\n forNode([1, 2, 3], () => CounterChild({})),\n ]),\n);\n```\n\nWhen a child is added, its `Counter` appears in the group. When it leaves the\nDOM, the group updates on its own.\n\n## Reading a group\n\nGroups are yieldable from a Craft factory. Their signal is `undefined` while no\ninstance is registered, and returns to `undefined` when the last one is\ndestroyed:\n\n```ts\nconst counters = yield* RegisterForCounter();\n\nconst incrementAll = function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n};\n```\n\nEach entry carries:\n\n- `ref` — the value produced by the service, or the context returned by the\n component/directive factory;\n- `hostName` — the name of the host scope that created the entry.\n\nThe signal is live: the parent never re-subscribes when a child appears or\ndisappears.\n\n## Partial exposure\n\nAs with `craftService`, a group can expose only the façade the parent needs. The\nfirst argument stays `undefined` to keep the yieldable-helper syntax, and\n`$self` is the group's full signal:\n\n```ts\nconst childComponents = yield* RegisterForCounter.CounterChild(\n undefined,\n ({ $self }) => ({\n total: craftComputed(function* () {\n return (yield* $self())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nThe parent then keeps only `total`, `incrementAll` and `decrementAll`. The\ndependency stays precise — the computed values read the group's signal, and\ninstances are still added and removed automatically.\n\n## Derived registry properties\n\nTo share common projections, the second parameter of `craftRegisterFor` receives\nthe groups' signals directly:\n\n```ts\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n totalCounter: craftComputed('totalCounter', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nEach derived property becomes a yieldable helper:\n\n```ts\nconst totalCounter = yield* RegisterForCounter.totalCounter();\nconst incrementAll = yield* RegisterForCounter.incrementAllCounterChild();\n\nconsole.log(yield* totalCounter());\nyield* incrementAll();\n```\n\nFor a single-target registry, the main call also returns the signal enriched\nwith those derived properties — so the value stays callable for the raw entries\nwhile exposing `total` and the added methods:\n\n```ts\nconst childComponents = yield* RegisterForCounterChild();\n\nconst entries = yield* childComponents();\nconst total = yield* childComponents.total();\nyield* childComponents.incrementAllChildCounter();\nyield* childComponents.decrementAllChildCounter();\n```\n\nIn a Craft template, pass a method straight to an event and call signals inside\na reactive callback:\n\n```ts\nbutton({ click: childComponents.incrementAllChildCounter }, 'Increment all');\nspan(function* () {\n return `Children: ${yield* childComponents.total()}`;\n});\n```\n\nThe main group, the additional groups and the derived properties can all be used\ntogether. Derived properties are computed once per registry injector and keep\nthe reactive signals the groups provide.\n\n## Registering a directive\n\nCraft directives can be targets too:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor('Counter', [\n CounterChild,\n CounterDebugDirective,\n]);\n\nconst debugEntries = yield* RegisterForCounter.CounterDebugDirective();\ndebugEntries()?.forEach(({ hostName, ref }) => {\n console.debug('directive active', hostName, ref);\n});\n```\n\nA functional directive has no class instance, so `ref` is the factory context of\nthe decorated component. Its `hostName` remains specific to the directive and\nits instance, which is what lets you tell several identical directives apart on\nthe same screen.\n\n## Lifecycle and references\n\nServices are registered when their yield resolves. The runtime attaches their\nremoval to the destruction of the injector that carries them.\n\nCraft components and directives are functional factories with no class instance,\nso `ref` is their factory context. For a directive used with `.pipe(...)`, the\nfinal component's context is exposed, because that is the execution scope the\ndirective shares.\n\nEvery Craft component automatically gets a host tag of the form\n`component:<ComponentName>#<id>`, so `provideHostName` is not needed in a\ncomponent's providers — it stays useful only to override that automatic name.\nDirectives applied to an element get their own `hostName`, generated from the\ndirective name and an instance id. These names distinguish two identical\ninstances and are usable for diagnostics and observability.\n\nEntries are removed automatically in every case: destruction of the\ncomponent/directive, destruction of its DI scope, or replacement of a\ncomposition.\n\n## Pitfalls\n\n::: warning An empty registry is not an error\nCompilation checks that the target you pass to `craftRegisterFor` is a valid\nCraft service, component or directive — but it cannot check that an instance\nwill ever be created. If no registered target exists in the executed code, there\nis no compile error and no runtime error: the signal is simply `undefined`.\n:::\n\n::: warning Craft targets only\n`craftRegisterFor` does not detect arbitrary classes. It targets\n`craftService`, `craftComponent` and `craftDirective`, whose scope and lifecycle\nthe runtime knows.\n:::\n\n**Declaring the same target twice** in the list is not supported — each target\nappears once.\n\n**Treating the group signal as always populated.** It is `undefined` before the\nfirst instance and after the last one; the `?.` is not optional.\n\n::: details Extending the mechanism — target and yield wrappers\nThe registry rests on two separate pieces:\n\n1. a **yield wrapper** observes services as they are actually resolved;\n2. the component/directive **runtime** reports their creation and ties cleanup\n to their lifecycle.\n\nThe first is `provideCraftTargetWrapper`, documented on\n[Target wrapper](/guide/app/target-wrapper). The second is\n`provideServiceYieldWrapper`, the low-level hook `craftRegisterFor` uses to wrap\nevery Craft service resolution in the scope where the yield runs — deliberately\nclose to `provideFnWrapper`, but limited to service yields:\n\n```ts\nimport {\n provideServiceYieldWrapper,\n type ServiceYieldContext,\n} from '@craft-ts/core';\n\nfunction* reportServiceYield(\n context: ServiceYieldContext,\n next: () => Generator<unknown, unknown, unknown>,\n) {\n const startedAt = performance.now();\n const value = yield* next();\n\n console.debug('service resolved', {\n name: context.name,\n hostScope: context.hostScope,\n duration: performance.now() - startedAt,\n });\n\n return value;\n}\n\nexport const providers = [\n provideServiceYieldWrapper(\n 'Warning: the wrapper runs in the current Craft injection context.',\n reportServiceYield,\n ),\n];\n```\n\n`context.resolve()` resolves the real service; `next()` keeps the wrapper chain\nintact. Wrappers compose in registration order — the first is the outermost. The\ncontext provides `name`, `scope`, `hostScope`, `injector` and `resolve`. Like\n`provideFnWrapper`, this hook suits cross-cutting concerns — registries,\nmetrics, traces, diagnostics — not business logic.\n\nA new tool can reuse `provideServiceYieldWrapper` to observe services without\n`craftRegisterFor` at all. For functional Craft targets the runtime also exposes\nits internal registration primitives, so another specialised view can be built —\nbut `craftRegisterFor` stays the recommended application-level API.\n:::\n\n## See Also\n\n- [Target wrapper](/guide/app/target-wrapper) — the extension point underneath\n- [craftService](/guide/app/craft-service)\n- [Customization](/guide/components/customization)\n"
76
76
  },
77
77
  {
78
78
  "path": "/guide/app/service-scopes",
@@ -87,57 +87,57 @@
87
87
  {
88
88
  "path": "/guide/components",
89
89
  "title": "Components",
90
- "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 `each` rendering](/guide/components/schedule-each) 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 — `each`, `ifBlock`,\n`matchBlock`, `defer`. 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 `each`.** 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(catchBlock.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"
90
+ "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"
91
91
  },
92
92
  {
93
93
  "path": "/guide/components/accessibility",
94
94
  "title": "Accessibility",
95
- "body": "# Accessibility\n\nCraft already enforces exhaustive exceptions, `pendingBlock`, and reactive\ntemplates. Accessibility follows the same DNA: **an illegal state doesn't\ncompile, an omission is an ESLint error, the block runtime doesn't wait for\nthe author to remember.**\n\nTarget: **WCAG 2.2 level AA**.\n\n## The five layers\n\n1. **Types** — `img` and `area` require `alt` (including a decorative `''`).\n Semantic helpers (`dialog`, `fieldset`, `table`, `iframe`, `h4`–`h6`,\n `svg`…) exist so the lint applies without going through `h()`.\n2. **ESLint `craft-ts/a11y`** — accessible name, labels, ARIA, no click on a\n `div`, `button` with `type`, `h()` forbidden when a named helper exists.\n3. **Block runtime** — `pendingBlock` announces the fallback (`aria-live`,\n `aria-busy`), `catchBlock` sets `role=\"alert\"`, `defer` renders a keyboard\n placeholder, `CraftRouterLink` sets `aria-current=\"page\"`.\n4. **Primitives** — `heading` / `headingSection` (relative outline), `dialog`\n (native modal + focus), `liveRegion` (toasts). No **styled** button:\n `buttonControl` / `fieldControl` / `disclosureControl` inject accessibility\n props into your native elements.\n5. **Tests** — `toBeAccessible()` on the template helper.\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n plugins: { 'craft-ts': craftRules },\n rules: {\n ...craftRules.configs.a11y.rules,\n },\n },\n];\n```\n\nThe rules are `error` in the preset. A disable is a documented deviation,\nnot the default path.\n\n## Hyperscript templates\n\nA Craft template is TypeScript, not an `.html` file: accessibility rules\noperate on the hyperscript calls themselves — `button(...)`, `img(...)` **and**\n`h('img', …)`.\n\n```ts\nimg({ src: photo.url, alt: photo.title }); // decorative: alt: ''\nbutton({ type: 'button' }, 'Save');\na({ href: '/tasks' }, 'Tasks');\nlabel({ htmlFor: 'email' }, 'Email');\ninput({ id: 'email', type: 'email' });\n```\n\n`h('button')` when a named helper exists is an error\n(`prefer-named-html-helpers`): it's a bypass of the types.\n\n## Heading outline\n\nAn `h3` inside a Card is a classic false positive: sometimes under an `h1`,\nsometimes under an `h2`. The title doesn't choose its rank. **The parent\nsupplies it.**\n\n```ts\nheading('Task list');\n\nheadingSection([\n heading('Detail'),\n TaskCard(), // the internal heading() becomes hN+1\n]);\n```\n\nThe snippet above is the core of the API. The skip-link and `main` belong to\nthe application shell:\n\n- `heading()` reads the current level (1–6) and renders `h1`…`h6`.\n- `headingSection(...)` increments by one for the subtree — comment\n fragments, no DOM wrapper, like `ifBlock`.\n- `headingRoot(...)` resets to `h1` (dialog, explicit reset). A `dialog` also\n sets its own outline root (the dialog title = level 1 **inside** the\n dialog). SFCs loaded via `loadComponent` stay on `heading()`.\n- `h1()`…`h6()` remain for raw HTML. The `prefer-relative-heading` rule\n forbids them inside a `craftComponent` (outside specs).\n\nA reusable component exposes `heading()` without a local `headingSection`:\nthe need for an outline **bubbles up** to the parent. Calling this component\noutside a `headingSection` **doesn't compile** (same DNA as `pendingBlock`).\n\nAny SFC mounted via `loadComponent` / `loadCraftComponent` calls `heading()` —\nnot `headingRoot()`. The rank (h1 vs h2+) comes from the parent:\n\n- **Page** (sibling under the shell): `heading()` is the h1.\n- **Layout** (SFC with `CraftRouterOutlet`): `heading()` +\n `headingSection([…, CraftRouterOutlet()])` so the child inherits h2+.\n- **Shell** (`App`): `skipLink` + `main` + `CraftRouterOutlet`, **without**\n `heading()` above the outlet. Otherwise two h1s, or children stuck at the\n same level as the chrome's title.\n\n`require-route-heading-outline` reads the lazy target.\n`require-outlet-heading-section` distinguishes layout from shell. The types\ndon't connect the outlet to the routed child.\n\n```ts\n// Shell — no heading() above the outlet\nskipLink('main', 'Skip to content');\nmain({ id: 'main', tabIndex: -1 }, CraftRouterOutlet());\n\n// Layout — title + outlet inside headingSection\nheading('Team');\nheadingSection([CraftRouterOutlet()]);\n\n// Page (loadComponent) — heading() only; h1 or h2+ depending on the parent\nheading('Task list');\nheadingSection([\n heading('Detail'),\n TaskCard(),\n]);\n```\n\n## Blocks\n\n`pendingBlock` detaches the source from the document while loading (the\nnodes stay mounted, they aren't CSS `hidden`). The fallback is wrapped in\n`aria-live=\"polite\"` `aria-atomic=\"true\"` `aria-busy=\"true\"`. On reload, the\nsource stays visible; `aria-busy` signals the refresh. Focus in the source is\nrestored when it resumes.\n\n`catchBlock` wraps the error message in `role=\"alert\"` if the fallback isn't\nalready a live region.\n\n`defer` sets `aria-busy` while loading. An `interaction` trigger on a\nplaceholder that isn't already a control gets `role=\"button\"` and\n`tabIndex=\"0\"`, and only fires on keyboard via Enter / Space.\n\n## Dialog and live region\n\n```ts\ndialog(\n { labelledBy: 'title', open: true, onClose },\n [heading({ id: 'title' }, 'Confirm'), button({ type: 'button', click: onClose }, 'Close')],\n);\n\nliveRegion({ politeness: 'polite' }, copied() ? 'Copied' : '');\n```\n\n`dialog` relies on the native `<dialog>` (`showModal`, Escape, `aria-modal`).\n`liveRegion` is a `<span role=\"status\">` (or `alert` if `assertive`).\n\n## Control helpers (props to merge)\n\nThe helpers are renderless: they supply the attributes to merge onto your own\nHTML elements, without imposing a visual widget.\n\n```ts\nconst email = fieldControl('email');\nlabel(email.label, 'Email');\ninput({ ...email.input, type: 'email' });\np(email.description, 'We never share your email.');\n\nconst faq = disclosureControl('faq-1', isOpen);\nbutton({ ...faq.button, click: toggle }, 'What is Craft?');\ndiv(faq.panel, '…');\n\nbutton(buttonControl({ disabled: isSaving, keepFocusable: true }), 'Save');\n```\n\nA closed panel gets `hidden` and `aria-hidden`, so no focus stays inside it.\n`keepFocusable` sets `aria-disabled` without `disabled`: the click isn't cut\noff, the author must no-op the handler.\n\nThe states are also exposed as `data-*`, which allows a simple CSS\nconvention independent of the component:\n\n```css\nbutton[data-disabled] { opacity: 0.5; }\ninput[data-invalid] { border-color: var(--danger); }\nbutton[data-open] { font-weight: 600; }\n```\n\nA live region must be mounted from the very first render: never condition\nits node on the message. This lets the screen reader subscribe to it before\nany event happens.\n\n```ts\n// correct — region exists at first paint\nliveRegion({ label: 'Notifications' }, copied() ? 'Copied' : '');\n\n// incorrect — SR never subscribes\nifBlock(copied, () => liveRegion('Copied'));\n```\n\n## Navigation\n\n`provideCraftRouter` registers `CraftTitleStrategy`: the route's `title` is\nwritten via `BrowserDocument.setTitle`.\n\n`withA11yNavigationFocus()` (opt-in, passed to `provideCraftRouter`) moves\nfocus to `#main` / `<main>` after each internal navigation — not on first\nload, the skip-link handles that.\n\n`skipLink('main', 'Skip to content')` at the top of the shell, with\n`main({ id: 'main', tabIndex: -1 }, …)`.\n\nTo sync the document's language and direction from a generator:\n\n```ts\nyield* BrowserDocument.setLang('en');\nyield* BrowserDocument.setDir('ltr');\n```\n\n`clickFocus` sets focus before running the handler, useful for controls that\nopen a search or a dialog:\n\n```ts\nbutton({\n type: 'button',\n click: clickFocus('#search-warmup', openSearch),\n}, 'Search');\n```\n\n## Tests\n\n```ts\nconst { getByRole, getByLabel, toBeAccessible } =\n await setupCraftComponentTemplateTest(\n Page,\n { context },\n );\nawait toBeAccessible();\ngetByRole('button', { name: 'Save' });\ngetByLabel('Email');\n```\n\n`assertAccessible` / `toBeAccessible()` cover the structural checks (alt,\naccessible name, tabindex, iframe title). Real contrast and the rest of\nWCAG 2.2 AA remain a job for axe / AccessLint in application CI.\n\n## CSS\n\nThe `require-focus-visible` and `require-reduced-motion` rules apply to the\n`styles` of a `craftComponent`: if you style `button` / `a` / `input`, define\n`:focus-visible`; if you animate, gate it with `prefers-reduced-motion`.\nContrast goes through tokens (`no-hardcoded-design-values`), not a second CSS\nlinter.\n"
95
+ "body": "# Accessibility\n\nCraft already enforces exhaustive exceptions, `pendingNode`, and reactive\ntemplates. Accessibility follows the same DNA: **an illegal state doesn't\ncompile, an omission is an ESLint error, the block runtime doesn't wait for\nthe author to remember.**\n\nTarget: **WCAG 2.2 level AA**.\n\n## The five layers\n\n1. **Types** — `img` and `area` require `alt` (including a decorative `''`).\n Semantic helpers (`dialog`, `fieldset`, `table`, `iframe`, `h4`–`h6`,\n `svg`…) exist so the lint applies without going through `h()`.\n2. **ESLint `craft-ts/a11y`** — accessible name, labels, ARIA, no click on a\n `div`, `button` with `type`, `h()` forbidden when a named helper exists.\n3. **Block runtime** — `pendingNode` announces the fallback (`aria-live`,\n `aria-busy`), `catchNode` sets `role=\"alert\"`, `deferNode` renders a keyboard\n placeholder, `CraftRouterLink` sets `aria-current=\"page\"`.\n4. **Primitives** — `heading` / `headingSection` (relative outline), `dialog`\n (native modal + focus), `liveRegion` (toasts). No **styled** button:\n `buttonControl` / `fieldControl` / `disclosureControl` inject accessibility\n props into your native elements.\n5. **Tests** — `toBeAccessible()` on the template helper.\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n plugins: { 'craft-ts': craftRules },\n rules: {\n ...craftRules.configs.a11y.rules,\n },\n },\n];\n```\n\nThe rules are `error` in the preset. A disable is a documented deviation,\nnot the default path.\n\n## Hyperscript templates\n\nA Craft template is TypeScript, not an `.html` file: accessibility rules\noperate on the hyperscript calls themselves — `button(...)`, `img(...)` **and**\n`h('img', …)`.\n\n```ts\nimg({ src: photo.url, alt: photo.title }); // decorative: alt: ''\nbutton({ type: 'button' }, 'Save');\na({ href: '/tasks' }, 'Tasks');\nlabel({ htmlFor: 'email' }, 'Email');\ninput({ id: 'email', type: 'email' });\n```\n\n`h('button')` when a named helper exists is an error\n(`prefer-named-html-helpers`): it's a bypass of the types.\n\n## Heading outline\n\nAn `h3` inside a Card is a classic false positive: sometimes under an `h1`,\nsometimes under an `h2`. The title doesn't choose its rank. **The parent\nsupplies it.**\n\n```ts\nheading('Task list');\n\nheadingSection([\n heading('Detail'),\n TaskCard(), // the internal heading() becomes hN+1\n]);\n```\n\nThe snippet above is the core of the API. The skip-link and `main` belong to\nthe application shell:\n\n- `heading()` reads the current level (1–6) and renders `h1`…`h6`.\n- `headingSection(...)` increments by one for the subtree — comment\n fragments, no DOM wrapper, like `ifNode`.\n- `headingRoot(...)` resets to `h1` (dialog, explicit reset). A `dialog` also\n sets its own outline root (the dialog title = level 1 **inside** the\n dialog). SFCs loaded via `loadComponent` stay on `heading()`.\n- `h1()`…`h6()` remain for raw HTML. The `prefer-relative-heading` rule\n forbids them inside a `craftComponent` (outside specs).\n\nA reusable component exposes `heading()` without a local `headingSection`:\nthe need for an outline **bubbles up** to the parent. Calling this component\noutside a `headingSection` **doesn't compile** (same DNA as `pendingNode`).\n\nAny SFC mounted via `loadComponent` / `loadCraftComponent` calls `heading()` —\nnot `headingRoot()`. The rank (h1 vs h2+) comes from the parent:\n\n- **Page** (sibling under the shell): `heading()` is the h1.\n- **Layout** (SFC with `CraftRouterOutlet`): `heading()` +\n `headingSection([…, CraftRouterOutlet()])` so the child inherits h2+.\n- **Shell** (`App`): `skipLink` + `main` + `CraftRouterOutlet`, **without**\n `heading()` above the outlet. Otherwise two h1s, or children stuck at the\n same level as the chrome's title.\n\n`require-route-heading-outline` reads the lazy target.\n`require-outlet-heading-section` distinguishes layout from shell. The types\ndon't connect the outlet to the routed child.\n\n```ts\n// Shell — no heading() above the outlet\nskipLink('main', 'Skip to content');\nmain({ id: 'main', tabIndex: -1 }, CraftRouterOutlet());\n\n// Layout — title + outlet inside headingSection\nheading('Team');\nheadingSection([CraftRouterOutlet()]);\n\n// Page (loadComponent) — heading() only; h1 or h2+ depending on the parent\nheading('Task list');\nheadingSection([\n heading('Detail'),\n TaskCard(),\n]);\n```\n\n## Blocks\n\n`pendingNode` detaches the source from the document while loading (the\nnodes stay mounted, they aren't CSS `hidden`). The fallback is wrapped in\n`aria-live=\"polite\"` `aria-atomic=\"true\"` `aria-busy=\"true\"`. On reload, the\nsource stays visible; `aria-busy` signals the refresh. Focus in the source is\nrestored when it resumes.\n\n`catchNode` wraps the error message in `role=\"alert\"` if the fallback isn't\nalready a live region.\n\n`deferNode` sets `aria-busy` while loading. An `interaction` trigger on a\nplaceholder that isn't already a control gets `role=\"button\"` and\n`tabIndex=\"0\"`, and only fires on keyboard via Enter / Space.\n\n## Dialog and live region\n\n```ts\ndialog(\n { labelledBy: 'title', open: true, onClose },\n [heading({ id: 'title' }, 'Confirm'), button({ type: 'button', click: onClose }, 'Close')],\n);\n\nliveRegion({ politeness: 'polite' }, copied() ? 'Copied' : '');\n```\n\n`dialog` relies on the native `<dialog>` (`showModal`, Escape, `aria-modal`).\n`liveRegion` is a `<span role=\"status\">` (or `alert` if `assertive`).\n\n## Control helpers (props to merge)\n\nThe helpers are renderless: they supply the attributes to merge onto your own\nHTML elements, without imposing a visual widget.\n\n```ts\nconst email = fieldControl('email');\nlabel(email.label, 'Email');\ninput({ ...email.input, type: 'email' });\np(email.description, 'We never share your email.');\n\nconst faq = disclosureControl('faq-1', isOpen);\nbutton({ ...faq.button, click: toggle }, 'What is Craft?');\ndiv(faq.panel, '…');\n\nbutton(buttonControl({ disabled: isSaving, keepFocusable: true }), 'Save');\n```\n\nA closed panel gets `hidden` and `aria-hidden`, so no focus stays inside it.\n`keepFocusable` sets `aria-disabled` without `disabled`: the click isn't cut\noff, the author must no-op the handler.\n\nThe states are also exposed as `data-*`, which allows a simple CSS\nconvention independent of the component:\n\n```css\nbutton[data-disabled] { opacity: 0.5; }\ninput[data-invalid] { border-color: var(--danger); }\nbutton[data-open] { font-weight: 600; }\n```\n\nA live region must be mounted from the very first render: never condition\nits node on the message. This lets the screen reader subscribe to it before\nany event happens.\n\n```ts\n// correct — region exists at first paint\nliveRegion({ label: 'Notifications' }, copied() ? 'Copied' : '');\n\n// incorrect — SR never subscribes\nifNode(copied, () => liveRegion('Copied'));\n```\n\n## Navigation\n\n`provideCraftRouter` registers `CraftTitleStrategy`: the route's `title` is\nwritten via `BrowserDocument.setTitle`.\n\n`withA11yNavigationFocus()` (opt-in, passed to `provideCraftRouter`) moves\nfocus to `#main` / `<main>` after each internal navigation — not on first\nload, the skip-link handles that.\n\n`skipLink('main', 'Skip to content')` at the top of the shell, with\n`main({ id: 'main', tabIndex: -1 }, …)`.\n\nTo sync the document's language and direction from a generator:\n\n```ts\nyield* BrowserDocument.setLang('en');\nyield* BrowserDocument.setDir('ltr');\n```\n\n`clickFocus` sets focus before running the handler, useful for controls that\nopen a search or a dialog:\n\n```ts\nbutton({\n type: 'button',\n click: clickFocus('#search-warmup', openSearch),\n}, 'Search');\n```\n\n## Tests\n\n```ts\nconst { getByRole, getByLabel, toBeAccessible } =\n await setupCraftComponentTemplateTest(\n Page,\n { context },\n );\nawait toBeAccessible();\ngetByRole('button', { name: 'Save' });\ngetByLabel('Email');\n```\n\n`assertAccessible` / `toBeAccessible()` cover the structural checks (alt,\naccessible name, tabindex, iframe title). Real contrast and the rest of\nWCAG 2.2 AA remain a job for axe / AccessLint in application CI.\n\n## CSS\n\nThe `require-focus-visible` and `require-reduced-motion` rules apply to the\n`styles` of a `craftComponent`: if you style `button` / `a` / `input`, define\n`:focus-visible`; if you animate, gate it with `prefers-reduced-motion`.\nContrast goes through tokens (`no-hardcoded-design-values`), not a second CSS\nlinter.\n"
96
96
  },
97
97
  {
98
98
  "path": "/guide/components/content-projection",
99
99
  "title": "Content projection",
100
- "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 each(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 `each` uses to reuse, move or remove the right\nprojection.\n\n```ts\nimport {\n craftComponent,\n div,\n each,\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 each(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 each(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 ? each(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"
100
+ "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"
101
101
  },
102
102
  {
103
103
  "path": "/guide/components/css-variables",
104
104
  "title": "Typed CSS variables and design tokens",
105
- "body": "# Typed CSS variables and design tokens\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\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\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"
105
+ "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"
106
106
  },
107
107
  {
108
108
  "path": "/guide/components/customization",
109
109
  "title": "Customizing components and directives",
110
- "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 `catchBlock.exhaustive` or\n`matchBlock.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 `catchBlock` or `matchBlock` 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- `catchBlock.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchBlock.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 `catchBlock` or `matchBlock`.\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### `catchBlock.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 catchBlock.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`catchBlock` 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 catchBlock.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### `matchBlock.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\nmatchBlock.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"
110
+ "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"
111
111
  },
112
112
  {
113
113
  "path": "/guide/components/directives",
114
114
  "title": "Directives and `.pipe(...)`",
115
- "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\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\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 be applied with `.pipe(...)`. Their declared\ninputs are consumed by the directive instead of becoming DOM attributes:\n\n```ts\nbutton({ craftRouterLink: link }).pipe(CraftRouterLink);\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"
115
+ "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"
116
116
  },
117
117
  {
118
118
  "path": "/guide/components/fine-grained-reactivity",
119
119
  "title": "Fine-grained reactivity",
120
- "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\nifBlock(\n hasItems,\n () => CartItems({ items: () => items() }),\n () => p('Your cart is empty.'),\n);\n\neach(items, { track: (item) => item.id }, (item) => p(item.name));\n```\n\n`ifBlock`, `each`, `matchBlock.exhaustive`, and `defer` 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 `each` rendering\n\nSee the dedicated [Progressive `each` rendering](/guide/components/schedule-each)\nguide for the complete usage and trade-offs.\n\n`each` is synchronous by default. For a large collection, opt into frame-based\nbatching on the `each` node itself:\n\n```ts\neach(items, { track: (item) => item.id }, (item) => p(item.name)).pipe(\n scheduleEach({\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 `ifBlock`, `each`,\n `matchBlock.exhaustive`, or `defer`.\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"
120
+ "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"
121
121
  },
122
122
  {
123
- "path": "/guide/components/pending-block",
124
- "title": "settledValue & pendingBlock",
125
- "body": "# settledValue & pendingBlock\n\nReading an async value in a template without ever handling `undefined` — and\nbeing told at **compile time** when the loading state has nowhere to go.\n\n**Use it when** a template renders data that comes from a `query`.\n**Not when** you want to drive the loading state yourself: `query.value()`\n(`T | undefined`) and `query.status()` stay exactly as they were.\n\n## Import\n\n```typescript\nimport { settled } from '@craft-ts/core';\nimport { pendingBlock } from '@craft-ts/component';\n```\n\n## Overview\n\nA resource-like `query`, `mutation` or `asyncProcess` exposes a second read next\nto `value`:\n\n```typescript\nusers.value(); // User[] | undefined — you handle the wait\nusers.settledValue(); // User[] — the wait is handled for you\n```\n\n`settledValue` never returns `undefined` and never returns a value while the\nsource carries an exception. When there is nothing to show it **suspends**: it\nthrows a `CraftNotSettled` that the nearest `pendingBlock` turns into a\nfallback. A business exception throws through the existing channel instead, and\nlands in the nearest `catchBlock`.\n\nBecause the dependency is visible in the types, a template that renders a\nsuspending value with no `pendingBlock` around it does not compile.\n\n## Reading a settled value in a computed\n\nInside a `craftComputed` generator, `yield* settled(ref)` hands back the\nresource's settled read:\n\n```typescript\nconst teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n // `list()` is `User[]` here — never undefined, never in exception\n return () => [...new Set(list().map((user) => user.team))].sort();\n});\n```\n\nNothing is awaited and nothing is yielded at runtime: the markers are type-only.\nWhat they do is tag `teams` as *depending on the async source `users`*, which is\nwhat the template checker reads.\n\n## The boundary\n\nThe boundary is piped onto any node above the reads:\n\n```typescript\ndiv([span(teams), span(total)]).pipe(\n pendingBlock({ fallback: () => p('Chargement…') }),\n);\n```\n\nOne boundary covers every async source in its subtree — the same shape as\n`Suspense`. When each zone deserves its own skeleton, name the sources instead;\nthe list is checked exhaustively, so a source with no fallback (and a fallback\nfor a source that never suspends here) is a compile error:\n\n```typescript\ndiv([...]).pipe(\n pendingBlock.exhaustive({\n users: () => SkeletonList(),\n orders: () => SkeletonRows(),\n }),\n);\n```\n\nThe handler keys are the **query names**, even when the template only ever sees\na computed derived from them.\n\n## What the compiler enforces\n\n```typescript\ncraftComponent(\n 'teamList',\n {},\n function* () {\n const users = yield* query('users', { ... });\n const teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n return () => list().length;\n });\n return { teams };\n },\n // ERROR_async_source_rendered_outside_a_pendingBlock: \"users\"\n ({ teams }) => div([span(teams)]),\n);\n```\n\n\n\nThe sources bubble up through the node tree exactly like unhandled exception\ncodes do, and the check fires on the `craftComponent` template argument, naming\nthe sources that have nowhere to show their loading state. Several suspending\ncomputeds in one template are all covered by the same rule: every one of them\nneeds a boundary above it.\n\nThe obligation travels through `each`, `ifBlock`, `defer`, projected content and\nnested elements — anywhere a node can carry children.\n\n## Stale-while-revalidate\n\nA reload that keeps its previous value does **not** suspend: the stale value is\nserved while the new one is in flight, so a refetch never blanks a screen that\nalready has data. Only a source with nothing to show suspends. To make a reload\nsuspend again, clear the value with `preservePreviousValue: () => false`.\n\nA refetch throws nothing, so the boundary cannot learn about it from the\nsuspension channel — it watches the source's own status instead. Give a handler\nits `reloading` slot to report it, rendered **next to the still-visible\nsubtree**:\n\n```typescript\npendingBlock.exhaustive({\n issue: {\n pending: () => p('Waiting for an invoice…'),\n reloading: () => p('Re-issuing…'),\n },\n});\n\n// or, for the catch-all form\npendingBlock({ fallback: () => Skeleton(), reloading: () => Spinner() });\n```\n\n## Runtime behaviour\n\nWhile a source is pending, the boundary renders its fallback and detaches the\nsuspended subtree's DOM — **detaches, not destroys**. Keeping it alive is what\nmakes resumption work: the suspended bindings stay subscribed to their source's\nstatus, so they re-run and release the boundary the moment the data arrives.\n\nTwo escapes are reported rather than silently swallowed:\n\n- a settled read that suspends with no boundary above it throws\n `CraftUnhandledPendingError`;\n- a settled read whose source carries an exception with no `catchBlock` above it\n throws `CraftUnhandledExceptionError`.\n\nThe first is the runtime backstop for what the types cannot see — typically a\nsettled read hidden inside a lambda (`() => users.settledValue().name`), where\nthe brand that carries the obligation is lost. Bind the value **by reference**\n(`span(users.settledValue)`, `span(teams)`) to keep the compile-time guarantee.\n\n## Two boundaries, two obligations\n\nA settled read has two exits and each one has its own boundary:\n\n| Exit | Thrown | Boundary | Checked at |\n| ---- | ------ | -------- | ---------- |\n| nothing to show yet | `CraftNotSettled` | `pendingBlock` | `craftComponent(...)` |\n| the source carries an exception | `CraftGenShortCircuit` | `catchBlock` | `craftComponent(...)` |\n\nBoth bubble up the node tree until a boundary clears them, and both fail the\n`craftComponent` template argument when uncovered. A `pendingBlock` is not an\nexception boundary — settled exceptions pass straight through it, and vice\nversa.\n\nThese two throws are intentional CraftTS control flow. The shared\n`isCraftControlFlow(error)` predicate identifies them so observability and\nerror-conversion wrappers can rethrow them without logging or taking an app\nsnapshot. If a pending read escapes its boundary, it becomes\n`CraftUnhandledPendingError`; that is a real template error and remains\nobservable.\n\n```typescript\ndiv([span(summary)])\n .pipe(pendingBlock.exhaustive({ issue: () => Skeleton() }))\n .pipe(catchBlock.exhaustive({ INVOICE_REJECTED: () => Rejected() }));\n```\n\nA `catchBlock` handler receives the exception as `AnyCraftException`: its `code`\nis known, its payload is not. Reach for `matchBlock` when the fallback needs the\npayload itself.\n\n## Current limits\n\n- The by-id forms (`select(...)` / `selectOrCreate(...)`) have no settled read\n yet: a by-id ref holds one status per group member.\n- A component cannot yet delegate its boundaries to its caller: both checks are\n enforced on each `craftComponent` template.\n- A settled read hidden inside a lambda loses its brand, and with it both\n compile-time obligations — the runtime backstops still fire.\n\nThe pending fallback is announced to assistive tech (`aria-live`, `aria-busy`).\nSee [Accessibility](/guide/components/accessibility).\n"
123
+ "path": "/guide/components/pending-node",
124
+ "title": "settledValue & pendingNode",
125
+ "body": "# settledValue & pendingNode\n\nReading an async value in a template without ever handling `undefined` — and\nbeing told at **compile time** when the loading state has nowhere to go.\n\n**Use it when** a template renders data that comes from a `query`.\n**Not when** you want to drive the loading state yourself: `query.value()`\n(`T | undefined`) and `query.status()` stay exactly as they were.\n\n## Import\n\n```typescript\nimport { settled } from '@craft-ts/core';\nimport { pendingNode } from '@craft-ts/component';\n```\n\n## Overview\n\nA resource-like `query`, `mutation` or `asyncProcess` exposes a second read next\nto `value`:\n\n```typescript\nusers.value(); // User[] | undefined — you handle the wait\nusers.settledValue(); // User[] — the wait is handled for you\n```\n\n`settledValue` never returns `undefined` and never returns a value while the\nsource carries an exception. When there is nothing to show it **suspends**: it\nthrows a `CraftNotSettled` that the nearest `pendingNode` turns into a\nfallback. A business exception throws through the existing channel instead, and\nlands in the nearest `catchNode`.\n\nBecause the dependency is visible in the types, a template that renders a\nsuspending value with no `pendingNode` around it does not compile.\n\n## Reading a settled value in a computed\n\nInside a `craftComputed` generator, `yield* settled(ref)` hands back the\nresource's settled read:\n\n```typescript\nconst teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n // `list()` is `User[]` here — never undefined, never in exception\n return () => [...new Set(list().map((user) => user.team))].sort();\n});\n```\n\nNothing is awaited and nothing is yielded at runtime: the markers are type-only.\nWhat they do is tag `teams` as *depending on the async source `users`*, which is\nwhat the template checker reads.\n\n## The boundary\n\nThe boundary is piped onto any node above the reads:\n\n```typescript\ndiv([span(teams), span(total)]).pipe(\n pendingNode({ fallback: () => p('Chargement…') }),\n);\n```\n\nOne boundary covers every async source in its subtree — the same shape as\n`Suspense`. When each zone deserves its own skeleton, name the sources instead;\nthe list is checked exhaustively, so a source with no fallback (and a fallback\nfor a source that never suspends here) is a compile error:\n\n```typescript\ndiv([...]).pipe(\n pendingNode.exhaustive({\n users: () => SkeletonList(),\n orders: () => SkeletonRows(),\n }),\n);\n```\n\nThe handler keys are the **query names**, even when the template only ever sees\na computed derived from them.\n\n## What the compiler enforces\n\n```typescript\ncraftComponent(\n 'teamList',\n {},\n function* () {\n const users = yield* query('users', { ... });\n const teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n return () => list().length;\n });\n return { teams };\n },\n // ERROR_async_source_rendered_outside_a_pendingNode: \"users\"\n ({ teams }) => div([span(teams)]),\n);\n```\n\n\n\nThe sources bubble up through the node tree exactly like unhandled exception\ncodes do, and the check fires on the `craftComponent` template argument, naming\nthe sources that have nowhere to show their loading state. Several suspending\ncomputeds in one template are all covered by the same rule: every one of them\nneeds a boundary above it.\n\nThe obligation travels through `forNode`, `ifNode`, `deferNode`, projected content and\nnested elements — anywhere a node can carry children.\n\n## Stale-while-revalidate\n\nA reload that keeps its previous value does **not** suspend: the stale value is\nserved while the new one is in flight, so a refetch never blanks a screen that\nalready has data. Only a source with nothing to show suspends. To make a reload\nsuspend again, clear the value with `preservePreviousValue: () => false`.\n\nA refetch throws nothing, so the boundary cannot learn about it from the\nsuspension channel — it watches the source's own status instead. Give a handler\nits `reloading` slot to report it, rendered **next to the still-visible\nsubtree**:\n\n```typescript\npendingNode.exhaustive({\n issue: {\n pending: () => p('Waiting for an invoice…'),\n reloading: () => p('Re-issuing…'),\n },\n});\n\n// or, for the catch-all form\npendingNode({ fallback: () => Skeleton(), reloading: () => Spinner() });\n```\n\n## Runtime behaviour\n\nWhile a source is pending, the boundary renders its fallback and detaches the\nsuspended subtree's DOM — **detaches, not destroys**. Keeping it alive is what\nmakes resumption work: the suspended bindings stay subscribed to their source's\nstatus, so they re-run and release the boundary the moment the data arrives.\n\nTwo escapes are reported rather than silently swallowed:\n\n- a settled read that suspends with no boundary above it throws\n `CraftUnhandledPendingError`;\n- a settled read whose source carries an exception with no `catchNode` above it\n throws `CraftUnhandledExceptionError`.\n\nThe first is the runtime backstop for what the types cannot see — typically a\nsettled read hidden inside a lambda (`() => users.settledValue().name`), where\nthe brand that carries the obligation is lost. Bind the value **by reference**\n(`span(users.settledValue)`, `span(teams)`) to keep the compile-time guarantee.\n\n## Two boundaries, two obligations\n\nA settled read has two exits and each one has its own boundary:\n\n| Exit | Thrown | Boundary | Checked at |\n| ---- | ------ | -------- | ---------- |\n| nothing to show yet | `CraftNotSettled` | `pendingNode` | `craftComponent(...)` |\n| the source carries an exception | `CraftGenShortCircuit` | `catchNode` | `craftComponent(...)` |\n\nBoth bubble up the node tree until a boundary clears them, and both fail the\n`craftComponent` template argument when uncovered. A `pendingNode` is not an\nexception boundary — settled exceptions pass straight through it, and vice\nversa.\n\nThese two throws are intentional CraftTS control flow. The shared\n`isCraftControlFlow(error)` predicate identifies them so observability and\nerror-conversion wrappers can rethrow them without logging or taking an app\nsnapshot. If a pending read escapes its boundary, it becomes\n`CraftUnhandledPendingError`; that is a real template error and remains\nobservable.\n\n```typescript\ndiv([span(summary)])\n .pipe(pendingNode.exhaustive({ issue: () => Skeleton() }))\n .pipe(catchNode.exhaustive({ INVOICE_REJECTED: () => Rejected() }));\n```\n\nA `catchNode` handler receives the exception as `AnyCraftException`: its `code`\nis known, its payload is not. Reach for `matchNode` when the fallback needs the\npayload itself.\n\n## Current limits\n\n- The by-id forms (`select(...)` / `selectOrCreate(...)`) have no settled read\n yet: a by-id ref holds one status per group member.\n- A component cannot yet delegate its boundaries to its caller: both checks are\n enforced on each `craftComponent` template.\n- A settled read hidden inside a lambda loses its brand, and with it both\n compile-time obligations — the runtime backstops still fire.\n\nThe pending fallback is announced to assistive tech (`aria-live`, `aria-busy`).\nSee [Accessibility](/guide/components/accessibility).\n"
126
126
  },
127
127
  {
128
- "path": "/guide/components/schedule-each",
129
- "title": "Progressive rendering with `scheduleEach`",
130
- "body": "# Progressive rendering with `scheduleEach`\n\n`each` renders synchronously by default. That is the right choice for short\nlists and keeps the initial behavior predictable. For a large collection,\n`scheduleEach` lets Craft spread fragment creation and updates over animation\nframes.\n\n## Basic usage\n\n```ts\nimport { each, scheduleEach } from '@craft-ts/component';\n\neach(cells, { track: (cell) => cell.id }, (cell) => renderCell(cell)).pipe(\n scheduleEach({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nThe directive is attached to the `each` node. It does not add a DOM wrapper and\ndoes not change the `item`, `index`, dependency, exception, or pending-source\ncontracts of the block.\n\n## When to use it\n\nUse `strategy: 'frame'` when the first visible items should appear quickly and\nthe browser must keep handling input and painting while the rest of the list is\ncreated. A smaller `frameBudgetMs` yields more often; a larger budget completes\nthe list sooner but can occupy the main thread for longer.\n\nDisable it explicitly when a screen needs the synchronous behavior:\n\n```ts\neach(items, { track: (item) => item.id }, renderItem).pipe(\n scheduleEach({ enabled: false, strategy: 'frame' }),\n);\n```\n\n`each` without `scheduleEach` is already synchronous. The first delivery\nsupports `sync` and `frame`; `idle` will be added with its fallback policy in a\nlater delivery.\n\n## What scheduling does—and does not do\n\nScheduling improves perceived responsiveness by yielding between batches. It\ndoes not reduce the total work required to create or update every fragment.\nStable keys still control reconciliation, and existing keyed DOM fragments keep\ntheir identity when the collection is reordered.\n\nFor very large or continuously scrolling collections, prefer virtualisation:\nit reduces the number of DOM nodes and bindings that exist at the same time.\nScheduling and virtualisation solve different problems and can eventually be\ncombined.\n\n## Pixel Art Workshop\n\nThe demo's Pixel Art Workshop uses frame scheduling for its 256-cell grid:\n\n```ts\neach(INDEXES, { track: (index) => index }, renderCell).pipe(\n scheduleEach({ strategy: 'frame', frameBudgetMs: 4 }),\n);\n```\n\nThe production benchmark can compare the synchronous baseline and frame mode\nwith 256, 1,000, and 10,000 cells. Its commands and metrics are documented in\n`docs/benchmarks/schedule-each-pixel-art.md` in the repository.\n"
128
+ "path": "/guide/components/schedule-for",
129
+ "title": "Progressive rendering with `scheduleFor`",
130
+ "body": "# Progressive rendering with `scheduleFor`\n\n`forNode` renders synchronously by default. That is the right choice for short\nlists and keeps the initial behavior predictable. For a large collection,\n`scheduleFor` lets Craft spread fragment creation and updates over animation\nframes.\n\n## Basic usage\n\n```ts\nimport { forNode, scheduleFor } from '@craft-ts/component';\n\nforNode(cells, { track: (cell) => cell.id }, (cell) => renderCell(cell)).pipe(\n scheduleFor({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nThe directive is attached to the `forNode` node. It does not add a DOM wrapper and\ndoes not change the `item`, `index`, dependency, exception, or pending-source\ncontracts of the block.\n\n## When to use it\n\nUse `strategy: 'frame'` when the first visible items should appear quickly and\nthe browser must keep handling input and painting while the rest of the list is\ncreated. A smaller `frameBudgetMs` yields more often; a larger budget completes\nthe list sooner but can occupy the main thread for longer.\n\nDisable it explicitly when a screen needs the synchronous behavior:\n\n```ts\nforNode(items, { track: (item) => item.id }, renderItem).pipe(\n scheduleFor({ enabled: false, strategy: 'frame' }),\n);\n```\n\n`forNode` without `scheduleFor` is already synchronous. The first delivery\nsupports `sync` and `frame`; `idle` will be added with its fallback policy in a\nlater delivery.\n\n## What scheduling does—and does not do\n\nScheduling improves perceived responsiveness by yielding between batches. It\ndoes not reduce the total work required to create or update every fragment.\nStable keys still control reconciliation, and existing keyed DOM fragments keep\ntheir identity when the collection is reordered.\n\nFor very large or continuously scrolling collections, prefer virtualisation:\nit reduces the number of DOM nodes and bindings that exist at the same time.\nScheduling and virtualisation solve different problems and can eventually be\ncombined.\n\n## Pixel Art Workshop\n\nThe demo's Pixel Art Workshop uses frame scheduling for its 256-cell grid:\n\n```ts\nforNode(INDEXES, { track: (index) => index }, renderCell).pipe(\n scheduleFor({ strategy: 'frame', frameBudgetMs: 4 }),\n);\n```\n\nThe production benchmark can compare the synchronous baseline and frame mode\nwith 256, 1,000, and 10,000 cells. Its commands and metrics are documented in\n`docs/benchmarks/schedule-for-pixel-art.md` in the repository.\n"
131
131
  },
132
132
  {
133
133
  "path": "/guide/components/styles",
134
134
  "title": "Encapsulated styles",
135
- "body": "# Encapsulated styles\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"
135
+ "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"
136
136
  },
137
137
  {
138
138
  "path": "/guide/components/template-migrator",
139
139
  "title": "Template migrator",
140
- "body": "# Template migrator\n\nPaste an HTML snippet or a web component from a UI library's documentation. The\nconverter generates the equivalent Craft functional template and the imports it\nneeds from `@craft-ts/component`.\n\n**Use it to** bring markup from outside — a design system's docs, a CodePen, an\nexisting template markup — into Craft's template syntax without transcribing it\nby hand.\n\n<CraftTemplateMigrator />\n\n## What it produces\n\nBy default the result is a callback to paste as the fourth argument of\n`craftComponent(...)`. Fill in a name to generate a complete component instead.\n\nNative HTML tags become the matching helpers (`div`, `button`, `section`, …);\ncustom tags become `customElement('my-element', ...)`.\n\n## Pitfalls\n\n**Interpolations and bindings are preserved as expressions**, not translated.\nAdapt them to your Craft context.\n\n**Control-flow directives are not converted.** Rewrite conditional and repeated\nsections with `ifBlock` or `each`.\n\n## See Also\n\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [CLI automation](/guide/routing/automation) — codemods for the rest of a migration\n"
140
+ "body": "# Template migrator\n\nPaste an HTML snippet or a web component from a UI library's documentation. The\nconverter generates the equivalent Craft functional template and the imports it\nneeds from `@craft-ts/component`.\n\n**Use it to** bring markup from outside — a design system's docs, a CodePen, an\nexisting template markup — into Craft's template syntax without transcribing it\nby hand.\n\n<CraftTemplateMigrator />\n\n## What it produces\n\nBy default the result is a callback to paste as the fourth argument of\n`craftComponent(...)`. Fill in a name to generate a complete component instead.\n\nNative HTML tags become the matching helpers (`div`, `button`, `section`, …);\ncustom tags become `customElement('my-element', ...)`.\n\n## Pitfalls\n\n**Interpolations and bindings are preserved as expressions**, not translated.\nAdapt them to your Craft context.\n\n**Control-flow directives are not converted.** Rewrite conditional and repeated\nsections with `ifNode` or `forNode`.\n\n## See Also\n\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [CLI automation](/guide/routing/automation) — codemods for the rest of a migration\n"
141
141
  },
142
142
  {
143
143
  "path": "/guide/concepts/choose-primitive",
@@ -147,7 +147,7 @@
147
147
  {
148
148
  "path": "/guide/concepts/exceptions",
149
149
  "title": "Exceptions as values",
150
- "body": "# Exceptions as values\n\nA declared failure is a **value you return**, not something you throw. It travels\nthrough types instead of escaping through the stack — so the compiler can see it,\nfollow it, and tell you when nobody handled it.\n\nThat is the whole idea, and it rests on a line most codebases leave blurry:\n\n- an **exception** is a failure you declared, expect, and intend to handle —\n \"this email is taken\", \"the session expired\", \"that id is malformed\";\n- an **error** is everything else — the unexpected kind, which should surface\n loudly rather than be silently absorbed.\n\nA `try/catch` tells you nothing about what it might catch. A returned\n`craftException` carries its code and payload all the way to whoever handles it,\nand the set of reachable codes is a **type** — which is what makes exhaustive\nchecking possible at all.\n\n## Declaring one\n\n```typescript\nimport { craftException } from '@craft-ts/core';\n\ncraftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title });\n```\n\nThe first argument carries the `code` (and an optional `scope`); the second is a\nfree-form **payload**, whose type flows all the way to whoever handles it.\n\n## Where they come from\n\nAn exception is a **returned value**, not a thrown one. Return it from the place\nthat detects the failure and the rest of the pipeline stops on its own:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n // rejected before any request is sent — the loader never runs\n method: (payload: { title: string }) =>\n payload.title.trim().length === 0\n ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title })\n : payload,\n\n loader: function* ({ params }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/tasks',\n body: params,\n success: response<Task>(),\n // recognised from the response\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) return;\n return craftException({ _tag: 'TITLE_ALREADY_EXISTS' });\n },\n ],\n }));\n },\n});\n```\n\nGuards, matchers and resolvers raise them the same way — see\n[Route guards](/guide/routing/guards).\n\n## Propagating through a shared utility\n\nThe interesting case isn't one primitive failing — it's a rule that lives in one\nplace and travels. Wrap it in a [`craftGen`](/guide/concepts/generators) and it\nbecomes a reusable unit that **short-circuits its callers**:\n\n```typescript\nimport { craftException, craftGen, craftUntilSettled } from '@craft-ts/core';\n\n// one business rule, declared once\nexport const loadReport = craftGen(function* () {\n const reportRef = yield* Report();\n const report = yield* craftUntilSettled(reportRef);\n\n return report.totalUsers === 0\n ? craftException({ _tag: 'REPORT_EMPTY' })\n : report;\n});\n```\n\nConsumers just `yield*` it. If the rule rejects, everything after the yield is\nskipped — no `if (result.isError)` at each level:\n\n```typescript\nconst { ReportFacade } = craftService(\n { name: 'ReportFacade', providedIn: 'global' },\n function* () {\n const report = yield* loadReport(); // narrowed: never the exception\n return { total: report.totalUsers };\n },\n);\n```\n\n`report` is the success value only. The exception left through the generator\nchannel, and — this is the point — **`REPORT_EMPTY` is now part of\n`ReportFacade`'s reachable codes.** It keeps travelling up until someone deals\nwith it.\n\n### Stopping the propagation\n\nTwo ways, and the difference matters:\n\n**Recover locally** with `catchTag`, and the code **leaves the union** — nobody\nupstream has to know about it:\n\n```typescript\nresolve: craftResolve(function* () {\n return yield* loadReport().pipe(\n catchTag('REPORT_EMPTY', function* () {\n return { totalUsers: 0, generatedAt: null };\n }),\n );\n});\n```\n\n**Let it reach the route**, and `handleExceptions` must have a handler for it —\nthe compiler says so. That's the right choice when the failure should change what\nthe user sees, rather than being papered over with a default value.\n\n::: tip Composition rule\nWhen several utilities are composed, the **first exception wins** — the rest of\nthe program doesn't run. See [Program\noperators](/guide/advanced/program-operators) for `catchTag` and `retry`.\n:::\n\nWorking example: the `slow-page` demo raises `REPORT_EMPTY` from a `craftGen`\nresolver and recovers it locally, so the route never declares a handler for it —\n[slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts).\n\n## Reading them\n\nEvery async primitive exposes its exceptions **split by origin**, and typed from\nthe codes you declared:\n\n```typescript\ncreateTask.hasException(); // boolean\n\ncreateTask.exceptions().params?.TITLE_REQUIRED; // rejected by `method`\ncreateTask.exceptions().loader?.TITLE_ALREADY_EXISTS; // produced by the request\n```\n\nThe origin matters: `params` means nothing left the browser, `loader` means the\nserver was involved. The union is closed, so the compiler knows\n`TITLE_ALREADY_EXISTS` exists and that `TITLE_TOO_LONG` doesn't.\n\n`queryParams` follows the same shape with a `parse` origin for decode failures.\n\n## Handling them\n\nWhere you handle an exception depends on how far it needs to travel:\n\n| The failure concerns… | Handle it… |\n| --------------------------- | ------------------------------------------------------------------------ |\n| One primitive's own UI | Read `exceptions()` where you render it |\n| A form's submission | [`insertFormSubmit`](/guide/forms/submit) — reshape the mutation's codes |\n| Whether a route can render | [Route exception handling](/guide/routing/exception-handling) |\n| Nothing in particular | Let it be an error — the global error component catches it |\n\n## An unhandled exception doesn't just disappear\n\nThis is the rule that ties the whole system together, and it is easy to miss.\n\nWhen a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's **initialization\nexceptions**. It stays attached to the component until something handles it.\n\nMost of the time what you want is a **fallback to render**, which is\n`catchBlock.exhaustive`:\n\n```typescript\nconst Restricted = MyRestrictedComponent.pipe(\n withProviders([provideRestrictedData(/* … */)]),\n catchBlock.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\nHere the failure comes from a provider, before the template exists — so there is\nno source block to preserve and the fallback renders alone. When the source\n*does* exist and should stay visible, use the object form:\n\n```typescript\ncatchBlock.exhaustive({\n NO_ACCESS: { render: () => p('Restricted'), showSource: true, position: 'after' },\n});\n```\n\nReach for `catchTag.exhaustive` only when the reaction is **logic** and produces\nno DOM — logging it, notifying a service:\n\n```typescript\ncatchTag.exhaustive({\n NO_ACCESS: function* () {\n yield* ToastService.show(() => 'No access');\n },\n});\n```\n\nEither way, handling a code at the component **removes it** from the component's\ncontract and from the route's. Whatever you don't handle is **residual**, and it flows up\ninto the route's exception union — where `handleExceptions` must cover it:\n\n```\ncomponent factory + providers\n ↓ (codes not handled by .pipe)\n residual exceptions\n ↓\nroute exception union ── handleExceptions must be exact\n```\n\nAt the route, the check is exhaustive **in both directions**: a reachable code\nwith no handler is a type error, and a handler for a code nothing can produce is\na type error too.\n\n::: warning Where the compile error actually appears\nToday the enforcement is at the **route**, not at the component. The variadic\ncomponent `.pipe(...)` overload is deliberately kept permissive to avoid\nexcessive TypeScript instantiation depth, so an unhandled code there is rejected\nby **runtime** dispatch rather than by the compiler. The compile-time proof is\n[`assertExhaustiveRouteExceptions(routes)`](/guide/routing/exception-handling#exhaustiveness).\n\nPractical consequence: a component rendered outside any route — in a test, or\nnested inside another component — gets no compile-time reminder. Handle its\ncodes explicitly.\n:::\n\nThree utilities do the handling, and which one you want depends on whether the\nresult is logic or DOM:\n\n| Utility | Handles in | Produces |\n| ----------------------- | ---------- | --------------------------------------------- |\n| `catchBlock.exhaustive` | template | a fallback around a source block — **the default choice** |\n| `matchBlock.exhaustive` | template | a fallback rendered from an exception value or signal |\n| `catchTag.exhaustive` | logic | nothing renderable — call a service, log, … |\n\nDetails on [Customization](/guide/components/customization#choosing-an-exception-utility).\n\n## Why exhaustiveness is worth the ceremony\n\nBecause the set of reachable codes is a **type**, the compiler can compare it\nagainst the set you handled. At the route level that comparison is an assertion\nyou place once per collection:\n\n```typescript\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\nA code that can be produced but isn't handled is a compile error. So is a\nhandler for a code nothing produces. Add a `craftException` to a guard six months\nfrom now and the routes file tells you exactly which routes must decide what to\ndo about it.\n\nThe assertion itself is an unused call unless it stays in the file.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\ncollection omits it.\n\n## Pitfalls\n\n**Throwing instead of returning.** A thrown value is an *error*: it bypasses the\ntyped union and lands in the global error path. Return the `craftException`.\n\n**Reusing one code for two meanings.** The code is the identity the handlers\nmatch on. Two different failures deserve two codes, with payloads carrying the\ndetail.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n- [query](/guide/state/server-state) — typed HTTP exception matchers\n- [Form exception handling](/guide/forms/exceptions)\n"
150
+ "body": "# Exceptions as values\n\nA declared failure is a **value you return**, not something you throw. It travels\nthrough types instead of escaping through the stack — so the compiler can see it,\nfollow it, and tell you when nobody handled it.\n\nThat is the whole idea, and it rests on a line most codebases leave blurry:\n\n- an **exception** is a failure you declared, expect, and intend to handle —\n \"this email is taken\", \"the session expired\", \"that id is malformed\";\n- an **error** is everything else — the unexpected kind, which should surface\n loudly rather than be silently absorbed.\n\nA `try/catch` tells you nothing about what it might catch. A returned\n`craftException` carries its code and payload all the way to whoever handles it,\nand the set of reachable codes is a **type** — which is what makes exhaustive\nchecking possible at all.\n\n## Declaring one\n\n```typescript\nimport { craftException } from '@craft-ts/core';\n\ncraftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title });\n```\n\nThe first argument carries the `code` (and an optional `scope`); the second is a\nfree-form **payload**, whose type flows all the way to whoever handles it.\n\n## Where they come from\n\nAn exception is a **returned value**, not a thrown one. Return it from the place\nthat detects the failure and the rest of the pipeline stops on its own:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n // rejected before any request is sent — the loader never runs\n method: (payload: { title: string }) =>\n payload.title.trim().length === 0\n ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title })\n : payload,\n\n loader: function* ({ params }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/tasks',\n body: params,\n success: response<Task>(),\n // recognised from the response\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) return;\n return craftException({ _tag: 'TITLE_ALREADY_EXISTS' });\n },\n ],\n }));\n },\n});\n```\n\nGuards, matchers and resolvers raise them the same way — see\n[Route guards](/guide/routing/guards).\n\n## Propagating through a shared utility\n\nThe interesting case isn't one primitive failing — it's a rule that lives in one\nplace and travels. Wrap it in a [`craftGen`](/guide/concepts/generators) and it\nbecomes a reusable unit that **short-circuits its callers**:\n\n```typescript\nimport { craftException, craftGen, craftUntilSettled } from '@craft-ts/core';\n\n// one business rule, declared once\nexport const loadReport = craftGen(function* () {\n const reportRef = yield* Report();\n const report = yield* craftUntilSettled(reportRef);\n\n return report.totalUsers === 0\n ? craftException({ _tag: 'REPORT_EMPTY' })\n : report;\n});\n```\n\nConsumers just `yield*` it. If the rule rejects, everything after the yield is\nskipped — no `if (result.isError)` at each level:\n\n```typescript\nconst { ReportFacade } = craftService(\n { name: 'ReportFacade', providedIn: 'global' },\n function* () {\n const report = yield* loadReport(); // narrowed: never the exception\n return { total: report.totalUsers };\n },\n);\n```\n\n`report` is the success value only. The exception left through the generator\nchannel, and — this is the point — **`REPORT_EMPTY` is now part of\n`ReportFacade`'s reachable codes.** It keeps travelling up until someone deals\nwith it.\n\n### Stopping the propagation\n\nTwo ways, and the difference matters:\n\n**Recover locally** with `catchTag`, and the code **leaves the union** — nobody\nupstream has to know about it:\n\n```typescript\nresolve: craftResolve(function* () {\n return yield* loadReport().pipe(\n catchTag('REPORT_EMPTY', function* () {\n return { totalUsers: 0, generatedAt: null };\n }),\n );\n});\n```\n\n**Let it reach the route**, and `handleExceptions` must have a handler for it —\nthe compiler says so. That's the right choice when the failure should change what\nthe user sees, rather than being papered over with a default value.\n\n::: tip Composition rule\nWhen several utilities are composed, the **first exception wins** — the rest of\nthe program doesn't run. See [Program\noperators](/guide/advanced/program-operators) for `catchTag` and `retry`.\n:::\n\nWorking example: the `slow-page` demo raises `REPORT_EMPTY` from a `craftGen`\nresolver and recovers it locally, so the route never declares a handler for it —\n[slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts).\n\n## Reading them\n\nEvery async primitive exposes its exceptions **split by origin**, and typed from\nthe codes you declared:\n\n```typescript\ncreateTask.hasException(); // boolean\n\ncreateTask.exceptions().params?.TITLE_REQUIRED; // rejected by `method`\ncreateTask.exceptions().loader?.TITLE_ALREADY_EXISTS; // produced by the request\n```\n\nThe origin matters: `params` means nothing left the browser, `loader` means the\nserver was involved. The union is closed, so the compiler knows\n`TITLE_ALREADY_EXISTS` exists and that `TITLE_TOO_LONG` doesn't.\n\n`queryParams` follows the same shape with a `parse` origin for decode failures.\n\n## Handling them\n\nWhere you handle an exception depends on how far it needs to travel:\n\n| The failure concerns… | Handle it… |\n| --------------------------- | ------------------------------------------------------------------------ |\n| One primitive's own UI | Read `exceptions()` where you render it |\n| A form's submission | [`insertFormSubmit`](/guide/forms/submit) — reshape the mutation's codes |\n| Whether a route can render | [Route exception handling](/guide/routing/exception-handling) |\n| Nothing in particular | Let it be an error — the global error component catches it |\n\n## An unhandled exception doesn't just disappear\n\nThis is the rule that ties the whole system together, and it is easy to miss.\n\nWhen a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's **initialization\nexceptions**. It stays attached to the component until something handles it.\n\nMost of the time what you want is a **fallback to render**, which is\n`catchNode.exhaustive`:\n\n```typescript\nconst Restricted = MyRestrictedComponent.pipe(\n withProviders([provideRestrictedData(/* … */)]),\n catchNode.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\nHere the failure comes from a provider, before the template exists — so there is\nno source block to preserve and the fallback renders alone. When the source\n*does* exist and should stay visible, use the object form:\n\n```typescript\ncatchNode.exhaustive({\n NO_ACCESS: { render: () => p('Restricted'), showSource: true, position: 'after' },\n});\n```\n\nReach for `catchTag.exhaustive` only when the reaction is **logic** and produces\nno DOM — logging it, notifying a service:\n\n```typescript\ncatchTag.exhaustive({\n NO_ACCESS: function* () {\n yield* ToastService.show(() => 'No access');\n },\n});\n```\n\nEither way, handling a code at the component **removes it** from the component's\ncontract and from the route's. Whatever you don't handle is **residual**, and it flows up\ninto the route's exception union — where `handleExceptions` must cover it:\n\n```\ncomponent factory + providers\n ↓ (codes not handled by .pipe)\n residual exceptions\n ↓\nroute exception union ── handleExceptions must be exact\n```\n\nAt the route, the check is exhaustive **in both directions**: a reachable code\nwith no handler is a type error, and a handler for a code nothing can produce is\na type error too.\n\n::: warning Where the compile error actually appears\nToday the enforcement is at the **route**, not at the component. The variadic\ncomponent `.pipe(...)` overload is deliberately kept permissive to avoid\nexcessive TypeScript instantiation depth, so an unhandled code there is rejected\nby **runtime** dispatch rather than by the compiler. The compile-time proof is\n[`assertExhaustiveRouteExceptions(routes)`](/guide/routing/exception-handling#exhaustiveness).\n\nPractical consequence: a component rendered outside any route — in a test, or\nnested inside another component — gets no compile-time reminder. Handle its\ncodes explicitly.\n:::\n\nThree utilities do the handling, and which one you want depends on whether the\nresult is logic or DOM:\n\n| Utility | Handles in | Produces |\n| ----------------------- | ---------- | --------------------------------------------- |\n| `catchNode.exhaustive` | template | a fallback around a source block — **the default choice** |\n| `matchNode.exhaustive` | template | a fallback rendered from an exception value or signal |\n| `catchTag.exhaustive` | logic | nothing renderable — call a service, log, … |\n\nDetails on [Customization](/guide/components/customization#choosing-an-exception-utility).\n\n## Why exhaustiveness is worth the ceremony\n\nBecause the set of reachable codes is a **type**, the compiler can compare it\nagainst the set you handled. At the route level that comparison is an assertion\nyou place once per collection:\n\n```typescript\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\nA code that can be produced but isn't handled is a compile error. So is a\nhandler for a code nothing produces. Add a `craftException` to a guard six months\nfrom now and the routes file tells you exactly which routes must decide what to\ndo about it.\n\nThe assertion itself is an unused call unless it stays in the file.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\ncollection omits it.\n\n## Pitfalls\n\n**Throwing instead of returning.** A thrown value is an *error*: it bypasses the\ntyped union and lands in the global error path. Return the `craftException`.\n\n**Reusing one code for two meanings.** The code is the identity the handlers\nmatch on. Two different failures deserve two codes, with payloads carrying the\ndetail.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n- [query](/guide/state/server-state) — typed HTTP exception matchers\n- [Form exception handling](/guide/forms/exceptions)\n"
151
151
  },
152
152
  {
153
153
  "path": "/guide/concepts/generators",
@@ -174,10 +174,40 @@
174
174
  "title": "Anatomy of a primitive",
175
175
  "body": "# Anatomy of a primitive\n\nThe five primitives — `state`, `query`, `mutation`, `queryParams`,\n`asyncProcess` — share one shape. Learn it once here; each primitive's page then\nonly covers what is specific to it.\n\n## The shape\n\n```typescript\nprimitive(name, config, insertion?);\n```\n\n- **`name`** — always first, always a string literal.\n- **`config`** — what the primitive needs to do its job (an initial value, a\n loader, a codec map…). This is the part that differs between primitives.\n- **`insertion`** — optional, adds methods and computed values to the result.\n\n## Naming is not decoration\n\nThe name tags the primitive's injector — `state:tasks`, `query:userQuery` — and\nis what identifies it in logs, snapshots and the observability tooling. Two\nprimitives with the same name in the same scope are two different things wearing\none label, and the tooling cannot tell them apart.\n\n## Driving it with `yield*`\n\nA primitive does not run itself. Inside any generator host — a `craftComponent`\nlogic factory, a `craftService` factory, a `craftComputed`, a `craftMethod`,\n`craftGen`, a route helper — `yield*` is the driver. The entity that yields\nrecords the dependency on **its** graph:\n\n```typescript\nconst tasks = yield* state('tasks', []);\n```\n\n`yield*` also folds whatever the primitive depends on into the enclosing\ndependency tree, which is what the route DI check and the test registers read.\n\n## It resolves to the primitive reference\n\nEvery named primitive returns its reference directly:\n\n```typescript\nconst tasks = yield* state('tasks', []);\n```\n\nA factory arrow can return a single primitive directly. `craftService` drives it\nand exposes the primitive reference itself:\n\n```typescript\nconst { MyService } = craftService(\n { name: 'MyService', providedIn: 'global' },\n () => state('counter', 0),\n);\n```\n\nWhen a factory exposes several primitives, wrap the record with\n`craftYieldRecord`. It yields each primitive generator and keeps the record\nkeys in the returned value:\n\n```typescript\nimport {\n craftComputed,\n craftService,\n craftYieldRecord,\n query,\n state,\n type CraftServiceInput,\n} from '@craft-ts/core';\n\nconst { UserQuery } = craftService(\n { name: 'UserQueryWithState', providedIn: 'global' },\n (inputs: { userId: CraftServiceInput<string | undefined> }) =>\n craftYieldRecord({\n userQuery: query('userQuery', {\n params: function* () {\n return yield* inputs.userId();\n },\n loader: ({ params }) => ApiService.getItemById(params),\n }),\n refresh: state('refresh', 0, ({ update }) => ({\n increment: () => update((value) => value + 1),\n })),\n }),\n);\n```\n\nUse the direct return for one primitive and `craftYieldRecord` for a record of\nprimitives. Inside a generator factory, the equivalent explicit form remains\navailable: `const userQuery = yield* query(...)`.\n\n## Insertions add to the result\n\nThe last argument receives the primitive's internals and returns what to expose:\n\n```typescript\nstate('counter', 0, ({ state, update }) => ({\n increment: () => update((value) => value + 1),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n```\n\nCompose several with the primitive-specific helpers described in\n[Typed insertion pipes](/guide/concepts/insertion-pipes). An insertion can also\nbe a `function*`, in which case it can `yield*` services. A derived value or\ngenerator method must yield readers it does not own — including this\nprimitive's `state()` / `update()` when the member is a generator. Keep\n[`craftPipe`](/guide/concepts/insertions) for universal or nested compositions.\n\n## Scoped providers\n\nEvery primitive config accepts `providers`, for dependencies that should be\nscoped to this primitive alone rather than to the whole service:\n\n```typescript\nquery('userQuery', {\n providers: [provideUserApiService()],\n loader: function* () {\n return yield* UserApiService.get();\n },\n});\n```\n\n## Injectable runtime context\n\nEveryday insertions already receive `set`, `update`, and `patch` as arguments.\nKeep using that.\n\nEach primitive also **provides those same writes through Craft DI** on every\ninsertion method. Wrappers, registries, tests, WebMCP tools, and other\nadvanced patterns can recover them without being passed the insertion context\n— for example to seed a query result, patch a mutation value, or drive a\n`state` from a\n[`provideFnWrapper`](/guide/advanced/observability#providefnwrapper).\n\nThat is also the surface a WebMCP client uses to inspect and mutate a live\nprimitive: `get` / `set` / `update` / `patch` on a query result, a `state`, a\nmutation, an `asyncProcess`, or `queryParams`, without editing TypeScript or\nreloading the page.\n\nThe helpers return `undefined` outside an insertion-method injection context.\nUse the one that matches the primitive, or the generic helper and branch on\n`kind`:\n\n| Primitive | Helper |\n| -------------- | ------------------------------------------- |\n| `state` | `injectStateMethodRuntimeContext()` |\n| `query` | `injectQueryMethodRuntimeContext()` |\n| `mutation` | `injectMutationMethodRuntimeContext()` |\n| `queryParams` | `injectQueryParamsMethodRuntimeContext()` |\n| `asyncProcess` | `injectAsyncProcessMethodRuntimeContext()` |\n| any of them | `injectPrimitiveMethodRuntimeContext()` |\n\nThe context is the same shape everywhere:\n\n```typescript\n{\n kind: 'state' | 'query' | 'mutation' | 'queryParams' | 'asyncProcess';\n get(): unknown;\n set(value: unknown): unknown;\n update(updater: (current: unknown) => unknown): unknown;\n patch(updater: (current: unknown) => object): unknown;\n originalSource: string;\n}\n```\n\n`patch` merges objects. Use `update` to replace arrays or primitives. Nested\n[`insertSelect`](/guide/state/select) methods receive the selected slice, not\nthe root.\n\n```typescript\nimport {\n injectQueryMethodRuntimeContext,\n provideFnWrapper,\n} from '@craft-ts/core';\n\nprovideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n const query = injectQueryMethodRuntimeContext();\n const result = yield* factory.apply(thisArg, args);\n query?.patch((current) => ({ ...current, viewed: true }));\n return result;\n },\n);\n```\n\n`query`, `mutation`, `asyncProcess`, and `queryParams` also publish the\n**primitive value itself** — not only its methods — through\n`providePrimitiveResourceRuntimeObserver`. Register it on the primitive's\n`providers` (or higher). The observer runs at creation; keep the context if\nyou need to write later. Grouped resources take an optional `id` equivalent to\n`.select(id)`. `state` has no resource observer: only the method context.\n\n```typescript\nimport {\n providePrimitiveResourceRuntimeObserver,\n query,\n type PrimitiveResourceRuntimeContext,\n} from '@craft-ts/core';\n\nlet usersRuntime: PrimitiveResourceRuntimeContext | undefined;\n\nconst users = yield* query('users', {\n providers: [\n providePrimitiveResourceRuntimeObserver((context) => {\n if (context.kind === 'query') {\n usersRuntime = context;\n }\n }),\n ],\n params: () => true,\n loader: function* () {\n return yield* UserApi.list();\n },\n});\n\nusersRuntime?.set([{ id: 'stub', name: 'Preview' }]);\n```\n\nThe internal token behind these helpers is not part of the public API. Use the\nhelpers; do not look up the token yourself.\n\n## Reading a value that may have failed\n\nThe async primitives (`query`, `mutation`, `asyncProcess`) expose one value reader:\n\n- `value()` — never throws, returns `undefined` when no value is available.\n\n::: tip\nPass `query.value` to a template binding. Inside a generator, `yield* query.value()`.\n:::\n\n## Pitfalls\n\n**A primitive invocation is single-use.** Each call produces one generator, to be\nconsumed exactly once. Storing one and `yield*`-ing it twice does not give you\ntwo primitives — it fails.\n\n**It must run in an injection context.** A field initialiser, a constructor, a\ncraft factory. Called outside one, a primitive returns only its configuration\nunder `_config` instead of a live ref — which usually surfaces later as a\nconfusing \"not a function\" error.\n\n**Methods bound to a source with `on$` are not exposed on the result.** They\nwork internally, driven by the source, and do not appear on the ref.\n\n**Don't inject the runtime context from feature insertions.** The insertion\nalready receives typed `set` / `update` / `patch` as arguments. The injectable\nhelpers are untyped and exist for wrappers, registries, WebMCP tools, and\nother advanced patterns.\n\n## See Also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Insertions](/guide/concepts/insertions)\n- [Generators and `yield*`](/guide/concepts/generators)\n- [Observability](/guide/advanced/observability) — `provideFnWrapper` as a\n consumer of the runtime context\n"
176
176
  },
177
+ {
178
+ "path": "/guide/create-project",
179
+ "title": "Create a CraftTS project",
180
+ "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 for:\n\n- the frontend runtime: `plain` or `effect`;\n- the backend runtime: `none`, `promise`, 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- CraftTS and, when Effect is selected, EffectTS source references for agent\n context;\n- integrations for Codex, Cursor, Claude Code, or Gemini CLI.\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.\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 cloned by default:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also cloned when an Effect frontend or backend is\n selected;\n- the sources are available to agents without replacing the installed npm\n packages.\n\nAnswer `n` to opt out. In non-interactive mode, references remain opt-in so\nthat `--yes` does not silently perform network clones; use\n`--references=craft-ts` or `--references=all` explicitly.\n\nThe cloned repositories are reference material for coding agents only. The\ngenerated application always imports the published CraftTS and EffectTS npm\npackages from `package.json`; it does not use `file:` dependencies or\nTypeScript/Vite aliases to the clones. Use `npm run update:references` to fetch\nthe requested refs and refresh the recorded SHAs.\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 clone 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 |\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\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. It does not create a commit. The generated\n`.gitignore` excludes `node_modules/`, build outputs, test reports, and local\nreference clones.\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\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"
181
+ },
182
+ {
183
+ "path": "/guide/deployment",
184
+ "title": "Deploying a CraftTS application",
185
+ "body": "# Deploying a CraftTS application\n\n::: warning Experimental\nThe deployment tooling is the newest part of CraftTS and it is **not settled**.\nWhat is written here works and is covered by tests, but the CLI surface, the\nmanifest fields and the diagnostic codes can still change between minor\nversions. Pin the version if you build a pipeline on it.\n\nConcretely, as of today: `craft-ts check`, `manifest`, `providers`,\n`deploy preview` and `deploy` exist; `init`, `build` and `deploy init` do not\nyet. One provider implementation ships, [Alchemy](./alchemy.md), and no real\ndeployment has been run from the CraftTS repository itself.\n:::\n\nA CraftTS application describes its deployment once, in a typed manifest, and\nthat description is enough to check it, to build it and to hand it to a\nprovider. Nothing in the manifest names a hosting company, so moving from a\ncontainer to a Worker, or from one publisher to another, does not touch the\nserver-functions or the routes.\n\n## Three notions that never merge\n\n| Notion | Question it answers | Values |\n| ------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |\n| **runtime** | What execution shape does the bundle take? | `static`, `node`, `worker`, `lambda` |\n| **platform** | Which technical platform executes that shape? | `node`, `docker`, `cloudflare`, `aws`, `vercel`, `netlify`, `firebase`, `github-pages` |\n| **provider** | Which integration builds, publishes or provisions it? | `alchemy`, `docker`, `cloudflare-pages`, `vercel`, `netlify`, `firebase`, `github-pages` |\n\nAlchemy is a provider of infrastructure, not a runtime. Cloudflare Pages is a\nprovider of publication for the same artefact. Both read the same manifest.\n\nThe `static` runtime carries a mode:\n\n- **`spa`** — one document, and unknown paths fall back to it. The route table\n lives in the browser.\n- **`ssg`** — one pre-rendered document per route. The route list is part of the\n contract, and any route that cannot be reduced to a single document has to be\n declared as needing a server runtime.\n\n## The manifest\n\n```ts\nimport { defineCraftDeployment } from '@craft-ts/deploy';\n\nexport default defineCraftDeployment({\n name: 'demo-ssr',\n runtime: 'node',\n platform: 'docker',\n server: {\n entry: 'dist/apps/demo-ssr/server/server.js',\n source: 'apps/demo-ssr/src/production-server.ts',\n healthPath: '/health',\n readyPath: '/ready',\n },\n});\n```\n\nThe runtime discriminates the type: a `worker` manifest cannot carry an SSR\nentry, and a `node` manifest cannot omit its health and readiness routes. See\nthe [manifest reference](./manifest.md) for every field.\n\n## The commands\n\n```bash\nnpx craft-ts check --provider docker\nnpx craft-ts manifest --out dist/apps/demo-ssr/craft-deployment-manifest.json\nnpx craft-ts providers\nnpx craft-ts deploy preview --provider alchemy --stage staging\nnpx craft-ts deploy --provider alchemy --stage staging --yes\n```\n\n`check` runs before the build. It resolves the manifest, then verifies the\ndeclared paths, the runtime/platform pair, the capabilities of the chosen\nprovider and the module graph of the runtime entry — a Node built-in reachable\nfrom a Worker entry, an SSR entry that never serves its own health route, an\nenvironment variable read but never declared. Add `--artifact` after the build\nto inspect the directory a provider would actually upload.\n\nEvery path in the manifest is relative to the directory `check` runs from,\nwhich is `--root` when given and the current directory otherwise. In a\nmonorepo, run the commands from the workspace root and point `--config` at the\napplication:\n\n```bash\nnpx craft-ts check --config apps/demo-ssr/craft.deploy.ts --provider docker\n```\n\n`manifest` resolves the manifest to its artefact form: every default applied,\nkeys sorted, protocol version stamped. Two builds of the same input produce a\nbyte-identical file, which is what makes\n`dist/<app>/craft-deployment-manifest.json` an immutable artefact.\n\n`providers` prints the [capability matrix](./providers.md). A provider listed\nthere is documented; the integration that deploys it is installed separately.\n\n`deploy preview` and `deploy` hand the checked manifest to that integration.\nThe CLI resolves `@craft-ts/deploy-<name>` from the project at run time, so it\ndepends on no provider itself. `preview` never mutates anything, and `deploy`\nruns the same checks and the same preview before it applies, refusing until\n`--yes` approves the plan. [Alchemy](./alchemy.md) is the provider that ships\ntoday.\n\n## What the tooling never does\n\n- It never writes a secret. The manifest declares the _names_ of the\n environment variables and whether they are required; a declared value is a\n reported error.\n- It never mutates an infrastructure by itself. Publishing and provisioning\n belong to a provider package, and applying a plan always needs `--yes`.\n- It never replaces the build. `check` reads what the build declares and what\n it produced; Vite, Nx and the application scripts stay in charge.\n\n## Where to go next\n\n- [Manifest reference](./manifest.md) — every field, every default.\n- [Diagnostics](./diagnostics.md) — every code, its cause and its fix.\n- [Providers](./providers.md) — the capability matrix and the limits of each\n entry.\n- [Alchemy provider](./alchemy.md) — credentials, state, stages, outputs and\n rollback for the infrastructure provider.\n"
186
+ },
187
+ {
188
+ "path": "/guide/deployment/alchemy",
189
+ "title": "Alchemy provider",
190
+ "body": "# Alchemy provider\n\n::: warning Experimental — never run against a live account\nThis provider is the least validated part of the deployment tooling. The\npresets, the plan, the credential checks and the refusal paths are covered by\ntests through a runtime port, so what CraftTS _decides_ is verified.\n\nWhat is **not** verified is the last hop: the adapter that calls Alchemy itself\nhas never run against a real Cloudflare or AWS account from this repository.\nTreat your first deployment as the validation of that adapter — run\n`deploy preview` first, read the plan, and expect to adjust\n`ALCHEMY_RESOURCE_EXPORTS` if the installed Alchemy renamed a resource.\n:::\n\n`@craft-ts/deploy-alchemy` deploys a CraftTS manifest to Cloudflare or AWS\nthrough [Alchemy](https://alchemy.run). It is an optional package: Alchemy\nnever appears in the dependencies of a CraftTS application, and a project that\npublishes a static artefact somewhere else never installs it.\n\nAlchemy is a **provider of infrastructure**, not a runtime. It can create the\nresources, the bindings, the permissions and the state, where a publication\nprovider only uploads an artefact. Both read the same manifest.\n\n## Install\n\n```bash\nnpm install --save-dev @craft-ts/deploy-alchemy alchemy\n```\n\nThe CLI resolves `@craft-ts/deploy-<name>` from the project being deployed, so\nnothing else has to be configured. A provider living elsewhere is pointed at\nwith `--provider-module`.\n\n## Credentials\n\nCredentials are read from the environment. The tooling checks that they are\nset, never reads their value beyond that, and never writes one to disk.\n\n| Platform | Variables |\n| ------------ | ---------------------------------------------------------------------------------------------- |\n| `cloudflare` | `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`), `CLOUDFLARE_ACCOUNT_ID` |\n| `aws` | `AWS_ACCESS_KEY_ID` (or `AWS_PROFILE`, `AWS_ROLE_ARN`), `AWS_REGION` (or `AWS_DEFAULT_REGION`) |\n| both | `ALCHEMY_PASSWORD` |\n\n`ALCHEMY_PASSWORD` encrypts the secrets Alchemy records in its state. Deploying\nwithout it would write a state nobody can read back, so the provider refuses.\nKeep the same value across every deployment of a given stage.\n\n## State and stages\n\nAlchemy reconciles against a recorded state, which is what lets a preview tell\na creation from an update. Every resource name carries the application **and**\nthe stage:\n\n```text\ndemo-production-worker\ndemo-preview-42-worker\n```\n\nA stage is passed with `--stage`; it defaults to the `environment` of the\nmanifest. Two stages never share a resource, so a preview deployment cannot\noverwrite production.\n\n## Preview before mutating\n\n```bash\nnpx craft-ts deploy preview --provider alchemy --stage staging\n```\n\n```text\nplan: alchemy → stage staging (2 resource(s))\n create cloudflare:KVNamespace demo-staging-sessions\n binding: SESSIONS\n update cloudflare:Worker demo-staging-worker\n entrypoint: dist/apps/demo/worker.js\n assets: dist/apps/demo\n note: Alchemy 0.70.0, stage `staging`.\nPreview only: nothing was created, updated or deleted.\n```\n\nThe preview opens Alchemy in its read phase, so it resolves the recorded state\nand creates nothing. A resource Alchemy still records that the manifest no\nlonger declares appears as `delete`: hiding it would understate the change.\n\n## Deploy\n\n```bash\nnpx craft-ts deploy --provider alchemy --stage staging --yes\n```\n\n`deploy` runs, in order: the manifest check, the provider check (credentials,\nartefacts, presets), then the same preview. It refuses to apply until `--yes`\napproves the plan, and reports `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` otherwise.\n\nOutputs are printed as `<resource>.<key>`, and the first `url` output becomes\nthe deployment URL:\n\n```text\nurl: https://demo-staging.workers.dev\noutput demo-staging-sessions.id: 5f1c…\noutput demo-staging-worker.url: https://demo-staging.workers.dev\nDeployed to stage `staging` with alchemy.\n```\n\nUse `--json` to get `{ applied, plan, result, diagnostics }` for a CI step.\n\n## What each manifest becomes\n\n| Manifest | Resources |\n| ------------------------ | -------------------------------------------------------------- |\n| `static` on `cloudflare` | `StaticSite`, with the SPA fallback or the pre-rendered routes |\n| `worker` on `cloudflare` | the resources the bindings name, then the `Worker` |\n| `static` on `aws` | a private `Bucket` behind a `CloudFrontDistribution` |\n| `lambda` on `aws` | a `Function` and its `FunctionUrl` |\n| `node` on `aws` | a Fargate `Cluster` and `Service` |\n\nBindings map to the resource their `type` names — `kv`, `r2`, `d1`, `queue`,\n`durable_object`. A binding typed `secret` is never created: its value must\nalready exist in the Alchemy state or the environment, and the plan says so\ninstead of carrying it. Any other type is refused with\n`CRAFT_DEPLOY_PROVIDER_UNSUPPORTED_RESOURCE` rather than silently dropped.\n\nThe Function URL keeps the `{ id, input, context }` protocol of a\nserver-function, so the same function behaves as it does locally.\n\n## Rollback\n\nAlchemy has no \"undo\": a rollback is a deployment of the previous artefact.\n\n1. Check out the commit whose artefact was healthy, or restore its\n `dist/<app>/craft-deployment-manifest.json`.\n2. Rebuild it: the manifest is byte-identical for a given input, so a rebuild\n of the same commit produces the same declaration.\n3. `npx craft-ts deploy preview --provider alchemy --stage <stage>` and read\n the plan: a rollback shows `update` on the resources that moved forward.\n4. Apply it with `--yes`.\n\nTwo things do not roll back on their own and have to be handled explicitly: a\nresource deleted by a finalize is recreated empty, and a stateful binding such\nas a KV namespace or a bucket keeps the data written by the newer version.\nRoll a stateful change back through the data, not through the deployment.\n\n## What stays in CraftTS, what is delegated\n\nCraftTS owns the manifest, the checks, the resolved artefact and the plan\nshape. It decides _what_ has to exist, and it refuses to deploy a manifest that\ndoes not pass `craft-ts check`.\n\nAlchemy owns the resources, the state, the credentials handling and the\nreconciliation. It decides _how_ what CraftTS declared comes to exist.\n\nEverything version-specific lives in one table, `ALCHEMY_RESOURCE_EXPORTS`,\nwhich maps a planned resource type to the Alchemy module and export that\ncreates it. An Alchemy release that renames a resource is a change to that\ntable, not to the presets or the manifest.\n\n## Limits\n\n- The adapter over the Alchemy API has never run against a live account, as\n stated at the top of this page.\n- The Fargate fallback runs the artefact as a container: the image build stays\n outside CraftTS.\n- Alchemy has no preset here for the platforms a publication provider already\n covers (`vercel`, `netlify`, `firebase`, `github-pages`).\n"
191
+ },
192
+ {
193
+ "path": "/guide/deployment/diagnostics",
194
+ "title": "Deployment diagnostics",
195
+ "body": "# Deployment diagnostics\n\n::: warning Experimental\nCodes can be added, and their messages can change, between minor versions. Do\nnot match on a message in a CI script: match on the `code` field of the `--json`\nreport, which is the part meant to be stable.\n:::\n\nEvery problem `craft-ts check` reports carries a code, the concerned runtime or\nplatform, a location, what is wrong and what to change. This page is the\nreference for the codes; it is verified against the checker, so a code cannot\nship undocumented.\n\n`error` fails the check and the command exits with `1`. `warning` is printed\nand the command still succeeds — warnings cover the heuristics, such as\nscanning sources for environment variable reads, where a false positive must\nnot block a deployment.\n\nRead a report with `--json` when a machine consumes it:\n\n```bash\nnpx craft-ts check --config apps/demo-ssr/craft.deploy.ts --json\n```\n\n## Codes\n\n### `CRAFT_DEPLOY_ARTIFACT_MISSING`\n\nThe artefact directory does not exist.\n\n- **Cause** — The directory the provider would publish has not been produced.\n- **Fix** — Run the declared build command before checking the artefact.\n\n### `CRAFT_DEPLOY_ARTIFACT_NO_ENTRY`\n\nThe artefact has no browser entry point.\n\n- **Cause** — The public directory contains no `index.html`.\n- **Fix** — Check the build output directory declared in `client.outDir`.\n\n### `CRAFT_DEPLOY_ARTIFACT_NO_JAVASCRIPT`\n\nThe artefact contains no JavaScript.\n\n- **Cause** — The public directory has no `.js` or `.mjs` file, which means the build produced nothing executable.\n- **Fix** — Check the build command and its output directory.\n\n### `CRAFT_DEPLOY_ARTIFACT_SOURCE_MAP`\n\nThe artefact ships source maps.\n\n- **Cause** — The source map policy is `forbidden` and the public directory contains `.map` files.\n- **Fix** — Disable source maps in the production build, or relax `artifact.sourceMaps`.\n\n### `CRAFT_DEPLOY_CONFIG_LOAD_FAILED`\n\nThe deployment manifest could not be imported.\n\n- **Cause** — Loading the manifest threw, or the serialised manifest is not valid JSON. A TypeScript manifest also fails when the running Node cannot strip types and TypeScript is not installed.\n- **Fix** — Fix the thrown error, or run the CLI under a TypeScript loader, or commit a `craft.deploy.json` produced by the build.\n\n### `CRAFT_DEPLOY_CONFIG_NOT_FOUND`\n\nNo deployment manifest found.\n\n- **Cause** — No `craft.deploy.ts`, `craft.deploy.mjs`, `craft.deploy.js` or `craft.deploy.json` was found in the checked directory.\n- **Fix** — Create a manifest with `defineCraftDeployment` or point the CLI at one with `--config`.\n\n### `CRAFT_DEPLOY_CONFIG_NO_DEFAULT_EXPORT`\n\nThe deployment manifest has no default export.\n\n- **Cause** — The module loaded but exposes no default export to read.\n- **Fix** — Add `export default defineCraftDeployment({ ... })`.\n\n### `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED`\n\nA deployment was requested without confirmation.\n\n- **Cause** — `craft-ts deploy` mutates an infrastructure, so it refuses to run until the plan has been approved explicitly.\n- **Fix** — Review `craft-ts deploy preview`, then pass `--yes` to apply it.\n\n### `CRAFT_DEPLOY_ENV_NAME_INVALID`\n\nInvalid environment variable name.\n\n- **Cause** — A declared name is not an upper snake case identifier, which several platforms reject.\n- **Fix** — Rename the variable to `UPPER_SNAKE_CASE`.\n\n### `CRAFT_DEPLOY_ENV_UNDECLARED`\n\nAn environment variable is read but not declared.\n\n- **Cause** — The module graph of the runtime entry reads a variable the manifest does not list, so no provider can know it must be set.\n- **Fix** — Declare the variable in `env`, or stop reading it from the runtime entry.\n\n### `CRAFT_DEPLOY_ENV_VALUE_FORBIDDEN`\n\nAn environment variable carries a value.\n\n- **Cause** — The manifest is committed and read by every provider, so it declares names and requirements only.\n- **Fix** — Remove the value and provide it through the CI or the provider secret store.\n\n### `CRAFT_DEPLOY_FUNCTION_ID_DUPLICATE`\n\nA server-function identifier is declared twice.\n\n- **Cause** — The identifier is the routing key of the protocol, so a duplicate makes the exposed contract ambiguous.\n- **Fix** — Keep one declaration per identifier.\n\n### `CRAFT_DEPLOY_FUNCTION_ID_UNKNOWN`\n\nA declared server-function identifier is not in the registry entry.\n\n- **Cause** — The identifier does not appear in the module graph of `functions.entry`.\n- **Fix** — Register the function in the entry, or remove the identifier from the manifest.\n\n### `CRAFT_DEPLOY_HEALTH_PATH_MISSING`\n\nThe health route is not served by the SSR entry.\n\n- **Cause** — The path declared as `server.healthPath` was not found in the module graph of the SSR entry.\n- **Fix** — Serve the declared path, or align the manifest with the path the server exposes.\n\n### `CRAFT_DEPLOY_MANIFEST_INVALID_FIELD`\n\nA manifest field has an invalid value.\n\n- **Cause** — The field exists but its type or its shape does not match the contract.\n- **Fix** — Correct the value reported by `path`; the message states what was expected.\n\n### `CRAFT_DEPLOY_MANIFEST_MISSING_FIELD`\n\nA required manifest field is missing.\n\n- **Cause** — A field the runtime or the providers need is absent.\n- **Fix** — Add the field reported by `path`.\n\n### `CRAFT_DEPLOY_MANIFEST_NOT_AN_OBJECT`\n\nThe manifest is not an object.\n\n- **Cause** — The default export or the parsed JSON is not a plain object.\n- **Fix** — Export the object returned by `defineCraftDeployment`.\n\n### `CRAFT_DEPLOY_MANIFEST_SECTION_MISSING`\n\nThe runtime section is missing.\n\n- **Cause** — Each runtime requires its own section: `static` and `client`, `server`, `worker` or `lambda`.\n- **Fix** — Add the section the runtime requires.\n\n### `CRAFT_DEPLOY_MANIFEST_SECTION_UNEXPECTED`\n\nA section does not belong to this runtime.\n\n- **Cause** — A section of another runtime is present; nothing would ever execute it.\n- **Fix** — Remove the section, or change the runtime to the one that uses it.\n\n### `CRAFT_DEPLOY_MANIFEST_UNKNOWN_PLATFORM`\n\nUnknown platform.\n\n- **Cause** — `platform` is not part of the documented platform list.\n- **Fix** — Pick a supported platform, or open an issue to add it to the matrix.\n\n### `CRAFT_DEPLOY_MANIFEST_UNKNOWN_RUNTIME`\n\nUnknown runtime.\n\n- **Cause** — `runtime` is not one of `static`, `node`, `worker` or `lambda`.\n- **Fix** — Pick one of the four supported runtimes.\n\n### `CRAFT_DEPLOY_NODE_BUILTIN_IMPORT`\n\nA Node built-in is imported by a Worker or Lambda entry.\n\n- **Cause** — The module graph reachable from the entry imports a Node built-in such as `node:fs` or `node:http`, which a Worker runtime does not provide.\n- **Fix** — Replace the built-in with a Web API, or move the code behind a platform adapter that the Worker entry does not import.\n\n### `CRAFT_DEPLOY_PATH_MISSING`\n\nA declared path does not exist.\n\n- **Cause** — An entry point, an output directory or a configuration file declared by the manifest is absent from disk.\n- **Fix** — Run the build that produces it, or correct the path in the manifest.\n\n### `CRAFT_DEPLOY_PLATFORM_MISMATCH`\n\nThe requested platform is not the manifest platform.\n\n- **Cause** — `--platform` was passed with a value the manifest does not declare.\n- **Fix** — Drop the flag, or change `platform` in the manifest.\n\n### `CRAFT_DEPLOY_PROTOCOL_VERSION_UNSUPPORTED`\n\nUnsupported manifest protocol version.\n\n- **Cause** — The serialised manifest was produced by another version of the protocol.\n- **Fix** — Rebuild the manifest with the current CraftTS tooling, or follow the migration notes.\n\n### `CRAFT_DEPLOY_PROVIDER_CAPABILITY_MISSING`\n\nThe provider does not support this runtime.\n\n- **Cause** — The capability required by the runtime, and by the static mode when relevant, is not offered by the provider.\n- **Fix** — Choose a provider that declares the capability, or change the runtime or the static mode.\n\n### `CRAFT_DEPLOY_PROVIDER_CREDENTIALS_MISSING`\n\nThe provider has no credentials.\n\n- **Cause** — A credential the platform requires is absent from the environment. The tooling never stores one, so it can only report the name it expected.\n- **Fix** — Export the named variable in the shell or the CI secret store, then run the command again.\n\n### `CRAFT_DEPLOY_PROVIDER_INVALID_MODULE`\n\nThe provider package does not export a provider.\n\n- **Cause** — The module loaded but exposes no `createCraftDeploymentProvider` factory.\n- **Fix** — Export `createCraftDeploymentProvider(options?)` returning a `CraftDeploymentProvider`.\n\n### `CRAFT_DEPLOY_PROVIDER_NOT_INSTALLED`\n\nThe provider package is not installed.\n\n- **Cause** — The CLI resolves a provider from `@craft-ts/deploy-<name>` at run time, and that package is absent from the project.\n- **Fix** — Install the provider package, or point `--provider-module` at the module that exports it.\n\n### `CRAFT_DEPLOY_PROVIDER_PLATFORM_UNSUPPORTED`\n\nThe provider does not target this platform.\n\n- **Cause** — The provider cannot deploy to the platform declared by the manifest.\n- **Fix** — Choose a provider that targets the platform, or change the platform.\n\n### `CRAFT_DEPLOY_PROVIDER_STATE_UNAVAILABLE`\n\nThe provider state cannot be read.\n\n- **Cause** — An infrastructure provider reconciles against a recorded state; without it, a deployment cannot tell a creation from an update.\n- **Fix** — Make the state backend reachable, or initialise it for this stage before deploying.\n\n### `CRAFT_DEPLOY_PROVIDER_TOOLCHAIN_MISSING`\n\nA tool the provider drives is missing.\n\n- **Cause** — The provider shells out to a CLI or imports a runtime package that is not installed.\n- **Fix** — Install the reported tool, or choose a provider that does not need it.\n\n### `CRAFT_DEPLOY_PROVIDER_UNKNOWN`\n\nUnknown provider.\n\n- **Cause** — The provider name is absent from the capability matrix.\n- **Fix** — Use a documented provider name, or register the provider before checking.\n\n### `CRAFT_DEPLOY_PROVIDER_UNSUPPORTED_RESOURCE`\n\nThe provider has no resource for this part of the manifest.\n\n- **Cause** — The runtime, the platform or a declared binding maps to nothing the provider knows how to create.\n- **Fix** — Remove the declaration, or deploy that part with a provider that supports it.\n\n### `CRAFT_DEPLOY_READY_PATH_MISSING`\n\nThe readiness route is not served by the SSR entry.\n\n- **Cause** — The path declared as `server.readyPath` was not found in the module graph of the SSR entry.\n- **Fix** — Serve the declared path, or align the manifest with the path the server exposes.\n\n### `CRAFT_DEPLOY_RUNTIME_MISMATCH`\n\nThe requested runtime is not the manifest runtime.\n\n- **Cause** — `--runtime` was passed with a value the manifest does not declare.\n- **Fix** — Drop the flag, or change `runtime` in the manifest.\n\n### `CRAFT_DEPLOY_RUNTIME_PLATFORM_INCOMPATIBLE`\n\nThis platform cannot execute this runtime.\n\n- **Cause** — The platform has no execution shape for the declared runtime, for instance a `lambda` runtime on Cloudflare.\n- **Fix** — Change the runtime or the platform; the compatibility matrix lists the supported pairs.\n\n### `CRAFT_DEPLOY_SPA_FALLBACK_MISSING`\n\nThe SPA fallback document is missing from the client output.\n\n- **Cause** — A `spa` deployment answers unknown paths with the fallback document, which is absent from the built output.\n- **Fix** — Build the client, or correct `static.fallback`.\n\n### `CRAFT_DEPLOY_SSG_ROUTES_MISSING`\n\nThe SSG mode declares no route.\n\n- **Cause** — A `ssg` deployment pre-renders one HTML file per route and the route list is empty.\n- **Fix** — List the routes in `static.routes`, or switch the mode to `spa`.\n\n### `CRAFT_DEPLOY_SSG_ROUTE_NOT_RENDERED`\n\nA declared SSG route has no pre-rendered document.\n\n- **Cause** — The artefact contains neither `<route>.html` nor `<route>/index.html` for a route listed in `static.routes`.\n- **Fix** — Run the pre-render step for that route, or remove it from `static.routes`.\n\n### `CRAFT_DEPLOY_SSG_ROUTE_NOT_STATIC`\n\nAn SSG route cannot be pre-rendered.\n\n- **Cause** — The route is not an absolute literal path: it carries a `:param`, a wildcard or a query string, so no single HTML file represents it.\n- **Fix** — Expand the route into its literal paths, or declare it in `static.serverRoutes`.\n"
196
+ },
197
+ {
198
+ "path": "/guide/deployment/manifest",
199
+ "title": "Manifest reference",
200
+ "body": "# Manifest reference\n\n::: warning Experimental\nManifest fields can still be added, renamed or removed between minor versions.\nThe serialised form carries `protocolVersion: '1'`, and a manifest produced by\nanother protocol is refused rather than reinterpreted — so a change here breaks\nloudly, at the check, and comes with migration notes.\n:::\n\nThe manifest is what an application writes in `craft.deploy.ts`. It describes\nonly the facts a build and a deployment need, and it stays provider-neutral:\nthe provider is chosen when deploying, never to produce the artefact.\n\nAccepted file names, in priority order: `craft.deploy.ts`, `craft.deploy.mts`,\n`craft.deploy.mjs`, `craft.deploy.js`, `craft.deploy.json`.\n\n## Common fields\n\n| Field | Required | Default | Meaning |\n| ------------- | -------- | ------------ | --------------------------------------------------------- |\n| `name` | yes | — | Name of the deployment. |\n| `environment` | no | `production` | Target environment, e.g. `staging`. |\n| `runtime` | yes | — | `static`, `node`, `worker` or `lambda`. |\n| `platform` | yes | — | Technical platform executing that runtime. |\n| `client` | depends | — | Browser build command and output directory. |\n| `functions` | no | — | Server-functions exposed by the deployment. |\n| `env` | no | `[]` | Environment variables expected, **without their values**. |\n| `artifact` | no | derived | What a provider ships, and the source map policy. |\n\n`client` is required for the `static` runtime and optional for the others.\n\n## Runtime sections\n\nThe runtime discriminates which section is mandatory, at the type level and at\nthe validation level. A section belonging to another runtime is refused:\nnothing would ever execute it.\n\n### `static`\n\n```ts\nstatic: {\n mode: 'spa' | 'ssg',\n fallback?: string, // spa, default `index.html`\n routes?: readonly string[], // ssg, the routes to pre-render\n serverRoutes?: readonly string[],\n}\n```\n\nIn `ssg` mode the route list is mandatory and every route must reduce to a\nsingle document: `/`, `/about`, `/blog/2026` are pre-renderable, `/users/:id`\nand `/blog/*` are not. Routes that need a server runtime go in `serverRoutes`,\nwhich documents the boundary instead of hiding it.\n\nA route is considered rendered when the artefact holds either `<route>.html`\nor `<route>/index.html`.\n\n### `server` — the `node` runtime\n\n```ts\nserver: {\n entry: string, // SSR entry produced by the build\n source?: string, // module producing `entry`\n build?: string,\n start?: string,\n healthPath: string, // absolute path, e.g. `/health`\n readyPath: string, // absolute path, e.g. `/ready`\n}\n```\n\n`healthPath` and `readyPath` are mandatory because an SSR deployment without a\nreadiness signal cannot be rolled out safely.\n\n`source` is what makes `craft-ts check` useful _before_ a build: the checker\nreads the module graph of the source, not of an output that does not exist yet.\n\n### `worker` — the `worker` runtime\n\n```ts\nworker: {\n entry: string, // module exporting `fetch(request, env, ctx)`\n source?: string,\n build?: string,\n bindings?: readonly { name: string; type: string; description?: string }[],\n}\n```\n\nBindings are declared, never valued. `createCraftWorkerFetch` makes the HTTP\napplication and the server-functions portable; it does not make an SSR entry\nthat reaches for `node:http` or `node:fs` portable, which is exactly what the\nNode built-in check catches.\n\n### `lambda` — the `lambda` runtime\n\n```ts\nlambda: {\n entry: string, // Function URL handler\n source?: string,\n build?: string,\n permissions?: readonly string[],\n}\n```\n\n## `functions`\n\n```ts\nfunctions: {\n entry: string, // module building the server-function registry\n basePath?: string, // default `/api`\n ids?: readonly string[], // identifiers exposed to clients\n}\n```\n\nThe identifier is the routing key of the protocol, so a duplicate is refused.\nAn identifier that never appears in the module graph of `entry` is reported as\na warning.\n\n## `env`\n\n```ts\nenv: [\n {\n name: 'PORT',\n required: false,\n description: 'TCP port the server listens on.',\n },\n];\n```\n\nNames are upper snake case. A `value` or a `default` is refused: the manifest\nis committed and read by every provider, so it carries the contract, not the\nsecret. Variables read by the runtime entry but absent from this list are\nreported as warnings.\n\n## `artifact`\n\n```ts\nartifact: {\n publicDir?: string, // default: client.outDir\n serverEntry?: string, // default: the runtime entry\n start?: string, // default: server.start\n configFiles?: readonly string[],\n sourceMaps?: 'forbidden' | 'external' | 'allowed', // default: forbidden\n}\n```\n\nThe default source map policy is `forbidden`: shipping the maps of a production\nbundle publishes the sources, so an application has to opt out explicitly.\n\n## The resolved manifest\n\n`craft-ts manifest` applies every default and stamps the protocol version:\n\n```bash\nnpx craft-ts manifest --config apps/demo-ssr/craft.deploy.ts \\\n --out dist/apps/demo-ssr/craft-deployment-manifest.json\n```\n\nThe output has sorted keys, so two builds of the same input are byte-identical\nand a diff is reviewable. `protocolVersion` is `1`; a manifest produced by\nanother protocol is refused rather than reinterpreted.\n\n## A complete example\n\nThe SSR demonstrator of this repository, `apps/demo-ssr/craft.deploy.ts`,\nchecked on every production run:\n\n```ts\nimport { defineCraftDeployment } from '@craft-ts/deploy';\n\n/**\n * Deployment of the SSR demo.\n *\n * Every path is relative to the workspace root, which is the directory\n * `craft-ts check` runs from. The manifest stays provider-neutral: it says\n * what the artefact is, never who publishes it.\n */\nexport default defineCraftDeployment({\n name: 'demo-ssr',\n environment: 'production',\n runtime: 'node',\n platform: 'docker',\n client: {\n build: 'nx run demo-ssr:build:production',\n outDir: 'dist/apps/demo-ssr',\n },\n server: {\n build: 'nx run demo-ssr:build:production',\n entry: 'dist/apps/demo-ssr/server/server.js',\n // The build output only exists after a build; declaring the source lets\n // `craft-ts check` read the real module graph before that.\n source: 'apps/demo-ssr/src/production-server.ts',\n start: 'node dist/apps/demo-ssr/server/server.js',\n healthPath: '/health',\n readyPath: '/ready',\n },\n functions: {\n entry: 'apps/demo-with-server-function/src/server/server.ts',\n basePath: '/api',\n ids: [\n 'demo.products.list',\n 'demo.users.list',\n 'demo.users.authenticated-list',\n 'demo.users.portable-list',\n 'demo.users.effect-middleware-list',\n ],\n },\n env: [\n {\n name: 'HOST',\n required: false,\n description: 'Interface the server binds to.',\n },\n {\n name: 'PORT',\n required: false,\n description: 'TCP port the server listens on.',\n },\n {\n name: 'GRACEFUL_SHUTDOWN_TIMEOUT_MS',\n required: false,\n description: 'Delay granted to in-flight requests on SIGTERM.',\n },\n {\n name: 'RATE_LIMIT_MAX',\n required: false,\n description: 'Requests allowed per window on API routes.',\n },\n {\n name: 'RATE_LIMIT_WINDOW_MS',\n required: false,\n description: 'Length of the rate limiting window.',\n },\n {\n name: 'FORCE_HTTPS',\n required: false,\n description: 'Treat requests as HTTPS behind a trusted proxy.',\n },\n {\n name: 'CORS_ORIGINS',\n required: false,\n description: 'Comma-separated list of allowed origins.',\n },\n {\n name: 'TRUSTED_HOSTS',\n required: false,\n description: 'Comma-separated list of accepted Host headers.',\n },\n {\n name: 'PUBLIC_ORIGIN',\n required: false,\n description: 'Origin used to build canonical and Open Graph URLs.',\n },\n ],\n artifact: {\n publicDir: 'dist/apps/demo-ssr',\n configFiles: ['apps/demo-ssr/Dockerfile', 'docker-compose.production.yml'],\n sourceMaps: 'forbidden',\n },\n});\n```\n"
201
+ },
202
+ {
203
+ "path": "/guide/deployment/providers",
204
+ "title": "Deployment providers",
205
+ "body": "# Deployment providers\n\n::: warning Experimental\nThe provider contract is not settled: the method signatures below, the\ncapability list and the plan shape can still change between minor versions. A\nprovider written today may need a small update to keep up.\n:::\n\nA provider is the integration that builds, publishes or provisions a platform.\nIt is never a runtime of CraftTS, and never a dependency of the application\nbundle: the CLI owns the manifest and delegates every mutation to a provider\npackage installed separately.\n\nA provider implements this contract:\n\n```ts\nexport type CraftDeploymentProvider = {\n readonly name: string;\n readonly capabilities: readonly CraftDeploymentCapability[];\n check?(\n request: CraftDeploymentRequest,\n ): Promise<readonly CraftDeploymentDiagnostic[]>;\n preview(request: CraftDeploymentRequest): Promise<CraftDeploymentPlan>;\n deploy(request: CraftDeploymentRequest): Promise<CraftDeploymentResult>;\n};\n\nexport type CraftDeploymentRequest = {\n readonly manifest: CraftDeploymentManifest;\n readonly rootDir: string;\n readonly stage: string;\n};\n\nexport type CraftDeploymentCapability =\n | 'static-spa'\n | 'static-ssg'\n | 'node-ssr'\n | 'worker'\n | 'lambda'\n | 'infrastructure'\n | 'local-preview';\n```\n\n`check` reports instead of throwing, so its diagnostics join the ones of\n`craft-ts check`. `preview` returns the plan rather than printing it, because\nthe plan is the approval surface: `craft-ts deploy` shows it and refuses to\napply anything until `--yes` approves it.\n\nA provider package exports one factory, and the CLI resolves it at run time\nfrom the project being deployed:\n\n```ts\nexport function createCraftDeploymentProvider(\n options?: Record<string, unknown>,\n): CraftDeploymentProvider;\n```\n\nAdding a provider therefore never means changing the CLI.\n\nTwo families share that contract without sharing an implementation. A\npublication provider uploads an artefact. An infrastructure provider also\ncreates the resources, the bindings, the permissions and the state. Both read\nthe same manifest.\n\n## Capability matrix\n\n| Provider | static-spa | static-ssg | node-ssr | worker | lambda | infrastructure | local-preview | Platforms |\n| ------------------ | ---------- | ---------- | -------- | ------ | ------ | -------------- | ------------- | ------------------- |\n| `alchemy` | yes | yes | yes | yes | yes | yes | yes | `cloudflare`, `aws` |\n| `docker` | no | no | yes | no | no | no | yes | `docker`, `node` |\n| `cloudflare-pages` | yes | yes | no | no | no | no | no | `cloudflare` |\n| `vercel` | yes | yes | yes | no | no | no | yes | `vercel` |\n| `netlify` | yes | yes | yes | no | yes | no | no | `netlify` |\n| `firebase` | yes | yes | yes | no | yes | no | no | `firebase` |\n| `github-pages` | yes | yes | no | no | no | no | no | `github-pages` |\n\n`craft-ts check --provider <name>` refuses a manifest whose runtime — and, for\nthe `static` runtime, whose mode — is not covered by the provider, and refuses\na provider that does not target the declared platform.\n\nPrint the same matrix from the CLI:\n\n```bash\nnpx craft-ts providers --json\n```\n\n## Runtime and platform compatibility\n\nA pair absent from this table is not a missing integration: nothing on that\nplatform executes that shape.\n\n| Runtime | Platforms |\n| -------- | -------------------------------------------------------------------------------------- |\n| `static` | `node`, `docker`, `cloudflare`, `aws`, `vercel`, `netlify`, `firebase`, `github-pages` |\n| `node` | `node`, `docker`, `aws`, `vercel`, `netlify`, `firebase` |\n| `worker` | `cloudflare` |\n| `lambda` | `aws`, `netlify`, `firebase` |\n\n## Provider details\n\n### `alchemy`\n\n- **Artefact** — Public directory plus the runtime entry declared by the manifest.\n- **Local preview** — `craft-ts deploy preview --provider alchemy`\n- **Credentials** — Cloudflare or AWS credentials read from the environment by the Alchemy CLI.\n- **Limit** — Requires the Alchemy CLI and a reachable state backend.\n- **Limit** — Shipped as the separate package `@craft-ts/deploy-alchemy`.\n\n### `docker`\n\n- **Artefact** — Image built from the SSR entry and the client output.\n- **Local preview** — `docker compose -f docker-compose.production.yml up`\n- **Credentials** — Registry credentials handled by the Docker CLI.\n- **Limit** — No static-only publication path: a plain bucket is cheaper.\n- **Limit** — Provisioning of the host is out of scope.\n\n### `cloudflare-pages`\n\n- **Artefact** — Public directory uploaded as-is.\n- **Local preview** — `wrangler pages dev <publicDir>`\n- **Credentials** — `CLOUDFLARE_API_TOKEN` read by Wrangler.\n- **Limit** — SSR and Worker runtimes need a Worker deployment, not Pages.\n\n### `vercel`\n\n- **Artefact** — Public directory plus an optional Node server entry.\n- **Local preview** — `vercel dev`\n- **Credentials** — `VERCEL_TOKEN` read by the Vercel CLI.\n- **Limit** — Worker and Lambda runtimes map to platform-specific functions and are not covered by this matrix.\n- **Limit** — Infrastructure provisioning is partial and platform-owned.\n\n### `netlify`\n\n- **Artefact** — Public directory plus a functions directory.\n- **Local preview** — `netlify dev`\n- **Credentials** — `NETLIFY_AUTH_TOKEN` read by the Netlify CLI.\n- **Limit** — The Lambda capability is served by Netlify Functions, not by AWS Function URLs.\n- **Limit** — No infrastructure provisioning.\n\n### `firebase`\n\n- **Artefact** — Hosting public directory plus Cloud Functions.\n- **Local preview** — `firebase emulators:start`\n- **Credentials** — Firebase CLI login or a service account key.\n- **Limit** — No Worker runtime.\n- **Limit** — Infrastructure provisioning is partial and project-scoped.\n\n### `github-pages`\n\n- **Artefact** — Public directory published as a Pages artefact.\n- **Local preview** — none\n- **Credentials** — The `GITHUB_TOKEN` of the publishing workflow.\n- **Limit** — No server runtime at all.\n- **Limit** — SPA fallback requires a `404.html` copy of the fallback document.\n\n## Using a provider\n\n```bash\nnpx craft-ts deploy preview --provider alchemy --stage staging\nnpx craft-ts deploy --provider alchemy --stage staging --yes\n```\n\n`deploy` runs the manifest check, the provider check and the preview before it\napplies anything, and stops with `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` when the\nplan has not been approved. Capabilities are verified against the provider that\nwas actually loaded, not against this table, so a project can deploy with a\nprovider CraftTS does not ship.\n\n## Status\n\nThe matrix above is documentation, not a list of shipped integrations. One\nimplementation ships today: [Alchemy](./alchemy.md), as the separate package\n`@craft-ts/deploy-alchemy`, so Alchemy never appears in the dependencies of the\nCraftTS runtime. The other entries describe integrations a project can write\nagainst the same contract.\n"
206
+ },
177
207
  {
178
208
  "path": "/guide/forms",
179
209
  "title": "Forms",
180
- "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## 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`fieldExceptionBlock.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 { fieldExceptionBlock, input, p } from '@craft-ts/component';\n\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldExceptionBlock.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 `fieldExceptionBlock.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 fieldExceptionBlock.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 fieldExceptionBlock.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(fieldExceptionBlock.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"
210
+ "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## 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"
181
211
  },
182
212
  {
183
213
  "path": "/guide/forms/examples",
@@ -187,12 +217,12 @@
187
217
  {
188
218
  "path": "/guide/forms/exceptions",
189
219
  "title": "Form exception handling",
190
- "body": "# Form exception handling\n\nForm validation exceptions are typed UI obligations. A component must handle\nevery reachable exception in its template or forward the remaining cases to a\ncomponent boundary before it can be rendered, mounted, or used by a route.\n\n**Read this when** you render validation messages, split them between several\nlocations, or validate a group of fields.\n\n## Reading exceptions as values\n\nValidators do not throw. They keep the field and form invalid and expose their\nexceptions as signals:\n\n```ts\nconst email = loginForm.form.selectEmail();\n\nemail.errors();\nemail.exceptions().list;\nemail.exceptions().byValidator.cRequired;\nemail.firstLeftFailedValidation();\nemail.lastRightFailedValidation();\n```\n\nHandling an exception only renders its message. It does not remove the\nexception or make the field valid.\n\n## Case 1: handle every exception beside one field\n\n`CraftFieldDirective` carries the field's exact validator cases onto the VNode.\nAn exhaustive block must provide exactly one handler for every reachable code:\n\n```ts\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldExceptionBlock.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n );\n```\n\nA missing handler and an unreachable extra handler are both TypeScript errors.\n\n## Case 2: handle only some exceptions locally\n\nUse `partial` when an exception belongs beside the control while the remaining\ncases should continue to an enclosing boundary:\n\n```ts\ninput({ id: 'password', type: 'password' })\n .pipe(CraftFieldDirective(loginForm.form.selectPassword()))\n .pipe(\n fieldExceptionBlock.partial({\n required: () => p('Password is required.'),\n }),\n );\n```\n\nIf the field also declares `minLength`, that case remains in the component's\ncontract until another `partial` or `exhaustive` block handles it.\n\n## Case 3: handle several fields at a component boundary\n\nAt a boundary that receives more than one field path, group handlers by their\nstatic path. Identical codes on different fields remain separate obligations:\n\n```ts\nconst SafeLoginForm = BaseLoginForm.pipe(\n fieldExceptionBlock.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\n## Case 4: handle a group or cross-field validator\n\nA group validator is declared on an object branch rather than on one leaf\ncontrol. Materialize that branch in the component logic and return it from the\nfactory:\n\n```ts\nfunction* registrationLogic() {\n const registration = yield* state(\n 'registration',\n {\n credentials: {\n password: '',\n confirmation: '',\n },\n },\n insertForm(\n insertSelectFormTree(\n 'credentials',\n insertNoopTypingAnchor,\n insertFormAttributes(({ field }) => ({\n validators: [\n cValidate({\n name: 'passwordsMatch',\n validWhen: () =>\n field.value().password === field.value().confirmation,\n exception: () =>\n craftException({ _tag: 'passwordMismatch' }, undefined),\n }),\n ],\n })),\n ),\n ),\n );\n\n const credentials = registration.form.selectCredentials();\n return { registration, credentials };\n}\n```\n\nThe component logic now declares the typed obligation\n`credentials.passwordMismatch`. The group itself does not need a\n`CraftFieldDirective`; only its leaf controls need their usual DOM bindings.\n\n### Handle the group in the template\n\nA grouped handler on an enclosing VNode consumes the logic-level obligation:\n\n```ts\n({ credentials }) =>\n div([\n input({ type: 'password' }).pipe(CraftFieldDirective(credentials.password)),\n input({ type: 'password' }).pipe(\n CraftFieldDirective(credentials.confirmation),\n ),\n ]).pipe(\n fieldExceptionBlock.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n );\n```\n\nThe exception source is registered from the component logic, independently of\na DOM binding for the group.\n\n### Forward the group to the component boundary\n\nThe template may leave the group case unresolved and let the component\nboundary handle it:\n\n```ts\nconst SafeRegistrationForm = BaseRegistrationForm.pipe(\n fieldExceptionBlock.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n);\n```\n\nIf neither location handles it, using the component is a compile-time error:\n\n```ts\n// TypeScript error: credentials.passwordMismatch remains unhandled.\nloadCraftComponent(async () => BaseRegistrationForm);\n```\n\n## Visibility: blur and submit\n\nBy default, a block consumes `visibleExceptions`. A validation exception is\nvisible when its field or group is touched, or after a submit attempt:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only the touched field and its parent groups reveal their\nremaining exceptions. A submit attempt reveals every remaining exception in\nthe form. Resetting the form clears `dirty`, `touched`, and `submitted`, so the\nmessages become hidden again.\n\nUse `visibility: 'always'`, another `anyOf` combination, or a predicate to\noverride this policy on one block. `mode` controls whether the first or all\nmatching exceptions render, and `position` selects `before` or `after`.\n\n## Submission and schema exceptions\n\n`insertFormSubmit` exposes submission exceptions separately from field\nvalidation cases. `insertFormSchema` projects issues with paths onto matching\nfields and leaves pathless issues on the form root through\n`schemaExceptions()`.\n\n## See also\n\n- [Validation](/guide/forms/validation)\n- [Nested forms](/guide/forms/nested)\n- [Submitting a form](/guide/forms/submit)\n- [Exceptions as values](/guide/concepts/exceptions)\n"
220
+ "body": "# Form exception handling\n\nForm validation exceptions are typed UI obligations. A component must handle\nevery reachable exception in its template or forward the remaining cases to a\ncomponent boundary before it can be rendered, mounted, or used by a route.\n\n**Read this when** you render validation messages, split them between several\nlocations, or validate a group of fields.\n\n## Reading exceptions as values\n\nValidators do not throw. They keep the field and form invalid and expose their\nexceptions as signals:\n\n```ts\nconst email = loginForm.form.selectEmail();\n\nemail.errors();\nemail.exceptions().list;\nemail.exceptions().byValidator.cRequired;\nemail.firstLeftFailedValidation();\nemail.lastRightFailedValidation();\n```\n\nHandling an exception only renders its message. It does not remove the\nexception or make the field valid.\n\n## Case 1: handle every exception beside one field\n\n`CraftFieldDirective` carries the field's exact validator cases onto the VNode.\nAn exhaustive block must provide exactly one handler for every reachable code:\n\n```ts\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\nA missing handler and an unreachable extra handler are both TypeScript errors.\n\n## Case 2: handle only some exceptions locally\n\nUse `partial` when an exception belongs beside the control while the remaining\ncases should continue to an enclosing 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\nIf the field also declares `minLength`, that case remains in the component's\ncontract until another `partial` or `exhaustive` block handles it.\n\n## Case 3: handle several fields at a component boundary\n\nAt a boundary that receives more than one field path, group handlers by their\nstatic path. Identical codes on 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\n## Case 4: handle a group or cross-field validator\n\nA group validator is declared on an object branch rather than on one leaf\ncontrol. Materialize that branch in the component logic and return it from the\nfactory:\n\n```ts\nfunction* registrationLogic() {\n const registration = yield* state(\n 'registration',\n {\n credentials: {\n password: '',\n confirmation: '',\n },\n },\n insertForm(\n insertSelectFormTree(\n 'credentials',\n insertNoopTypingAnchor,\n insertFormAttributes(({ field }) => ({\n validators: [\n cValidate({\n name: 'passwordsMatch',\n validWhen: () =>\n field.value().password === field.value().confirmation,\n exception: () =>\n craftException({ _tag: 'passwordMismatch' }, undefined),\n }),\n ],\n })),\n ),\n ),\n );\n\n const credentials = registration.form.selectCredentials();\n return { registration, credentials };\n}\n```\n\nThe component logic now declares the typed obligation\n`credentials.passwordMismatch`. The group itself does not need a\n`CraftFieldDirective`; only its leaf controls need their usual DOM bindings.\n\n### Handle the group in the template\n\nA grouped handler on an enclosing VNode consumes the logic-level obligation:\n\n```ts\n({ credentials }) =>\n div([\n input({ type: 'password' }).pipe(CraftFieldDirective(credentials.password)),\n input({ type: 'password' }).pipe(\n CraftFieldDirective(credentials.confirmation),\n ),\n ]).pipe(\n fieldErrorNode.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n );\n```\n\nThe exception source is registered from the component logic, independently of\na DOM binding for the group.\n\n### Forward the group to the component boundary\n\nThe template may leave the group case unresolved and let the component\nboundary handle it:\n\n```ts\nconst SafeRegistrationForm = BaseRegistrationForm.pipe(\n fieldErrorNode.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n);\n```\n\nIf neither location handles it, using the component is a compile-time error:\n\n```ts\n// TypeScript error: credentials.passwordMismatch remains unhandled.\nloadCraftComponent(async () => BaseRegistrationForm);\n```\n\n## Visibility: blur and submit\n\nBy default, a block consumes `visibleExceptions`. A validation exception is\nvisible when its field or group is touched, or after a submit attempt:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only the touched field and its parent groups reveal their\nremaining exceptions. A submit attempt reveals every remaining exception in\nthe form. Resetting the form clears `dirty`, `touched`, and `submitted`, so the\nmessages become hidden again.\n\nUse `visibility: 'always'`, another `anyOf` combination, or a predicate to\noverride this policy on one block. `mode` controls whether the first or all\nmatching exceptions render, and `position` selects `before` or `after`.\n\n## Submission and schema exceptions\n\n`insertFormSubmit` exposes submission exceptions separately from field\nvalidation cases. `insertFormSchema` projects issues with paths onto matching\nfields and leaves pathless issues on the form root through\n`schemaExceptions()`.\n\n## See also\n\n- [Validation](/guide/forms/validation)\n- [Nested forms](/guide/forms/nested)\n- [Submitting a form](/guide/forms/submit)\n- [Exceptions as values](/guide/concepts/exceptions)\n"
191
221
  },
192
222
  {
193
223
  "path": "/guide/forms/nested",
194
224
  "title": "Nested forms",
195
- "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 `fieldExceptionBlock`. 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"
225
+ "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"
196
226
  },
197
227
  {
198
228
  "path": "/guide/forms/submit",
@@ -204,6 +234,31 @@
204
234
  "title": "Validators",
205
235
  "body": "# Validators\n\nValidators are declared on a field and produce **typed exceptions** — so the\nerrors a field can raise are known to the compiler, not discovered at runtime.\n\n**Start with the built-ins** below; reach for `cValidate` / `cAsyncValidate`\nwhen a rule is specific to your domain.\n\n@craft-ts provides a complete set of validators with structured exception handling:\n\n## Schema validation\n\nUse `insertFormSchema` when the rules describe the complete form value rather\nthan one field at a time. It accepts any schema compatible with\n`StandardSchemaV1`, including current versions of Zod, Valibot, ArkType and\nEffect Schema — the latter through\n[`Schema.toStandardSchemaV1`](/guide/state/schema-validation#effect-schema).\n\n```ts\nimport { z } from 'zod';\nimport { craftUse, insertForm, insertFormSchema, state } from '@craft-ts/core';\n\nconst userSchema = z.object({\n name: z.string().min(1),\n email: z.string().email(),\n address: z.object({\n zip: z.string().length(5),\n }),\n});\n\nconst userFormState = craftUse(\n state(\n 'userForm',\n {\n name: '',\n email: '',\n address: { zip: '' },\n },\n insertForm(insertFormSchema(userSchema)),\n ),\n);\nconst form = userFormState.form;\n```\n\nIssues with a Standard Schema path are projected onto the matching field:\n\n```ts\nform.email.errors();\nform.address.zip.errors();\nform.schemaExceptions(); // also includes root/unmaterialized issues\n```\n\nIssues without a path remain on the form root. The form is invalid while any\nschema issue exists, so `validatedFormValue()` is `undefined` and\n`insertFormSubmit` does not call its mutation.\n\nSchema validation is synchronous in forms. Use `cAsyncValidate` for an\nasynchronous field rule or an async resource for a server-side check.\n\n### Schema transformations\n\nFollowing the Standard Schema form convention, validation does not replace the\nform's input value with the schema output:\n\n```ts\nconst schema = z.object({\n age: z.string().transform(Number),\n});\n```\n\nThe form keeps `age` as a string. If the submit payload needs the transformed\nnumber, put the same schema on the mutation's `methodSchema`; the mutation\nmethod then receives the parsed output:\n\n```ts\nconst saveUser = mutation('saveUser', {\n methodSchema: schema,\n method: (user) => user, // user.age is number\n loader: saveUserRequest,\n});\n```\n\n`schemaExceptions()` returns typed `SCHEMA_VALIDATION_ERROR` exceptions. Each\nexception contains the original Standard Schema issue and its path in\n`payload.issues`.\n\n## Built-in Validators\n\n### cRequired\n\nChecks that a value is present (not empty).\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n}));\n\n// With condition\ninsertFormAttributes(() => ({\n validators: [cRequired({ when: () => fieldIsRequired() })],\n}));\n```\n\n### cEmail\n\nChecks that a string is a valid email.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cEmail()],\n}));\n```\n\n### cMin / cMax\n\nChecks that a numeric value is within a range.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cMin({ min: 18 }), cMax({ max: 100 })],\n}));\n\n// Dynamic values\ninsertFormAttributes(() => ({\n validators: [cMin({ min: () => minimumValue() })],\n}));\n```\n\n### cMinLength / cMaxLength\n\nChecks the length of a string or collection.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cMinLength({ minLength: 8 }), cMaxLength({ maxLength: 500 })],\n}));\n```\n\n### cPattern\n\nChecks that a string matches a regex pattern.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cPattern({ pattern: /^\\d{10}$/ })],\n}));\n```\n\n## Custom Validators\n\n### cValidate\n\nCreates a custom synchronous validator.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [\n cValidate({\n name: 'passwordStrength',\n validWhen: () => {\n const pwd = password();\n return pwd.length >= 8 && /[A-Z]/.test(pwd);\n },\n exception: () =>\n craftException(\n { _tag: 'weak-password' },\n {\n message:\n 'Password must contain 8 characters and an uppercase letter',\n },\n ),\n }),\n ],\n}));\n```\n\n### Group and cross-field validation\n\n`insertFormAttributes` can target an object branch as well as a leaf field. Use\nthat branch when one rule depends on several values, such as password and\nconfirmation:\n\n```ts\nfunction* registrationLogic() {\n const registration = yield* state(\n 'registration',\n {\n credentials: {\n password: '',\n confirmation: '',\n },\n },\n insertForm(\n insertSelectFormTree(\n 'credentials',\n insertNoopTypingAnchor,\n insertFormAttributes(({ field }) => ({\n validators: [\n cValidate({\n name: 'passwordsMatch',\n validWhen: () =>\n field.value().password === field.value().confirmation,\n exception: () =>\n craftException({ _tag: 'passwordMismatch' }, undefined),\n }),\n ],\n })),\n ),\n ),\n );\n\n const credentials = registration.form.selectCredentials();\n return { registration, credentials };\n}\n```\n\nCalling `selectCredentials()` materializes the branch insertion. Returning the\nselected group from component logic exposes the typed case\n`credentials.passwordMismatch` to the component contract. It must then be\nhandled in the template or at a component boundary before the component can be\nrendered, mounted, or loaded by a route.\n\nThe group does not need its own DOM control or `CraftFieldDirective`. Bind its\nleaf fields normally and render the group message on an enclosing boundary.\nSee [Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator)\nfor both rendering options.\n\n### cAsyncValidate\n\nCreates an asynchronous validator based on a resource (query or mutation).\n\n::: warning\nIt is not working yet. We are still working on it. The API is not final and may change.\n:::\n\n```ts\nconst { checkEmailQuery } = query('checkEmailQuery', {\n params: () => ({ email: emailInput() }),\n loader: async ({ params }) => {\n const response = await fetch(`/api/check-email?email=${params.email}`);\n return response.json();\n },\n});\n\ninsertFormAttributes(() => ({\n validators: [\n cAsyncValidate(checkEmailQuery, {\n name: 'emailAvailability',\n exceptionsOnSuccess: ({ validateAsyncCraftResource }) => {\n if (!validateAsyncCraftResource.value()?.available) {\n return craftException({ _tag: 'email-taken' }, undefined);\n }\n return undefined;\n },\n }),\n ],\n}));\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Form exceptions](/guide/forms/exceptions)\n"
206
236
  },
237
+ {
238
+ "path": "/guide/i18n",
239
+ "title": "Type-safe i18n",
240
+ "body": "# Type-safe i18n\n\n`@craft-ts/i18n` has **no CraftTS, Angular or Effect import**. The catalogue is\na plain TypeScript value and the runtime works in a browser, a server, a worker\nor a test without a framework. That is not a packaging detail — it is what lets\nthe same catalogue be checked by `tsc`, exercised by a Node test, and rendered\nduring SSR without a second implementation.\n\n## The contract\n\nFour things are guaranteed, and all four are checked before the app runs.\n\n| guarantee | what it costs you to break |\n| ----------------------------------------------------------- | ------------------------------------------------------------------- |\n| the key set is a **closed union** | an unknown key does not compile — no silent `order.totl` |\n| every locale has the **same keys with the same parameters** | a translation you forgot is a compile error, not a fallback |\n| parameters are **typed by their token** | a date cannot be passed where a currency amount belongs |\n| a plural carries **every category the locale requires** | Polish needs `one`/`few`/`many`/`other`; French needs `one`/`other` |\n\nThe usual failure mode of a translation layer is that all four of these are\nruntime concerns: a missing key renders its own name, a wrong parameter renders\n`[object Object]`, and a missing plural category renders the wrong branch to the\nusers of one locale only. None of that is observable from the code that calls\n`t`.\n\n## The shape of it\n\n```\nsrc/i18n/\n catalog.ts the reference locale — defineCatalog + msg + plural\n locales/fr-FR.ts every other locale — defineLocaleLike\n project-tokens.ts business tokens: defineToken / defineTokenFactory\n runtime.ts createI18nRuntime, and the reactive binding\n```\n\nA key is its dotted path: `order.total` reaches\n`{ order: { total: msg`…` } }`.\n\n## Where to go next\n\n- [The catalogue](./catalog.md) — `defineCatalog`, `msg`, `plural`,\n `defineLocale`, `defineLocaleLike`.\n- [Tokens](./tokens.md) — the shipped semantic tokens, and how to add your own.\n- [The runtime](./runtime.md) — `createI18nRuntime`, `t`, `bind`, lazy locales.\n- [With Effect](./effect.md) — `@craft-ts/i18n-effect`.\n\nTwo checks belong in CI, and `craft create` wires both:\n\n```bash\nnpm run i18n:check\nnpm run i18n:test\n```\n\nA working example lives in the demo, at `apps/demo/src/app/examples/i18n/`.\n"
241
+ },
242
+ {
243
+ "path": "/guide/i18n/catalog",
244
+ "title": "The catalogue",
245
+ "body": "# The catalogue\n\nA catalogue is a plain nested object. Nothing is parsed, nothing is loaded from\nJSON at build time, and every guarantee on this page comes from the type of the\nvalue itself.\n\n\n\n## Tokens name the parameters\n\n\n\nA token carries a **name** and a **formatter**. The name becomes the parameter\nkey; the formatter decides how the value is rendered in the active locale. That\nis why `msg` can derive the params type of a message from the tokens it\ninterpolates — see [Tokens](./tokens.md) for the full list.\n\n## `defineCatalog`, `msg`, `plural`\n\n\n\n`msg` is a **tagged template**: the literal parts are text, the interpolations\nare tokens. `` msg`Order total ${amount}.` `` has params `{ amount: number }`,\nand nothing else.\n\n`plural(count, branches)` takes the counting token and one message per category.\nWhich categories are _required_ is decided by the locale id, not by you:\n`defineLocale('pl-PL', …)` will not accept a plural missing `few` or `many`.\nThat check is a type error, before any Polish speaker sees the wrong branch.\n\nKeys nest as deeply as you like; the key used at the call site is the dotted\npath.\n\n## Every other locale is `defineLocaleLike`\n\n\n\n`defineLocale` is for the **reference** locale — the one that decides what the\nkey set is. Every other locale goes through `defineLocaleLike(reference, id,\ncatalog)`, which checks three things against the reference at compile time:\n\n- the same keys, no more and no fewer;\n- the same parameters on every message;\n- the plural categories that _this_ locale requires, which may differ from the\n reference's.\n\nA renamed key in the reference therefore breaks every translation file that\nstill has the old name, which is the entire point. It also runs\n`assertLocaleParity` at construction, so a mismatch that slips past the types —\na catalogue built dynamically, say — still fails loudly rather than rendering a\nkey name.\n\n## Checking outside the typechecker\n\n```bash\nnpm run i18n:check\n```\n\nRuns catalogue validation and locale parity as an ordinary command, so CI and a\npre-commit hook can see what `tsc` sees. Under the hood it is\n`validateCatalog` / `assertValidCatalog` (also exported from\n`@craft-ts/i18n/testing`) and `validateLocaleParity` / `assertLocaleParity`.\n\nWhen it fails, it names the key and the locale. Add the key; do not loosen the\ncatalogue's type to make the message go away.\n\n## Next\n\n- [Tokens](./tokens.md) — what `amount` and `count` above actually are.\n- [The runtime](./runtime.md) — turning these locales into a `t`.\n"
246
+ },
247
+ {
248
+ "path": "/guide/i18n/effect",
249
+ "title": "i18n with Effect",
250
+ "body": "# i18n with Effect\n\n`@craft-ts/i18n-effect` is an **adapter, and only an adapter**. It exposes three\nthings — a service tag, a `Layer`, and one function — over a runtime you built\nthe ordinary way. `@craft-ts/i18n` itself never imports Effect, and plain\ncomponent code should keep calling `t` directly.\n\n## The Layer\n\n\n\n`provideI18nRuntime(runtime)` returns `Layer.Layer<I18nEffectService>`. It wraps\nthe runtime you already have, so there is exactly one active locale in the\nprocess — the Effect side does not get its own.\n\n## Bind the locales once\n\n`translateEffect` has no value parameter carrying the locales, so TypeScript has\nnothing to infer them from. Called bare, its key parameter resolves to `never`\nand **even a valid key is rejected**. Bind them once, in the same file as the\nLayer:\n\n\n\nFrom there, `t` has the closed key union and the typed params back. Passing the\ntype arguments at every call site —\n`translateEffect<typeof locales, 'order.total'>(…)` — works too, and is what\nthis wrapper spares you.\n\n## `translateEffect`\n\n\n\nThe signature is `translateEffect(key, params) =>\nEffect.Effect<string, never, I18nEffectService>`. Same closed key union, same\ntyped params, same string as `runtime.t` — the snippet above is checked against\n`runtime.t` in the docs test suite rather than trusted.\n\nThe error channel is `never` on purpose: a translation that reaches this point\ncannot fail on a bad key or a bad parameter, because neither compiles. What\n_can_ fail is the locale not being loaded, and that is a defect in the app's\nstartup, which is why it throws `I18nRuntimeError` rather than becoming a typed\nfailure every call site would have to handle.\n\n## When to reach for it\n\nUse `translateEffect` **inside an Effect program** — a domain service building a\nmessage, a server handler rendering an email. In a component, `t` is the shorter\nand framework-independent path, and reaching for Effect just to format a string\nadds a requirement to the program for nothing.\n\nSee also the [Effect adapters](../advanced/effect.md) page for the rest of the\n`@craft-ts/*-effect` family.\n"
251
+ },
252
+ {
253
+ "path": "/guide/i18n/runtime",
254
+ "title": "The runtime",
255
+ "body": "# The runtime\n\n`createI18nRuntime` turns a set of locales into the object the application\ntranslates through. It holds one active locale, and it is deliberately small:\n`locale`, `setLocale`, `translate` (aliased `t`), `bind`, `loadLocale`.\n\n\n\n`strict` defaults to **on**. At construction, every catalogue is validated and\nevery locale is checked for parity against the first one — so a catalogue built\nin a way the types could not see still fails at startup rather than at the\nmoment a user opens the page that needs it. Pass `strict: false` only when you\nhave a reason you can write down.\n\n`timeZone` belongs on the runtime, once. Putting it on each call site is how two\ndates in the same view end up in two zones.\n\n## Translating\n\n\n\n`t` **is** `translate` — the same function under two names, so a call site can\nread as `t('order.total', …)` without a local alias. The params argument is\noptional exactly when the message has no parameters, and required, with its\nexact shape, when it does.\n\n`setLocale(id)` throws `I18nRuntimeError` with the code `LOCALE_NOT_LOADED` for\na locale the runtime does not hold. So does `t`, if the active locale was\nsomehow never loaded. The error is not a formatting failure to be swallowed: it\nmeans the app is about to render the wrong language.\n\n## Reactive translation\n\nA string that does not change when the locale changes is not a translation.\n`runtime.bind(dependency)` returns a translator whose result re-reads whenever\nthe dependency does — the dependency being an ordinary Craft reader, typically\nthe `state` that holds the active locale:\n\n\n\n`translate('order.items', { count })` then returns a generator the template\nyields like any other Craft reader. One service owns the locale for the whole\napp; components consume it rather than each building a local binding, which is\nwhat keeps two components from disagreeing about which language is on screen.\n\n## Loading catalogues\n\n\n\n`createI18nLoader` caches by id and — the part that matters — **evicts a failed\nload**, so a catalogue whose chunk died on a flaky network can be retried\ninstead of staying permanently poisoned. `loadLocale(id)` resolves once the\ncatalogue is in; only then does `setLocale` accept it.\n\n::: warning A locale must be listed to be named\n`setLocale` and `loadLocale` are keyed on the ids in `locales`, so today a\nlocale that is **not** in that array cannot be named without a cast — while a\nlocale that _is_ in it counts as already loaded and never reaches the loader.\nIn practice that means the fully lazy catalogue is not expressible in the types\nyet. List every locale, and treat `loader` as the retry-safe cache in front of\nwhatever your own loading code does.\n:::\n\nA lazily obtained locale is not present at construction, so it is **not**\ncovered by the startup parity check. Keep it covered by `npm run i18n:check`,\nwhich reads the files rather than the runtime.\n\n## Next\n\n- [With Effect](./effect.md) — the same keys, as an `Effect`.\n"
256
+ },
257
+ {
258
+ "path": "/guide/i18n/tokens",
259
+ "title": "Tokens",
260
+ "body": "# Tokens\n\nA token is the unit that makes a message parameter typed. It carries a **name**\n(the parameter key), a **kind**, an optional **guard**, and a **formatter** that\nreceives the active locale.\n\n## The shipped tokens\n\nThey are semantic, not stylistic, and every one of them formats through `Intl`,\nso the output follows the locale rather than a hand-written rule:\n\n\n\n| factory | parameter type | formats as |\n| ------------------------------------ | ---------------- | ----------------------------- |\n| `number`, `integer`, `compactNumber` | `number` | decimal, no fraction, compact |\n| `percent` | `number` | `0.125` → `12.5 %` |\n| `money` | `number` | currency, `EUR` by default |\n| `dateShort`, `dateLong`, `dateTime` | `Date \\| number` | date and date-time styles |\n| `relativeTime` | `number` | `-2` → `2 days ago` |\n\nEach is a factory: `factory(name, adapter?, options?)`. The **name** is what the\nparams object will be keyed by, so the same factory serves any number of\nparameters — `money('amount')` and `money('refund')` are two different tokens.\n\n`percent` takes a ratio, not a percentage: `0.125`, not `12.5`. That is `Intl`'s\nconvention and the token does not second-guess it.\n\n## Your own token\n\nBusiness vocabulary does not belong in a shared library. `defineToken` builds\none, and it looks exactly like a shipped token at the call site:\n\n\n\nThe `validate` guard is what keeps an arbitrary string out of the params type: a\nmessage that interpolates this token accepts `'paid' | 'pending' | 'refunded'`\nand nothing else. Without it, the parameter widens and the token stops earning\nits place.\n\n`format` receives the value and a context carrying `locale` and, when the\nruntime was given one, `timeZone`. Keep the branching on `context.locale`\ncoarse — a language prefix, not a full locale match — unless you genuinely have\nper-region wording.\n\n## A family of tokens\n\nWhen the same formatting rule serves several parameter names and options,\n`defineTokenFactory` builds the factory instead of the token:\n\n\n\nThat is exactly how `number`, `money` and the rest are built; there is no\nprivileged path for the shipped ones.\n\nConventionally these live in `src/i18n/project-tokens.ts`, which is where\n`craft create` puts them and what the generated agent skill points at.\n\n## Next\n\n- [The runtime](./runtime.md) — spending a catalogue built from these.\n"
261
+ },
207
262
  {
208
263
  "path": "/guide/migration/wave-1-tag-and-provided-in",
209
264
  "title": "Migrating to `_tag` and `providedIn`",
@@ -212,7 +267,7 @@
212
267
  {
213
268
  "path": "/guide/patterns/inject-at-point-of-use",
214
269
  "title": "Inject at the point of use",
215
- "body": "# Inject at the point of use\n\nThis page introduces the first **recommended approach** for structuring a\nCraft application. The useful rule is simple:\n\n> **Get what you need where you need it.**\n\nDeclare a dependency in the smallest factory that actually uses it. If a query\nneeds an API method, the query yields that method. If a route guard needs the\ncurrent user, the guard yields the user service. There is no need to add an\nintermediary method to a component just to forward the call.\n\n## The forwarding shape to avoid\n\nA component should not own dependency resolution, request orchestration, state\nstorage, and loading/error handling in one method:\n\n```typescript\nexport class TasksComponent {\n constructor(private readonly api: TaskApi) {}\n\n readonly tasks = signal<Task[]>([]);\n\n loadTasks() {\n this.api.list().subscribe((tasks) => this.tasks.set(tasks));\n }\n}\n```\n\nThis shape gives the component several different responsibilities: it resolves\nthe API, starts the request, stores the result, and usually reproduces loading\nand error handling as well.\n\nThe actual dependency is also hidden from the outside. Looking at the public\ntype of `TasksComponent` does not tell the compiler, a route, or a test that\n`TaskApi` is required.\n\n## Craft puts the dependency next to the work\n\nWith Craft, the component declares the query directly, and the query yields\nexactly the API operation it needs. In this example, `TaskApi` is a crafted\nservice (or a small boundary adapter):\n\n```typescript\nimport { craftComponent, each, ifBlock, li, p, ul } from '@craft-ts/component';\nimport { query } from '@craft-ts/core';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* query('tasks', {\n params: () => true,\n loader: function* () {\n return yield* TaskApi.list();\n },\n });\n\n return { tasks };\n },\n ({ tasks }) =>\n ifBlock(\n tasks.isLoading,\n () => p('Loading…'),\n () =>\n ul(\n each(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n ),\n);\n```\n\n`TaskApi` is used directly from the `query` loader. The query owns the server\nstate, while the template owns only the rendering of that state. There is no\n`loadTasks()` method, and no extra service whose only job is to forward this\nrequest.\n\n## Why this is useful\n\n### The dependency graph is explicit\n\n`yield* TaskApi.list()` is part of the factory's dependency type. Craft can use\nthe same information for route DI checks, test registers, and dependency\nsnapshots. A missing provider or mock is found at the boundary where it matters.\n\n### Dependencies stay granular\n\nWhen a consumer needs one operation, yield that operation instead of the whole\nservice:\n\n```typescript\nconst list = yield * TaskApi.list();\n```\n\nThe graph records the property that was used. Tests only need to provide\n`list`, and future changes to unrelated API methods do not expand this\nconsumer's contract.\n\n### Async behaviour has one owner\n\n`query` derives the loading, value, and exception state. The component does not\nneed a second signal, subscription, or manual error flag that could drift away\nfrom the request.\n\n## The rule of thumb\n\n- If a query or mutation needs an API operation, yield it in that query or\n mutation.\n- If a service needs another service, yield the dependency in that service's\n factory.\n- If a component needs a dependency directly, yield it in the component's\n factory.\n- Create a dedicated service when it owns reusable behaviour or a meaningful\n boundary — not merely to forward one method call.\n\nDirect does not mean unstructured. The dependency is still named, tracked,\nscoped, mockable, and exposed through a deliberate public API. It simply lives\nclose to the code that uses it.\n\n## See also\n\n- [The mental model](/guide/concepts/mental-model) — declare, yield, derive\n- [`craftService`](/guide/app/craft-service) — define and compose services\n- [Shaping a service's public API](/guide/app/expose-api) — expose only what a\n consumer needs\n- [Testing services](/guide/testing/services) — test the same dependency graph\n- [Architecture rules](/guide/testing/architecture) — constraints across that graph\n"
270
+ "body": "# Inject at the point of use\n\nThis page introduces the first **recommended approach** for structuring a\nCraft application. The useful rule is simple:\n\n> **Get what you need where you need it.**\n\nDeclare a dependency in the smallest factory that actually uses it. If a query\nneeds an API method, the query yields that method. If a route guard needs the\ncurrent user, the guard yields the user service. There is no need to add an\nintermediary method to a component just to forward the call.\n\n## The forwarding shape to avoid\n\nEven with Craft, do not resolve an API in the component factory only to forward\nit into a query:\n\n```typescript\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const api = yield* TaskApi();\n const tasks = yield* query('tasks', {\n params: () => true,\n loader: function* () {\n return yield* api.list();\n },\n });\n\n return { tasks };\n },\n ({ tasks }) => /* */,\n);\n```\n\nThe loader closes over `api`, so the query itself does not declare the operation\nit uses. The dependency is attached to the component factory instead of to the\nsmallest factory that performs the request.\n\n## Craft puts the dependency next to the work\n\nWith Craft, the component declares the query directly, and the query yields\nexactly the API operation it needs. In this example, `TaskApi` is a crafted\nservice (or a small boundary adapter):\n\n```typescript\nimport { craftComponent, forNode, ifNode, li, p, ul } from '@craft-ts/component';\nimport { query } from '@craft-ts/core';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* query('tasks', {\n params: () => true,\n loader: function* () {\n return yield* TaskApi.list();\n },\n });\n\n return { tasks };\n },\n ({ tasks }) =>\n ifNode(\n tasks.isLoading,\n () => p('Loading…'),\n () =>\n ul(\n forNode(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n ),\n);\n```\n\n`TaskApi.list()` is yielded directly from the `query` loader. The query owns the\nserver state, while the template owns only the rendering of that state. There\nis no `loadTasks()` method, no subscription, and no extra service whose only\njob is to forward this request.\n\n## Why this is useful\n\n### The dependency graph is explicit\n\n`yield* TaskApi.list()` is part of the loader's dependency type. Craft can use\nthe same information for route DI checks, test registers, and dependency\nsnapshots. A missing provider or mock is found at the boundary where it matters.\n\n### Dependencies stay granular\n\nWhen a consumer needs one operation, yield that operation instead of the whole\nservice:\n\n```typescript\nconst list = yield* TaskApi.list();\n```\n\nThe graph records the property that was used. Tests only need to provide\n`list`, and future changes to unrelated API methods do not expand this\nconsumer's contract.\n\n### Async behaviour has one owner\n\n`query` derives the loading, value, and exception state. The component does not\nneed a second signal, subscription, or manual error flag that could drift away\nfrom the request.\n\n## The rule of thumb\n\n- If a query or mutation needs an API operation, yield it in that query or\n mutation.\n- If a service needs another service, yield the dependency in that service's\n factory.\n- If a component needs a dependency directly, yield it in the component's\n factory.\n- Create a dedicated service when it owns reusable behaviour or a meaningful\n boundary — not merely to forward one method call.\n\nDirect does not mean unstructured. The dependency is still named, tracked,\nscoped, mockable, and exposed through a deliberate public API. It simply lives\nclose to the code that uses it.\n\n## See also\n\n- [The mental model](/guide/concepts/mental-model) — declare, yield, derive\n- [`craftService`](/guide/app/craft-service) — define and compose services\n- [Shaping a service's public API](/guide/app/expose-api) — expose only what a\n consumer needs\n- [Testing services](/guide/testing/services) — test the same dependency graph\n- [Architecture rules](/guide/testing/architecture) — constraints across that graph\n"
216
271
  },
217
272
  {
218
273
  "path": "/guide/reactivity/after-recomputation",
@@ -237,7 +292,7 @@
237
292
  {
238
293
  "path": "/guide/reactivity/from-event-to-source",
239
294
  "title": "fromEventToSource$",
240
- "body": "# fromEventToSource$\n\nTurns a DOM event into a readonly [`source$`](/guide/reactivity/source), with\nautomatic cleanup.\n\n**Use it when** a primitive should react to something happening on the page:\na scroll, a key, a window resize.\n\n## Overview\n\n`fromEventToSource$` bridges DOM events with craft-ts's reactive system by combining:\n\n- Event conversion to `ReadonlySource$` emissions\n- Automatic event listener cleanup via `DestroyRef`\n- Optional event payload transformation\n- Signal-based reactive access to the last emitted value\n- Manual disposal capability for dynamic use cases\n\n## Import\n\n```typescript\nimport { fromEventToSource$ } from '@craft-ts/core';\n```\n\nThe component examples below also use the hyperscript helpers:\n\n```typescript\nimport { button, craftComponent, div, each, form, input, p } from '@craft-ts/component';\n```\n\n## Signature\n\n```typescript\nfunction fromEventToSource$<T>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue?: never;\n },\n): FromEventToSource$<T>;\n\nfunction fromEventToSource$<T, ComputedValue>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue: (event: T) => ComputedValue;\n },\n): FromEventToSource$<ComputedValue>;\n```\n\n### Parameters\n\n- **`target`** - The DOM element or event target to listen to (HTMLElement, Window, Document, etc.)\n- **`eventName`** - The event name to listen for ('click', 'input', 'scroll', etc.)\n- **`options`** (optional)\n - **`event`** - Event listener options (capture, passive, once, etc.)\n - **`computedValue`** - Function to transform the event before emission\n\n### Returns\n\n`FromEventToSource$<T>` - A readonly source with:\n\n- **`subscribe(callback: (value: T) => void)`** - Subscribe to event emissions\n- **`value: Signal<T | undefined>`** - Read-only signal containing the last emitted value\n- **`dispose()`** - Method to manually remove the event listener\n\nThe result is also a named yieldable primitive. The yielded source remains\nreadonly and keeps `dispose()`:\n\n```typescript\nconst clickSource = fromEventToSource$(button, 'click');\nconst click = yield* clickSource;\n\nclick.subscribe((event) => console.log(event));\nclick.dispose();\n```\n\n## Types\n\n### FromEventToSource$\n\n```typescript\ntype FromEventToSource$<T> = ReadonlySource$<T> & {\n dispose: () => void;\n} & NamedCraftPrimitiveGen<\n string,\n ReadonlySource$<T> & {\n dispose: () => void;\n }\n >;\n```\n\n### ReadonlySource$\n\n```typescript\ntype ReadonlySource$<T> = {\n subscribe: (callback: (value: T) => void) => Subscription;\n value: Signal<T | undefined>;\n};\n```\n\n## Key Features\n\n### Source services and dependency tracking\n\nExpose the event source through a `craftService` when consumers should depend\non the event handle:\n\n```typescript\nconst { Click } = craftService(\n { name: 'Click', providedIn: 'global' },\n function* () {\n const click = yield* fromEventToSource$(button, 'click');\n return click;\n },\n);\n\nconst counter = yield* state('counter', 0, ({ set }) => ({\n click: on$(Click, () => set(1)),\n}));\n```\n\n`on$(Click, ...)` tracks `Click`. Calling `dispose()` only removes the DOM\nlistener and does not alter dependency metadata.\n\n### Automatic Cleanup\n\nEvent listeners are automatically removed when the injection context is destroyed:\n\n\n\n\n### Signal Integration\n\nAccess the last emitted value reactively via the `value` signal:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n});\n\n// Use in template or computed\nconst trimmedValue = craftComputed('trimmedValue', function* () {\n return (yield* input$.value())?.trim() ?? '';\n});\n```\n\n### Event Transformation\n\nTransform events before emission using `computedValue`:\n\n```typescript\nconst resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n});\n\n// resize$.value() returns { width: number; height: number } | undefined\n```\n\n### Integration with State\n\nUse with `on$()` to trigger state updates on DOM events:\n\n```typescript\nimport { state, on$, fromEventToSource$ } from '@craft-ts/core';\n\nconst button = document.querySelector('button')!;\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\nconst { counter } = state('counter', 0, ({ update }) => ({\n increment: on$(click$, () => update((count) => count + 1)),\n}));\n```\n\n## Examples\n\n### Basic Click Counter\n\n```typescript\nimport { craftComponent, p } from '@craft-ts/component';\nimport { fromEventToSource$, on$, state } from '@craft-ts/core';\n\nexport const Clicker = craftComponent(\n 'Clicker',\n {},\n function* () {\n const click$ = fromEventToSource$<MouseEvent>(document, 'click');\n\n const clicks = yield* state('clicks', 0, ({ update }) => ({\n // bound to the source, so NOT exposed on the ref\n increment: on$(click$, () => update((count) => count + 1)),\n }));\n\n return { clicks };\n },\n ({ clicks }) =>\n p(function* () {\n return `Clicks: ${yield* clicks()}`;\n }),\n);\n```\n\n### Input Value Tracking\n\n```typescript\nexport const Search = craftComponent(\n 'Search',\n {},\n function* () {\n const input$ = fromEventToSource$(document, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n });\n\n // reactive access to the current input value\n return { searchTerm: input$.value };\n },\n ({ searchTerm }) => [\n input({ type: 'text', placeholder: 'Search…' }),\n p(function* () {\n return `You typed: ${(yield* searchTerm()) || 'nothing yet'}`;\n }),\n ],\n);\n```\n\n### Window Scroll Tracking\n\n```typescript\nexport const InfiniteScroll = craftComponent(\n 'InfiniteScroll',\n {},\n function* () {\n const scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => ({\n scrollY: window.scrollY,\n scrollHeight: document.documentElement.scrollHeight,\n clientHeight: window.innerHeight,\n }),\n event: { passive: true }, // optimize performance\n });\n\n scroll$.subscribe((data) => {\n const nearBottom =\n data.scrollY + data.clientHeight >= data.scrollHeight - 100;\n\n if (nearBottom) {\n loadMoreData();\n }\n });\n\n return { scrollPosition: scroll$.value };\n },\n ({ scrollPosition }) =>\n div(\n p(function* () {\n return `Scroll position: ${(yield* scrollPosition())?.scrollY}`;\n }),\n ),\n);\n```\n\n### Window Resize Handling\n\n```typescript\nexport const Responsive = craftComponent(\n 'Responsive',\n {},\n function* () {\n const resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n });\n\n const dimensions = resize$.value;\n\n return {\n dimensions,\n isMobile: craftComputed('isMobile', function* () {\n const dims = yield* dimensions();\n return dims ? dims.width < 768 : false;\n }),\n };\n },\n ({ dimensions }) =>\n div(\n p(function* () {\n const dims = yield* dimensions();\n return `Viewport: ${dims?.width} x ${dims?.height}`;\n }),\n ),\n);\n```\n\n### Keyboard Shortcuts\n\n\n\n\n### Dynamic Element Listening\n\n```typescript\nexport const Dynamic = craftComponent(\n 'Dynamic',\n {},\n function* (items: Input<readonly Item[]>) {\n let currentListener$: FromEventToSource$<MouseEvent> | undefined;\n\n const attachListener = (element: HTMLElement) => {\n // remove the previous listener, if any\n currentListener$?.dispose();\n\n currentListener$ = fromEventToSource$<MouseEvent>(element, 'click');\n currentListener$.subscribe((event) => {\n console.log('Element clicked:', event);\n });\n };\n\n return { items, attachListener };\n },\n ({ items, attachListener }) =>\n each(\n () => items(),\n { track: (item) => item.id },\n (item) =>\n div(\n button(\n { click: (event) => attachListener(event.target as HTMLElement) },\n 'Attach listener',\n ),\n ),\n ),\n);\n```\n\n### Mouse Position Tracker\n\n```typescript\ninterface Position {\n x: number;\n y: number;\n}\n\nexport const CursorTracker = craftComponent(\n 'CursorTracker',\n {},\n function* () {\n const mouseMove$ = fromEventToSource$(document, 'mousemove', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n event: { passive: true },\n });\n\n return { position: mouseMove$.value };\n },\n ({ position }) =>\n div(\n p(function* () {\n const pos = yield* position();\n return `Mouse position: ${pos?.x}, ${pos?.y}`;\n }),\n ),\n);\n```\n\n### Form Submission\n\n\n\n\n## Comparison with sourceFromEvent\n\n| Feature | `fromEventToSource$` | `sourceFromEvent` |\n| ------------- | ----------------------------------------------------------- | ------------------------------------------------ |\n| Return type | `ReadonlySource$<T>` (with `subscribe`, `value`, `dispose`) | `SignalSource<T>` (with `set`, mutation methods) |\n| Modification | Read-only, no `emit` method | Writable via `set` method |\n| Use case | Event observation and subscription | Event-driven source with manual control |\n| Signal access | ✅ via `value` property | ✅ as direct signal |\n| Subscription | ✅ via `subscribe` method | ❌ (uses `afterRecomputation()`) |\n\n## Best Practices\n\n### Use Passive Event Listeners\n\nFor scroll and mouse events, use `passive: true` to improve performance:\n\n```typescript\nconst scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => window.scrollY,\n event: { passive: true },\n});\n```\n\n### Extract Only Needed Data\n\nTransform events to extract only the data you need:\n\n```typescript\n// ❌ Bad - stores entire event object\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\n// ✅ Good - extracts only needed properties\nconst click$ = fromEventToSource$(button, 'click', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n});\n```\n\n### Cleanup Dynamic Listeners\n\nFor dynamic elements, manually dispose of listeners:\n\n```typescript\nprivate listener$?: FromEventToSource$<Event>;\n\nattachToElement(element: HTMLElement) {\n this.listener$?.dispose(); // Clean up previous\n this.listener$ = fromEventToSource$(element, 'click');\n}\n\nngOnDestroy() {\n this.listener$?.dispose();\n}\n```\n\n### Combine with State Management\n\nIntegrate with state management using `on$()`:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\nconst { searchResults } = state('searchResults', [], ({ set }) => ({\n search: on$(input$, async (term) => {\n const results = await api.search(term);\n set(results);\n }),\n}));\n```\n\n## Common Patterns\n\n### Debounced Input\n\n```typescript\nimport { debounceTime } from 'rxjs/operators';\n\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\n// Use with rxjs operators if needed\nfrom(input$).pipe(\n debounceTime(300),\n subscribe((value) => console.log(value)),\n);\n```\n\n### Multiple Event Handlers\n\n```typescript\nconst buttonClick$ = fromEventToSource$(button, 'click');\nconst buttonHover$ = fromEventToSource$(button, 'mouseenter');\n\nbuttonClick$.subscribe(() => console.log('Clicked'));\nbuttonHover$.subscribe(() => console.log('Hovered'));\n```\n\n### Conditional Event Processing\n\n```typescript\nconst keydown$ = fromEventToSource$(document, 'keydown', {\n computedValue: (event: KeyboardEvent) => event.key,\n});\n\nkeydown$.subscribe((key) => {\n if (key === 'Escape') {\n this.closeModal();\n } else if (key === 'Enter') {\n this.submit();\n }\n});\n```\n\n## Notes\n\n- Must be called within an injection context\n- Event listeners are automatically removed on component destruction\n- Returns a **readonly** source - no `emit` method is exposed\n- The `value` signal is `undefined` until the first event is emitted\n- Use `dispose()` for manual cleanup when needed\n\n## See Also\n\n- [source$](/guide/reactivity/source) - Event emitter with signal tracking\n- [sourceFromEvent](/guide/reactivity/source-from-event) - Writable source from events\n- [on$](/guide/reactivity/on) - Subscribe to sources in state management\n- [state](/guide/state/local-state) - State primitive with source integration\n"
295
+ "body": "# fromEventToSource$\n\nTurns a DOM event into a readonly [`source$`](/guide/reactivity/source), with\nautomatic cleanup.\n\n**Use it when** a primitive should react to something happening on the page:\na scroll, a key, a window resize.\n\n## Overview\n\n`fromEventToSource$` bridges DOM events with craft-ts's reactive system by combining:\n\n- Event conversion to `ReadonlySource$` emissions\n- Automatic event listener cleanup via `DestroyRef`\n- Optional event payload transformation\n- Signal-based reactive access to the last emitted value\n- Manual disposal capability for dynamic use cases\n\n## Import\n\n```typescript\nimport { fromEventToSource$ } from '@craft-ts/core';\n```\n\nThe component examples below also use the hyperscript helpers:\n\n```typescript\nimport { button, craftComponent, div, forNode, form, input, p } from '@craft-ts/component';\n```\n\n## Signature\n\n```typescript\nfunction fromEventToSource$<T>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue?: never;\n },\n): FromEventToSource$<T>;\n\nfunction fromEventToSource$<T, ComputedValue>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue: (event: T) => ComputedValue;\n },\n): FromEventToSource$<ComputedValue>;\n```\n\n### Parameters\n\n- **`target`** - The DOM element or event target to listen to (HTMLElement, Window, Document, etc.)\n- **`eventName`** - The event name to listen for ('click', 'input', 'scroll', etc.)\n- **`options`** (optional)\n - **`event`** - Event listener options (capture, passive, once, etc.)\n - **`computedValue`** - Function to transform the event before emission\n\n### Returns\n\n`FromEventToSource$<T>` - A readonly source with:\n\n- **`subscribe(callback: (value: T) => void)`** - Subscribe to event emissions\n- **`value: Signal<T | undefined>`** - Read-only signal containing the last emitted value\n- **`dispose()`** - Method to manually remove the event listener\n\nThe result is also a named yieldable primitive. The yielded source remains\nreadonly and keeps `dispose()`:\n\n```typescript\nconst clickSource = fromEventToSource$(button, 'click');\nconst click = yield* clickSource;\n\nclick.subscribe((event) => console.log(event));\nclick.dispose();\n```\n\n## Types\n\n### FromEventToSource$\n\n```typescript\ntype FromEventToSource$<T> = ReadonlySource$<T> & {\n dispose: () => void;\n} & NamedCraftPrimitiveGen<\n string,\n ReadonlySource$<T> & {\n dispose: () => void;\n }\n >;\n```\n\n### ReadonlySource$\n\n```typescript\ntype ReadonlySource$<T> = {\n subscribe: (callback: (value: T) => void) => Subscription;\n value: Signal<T | undefined>;\n};\n```\n\n## Key Features\n\n### Source services and dependency tracking\n\nExpose the event source through a `craftService` when consumers should depend\non the event handle:\n\n```typescript\nconst { Click } = craftService(\n { name: 'Click', providedIn: 'global' },\n function* () {\n const click = yield* fromEventToSource$(button, 'click');\n return click;\n },\n);\n\nconst counter = yield* state('counter', 0, ({ set }) => ({\n click: on$(Click, () => set(1)),\n}));\n```\n\n`on$(Click, ...)` tracks `Click`. Calling `dispose()` only removes the DOM\nlistener and does not alter dependency metadata.\n\n### Automatic Cleanup\n\nEvent listeners are automatically removed when the injection context is destroyed:\n\n\n\n\n### Signal Integration\n\nAccess the last emitted value reactively via the `value` signal:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n});\n\n// Use in template or computed\nconst trimmedValue = craftComputed('trimmedValue', function* () {\n return (yield* input$.value())?.trim() ?? '';\n});\n```\n\n### Event Transformation\n\nTransform events before emission using `computedValue`:\n\n```typescript\nconst resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n});\n\n// resize$.value() returns { width: number; height: number } | undefined\n```\n\n### Integration with State\n\nUse with `on$()` to trigger state updates on DOM events:\n\n```typescript\nimport { state, on$, fromEventToSource$ } from '@craft-ts/core';\n\nconst button = document.querySelector('button')!;\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\nconst { counter } = state('counter', 0, ({ update }) => ({\n increment: on$(click$, () => update((count) => count + 1)),\n}));\n```\n\n## Examples\n\n### Basic Click Counter\n\n```typescript\nimport { craftComponent, p } from '@craft-ts/component';\nimport { fromEventToSource$, on$, state } from '@craft-ts/core';\n\nexport const Clicker = craftComponent(\n 'Clicker',\n {},\n function* () {\n const click$ = fromEventToSource$<MouseEvent>(document, 'click');\n\n const clicks = yield* state('clicks', 0, ({ update }) => ({\n // bound to the source, so NOT exposed on the ref\n increment: on$(click$, () => update((count) => count + 1)),\n }));\n\n return { clicks };\n },\n ({ clicks }) =>\n p(function* () {\n return `Clicks: ${yield* clicks()}`;\n }),\n);\n```\n\n### Input Value Tracking\n\n```typescript\nexport const Search = craftComponent(\n 'Search',\n {},\n function* () {\n const input$ = fromEventToSource$(document, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n });\n\n // reactive access to the current input value\n return { searchTerm: input$.value };\n },\n ({ searchTerm }) => [\n input({ type: 'text', placeholder: 'Search…' }),\n p(function* () {\n return `You typed: ${(yield* searchTerm()) || 'nothing yet'}`;\n }),\n ],\n);\n```\n\n### Window Scroll Tracking\n\n```typescript\nexport const InfiniteScroll = craftComponent(\n 'InfiniteScroll',\n {},\n function* () {\n const scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => ({\n scrollY: window.scrollY,\n scrollHeight: document.documentElement.scrollHeight,\n clientHeight: window.innerHeight,\n }),\n event: { passive: true }, // optimize performance\n });\n\n scroll$.subscribe((data) => {\n const nearBottom =\n data.scrollY + data.clientHeight >= data.scrollHeight - 100;\n\n if (nearBottom) {\n loadMoreData();\n }\n });\n\n return { scrollPosition: scroll$.value };\n },\n ({ scrollPosition }) =>\n div(\n p(function* () {\n return `Scroll position: ${(yield* scrollPosition())?.scrollY}`;\n }),\n ),\n);\n```\n\n### Window Resize Handling\n\n```typescript\nexport const Responsive = craftComponent(\n 'Responsive',\n {},\n function* () {\n const resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n });\n\n const dimensions = resize$.value;\n\n return {\n dimensions,\n isMobile: craftComputed('isMobile', function* () {\n const dims = yield* dimensions();\n return dims ? dims.width < 768 : false;\n }),\n };\n },\n ({ dimensions }) =>\n div(\n p(function* () {\n const dims = yield* dimensions();\n return `Viewport: ${dims?.width} x ${dims?.height}`;\n }),\n ),\n);\n```\n\n### Keyboard Shortcuts\n\n\n\n\n### Dynamic Element Listening\n\n```typescript\nexport const Dynamic = craftComponent(\n 'Dynamic',\n {},\n function* (items: Input<readonly Item[]>) {\n let currentListener$: FromEventToSource$<MouseEvent> | undefined;\n\n const attachListener = (element: HTMLElement) => {\n // remove the previous listener, if any\n currentListener$?.dispose();\n\n currentListener$ = fromEventToSource$<MouseEvent>(element, 'click');\n currentListener$.subscribe((event) => {\n console.log('Element clicked:', event);\n });\n };\n\n return { items, attachListener };\n },\n ({ items, attachListener }) =>\n forNode(\n () => items(),\n { track: (item) => item.id },\n (item) =>\n div(\n button(\n { click: (event) => attachListener(event.target as HTMLElement) },\n 'Attach listener',\n ),\n ),\n ),\n);\n```\n\n### Mouse Position Tracker\n\n```typescript\ninterface Position {\n x: number;\n y: number;\n}\n\nexport const CursorTracker = craftComponent(\n 'CursorTracker',\n {},\n function* () {\n const mouseMove$ = fromEventToSource$(document, 'mousemove', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n event: { passive: true },\n });\n\n return { position: mouseMove$.value };\n },\n ({ position }) =>\n div(\n p(function* () {\n const pos = yield* position();\n return `Mouse position: ${pos?.x}, ${pos?.y}`;\n }),\n ),\n);\n```\n\n### Form Submission\n\n\n\n\n## Comparison with sourceFromEvent\n\n| Feature | `fromEventToSource$` | `sourceFromEvent` |\n| ------------- | ----------------------------------------------------------- | ------------------------------------------------ |\n| Return type | `ReadonlySource$<T>` (with `subscribe`, `value`, `dispose`) | `SignalSource<T>` (with `set`, mutation methods) |\n| Modification | Read-only, no `emit` method | Writable via `set` method |\n| Use case | Event observation and subscription | Event-driven source with manual control |\n| Signal access | ✅ via `value` property | ✅ as direct signal |\n| Subscription | ✅ via `subscribe` method | ❌ (uses `afterRecomputation()`) |\n\n## Best Practices\n\n### Use Passive Event Listeners\n\nFor scroll and mouse events, use `passive: true` to improve performance:\n\n```typescript\nconst scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => window.scrollY,\n event: { passive: true },\n});\n```\n\n### Extract Only Needed Data\n\nTransform events to extract only the data you need:\n\n```typescript\n// ❌ Bad - stores entire event object\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\n// ✅ Good - extracts only needed properties\nconst click$ = fromEventToSource$(button, 'click', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n});\n```\n\n### Cleanup Dynamic Listeners\n\nFor dynamic elements, manually dispose of listeners:\n\n```typescript\nprivate listener$?: FromEventToSource$<Event>;\n\nattachToElement(element: HTMLElement) {\n this.listener$?.dispose(); // Clean up previous\n this.listener$ = fromEventToSource$(element, 'click');\n}\n\nngOnDestroy() {\n this.listener$?.dispose();\n}\n```\n\n### Combine with State Management\n\nIntegrate with state management using `on$()`:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\nconst { searchResults } = state('searchResults', [], ({ set }) => ({\n search: on$(input$, async (term) => {\n const results = await api.search(term);\n set(results);\n }),\n}));\n```\n\n## Common Patterns\n\n### Debounced Input\n\n```typescript\nimport { debounceTime } from 'rxjs/operators';\n\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\n// Use with rxjs operators if needed\nfrom(input$).pipe(\n debounceTime(300),\n subscribe((value) => console.log(value)),\n);\n```\n\n### Multiple Event Handlers\n\n```typescript\nconst buttonClick$ = fromEventToSource$(button, 'click');\nconst buttonHover$ = fromEventToSource$(button, 'mouseenter');\n\nbuttonClick$.subscribe(() => console.log('Clicked'));\nbuttonHover$.subscribe(() => console.log('Hovered'));\n```\n\n### Conditional Event Processing\n\n```typescript\nconst keydown$ = fromEventToSource$(document, 'keydown', {\n computedValue: (event: KeyboardEvent) => event.key,\n});\n\nkeydown$.subscribe((key) => {\n if (key === 'Escape') {\n this.closeModal();\n } else if (key === 'Enter') {\n this.submit();\n }\n});\n```\n\n## Notes\n\n- Must be called within an injection context\n- Event listeners are automatically removed on component destruction\n- Returns a **readonly** source - no `emit` method is exposed\n- The `value` signal is `undefined` until the first event is emitted\n- Use `dispose()` for manual cleanup when needed\n\n## See Also\n\n- [source$](/guide/reactivity/source) - Event emitter with signal tracking\n- [sourceFromEvent](/guide/reactivity/source-from-event) - Writable source from events\n- [on$](/guide/reactivity/on) - Subscribe to sources in state management\n- [state](/guide/state/local-state) - State primitive with source integration\n"
241
296
  },
242
297
  {
243
298
  "path": "/guide/reactivity/on",
@@ -262,7 +317,7 @@
262
317
  {
263
318
  "path": "/guide/routing/eslint-rules",
264
319
  "title": "ESLint rules",
265
- "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\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-type-assertions-in-template': '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/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\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-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': '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/require-cascade-route-di-check': '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 `ifBlock(...)`, `matchBlock.exhaustive(...)`, `each(...)`, or `defer(...)`\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/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-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/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-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`; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`\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-async-await`: forbids `async` functions, `await`, and `for await...of`; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/no-throw`: forbids `throw` in Craft code 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-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 that only returns `yield* callback()` with the callback reference itself\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/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/require-cascade-route-di-check`: rejects any `craftRoutes(...)` collection without a same-file `ValidateCascadeRoutesFile + CanRun` proof; its autofix adds the conservative `<never, Router>` context, which should be adjusted when the mount inherits providers\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\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\nifBlock(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchBlock.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({ disabled: function* () {\n return !(yield* machine.canGoBack());\n} }, 'Back');\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 = yield* query(\n 'users',\n config,\n ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\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.\nmatchBlock.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### 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-cascade-route-di-check` | the same-file DI proof for a `craftRoutes(...)` collection |\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` | removes redundant template generators |\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 `prefer-craft-http-client`, `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\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"
320
+ "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\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-type-assertions-in-template': '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/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/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': '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-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/require-cascade-route-di-check': '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/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/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-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/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`; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`\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-async-await`: forbids `async` functions, `await`, and `for await...of`; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/no-throw`: forbids `throw` in Craft code 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-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/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` — instead of the plain primitives 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/require-cascade-route-di-check`: rejects any `craftRoutes(...)` collection without a same-file `ValidateCascadeRoutesFile + CanRun` proof; its autofix adds the conservative `<never, Router>` context, which should be adjusted when the mount inherits providers\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\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### 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-cascade-route-di-check` | the same-file DI proof for a `craftRoutes(...)` collection |\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"
266
321
  },
267
322
  {
268
323
  "path": "/guide/routing/exception-handling",
@@ -282,7 +337,7 @@
282
337
  {
283
338
  "path": "/guide/routing/pending-ui",
284
339
  "title": "Non-blocking navigation",
285
- "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\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` (kept out of the parent's cascade DI budget).\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\n[craftRouterLink]=\"{\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n}\"\n```\n\nThe skeleton (and/or the target) reads it through the **route-generated typed helper** and wears the\nmatching `view-transition-name`:\n\n```ts\nexport default class PhotoSkeleton {\n protected readonly photoId = injectPhotosPhotoIdParams();\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, …), but the aggregated cascade (`ValidateCascadeRoutesFile`) only sees the **target**\ncomponent — it never descends into `pendingComponent`. So the skeleton is verified **directly**, with\nthe per-component, O(1) [`RouteCheckedDI`](/guide/routing/setup#escape-hatch-the-o-1-per-route-check) escape\nhatch (not a second aggregated pass — that would add to the instantiation-count budget the cascade is\nalready spending):\n\n```ts\ntype _CheckTargetDI = ValidateCascadeRoutesFile<\n AppNames,\n AppValues,\n typeof photosRoutes\n>;\ntype _CanRunTarget = CanRun<_CheckTargetDI>;\n\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 cascade check 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 `GenDeps_*`, deriving the auto-provided\nservice names from the route's path params + payload, and borrowing the parent context from the\ncollection's own `ValidateCascadeRoutesFile` — so you never hand-write or stale it.\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"
340
+ "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` (kept out of the parent's cascade DI budget).\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 (and/or the target) reads it through the **route-generated typed helper** and wears the\nmatching `view-transition-name`:\n\n```ts\nexport default class PhotoSkeleton {\n protected readonly photoId = injectPhotosPhotoIdParams();\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, …), but the aggregated cascade (`ValidateCascadeRoutesFile`) only sees the **target**\ncomponent — it never descends into `pendingComponent`. So the skeleton is verified **directly**, with\nthe per-component, O(1) [`RouteCheckedDI`](/guide/routing/setup#escape-hatch-the-o-1-per-route-check) escape\nhatch (not a second aggregated pass — that would add to the instantiation-count budget the cascade is\nalready spending):\n\n```ts\ntype _CheckTargetDI = ValidateCascadeRoutesFile<\n AppNames,\n AppValues,\n typeof photosRoutes\n>;\ntype _CanRunTarget = CanRun<_CheckTargetDI>;\n\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 cascade check 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 `GenDeps_*`, deriving the auto-provided\nservice names from the route's path params + payload, and borrowing the parent context from the\ncollection's own `ValidateCascadeRoutesFile` — so you never hand-write or stale it.\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"
286
341
  },
287
342
  {
288
343
  "path": "/guide/routing/route-load-errors",
@@ -332,7 +387,7 @@
332
387
  {
333
388
  "path": "/guide/state/pagination-placeholder",
334
389
  "title": "Pagination placeholders",
335
- "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, each, ifBlock, 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 each(\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 ifBlock(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"
390
+ "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"
336
391
  },
337
392
  {
338
393
  "path": "/guide/state/persistence",
@@ -362,13 +417,53 @@
362
417
  {
363
418
  "path": "/guide/state/server-state",
364
419
  "title": "query",
365
- "body": "# query\n\n`query` fetches data and owns its whole lifecycle — loading, resolved,\nexception — re-running itself when its inputs change.\n\n**Use it when** you display data that lives on a server.\n**Not when** you write to the server ([`mutation`](/guide/state/mutations)) or\nrun a one-off async action that isn't a fetch\n([`asyncProcess`](/guide/state/async-process)).\n\n::: warning One source of truth\nDon't copy a query's result into a `state`. The query _is_ the state.\nDon't reload it from a `craftEffect` either — put the inputs in `params` so\nthe loader re-runs when they change.\n:::\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: () => ({ userId: currentUserId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params.userId}`,\n success: response<User>(),\n }));\n },\n });\n```\n\n`params` is reactive: when what it returns changes, the loader runs again. The\nresult carries the full async state:\n\n```typescript\nuserQuery.value(); // User | undefined — never throws\nuserQuery.isLoading(); // boolean\nuserQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\nuserQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## Reading only settled data\n\nUse `settledValue` when a template or derived computation requires a real\nvalue. It suspends to the nearest `pendingBlock` while the first value is\nunavailable, propagates query exceptions to a `catchBlock`, and keeps the\nprevious value during a reload.\n\n```typescript\nconst userName = craftComputed('userName', function* () {\n return (yield* settled(userQuery)).name;\n});\n\nconst user = craftUse(userQuery.settledValue());\n```\n\nInsertion contexts keep the existing fallback behaviour of `state()`. Use\n`settledState()` when `yield*` (or `craftUse`) should return a non-nullable\nvalue and suspend until the current resource is available.\n\n## Triggering it yourself\n\nWhen the trigger is a user action rather than a reactive input, use `method`\ninstead of `params`:\n\n```typescript\nconst { searchQuery } =\n yield *\n query('searchQuery', {\n method: (term: string) => term,\n loader: function* ({ params: term }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/search?q=${term}`,\n success: response<Array<{ id: string; title: string }>>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * searchQuery.call('craft');\n```\n\nFrom an ordinary UI callback, the imperative form remains valid:\n`click: () => searchQuery.call(term)`. Do not put either form in a\n`craftEffect` dependency graph; use reactive `params` for data loading.\n\n## Adding derived values\n\nSame insertion mechanism as any primitive:\n\n```typescript\nconst { todosQuery } =\n yield *\n query(\n 'todosQuery',\n {\n params: () => ({ completed: showCompleted() }),\n loader: async ({ params }) =>\n (await fetch(`/api/todos?completed=${params.completed}`)).json(),\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* todosQuery.count();\n```\n\nAn insertion can also be a `function*` when it needs to yield services.\n\n## Enriching every item in a list\n\nWhen a query returns an array, `insertQuerySelect` attaches an insertion to each\nselected item. The selector keeps the item type, so derived values can use its\nproperties without casting:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core';\n\ntype User = {\n id: string;\n firstName: string;\n lastName: string;\n role: 'admin' | 'member';\n};\n\nconst { usersQuery } =\n yield *\n query(\n 'usersQuery',\n {\n params: () => ({ teamId: currentTeamId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/teams/${params.teamId}/users`,\n success: response<User[]>(),\n }));\n },\n },\n insertQuerySelect('user', ({ state }) => ({\n displayName: craftComputed(function* () {\n const user = yield* state();\n return `${user.firstName} ${user.lastName}`;\n }),\n roleLabel: craftComputed(function* () {\n return (yield* state()).role === 'admin' ? 'Administrator' : 'Member';\n }),\n })),\n );\n\n// `selectUser` targets one item in the returned array.\nconst firstUser = usersQuery.selectUser(0);\nyield* firstUser?.displayName(); // 'Ada Lovelace'\nyield* firstUser?.roleLabel(); // 'Administrator'\n```\n\nThe same pattern supports selecting a nested object property with\n`insertQuerySelect`, while preserving the selected property's type.\n\n## Avoiding the flicker when inputs change\n\n**This is already the default.** When `params` change, the previous value stays\nvisible until the new one resolves, so a paginated list never blanks out\nmid-navigation.\n\nYou only touch the option to turn it **off**:\n\n```typescript\nquery('postsQuery', {\n params: () => ({ page: currentPage() }),\n preservePreviousValue: () => false, // clear the value while loading\n loader: async ({ params }) =>\n (await fetch(`/api/posts?page=${params.page}`)).json(),\n});\n```\n\n::: tip Not consulted for parallel queries\nWith an `identifier`, each key keeps its own resource, so there is no \"previous\nvalue\" to preserve — the option is ignored on that path.\n:::\n\n## Reacting to a mutation\n\nRather than reloading by hand after a write, declare the link:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst userQuery = yield* query(\n 'userQuery',\n {\n params: () => ({ userId: currentUserId() }),\n loader: /* … */,\n },\n insertQueryPipe(\n insertReactOnMutation(updateUserMutation, {\n // apply the change immediately, before the server answers\n optimisticPatch: {\n name: ({ mutationParams }) => mutationParams.name,\n email: ({ mutationParams }) => mutationParams.email,\n },\n // and go get the truth back if the mutation failed\n reload: { onMutationException: true },\n }),\n insertStoragePersister(craftUnique({\n storeName: 'demo-app',\n key: 'user-query',\n })),\n ),\n);\n```\n\nFull options on [Reacting to mutations](/guide/state/react-on-mutation).\n\n## Exceptions\n\n`exceptions()` is split by **origin** and typed from the codes you declared —\n`params` for what your `method` rejected before any request, `loader` for what\nthe request produced:\n\n```typescript\nimport { craftException, query } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'forbidden'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * userQuery.call('ab');\nuserQuery.hasException(); // true\nuserQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * userQuery.call('forbidden');\nuserQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs — you\ndon't send a request you already know will fail.\n\n## Pitfalls\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the query is loading or in exception.\n\n**`params` must be cheap and pure.** It runs inside a reactive computation; side\neffects belong in the loader.\n\n::: details Advanced — parallel queries by identifier\n`identifier` keeps one resource per key, so several runs coexist instead of\nreplacing each other:\n\n```typescript\nconst userId = signal<number | undefined>(undefined);\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: userId,\n identifier: (id) => id,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n });\n\nuserId.set(1);\nuserId.set(2);\n\nuserQuery.select('1').value(); // user 1\nuserQuery.select('2').value(); // user 2\n```\n\n:::\n\n::: details Advanced — typed HTTP exceptions\nLoader exceptions are matched declaratively: each matcher yields predicates on\nthe response and returns a `craftException` when it recognises the failure.\n\n```typescript\nloader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeatureForDependencies',\n });\n },\n function* ({ body, header }) {\n const payload = yield* body<{\n errors?: Array<{ field: 'password' }>;\n }>();\n\n if (!payload.errors?.some((error) => error.field === 'password')) return;\n if (!(yield* header('x-error-kind', 'validation'))) return;\n\n return craftException({\n _tag: 'VALIDATION_HEADER_ERROR',\n scope: 'UsersFeatureForDependencies',\n });\n },\n ],\n }));\n}\n```\n\nWorking source:\n[exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts).\n:::\n\n::: details Advanced — yielding dependencies from `params`\n`params` can be a generator, and so can an insertion:\n\n```typescript\nconst { userQuery } =\n yield *\n query(\n 'userQuery',\n {\n providers: [provideUserService(), provideUserApiService()],\n params: function* () {\n return yield* UserService.userId();\n },\n loader: function* ({ params: userId }) {\n return yield* UserApiService.get(userId);\n },\n },\n function* () {\n const queryTools = yield* QueryTools();\n return { queryKey: `${queryTools.prefix()}:details` };\n },\n );\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryMethodRuntimeContext()`, and the query\nvalue itself is published to `providePrimitiveResourceRuntimeObserver`. Both\nexpose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and\nother advanced patterns can seed or replace a result without going through the\ninsertion callback. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Mutations](/guide/state/mutations) — the write side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
420
+ "body": "# query\n\n`query` fetches data and owns its whole lifecycle — loading, resolved,\nexception — re-running itself when its inputs change.\n\n**Use it when** you display data that lives on a server.\n**Not when** you write to the server ([`mutation`](/guide/state/mutations)) or\nrun a one-off async action that isn't a fetch\n([`asyncProcess`](/guide/state/async-process)).\n\n::: warning One source of truth\nDon't copy a query's result into a `state`. The query _is_ the state.\nDon't reload it from a `craftEffect` either — put the inputs in `params` so\nthe loader re-runs when they change.\n:::\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: () => ({ userId: currentUserId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params.userId}`,\n success: response<User>(),\n }));\n },\n });\n```\n\n`params` is reactive: when what it returns changes, the loader runs again. The\nresult carries the full async state:\n\n```typescript\nuserQuery.value(); // User | undefined — never throws\nuserQuery.isLoading(); // boolean\nuserQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\nuserQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## Reading only settled data\n\nUse `settledValue` when a template or derived computation requires a real\nvalue. It suspends to the nearest `pendingNode` while the first value is\nunavailable, propagates query exceptions to a `catchNode`, and keeps the\nprevious value during a reload.\n\n```typescript\nconst userName = craftComputed('userName', function* () {\n return (yield* settled(userQuery)).name;\n});\n\nconst user = craftUse(userQuery.settledValue());\n```\n\nInsertion contexts keep the existing fallback behaviour of `state()`. Use\n`settledState()` when `yield*` (or `craftUse`) should return a non-nullable\nvalue and suspend until the current resource is available.\n\n## Triggering it yourself\n\nWhen the trigger is a user action rather than a reactive input, use `method`\ninstead of `params`:\n\n```typescript\nconst { searchQuery } =\n yield *\n query('searchQuery', {\n method: (term: string) => term,\n loader: function* ({ params: term }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/search?q=${term}`,\n success: response<Array<{ id: string; title: string }>>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * searchQuery.call('craft');\n```\n\nFrom an ordinary UI callback, the imperative form remains valid:\n`click: () => searchQuery.call(term)`. Do not put either form in a\n`craftEffect` dependency graph; use reactive `params` for data loading.\n\n## Adding derived values\n\nSame insertion mechanism as any primitive:\n\n```typescript\nconst { todosQuery } =\n yield *\n query(\n 'todosQuery',\n {\n params: () => ({ completed: showCompleted() }),\n loader: async ({ params }) =>\n (await fetch(`/api/todos?completed=${params.completed}`)).json(),\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* todosQuery.count();\n```\n\nAn insertion can also be a `function*` when it needs to yield services.\n\n## Enriching every item in a list\n\nWhen a query returns an array, `insertQuerySelect` attaches an insertion to each\nselected item. The selector keeps the item type, so derived values can use its\nproperties without casting:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core';\n\ntype User = {\n id: string;\n firstName: string;\n lastName: string;\n role: 'admin' | 'member';\n};\n\nconst { usersQuery } =\n yield *\n query(\n 'usersQuery',\n {\n params: () => ({ teamId: currentTeamId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/teams/${params.teamId}/users`,\n success: response<User[]>(),\n }));\n },\n },\n insertQuerySelect('user', ({ state }) => ({\n displayName: craftComputed(function* () {\n const user = yield* state();\n return `${user.firstName} ${user.lastName}`;\n }),\n roleLabel: craftComputed(function* () {\n return (yield* state()).role === 'admin' ? 'Administrator' : 'Member';\n }),\n })),\n );\n\n// `selectUser` targets one item in the returned array.\nconst firstUser = usersQuery.selectUser(0);\nyield* firstUser?.displayName(); // 'Ada Lovelace'\nyield* firstUser?.roleLabel(); // 'Administrator'\n```\n\nThe same pattern supports selecting a nested object property with\n`insertQuerySelect`, while preserving the selected property's type.\n\n## Avoiding the flicker when inputs change\n\n**This is already the default.** When `params` change, the previous value stays\nvisible until the new one resolves, so a paginated list never blanks out\nmid-navigation.\n\nYou only touch the option to turn it **off**:\n\n```typescript\nquery('postsQuery', {\n params: () => ({ page: currentPage() }),\n preservePreviousValue: () => false, // clear the value while loading\n loader: async ({ params }) =>\n (await fetch(`/api/posts?page=${params.page}`)).json(),\n});\n```\n\n::: tip Not consulted for parallel queries\nWith an `identifier`, each key keeps its own resource, so there is no \"previous\nvalue\" to preserve — the option is ignored on that path.\n:::\n\n## Reacting to a mutation\n\nRather than reloading by hand after a write, declare the link:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst userQuery = yield* query(\n 'userQuery',\n {\n params: () => ({ userId: currentUserId() }),\n loader: /* … */,\n },\n insertQueryPipe(\n insertReactOnMutation(updateUserMutation, {\n // apply the change immediately, before the server answers\n optimisticPatch: {\n name: ({ mutationParams }) => mutationParams.name,\n email: ({ mutationParams }) => mutationParams.email,\n },\n // and go get the truth back if the mutation failed\n reload: { onMutationException: true },\n }),\n insertStoragePersister(craftUnique({\n storeName: 'demo-app',\n key: 'user-query',\n })),\n ),\n);\n```\n\nFull options on [Reacting to mutations](/guide/state/react-on-mutation).\n\n## Exceptions\n\n`exceptions()` is split by **origin** and typed from the codes you declared —\n`params` for what your `method` rejected before any request, `loader` for what\nthe request produced:\n\n```typescript\nimport { craftException, query } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'forbidden'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * userQuery.call('ab');\nuserQuery.hasException(); // true\nuserQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * userQuery.call('forbidden');\nuserQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs — you\ndon't send a request you already know will fail.\n\n## Pitfalls\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the query is loading or in exception.\n\n**`params` must be cheap and pure.** It runs inside a reactive computation; side\neffects belong in the loader.\n\n::: details Advanced — parallel queries by identifier\n`identifier` keeps one resource per key, so several runs coexist instead of\nreplacing each other:\n\n```typescript\nconst userId = signal<number | undefined>(undefined);\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: userId,\n identifier: (id) => id,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n });\n\nuserId.set(1);\nuserId.set(2);\n\nuserQuery.select('1').value(); // user 1\nuserQuery.select('2').value(); // user 2\n```\n\n:::\n\n::: details Advanced — typed HTTP exceptions\nLoader exceptions are matched declaratively: each matcher yields predicates on\nthe response and returns a `craftException` when it recognises the failure.\n\n```typescript\nloader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeatureForDependencies',\n });\n },\n function* ({ body, header }) {\n const payload = yield* body<{\n errors?: Array<{ field: 'password' }>;\n }>();\n\n if (!payload.errors?.some((error) => error.field === 'password')) return;\n if (!(yield* header('x-error-kind', 'validation'))) return;\n\n return craftException({\n _tag: 'VALIDATION_HEADER_ERROR',\n scope: 'UsersFeatureForDependencies',\n });\n },\n ],\n }));\n}\n```\n\nWorking source:\n[exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts).\n:::\n\n::: details Advanced — yielding dependencies from `params`\n`params` can be a generator, and so can an insertion:\n\n```typescript\nconst { userQuery } =\n yield *\n query(\n 'userQuery',\n {\n providers: [provideUserService(), provideUserApiService()],\n params: function* () {\n return yield* UserService.userId();\n },\n loader: function* ({ params: userId }) {\n return yield* UserApiService.get(userId);\n },\n },\n function* () {\n const queryTools = yield* QueryTools();\n return { queryKey: `${queryTools.prefix()}:details` };\n },\n );\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryMethodRuntimeContext()`, and the query\nvalue itself is published to `providePrimitiveResourceRuntimeObserver`. Both\nexpose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and\nother advanced patterns can seed or replace a result without going through the\ninsertion callback. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Mutations](/guide/state/mutations) — the write side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
421
+ },
422
+ {
423
+ "path": "/guide/state/state-machines",
424
+ "title": "State machines",
425
+ "body": "# State machines\n\n`craftStateMachine` models a finite workflow as a named, typed, reactive\nprimitive. It is useful when a feature has a small set of meaningful modes —\nfor example, an editor that is either reading or editing, a form that moves\nthrough validation and submission, or a resource that moves through loading,\nsuccess and failure.\n\nThe important part is not only that the machine has states. It is that the\nstates and the transitions are **100% declarative**: the machine describes\nwhich events can enter each state, and Craft derives the current state from\nthose declarations at runtime.\n\n## A different perspective on transitions\n\nMany state-machine APIs describe a transition from the current state:\n\n```text\nwhile in reading, when edit happens, go to editing\n```\n\nThat perspective makes the target explicit in the transition itself. You look\nat the `reading` state's handlers to discover where an `edit` event goes.\n\nCraft reverses the perspective. Each entry in the transitions record describes\n**how to enter that step**. The record key is the target step, and `transit()`\ninside that step's block means “attempt to enter this step”. It does not take a\nstate name because the surrounding key already supplies it.\n\nIf you are used to XState or a similar state-machine API, the difference is\nthe direction in which you read the same workflow graph. You may usually start\nfrom `reading` and ask “where does `edit` go?”. In Craft, you start from\n`editing` and ask “which event makes the machine enter `editing`?”. The graph\nis still explicit; its declarations are owned by their destination step.\n\n```typescript\nfunction* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n}\n```\n\nReading this declaration tells you immediately:\n\n- `reading` is entered during initialisation, after `commit`, or after\n `cancel`;\n- `editing` is entered after `edit`.\n\nThere is no `currentStep = ...`, no imperative transition table, and no string\nsuch as `transit('editing')`. The destination is the step whose block declared\nthe event. If an event attempts to enter the step that is already active, the\nattempt is a no-op.\n\nThis makes the transition logic especially easy to inspect: to answer “when\ncan the machine enter `reading`?”, read the `reading` block and look at the\nevents it listens to. The machine's transition behavior is visible in the\ndeclarations themselves.\n\nThe same principle applies to the steps themselves. Each step registered in a\n`craftStateMachine` can be 100% declarative: its context can be assembled from\nCraft primitives, its event reactions can be expressed with `on$`, and its\nview can be selected from the typed step context. A step does not need an\nimperative “enter” function that manually changes the machine or coordinates\nthe rest of the feature.\n\n## The text editor example\n\nThe demo application contains a complete [declarative text editor\nexample](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts).\nIt has two steps:\n\n```text\nreading ← initialisation, commit, cancel\nediting ← edit\n```\n\nThe machine's context owns the events and the text state. The transitions only\ndeclare which events enter which step:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'textEditor',\n\n function* () {\n const edit$ = yield* source$<void>('text.edit');\n const commit$ = yield* source$<void>('text.commit');\n const cancel$ = yield* source$<void>('text.cancel');\n\n const text = yield* state(\n 'text',\n { committedValue: '', value: '' },\n insertStatePipe(insertDeepYieldable(), ({ patch }) => ({\n change: (value: string) => patch(() => ({ value })),\n commit: on$(commit$, () =>\n patch((current) => ({ committedValue: current.value })),\n ),\n cancel: on$(cancel$, () =>\n patch((current) => ({ value: current.committedValue })),\n ),\n })),\n );\n\n return { edit$, commit$, cancel$, text };\n },\n\n function* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n },\n\n function* ({ text, cancel$, commit$, edit$ }) {\n return {\n reading: { text, edit$ },\n editing: { text, commit$, cancel$ },\n };\n },\n );\n```\n\nThe first factory creates the shared context. The second factory declares the\nmachine's steps and their incoming events. The third factory gives each step a\ntyped context for its view: the reading view can edit, while the editing view\ncan commit or cancel.\n\n`insertDeepYieldable()` makes the object-valued `text` state deeply readable.\nThe template can therefore bind to `reading.text.value` and\n`reading.text.committedValue` without creating a separate `craftComputed` for\neach property.\n\n## Rendering the current step\n\nThe machine exposes `currentStep` as a union of step names and\n`currentStepWithContext` as a discriminated union. Use the latter when each\nstep needs different data or actions:\n\n```typescript\nmatchNode.exhaustive(machine.currentStepWithContext, 'step', {\n reading: (reading) =>\n div([\n p(['Committed value: ', reading.text.committedValue]),\n p(['Current value: ', reading.text.value]),\n button({ click: () => reading.edit$.emit() }, 'Edit'),\n ]),\n\n editing: (editing) =>\n div([\n input({\n value: editing.text.value,\n input: function* (event) {\n yield* editing.text.change(event.target.value);\n },\n }),\n button({ click: () => editing.commit$.emit() }, 'Commit'),\n button({ click: () => editing.cancel$.emit() }, 'Cancel'),\n ]),\n});\n```\n\n`matchNode.exhaustive` checks that every step is handled, and narrows the\nhandler argument to that step's context. Adding a new step therefore produces\ncompile-time feedback both in the machine's transition record and in the\nrendering code.\n\nIf the view only needs the name, use the shorter scalar form:\n\n```typescript\nmatchNode.exhaustive(machine.currentStep, {\n reading: () => p('Reading'),\n editing: () => p('Editing'),\n});\n```\n\n## Guards\n\nThe event declaration says when a transition is attempted. A\n`transitionGuard` says whether that attempt is accepted. Guards can be local to\none step, global to the machine, or attached to one particular attempt:\n\n```typescript\nediting: transitionStep(function* () {\n yield* on$(context.edit$, () =>\n transit().pipe(\n transitionGuard(({ context }) => context.form.isValid()),\n ),\n );\n}),\n```\n\nA guard can also be a generator and yield Craft services. Those dependencies\nbecome part of the machine's dependency graph. This keeps the condition\ndeclarative as well: the transition is still described by its event and its\naccepted predicate, rather than by an imperative event handler that manually\ncoordinates state.\n\n## Composition and extensions\n\nThe final machine insertion has the same role as an insertion on `state`,\n`query`, or `mutation`. Use it for derived values, view helpers, selectors, or\nreusable behavior. For several machine insertions, use\n`insertStateMachinePipe`:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'editor',\n contextFactory,\n transitions,\n stepContextFactory,\n insertStateMachinePipe(\n withStateMachineHistory({\n persist: { storeName: 'demo', key: 'editor' },\n }),\n withBackNavigation(),\n ({ currentStep }) => ({\n isReading: craftComputed('isReading', function* () {\n return (yield* currentStep()) === 'reading';\n }),\n }),\n ),\n );\n```\n\nHistory, back/forward navigation, and derived flags are therefore extensions\nof the machine rather than hidden responsibilities of its core. The machine\nremains focused on declaring steps and the events that enter them.\n\n## When to use a state machine\n\nUse `craftStateMachine` when:\n\n- the feature has a finite set of named workflow steps;\n- different steps expose different actions or view data;\n- events, recomputations, or initialisation determine when a step is entered;\n- exhaustive handling of steps is valuable;\n- guards or reusable workflow extensions belong on the state-machine boundary.\n\nFor a single independent value, use [`state`](/guide/state/local-state). For a\nserver read or write, use [`query`](/guide/state/server-state) or\n[`mutation`](/guide/state/mutations). A state machine can compose those\nprimitives in its context when the workflow needs them.\n\n## API summary\n\n| API | Role |\n| ------------------------ | ------------------------------------------------------ |\n| `craftStateMachine` | Creates the named state-machine primitive |\n| `transitionStep` | Declares how one step is entered |\n| `transit()` | Creates an attempt to enter the surrounding step |\n| `initStateMachine` | Declares the attempt that establishes the initial step |\n| `transitionGuard` | Accepts or rejects a transition attempt |\n| `currentStep` | Reactive union of step names |\n| `currentStepWithContext` | Reactive discriminated union of step contexts |\n| `insertStateMachinePipe` | Composes machine insertions |\n\n## See also\n\n- [Local state](/guide/state/local-state)\n- [Typed insertion pipes](/guide/concepts/insertion-pipes)\n- [Fine-grained reactivity](/guide/components/fine-grained-reactivity)\n- [The text editor example on GitHub](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts)\n"
366
426
  },
367
427
  {
368
428
  "path": "/guide/state/url-state",
369
429
  "title": "queryParams",
370
430
  "body": "# queryParams\n\n`queryParams` is a state whose home is the URL's query string. Reading and\nwriting look like any other state; the address bar follows, and so does the back\nbutton.\n\n**Use it when** the value should survive a refresh and be shareable by copying\nthe link: filters, pagination, a selected tab.\n**Not when** the value is ephemeral or private — that's\n[`state`](/guide/state/local-state).\n\n::: tip No synchronisation code\nThere is no effect to write and no `ActivatedRoute` subscription. If you find\nyourself syncing a `state` with the URL, you want this primitive instead.\n:::\n\n## The common case\n\n```typescript\nimport { queryParams } from '@craft-ts/core';\n\nconst numberCodec = {\n decode: (value: string) => parseInt(value, 10),\n encode: (value: number) => String(value),\n};\nconst booleanCodec = {\n decode: (value: string) => value === 'true',\n encode: (value: boolean) => String(value),\n};\n\nconst pagination = yield* queryParams(\n 'pagination',\n {\n state: {\n page: { fallbackValue: 1, codec: numberCodec },\n showArchived: { fallbackValue: false, codec: booleanCodec },\n },\n },\n ({ set, update, patch, reset }) => ({ set, update, patch, reset }),\n);\n\npagination(); // { page: 1, showArchived: false }\npagination.page(); // 1\n\npagination.patch({ showArchived: true }); // navigates to ?showArchived=true\npagination.set({ page: 4, showArchived: false });\npagination.update((current) => ({ ...current, page: current.page + 1 }));\npagination.reset();\n```\n\n`?page=3&showArchived=true` becomes `{ page: 3, showArchived: true }` on load.\n\n## Codecs are mandatory\n\nA URL only holds strings, so every parameter declares how it converts both ways.\nThe decoded type is your application type; the encoded one is what appears in the\naddress bar.\n\n`fallbackValue` is what you get when the parameter is absent — which is why the\nstate type is never `undefined`.\n\nCodecs stay synchronous because they run inside the reactive URL computation.\n`@craft-ts/core` deliberately doesn't depend on a validation library: supply a\nsmall `{ decode, encode }` pair directly, or adapt one from the library you\nalready use.\n\n```typescript\n// arrays\ntags: {\n fallbackValue: [],\n codec: {\n decode: (value) => value.split(',').filter(Boolean),\n encode: (value) => value.join(','),\n },\n},\n\n// plain strings\nq: { fallbackValue: '', codec: { decode: String, encode: String } },\n```\n\nThe same pattern covers dates, enums and JSON-encoded objects.\n\n## Custom methods\n\n```typescript\nyield* queryParams(\n 'pagination',\n {\n state: { page: { fallbackValue: 1, codec: numberCodec } },\n },\n ({ state, patch }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n previousPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page - 1 });\n },\n setPageSize: function* (pageSize: number) {\n return yield* patch({ pageSize, page: 1 });\n },\n }),\n);\n```\n\n## Feeding a query\n\nThe point of URL state is usually to drive a fetch. Read it from the query's\n`params`:\n\n```typescript\nyield* query('tasksQuery', {\n params: () => ({ page: pagination.page() }),\n loader: /* … */,\n});\n```\n\nOne direction of data flow: click → URL → loader → view.\n\n## Decode failures\n\nA `decode` that throws keeps the fallback value rather than corrupting your\nstate, and surfaces the failure:\n\n```typescript\nif (mode.hasException()) {\n mode.exceptions().list;\n mode.exceptions().parse.mode?.code; // 'QueryParamDecodeError'\n mode.exceptions().parse.mode?.payload;\n}\n```\n\nAn encode failure raises `QueryParamEncodeError` before router navigation starts.\n\n## Pitfalls\n\n**Every parameter needs a `codec`** — there is no implicit string passthrough.\n\n**Methods bound to a source with `on$` are not exposed** on the result, same as\nevery primitive.\n\n::: details Advanced — declaring query params on the route\nQuery parameters can live in the route rather than in a component, so they belong\nto the URL definition itself:\n\n```typescript\nexport const { demoRoutes, injectDemoQueryParamsQueryParams } = craftRoutes(\n 'demo',\n [\n {\n path: 'query-params',\n ...loadCraftComponent(({ withRetry }) =>\n withRetry(import('./qp-list-with-pagination')).then(\n ({ default: component }) => component,\n ),\n ),\n queryParams: function* () {\n const pagination = yield* queryParams(\n 'pagination',\n {\n state: {\n page: { fallbackValue: 1, codec: numberCodec },\n pageSize: { fallbackValue: 4, codec: numberCodec },\n },\n },\n ({ patch, state }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n previousPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page - 1 });\n },\n updatePageSize: function* (pageSize: number) {\n return yield* patch({ pageSize, page: 1 });\n },\n }),\n );\n return pagination;\n },\n },\n ],\n);\n```\n\n\n\nWorking source:\n[exception-query-params.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exception-query-params.ts).\n:::\n\n::: details Advanced — yielding dependencies\nThe insertion can be a generator, so a rule can come from a service:\n\n```typescript\nyield* queryParams(\n 'pagination',\n { state: { page: { fallbackValue: 1, codec: numberCodec } } },\n function* ({ patch, state }) {\n const maxPage = yield* PaginationRules.maxPage();\n return {\n nextPage: function* () {\n const current = yield* state();\n if (current.page >= maxPage()) return;\n return yield* patch(({ page }) => ({ page: page + 1 }));\n },\n };\n },\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryParamsMethodRuntimeContext()`, and the\nURL state itself is published to `providePrimitiveResourceRuntimeObserver`.\nBoth expose `get`, `set`, `update`, and `patch` for wrappers, WebMCP tools,\nand other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Local state](/guide/state/local-state) — for non-URL state\n- [query](/guide/state/server-state) — consuming URL state from a loader\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
371
431
  },
432
+ {
433
+ "path": "/guide/style",
434
+ "title": "Typed styles",
435
+ "body": "# Typed styles\n\n::: tip Two style systems, and which to pick\nThis section is `@craft-ts/style`: typed values, CSS emitted at build time, and\na visual matrix you can enumerate. It costs a Vite plugin — see\n[Activating the style system](./setup.md) — and a design system to declare.\n\n[`meta.styles`](../components/styles.md) is the other one: a string of CSS\nshipped with the component and scoped with `@scope`, with no build step. It is\nthe shortest path to a component's own appearance.\n\nPick `meta.styles` for a component whose look is settled and local. Pick this\none when the variants are a matrix you need to prove you covered. They coexist.\n:::\n\n`@craft-ts/style` makes a component's visual surface **derivable** instead of\nguessed. For any component you can ask what the exhaustive set of visual states\nis, which of them are impossible, and whether the context it needs exists — and\nthe answers come from the same values the CSS is emitted from, not from a second\ndescription that can drift.\n\nIt buys that in three levels, and they do not adopt the same way.\n\n| level | what it gives you | granularity of adoption |\n| ------------------------------ | ---------------------------------------------------------------------- | --------------------------- |\n| 1 — tokens and typed variables | no value is a string; no class is built at runtime | **one component at a time** |\n| 2 — axes and the matrix | the exhaustive list of visual states, with the drivers that reach them | **per component** |\n| 3 — context obligations | a missing scroll port, container or clipping ancestor fails the build | **per whole route** |\n\nRead that last column carefully, because it is the part that is easy to get\nwrong. Level 3 is not a per-component guarantee: one unmigrated link in a route\nand the requirement travels past it unanswered, so the compiler has nothing to\ncheck. A partial level-3 adoption gives **zero** of the guarantee, not most of\nit — and the graph reports it rather than hiding it.\n\n## The rule the whole thing turns on\n\n**Static goes to a class at build time; dynamic goes through a typed custom\nproperty.** No class is ever assembled in the browser.\n\n```ts\n// tone is an axis: five rules the emitter already wrote.\nwhen(tone.danger, [set(v.bg, palette.accent.danger)]);\n```\n\n```ts\n// a width that depends on a signal cannot be a class — there is no finite set\n// of widths to emit — so it goes through a registered <percentage>.\nstyle: function* () {\n return assign(meterVars.value, unit.pct(yield* value()));\n}\n```\n\nThat split is what keeps the set of visual states finite, and therefore\nenumerable. A class built from a signal is a state nothing recorded.\n\n## Where to go next\n\nStart with [Activating `@craft-ts/style`](./setup.md): the system is a build\nstep, and none of the pages below produce a single byte of CSS until the Vite\nplugin is wired. Then [Define your design system](./define.md), which is where\n`bp`, `palette` and the theme variables the other pages spend come from.\n\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n- [Context obligations](./obligations.md) — level 3.\n- [Testing what you built](./testing.md) — drivers, baselines, exhaustiveness.\n\nA working example lives in the demo, at\n`apps/demo/src/app/examples/design-system/`, with a README that walks through\nthe same three levels in code.\n"
436
+ },
437
+ {
438
+ "path": "/guide/style/define",
439
+ "title": "Defining your design system",
440
+ "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"
441
+ },
442
+ {
443
+ "path": "/guide/style/obligations",
444
+ "title": "Context obligations",
445
+ "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"
446
+ },
447
+ {
448
+ "path": "/guide/style/setup",
449
+ "title": "Activating `@craft-ts/style`",
450
+ "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"
451
+ },
452
+ {
453
+ "path": "/guide/style/testing",
454
+ "title": "Testing what you built",
455
+ "body": "# Testing what you built\n\nThe matrix says what the states are. This page is how you look at them.\n\n## Drivers\n\nEvery axis point carries the driver that reaches it. An axis without one would\nbe worse than a missing axis: the matrix would enumerate scenarios nothing can\nproduce and render identical captures — false coverage rather than none.\n\n```ts\nimport { applyScenario, visualMatrix } from '@craft-ts/style-testing';\n\nfor (const scenario of visualMatrix(card)) {\n await applyScenario(page, scenario);\n await expect(page).toHaveScreenshot(`${scenario.id}.png`);\n}\n```\n\n`page` is described structurally, so Playwright is not a dependency — a\nPlaywright `Page` matches the shape and is passed unchanged.\n\nApplication order is fixed in one place (`orderedDrivers`): emulation and\nviewport first because they relayout, container width next, DOM state after,\nscrolling last. Applying them in declaration order instead would make a capture\ndepend on which axis someone wrote first.\n\n## Exhaustiveness\n\n```ts\nimport {\n assertExhaustiveVisualMatrix,\n baselinesIn,\n visualMatrix,\n} from '@craft-ts/style-testing';\n\nassertExhaustiveVisualMatrix(visualMatrix(card), baselinesIn(files));\n```\n\nIt fails in **both** directions. A baseline nothing produces any more matters as\nmuch as a missing one: it is a state the component used to have, and whoever\nopens the folder still counts it as covered.\n\nThe check is post-inference on purpose. A self-referential constraint on the\ncomponent's own declaration resolves the union to `never` and passes while\nchecking nothing — the same shape as `assertExhaustiveRouteExceptions`.\n\n## Content cases\n\nThe matrix covers _conditions_, not _data_ — and the eighty-character title, the\nempty list and the seven-figure price are what break layouts most often. No type\ncan derive them, so they are declared:\n\n```ts\nimport { contentCases, visualMatrix } from '@craft-ts/style-testing';\n\ncontentCases(visualMatrix(card), { longTitle: 'x'.repeat(80), empty: '' });\n```\n\nA data case is rendered at one point of each axis, except on the axes that change\nthe space available — viewport and container — where the crossing is complete. A\nlong title behaves differently at two widths; it does not behave differently in\ntwo colour schemes.\n\n## What the graph adds\n\nThe style dump joins the dependency graph, so the questions that cross layers\nhave answers. `graph` is the dependency graph the dev tools build; the dump half\nof it is what [`craftStyle({ dumpPath })`](./setup.md#what-the-plugin-produces)\nwrites, so these queries return nothing useful until that option is set.\n\n```ts\nimport {\n danglingVars,\n impactedClasses,\n matrixSizeByComponent,\n unproven,\n varsWrittenBy,\n} from '@craft-ts/dev-tools';\n\nmatrixSizeByComponent(graph); // what a component costs to capture\nimpactedClasses(graph, ['--ds-accent']); // what one token change can be seen in\nvarsWrittenBy(graph); // proves a colour axis only repaints\ndanglingVars(graph); // declared and never read\nunproven(graph); // every escape hatch, with its reason\n```\n\n`impactedClasses` is the one that pays for the visual CI: changing a colour\nshould recapture what reaches it, not the whole suite.\n\n### The same questions, from an agent\n\nThree of these are exposed as MCP tools by `@craft-ts/mcp`, so an agent can ask\nthem without writing a script:\n\n| MCP tool | answers |\n| -------------- | ----------------------------------------------------------------------- |\n| `style_impact` | which classes and components one token or variable change is visible in |\n| `style_matrix` | how many scenarios each component costs to capture |\n| `style_debt` | every escape hatch — `unsafeLength`, `unsafeAssume` — with its reason |\n\nThey read the same dump. A `style_matrix` that answers with nothing is the\nsignature of a missing `dumpPath`, and the server says so.\n"
456
+ },
457
+ {
458
+ "path": "/guide/style/tokens",
459
+ "title": "Tokens and typed variables",
460
+ "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"
461
+ },
462
+ {
463
+ "path": "/guide/style/variants",
464
+ "title": "Axes and the visual matrix",
465
+ "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## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size] })\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"
466
+ },
372
467
  {
373
468
  "path": "/guide/testing/architecture",
374
469
  "title": "Architecture rules",
@@ -467,7 +562,7 @@
467
562
  {
468
563
  "path": "/guide/testing/components",
469
564
  "title": "Testing components",
470
- "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 `ifBlock`, `each`, or `defer`, 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 `ifBlock`, `each`,\nor `defer`, 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"
565
+ "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"
471
566
  },
472
567
  {
473
568
  "path": "/guide/testing/craft-graph-vs-nx",
@@ -487,7 +582,7 @@
487
582
  {
488
583
  "path": "/guide/testing/type-level",
489
584
  "title": "Type-level tests",
490
- "body": "# Type-level tests\n\nSome of what a template guarantees is not observable at runtime — it is in the\ntypes. These assertions resolve **entirely at compile time**: no `TestBed`, no\nDOM, no fixture, no component instantiation.\n\n**Use them when** a regression would be silent: an element quietly stops\nrendering under a condition, a binding is repointed at another context member, a\nhandler becomes imperative, a prop changes shape.\n**Not instead of** runtime tests — they prove the template's _contract_, not\nwhat the user ends up seeing. Pair them with\n[template tests](/guide/testing/components#component-template).\n\n## The three questions they answer\n\nMost of what you'll write falls into one of these. Each is expanded below.\n\n| You want to prove… | Use |\n| ------------------------------------------------------------ | -------------------------------------------------- |\n| an element renders **only under a condition** | `TemplateRendersNamedElementWhen` with `{ when }` |\n| a binding is rendered **for every item of a non-empty list** | the same, with `{ when: { items: 'nonEmpty' } }` |\n| a **property is used** on a named element | `TemplateNamedElementRendersStateWhen` |\n| an element property delegates to a context method | `TemplateNamedElementDelegatesToContext` |\n| a component logic field has a specific service output | `ComponentLogicOutputOf` + `ResolvedServiceOutput` |\n\n::: warning Experimental\nThis contract is the least settled part of `@craft-ts`. The assertions below\nwork and are covered by the library's own tests, but their **names and\nergonomics are still moving** — expect the DX to get shorter and more readable\nbefore it stabilises. Pin the version if you rely on them heavily.\n:::\n\n## Setting it up\n\nThe assertions build on two type helpers, published for applications on a\ndedicated subpath:\n\n```ts\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n```\n\nThey are **types only** — nothing is emitted, so importing them costs nothing at\nruntime.\n\nAn assertion is the pair: a helper that computes a boolean type, wrapped in\n`Expect<Equal<…, true>>`. If the computed type stops being `true`, the file stops\ncompiling.\n\n`ComponentTemplateOf` gets you the template type to assert on:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n```\n\n`ComponentLogicOutputOf` gets the value returned by the component logic\nfactory. This lets you assert the type of a field returned by the factory,\ninstead of checking only that the component declares a dependency:\n\n```ts\nimport type { ComponentLogicOutputOf } from '@craft-ts/component';\nimport type { ResolvedServiceOutput } from '@craft-ts/core';\n\ntype FullDemoLogic = ComponentLogicOutputOf<typeof FullDemoCraft>;\ntype TodoStoreOutput = ResolvedServiceOutput<typeof TodoStore, {}>;\n\ntype StoreIsTodoStore = Expect<Equal<FullDemoLogic['store'], TodoStoreOutput>>;\n```\n\n`ResolvedServiceOutput` is used here because it preserves the reactive brands\npresent on the value produced by `yield* TodoStore()`.\n\n### Running them with Vitest\n\nType assertions fail at **compile** time, so they need something to typecheck the\nfile. `tsc --noEmit` is enough, but Vitest can run them alongside your runtime\ntests:\n\n```shell\nvitest typecheck\n```\n\nPut the assertions in a `*.test-d.ts` file and Vitest reports a failing type as a\nfailing test, in the same run and the same output as everything else. Vitest's\nown `expectTypeOf` / `assertType` work there too, and compose with the helpers\nbelow.\n\n::: tip Give them a home\nA type assertion nobody typechecks proves nothing. Either keep them in files\ncovered by `vitest typecheck`, or make sure `tsc --noEmit` runs over them in CI.\n:::\n\n## The template contract\n\n`SetupTestComponentTemplate` resolves the template without `TestBed`, a DOM,\nthe factory, or runtime providers. The component tuple contains the references\nallowed for children:\n\n```ts\ntype CounterTemplateTest = SetupTestComponentTemplate<\n typeof Counter,\n [typeof CounterButton, typeof PlusIcon]\n>;\n```\n\nThe resolver traverses elements, directives, `each`, `defer`, and child\ncomponents. A component reference missing from the tuple becomes a type\ndiagnostic. Visited components are tracked so recursive templates do not create\na resolution loop.\n\nThe contract also checks the required public props of `ComponentNode` and keeps\nthe concrete child component reference. Dynamic component unions produce a\ndedicated diagnostic; split them into static branches so they can be checked at\nthe type level. Components owned by another package form an explicit boundary\nand should be tested with that package's harness.\n\nThe available assertions can verify elements, their exact props, event\narguments, generator callbacks, and outputs. For example, start with a\ncomponent whose `disabled` property is nested inside the `counter` state:\n\n\n\n\nThe type assertions inspect the template returned by `Counter`; they do not\ninstantiate the component or render a DOM fixture:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype HasButton = Expect<\n Equal<TemplateHasElement<CounterTemplate, 'button'>, true>\n>;\n\n// TemplateHasElementWithProps checks the exact prop set of the matching node.\ntype HasCounterClass = Expect<\n Equal<\n TemplateHasElementWithProps<\n CounterTemplate,\n 'div',\n { readonly class: string }\n >,\n true\n >\n>;\n\ntype HasClick = Expect<\n Equal<\n TemplateHasYieldableEvent<CounterTemplate, 'button', 'click', [MouseEvent]>,\n true\n >\n>;\n\ntype HasNestedDisabledBinding = Expect<\n Equal<TemplateRendersStateWhen<CounterTemplate, 'counter.disabled'>, true>\n>;\n```\n\nThese checks detect different regressions at compile time. Removing the\n`button`, changing the `click` callback to an imperative function, changing\nits event arguments, or replacing `counter.disabled` with another context\nmember makes the corresponding assertion fail. The `TemplateHasElementWithProps`\ncheck also catches an unexpected extra, missing, or differently typed prop.\n\nPrimitive properties follow the same contract as events. For derived state, use\n`craftComputed` in the `state` insertion:\n\n\n\n\nHere, `counter` is created by the component factory and returned in its\ncontext. The template receives that context, and the branded\n`context.counter.disabled()` read is the binding that the type assertion\nchecks:\n\n```ts\ntype HasDerivedDisabledBinding = TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.disabled'\n>;\n\ntype _HasDerivedDisabledBinding = Expect<\n Equal<HasDerivedDisabledBinding, true>\n>;\n```\n\nIf the template were accidentally changed to use another member, the\nassertion would fail:\n\n```ts\ntype UsesWrongBinding = Expect<\n Equal<\n TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.enabled'\n >,\n false\n >\n>;\n```\n\nThe callback is executed by the Craft driver before the DOM property is\nwritten. The assertion verifies the exact binding source without a fixture or\nDOM.\n\n`computed` remains a synchronous signal when called directly\n(`counter.disabled()`) and in a template context.\n\nTemplates supplied to `each` and `defer` are also resolved. When a `defer`\ndirectly loads a Craft component, that component must appear in the registry.\nUnder this contract, DOM and output callbacks must be generators or branded\nCraft methods; ordinary imperative callbacks produce a diagnostic.\n\nBranded Craft methods are projected into the template context as yieldable\ncallbacks:\n\n```ts\nbutton(\n {\n *click() {\n yield* context.counter.increment(2);\n },\n },\n '+',\n);\n```\n\nThe renderer executes these callbacks with the Craft driver. Render callbacks\n(text, classes, styles, `each`, `defer`) remain synchronous.\n\n## Conditional visibility and named elements\n\nReactive values exposed by Craft primitives and services keep their property\nname in the template type. They remain synchronously readable in templates,\nwhile their name brand is available to `ifBlock` and the visibility contract.\nUse `ifBlock` to retain the condition and its branches in the VNode contract:\n\n\n\n\n\nThe local name is rendered as `data-craft-name`; `data-craft-root` remains an\ninternal tracking attribute.\n\n### Proving an element renders only under a condition\n\nA named element is asserted with its **full component identity** —\n`'<Component>:<tag>:<localName>'` — and the visibility path it sits behind:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype CanIncrement = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n CounterTemplate,\n 'Counter:button:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\nWhen the element is truly unconditional, omit `when` (or use an empty object).\nAn element inside an `ifBlock` or `each` requires its visibility condition;\nomitting `when` deliberately returns `false` for such an element. Keep the\ncomplete `ComponentTemplateOf` type if you want editor completion for the\ncomponent prefix of the identity. Using `ReturnType<...>`\npreserves the element and tag, but loses the component name used for the most\nuseful completion suggestions:\n\n```ts\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype DisplayNewTodoNameInput = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n FullDemoTemplate,\n 'FullDemoCraft:input:TodoNameToAddInput'\n >,\n true\n >\n>;\n```\n\nWhen editing the second argument, the available identities are proposed from\nthe template, for example `FullDemoCraft:input:TodoNameToAddInput` and\n`FullDemoCraft:button:AddTodoButton`. `{ when: {} }` is equivalent to omitting\nthe third argument: both assert that the element is unconditional. For an\nelement inside an `ifBlock` named `isAuth`, use `{ when: { isAuth: true } }`.\n\nThe same visibility contract can identify an element through branded direct\ncontent. Here, `brandedStatus` is not selected by its text; its brand proves that the\n`span` renders that value in the authenticated branch:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype StatusIsRenderedWhenAuthenticated = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'brandedStatus',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Counter, {\n context: {\n isAuth: markYieldableValue(signal(true), 'isAuth'),\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nbrandedStatusElement?.textContent;\ntest.destroy();\n```\n\nBecause the element is conditional, `brandedStatusElement` is typed as\n`HTMLSpanElement | undefined`. After `updateContext` and `detectChanges`, the\nsame locator returns `undefined` while the branch is absent.\n\n### Proving a binding renders for every item of a non-empty list\n\n`each` contributes `<listName>: 'nonEmpty'` to the visibility path, so you can\nassert what every item renders — here a translated label exposed by an\n`insertSelect` insertion:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { insertSelect, state } from '@craft-ts/core';\nimport { craftComponent, each, span } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRendersNamedElementWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst ItemList = craftComponent(\n 'ItemList',\n {},\n function* () {\n const items = yield* state(\n 'items',\n [{ key: 'first' }, { key: 'second' }],\n insertSelect('item', ({ state: selectedItem }) => ({\n translatedLabel: craftComputed(function* () {\n return `translated:${(yield* selectedItem()).key}`;\n }),\n })),\n );\n return { items };\n },\n ({ items }) =>\n each(items, { track: (item) => item.key }, (_item, index) =>\n span(\n 'itemLabel',\n { 'aria-label': items.selectItem(index)?.translatedLabel },\n () => items.selectItem(index)?.translatedLabel() ?? '',\n ),\n ),\n);\n\ntype ItemListTemplate = ReturnType<ComponentTemplateOf<typeof ItemList>>;\n\ntype HasTranslatedLabel = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n ItemListTemplate,\n 'ItemList:span:itemLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n\ntype RendersTranslatedLabel = Expect<\n Equal<\n TemplateRendersStateWhen<\n ItemListTemplate,\n 'items.selectItem.translatedLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n```\n\n\n\n### Proving a property is used on a named element\n\nThe same visibility paths verify that a state really feeds a rendered binding,\nand that a yieldable action is available on a named element — `'click:increment'`\nreads as \"the `click` action on the element named `increment`\":\n\n```typescript\nimport { craftMethod, state } from '@craft-ts/core';\nimport { button, craftComponent, ifBlock } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRenderAvailableActionWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst Counter = craftComponent(\n 'Counter',\n {},\n function* () {\n const isAuth = yield* state('isAuth', true);\n const isAdult = yield* state('isAdult', true);\n const increment = craftMethod('increment', function* () {\n return undefined;\n });\n\n return { isAuth, isAdult, increment };\n },\n ({ isAuth, isAdult, increment }) =>\n ifBlock(\n isAuth,\n () => button('increment', { click: increment }, () => isAdult()),\n () => [],\n ),\n);\n\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype RendersAdultState = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'isAdult',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\n// The key is `${event}:${localName}`.\ntype CanIncrementWhenAuthenticated = Expect<\n Equal<\n TemplateRenderAvailableActionWhen<\n CounterTemplate,\n 'click:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\n\n\n`TemplateRendersStateWhen` recognizes branded reads that contribute to visible\ntext or other render bindings such as `class` and `style`. Both assertions\nreturn `false` when the state or action exists only under a visibility branch\nthat is incompatible with `when`.\n\n`each` adds `<listName>: 'nonEmpty'` for its item template and\n`<listName>: 'empty'` for its empty template. Interactive helpers must use the\nnamed form (`button('increment', {}, '+')`): ESLint\n`craft-ts/require-interactive-local-name` requires the literal first argument,\nand `assertInteractiveElementNamed` requires that `data-craft-name` to be unique\nin the app. `craft-ts/template-element-name-unique` still forbids two\n`tag:localName` pairs in the same component template, including across\nconditional branches.\n\n### Proving a named property uses a specific state\n\n`TemplateNamedElementRendersStateWhen` combines the named-element identity,\nthe element property, and the context path. All three arguments are constrained\nby the template type, so editors can complete the element identity, the\navailable property names, and the available context paths:\n\n```ts\nimport type {\n ComponentTemplateOf,\n TemplateNamedElementRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype RemoveButtonUsesRemoveLoading = Expect<\n Equal<\n TemplateNamedElementRendersStateWhen<\n FullDemoTemplate,\n 'FullDemoCraft:button:RemoveTodoButton',\n 'disabled',\n 'store.remove.isLoading'\n >,\n true\n >\n>;\n```\n\nFor a reactive property binding, keep the read inside a render callback so\nthe context marker remains visible to the template contract:\n\n```ts\nbutton('RemoveTodoButton', {\n disabled: store.remove.isLoading,\n});\n```\n\nThis assertion proves that the `disabled` binding on the named remove button\nis driven by `store.remove.isLoading`; it does not instantiate the component or\nobserve the DOM.\n\n### Proving a named event delegates to a context method\n\n`TemplateNamedElementDelegatesToContext` checks the same relationship for a\ngenerator event callback:\n\n```ts\nimport type { TemplateNamedElementDelegatesToContext } from '@craft-ts/component';\n\ntype AddButtonClickUsesAddMutation = Expect<\n Equal<\n TemplateNamedElementDelegatesToContext<\n FullDemoTemplate,\n 'FullDemoCraft:button:AddTodoButton',\n 'click',\n 'store.add.mutate'\n >,\n true\n >\n>;\n```\n\nThe source callback must delegate with `yield*`:\n\n```ts\nbutton('AddTodoButton', {\n *click() {\n yield* store.add.mutate(title().trim());\n },\n});\n```\n\nThe named identity prevents a different button's `click` handler from\nsatisfying the assertion.\n\n## Pitfalls\n\n**Asserting `true` where the answer is `false`.** These helpers return a\nboolean type, so `Expect<Equal<…, true>>` is the assertion. Writing the helper\nalone proves nothing — it just computes a type nobody checks.\n\n**Naming the element is what makes it addressable.** A `button('increment', …)`\ncarries the local name that `'Counter:button:increment'` resolves. Without it\nthere is no identity to assert on.\n\n**Imperative callbacks are rejected.** Under this contract, DOM and output\ncallbacks must be generators or branded Craft methods; an ordinary function\nproduces a diagnostic.\n\n**A `defer` that loads a Craft component** requires that component to be present\nin the registry tuple.\n\n**The ergonomics are known to be rough.** `Expect<Equal<Helper<ReturnType<\nComponentTemplateOf<typeof X>>, …>, true>>` is a lot of ceremony for one\nassertion. Shorter façades are being explored; until then, alias what repeats:\n\n```ts\ntype Tpl = ReturnType<ComponentTemplateOf<typeof Counter>>;\ntype Assert<T extends true> = Expect<T>;\n```\n\n## See Also\n\n- [Testing components](/guide/testing/components) — the runtime half\n- [Testing services](/guide/testing/services)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Learn: test what you wrote](/learn/10-testing)\n"
585
+ "body": "# Type-level tests\n\nSome of what a template guarantees is not observable at runtime — it is in the\ntypes. These assertions resolve **entirely at compile time**: no `TestBed`, no\nDOM, no fixture, no component instantiation.\n\n**Use them when** a regression would be silent: an element quietly stops\nrendering under a condition, a binding is repointed at another context member, a\nhandler becomes imperative, a prop changes shape.\n**Not instead of** runtime tests — they prove the template's _contract_, not\nwhat the user ends up seeing. Pair them with\n[template tests](/guide/testing/components#component-template).\n\n## The three questions they answer\n\nMost of what you'll write falls into one of these. Each is expanded below.\n\n| You want to prove… | Use |\n| ------------------------------------------------------------ | -------------------------------------------------- |\n| an element renders **only under a condition** | `TemplateRendersNamedElementWhen` with `{ when }` |\n| a binding is rendered **for every item of a non-empty list** | the same, with `{ when: { items: 'nonEmpty' } }` |\n| a **property is used** on a named element | `TemplateNamedElementRendersStateWhen` |\n| an element property delegates to a context method | `TemplateNamedElementDelegatesToContext` |\n| a component logic field has a specific service output | `ComponentLogicOutputOf` + `ResolvedServiceOutput` |\n\n::: warning Experimental\nThis contract is the least settled part of `@craft-ts`. The assertions below\nwork and are covered by the library's own tests, but their **names and\nergonomics are still moving** — expect the DX to get shorter and more readable\nbefore it stabilises. Pin the version if you rely on them heavily.\n:::\n\n## Setting it up\n\nThe assertions build on two type helpers, published for applications on a\ndedicated subpath:\n\n```ts\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n```\n\nThey are **types only** — nothing is emitted, so importing them costs nothing at\nruntime.\n\nAn assertion is the pair: a helper that computes a boolean type, wrapped in\n`Expect<Equal<…, true>>`. If the computed type stops being `true`, the file stops\ncompiling.\n\n`ComponentTemplateOf` gets you the template type to assert on:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n```\n\n`ComponentLogicOutputOf` gets the value returned by the component logic\nfactory. This lets you assert the type of a field returned by the factory,\ninstead of checking only that the component declares a dependency:\n\n```ts\nimport type { ComponentLogicOutputOf } from '@craft-ts/component';\nimport type { ResolvedServiceOutput } from '@craft-ts/core';\n\ntype FullDemoLogic = ComponentLogicOutputOf<typeof FullDemoCraft>;\ntype TodoStoreOutput = ResolvedServiceOutput<typeof TodoStore, {}>;\n\ntype StoreIsTodoStore = Expect<Equal<FullDemoLogic['store'], TodoStoreOutput>>;\n```\n\n`ResolvedServiceOutput` is used here because it preserves the reactive brands\npresent on the value produced by `yield* TodoStore()`.\n\n### Running them with Vitest\n\nType assertions fail at **compile** time, so they need something to typecheck the\nfile. `tsc --noEmit` is enough, but Vitest can run them alongside your runtime\ntests:\n\n```shell\nvitest typecheck\n```\n\nPut the assertions in a `*.test-d.ts` file and Vitest reports a failing type as a\nfailing test, in the same run and the same output as everything else. Vitest's\nown `expectTypeOf` / `assertType` work there too, and compose with the helpers\nbelow.\n\n::: tip Give them a home\nA type assertion nobody typechecks proves nothing. Either keep them in files\ncovered by `vitest typecheck`, or make sure `tsc --noEmit` runs over them in CI.\n:::\n\n## The template contract\n\n`SetupTestComponentTemplate` resolves the template without `TestBed`, a DOM,\nthe factory, or runtime providers. The component tuple contains the references\nallowed for children:\n\n```ts\ntype CounterTemplateTest = SetupTestComponentTemplate<\n typeof Counter,\n [typeof CounterButton, typeof PlusIcon]\n>;\n```\n\nThe resolver traverses elements, directives, `forNode`, `deferNode`, and child\ncomponents. A component reference missing from the tuple becomes a type\ndiagnostic. Visited components are tracked so recursive templates do not create\na resolution loop.\n\nThe contract also checks the required public props of `ComponentNode` and keeps\nthe concrete child component reference. Dynamic component unions produce a\ndedicated diagnostic; split them into static branches so they can be checked at\nthe type level. Components owned by another package form an explicit boundary\nand should be tested with that package's harness.\n\nThe available assertions can verify elements, their exact props, event\narguments, generator callbacks, and outputs. For example, start with a\ncomponent whose `disabled` property is nested inside the `counter` state:\n\n\n\n\nThe type assertions inspect the template returned by `Counter`; they do not\ninstantiate the component or render a DOM fixture:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype HasButton = Expect<\n Equal<TemplateHasElement<CounterTemplate, 'button'>, true>\n>;\n\n// TemplateHasElementWithProps checks the exact prop set of the matching node.\ntype HasCounterClass = Expect<\n Equal<\n TemplateHasElementWithProps<\n CounterTemplate,\n 'div',\n { readonly class: string }\n >,\n true\n >\n>;\n\ntype HasClick = Expect<\n Equal<\n TemplateHasYieldableEvent<CounterTemplate, 'button', 'click', [MouseEvent]>,\n true\n >\n>;\n\ntype HasNestedDisabledBinding = Expect<\n Equal<TemplateRendersStateWhen<CounterTemplate, 'counter.disabled'>, true>\n>;\n```\n\nThese checks detect different regressions at compile time. Removing the\n`button`, changing the `click` callback to an imperative function, changing\nits event arguments, or replacing `counter.disabled` with another context\nmember makes the corresponding assertion fail. The `TemplateHasElementWithProps`\ncheck also catches an unexpected extra, missing, or differently typed prop.\n\nPrimitive properties follow the same contract as events. For derived state, use\n`craftComputed` in the `state` insertion:\n\n\n\n\nHere, `counter` is created by the component factory and returned in its\ncontext. The template receives that context, and the branded\n`context.counter.disabled()` read is the binding that the type assertion\nchecks:\n\n```ts\ntype HasDerivedDisabledBinding = TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.disabled'\n>;\n\ntype _HasDerivedDisabledBinding = Expect<\n Equal<HasDerivedDisabledBinding, true>\n>;\n```\n\nIf the template were accidentally changed to use another member, the\nassertion would fail:\n\n```ts\ntype UsesWrongBinding = Expect<\n Equal<\n TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.enabled'\n >,\n false\n >\n>;\n```\n\nThe callback is executed by the Craft driver before the DOM property is\nwritten. The assertion verifies the exact binding source without a fixture or\nDOM.\n\n`computed` remains a synchronous signal when called directly\n(`counter.disabled()`) and in a template context.\n\nTemplates supplied to `forNode` and `deferNode` are also resolved. When a `deferNode`\ndirectly loads a Craft component, that component must appear in the registry.\nUnder this contract, DOM and output callbacks must be generators or branded\nCraft methods; ordinary imperative callbacks produce a diagnostic.\n\nBranded Craft methods are projected into the template context as yieldable\ncallbacks:\n\n```ts\nbutton(\n {\n *click() {\n yield* context.counter.increment(2);\n },\n },\n '+',\n);\n```\n\nThe renderer executes these callbacks with the Craft driver. Render callbacks\n(text, classes, styles, `forNode`, `deferNode`) remain synchronous.\n\n## Conditional visibility and named elements\n\nReactive values exposed by Craft primitives and services keep their property\nname in the template type. They remain synchronously readable in templates,\nwhile their name brand is available to `ifNode` and the visibility contract.\nUse `ifNode` to retain the condition and its branches in the VNode contract:\n\n\n\n\n\nThe local name is rendered as `data-craft-name`; `data-craft-root` remains an\ninternal tracking attribute.\n\n### Proving an element renders only under a condition\n\nA named element is asserted with its **full component identity** —\n`'<Component>:<tag>:<localName>'` — and the visibility path it sits behind:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype CanIncrement = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n CounterTemplate,\n 'Counter:button:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\nWhen the element is truly unconditional, omit `when` (or use an empty object).\nAn element inside an `ifNode` or `forNode` requires its visibility condition;\nomitting `when` deliberately returns `false` for such an element. Keep the\ncomplete `ComponentTemplateOf` type if you want editor completion for the\ncomponent prefix of the identity. Using `ReturnType<...>`\npreserves the element and tag, but loses the component name used for the most\nuseful completion suggestions:\n\n```ts\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype DisplayNewTodoNameInput = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n FullDemoTemplate,\n 'FullDemoCraft:input:TodoNameToAddInput'\n >,\n true\n >\n>;\n```\n\nWhen editing the second argument, the available identities are proposed from\nthe template, for example `FullDemoCraft:input:TodoNameToAddInput` and\n`FullDemoCraft:button:AddTodoButton`. `{ when: {} }` is equivalent to omitting\nthe third argument: both assert that the element is unconditional. For an\nelement inside an `ifNode` named `isAuth`, use `{ when: { isAuth: true } }`.\n\nThe same visibility contract can identify an element through branded direct\ncontent. Here, `brandedStatus` is not selected by its text; its brand proves that the\n`span` renders that value in the authenticated branch:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype StatusIsRenderedWhenAuthenticated = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'brandedStatus',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Counter, {\n context: {\n isAuth: markYieldableValue(signal(true), 'isAuth'),\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nbrandedStatusElement?.textContent;\ntest.destroy();\n```\n\nBecause the element is conditional, `brandedStatusElement` is typed as\n`HTMLSpanElement | undefined`. After `updateContext` and `detectChanges`, the\nsame locator returns `undefined` while the branch is absent.\n\n### Proving a binding renders for every item of a non-empty list\n\n`forNode` contributes `<listName>: 'nonEmpty'` to the visibility path, so you can\nassert what every item renders — here a translated label exposed by an\n`insertSelect` insertion:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { insertSelect, state } from '@craft-ts/core';\nimport { craftComponent, forNode, span } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRendersNamedElementWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst ItemList = craftComponent(\n 'ItemList',\n {},\n function* () {\n const items = yield* state(\n 'items',\n [{ key: 'first' }, { key: 'second' }],\n insertSelect('item', ({ state: selectedItem }) => ({\n translatedLabel: craftComputed(function* () {\n return `translated:${(yield* selectedItem()).key}`;\n }),\n })),\n );\n return { items };\n },\n ({ items }) =>\n forNode(items, { track: (item) => item.key }, (_item, index) =>\n span(\n 'itemLabel',\n { 'aria-label': items.selectItem(index)?.translatedLabel },\n () => items.selectItem(index)?.translatedLabel() ?? '',\n ),\n ),\n);\n\ntype ItemListTemplate = ReturnType<ComponentTemplateOf<typeof ItemList>>;\n\ntype HasTranslatedLabel = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n ItemListTemplate,\n 'ItemList:span:itemLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n\ntype RendersTranslatedLabel = Expect<\n Equal<\n TemplateRendersStateWhen<\n ItemListTemplate,\n 'items.selectItem.translatedLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n```\n\n\n\n### Proving a property is used on a named element\n\nThe same visibility paths verify that a state really feeds a rendered binding,\nand that a yieldable action is available on a named element — `'click:increment'`\nreads as \"the `click` action on the element named `increment`\":\n\n```typescript\nimport { craftMethod, state } from '@craft-ts/core';\nimport { button, craftComponent, ifNode } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRenderAvailableActionWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst Counter = craftComponent(\n 'Counter',\n {},\n function* () {\n const isAuth = yield* state('isAuth', true);\n const isAdult = yield* state('isAdult', true);\n const increment = craftMethod('increment', function* () {\n return undefined;\n });\n\n return { isAuth, isAdult, increment };\n },\n ({ isAuth, isAdult, increment }) =>\n ifNode(\n isAuth,\n () => button('increment', { click: increment }, () => isAdult()),\n () => [],\n ),\n);\n\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype RendersAdultState = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'isAdult',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\n// The key is `${event}:${localName}`.\ntype CanIncrementWhenAuthenticated = Expect<\n Equal<\n TemplateRenderAvailableActionWhen<\n CounterTemplate,\n 'click:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\n\n\n`TemplateRendersStateWhen` recognizes branded reads that contribute to visible\ntext or other render bindings such as `class` and `style`. Both assertions\nreturn `false` when the state or action exists only under a visibility branch\nthat is incompatible with `when`.\n\n`forNode` adds `<listName>: 'nonEmpty'` for its item template and\n`<listName>: 'empty'` for its empty template. Interactive helpers must use the\nnamed form (`button('increment', {}, '+')`): ESLint\n`craft-ts/require-interactive-local-name` requires the literal first argument,\nand `assertInteractiveElementNamed` requires that `data-craft-name` to be unique\nin the app. `craft-ts/template-element-name-unique` still forbids two\n`tag:localName` pairs in the same component template, including across\nconditional branches.\n\n### Proving a named property uses a specific state\n\n`TemplateNamedElementRendersStateWhen` combines the named-element identity,\nthe element property, and the context path. All three arguments are constrained\nby the template type, so editors can complete the element identity, the\navailable property names, and the available context paths:\n\n```ts\nimport type {\n ComponentTemplateOf,\n TemplateNamedElementRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype RemoveButtonUsesRemoveLoading = Expect<\n Equal<\n TemplateNamedElementRendersStateWhen<\n FullDemoTemplate,\n 'FullDemoCraft:button:RemoveTodoButton',\n 'disabled',\n 'store.remove.isLoading'\n >,\n true\n >\n>;\n```\n\nFor a reactive property binding, keep the read inside a render callback so\nthe context marker remains visible to the template contract:\n\n```ts\nbutton('RemoveTodoButton', {\n disabled: store.remove.isLoading,\n});\n```\n\nThis assertion proves that the `disabled` binding on the named remove button\nis driven by `store.remove.isLoading`; it does not instantiate the component or\nobserve the DOM.\n\n### Proving a named event delegates to a context method\n\n`TemplateNamedElementDelegatesToContext` checks the same relationship for a\ngenerator event callback:\n\n```ts\nimport type { TemplateNamedElementDelegatesToContext } from '@craft-ts/component';\n\ntype AddButtonClickUsesAddMutation = Expect<\n Equal<\n TemplateNamedElementDelegatesToContext<\n FullDemoTemplate,\n 'FullDemoCraft:button:AddTodoButton',\n 'click',\n 'store.add.mutate'\n >,\n true\n >\n>;\n```\n\nThe source callback must delegate with `yield*`:\n\n```ts\nbutton('AddTodoButton', {\n *click() {\n yield* store.add.mutate(title().trim());\n },\n});\n```\n\nThe named identity prevents a different button's `click` handler from\nsatisfying the assertion.\n\n## Pitfalls\n\n**Asserting `true` where the answer is `false`.** These helpers return a\nboolean type, so `Expect<Equal<…, true>>` is the assertion. Writing the helper\nalone proves nothing — it just computes a type nobody checks.\n\n**Naming the element is what makes it addressable.** A `button('increment', …)`\ncarries the local name that `'Counter:button:increment'` resolves. Without it\nthere is no identity to assert on.\n\n**Imperative callbacks are rejected.** Under this contract, DOM and output\ncallbacks must be generators or branded Craft methods; an ordinary function\nproduces a diagnostic.\n\n**A `deferNode` that loads a Craft component** requires that component to be present\nin the registry tuple.\n\n**The ergonomics are known to be rough.** `Expect<Equal<Helper<ReturnType<\nComponentTemplateOf<typeof X>>, …>, true>>` is a lot of ceremony for one\nassertion. Shorter façades are being explored; until then, alias what repeats:\n\n```ts\ntype Tpl = ReturnType<ComponentTemplateOf<typeof Counter>>;\ntype Assert<T extends true> = Expect<T>;\n```\n\n## See Also\n\n- [Testing components](/guide/testing/components) — the runtime half\n- [Testing services](/guide/testing/services)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Learn: test what you wrote](/learn/10-testing)\n"
491
586
  },
492
587
  {
493
588
  "path": "/learn",
@@ -497,32 +592,32 @@
497
592
  {
498
593
  "path": "/learn-effect",
499
594
  "title": "Learn CraftTS with Effect",
500
- "body": "# Learn CraftTS with Effect\n\nThis is the guided path for teams that already use [Effect](https://effect.website/)\nand want CraftTS to own the UI, reactivity and application graph.\n\nIf you are evaluating CraftTS from an existing Effect codebase, start with\n[Effect users: start here](/learn-effect/00-start-here). It explains what stays\nin Effect, what moves to Craft's UI model, and how to try the integration in\nfifteen minutes.\n\nYou start with a Craft component, then move the domain work into Effect programs:\n`Layer` provides services, `Effect<A, E, R>` carries success, typed failures and\nrequirements, and Craft adapters expose those programs as reactive resources.\n\n## What you will build\n\n| Step | What you add |\n| --- | --- |\n| [0. Effect users: start here](/learn-effect/00-start-here) | boundary, quickstart and adapter choice |\n| [1. Start with a Craft component](/learn-effect/01-first-component) | `craftComponent`, templates, native Craft state |\n| [2. Derive UI state](/learn-effect/02-derive) | `craftComputed`, `yield*`, precise dependencies |\n| [3. Put the domain in Effect](/learn-effect/03-effect-domain) | `Effect`, tagged errors, `Context.Service`, `Layer` |\n| [4. Load data with Effect](/learn-effect/04-load-data) | `queryEffect`, typed errors and defects |\n| [5. Write data with Effect](/learn-effect/05-write-data) | `mutationEffect`, `asyncProcessEffect`, reactive updates |\n| [6. Provide Layers and route the app](/learn-effect/06-layers-routing) | app/route Layers, DI proofs, type-safe routes |\n| [7. Build forms and validate boundaries](/learn-effect/07-forms-validation) | Effect Schema, forms, typed submit errors |\n| [8. Test the graph](/learn-effect/08-testing) | Effect service mocks, Craft registers, architecture tests |\n| [9. Call server functions — POC](/learn-effect/09-server-functions) | client/server boundary, `serverFunction`, `executeEffect` |\n\n## Before you start\n\nYou need a TypeScript application, Node.js 20.19+ (or 22.12+), and basic\nknowledge of generators. The guide uses Effect 4 RC and the beta CraftTS\npackages:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep `@craft-ts/core`, `@craft-ts/component` and `@craft-ts/effect` on the same\nCraftTS version. `@craft-ts/effect` has `effect` as a peer dependency.\n\n::: warning Experimental APIs\n\nCraftTS and this Effect integration are still experimental. The Effect bridge,\nthe server-function API and their types can change between beta releases. The\nserver-function chapter is deliberately labelled **proof of concept**: use it\nto explore the model, not as a final production contract.\n\n:::\n\n::: tip The central rule\n\nCraft owns the reactive boundary. Effect owns domain programs and their\ndependencies. Do not create a `stateEffect`: use native Craft `state` for UI\nstate, and use `queryEffect`, `mutationEffect` or `asyncProcessEffect` when an\nEffect program crosses into a Craft resource.\n\n:::\n\n<div style=\"text-align: right; margin-top: 2rem\">\n\n[Start → Craft component](/learn-effect/01-first-component)\n\n</div>\n"
595
+ "body": "# Learn CraftTS with Effect\n\n<div class=\"effect-logo-lockup\">\n <img src=\"/assets/effect-logo-black.png\" alt=\"Effect\" />\n</div>\n\nThis is the guided path for teams that already use [Effect](https://effect.website/)\nand want CraftTS to own the UI, reactivity and application graph.\n\nIf you are evaluating CraftTS from an existing Effect codebase, start with\n[Effect users: start here](/learn-effect/00-start-here). It explains what stays\nin Effect, what moves to Craft's UI model, and how to try the integration in\nfifteen minutes.\n\nYou start with a Craft component, then move the domain work into Effect programs:\n`Layer` provides services, `Effect<A, E, R>` carries success, typed failures and\nrequirements, and Craft adapters expose those programs as reactive resources.\n\n## What you will build\n\n| Step | What you add |\n| --- | --- |\n| [0. Effect users: start here](/learn-effect/00-start-here) | boundary, quickstart and adapter choice |\n| [1. Start with a Craft component](/learn-effect/01-first-component) | `craftComponent`, templates, native Craft state |\n| [2. Derive UI state](/learn-effect/02-derive) | `craftComputed`, `yield*`, precise dependencies |\n| [3. Put the domain in Effect](/learn-effect/03-effect-domain) | `Effect`, tagged errors, `Context.Service`, `Layer`, `SyncOp` |\n| [4. Load data with Effect](/learn-effect/04-load-data) | `queryEffect`, typed errors and defects |\n| [5. Write data with Effect](/learn-effect/05-write-data) | `mutationEffect`, `asyncProcessEffect`, reactive updates |\n| [6. Provide Layers and route the app](/learn-effect/06-layers-routing) | app/route Layers, DI proofs, type-safe routes |\n| [7. Build forms and validate boundaries](/learn-effect/07-forms-validation) | Effect Schema, forms, typed submit errors |\n| [8. Test the graph](/learn-effect/08-testing) | Effect service mocks, Craft registers, architecture tests |\n| [9. Call server functions — POC](/learn-effect/09-server-functions) | client/server boundary, `serverFunction`, `executeEffect` |\n\n## Before you start\n\nYou need a TypeScript application, Node.js 20.19+ (or 22.12+), and basic\nknowledge of generators. The guide uses Effect 4 RC and the beta CraftTS\npackages:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep `@craft-ts/core`, `@craft-ts/component` and `@craft-ts/effect` on the same\nCraftTS version. `@craft-ts/effect` has `effect` as a peer dependency.\n\n::: warning Experimental APIs\n\nCraftTS and this Effect integration are still experimental. The Effect bridge,\nthe server-function API and their types can change between beta releases. The\nserver-function chapter is deliberately labelled **proof of concept**: use it\nto explore the model, not as a final production contract.\n\n:::\n\n::: tip The central rule\n\nCraft owns the reactive boundary. Effect owns domain programs and their\ndependencies. Do not create a `stateEffect`: use native Craft `state` for UI\nstate, and use `queryEffect`, `mutationEffect` or `asyncProcessEffect` when an\nEffect program crosses into a Craft resource.\n\n:::\n\n<div style=\"text-align: right; margin-top: 2rem\">\n\n[Start → Craft component](/learn-effect/01-first-component)\n\n</div>\n"
501
596
  },
502
597
  {
503
598
  "path": "/learn-effect/00-start-here",
504
599
  "title": "Effect users: start here",
505
- "body": "# Effect users: start here\n\nThis page is for teams that already use Effect and are evaluating CraftTS for\nthe frontend.\n\nThe important distinction is this:\n\n> You do not need to replace your domain model or your Effect programs. You do\n> need to adopt Craft's UI model for components, templates, reactive state,\n> forms and routing.\n\nEffect remains the place for domain programs, typed failures, services and\n`Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity,\nloading, cancellation and URL state.\n\n## The boundary in one picture\n\n```mermaid\nflowchart LR\n UI[\"Craft component and template\"] --> R[\"Craft resource\\nqueryEffect / mutationEffect\"]\n R --> P[\"Effect program\\nEffect<A, E, R>\"]\n P --> L[\"Layer<R>\"]\n L --> I[\"Craft injector\\napplication / route / component\"]\n R --> V[\"Reactive Craft readers\\nvalue / loading / exceptions\"]\n V --> UI\n```\n\nThe two sides have different responsibilities:\n\n| Concern | Effect | CraftTS |\n| --- | --- | --- |\n| Domain rules | `Effect<A, E, R>` | consumes the result |\n| Services | `Context.Service` + `Layer` | provides the Layer at a Craft scope |\n| Business failures | tagged errors in `E` | typed exceptions to render or handle |\n| UI state | not the owner | `state`, `queryParams`, derived readers |\n| Loading and cancellation | Effect runtime | `queryEffect`, `mutationEffect`, `asyncProcessEffect` |\n| Components and templates | not the owner | `craftComponent` and typed hyperscript |\n\n`yield*` appears on both sides, but it does not mean the same thing. Inside an\nEffect program it reads an Effect service or runs another Effect. Inside a Craft\nfactory it declares a Craft dependency or crosses the boundary through an\nEffect adapter.\n\n## A 15-minute quickstart\n\nThe goal is one page that loads a user from an Effect program and renders the\nresult through a Craft query.\n\n### 1. Install the matching packages — 2 minutes\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep the Craft packages on the same version. See the\n[compatibility and maturity matrix](/resources/effect-compatibility) before\nusing this in a production application.\n\n### 2. Define the domain program — 4 minutes\n\nThis code is ordinary Effect code. It does not import Craft.\n\n\n\nThe component will call `loadUser`, but it will not resolve\n`UserRepositoryService`. The nearest `Layer` will provide it.\n\n### 3. Cross the boundary with `queryEffect` — 4 minutes\n\nThe adapter turns `Effect<User, UserNotFound, UserRepositoryService>` into a\nCraft resource with loading, value and exception readers.\n\n\n\nDo not call `Effect.runPromise` or subscribe inside the component. The resource\nowns execution, cancellation and the transition between loading, success and\nfailure.\n\n### 4. Provide the Layer and install the bridge — 3 minutes\n\nInstall the bridge once at application bootstrap. Provide the Effect Layer at\nthe same Craft scope where the operation is used.\n\n\n\nRun the application with your normal frontend command. The executable version\nof this example is also covered by the docs test suite.\n\n### 5. Verify the boundary — 2 minutes\n\n```shell\nnpx nx test docs\nnpx nx typecheck demo-effect\nnpx nx test demo-effect\n```\n\nThe docs test target now performs three checks: it transpiles every TypeScript\nor TSX code fence in `learn-effect`, type-checks the complete snippets under\n`tests/snippets/learn-effect`, and executes their Vitest tests. The transpilation\ncheck is intentionally syntax-focused because several excerpts are meant to be\ncopied into an existing Craft or Effect generator; complete examples receive\nthe stronger typecheck and runtime coverage. The Effect demo covers success,\ntyped business errors, defects, application Layers and route-scoped Layers.\n\nFor a runnable starter that keeps this boundary intentionally small, use the\nrepository's [`quickstart-effect`](https://github.com/craft-ts/craft-ts/tree/main/apps/quickstart-effect)\napplication. It is wired into the same ESLint, EffectTS diagnostics and\narchitecture checks that a new Effect frontend should adopt.\n\n## Which adapter should I choose?\n\n| Situation | Adapter |\n| --- | --- |\n| Local toggle, draft or selection | `state` |\n| Server or domain read | `queryEffect` |\n| Explicit write | `mutationEffect` |\n| Reactive Effect derived from Craft state | `computedEffect` |\n| Export, refresh or other explicit command | `asyncProcessEffect` |\n| One Effect in a guard or resolver | `runEffect` |\n| URL filters and pagination state | native Craft `queryParams` |\n\nThere is intentionally no `stateEffect`: local UI state belongs to Craft; an\nEffect is introduced when a computation, I/O operation or service dependency\ncrosses into the UI.\n\n## Continue from here\n\n- Read the [full Effect learning path](/learn-effect/).\n- Check [compatibility and maturity](/resources/effect-compatibility).\n- Follow the [progressive adoption plan](/resources/effect-adoption).\n- For the detailed API contract, read [Using Effect with CraftTS](/guide/advanced/effect).\n"
600
+ "body": "# Effect users: start here\n\nThis page is for teams that already use Effect and are evaluating CraftTS for\nthe frontend.\n\nThe important distinction is this:\n\n> You do not need to replace your domain model or your Effect programs. You do\n> need to adopt Craft's UI model for components, templates, reactive state,\n> forms and routing.\n\nEffect remains the place for domain programs, typed failures, services and\n`Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity,\nloading, cancellation and URL state.\n\n## The boundary in one picture\n\n```mermaid\nflowchart LR\n UI[\"Craft component and template\"] --> R[\"Craft resource\\nqueryEffect / mutationEffect\"]\n R --> P[\"Effect program\\nEffect<A, E, R>\"]\n P --> L[\"Layer<R>\"]\n L --> I[\"Craft injector\\napplication / route / component\"]\n R --> V[\"Reactive Craft readers\\nvalue / loading / exceptions\"]\n V --> UI\n```\n\nThe two sides have different responsibilities:\n\n| Concern | Effect | CraftTS |\n| --- | --- | --- |\n| Domain rules | `Effect<A, E, R>` | consumes the result |\n| Services | `Context.Service` + `Layer` | provides the Layer at a Craft scope |\n| Business failures | tagged errors in `E` | typed exceptions to render or handle |\n| UI state | not the owner | `state`, `queryParams`, derived readers |\n| Loading and cancellation | Effect runtime | `queryEffect`, `mutationEffect`, `asyncProcessEffect` |\n| Components and templates | not the owner | `craftComponent` and typed hyperscript |\n\n`yield*` appears on both sides, but it does not mean the same thing. Inside an\nEffect program it reads an Effect service or runs another Effect. Inside a Craft\nfactory it declares a Craft dependency or crosses the boundary through an\nEffect adapter.\n\n## A 15-minute quickstart\n\nThe goal is one page that loads a user from an Effect program and renders the\nresult through a Craft query.\n\n### 1. Install the matching packages — 2 minutes\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep the Craft packages on the same version. See the\n[compatibility and maturity matrix](/resources/effect-compatibility) before\nusing this in a production application.\n\n### 2. Define the domain program — 4 minutes\n\nThis code is ordinary Effect code. It does not import Craft.\n\n\n\nThe component will call `loadUser`, but it will not resolve\n`UserRepositoryService`. The nearest `Layer` will provide it.\n\n### 3. Cross the boundary with `queryEffect` — 4 minutes\n\nThe adapter turns `Effect<User, UserNotFound, UserRepositoryService>` into a\nCraft resource with loading, value and exception readers.\n\n\n\nDo not call `Effect.runPromise` or subscribe inside the component. The resource\nowns execution, cancellation and the transition between loading, success and\nfailure.\n\n### 4. Provide the Layer and install the bridge — 3 minutes\n\nInstall the bridge once at application bootstrap. Provide the Effect Layer at\nthe same Craft scope where the operation is used.\n\n\n\nRun the application with your normal frontend command. The executable version\nof this example is also covered by the docs test suite.\n\n### 5. Verify the boundary — 2 minutes\n\n```shell\nnpx nx test docs\nnpx nx typecheck demo-effect\nnpx nx test demo-effect\n```\n\nThe docs test target now performs three checks: it transpiles every TypeScript\nor TSX code fence in `learn-effect`, type-checks the complete snippets under\n`tests/snippets/learn-effect`, and executes their Vitest tests. The transpilation\ncheck is intentionally syntax-focused because several excerpts are meant to be\ncopied into an existing Craft or Effect generator; complete examples receive\nthe stronger typecheck and runtime coverage. The Effect demo covers success,\ntyped business errors, defects, application Layers and route-scoped Layers.\n\nFor a runnable starter that keeps this boundary intentionally small, use the\nrepository's [`quickstart-effect`](https://github.com/craft-ts/craft-ts/tree/main/apps/quickstart-effect)\napplication. It is wired into the same ESLint, EffectTS diagnostics and\narchitecture checks that a new Effect frontend should adopt.\n\n## Which adapter should I choose?\n\n| Situation | Adapter |\n| --- | --- |\n| Local toggle, draft or selection | `state` |\n| Server or domain read | `queryEffect` |\n| Explicit write | `mutationEffect` |\n| Synchronous business calculation from an Effect service | `computedEffect` |\n| Export, refresh or other explicit command | `asyncProcessEffect` |\n| One Effect in a guard or resolver | `runEffect` |\n| URL filters and pagination state | native Craft `queryParams` |\n\nThere is intentionally no `stateEffect`: local UI state belongs to Craft; an\nEffect is introduced when a computation, I/O operation or service dependency\ncrosses into the UI.\n\n## Continue from here\n\n- Read the [full Effect learning path](/learn-effect/).\n- Check [compatibility and maturity](/resources/effect-compatibility).\n- Follow the [progressive adoption plan](/resources/effect-adoption).\n- For the detailed API contract, read [Using Effect with CraftTS](/guide/advanced/effect).\n"
506
601
  },
507
602
  {
508
603
  "path": "/learn-effect/01-first-component",
509
604
  "title": "1. Start with a Craft component",
510
- "body": "# 1. Start with a Craft component\n\n**Goal:** render a reactive task list before introducing Effect.\n\nEffect users do not need to replace their domain model or Effect programs. They\ndo need to adopt Craft's UI model: a component is a function with a generator\nlogic factory and a typed template:\n\n```typescript\nimport { craftComponent, div, h1, li, ul, each } from '@craft-ts/component';\nimport { state } from '@craft-ts/core';\n\ntype Task = { readonly id: string; readonly title: string; readonly done: boolean };\n\nexport const Tasks = craftComponent(\n 'Tasks', // name: stable component name used by tooling and the graph\n {}, // meta: providers, styles and host configuration\n function* () { // logic factory: creates the component context\n const tasks = yield* state('tasks', [ // name: state identifier\n { id: '1', title: 'Learn Craft components', done: true },\n { id: '2', title: 'Add the first Effect program', done: false },\n ] satisfies Task[]); // initial value: the seeded task list\n\n return { tasks };\n },\n ({ tasks }) => [ // template: turns the context into rendered nodes\n h1('Tasks'),\n ul(\n each(\n tasks, // source: the reactive collection to render\n { track: (task) => task.id }, // options: stable identity for each item\n (task) => li(task.title), // render: creates one node per task\n ),\n ),\n ],\n);\n```\n\nThere is no class, decorator, selector or separate HTML file. The template is\ntyped hyperscript. A component has four responsibilities:\n\n| Argument | Responsibility |\n| --- | --- |\n| `'Tasks'` | stable name used by tooling and the graph |\n| `{}` | providers, styles and host configuration |\n| `function*` | create the component context and yield dependencies |\n| template | turn that context into nodes |\n\n`tasks` is a Craft reader. Yield it when a generator reads it; pass it directly\nto a template binding. The renderer tracks the exact binding that reads it.\n\n## Bootstrap once\n\nThe root component is provided in the app config and mounted by\n`bootstrapCraft`:\n\n```typescript\n// app.config.ts\nimport { provideCraftRootComponent } from '@craft-ts/component';\nimport { craftAppConfig } from '@craft-ts/core';\nimport { App } from './app';\n\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## Where Effect fits\n\nCraft owns reactive UI state and rendering. Effect owns domain operations — work\nthat may fail with typed errors or depend on services. In the next step, we will\nconnect an Effect program to Craft so the component can render its result\nwithout managing subscriptions or fibers.\n\n## What you gained\n\nA selectorless, typed component with fine-grained rendering. The next step adds\nderived UI state without duplicating data.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn-effect/)\n\n[2. Derive UI state →](/learn-effect/02-derive)\n\n</div>\n"
605
+ "body": "# 1. Start with a Craft component\n\n**Goal:** render a reactive task list before introducing Effect.\n\nEffect users do not need to replace their domain model or Effect programs. They\ndo need to adopt Craft's UI model: a component is a function with a generator\nlogic factory and a typed template:\n\n```typescript\nimport { craftComponent, div, h1, li, ul, forNode } from '@craft-ts/component';\nimport { state } from '@craft-ts/core';\n\ntype Task = { readonly id: string; readonly title: string; readonly done: boolean };\n\nexport const Tasks = craftComponent(\n 'Tasks', // name: stable component name used by tooling and the graph\n {}, // meta: providers, styles and host configuration\n function* () { // logic factory: creates the component context\n const tasks = yield* state('tasks', [ // name: state identifier\n { id: '1', title: 'Learn Craft components', done: true },\n { id: '2', title: 'Add the first Effect program', done: false },\n ] satisfies Task[]); // initial value: the seeded task list\n\n return { tasks };\n },\n ({ tasks }) => [ // template: turns the context into rendered nodes\n h1('Tasks'),\n ul(\n forNode(\n tasks, // source: the reactive collection to render\n { track: (task) => task.id }, // options: stable identity for each item\n (task) => li(task.title), // render: creates one node per task\n ),\n ),\n ],\n);\n```\n\nThere is no class, decorator, selector or separate HTML file. The template is\ntyped hyperscript. A component has four responsibilities:\n\n| Argument | Responsibility |\n| --- | --- |\n| `'Tasks'` | stable name used by tooling and the graph |\n| `{}` | providers, styles and host configuration |\n| `function*` | create the component context and yield dependencies |\n| template | turn that context into nodes |\n\n`tasks` is a Craft reader. Yield it when a generator reads it; pass it directly\nto a template binding. The renderer tracks the exact binding that reads it.\n\n## Bootstrap once\n\nThe root component is provided in the app config and mounted by\n`bootstrapCraft`:\n\n```typescript\n// app.config.ts\nimport { provideCraftRootComponent } from '@craft-ts/component';\nimport { craftAppConfig } from '@craft-ts/core';\nimport { App } from './app';\n\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## Where Effect fits\n\nCraft owns reactive UI state and rendering. Effect owns domain operations — work\nthat may fail with typed errors or depend on services. In the next step, we will\nconnect an Effect program to Craft so the component can render its result\nwithout managing subscriptions or fibers.\n\n## What you gained\n\nA selectorless, typed component with fine-grained rendering. The next step adds\nderived UI state without duplicating data.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn-effect/)\n\n[2. Derive UI state →](/learn-effect/02-derive)\n\n</div>\n"
511
606
  },
512
607
  {
513
608
  "path": "/learn-effect/02-derive",
514
609
  "title": "2. Derive UI state",
515
- "body": "# 2. Derive UI state\n\n**Goal:** calculate UI state from the source of truth, and understand the\n`yield*` rule that Craft and Effect share.\n\nUse `craftComputed` for synchronous derivations. Its factory is a generator when\nit reads a Craft value:\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst tasks = yield* state('tasks', [] as Task[]);\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nThe template can bind `remaining` directly. Craft re-runs only the binding that\ndepends on it.\n\n## The shared dependency vocabulary\n\nBoth runtimes use generators, but they solve different problems:\n\n```typescript\nconst tasks = yield* TaskList(); // Craft service\nconst access = yield* AccessPolicyService; // Effect service inside an Effect\nconst value = yield* resource.value(); // Craft reader inside a derivation\n```\n\nThe rule is the same: yield what the current function does not own. A Craft\nfactory yields Craft dependencies; an Effect program yields Effect dependencies.\nThe adapter connects the two at a deliberate boundary.\n\n## Do not duplicate domain state in the component\n\nThe component should not subscribe to an Effect, convert an Effect to a signal\nby hand, or start a fiber in a template callback. Those approaches hide loading,\ncancellation and failure state from Craft. Instead:\n\n1. Keep the domain operation as `Effect<A, E, R>`.\n2. Expose it through a Craft Effect-aware primitive.\n3. Derive the display state from the resulting Craft resource.\n\nThe next step defines the domain operation and the services it requires.\n\n## What you gained\n\nDerived state that stays reactive and a clear division: Craft derives the UI;\nEffect composes the domain program.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Start with a Craft component](/learn-effect/01-first-component)\n\n[3. Put the domain in Effect →](/learn-effect/03-effect-domain)\n\n</div>\n"
610
+ "body": "# 2. Derive UI state\n\n**Goal:** calculate UI state from the source of truth, and understand the\n`yield*` rule that Craft and Effect share.\n\nUse `computedEffect` for synchronous Effect-backed derivations. Its factory is a\ngenerator when it reads a Craft value and returns the Effect to run:\n\n```typescript\nimport { Effect } from 'effect';\nimport { state } from '@craft-ts/core';\nimport { computedEffect } from '@craft-ts/effect';\n\nconst tasks = yield* state('tasks', [] as Task[]);\nconst remaining = computedEffect('remaining', function* () {\n const currentTasks = yield* tasks();\n return Effect.succeed(\n currentTasks.filter((task) => !task.done).length,\n );\n});\n```\n\nThe template can bind `remaining` directly. `computedEffect` runs the returned\nsynchronous Effect in place, and Craft re-runs only the binding that depends on\nit.\n\n## The shared dependency vocabulary\n\nBoth runtimes use generators, but they solve different problems:\n\n```typescript\nconst tasks = yield* TaskList(); // Craft service\nconst access = yield* AccessPolicyService; // Effect service inside an Effect\nconst value = yield* resource.value(); // Craft reader inside a derivation\n```\n\nThe rule is the same: yield what the current function does not own. A Craft\nfactory yields Craft dependencies; an Effect program yields Effect dependencies.\nThe adapter connects the two at a deliberate boundary.\n\n## Do not duplicate domain state in the component\n\nThe component should not subscribe to an Effect, convert an Effect to a signal\nby hand, or start a fiber in a template callback. Those approaches hide loading,\ncancellation and failure state from Craft. Instead:\n\n1. Keep the domain operation as `Effect<A, E, R>`.\n2. Expose it through a Craft Effect-aware primitive.\n3. Derive the display state from the resulting Craft resource.\n\nThe next step defines the domain operation and the services it requires.\n\n## What you gained\n\nDerived state that stays reactive and a clear division: Craft derives the UI;\nEffect composes the domain program.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Start with a Craft component](/learn-effect/01-first-component)\n\n[3. Put the domain in Effect →](/learn-effect/03-effect-domain)\n\n</div>\n"
516
611
  },
517
612
  {
518
613
  "path": "/learn-effect/03-effect-domain",
519
614
  "title": "3. Put the domain in Effect",
520
- "body": "# 3. Put the domain in Effect\n\n**Goal:** define typed business failures and services without making the Craft\ncomponent know how they are provided.\n\n## Typed failures are values\n\nEffect's tagged errors map naturally to Craft's exception channel:\n\n```typescript\nimport { Context, Data, Effect } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\ntype User = {\n readonly id: string;\n};\n\ntype UserRepository = {\n readonly find: (userId: string) => Effect.Effect<User | undefined>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport function loadUser(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n const user = yield* repository.find(userId);\n if (!user) return yield* new UserNotFound({ userId });\n return user;\n });\n}\n```\n\nThe program has the shape `Effect<User, UserNotFound, UserRepositoryService>`.\n`yield* UserRepositoryService` gets the repository from the Effect context;\n`yield* repository.find(userId)` then runs the `Effect` returned by its method.\n`UserNotFound` is a business outcome that the UI can handle. An unexpected\ndefect raised by `Effect.die` remains a technical error; it is not turned into a\nbusiness exception.\n\n`Data.TaggedError` creates a **yieldable error** in Effect v4, so this is the\nidiomatic form inside `Effect.gen`:\n\n```typescript\nif (!user) return yield* new UserNotFound({ userId });\n```\n\nThe explicit equivalent is `yield* Effect.fail(new UserNotFound({ userId }))`;\nthere is no `Effect.failed` constructor. At the Craft boundary, yield the\neffect through `runEffect(...)` instead of yielding the error instance directly.\n\n## Define an Effect service\n\nUse `Context.Service` for the contract and a `Layer` for the implementation:\n\n```typescript\nimport { Context, Effect, Layer } from 'effect';\n\ntype AccessPolicy = {\n readonly decide: (userId: string) => Effect.Effect<AccessDecision, UserNotFound>;\n};\n\nexport class AccessPolicyService extends Context.Service<\n AccessPolicyService,\n AccessPolicy\n>()('app/AccessPolicyService') {}\n\nexport const AccessPolicyLive = Layer.sync(AccessPolicyService)(() => ({\n decide: (userId) => findAccessDecision(userId),\n}));\n\nexport function checkUserAccess(userId: string) {\n return Effect.gen(function* () {\n const policy = yield* AccessPolicyService;\n return yield* policy.decide(userId);\n });\n}\n```\n\nThe component calls `checkUserAccess`; it does not call `AccessPolicyService`\nand does not know which Layer implements it.\n\nWhen a Craft factory genuinely needs a service member, narrow it explicitly with\n`effectService` rather than resolving an untracked value:\n\n```typescript\nimport { effectService } from '@craft-ts/effect';\n\nconst { decide } = yield* effectService(\n AccessPolicyService,\n ({ decide }) => ({ decide }),\n);\n```\n\nPrefer exposing a domain operation such as `checkUserAccess` to a component. The\nselector form is useful for a Craft service or adapter that deliberately owns\nthe boundary and wants the graph to record only the members it uses.\n\n## Derive Craft state from the Effect service\n\n`craftComputed` stays synchronous: it derives a Craft reader. Let\n`queryEffect` execute the Effect operation, then derive a display value from the\nquery resource:\n\n```typescript\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\n\nconst accessQuery = yield* queryEffect('accessQuery', {\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n\nconst accessLabel = craftComputed('accessLabel', function* () {\n return (yield* accessQuery.value())?.label ?? 'Loading…';\n});\n```\n\nThe chain is: `queryEffect` runs `checkUserAccess`, the active `Layer` provides\n`AccessPolicyService`, and `accessLabel` reacts to the query's Craft value. The\ncomputed does not call the Effect service or start an Effect itself.\n\n## Run a standalone Effect\n\nFor a low-level bridge, `runEffect` lets a Craft generator yield an Effect while\npreserving its typed error channel:\n\n```typescript\nimport { Effect } from 'effect';\nimport { runEffect } from '@craft-ts/effect';\n\nconst name = yield* runEffect(Effect.succeed('Ada'));\n```\n\nUse the adapters in the next chapters for application data. They resolve the\nEffect requirement `R` through the nearest `provideLayer(...)` and keep loading,\nvalue and exception state in the Craft resource.\n\n## Install the bridge once\n\nThe bridge teaches Craft how to execute a yielded Effect. Install it during app\nbootstrap, not in every loader:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn tests, call `installCraftEffectBridge()` in `beforeEach` and dispose the\nreturned function in `afterEach`.\n\n## What you gained\n\nAn Effect domain with typed failures, explicit service requirements and swappable\nLayers. The next step puts that program behind a reactive `queryEffect`.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 2. Derive UI state](/learn-effect/02-derive)\n\n[4. Load data with Effect →](/learn-effect/04-load-data)\n\n</div>\n"
615
+ "body": "# 3. Put the domain in Effect\n\n**Goal:** define typed business failures and services without making the Craft\ncomponent know how they are provided.\n\n## Typed failures are values\n\nEffect's tagged errors map naturally to Craft's exception channel:\n\n```typescript\nimport { Context, Data, Effect } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\ntype User = {\n readonly id: string;\n};\n\ntype UserRepository = {\n readonly find: (userId: string) => Effect.Effect<User | undefined>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport function loadUser(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n const user = yield* repository.find(userId);\n if (!user) return yield* new UserNotFound({ userId });\n return user;\n });\n}\n```\n\nThe program has the shape `Effect<User, UserNotFound, UserRepositoryService>`.\n`yield* UserRepositoryService` gets the repository from the Effect context;\n`yield* repository.find(userId)` then runs the `Effect` returned by its method.\n`UserNotFound` is a business outcome that the UI can handle. An unexpected\ndefect raised by `Effect.die` remains a technical error; it is not turned into a\nbusiness exception.\n\n`Data.TaggedError` creates a **yieldable error** in Effect v4, so this is the\nidiomatic form inside `Effect.gen`:\n\n```typescript\nif (!user) return yield* new UserNotFound({ userId });\n```\n\nThe explicit equivalent is `yield* Effect.fail(new UserNotFound({ userId }))`;\nthere is no `Effect.failed` constructor. At the Craft boundary, yield the\neffect through `runEffect(...)` instead of yielding the error instance directly.\n\n## Define an Effect service\n\nUse `Context.Service` for the contract and a `Layer` for the implementation:\n\n```typescript\nimport { Context, Effect, Layer } from 'effect';\n\ntype AccessPolicy = {\n readonly decide: (userId: string) => Effect.Effect<AccessDecision, UserNotFound>;\n};\n\nexport class AccessPolicyService extends Context.Service<\n AccessPolicyService,\n AccessPolicy\n>()('app/AccessPolicyService') {}\n\nexport const AccessPolicyLive = Layer.sync(AccessPolicyService)(() => ({\n decide: (userId) => findAccessDecision(userId),\n}));\n\nexport function checkUserAccess(userId: string) {\n return Effect.gen(function* () {\n const policy = yield* AccessPolicyService;\n return yield* policy.decide(userId);\n });\n}\n```\n\nThe component calls `checkUserAccess`; it does not call `AccessPolicyService`\nand does not know which Layer implements it.\n\nWhen a Craft factory genuinely needs a service member, narrow it explicitly with\n`effectService` rather than resolving an untracked value:\n\n```typescript\nimport { effectService } from '@craft-ts/effect';\n\nconst { decide } = yield* effectService(\n AccessPolicyService,\n ({ decide }) => ({ decide }),\n);\n```\n\nPrefer exposing a domain operation such as `checkUserAccess` to a component. The\nselector form is useful for a Craft service or adapter that deliberately owns\nthe boundary and wants the graph to record only the members it uses.\n\n## Derive Craft state from the Effect service\n\n`craftComputed` stays synchronous: it derives a Craft reader. Let\n`queryEffect` execute the Effect operation, then derive a display value from the\nquery resource:\n\n```typescript\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\n\nconst accessQuery = yield* queryEffect('accessQuery', {\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n\nconst accessLabel = craftComputed('accessLabel', function* () {\n return (yield* accessQuery.value())?.label ?? 'Loading…';\n});\n```\n\nThe chain is: `queryEffect` runs `checkUserAccess`, the active `Layer` provides\n`AccessPolicyService`, and `accessLabel` reacts to the query's Craft value. The\ncomputed does not call the Effect service or start an Effect itself.\n\n## Declare a synchronous member\n\nThat last sentence used to be a hard rule: no Effect at all inside `params`,\n`craftComputed(...)` or `craftMethod(...)`. Those run on Craft's **synchronous**\ndriver, which completes on one tick and cannot wait — and `Effect<A, E, R>` does\nnot say whether running it will suspend.\n\nIt is worse than it looks for a service member. A `Layer` closes over the\nmember's dependencies when it builds the service, so a member that calls the\nnetwork and a member that adds two numbers *both* surface as `R = never`:\n\n\n\nThe information does not exist in the type, so you write it there. `SyncOp` is a\nphantom requirement — never provided, no runtime cost — and `R` is the one\nchannel Effect accumulates across composition. An Effect that requires `SyncOp`\nis one its author declares never suspends.\n\nRequirements union through `Effect.gen`, so the declaration propagates on its\nown. A standalone program that only calls declared-synchronous members inherits\nthe marker; one that calls nothing marked spells it out with `yield* SyncOp`:\n\n\n\n`CartPricing` in `R` is not a problem: the level in force satisfies it, exactly\nas it does for a loader. The only thing checked is that `SyncOp` is among the\nrequirements.\n\n## Use it: computedEffect, then syncEffect\n\nFor a derived value, reach for `computedEffect` — the Effect counterpart of\n`craftComputed`. The factory reads Craft dependencies and **returns** the\nEffect; the adapter runs it in place, so you get a plain reactive value:\n\n\n\nAnything nobody declared synchronous is refused at the call, before anything\nruns:\n\n\n\nFor a synchronous Effect exposed as a callable method, use `methodEffect`, the\nEffect counterpart of `craftMethod`. For lower-level positions such as a\n`params` factory or a `state` updater, `syncEffect(...)` is the same door,\nopened by hand:\n\n```typescript\nqueryEffect('shippingQuote', {\n params: function* () {\n return yield* syncEffect(cartWeightGrams(yield* lines()));\n },\n loader: ({ params }) => quoteShipping(params),\n});\n```\n\nThe relationship mirrors the asynchronous side: `computedEffect` is to\n`methodEffect` what `queryEffect` is to `asyncProcessEffect` — a value or method\nwith no resource lifecycle. `syncEffect` remains the lower-level escape hatch.\n\n::: tip Three lines of defence\n\n`SyncOp` is a claim, not a proof — nothing stops a body from declaring itself\nsynchronous and awaiting anyway. Three mechanisms check it, and none is\nredundant:\n\n1. **the type** — `computedEffect` and `syncEffect(...)` refuse an Effect nobody\n declared, at the call site;\n2. **`craft-ts/sync-effect-body`** — reads the body, every branch at once, and\n rejects a declared-synchronous body that yields something async. A unit test\n cannot do this: it only proves the inputs it was given;\n3. **the runtime** — both run through `Effect.runSyncExitWith`, which\n cannot suspend. A broken declaration throws `CraftEffectNotSynchronous`\n immediately, at the first call, instead of freezing the UI.\n\n:::\n\nKeep asynchronous work where it belongs: a loader. `SyncOp` opens one narrow,\nexplicit door for business calculations, not a way around the adapters.\n\n## Run a standalone Effect\n\nFor a low-level bridge, `runEffect` lets a Craft generator yield an Effect while\npreserving its typed error channel:\n\n```typescript\nimport { Effect } from 'effect';\nimport { runEffect } from '@craft-ts/effect';\n\nconst name = yield* runEffect(Effect.succeed('Ada'));\n```\n\nUse the adapters in the next chapters for application data. They resolve the\nEffect requirement `R` through the nearest `provideLayer(...)` and keep loading,\nvalue and exception state in the Craft resource.\n\n## Install the bridge once\n\nThe bridge teaches Craft how to execute a yielded Effect. Install it during app\nbootstrap, not in every loader:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn tests, call `installCraftEffectBridge()` in `beforeEach` and dispose the\nreturned function in `afterEach`.\n\n## What you gained\n\nAn Effect domain with typed failures, explicit service requirements and swappable\nLayers. The next step puts that program behind a reactive `queryEffect`.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 2. Derive UI state](/learn-effect/02-derive)\n\n[4. Load data with Effect →](/learn-effect/04-load-data)\n\n</div>\n"
521
616
  },
522
617
  {
523
618
  "path": "/learn-effect/04-load-data",
524
619
  "title": "4. Load data with Effect",
525
- "body": "# 4. Load data with Effect\n\n**Goal:** expose an `Effect<A, E, R>` as a Craft query.\n\n## The operation being loaded\n\n`queryEffect` receives a domain function that returns an Effect. Here is the\n`loadUserProfile` used by the query below; the data is mocked so the example\ncan show each result channel:\n\n```typescript\n// profile-domain.ts\nimport { Data, Effect } from 'effect';\n\nexport type ProfileScenario =\n | 'success'\n | 'not-found'\n | 'session-expired'\n | 'database-down';\n\ntype Profile = { readonly name: string };\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\nexport function loadUserProfile(scenario: ProfileScenario) {\n return Effect.gen(function* () {\n // Simulate the latency of a backend request.\n yield* Effect.sleep('400 millis');\n\n switch (scenario) {\n case 'not-found':\n return yield* new UserNotFound({ userId: 'user-404' });\n case 'session-expired':\n return yield* new Unauthorized({ reason: 'session expired' });\n case 'database-down':\n return yield* Effect.die(new Error('database unavailable'));\n case 'success':\n return { name: 'Ada Lovelace' } satisfies Profile;\n }\n });\n}\n```\n\n`loadUserProfile` does not run when it is declared. It returns an\n`Effect<Profile, UserNotFound | Unauthorized>`, which represents a backend\nrequest and which the query runs whenever its parameters trigger the loader.\n\n## `queryEffect`\n\nThe adapter has the same lifecycle as `query`, but its loader returns an Effect:\n\n```typescript\nimport {\n type Input,\n craftComponent,\n ifBlock,\n matchBlock,\n p,\n} from '@craft-ts/component';\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile, type ProfileScenario } from './profile-domain';\n\nconst Profile = craftComponent(\n 'Profile',\n {},\n function* (profileScenarioInput: Input<ProfileScenario>) {\n const profile = yield* queryEffect(\n 'profile',\n {\n params: profileScenarioInput,\n loader: ({ params }) => loadUserProfile(params),\n },\n ({ resource, exceptions }) => ({\n hasProfile: craftComputed('hasProfile', () => resource.hasValue()),\n currentError: craftComputed('currentError', function* () {\n return (yield* exceptions()).loader;\n }),\n }),\n );\n\n return { profile };\n },\n ({ profile }) => [\n ifBlock(profile.isLoading, () => p('Loading…')),\n /* bind profile.value() or match profile.exceptions().loader here */\n ],\n);\n```\n\n`queryEffect` is a Craft query with an Effect loader. It owns cancellation,\nloading state, the last value and typed exceptions. Its `Effect` requirements are\nresolved by the active Layer. Here, `profileScenarioInput` is the reactive input\nsource: changing it reruns `loadUserProfile`; there is no `method` or manual\n`profile.call(...)` because the input drives the query.\n\n## The three result channels\n\n| Effect outcome | Craft outcome |\n| -------------------------- | -------------------------------------------------- |\n| `Effect.succeed(value)` | query value; the generator resumes with `value` |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` |\n| `Effect.die(defect)` | technical resource error, not a business exception |\n\nInterruption is cancellation. It does not become a user-facing exception.\n\nHandle typed errors exhaustively with `matchBlock.exhaustive` or with a route\nexception handler:\n\n```typescript\nmatchBlock.exhaustive(profile.exception, '_tag', {\n UserNotFound: () => p('No profile matches that user.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nWhen the Effect is used in a route guard or resolver directly, prefer\n`yield* runEffect(program)`. A bare `yield* program` executes at runtime but\ndoes not advertise `E` to Craft's compile-time route exception analysis.\n\n## Reactive Effect computations\n\nWhen the Effect itself is the value derived from Craft dependencies, use\n`computedEffect`:\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst profile =\n yield *\n computedEffect('profile', function* () {\n const userId = yield* currentUserId();\n return loadUserProfile(userId);\n });\n```\n\n`computedEffect` reruns the factory when a Craft dependency changes, executes\nthe returned Effect with the nearest `provideLayer(...)`, and exposes the same\nresource lifecycle as a query: `value`, `status`, `isLoading`, cancellation and\ntyped exceptions. It is useful for a derived Effect value; use `queryEffect`\nwhen the input/loader boundary and query cache semantics are the important\npart of the feature.\n\n## Synchronous params and methods\n\nThe `params` factory remains synchronous: it may read Craft dependencies, but it\nmust not construct an Effect or yield an Effect service. Use `computedEffect`\nfor an asynchronous derivation. A `method` only maps its arguments to params;\nthe loader is the only Effect-aware callback:\n\n```typescript\nconst profile =\n yield *\n queryEffect('profile', {\n params: function* () {\n const input = yield* currentUserInput();\n return resolveProfileParams(input);\n },\n loader: ({ params }) => loadUserProfile(params),\n });\n\nconst profileByMethod =\n yield *\n queryEffect('profileByMethod', {\n method: (input: UserInput) => resolveProfileParams(input),\n loader: ({ params }) => loadUserProfile(params),\n });\n```\n\nThe Effect ESLint rule rejects Effect values and Effect service reads inside\n`params`, methods, `craftComputed(...)`, and `craftEffect(...)`, keeping the\nquery boundary synchronous and deterministic. Only the loader may return an\nEffect.\n\nFor purely synchronous local state, use native Craft values and `state`; there\nis intentionally no `stateEffect`:\n\n```typescript\nconst request = yield * state('request', 'support');\n```\n\nUse Effect for computations, I/O and service dependencies.\n\n## What you gained\n\nEffect's typed result becomes a reactive Craft resource without a manual\nsubscription or signal conversion.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 3. Put the domain in Effect](/learn-effect/03-effect-domain)\n\n[5. Write data with Effect →](/learn-effect/05-write-data)\n\n</div>\n"
620
+ "body": "# 4. Load data with Effect\n\n**Goal:** expose an `Effect<A, E, R>` as a Craft query.\n\n## The operation being loaded\n\n`queryEffect` receives a domain function that returns an Effect. Here is the\n`loadUserProfile` used by the query below; the data is mocked so the example\ncan show each result channel:\n\n```typescript\n// profile-domain.ts\nimport { Data, Effect } from 'effect';\n\nexport type ProfileScenario =\n | 'success'\n | 'not-found'\n | 'session-expired'\n | 'database-down';\n\ntype Profile = { readonly name: string };\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\nexport function loadUserProfile(scenario: ProfileScenario) {\n return Effect.gen(function* () {\n // Simulate the latency of a backend request.\n yield* Effect.sleep('400 millis');\n\n switch (scenario) {\n case 'not-found':\n return yield* new UserNotFound({ userId: 'user-404' });\n case 'session-expired':\n return yield* new Unauthorized({ reason: 'session expired' });\n case 'database-down':\n return yield* Effect.die(new Error('database unavailable'));\n case 'success':\n return { name: 'Ada Lovelace' } satisfies Profile;\n }\n });\n}\n```\n\n`loadUserProfile` does not run when it is declared. It returns an\n`Effect<Profile, UserNotFound | Unauthorized>`, which represents a backend\nrequest and which the query runs whenever its parameters trigger the loader.\n\n## `queryEffect`\n\nThe adapter has the same lifecycle as `query`, but its loader returns an Effect:\n\n```typescript\nimport {\n type Input,\n craftComponent,\n ifNode,\n matchNode,\n p,\n} from '@craft-ts/component';\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile, type ProfileScenario } from './profile-domain';\n\nconst Profile = craftComponent(\n 'Profile',\n {},\n function* (profileScenarioInput: Input<ProfileScenario>) {\n const profile = yield* queryEffect(\n 'profile',\n {\n params: profileScenarioInput,\n loader: ({ params }) => loadUserProfile(params),\n },\n ({ resource, exceptions }) => ({\n hasProfile: craftComputed('hasProfile', () => resource.hasValue()),\n currentError: craftComputed('currentError', function* () {\n return (yield* exceptions()).loader;\n }),\n }),\n );\n\n return { profile };\n },\n ({ profile }) => [\n ifNode(profile.isLoading, () => p('Loading…')),\n /* bind profile.value() or match profile.exceptions().loader here */\n ],\n);\n```\n\n`queryEffect` is a Craft query with an Effect loader. It owns cancellation,\nloading state, the last value and typed exceptions. Its `Effect` requirements are\nresolved by the active Layer. Here, `profileScenarioInput` is the reactive input\nsource: changing it reruns `loadUserProfile`; there is no `method` or manual\n`profile.call(...)` because the input drives the query.\n\n## The three result channels\n\n| Effect outcome | Craft outcome |\n| -------------------------- | -------------------------------------------------- |\n| `Effect.succeed(value)` | query value; the generator resumes with `value` |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` |\n| `Effect.die(defect)` | technical resource error, not a business exception |\n\nInterruption is cancellation. It does not become a user-facing exception.\n\nHandle typed errors exhaustively with `matchNode.exhaustive` or with a route\nexception handler:\n\n```typescript\nmatchNode.exhaustive(profile.exception, '_tag', {\n UserNotFound: () => p('No profile matches that user.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nWhen the Effect is used in a route guard or resolver directly, prefer\n`yield* runEffect(program)`. A bare `yield* program` executes at runtime but\ndoes not advertise `E` to Craft's compile-time route exception analysis.\n\n## Reactive Effect computations\n\nWhen the derived value comes from an Effect that **cannot suspend**, use\n`computedEffect`. It is the Effect counterpart of `craftComputed`, and the\nsymmetry is the contract:\n\n```\ncraftComputed : computedEffect :: query : queryEffect\n```\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst totalLabel = computedEffect('totalLabel', function* () {\n const lines = yield* cartLines();\n return cartTotalLabel(lines); // returns the Effect, never runs it\n});\n```\n\nThe factory reads Craft dependencies with `yield*` and **returns** an Effect;\n`computedEffect` runs it in place against the nearest `provideLayer(...)`. The\nresult is a plain reactive value no `value`, no `isLoading`, no `settled(...)`,\nno `pendingNode`. Read it like any `craftComputed`.\n\nWhich is why the Effect must be declared synchronous with\n[`SyncOp`](/learn-effect/03-effect-domain#declare-a-synchronous-member). A\ncomputation is asked for its value now and cannot suspend to produce it, so an\nEffect whose `R` does not carry `SyncOp` is refused at the call site:\n\n```typescript\ncomputedEffect('profile', function* () {\n const userId = yield* currentUserId();\n return loadUserProfile(userId); // ✗ hits the network\n // ^ Argument of type 'Effect<Profile, …, UserRepository>' is not\n // assignable to '… & NotDeclaredSynchronous<UserRepository>'\n});\n```\n\nThat is not a gap: the suspending case is what `queryEffect` is for. A typed\nfailure remains fine — failing is not suspending, and it travels on Craft's\nexception channel.\n\n## Synchronous params and methods\n\nThe `params` factory remains synchronous: it may read Craft dependencies, and it\nmay run a declared-synchronous Effect through `syncEffect(...)`, but it must\nnever construct a suspending Effect move that to the loader. A `method` only\nmaps its arguments to params; the loader is the only callback allowed to\nsuspend:\n\n```typescript\nconst profile =\n yield *\n queryEffect('profile', {\n params: function* () {\n const input = yield* currentUserInput();\n return resolveProfileParams(input);\n },\n loader: ({ params }) => loadUserProfile(params),\n });\n\nconst profileByMethod =\n yield *\n queryEffect('profileByMethod', {\n method: (input: UserInput) => resolveProfileParams(input),\n loader: ({ params }) => loadUserProfile(params),\n });\n```\n\nThe Effect ESLint rule rejects Effect values and Effect service reads inside\n`params`, methods, `craftComputed(...)`, and `craftEffect(...)`, keeping the\nquery boundary synchronous and deterministic. Only the loader may return an\nEffect.\n\nFor purely synchronous local state, use native Craft values and `state`; there\nis intentionally no `stateEffect`:\n\n```typescript\nconst request = yield * state('request', 'support');\n```\n\nUse Effect for computations, I/O and service dependencies.\n\n## What you gained\n\nEffect's typed result becomes a reactive Craft resource without a manual\nsubscription or signal conversion.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 3. Put the domain in Effect](/learn-effect/03-effect-domain)\n\n[5. Write data with Effect →](/learn-effect/05-write-data)\n\n</div>\n"
526
621
  },
527
622
  {
528
623
  "path": "/learn-effect/05-write-data",
@@ -532,7 +627,7 @@
532
627
  {
533
628
  "path": "/learn-effect/06-layers-routing",
534
629
  "title": "6. Provide Layers and route the app",
535
- "body": "# 6. Provide Layers and route the app\n\n**Goal:** make Effect requirements explicit at the same scopes as your Craft\ninjectors.\n\n## Provide one application Layer\n\n`provideLayer` builds an Effect context and stores it on the Craft injector:\n\n```typescript\nimport { Layer } from 'effect';\nimport { provideLayer } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive)),\n ],\n});\n```\n\nThe Layer is built once at that injector level. Child injectors reuse the parent\ncontext and add their own services.\n\n## Add a route Layer\n\nKeep route providers in a named tuple so the compile-time proof can inspect it:\n\n```typescript\nconst teamRouteProviders = [\n provideLayer(SupportTeamLive),\n] as const;\n\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(\n () => import('./team').then(({ default: component }) => component),\n teamRouteProviders,\n ),\n },\n]);\n```\n\nThe team query can require `SessionService | TeamContextService` while the\ncomponent only sees `TeamOverview`. Route-scoped resources are closed when the\nroute injector is destroyed, so Layer scopes do not leak across navigation.\n\n## Layer scopes follow Craft provider scopes\n\n`provideLayer(...)` is a normal Craft provider, so the same Effect context can\nbe attached at every Craft scope that accepts providers:\n\n| Scope | Where to put `provideLayer(...)` | Lifetime and visibility |\n| --- | --- | --- |\n| Application | `appConfig.providers` | shared by the whole application |\n| Route | the route's `providers` array | shared by that route and its children |\n| Component | `craftComponent` meta `providers` | limited to that component subtree |\n| Primitive | a primitive config's `providers` | limited to that primitive |\n| Insertion | the containing primitive's `providers` | inherited by its insertion callbacks and methods |\n\nFor example, a component or a primitive can provide a local implementation\nwithout changing the application Layer:\n\n```typescript\nconst Profile = craftComponent(\n 'Profile',\n { providers: [provideLayer(AccessPolicyLive)] },\n /* … */\n);\n\nconst profile = yield* queryEffect('profile', {\n providers: [provideLayer(AccessPolicyLive)],\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n```\n\nAn insertion receives the primitive's injector, so its generators and methods\nsee the primitive's Layer as well. There is no separate `provideLayer` argument\non an insertion today; use the containing primitive's `providers` to scope it.\nIf two services must be provided at the same scope, merge them into one Layer:\n\n```typescript\nproviders: [provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive))]\n```\n\nChild scopes inherit the parent context and can add a more local implementation\nof a service. Their scopes are closed with the corresponding Craft injector.\n\n## Prove requirements at compile time\n\nEffect requirements are not regular Craft services, so add an explicit proof:\n\n```typescript\nimport type { Effect } from 'effect';\nimport type { AppProvidedDependencyValuesOf, CanRun } from '@craft-ts/core';\nimport type {\n EffectRequirementsCheckedDI,\n ProvidedEffectServicesOf,\n} from '@craft-ts/effect';\n\ntype AppProvidedEffectServices = AppProvidedDependencyValuesOf<\n typeof appConfig\n>;\n\ntype CheckTeam = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices | ProvidedEffectServicesOf<typeof teamRouteProviders>\n>;\ntype CanRunTeam = CanRun<CheckTeam>;\n```\n\nRemove `SupportTeamLive` and `CanRunTeam` becomes a useful type error naming the\nmissing Effect service. This is the Effect equivalent of Craft's `RouteCheckedDI`.\n\n## Route errors are still exhaustive\n\n`queryEffect` and `runEffect` make tagged Effect errors visible to Craft's route\nexception analysis. Keep the route map exhaustive:\n\n```typescript\nconst { routes } = craftRoutes('app', [\n {\n path: '',\n ...loadCraftComponent(() => import('./profile')),\n handleExceptions: {\n UserNotFound: craftExceptionHandler(/* … */),\n Unauthorized: craftExceptionHandler(/* … */),\n },\n },\n]);\n\nassertExhaustiveRouteExceptions(routes);\n```\n\n## Keep URL state in Craft\n\nURL state is a UI concern, so it stays a native `queryParams` primitive even in\nan Effect application:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst Search = Schema.String;\nconst searchCodec = {\n decode: Schema.decodeUnknownSync(Search),\n encode: Schema.encodeSync(Search),\n};\n\nconst filters = yield* queryParams('filters', {\n state: {\n search: {\n fallbackValue: '',\n codec: searchCodec,\n },\n },\n});\n\nconst users = yield* queryEffect('users', {\n params: () => filters(),\n loader: ({ params }) => searchUsers(params),\n});\n```\n\nThe codec remains synchronous, as required by `queryParams`, but validation and\nencoding now come from an Effect `Schema`. Replace `Schema.String` with a\ntransformation schema when the URL representation differs from the value used\nby the component.\n\nThere is no `queryParamsEffect`: Craft synchronises the URL, while the Effect\nloader reacts to the resulting typed params.\n\n## What you gained\n\nEffect Layers now follow Craft's app and route scopes, their requirements are\nchecked, and typed Effect failures cannot silently disappear at a route.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 5. Write data with Effect](/learn-effect/05-write-data)\n\n[7. Build forms and validate boundaries →](/learn-effect/07-forms-validation)\n\n</div>\n"
630
+ "body": "# 6. Provide Layers and route the app\n\n**Goal:** make Effect requirements explicit at the same scopes as your Craft\ninjectors.\n\n## Provide one application Layer\n\n`provideLayer` builds an Effect context and stores it on the Craft injector:\n\n```typescript\nimport { Layer } from 'effect';\nimport { provideLayer } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive)),\n ],\n});\n```\n\nThe Layer is built once at that injector level. Child injectors reuse the parent\ncontext and add their own services.\n\n## Add a route Layer\n\nInline route providers in `loadCraftComponent(...)`; the compile-time proof\npreserves and inspects the tuple from the typed route collection:\n\n```typescript\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(\n () => import('./team').then(({ default: component }) => component),\n [provideLayer(SupportTeamLive)] as const,\n ),\n },\n]);\n```\n\nThe team query can require `SessionService | TeamContextService` while the\ncomponent only sees `TeamOverview`. Route-scoped resources are closed when the\nroute injector is destroyed, so Layer scopes do not leak across navigation.\n\n## Layer scopes follow Craft provider scopes\n\n`provideLayer(...)` is a normal Craft provider, so the same Effect context can\nbe attached at every Craft scope that accepts providers:\n\n| Scope | Where to put `provideLayer(...)` | Lifetime and visibility |\n| --- | --- | --- |\n| Application | `appConfig.providers` | shared by the whole application |\n| Route | the route's `providers` array | shared by that route and its children |\n| Component | `craftComponent` meta `providers` | limited to that component subtree |\n| Primitive | a primitive config's `providers` | limited to that primitive |\n| Insertion | the containing primitive's `providers` | inherited by its insertion callbacks and methods |\n\nFor example, a component or a primitive can provide a local implementation\nwithout changing the application Layer:\n\n```typescript\nconst Profile = craftComponent(\n 'Profile',\n { providers: [provideLayer(AccessPolicyLive)] },\n /* … */\n);\n\nconst profile = yield* queryEffect('profile', {\n providers: [provideLayer(AccessPolicyLive)],\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n```\n\nAn insertion receives the primitive's injector, so its generators and methods\nsee the primitive's Layer as well. There is no separate `provideLayer` argument\non an insertion today; use the containing primitive's `providers` to scope it.\nIf two services must be provided at the same scope, merge them into one Layer:\n\n```typescript\nproviders: [provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive))]\n```\n\nChild scopes inherit the parent context and can add a more local implementation\nof a service. Their scopes are closed with the corresponding Craft injector.\n\n## Prove requirements at compile time\n\nEffect requirements are not regular Craft services, so add an explicit proof:\n\n```typescript\nimport type { Effect } from 'effect';\nimport type { AppProvidedDependencyValuesOf, CanRun } from '@craft-ts/core';\nimport type {\n EffectRequirementsCheckedDI,\n ProvidedEffectServicesOfRoute,\n} from '@craft-ts/effect';\n\ntype AppProvidedEffectServices = AppProvidedDependencyValuesOf<\n typeof appConfig\n>;\n\ntype CheckTeam = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices |\n ProvidedEffectServicesOfRoute<typeof routes._routes, 'team'>\n>;\ntype CanRunTeam = CanRun<CheckTeam>;\n```\n\nRemove `SupportTeamLive` and `CanRunTeam` becomes a useful type error naming the\nmissing Effect service. This is the Effect equivalent of Craft's `RouteCheckedDI`.\n\n## Route errors are still exhaustive\n\n`queryEffect` and `runEffect` make tagged Effect errors visible to Craft's route\nexception analysis. Keep the route map exhaustive:\n\n```typescript\nconst { routes } = craftRoutes('app', [\n {\n path: '',\n ...loadCraftComponent(() => import('./profile')),\n handleExceptions: {\n UserNotFound: craftExceptionHandler(/* … */),\n Unauthorized: craftExceptionHandler(/* … */),\n },\n },\n]);\n\nassertExhaustiveRouteExceptions(routes);\n```\n\n## Keep URL state in Craft\n\nURL state is a UI concern, so it stays a native `queryParams` primitive even in\nan Effect application:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst Search = Schema.String;\nconst searchCodec = {\n decode: Schema.decodeUnknownSync(Search),\n encode: Schema.encodeSync(Search),\n};\n\nconst filters = yield* queryParams('filters', {\n state: {\n search: {\n fallbackValue: '',\n codec: searchCodec,\n },\n },\n});\n\nconst users = yield* queryEffect('users', {\n params: () => filters(),\n loader: ({ params }) => searchUsers(params),\n});\n```\n\nThe codec remains synchronous, as required by `queryParams`, but validation and\nencoding now come from an Effect `Schema`. Replace `Schema.String` with a\ntransformation schema when the URL representation differs from the value used\nby the component.\n\nThere is no `queryParamsEffect`: Craft synchronises the URL, while the Effect\nloader reacts to the resulting typed params.\n\n## What you gained\n\nEffect Layers now follow Craft's app and route scopes, their requirements are\nchecked, and typed Effect failures cannot silently disappear at a route.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 5. Write data with Effect](/learn-effect/05-write-data)\n\n[7. Build forms and validate boundaries →](/learn-effect/07-forms-validation)\n\n</div>\n"
536
631
  },
537
632
  {
538
633
  "path": "/learn-effect/07-forms-validation",
@@ -552,12 +647,12 @@
552
647
  {
553
648
  "path": "/learn/01-first-state",
554
649
  "title": "1. Your first state",
555
- "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 each(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 `each(...)` 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"
650
+ "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"
556
651
  },
557
652
  {
558
653
  "path": "/learn/02-derive",
559
654
  "title": "2. Derive instead of duplicate",
560
- "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 each,\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 each(\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. `each(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| `each` | Renders a collection with stable tracking and an optional empty branch |\n| `ifBlock` | Preserves a conditional branch in the render contract |\n| `matchBlock.exhaustive` | Matches every member of a discriminated union |\n| `defer` | Loads a branch lazily |\n\n`matchBlock.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\nmatchBlock.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`ifBlock` and `matchBlock` 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 `ifBlock` or `matchBlock`.\n:::\n\n`ifBlock` 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 catchBlock.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\n`catchBlock.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`matchBlock.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"
655
+ "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"
561
656
  },
562
657
  {
563
658
  "path": "/learn/03-service",
@@ -572,7 +667,7 @@
572
667
  {
573
668
  "path": "/learn/05-load-data",
574
669
  "title": "5. Load server data",
575
- "body": "# 5. Load server data\n\n**Goal:** replace the hand-rolled `load()` from step 4 with `query`, and get\nloading, error and exception state for free.\n\n## The query primitive\n\n\n\nThree things to read here.\n\n**`params`** is reactive. When what it returns changes, the loader re-runs. It\ncan be a signal, a function, or a generator that yields other services.\n\n**`loader`** is a generator, so it can `yield*` — here `CraftHttpClient`, which\nis the craft-tracked HTTP client. A plain `async` function works too when there\nis nothing to yield.\n\n**The result** is a ref carrying the full async state:\n\n```typescript\ntasksQuery.value(); // Task[] | undefined — never throws\ntasksQuery.isLoading(); // boolean\ntasksQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ntasksQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## In the template\n\n`ifBlock` / `matchBlock` are the structural conditionals (see [step\n2](/learn/02-derive#control-flow)). For a first pass a\nternary chain reads fine — just remember it makes the branch invisible to the\n[type-level assertions](/guide/testing/type-level):\n\n```typescript\nimport { craftComponent, each, li, p, ul } from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* TaskList();\n return { tasks };\n },\n ({ tasks }) =>\n tasks.isLoading()\n ? p('Loading…')\n : tasks.hasException()\n ? p('Could not load tasks.')\n : ul(\n each(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n);\n```\n\nWhen the branches depend on an exception **code** rather than a boolean, reach\nfor `matchBlock.exhaustive(...)` — the compiler then checks you covered every\ncode:\n\n```typescript\nmatchBlock.exhaustive(() => tasks.exceptions().loader, '_tag', {\n TASKS_FORBIDDEN: () => p('You do not have access to this list.'),\n TASKS_NOT_FOUND: () => p('This list no longer exists.'),\n});\n```\n\nSee [Exceptions as values](/guide/concepts/exceptions).\n\n## Triggering it yourself\n\n`params` re-runs the loader automatically. When the trigger is a user action\ninstead, use `method`:\n\n\n\n## Adding derived values\n\nSame insertion mechanism as step 2 — third argument:\n\n```typescript\nconst { tasksQuery } =\n yield *\n query(\n 'tasksQuery',\n {\n /* … */\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* tasksQuery.count();\n```\n\n## About the flicker\n\nThere isn't one: when `params` change, the previous value stays on screen until\nthe new one resolves. That is the **default**, so paginating never blanks the\nlist.\n\nIf you actually want the value cleared while loading, opt out explicitly:\n\n```typescript\nquery('tasksQuery', {\n params: () => ({ page: page() }),\n preservePreviousValue: () => false,\n loader: /* … */,\n});\n```\n\n## What you gained\n\nServer state with the same shape as local state — named, insertable, tracked —\nand no manual `isLoading` flag.\n\n::: details Beyond the basics\nParallel queries per identifier, business exceptions raised from `params`, typed\nHTTP exception matchers, and reacting to mutations are all on\n[query](/guide/state/server-state).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 4. Compose services](/learn/04-compose)\n\n[6. Write server data →](/learn/06-mutate-data)\n\n</div>\n"
670
+ "body": "# 5. Load server data\n\n**Goal:** replace the hand-rolled `load()` from step 4 with `query`, and get\nloading, error and exception state for free.\n\n## The query primitive\n\n\n\nThree things to read here.\n\n**`params`** is reactive. When what it returns changes, the loader re-runs. It\ncan be a signal, a function, or a generator that yields other services.\n\n**`loader`** is a generator, so it can `yield*` — here `CraftHttpClient`, which\nis the craft-tracked HTTP client. A plain `async` function works too when there\nis nothing to yield.\n\n**The result** is a ref carrying the full async state:\n\n```typescript\ntasksQuery.value(); // Task[] | undefined — never throws\ntasksQuery.isLoading(); // boolean\ntasksQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ntasksQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## In the template\n\n`ifNode` / `matchNode` are the structural conditionals (see [step\n2](/learn/02-derive#control-flow)). For a first pass a\nternary chain reads fine — just remember it makes the branch invisible to the\n[type-level assertions](/guide/testing/type-level):\n\n```typescript\nimport { craftComponent, forNode, li, p, ul } from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* TaskList();\n return { tasks };\n },\n ({ tasks }) =>\n tasks.isLoading()\n ? p('Loading…')\n : tasks.hasException()\n ? p('Could not load tasks.')\n : ul(\n forNode(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n);\n```\n\nWhen the branches depend on an exception **code** rather than a boolean, reach\nfor `matchNode.exhaustive(...)` — the compiler then checks you covered every\ncode:\n\n```typescript\nmatchNode.exhaustive(() => tasks.exceptions().loader, '_tag', {\n TASKS_FORBIDDEN: () => p('You do not have access to this list.'),\n TASKS_NOT_FOUND: () => p('This list no longer exists.'),\n});\n```\n\nSee [Exceptions as values](/guide/concepts/exceptions).\n\n## Triggering it yourself\n\n`params` re-runs the loader automatically. When the trigger is a user action\ninstead, use `method`:\n\n\n\n## Adding derived values\n\nSame insertion mechanism as step 2 — third argument:\n\n```typescript\nconst { tasksQuery } =\n yield *\n query(\n 'tasksQuery',\n {\n /* … */\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* tasksQuery.count();\n```\n\n## About the flicker\n\nThere isn't one: when `params` change, the previous value stays on screen until\nthe new one resolves. That is the **default**, so paginating never blanks the\nlist.\n\nIf you actually want the value cleared while loading, opt out explicitly:\n\n```typescript\nquery('tasksQuery', {\n params: () => ({ page: page() }),\n preservePreviousValue: () => false,\n loader: /* … */,\n});\n```\n\n## What you gained\n\nServer state with the same shape as local state — named, insertable, tracked —\nand no manual `isLoading` flag.\n\n::: details Beyond the basics\nParallel queries per identifier, business exceptions raised from `params`, typed\nHTTP exception matchers, and reacting to mutations are all on\n[query](/guide/state/server-state).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 4. Compose services](/learn/04-compose)\n\n[6. Write server data →](/learn/06-mutate-data)\n\n</div>\n"
576
671
  },
577
672
  {
578
673
  "path": "/learn/06-mutate-data",
@@ -597,7 +692,7 @@
597
692
  {
598
693
  "path": "/learn/10-testing",
599
694
  "title": "10. Test what you wrote",
600
- "body": "# 10. Test what you wrote\n\n**Goal:** test `TaskList` and the `Tasks` component without guessing what to\nmock — the dependency graph tells you.\n\n## The idea\n\nMost test setups let you forget a dependency and find out at runtime. Craft\ninverts it: you pass a **register** covering the whole graph, and the compiler\nrefuses to run the test until every node is accounted for.\n\nEach node is one of four things: `'real'`, its own `provideX(...)`, a mock\nobject, or `'notReached'`.\n\n## Testing a service\n\nHere is the service under test — the one from [step 4](/learn/04-compose), with\nits scope changed to `toProvide` so it has a `provideTaskStats()` to mount in\nthe test:\n\n\n\nIt depends on one thing, `TaskList`, and exposes one thing, `done`. The test\nmirrors that exactly:\n\n\n\n`sut` is the service under test; `mocks` gives you back the mocks you supplied,\nalready typed, so `mocks.TaskList.$self` is assertable.\n\n::: tip Which register entry to use\n`provideX()` for a `toProvide` or `manuallyProvidedAtRoot` service, `'real'` for\na reachable `global` or `function` one, a plain object to mock it, and\n`'notReached'` for a branch this test never touches.\n:::\n\n`$self` is the service's own returned value — the ref itself, as opposed to a\nproperty hanging off it.\n\n## Why the register is small\n\nBecause of step 4. `TaskStats` yielded only what it needed, so the register only\nasks for that. Had it yielded the whole `TaskApi`, the register would demand\n`TaskApi` too. **Precise yields make short tests** — that's the payoff for the\n`yield*` discipline.\n\n## Testing a component\n\nHere is the component under test, from steps 2 and 3 — a factory that yields\n`TaskList`, and a template that renders it:\n\n\n\nThose two halves are tested **independently**: the factory produces a context\nwithout touching the DOM, and the template renders a context without running the\nfactory.\n\nThe logic test runs the factory only — no DOM:\n\n\n\nThe template test does the opposite — it renders with a context you hand it, and\nnever runs the factory:\n\n\n\nThat separation is why component tests stay fast: you only pay for the DOM when\nthe DOM is what you're asserting on.\n\n## Finding elements\n\nTemplate tests expose `locator(tag, criteria)` rather than raw CSS selectors:\n\n```typescript\nconst removeButton = test.locator('button', { 'data-testid': 'remove' });\nremoveButton?.click();\n```\n\n## Proving it at the type level\n\nSome of what craft guarantees isn't observable at runtime at all — it's in the\ntypes. Those get their own kind of test, resolved by the compiler with no\n`TestBed`, no DOM and no factory:\n\n```typescript\ntype TasksTemplateTest = SetupTestComponentTemplate<typeof Tasks, [typeof TaskRow]>;\n```\n\nThe resolver walks elements, directives, `each`, `defer` and child components,\nand a child missing from the tuple becomes a type diagnostic. Companion\nassertions — `TemplateHasElement`, `TemplateHasElementWithProps`,\n`TemplateHasYieldableEvent`, `TemplateRendersStateWhen` — check that the template\nreally renders what you think, including event argument types.\n\nThis is how you pin down a template contract that a runtime test would only\ncatch by accident. Full reference:\n[Type-level tests](/guide/testing/type-level).\n\n## Tests that stay close to reality\n\nMocking everything makes tests that pass while the app is broken. `boundaryOnly`\nkeeps the real graph and lets you replace only what actually touches the outside\nworld — the services marked `browserBoundary: true` (HTTP, storage, location):\n\n```typescript\nconst { sut } = await setupCraftServiceTestingByRegister(TaskList, register, {\n boundaryOnly: true,\n});\n```\n\nEverything in between runs for real. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Architecture of the whole app\n\nThe register proves one service's graph is complete. Architecture rules prove\ninvariants **across** services: this feature must not depend on that one, this\nHTTP endpoint is owned once, this `craftUnique` storage key appears once.\n\nThey live next to `e2e/`, analyze TypeScript without starting the application, and are\nordinary Vitest assertions on a typed graph. Look a node up, walk its edges,\nassert. A precise rule — HTTP may only be called from a `browserBoundary`\nservice — is an `it()`:\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\nAnything you can see on the graph is a rule you can write: folder lanes,\nexclusive feature branches, a method that must not both be called and write a\n`source$`. Built-in helpers cover unique `craftUnique` identities, unique HTTP\nverb+URL, pure `craftComputed`, no `depends-on` cycles, `assertPathBoundaries`,\n`noExclusiveLink`, `assertMutationHasReactOn`, `assertPersistedPrimitiveHasUnique`,\n`assertInsertSelectUnique`, `assertCraftEffectNoNetwork`,\n`assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, and the route DI\nproofs from [step 9](/learn/09-routing).\n\nThose proofs (`CanRun`, `RouteCheckedDI`) are unused type aliases — omit one\nand the project still compiles. `assertRouteDiProofs` fails the suite unless\nevery routed component and every `app.config` error screen stays hooked to an\narmed mapper. TypeScript still judges injection; the architecture suite judges\nwhether that judgement was invoked.\n\nFull setup: [Architecture rules](/guide/testing/architecture). Why that graph\nis not Nx's project graph: [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx).\nThe demo app already imports the helpers. From the repository root:\n\n```shell\nnpx nx architecture demo\n```\n\n## What you gained\n\nTests whose setup is derived from the real dependency graph, so \"I forgot to\nmock that\" becomes a compile error — and architecture rules on that same graph,\nso the app can be taught its boundaries.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 9. Wire up routing](/learn/09-routing)\n\n[Where to go next →](/learn/next)\n\n</div>\n"
695
+ "body": "# 10. Test what you wrote\n\n**Goal:** test `TaskList` and the `Tasks` component without guessing what to\nmock — the dependency graph tells you.\n\n## The idea\n\nMost test setups let you forget a dependency and find out at runtime. Craft\ninverts it: you pass a **register** covering the whole graph, and the compiler\nrefuses to run the test until every node is accounted for.\n\nEach node is one of four things: `'real'`, its own `provideX(...)`, a mock\nobject, or `'notReached'`.\n\n## Testing a service\n\nHere is the service under test — the one from [step 4](/learn/04-compose), with\nits scope changed to `toProvide` so it has a `provideTaskStats()` to mount in\nthe test:\n\n\n\nIt depends on one thing, `TaskList`, and exposes one thing, `done`. The test\nmirrors that exactly:\n\n\n\n`sut` is the service under test; `mocks` gives you back the mocks you supplied,\nalready typed, so `mocks.TaskList.$self` is assertable.\n\n::: tip Which register entry to use\n`provideX()` for a `toProvide` or `manuallyProvidedAtRoot` service, `'real'` for\na reachable `global` or `function` one, a plain object to mock it, and\n`'notReached'` for a branch this test never touches.\n:::\n\n`$self` is the service's own returned value — the ref itself, as opposed to a\nproperty hanging off it.\n\n## Why the register is small\n\nBecause of step 4. `TaskStats` yielded only what it needed, so the register only\nasks for that. Had it yielded the whole `TaskApi`, the register would demand\n`TaskApi` too. **Precise yields make short tests** — that's the payoff for the\n`yield*` discipline.\n\n## Testing a component\n\nHere is the component under test, from steps 2 and 3 — a factory that yields\n`TaskList`, and a template that renders it:\n\n\n\nThose two halves are tested **independently**: the factory produces a context\nwithout touching the DOM, and the template renders a context without running the\nfactory.\n\nThe logic test runs the factory only — no DOM:\n\n\n\nThe template test does the opposite — it renders with a context you hand it, and\nnever runs the factory:\n\n\n\nThat separation is why component tests stay fast: you only pay for the DOM when\nthe DOM is what you're asserting on.\n\n## Finding elements\n\nTemplate tests expose `locator(tag, criteria)` rather than raw CSS selectors:\n\n```typescript\nconst removeButton = test.locator('button', { 'data-testid': 'remove' });\nremoveButton?.click();\n```\n\n## Proving it at the type level\n\nSome of what craft guarantees isn't observable at runtime at all — it's in the\ntypes. Those get their own kind of test, resolved by the compiler with no\n`TestBed`, no DOM and no factory:\n\n```typescript\ntype TasksTemplateTest = SetupTestComponentTemplate<typeof Tasks, [typeof TaskRow]>;\n```\n\nThe resolver walks elements, directives, `forNode`, `deferNode` and child components,\nand a child missing from the tuple becomes a type diagnostic. Companion\nassertions — `TemplateHasElement`, `TemplateHasElementWithProps`,\n`TemplateHasYieldableEvent`, `TemplateRendersStateWhen` — check that the template\nreally renders what you think, including event argument types.\n\nThis is how you pin down a template contract that a runtime test would only\ncatch by accident. Full reference:\n[Type-level tests](/guide/testing/type-level).\n\n## Tests that stay close to reality\n\nMocking everything makes tests that pass while the app is broken. `boundaryOnly`\nkeeps the real graph and lets you replace only what actually touches the outside\nworld — the services marked `browserBoundary: true` (HTTP, storage, location):\n\n```typescript\nconst { sut } = await setupCraftServiceTestingByRegister(TaskList, register, {\n boundaryOnly: true,\n});\n```\n\nEverything in between runs for real. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Architecture of the whole app\n\nThe register proves one service's graph is complete. Architecture rules prove\ninvariants **across** services: this feature must not depend on that one, this\nHTTP endpoint is owned once, this `craftUnique` storage key appears once.\n\nThey live next to `e2e/`, analyze TypeScript without starting the application, and are\nordinary Vitest assertions on a typed graph. Look a node up, walk its edges,\nassert. A precise rule — HTTP may only be called from a `browserBoundary`\nservice — is an `it()`:\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\nAnything you can see on the graph is a rule you can write: folder lanes,\nexclusive feature branches, a method that must not both be called and write a\n`source$`. Built-in helpers cover unique `craftUnique` identities, unique HTTP\nverb+URL, pure `craftComputed`, no `depends-on` cycles, `assertPathBoundaries`,\n`noExclusiveLink`, `assertMutationHasReactOn`, `assertPersistedPrimitiveHasUnique`,\n`assertInsertSelectUnique`, `assertCraftEffectNoNetwork`,\n`assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, and the route DI\nproofs from [step 9](/learn/09-routing).\n\nThose proofs (`CanRun`, `RouteCheckedDI`) are unused type aliases — omit one\nand the project still compiles. `assertRouteDiProofs` fails the suite unless\nevery routed component and every `app.config` error screen stays hooked to an\narmed mapper. TypeScript still judges injection; the architecture suite judges\nwhether that judgement was invoked.\n\nFull setup: [Architecture rules](/guide/testing/architecture). Why that graph\nis not Nx's project graph: [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx).\nThe demo app already imports the helpers. From the repository root:\n\n```shell\nnpx nx architecture demo\n```\n\n## What you gained\n\nTests whose setup is derived from the real dependency graph, so \"I forgot to\nmock that\" becomes a compile error — and architecture rules on that same graph,\nso the app can be taught its boundaries.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 9. Wire up routing](/learn/09-routing)\n\n[Where to go next →](/learn/next)\n\n</div>\n"
601
696
  },
602
697
  {
603
698
  "path": "/learn/next",
@@ -612,7 +707,7 @@
612
707
  {
613
708
  "path": "/reference",
614
709
  "title": "API index",
615
- "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| `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| `fieldExceptionBlock.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| `ValidateCascadeRoutesFile`, `CanRun` | Compile-time DI check for a routes file | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI` | Per-route `O(1)` variant of the check | [Scaling routes](/guide/routing/scaling) |\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| `pendingBlock({ 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| `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`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\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"
710
+ "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| `ValidateCascadeRoutesFile`, `CanRun` | Compile-time DI check for a routes file | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI` | Per-route `O(1)` variant of the check | [Scaling routes](/guide/routing/scaling) |\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| `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`, `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| `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` has no CraftTS, Angular or Effect import; the catalogue is a\nplain TypeScript value.\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` | 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| `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| `I18nRuntimeError` | `NO_LOCALES`, `LOCALE_NOT_LOADED`, `INVALID_NUMBER`, `INVALID_DATE` | [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"
616
711
  },
617
712
  {
618
713
  "path": "/resources/ai-agents",
@@ -628,17 +723,17 @@
628
723
  {
629
724
  "path": "/resources/effect-adoption",
630
725
  "title": "Adopting CraftTS progressively",
631
- "body": "# Adopting CraftTS progressively\n\nYou do not need to rewrite an Effect application before evaluating CraftTS. Keep\nthe domain programs and Layers intact, then introduce Craft at the browser\nboundary one feature at a time.\n\n## Recommended path\n\n### 0. Establish the constraints\n\nBefore changing application code, confirm the\n[compatibility matrix](/resources/effect-compatibility). In particular, check\nthe Effect 4 release-candidate requirement, the Node version and whether SSR is\na hard requirement.\n\n### 1. Keep the domain in Effect\n\nSelect one existing operation with a clear type:\n\n```ts\nEffect<Output, BusinessError, RequiredServices>\n```\n\nKeep its `Context.Service`, `Layer`, tagged errors and tests. The first Craft\nchange should be an adapter, not a rewrite of the business logic.\n\n### 2. Add the Craft boundary\n\nInstall `@craft-ts/effect`, install the bridge once at bootstrap, and expose the\noperation through `queryEffect`, `mutationEffect` or `asyncProcessEffect`.\n\nThe component should call the domain operation. It should not resolve the\nrepository, call `Effect.runPromise`, start a fiber from a click handler or\nduplicate the domain state in a Craft `state`.\n\n### 3. Pilot one read-only feature\n\nStart with a page that has:\n\n- one query;\n- one application or route Layer;\n- one loading state;\n- one typed business error;\n- one technical error path;\n- one executable test.\n\nThis exposes the real cost of the Craft UI model without mixing in forms,\noptimistic updates or server-function transport.\n\n### 4. Add writes and forms\n\nOnce the read path is stable, add `mutationEffect`, then connect it to Craft\nforms and `insertReactOnMutation`. Keep validation responsibilities explicit:\n\n- Effect Schema or `methodSchema` validates a boundary payload;\n- Craft owns field state, validity and interaction;\n- Effect typed errors represent business rejection;\n- defects remain technical failures.\n\n### 5. Introduce route and feature scopes\n\nMove a Layer to the narrowest scope that owns it. Add the compile-time Effect\nrequirements proof for the route, and keep route providers in a named tuple so\nthe type checker can inspect them.\n\nDo this after the first feature works. The proof is valuable, but introducing\nit before the boundary is understood makes the first experiment look more\ncomplex than it is.\n\n### 6. Evaluate server functions separately\n\nTreat the current server-function integration as a separate experiment. Its\ntransport, file conventions, middleware API and deployment integration are not\nfinal. Never treat a client Layer as an authentication or authorization\nboundary; the server must verify claims again.\n\n## What can stay and what changes?\n\n| Existing Effect application asset | During a Craft pilot |\n| --- | --- |\n| Domain types and business operations | Keep |\n| Tagged errors and error unions | Keep; map at the Craft boundary |\n| `Context.Service` contracts | Keep |\n| Live and test `Layer`s | Keep; expose through `provideLayer` |\n| Effect unit tests | Keep |\n| Existing UI components and templates | Keep outside the pilot; replace only the selected Craft feature |\n| UI loading, cancellation and rendering state | Move to Craft resources |\n| URL state and form interaction | Model with Craft primitives and forms |\n\n## Go / no-go signals\n\nProceed when the pilot has a clear resource boundary, an executable Layer setup\nand tests that distinguish business errors from defects.\n\nPause and resolve the issue before expanding when:\n\n- the project is still on Effect 3 without an isolation plan;\n- SSR is mandatory but no SSR host has been selected;\n- the team cannot explain which side owns a piece of state;\n- every feature requires a custom bridge or manual subscription;\n- typecheck time or type errors make the feedback loop unacceptable.\n\n"
726
+ "body": "# Adopting CraftTS progressively\n\nYou do not need to rewrite an Effect application before evaluating CraftTS. Keep\nthe domain programs and Layers intact, then introduce Craft at the browser\nboundary one feature at a time.\n\n## Recommended path\n\n### 0. Establish the constraints\n\nBefore changing application code, confirm the\n[compatibility matrix](/resources/effect-compatibility). In particular, check\nthe Effect 4 release-candidate requirement, the Node version and whether SSR is\na hard requirement.\n\n### 1. Keep the domain in Effect\n\nSelect one existing operation with a clear type:\n\n```ts\nEffect<Output, BusinessError, RequiredServices>\n```\n\nKeep its `Context.Service`, `Layer`, tagged errors and tests. The first Craft\nchange should be an adapter, not a rewrite of the business logic.\n\n### 2. Add the Craft boundary\n\nInstall `@craft-ts/effect`, install the bridge once at bootstrap, and expose the\noperation through `queryEffect`, `mutationEffect` or `asyncProcessEffect`.\n\nThe component should call the domain operation. It should not resolve the\nrepository, call `Effect.runPromise`, start a fiber from a click handler or\nduplicate the domain state in a Craft `state`.\n\n### 3. Pilot one read-only feature\n\nStart with a page that has:\n\n- one query;\n- one application or route Layer;\n- one loading state;\n- one typed business error;\n- one technical error path;\n- one executable test.\n\nThis exposes the real cost of the Craft UI model without mixing in forms,\noptimistic updates or server-function transport.\n\n### 4. Add writes and forms\n\nOnce the read path is stable, add `mutationEffect`, then connect it to Craft\nforms and `insertReactOnMutation`. Keep validation responsibilities explicit:\n\n- Effect Schema or `methodSchema` validates a boundary payload;\n- Craft owns field state, validity and interaction;\n- Effect typed errors represent business rejection;\n- defects remain technical failures.\n\n### 5. Introduce route and feature scopes\n\nMove a Layer to the narrowest scope that owns it. Add the compile-time Effect\nrequirements proof for the route, and inline single-use route providers so the\ntyped route collection can preserve them for the type checker.\n\nDo this after the first feature works. The proof is valuable, but introducing\nit before the boundary is understood makes the first experiment look more\ncomplex than it is.\n\n### 6. Evaluate server functions separately\n\nTreat the current server-function integration as a separate experiment. Its\ntransport, file conventions, middleware API and deployment integration are not\nfinal. Never treat a client Layer as an authentication or authorization\nboundary; the server must verify claims again.\n\n## What can stay and what changes?\n\n| Existing Effect application asset | During a Craft pilot |\n| --- | --- |\n| Domain types and business operations | Keep |\n| Tagged errors and error unions | Keep; map at the Craft boundary |\n| `Context.Service` contracts | Keep |\n| Live and test `Layer`s | Keep; expose through `provideLayer` |\n| Effect unit tests | Keep |\n| Existing UI components and templates | Keep outside the pilot; replace only the selected Craft feature |\n| UI loading, cancellation and rendering state | Move to Craft resources |\n| URL state and form interaction | Model with Craft primitives and forms |\n\n## Go / no-go signals\n\nProceed when the pilot has a clear resource boundary, an executable Layer setup\nand tests that distinguish business errors from defects.\n\nPause and resolve the issue before expanding when:\n\n- the project is still on Effect 3 without an isolation plan;\n- SSR is mandatory but no SSR host has been selected;\n- the team cannot explain which side owns a piece of state;\n- every feature requires a custom bridge or manual subscription;\n- typecheck time or type errors make the feedback loop unacceptable.\n"
632
727
  },
633
728
  {
634
729
  "path": "/resources/effect-compatibility",
635
730
  "title": "Effect compatibility and maturity",
636
- "body": "# Effect compatibility and maturity\n\nThis page describes the current repository contract. It is a decision aid for\nteams evaluating CraftTS, not a promise that beta APIs will remain unchanged.\n\n## Compatibility matrix\n\n| Area | Current contract | Status |\n| --- | --- | --- |\n| Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta |\n| Craft components | `@craft-ts/component` on the same Craft version | Beta |\n| Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration |\n| Effect runtime | `effect` `^4.0.0-rc.110` | Effect 4 release candidate |\n| Effect 3 projects | No compatibility contract | Migrate or isolate before adopting |\n| Node.js | 20.19+ or 22.12+ | Required by the current docs |\n| TypeScript | Use the version supported by the selected Craft beta; verify with the project lockfile | Toolchain-sensitive |\n| Browser application | Vite demo and jsdom tests are covered | Experimental but executable |\n| SSR | No product SSR renderer in this release | Not ready |\n| Server functions | Local transport and middleware experiment | Proof of concept |\n| Migration tooling | `craft-migrate` for Craft concepts | No complete Effect-specific migration |\n\nInstall all Craft packages from the same beta channel. `@craft-ts/effect` also\ndeclares `effect` as a peer dependency, so the Effect version is part of the\napplication's compatibility surface.\n\n## Maturity by capability\n\n| Capability | What is covered today | Adoption guidance |\n| --- | --- | --- |\n| Effect domain programs | `Effect`, tagged errors, `Context.Service`, `Layer` | Good candidate for a pilot |\n| Effect-backed reads and writes | `queryEffect`, `mutationEffect`, `computedEffect`, `asyncProcessEffect` | Pilot with real tests and a narrow feature |\n| Layer scoping | application, route, component and primitive providers | Use after the basic boundary is understood |\n| Typed error mapping | `E` becomes Craft exceptions; defects stay technical errors | Suitable for explicit UI error handling |\n| Effect service mocks | `mockEffectService` plus Craft registers | Suitable for focused tests |\n| Static Effect graph | Effect services, operations and Layers are collected | Useful for architecture rules; still evolving |\n| Server functions | `executeEffect`, middleware and local HTTP demo | Keep behind an experimental boundary |\n| SSR and deployment integration | Not shipped as a product contract | Do not make it a prerequisite for adoption |\n\n## How to read this table\n\nThe safest first adoption is browser-side, one feature, with an existing Effect\ndomain and an application Layer provided by Craft. Defer SSR-specific decisions\nand server functions until their contracts are stable.\n\nSee [Adopting CraftTS progressively](/resources/effect-adoption) for a staged\nplan and [the quickstart's verification section](/learn-effect/00-start-here#5-verify-the-boundary)\nfor the executable Effect demo checks.\n"
731
+ "body": "# Effect compatibility and maturity\n\nThis page describes the current repository contract. It is a decision aid for\nteams evaluating CraftTS, not a promise that beta APIs will remain unchanged.\n\n## Compatibility matrix\n\n| Area | Current contract | Status |\n| --- | --- | --- |\n| Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta |\n| Craft components | `@craft-ts/component` on the same Craft version | Beta |\n| Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration |\n| Effect runtime | `effect` `^4.0.0-rc.110` | Effect 4 release candidate |\n| Effect 3 projects | No compatibility contract | Migrate or isolate before adopting |\n| Node.js | 20.19+ or 22.12+ | Required by the current docs |\n| TypeScript | Use the version supported by the selected Craft beta; verify with the project lockfile | Toolchain-sensitive |\n| Browser application | Vite demo and jsdom tests are covered | Experimental but executable |\n| SSR | No product SSR renderer in this release | Not ready |\n| Server functions | Local transport and middleware experiment | Proof of concept |\n| Migration tooling | `craft-migrate` for Craft concepts | No complete Effect-specific migration |\n\nInstall all Craft packages from the same beta channel. `@craft-ts/effect` also\ndeclares `effect` as a peer dependency, so the Effect version is part of the\napplication's compatibility surface.\n\n## Maturity by capability\n\n| Capability | What is covered today | Adoption guidance |\n| --- | --- | --- |\n| Effect domain programs | `Effect`, tagged errors, `Context.Service`, `Layer` | Good candidate for a pilot |\n| Effect-backed reads and writes | `queryEffect`, `mutationEffect`, `asyncProcessEffect` | Pilot with real tests and a narrow feature |\n| Synchronous Effect members in a computation | `SyncOp`, `computedEffect`, `syncEffect` | Declare the members that never suspend, then reuse them in `craftComputed` and `params` |\n| Layer scoping | application, route, component and primitive providers | Use after the basic boundary is understood |\n| Typed error mapping | `E` becomes Craft exceptions; defects stay technical errors | Suitable for explicit UI error handling |\n| Effect service mocks | `mockEffectService` plus Craft registers | Suitable for focused tests |\n| Static Effect graph | Effect services, operations and Layers are collected | Useful for architecture rules; still evolving |\n| Server functions | `executeEffect`, middleware and local HTTP demo | Keep behind an experimental boundary |\n| SSR and deployment integration | Not shipped as a product contract | Do not make it a prerequisite for adoption |\n\n## How to read this table\n\nThe safest first adoption is browser-side, one feature, with an existing Effect\ndomain and an application Layer provided by Craft. Defer SSR-specific decisions\nand server functions until their contracts are stable.\n\nSee [Adopting CraftTS progressively](/resources/effect-adoption) for a staged\nplan and [the quickstart's verification section](/learn-effect/00-start-here#5-verify-the-boundary)\nfor the executable Effect demo checks.\n"
637
732
  },
638
733
  {
639
734
  "path": "/resources/examples",
640
735
  "title": "Examples",
641
- "body": "# Examples\n\nEvery example below is a real route of the demo application. Each opens in\nStackBlitz on the relevant file, already navigated to the page.\n\nThe demo groups them the way you would meet them: **components** first, then the\n**primitives** on their own, then the same features **behind services**, then\n**routing** and the rest.\n\n::: tip Just want to poke at something?\nThe [Playground](#playground) is a shareable sandbox with a small todo flow —\nthe fastest way to try an idea.\n:::\n\n## Components\n\nFunctional, selectorless components rendered from typed hyperscript.\n\n| Example | What it shows |\n| --- | --- |\n| [Functional Components](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-demo.ts&initialpath=/) | `craftComponent`, inputs and outputs as factory parameters, hyperscript templates |\n| [Reactive Composition](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-composition-demo.ts&initialpath=/component-composition) | Composing components and directives with `.pipe(...)` |\n| [Content Projection](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/content-projection-demo.ts&initialpath=/content-projection) | Free DOM content, typed DOM contracts, and logical projection by contract |\n| [Pending Block](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-block-demo.ts&initialpath=/pending-block) | Type-safe async suspension with `settledValue`, `settled(...)` and `pendingBlock` |\n| [Pending Block — Exception](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-block-exception-demo.ts&initialpath=/pending-block/exception) | Coordinating pending, reloading and business-exception fallbacks with `pendingBlock` and `catchBlock` |\n\n## Primitives\n\nUsing `state`, `query`, `mutation`, `queryParams` and `asyncProcess` directly,\nwith no service layer.\n\n| Example | What it shows |\n| --- | --- |\n| [Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/query/query.ts&initialpath=/query/1) | `query()` with reactive params, status and caching |\n| [Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/mutation/mutation.ts&initialpath=/mutation/1) | `mutation()` with manual control of modification operations |\n| [List with Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/list-with-pagination/list-with-pagination.ts&initialpath=/list-with-pagination) | Pagination with hand-managed query params and page state |\n| [Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/granular-mutation/granular-mutation.ts&initialpath=/granular-mutation) | Optimistic updates and cache invalidation, done by hand |\n| [Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/full-demo/full-demo.ts&initialpath=/full-demo) | Everything at once, without store or service abstractions |\n| [Login Form](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/forms/login-form.ts&initialpath=/login-form) | `insertForm`, validators, and a typed submit wired to a mutation |\n| [Pixel Art](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art/pixel-art.ts&initialpath=/pixel-art) | `state` + `insertSelect` over a flat array |\n| [Pixel Art Matrix](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts&initialpath=/pixel-art-matrix) | Nested `insertSelect` and internal `source$` between rows and cells |\n| [Exceptions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exceptions.ts&initialpath=/exceptions) | Business exceptions on `query()`, rendered per code with `matchBlock.exhaustive` |\n| [Exception QueryParams](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exception-query-params.ts&initialpath=/exception-query-params) | `queryParams` decode failures through `hasException()` and `exceptions().parse` |\n\n## Services\n\nThe same features, packaged behind `craftService`.\n\n| Example | What it shows |\n| --- | --- |\n| [Craft Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/query/query.ts&initialpath=/craft/query/1) | A reusable query service with configured storage persistence (localStorage by default) |\n| [Craft Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/mutation/mutation.ts&initialpath=/craft/mutation/1) | Create / update / delete with reactive cache synchronisation |\n| [Craft List Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/list-with-pagination/list-with-pagination.ts&initialpath=/craft/list-with-pagination) | `queryParams` + `insertPaginationPlaceholderData` in a service |\n| [Craft Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/granular-mutation/granular-mutation.ts&initialpath=/craft/granular-mutation) | `insertReactOnMutation` updating cached data without a reload |\n| [Craft Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/full-demo/full-demo.ts&initialpath=/craft/full-demo) | Queries, mutations, async work, URL state and persistence together |\n| [craftService Counter](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-counter.ts&initialpath=/craft-service/counter) | The smallest possible service — scopes and composition |\n| [craftService User Detail](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-user-detail.ts&initialpath=/craft-service/user-detail) | Service inputs, and exposing only part of a dependency |\n| [craftRegisterFor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/register-for.ts&initialpath=/craft-service/register-for) | A parent driving live children through a typed registry |\n\n## Routing\n\n| Example | What it shows |\n| --- | --- |\n| [Query Params in the route](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/list-with-pagination/qp-list-with-pagination.ts&initialpath=/query-params) | `queryParams` declared on the route rather than in a component |\n| [Guard Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/guard-demo/GuardDemo.ts&initialpath=/guard-demo) | Guards as bare generators, and `handleExceptions` per code |\n| [Slow Page](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/slow-page/slow-page.routes.ts&initialpath=/slow-page) | Non-blocking navigation: the stay → blank → loader phases, and a `craftGen` resolver recovered locally with `catchTag` |\n| [View Transitions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/view-transitions/view-transitions.routes.ts&initialpath=/view-transitions) | Outlet-driven view transitions surviving the guard/resolve chain, with a per-route skeleton |\n| [Lazy Layout](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/lazy-layout/lazy-layout.routes.ts&initialpath=/craft/lazy-layout/1) | A lazy child collection with its own DI check and a route-provided service |\n\n## Tooling\n\n| Example | What it shows |\n| --- | --- |\n| [Playground](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/playground/playground.ts&initialpath=/playground) | A shareable sandbox: a small todo flow with `craftService`, `query()` and `mutation()` |\n| [Send Context to AI](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/ia/demo-send-context/demo-send-context.ts&initialpath=/demo-send-context) | Exporting the live dependency graph and app context to an assistant |\n\n## Notes\n\nEach example ships its own `api.service.ts` simulating the network, so every\nroute works standalone.\n\nSource repository:\n[craft-ts-demo](https://github.com/craft-ts/craft-ts-demo).\n"
736
+ "body": "# Examples\n\nEvery example below is a real route of one of the demo applications. Each\nopens in StackBlitz on the relevant file, already navigated to the page.\n\nThe demo groups them the way you would meet them: **components** first, then the\n**primitives** on their own, then the same features **behind services**, then\n**routing** and the rest.\n\n::: tip Just want to poke at something?\nThe [Playground](https://stackblitz.com/fork/github/craft-ts/craft-ts-demo/tree/main?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts&initialpath=%2Fplayground)\nis a shareable sandbox with a small todo flow — the fastest way to try an idea.\n:::\n\n## Components\n\nFunctional, selectorless components rendered from typed hyperscript.\n\n| Example | What it shows |\n| --- | --- |\n| [Functional Components](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-demo.ts&initialpath=/) | `craftComponent`, inputs and outputs as factory parameters, hyperscript templates |\n| [Reactive Composition](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-composition-demo.ts&initialpath=/component-composition) | Composing components and directives with `.pipe(...)` |\n| [Content Projection](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/content-projection-demo.ts&initialpath=/content-projection) | Free DOM content, typed DOM contracts, and logical projection by contract |\n| [Pending Block](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-node-demo.ts&initialpath=/pending-node) | Type-safe async suspension with `settledValue`, `settled(...)` and `pendingNode` |\n| [Pending Block — Exception](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-node-exception-demo.ts&initialpath=/pending-node/exception) | Coordinating pending, reloading and business-exception fallbacks with `pendingNode` and `catchNode` |\n\n## Primitives\n\nUsing `state`, `query`, `mutation`, `queryParams` and `asyncProcess` directly,\nwith no service layer.\n\n| Example | What it shows |\n| --- | --- |\n| [Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/query/query.ts&initialpath=/query/1) | `query()` with reactive params, status and caching |\n| [Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/mutation/mutation.ts&initialpath=/mutation/1) | `mutation()` with manual control of modification operations |\n| [List with Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/list-with-pagination/list-with-pagination.ts&initialpath=/list-with-pagination) | Pagination with hand-managed query params and page state |\n| [Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/granular-mutation/granular-mutation.ts&initialpath=/granular-mutation) | Optimistic updates and cache invalidation, done by hand |\n| [Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/full-demo/full-demo.ts&initialpath=/full-demo) | Everything at once, without store or service abstractions |\n| [Login Form](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/forms/login-form.ts&initialpath=/login-form) | `insertForm`, validators, and a typed submit wired to a mutation |\n| [Pixel Art](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art/pixel-art.ts&initialpath=/pixel-art) | `state` + `insertSelect` over a flat array |\n| [Pixel Art Matrix](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts&initialpath=/pixel-art-matrix) | Nested `insertSelect` and internal `source$` between rows and cells |\n| [Exceptions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exceptions.ts&initialpath=/exceptions) | Business exceptions on `query()`, rendered per code with `matchNode.exhaustive` |\n| [Exception QueryParams](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exception-query-params.ts&initialpath=/exception-query-params) | `queryParams` decode failures through `hasException()` and `exceptions().parse` |\n\n## State machines\n\nState machines for explicit transitions, history and collection-oriented UI.\n\n| Example | What it shows |\n| --- | --- |\n| [Profile editor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine/profile-editor.ts&initialpath=/state-machine) | `craftStateMachine`, typed transitions and persisted history |\n| [Text editor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine/text-editor.ts&initialpath=/state-machine-text) | A compact state machine for editing, validation and transitions |\n| [Task board](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine-list/task-board.ts&initialpath=/state-machine-list) | A state machine per list item with history and reactive collection updates |\n\n## Services\n\nThe same features, packaged behind `craftService`.\n\n| Example | What it shows |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| [Craft Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/query/query.ts&initialpath=/craft/query/1) | A reusable query service with configured storage persistence (localStorage by default) |\n| [Craft Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/mutation/mutation.ts&initialpath=/craft/mutation/1) | Create / update / delete with reactive cache synchronisation |\n| [Craft List Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/list-with-pagination/list-with-pagination.ts&initialpath=/craft/list-with-pagination) | `queryParams` + `insertPaginationPlaceholderData` in a service |\n| [Craft Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/granular-mutation/granular-mutation.ts&initialpath=/craft/granular-mutation) | `insertReactOnMutation` updating cached data without a reload |\n| [Craft Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/full-demo/full-demo.ts&initialpath=/craft/full-demo) | Queries, mutations, async work, URL state and persistence together |\n| [craftService Counter](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-counter.ts&initialpath=/craft-service/counter) | The smallest possible service — scopes and composition |\n| [craftService User Detail](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-user-detail.ts&initialpath=/craft-service/user-detail) | Service inputs, and exposing only part of a dependency |\n| [craftRegisterFor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/register-for.ts&initialpath=/craft-service/register-for) | A parent driving live children through a typed registry |\n\n## Effect\n\nConcrete EffectTS integration examples, using the dedicated Effect demo.\n\n| Example | What it shows |\n| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |\n| [Profile Lookup](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-profile-lookup.ts&initialpath=/) | `queryEffect`, typed business errors, and pending / exception rendering |\n| [Access Check](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-access-check-shared-service.ts&initialpath=/access) | An Effect service provided by the application Layer |\n| [Team Overview](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-team-overview-layer-scope.ts&initialpath=/team) | Combining application-wide and route-scoped Effect Layers |\n| [Effect Playground](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-playground.ts&initialpath=/playground) | A shareable todo sandbox with `queryEffect`, `mutationEffect`, and a route-provided Effect service |\n| [Translate in an Effect](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/shared/i18n-domain.ts&initialpath=/i18n) | `provideI18nRuntime` as a route Layer, `translateEffect` inside a plain Effect program, and the locale as Craft state driving the query params |\n\n## Design system\n\nThe typed style system, at all three of its levels. Both routes read from the\nsame sheets under `src/app/examples/design-system/`, which has a README walking\nthrough the same progression in code.\n\n| Example | What it shows |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [Mini Design System](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/design-system/foundation.style.ts&initialpath=/design-system) | `definePalette`, `defineStateAxis`, `cssVars` and the theme: one dark-mode rule for the whole system, and variants as `data-*` attributes rather than class strings |\n| [Scroll context](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/design-system/scroll.style.ts&initialpath=/design-system/scroll) | Level 3: `requires(scrollPort.block)` travelling up the tree, `provides(...)` on the layout that owns the area, and the `scrollState` axis |\n\nStart from [Activating the style system](/guide/style/setup) — the sheets emit\nnothing without the Vite plugin.\n\n## Internationalisation\n\n| Example | What it shows |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [Type-safe i18n](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/i18n/i18n.service.ts&initialpath=/i18n) | `defineCatalog` + `msg` + `plural`, a second locale through `defineLocaleLike`, every shipped semantic token, a custom `defineToken`, and `runtime.bind` switching the whole page reactively |\n\nThe guide is [Type-safe i18n](/guide/i18n/).\n\n## Routing\n\n| Example | What it shows |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |\n| [Query Params in the route](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/list-with-pagination/qp-list-with-pagination.ts&initialpath=/query-params) | `queryParams` declared on the route rather than in a component |\n| [Guard Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/guard-demo/GuardDemo.ts&initialpath=/guard-demo) | Guards as bare generators, and `handleExceptions` per code |\n| [Slow Page](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/slow-page/slow-page.routes.ts&initialpath=/slow-page) | Non-blocking navigation: the stay → blank → loader phases, and a `craftGen` resolver recovered locally with `catchTag` |\n| [View Transitions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/view-transitions/view-transitions.routes.ts&initialpath=/view-transitions) | Outlet-driven view transitions surviving the guard/resolve chain, with a per-route skeleton |\n| [Lazy Layout](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/lazy-layout/lazy-layout.routes.ts&initialpath=/craft/lazy-layout/1) | A lazy child collection with its own DI check and a route-provided service |\n\n## Tooling\n\n| Example | What it shows |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |\n| [Playground](https://stackblitz.com/fork/github/craft-ts/craft-ts-demo/tree/main?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts&initialpath=%2Fplayground) | A shareable sandbox: a small todo flow with `craftService`, `query()` and `mutation()` |\n| [Send Context to AI](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/ia/demo-send-context/demo-send-context.ts&initialpath=/demo-send-context) | Exporting the live dependency graph and app context to an assistant |\n\n## Notes\n\nEach example ships its own `api.service.ts` simulating the network, so every\nroute works standalone.\n\nSource repository:\n[craft-ts-demo](https://github.com/craft-ts/craft-ts-demo).\n\nEffect demo source repository:\n[craft-demo-effect](https://github.com/craft-ts/craft-demo-effect).\n"
642
737
  },
643
738
  {
644
739
  "path": "/resources/migration",