@craft-ts/mcp 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/content/docs-index.json +45 -30
- package/package.json +2 -2
- package/skills/translate-spec-to-craft-ts/SKILL.md +7 -7
- package/skills/translate-spec-to-craft-ts/references/lexical-map.md +31 -83
- package/skills/translate-spec-to-craft-ts/references/pattern-recipes.md +17 -17
- package/skills/translate-spec-to-craft-ts/references/project-index.md +1 -1
package/content/docs-index.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"path": "/guide",
|
|
9
9
|
"title": "Guide",
|
|
10
|
-
"body": "# Guide\n\nThe guide is organised by **what you are trying to do**. If you are starting\nout, the [Learn path](/learn/) is a better entry point — it introduces the same\nmaterial one idea at a time.\n\n## Start here\n\nFour pages carry most of the weight. Reading them in this order is worth an\nafternoon:\n\n1. [The mental model](/guide/concepts/mental-model) — the principles the API\n follows and the guarantees they provide\n2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the\n five-way decision you make constantly\n3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all\n five share\n4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel\n everything is built on\n5. [Insertions](/guide/concepts/insertions) — how behaviour is composed\n\n## Project setup\n\n[Create a CraftTS project](/guide/create-project) — interactive and\nnon-interactive starters, configuration options, and first checks\n\n## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\n[State machines](/guide/state/state-machines) ·\n[query](/guide/state/server-state) ·\n[Mutations](/guide/state/mutations) ·\n[queryParams](/guide/state/url-state) ·\n[asyncProcess](/guide/state/async-process) ·\n[Collections](/guide/state/collections) ·\n[Persistence](/guide/state/persistence) ·\n[Selecting](/guide/state/select) ·\n[Reacting to mutations](/guide/state/react-on-mutation) ·\n[Schema validation](/guide/state/schema-validation)\n\n### Structuring the app\n\n[craftService](/guide/app/craft-service) ·\n[Service scopes](/guide/app/service-scopes) ·\n[Shaping the public API](/guide/app/expose-api) ·\n[Abstract services](/guide/app/abstract-services) ·\n[App start](/guide/app/app-start) ·\n[Lazy services](/guide/app/lazy-services)\n\n[Server functions](/guide/app/server-functions)\n\n### Recommended approaches\n\n[Inject at the point of use](/guide/patterns/inject-at-point-of-use)\n\n### Routing and type-safe DI\n\n[Setup](/guide/routing/setup) ·\n[CLI automation](/guide/routing/automation) ·\n[ESLint rules](/guide/routing/eslint-rules) ·\n[Route providers](/guide/routing/route-providers) ·\n[Guards](/guide/routing/guards) ·\n[Exception handling](/guide/routing/exception-handling) ·\n[Pending UI](/guide/routing/pending-ui) ·\n[Route load errors](/guide/routing/route-load-errors) ·\n[Scaling routes](/guide/routing/scaling)\n\n### Components and templates\n\n[Components](/guide/components/) ·\n[Fine-grained reactivity](/guide/components/fine-grained-reactivity) ·\n[Progressive `forNode`](/guide/components/schedule-for) ·\n[Directives and `.pipe(...)`](/guide/components/directives) ·\n[Customization](/guide/components/customization) ·\n[Content projection](/guide/components/content-projection) ·\n[
|
|
10
|
+
"body": "# Guide\n\nThe guide is organised by **what you are trying to do**. If you are starting\nout, the [Learn path](/learn/) is a better entry point — it introduces the same\nmaterial one idea at a time.\n\n## Start here\n\nFour pages carry most of the weight. Reading them in this order is worth an\nafternoon:\n\n1. [The mental model](/guide/concepts/mental-model) — the principles the API\n follows and the guarantees they provide\n2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the\n five-way decision you make constantly\n3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all\n five share\n4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel\n everything is built on\n5. [Insertions](/guide/concepts/insertions) — how behaviour is composed\n\n## Project setup\n\n[Create a CraftTS project](/guide/create-project) — interactive and\nnon-interactive starters, configuration options, and first checks\n\n## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\n[State machines](/guide/state/state-machines) ·\n[query](/guide/state/server-state) ·\n[Mutations](/guide/state/mutations) ·\n[queryParams](/guide/state/url-state) ·\n[asyncProcess](/guide/state/async-process) ·\n[Collections](/guide/state/collections) ·\n[Persistence](/guide/state/persistence) ·\n[Selecting](/guide/state/select) ·\n[Reacting to mutations](/guide/state/react-on-mutation) ·\n[Schema validation](/guide/state/schema-validation)\n\n### Structuring the app\n\n[craftService](/guide/app/craft-service) ·\n[Service scopes](/guide/app/service-scopes) ·\n[Shaping the public API](/guide/app/expose-api) ·\n[Abstract services](/guide/app/abstract-services) ·\n[App start](/guide/app/app-start) ·\n[Lazy services](/guide/app/lazy-services)\n\n[Server functions](/guide/app/server-functions)\n\n### Recommended approaches\n\n[Inject at the point of use](/guide/patterns/inject-at-point-of-use)\n\n### Routing and type-safe DI\n\n[Setup](/guide/routing/setup) ·\n[CLI automation](/guide/routing/automation) ·\n[ESLint rules](/guide/routing/eslint-rules) ·\n[Route providers](/guide/routing/route-providers) ·\n[Guards](/guide/routing/guards) ·\n[Exception handling](/guide/routing/exception-handling) ·\n[Pending UI](/guide/routing/pending-ui) ·\n[Route load errors](/guide/routing/route-load-errors) ·\n[Scaling routes](/guide/routing/scaling)\n\n### Components and templates\n\n[Components](/guide/components/) ·\n[Fine-grained reactivity](/guide/components/fine-grained-reactivity) ·\n[Progressive `forNode`](/guide/components/schedule-for) ·\n[Directives and `.pipe(...)`](/guide/components/directives) ·\n[Customization](/guide/components/customization) ·\n[Content projection](/guide/components/content-projection) ·\n[Styling a component](/guide/components/styles) ·\n[Accessibility](/guide/components/accessibility)\n\n### Forms\n\n[Overview](/guide/forms/) ·\n[Validators](/guide/forms/validation) ·\n[Submitting](/guide/forms/submit) ·\n[Nested forms](/guide/forms/nested)\n\n### Testing\n\n[Services](/guide/testing/services) ·\n[Components](/guide/testing/components) ·\n[Type-level tests](/guide/testing/type-level) ·\n[Browser boundaries](/guide/testing/browser-boundaries) ·\n[Architecture rules](/guide/testing/architecture) ·\n[Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n\n### Reactivity utilities\n\n[craftComputed](/guide/reactivity/craft-computed) ·\n[craftEffect](/guide/reactivity/craft-effect) ·\n[craftMethod](/guide/reactivity/craft-method) ·\n[source$](/guide/reactivity/source) ·\n[on$](/guide/reactivity/on)\n\n### Going further\n\n[SSR and hydration](/guide/advanced/ssr-hydration) ·\n[Program operators](/guide/advanced/program-operators) ·\n[Pattern matching](/guide/advanced/pattern-matching) ·\n[Observability](/guide/advanced/observability)\n\n### AI agents\n\n[AI agents overview](/guide/ai/) ·\n[Coding agents](/resources/ai-agents) ·\n[MCP tools](/guide/ai/mcp-tools) ·\n[Live page MCP](/guide/ai/dev-page) ·\n[Send context to AI](/guide/ai/send-context-webhook)\n\n## Looking for one symbol?\n\nThe [API index](/reference/) lists every export with a one-line description.\n"
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"path": "/guide/advanced/effect",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
{
|
|
58
58
|
"path": "/guide/ai/send-context-webhook",
|
|
59
59
|
"title": "Send application context to AI",
|
|
60
|
-
"body": "# Send application context to AI\n\n`provideSendContextToAi` adds a developer-oriented context inspector to a Craft\napplication. It is useful when an AI assistant needs more than a copied error\nmessage: the selected component, the recent user journey, the relevant app\nstate, and optionally the DOM and computed CSS.\n\nTypical uses include:\n\n- asking an AI assistant to explain or fix a broken screen;\n- preparing a reproducible bug report or a support ticket;\n- investigating a failed HTTP request, navigation, mutation, or query;\n- sending a consistent, structured context to an internal debugging agent.\n\nThe feature is user-driven. It does not call an AI service by itself. Without\nan `endpoint`, everything stays in the browser and the user copies the prompt\nor the timeline when they choose to.\n\n## Minimal setup\n\nRegister the provider once in the application providers. The default UI then\nadds an `AI context` launcher in the bottom-right corner and a context menu to\nCraft component hosts.\n\n\n\nThe two entry points are equivalent:\n\n- open the launcher to start with an empty context, then interact with the app;\n- right-click a component to start with that component already selected.\n\nFrom the chat, the user can add more elements with another right-click, remove\nselected elements, write an instruction, record or clear the timeline, and\nchoose which sections to include in the generated Markdown prompt.\n\n## What is collected\n\nThe session combines several kinds of context:\n\n- **Selected elements**: tag name, text, outer HTML, and optionally a selector.\n- **Component information**: the host name, Craft host tags, click coordinates,\n the clicked element, and truncated host HTML.\n- **Timeline**: DOM interactions, HTTP requests, router activity, primitive\n activity, and app snapshot reports. HTTP and navigation entries are linked by\n operation and correlation IDs when those services provide them.\n- **App snapshots**: the reports emitted by the app snapshot registry while the\n context is being prepared.\n- **DOM and CSS captures**: the selected component or the full page, including\n computed styles. These captures are optional because they can be large and\n briefly pause the page while they are collected.\n\nThe default prompt contains selected elements, component information, the\ntimeline summary, and app snapshots when they exist. Timeline JSON and DOM/CSS\ncaptures are opt-in. The checkboxes in the chat change the Markdown prompt;\nthe webhook also receives the structured fields so an agent can process them\nwithout parsing Markdown.\n\nThe chat also supports recording a named **clip**. A clip is a subset of the\ntimeline, which is useful when an investigation contains several unrelated\ninteractions. `Copy JSON` exports the visible timeline (or the selected clip),\nwhereas `Copy prompt` builds the AI-oriented Markdown document.\n\n## Send the context to an agent\n\nPass a browser-accessible webhook URL to enable the `Send` action:\n\n\n\nThe browser sends a JSON `POST` with this versioned shape:\n\n```json\n{\n \"version\": 1,\n \"prompt\": \"# Instruction\\nInvestigate this screen\",\n \"instruction\": \"Investigate this screen\",\n \"selectedElements\": [],\n \"events\": [],\n \"snapshot\": [],\n \"captures\": {},\n \"component\": {\n \"hostName\": \"OrdersPage\",\n \"tagList\": [\"component:OrdersPage#1\"],\n \"coords\": { \"x\": 120, \"y\": 80 },\n \"outerHTML\": \"<section>…</section>\"\n }\n}\n```\n\n`prompt` is generated from the instruction, the selected options, and the\nstructured fields. `component` is omitted when the chat was opened from the\nlauncher without a captured component. `captures.component` and\n`captures.page` are present only when their corresponding DOM/CSS options were\nselected. Events generated by the webhook request itself are excluded from\nthe context sent to that webhook.\n\nAny `2xx` response is successful, including `200`, `202`, and `204`. Network\nfailures, timeouts, and non-`2xx` responses are shown in the chat. `Retry`\nreuses the exact same payload, and `Copy payload` copies that payload only\nafter a failed request.\n\nThe endpoint is application configuration shipped to the browser, not a\nsecret. It must allow the application's origin through CORS and accept JSON\n`POST` requests. Put authentication and secret management in a protected\nsame-origin proxy or agent gateway.\n\n## Customize the collected context with DI\n\n`provideSendContextToAi()` installs the default session, but the session reads\nits policy and extensions from injection tokens. These providers can be placed\nalongside it in `appConfig`:\n\n\n\n### Retention, redaction, and serialization\n\n`SEND_CONTEXT_RETENTION_POLICY` limits the number and approximate size of\nevents kept in memory. The default is 500 events and 2 MiB.\n\n`SEND_CONTEXT_REDACTOR` runs before values are serialized. Use it to remove\napplication-specific secrets or personal data. The built-in redactor already\nredacts keys matching `password`, `secret`, `token`, `authorization`, and\n`cookie`; replacing it means taking responsibility for the complete policy.\n\n`SEND_CONTEXT_VALUE_SERIALIZER` converts values that are not naturally JSON\nfriendly, such as `Date`, `Error`, `BigInt`, functions, or circular objects.\nBoth hooks apply to event `payload`, `response`, and `state` values.\n\n### Add, enrich, or filter events\n\nUse the multi providers to extend the timeline without changing feature code:\n\n- `provideSendContextEventSource(...)` connects an application event bus to the\n session. The source may return a cleanup function.\n- `provideSendContextEventEnricher(...)` adds common metadata such as a tenant,\n release, route, or feature flag to every event.\n- `provideSendContextEventFilter(...)` drops noisy or sensitive events before\n they enter the session.\n\nAn event source emits through `session.capture(...)` or `session.emit(...)`.\nThe `kind` can be one of the built-in kinds (`dom`, `http`, `navigation`,\n`primitive`, `snapshot`, `custom`) or an application-specific string.\n\nThe session and its record controller are also injectable:\n\n```ts\nimport {\n SEND_CONTEXT_RECORD_CONTROLLER,\n SEND_CONTEXT_SESSION,\n} from '@craft-ts/component';\nimport { ɵinject as inject } from '@craft-ts/core';\n\nconst record = inject(SEND_CONTEXT_RECORD_CONTROLLER);\nrecord.startRecord('Checkout failure');\n\n// Later, from the same application flow:\nrecord.stopRecord();\nconst summary = record.exportSummary();\nconst json = record.exportJson();\n\nconst session = inject(SEND_CONTEXT_SESSION);\nsession.capture('custom', 'emitted', {\n name: 'checkout.validation',\n state: { step: 'payment' },\n});\n```\n\nUse the record controller for clips and the session for low-level event\nemission, subscription, clearing, and programmatic export.\n\n## Customize the UI with DI\n\nThere are three levels of UI customization:\n\n| Provider | What it replaces or adds | When to use it |\n| ------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ |\n| `provideSendContextChatComponent(() => MyChat)` | The default chat panel | Keep the built-in launcher and context menu, but replace the panel |\n| `provideSendContextUiRenderer(() => MyRenderer)` | The complete renderer | Own the launcher/chat lifecycle and render the whole experience |\n| `SEND_CONTEXT_LAUNCHER_COMPONENT` / `SEND_CONTEXT_CONTEXT_MENU_COMPONENT` | The floating launcher or right-click menu | Match the application's controls or visual language |\n\nThe complete renderer receives `SendContextUiContext`. It exposes the live\nsession, events, clips, selected targets, captured payload, DOM capture element,\nrecording state, endpoint, and operations such as `addTarget`, `removeTarget`,\n`selectClip`, and `close`.\n\n```ts\nimport {\n provideSendContextChatComponent,\n provideSendContextUiRenderer,\n type SendContextUiContext,\n} from '@craft-ts/component';\n\n// A chat replacement keeps the default surrounding behavior.\nprovideSendContextChatComponent(() => MyChat);\n\n// A complete renderer receives the live context as its `context` input.\nprovideSendContextUiRenderer(() => MyRenderer);\n\n// MyRenderer's input contract is:\n// { context: Input<SendContextUiContext>; onClose: Output<() => void> }\n```\n\n`provideSendContextChatSection`, `provideSendContextChatAction`, and\n`provideSendContextExportSection` are multi providers intended for a custom\nrenderer. They let feature libraries contribute sections, commands, or export\nviews without depending on one global renderer. The built-in chat does not\nrender those extension entries itself; a custom renderer reads them from\n`SendContextUiContext`.\n\nFor example, a feature can contribute an action that starts a named clip:\n\n```ts\nimport { provideSendContextChatAction } from '@craft-ts/component';\n\nconst providers = [\n provideSendContextChatAction({\n id: 'record-checkout',\n label: 'Record checkout flow',\n run: (context) => context.session.startRecord('Checkout flow'),\n }),\n];\n```\n\nThe UI provider tokens are regular DI contracts, so a custom launcher or menu\ncan be registered directly:\n\n```ts\nimport {\n SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n SEND_CONTEXT_LAUNCHER_COMPONENT,\n} from '@craft-ts/component';\n\nconst providers = [\n {\n provide: SEND_CONTEXT_LAUNCHER_COMPONENT,\n useValue: MyLauncher,\n },\n {\n provide: SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n useValue: MyContextMenu,\n },\n];\n```\n\n## Complete integration example\n\nAn application can combine the default UI, a protected endpoint, stricter\nretention, and application-specific event filtering:\n\n```ts\nimport { craftAppConfig } from '@craft-ts/core';\nimport {\n provideSendContextEventFilter,\n provideSendContextToAi,\n SEND_CONTEXT_RETENTION_POLICY,\n} from '@craft-ts/component';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideSendContextToAi({\n endpoint: '/internal/ai/context',\n }),\n {\n provide: SEND_CONTEXT_RETENTION_POLICY,\n useValue: { maxEvents: 250, maxBytes: 1024 * 1024 },\n },\n provideSendContextEventFilter((event) => event.name !== 'healthcheck'),\n ],\n});\n```\n\nThe server-side endpoint should validate `version`, authenticate the user,\napply any additional server-side redaction, and then forward either `prompt`\nor the structured context to the selected agent.\n"
|
|
60
|
+
"body": "# Send application context to AI\n\n`provideSendContextToAi` adds a developer-oriented context inspector to a Craft\napplication. It is useful when an AI assistant needs more than a copied error\nmessage: the selected component, the recent user journey, the relevant app\nstate, and optionally the DOM and computed CSS.\n\nTypical uses include:\n\n- asking an AI assistant to explain or fix a broken screen;\n- preparing a reproducible bug report or a support ticket;\n- investigating a failed HTTP request, navigation, mutation, or query;\n- sending a consistent, structured context to an internal debugging agent.\n\nThe feature is user-driven. It does not call an AI service by itself. Without\nan `endpoint`, everything stays in the browser and the user copies the prompt\nor the timeline when they choose to.\n\n## Minimal setup\n\nRegister the provider once in the application providers. The default UI then\nadds an `AI context` launcher in the bottom-right corner and a context menu to\nCraft component hosts.\n\n\n\nThe two entry points are equivalent:\n\n- open the launcher to start with an empty context, then interact with the app;\n- right-click a component to start with that component already selected.\n\nFrom the chat, the user can add more elements with another right-click, remove\nselected elements, write an instruction, record or clear the timeline, and\nchoose which sections to include in the generated Markdown prompt.\n\n## What is collected\n\nThe session combines several kinds of context:\n\n- **Selected elements**: tag name, text, outer HTML, and optionally a selector.\n- **Component information**: the host name, Craft host tags, click coordinates,\n the clicked element, and truncated host HTML.\n- **Timeline**: DOM interactions, HTTP requests, router activity, primitive\n activity, and app snapshot reports. HTTP and navigation entries are linked by\n operation and correlation IDs when those services provide them.\n- **App snapshots**: the reports emitted by the app snapshot registry while the\n context is being prepared.\n- **DOM and CSS captures**: the selected component or the full page, including\n computed styles. These captures are optional because they can be large and\n briefly pause the page while they are collected.\n\nThe default prompt contains selected elements, component information, the\ntimeline summary, and app snapshots when they exist. Timeline JSON and DOM/CSS\ncaptures are opt-in. The checkboxes in the chat change the Markdown prompt;\nthe webhook also receives the structured fields so an agent can process them\nwithout parsing Markdown.\n\nThe chat also supports recording a named **clip**. A clip is a subset of the\ntimeline, which is useful when an investigation contains several unrelated\ninteractions. `Copy JSON` exports the visible timeline (or the selected clip),\nwhereas `Copy prompt` builds the AI-oriented Markdown document.\n\n## Send the context to an agent\n\nPass a browser-accessible webhook URL to enable the `Send` action:\n\n\n\nThe browser sends a JSON `POST` with this versioned shape:\n\n```json\n{\n \"version\": 1,\n \"prompt\": \"# Instruction\\nInvestigate this screen\",\n \"instruction\": \"Investigate this screen\",\n \"selectedElements\": [],\n \"events\": [],\n \"snapshot\": [],\n \"captures\": {},\n \"component\": {\n \"hostName\": \"OrdersPage\",\n \"tagList\": [\"component:OrdersPage#1\"],\n \"coords\": { \"x\": 120, \"y\": 80 },\n \"outerHTML\": \"<section>…</section>\"\n }\n}\n```\n\n`prompt` is generated from the instruction, the selected options, and the\nstructured fields. `component` is omitted when the chat was opened from the\nlauncher without a captured component. `captures.component` and\n`captures.page` are present only when their corresponding DOM/CSS options were\nselected. Events generated by the webhook request itself are excluded from\nthe context sent to that webhook.\n\nAny `2xx` response is successful, including `200`, `202`, and `204`. Network\nfailures, timeouts, and non-`2xx` responses are shown in the chat. `Retry`\nreuses the exact same payload, and `Copy payload` copies that payload only\nafter a failed request.\n\nThe endpoint is application configuration shipped to the browser, not a\nsecret. It must allow the application's origin through CORS and accept JSON\n`POST` requests. Put authentication and secret management in a protected\nsame-origin proxy or agent gateway.\n\n## Configure the webhook\n\n`provideSendContextToAi` currently accepts the browser-accessible `endpoint`.\nOmit it to keep the experience copy-only, or pass a URL to enable sending:\n\n\n\nTreat that URL as public application configuration. Put authentication,\nredaction, and any tenant-specific policy in a protected same-origin proxy or\nagent gateway; never put credentials in the browser bundle.\n\nThe session and its record controller are also injectable:\n\n```ts\nimport {\n SEND_CONTEXT_RECORD_CONTROLLER,\n SEND_CONTEXT_SESSION,\n} from '@craft-ts/component';\nimport { ɵinject as inject } from '@craft-ts/core';\n\nconst record = inject(SEND_CONTEXT_RECORD_CONTROLLER);\nrecord.startRecord('Checkout failure');\n\n// Later, from the same application flow:\nrecord.stopRecord();\nconst summary = record.exportSummary();\nconst json = record.exportJson();\n\nconst session = inject(SEND_CONTEXT_SESSION);\nsession.capture('custom', 'emitted', {\n name: 'checkout.validation',\n state: { step: 'payment' },\n});\n```\n\nUse the record controller for clips and the session for low-level event\nemission, subscription, clearing, and programmatic export.\n\n## Customize the UI with DI\n\nThere are three levels of UI customization:\n\n| Provider | What it replaces or adds | When to use it |\n| ------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ |\n| `provideSendContextChatComponent(() => MyChat)` | The default chat panel | Keep the built-in launcher and context menu, but replace the panel |\n| `provideSendContextUiRenderer(() => MyRenderer)` | The complete renderer | Own the launcher/chat lifecycle and render the whole experience |\n| `SEND_CONTEXT_LAUNCHER_COMPONENT` / `SEND_CONTEXT_CONTEXT_MENU_COMPONENT` | The floating launcher or right-click menu | Match the application's controls or visual language |\n\nThe complete renderer receives `SendContextUiContext`. It exposes the live\nsession, events, clips, selected targets, captured payload, DOM capture element,\nrecording state, endpoint, and operations such as `addTarget`, `removeTarget`,\n`selectClip`, and `close`.\n\n```ts\nimport {\n provideSendContextChatComponent,\n provideSendContextUiRenderer,\n type SendContextUiContext,\n} from '@craft-ts/component';\n\n// A chat replacement keeps the default surrounding behavior.\nprovideSendContextChatComponent(() => MyChat);\n\n// A complete renderer receives the live context as its `context` input.\nprovideSendContextUiRenderer(() => MyRenderer);\n\n// MyRenderer's input contract is:\n// { context: Input<SendContextUiContext>; onClose: Output<() => void> }\n```\n\n`provideSendContextChatSection`, `provideSendContextChatAction`, and\n`provideSendContextExportSection` are multi providers intended for a custom\nrenderer. They let feature libraries contribute sections, commands, or export\nviews without depending on one global renderer. The built-in chat does not\nrender those extension entries itself; a custom renderer reads them from\n`SendContextUiContext`.\n\nFor example, a feature can contribute an action that starts a named clip:\n\n```ts\nimport { provideSendContextChatAction } from '@craft-ts/component';\n\nconst providers = [\n provideSendContextChatAction({\n id: 'record-checkout',\n label: 'Record checkout flow',\n run: (context) => context.session.startRecord('Checkout flow'),\n }),\n];\n```\n\nThe UI provider tokens are regular DI contracts, so a custom launcher or menu\ncan be registered directly:\n\n```ts\nimport {\n SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n SEND_CONTEXT_LAUNCHER_COMPONENT,\n} from '@craft-ts/component';\n\nconst providers = [\n {\n provide: SEND_CONTEXT_LAUNCHER_COMPONENT,\n useValue: MyLauncher,\n },\n {\n provide: SEND_CONTEXT_CONTEXT_MENU_COMPONENT,\n useValue: MyContextMenu,\n },\n];\n```\n\n## Complete integration example\n\nAn application can combine the default UI, a protected endpoint, stricter\nretention, and application-specific event filtering:\n\n```ts\nimport { craftAppConfig } from '@craft-ts/core';\nimport {\n provideSendContextEventFilter,\n provideSendContextToAi,\n SEND_CONTEXT_RETENTION_POLICY,\n} from '@craft-ts/component';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideSendContextToAi({\n endpoint: '/internal/ai/context',\n }),\n {\n provide: SEND_CONTEXT_RETENTION_POLICY,\n useValue: { maxEvents: 250, maxBytes: 1024 * 1024 },\n },\n provideSendContextEventFilter((event) => event.name !== 'healthcheck'),\n ],\n});\n```\n\nThe server-side endpoint should validate `version`, authenticate the user,\napply any additional server-side redaction, and then forward either `prompt`\nor the structured context to the selected agent.\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "/guide/app/abstract-services",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
{
|
|
98
98
|
"path": "/guide/app/service-scopes",
|
|
99
99
|
"title": "Service scopes",
|
|
100
|
-
"body": "# Service scopes\n\n`scope` decides how many instances of a `craftService` exist and who has to\nprovide it. It is the one decision to make when declaring a service.\n\n::: tip Short version\nDefault to `function`. Move to `toProvide` the day a child component needs the\nsame instance. Use `global` only for genuinely app-wide state.\n:::\n\n## Supported Scopes\n\n### `global`\n\n- singleton provided at root\n- ideal for app-wide services and shared state\n- no explicit `provideX()` helper\n\n### `toProvide`\n\n- requires `provideX()` where the service is mounted\n- useful for feature-local service trees\n- works well with tests that need explicit providers\n\n### `manuallyProvidedAtRoot`\n\n- explicit provider helper, but designed to be mounted at root\n-
|
|
100
|
+
"body": "# Service scopes\n\n`scope` decides how many instances of a `craftService` exist and who has to\nprovide it. It is the one decision to make when declaring a service.\n\n::: tip Short version\nDefault to `function`. Move to `toProvide` the day a child component needs the\nsame instance. Use `global` only for genuinely app-wide state.\n:::\n\n## Supported Scopes\n\n### `global`\n\n- singleton provided at root\n- ideal for app-wide services and shared state\n- no explicit `provideX()` helper\n\n### `toProvide`\n\n- requires `provideX()` where the service is mounted\n- useful for feature-local service trees\n- works well with tests that need explicit providers\n\n### `manuallyProvidedAtRoot`\n\n- explicit provider helper, but designed to be mounted at root\n- exposes the generated `provideX()` helper for explicit root composition\n- allows this scope to be yielded by global services, which is not possible with `toProvide` (it still requires explicit setup when testing with `setupCraftServiceTestingByRegister`).\n\n### `function`\n\n- creates a fresh instance on each injection\n- useful for reusable factories with bindings and inputs\n\n### `abstract`\n\n- declares a contract without implementation\n- exposes a requirement token to force a concrete implementation later\n\n## Recommendations For Choosing a Scope\n\n- Prefer `function` for a service owned by a single component. It avoids an explicit provider and makes it clear the instance is not meant to be shared with other components or child components.\n- Move to `toProvide` when the same instance must be shared with child components, or across several components through a common parent or route. In that case, provide it at the component boundary, a parent component, or the route.\n- Be careful with `toProvide`: a missing provider is a runtime failure unless the route DI check is armed. The [route DI check](/guide/routing/setup) and [architecture tests](/guide/testing/architecture#assertroutediproofs) keep that proof in place.\n- Use `global` when the instance is intentionally shared application-wide.\n- For startup-only logic that should run when the app boots but is not injected elsewhere, prefer `function` together with `provideAppInitializer(...)`. If the same instance also needs to be injected by other services, use `global` instead.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Route providers](/guide/routing/route-providers) — providing a service from a route\n- [Testing services](/guide/testing/services)\n"
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"path": "/guide/app/target-wrapper",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
{
|
|
108
108
|
"path": "/guide/components",
|
|
109
109
|
"title": "Components",
|
|
110
|
-
"body": "# Components\n\nA Craft component is a **function**, not a class. No decorator, no separate\ntemplate file, no host element wrapped around your markup.\n\n**Use it for** application components. [`loadCraftComponent`](/guide/routing/setup)\nmounts a Craft component on a route.\n\n## Install\n\nThe component renderer is published as a separate package and is currently on\nthe `beta` channel:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\n```\n\nSee [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component).\n\n## The shape\n\n```typescript\ncraftComponent(name, meta, factory, template);\n```\n\n| Argument | What it is |\n| ---------- | ----------------------------------------------------------------- |\n| `name` | the component's name — used for host tags, snapshots, diagnostics |\n| `meta` | `providers`, `
|
|
110
|
+
"body": "# Components\n\nA Craft component is a **function**, not a class. No decorator, no separate\ntemplate file, no host element wrapped around your markup.\n\n**Use it for** application components. [`loadCraftComponent`](/guide/routing/setup)\nmounts a Craft component on a route.\n\n## Install\n\nThe component renderer is published as a separate package and is currently on\nthe `beta` channel:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\n```\n\nSee [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component).\n\n## The shape\n\n```typescript\ncraftComponent(name, meta, factory, template);\n```\n\n| Argument | What it is |\n| ---------- | ----------------------------------------------------------------- |\n| `name` | the component's name — used for host tags, snapshots, diagnostics |\n| `meta` | `providers`, `host` |\n| `factory` | the **logic**: builds and returns the context |\n| `template` | receives that context, returns nodes |\n\n\n\n\nThe split matters: the factory produces a context **without touching the DOM**,\nand the template renders a context **without running the factory**. That is what\nmakes the two [testable independently](/guide/testing/components).\n\n## The logic factory\n\nA `function*` when it needs dependencies — every `yield*` is tracked and folds\ninto the component's dependency type:\n\n```typescript\nfunction* () {\n const tasks = yield* TaskList();\n return { tasks };\n}\n```\n\nA plain arrow when it needs none:\n\n```typescript\n() => ({});\n```\n\nWhatever it returns is the context the template receives. Nothing else is\nexposed.\n\n## Inputs and outputs\n\nThey are **parameters of the factory**, typed with `Input<T>` and\n`Output<Handler>`:\n\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nRendering a child is a function call, so there is no binding layer to get wrong:\n\n```typescript\nUserCard({ user: currentUser, onRemove: removeUser });\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\n## The template\n\nNodes are built with hyperscript helpers — `div`, `ul`, `button`, and `h(tag, …)`\nfor anything without one. Pass a yieldable reader to a binding. Use a generator\nwhen the binding must format or call a method:\n\n```typescript\n({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n h1(`Tasks — static`); // static text needs no reader\n];\n```\n\nThe same binding boundary applies to attributes, DOM properties, classes,\nstyles, and host props. Prefer exposing a derived reader on the primitive\n(`tasks.isEmpty`) and passing it (`disabled: tasks.isEmpty`) over wrapping a\nsynchronous call.\n\nSee [Fine-grained reactivity](/guide/components/fine-grained-reactivity) for\nthe complete rendering model, structural scopes, observability expectations,\nand migration checklist.\n\nSee [Progressive `forNode` rendering](/guide/components/schedule-for) when a\nlarge collection needs frame-based scheduling.\n\nKeep render callbacks pure. They may read signals and calculate values, but\nmust not call `set`, `update`, or `mutate`. Perform writes from DOM events,\noutputs, mutations, or explicit business effects. Enable\n`craft-ts/no-render-writes` to diagnose common violations.\n\nControl flow is made of functions rather than syntax — `forNode`, `ifNode`,\n`matchNode`, `deferNode`. The relationship between these blocks, and why a raw\nternary is the wrong tool for **structure**, is in\n[Learn step 2](/learn/02-derive#control-flow).\n\n## The meta\n\n```typescript\nimport { card } from './card.style';\n\ncraftComponent(\n 'Card',\n {\n providers: [provideCardStore()],\n host: { class: card.root },\n },\n /* … */\n);\n```\n\n- **`providers`** — the component's own DI scope, evaluated before the template.\n- **`host`** — default properties for the root element. Its `class` comes from\n a sheet, like every class.\n\nThe meta carries no CSS. A component's look lives in a `*.style.ts` sheet beside\nit — see [Styling a component: the only way](/guide/components/styles).\n`styles`, `stylesUrl` and `contentStyles` still exist on the type, deprecated,\nand `craft-ts/no-component-css` refuses them.\n\n## Composing behaviour\n\n`.pipe(...)` attaches directives, which decorate **both** the logic factory and\nthe template, left to right:\n\n```typescript\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nThe same mechanism carries `withProviders(...)` and the exception handlers below.\nSee [Directives and `.pipe(...)`](/guide/components/directives).\n\n## Mounting the root\n\nThe app root is a Craft component too:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>` (or the element you pass as\n`host`).\n\n## Pitfalls\n\n**Reading a reader outside a binding.** `h1(tasks().length)` evaluates once at\nbuild time. Pass the reader (`p(tasks.remaining)`) or use a generator:\n`h1(function* () { return yield* tasks.remaining(); })`.\n\n**Forgetting `track` in `forNode`.** Without a stable identity the renderer cannot\nreuse, move or remove the right node.\n\n**Exceptions from the factory or providers don't vanish.** They become the\ncomponent's initialization exceptions and flow up to the route unless handled\nwith `.pipe(catchNode.exhaustive(...))` — see\n[Exceptions as values](/guide/concepts/exceptions).\n\n**Naming mismatch.** The first argument must match the exported binding; the\n`craft-component-name-match` rule enforces it.\n\n## See Also\n\n- [Learn: your first state](/learn/01-first-state) — the guided version\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Accessibility](/guide/components/accessibility)\n- [Testing components](/guide/testing/components)\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "/guide/components/accessibility",
|
|
@@ -117,27 +117,27 @@
|
|
|
117
117
|
{
|
|
118
118
|
"path": "/guide/components/content-projection",
|
|
119
119
|
"title": "Content projection",
|
|
120
|
-
"body": "# Content projection\n\nProjection is a **rendering context, not a category of component**. The same\n`craftComponent` can be rendered directly or supplied into a compatible logical\nslot — its definition doesn't change either way.\n\n**Use it when** a component composes content it doesn't own: a card with a\ncaller-supplied body, a toolbar filled with actions, a dialog with its buttons.\n**Not when** the child is fixed — just render it.\n\nBoth forms go through one primitive:\n\n```ts\nrenderContent(value);\n```\n\nIt accepts either deferred DOM content (`RenderableContent`) or a component unit\nexposing a logical contract. There is **no runtime registry** like\n`contentChildren`, and no special projection component.\n\n## The common case — free DOM content\n\n`ContentSlot` describes optional or free-form DOM content. `RequiredContent`\nadds a structural contract that TypeScript checks.\n\n```typescript\nimport {\n content,\n craftComponent,\n div,\n renderContent,\n section,\n type ContentSlot,\n type RequiredContent,\n} from '@craft-ts/component';\n\ntype CardInput = {\n readonly header?: ContentSlot;\n readonly body: RequiredContent<{\n readonly selector: {\n readonly tag: 'div';\n readonly
|
|
120
|
+
"body": "# Content projection\n\nProjection is a **rendering context, not a category of component**. The same\n`craftComponent` can be rendered directly or supplied into a compatible logical\nslot — its definition doesn't change either way.\n\n**Use it when** a component composes content it doesn't own: a card with a\ncaller-supplied body, a toolbar filled with actions, a dialog with its buttons.\n**Not when** the child is fixed — just render it.\n\nBoth forms go through one primitive:\n\n```ts\nrenderContent(value);\n```\n\nIt accepts either deferred DOM content (`RenderableContent`) or a component unit\nexposing a logical contract. There is **no runtime registry** like\n`contentChildren`, and no special projection component.\n\n## The common case — free DOM content\n\n`ContentSlot` describes optional or free-form DOM content. `RequiredContent`\nadds a structural contract that TypeScript checks.\n\n```typescript\nimport {\n content,\n craftComponent,\n div,\n renderContent,\n section,\n type ContentSlot,\n type RequiredContent,\n} from '@craft-ts/component';\n\ntype CardInput = {\n readonly header?: ContentSlot;\n readonly body: RequiredContent<{\n readonly selector: {\n readonly tag: 'div';\n readonly 'data-slot': 'body';\n };\n }>;\n};\n\nconst Card = craftComponent(\n 'Card',\n {},\n (input: CardInput) => input,\n ({ header, body }) =>\n section([\n header ? renderContent('header', header) : 'Default title',\n renderContent('body', body),\n ]),\n);\n\nCard({\n header: content(() => div('Title supplied by the caller')),\n body: content(() => div({ 'data-slot': 'body' }, 'Card content')),\n});\n```\n\n\n\nThe selector is analysed **statically**. This is rejected, because it does not\ncontain `div[data-slot=\"body\"]`:\n\n```ts\nCard({\n // @ts-expect-error the content does not satisfy the slot's DOM contract.\n body: content(() => div({ 'data-slot': 'footer' })),\n});\n```\n\nThe contract names an attribute, not a class. A class comes from a sheet and is\na list of atoms — not a name a selector can require — while a `data-*`\nattribute is a stable, declared part of the markup.\n\nContent can be built from arrays, conditions, loops and templates — the analysis\nlooks for the selector in every rendered branch:\n\n```ts\nconst body = content(() => [\n showIntro() ? div({ 'data-slot': 'body' }, 'Introduction') : undefined,\n forNode(rows(), { track: (row) => row.id }, (row) =>\n div({ 'data-slot': 'body' }, row.label),\n ),\n renderTemplate(cardRowTemplate, { $implicit: selectedRow() }),\n]);\n\nCard({ body });\n```\n\nThe constraint creates no wrapper and adds no runtime validation. DOM contracts\nand logical contracts are independent:\n\n```text\nRequiredContent<Requirement> → the shape of the DOM supplied\nProjectionOf<Component> → the logical capabilities of a component\n```\n\n## Logical projection by contract\n\nA component becomes projectable when its logic factory returns a `contract`\nproperty, built and checked with `satisfies`.\n\n\n\n\n`ProjectionContractOf<Component>` extracts the type of `logicOutput.contract`.\n`ProjectionOf<Component>` adds the stable key the renderer expects. For generic\nconsumers, `ProjectionSlot<Contract>` directly describes a collection of\ncompatible units.\n\nProjection therefore depends on **neither** the component's name, **nor** a\n`projection` metadata field, **nor** a runtime registry.\n\n## Explicit collections, order and stable keys\n\nThe consuming component receives a typed collection explicitly. Each unit must\nsupply a **stable key**, which `forNode` uses to reuse, move or remove the right\nprojection.\n\n```ts\nimport {\n craftComponent,\n div,\n forNode,\n renderContent,\n type ProjectionOf,\n} from '@craft-ts/component';\n\nconst Toolbar = craftComponent(\n 'Toolbar',\n {},\n (input: {\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ actions }) =>\n div(\n { role: 'toolbar' },\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n);\n\nToolbar({\n actions: [\n ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }),\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }),\n ],\n});\n```\n\nThe same `ToolbarAction` stays usable on its own:\n\n```ts\nconst Page = craftComponent(\n 'Page',\n {},\n () => ({}),\n () => [\n ToolbarAction({\n key: 'standalone',\n content: () => 'Direct action',\n trigger: save,\n }),\n Toolbar({\n actions: [\n ToolbarAction({\n key: 'projected',\n content: () => 'Projected action',\n trigger: save,\n }),\n ],\n }),\n ],\n);\n```\n\n## Styling projected content\n\nThe component styles **its own frame** around the slot; the content is styled\nby whoever writes it, with their own sheet. What the frame offers its content\ntravels the way everything crosses a component boundary in `@craft-ts/style`:\ninherited properties — `color`, fonts — and variables declared with\n`{ inherits: true }` that the content's sheet chooses to read.\n\n\n\n\n\nNothing reaches into the content: a caller that does not read\n`styledCardVars.accent` is unaffected by it, and a nested Craft component keeps\nits own classes. The deprecated `contentStyles` meta — CSS strings pushed into a\nslot — is refused by `no-component-css`.\n\n## Pitfalls\n\n**Forgetting the stable key.** Without it the renderer cannot tell one projected\nunit from another across updates, and reuse breaks.\n\n**Expecting a plain component to satisfy a contract slot.** It stays perfectly\nusable as a direct child, but the slot rejects it:\n\n```ts\nconst PlainCard = craftComponent(\n 'PlainCard',\n {},\n () => ({}),\n () => 'Card with no contract',\n);\n\nToolbar({\n actions: [\n // @ts-expect-error PlainCard does not expose ToolbarActionContract.\n PlainCard({}),\n ],\n});\n```\n\nAn incomplete contract is rejected where it is declared:\n\n```ts\nconst invalidContract = {\n kind: 'toolbar-action',\n // @ts-expect-error trigger and disabled are required.\n} satisfies ToolbarActionContract;\n```\n\n::: details Combining optional content and contractual actions — a dialog\nA component can mix optional DOM content with several logical slots in one\nexplicit collection:\n\n```ts\nconst Dialog = craftComponent(\n 'Dialog',\n {},\n (input: {\n readonly body?: ContentSlot;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ body, actions }) =>\n section({ role: 'dialog' }, [\n body ? renderContent(body) : [],\n footer(\n forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n ]),\n);\n\nDialog({\n body: content(() =>\n div(['Delete the account', 'This action cannot be undone.']),\n ),\n actions: [\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: closeDialog }),\n ToolbarAction({ key: 'delete', content: () => 'Delete', trigger: deleteAccount }),\n ],\n});\n```\n\n`closeDialog` and `deleteAccount` are captured by the caller's closures.\nProjection preserves the lexical context **and the injector** of wherever the\nunit or the content was declared.\n:::\n\n::: details Conditions, reactivity and cleanup\nProjections are ordinary Craft nodes, so they can sit inside conditions and\ntemplates while keeping their identity by key within a collection. Here `visible`\nis a callable reactive value supplied by the caller:\n\n```ts\nconst OptionalToolbar = craftComponent(\n 'OptionalToolbar',\n {},\n (input: {\n readonly visible: () => boolean;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ visible, actions }) =>\n visible()\n ? forNode(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n )\n : [],\n);\n```\n\nOn update the renderer adds, removes and moves projections by key. On teardown\nthe projected content, its effects and its styles are cleaned up with the rest\nof the tree.\n:::\n\n## API summary\n\n- `content(renderer, options?)` — create deferred DOM content\n- `renderContent(value)` and `renderContent(slotName, value)` — render it\n- `RenderableContent`, `ContentSlot` — free-form slots\n- `RequiredContent<Requirement>` — static DOM contracts\n- `ProjectionContractOf<Component>` — extract a logical contract\n- `ProjectionOf<Component>`, `ProjectionSlot<Contract>` — type projectable\n collections\n\nThe older fragment and slot primitives are no longer part of the public API.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Styling a component](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
123
|
"path": "/guide/components/css-variables",
|
|
124
|
-
"title": "Typed CSS variables
|
|
125
|
-
"body": "# Typed CSS variables
|
|
124
|
+
"title": "Typed CSS variables",
|
|
125
|
+
"body": "# Typed CSS variables\n\nA component's styling API is a set of **typed custom properties** declared with\n[`cssVars`](../style/define.md#the-theme) in its sheet. Each one has a kind — a\ncolour, a length, a percentage — and a typed initial value, registered with\n`@property`. None is \"required\": a variable nobody sets keeps its initial value,\nand one nobody reads is reported by the architecture rule `no-dangling-css-vars`.\n\n\n\n\n\n## Per instance: a variant sets what it changes\n\nA caller does not hand a component raw values. It picks a variant — here\n`data-cardLook` — and the sheet sets the variables that variant changes with\n`set(...)`. The others keep their initial value. The set of looks is therefore\nclosed and enumerable, which is what lets the [visual matrix](/guide/style/variants)\ncapture every one of them.\n\n## Inherited: a parent sets, descendants read\n\n`{ inherits: true }` is for a variable set once on a wrapper and read below it —\na theme, or a card that tints whatever it contains. The default, `false`, is for\na variable an element both sets and reads on itself.\n\n## Forwarded: a parent re-exposes a child's variable\n\nA parent that wants its own API writes `set(child, parent)`: the panel above\ndeclares `panelVars.ink` and forwards it to `cardVars.ink`. A caller overrides\nthe panel's variable in its own sheet, and the card follows without the panel\nknowing how the card is built.\n\n## At runtime: `assign`\n\nA value known only at runtime — a progress, a position, a colour picked by the\nuser — is written on the element with `assign(variable, value)`, the only thing\n`style:` accepts. The sheet reads it like any other variable. Because the\nvariable is registered with its kind, the browser can interpolate it: a\n`transition` on `width` driven by a percentage variable animates.\n\n## `@property` is emitted, not written\n\nEvery variable declared with `cssVars` is emitted as an `@property` block by the\nbuild plugin, with its syntax, its `inherits` flag and its initial value. You\nnever write one by hand. Two rules follow from the registration:\n\n- an initial value must be computationally independent — `unit.px(16)`, not\n `unit.rem(1)` — or the browser drops the whole registration; the architecture\n suite catches it;\n- a prefix belongs to one sheet: `cssVars` throws when two sheets declare the\n same prefix.\n\n## `meta.cssVars`\n\nThe `cssVars` field of `craftComponent`'s meta — a contract extracted from a\nCSS string, with `required()`, `inherit`, `omit` and `forward()` at the call\nsite — belongs to the component CSS that `no-component-css` refuses. It is\n`@deprecated`, kept only so that an [attested bypass](/guide/components/styles#the-one-real-exception)\nstays possible.\n"
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
"path": "/guide/components/customization",
|
|
129
129
|
"title": "Customizing components and directives",
|
|
130
|
-
"body": "# Customizing components and directives\n\nCraft splits customization into three layers, and which one you reach for\ndepends on how far the change should travel:\n\n| Layer | Changes |\n| --------------------- | ------------------------------------- |\n| Root-element `host` | The component's own root defaults |\n| Encapsulated `styles` | Its internal appearance |\n| Composable directives | Behaviour, reusable across components |\n\n**Start with `host`** for one component's defaults, and move to a directive only\nwhen the same customization needs to apply somewhere else too.\n\n## Customizing the root element\n\nThe component meta `host` properties define defaults for the component’s root\nelement. The caller can extend or override them:\n\n\n\n\nClasses, attributes, styles, and events recognized as host properties are\napplied to the component root. Other properties remain factory props.\n\nValues can be reactive:\n\n```ts\nconst { active } = state('active', false, ({ set }) => ({ set }));\n\nCard({\n class: () => (active() ? 'is-active' : 'is-idle'),\n style: () => ({ opacity: active() ? 1 : 0.6 }),\n});\n```\n\n## Customizing with styles\n\nStyles declared in `meta.styles` are shared across instances and encapsulated\nwith `@scope`. The template root is written as `:scope`:\n\n```typescript\nconst Panel = craftComponent(\n 'Panel',\n {\n styles: `\n :scope { padding: 1rem; border: 1px solid #ddd; }\n .title { font-weight: 700; }\n button { cursor: pointer; }\n `,\n },\n () => ({}),\n () => div([h2({ class: 'title' }, 'Panel'), button('Save')]),\n);\n```\n\n\n\nStyles do not leak into descendant components. Global rules such as\n`@keyframes` and `@font-face` cannot be nested in `@scope`, so their private\nnames must start with the component scope. `@import` and document-root selectors\nare rejected. `@media`, `@supports`, and `@container` remain composable inside\nthe scope. For the typed styling API, see\n[Typed CSS variables and design tokens](/guide/components/css-variables).\n\n## Adding reusable customization with a directive\n\nA directive transforms a component’s factory and template. It is applied from\nleft to right with `.pipe(...)`:\n\n```ts\nconst Highlight = craftDirective(\n 'Highlight',\n {\n styles: '.highlight { background: #fff3bf; }',\n },\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) => baseTemplate(context, { class: 'highlight' }),\n);\n\nconst HighlightedPanel = Panel.pipe(Highlight);\n```\n\nA directive can also add context and public props:\n\n```ts\nconst WithPermission = craftDirective(\n 'WithPermission',\n {},\n (baseLogic) => (user: Input<User>) => ({\n ...baseLogic(user),\n canEdit: () => user().permissions.includes('edit'),\n }),\n (baseTemplate) => (context) =>\n context.canEdit() ? baseTemplate(context) : [],\n);\n\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nDirective styles are registered in the scope of the component that owns them.\nThe same directive can therefore be reused by several components without\nintroducing an HTML wrapper.\n\n## Composing providers and exception handlers\n\n`withProviders` configures the provider scope of a component before it is\ninvoked. `catchTag.exhaustive` is a logic boundary: each handler is a\ngenerator that can call a service or perform another logic operation. It must\nnot return template children. Use `catchNode.exhaustive` or\n`matchNode.exhaustive` when the exception should produce DOM.\n\n```ts\nimport { abstract, craftException, craftService } from '@craft-ts/core';\nimport {\n catchTag,\n craftComponent,\n p,\n withProviders,\n} from '@craft-ts/component';\n\nconst noAccess = craftException({ _tag: 'NO_ACCESS' });\nconst { RestrictedData, provideRestrictedData } = craftService(\n { name: 'restrictedData', scope: 'abstract' },\n abstract<string | typeof noAccess>(),\n);\n\nconst MyRestrictedCraftComponent = craftComponent(\n 'MyRestrictedCraftComponent',\n {},\n function* () {\n return { value: yield* RestrictedData() };\n },\n ({ value }) => p(`Private data: ${value}`),\n);\n\nconst Restricted = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* () {\n // yield* ToastService.show(() => 'No access');\n },\n }),\n);\n\nRestricted();\n```\n\nProviders are evaluated before the component template. If a provider reads a\nsignal, changing that signal recreates the composed rendering, including the\nprovider scope. The handler generator runs for the exception state. Since\n`catchTag` does not render a template, use `catchNode` or `matchNode` for a\nvisual fallback.\n\nThe component adapter reuses the exhaustive `catchTag` rules from the core and\nthe composed component carries the exception codes produced by its initializer\nand providers. The providers also participate in the normal Craft DI graph, so\nthey can satisfy dependencies used by the component and its children. The\nvariadic component `.pipe(...)` overload is currently kept permissive to avoid\nexcessive TypeScript instantiation depth; runtime dispatch still rejects an\nunhandled exception code.\n\n## Choosing an exception utility\n\nCraft exposes three complementary utilities. The important distinction is\nwhether the exception is handled in logic or rendered in a template:\n\n- `catchTag.exhaustive` handles component initialization exceptions in logic;\n- `catchNode.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchNode.exhaustive` renders a fallback from an exception value or signal.\n\n### `catchTag.exhaustive`: logic only\n\nHandlers are generator functions. They can call services and yield other Craft\noperations, but they cannot return `p(...)`, an element, or any other template\nchildren. A DOM fallback belongs to `catchNode` or `matchNode`.\n\n```ts\nconst SafeComponent = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* (exception) {\n yield* ToastService.show(() => `Access denied: ${exception._tag}`);\n },\n }),\n);\n```\n\n### `catchNode.exhaustive`: preserve a source block\n\nApply it to a rendered VNode when the source subtree may throw. The source is\nkept and the fallback is inserted at the requested position. Applying it to a\ncomponent in `.pipe(...)` also creates a residual component boundary and\nremoves the handled codes from the component and route contracts.\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive(\n {\n UserNotFoundException: () => p('User not found'),\n },\n { position: 'after' },\n ),\n);\n```\n\nFor a template boundary, the source block remains visible by default. When\n`catchNode` is piped onto a component and the exception comes from its\ncomposed scope, a function handler keeps the existing component behavior and\nreplaces the source. A handler can keep that source visible by using the object\nform and setting `showSource: true`:\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive({\n UserNotFoundException: {\n render: () => p('User not found'),\n showSource: true,\n position: 'after',\n },\n }),\n);\n```\n\nWith `showSource: true`, the source and fallback are both rendered. Use\n`showSource: false` to hide the source explicitly. `position` can be set on\neach handler (`before` or `after`); the second argument remains available as a\ndefault for handlers that do not specify their own position. Existing function\nhandlers keep their previous behavior. If the component factory or a provider\nfails before the template is created, there is no source block to preserve, so\nthe fallback is rendered alone.\n\n### `matchNode.exhaustive`: render a resource exception\n\nUse it when a query, mutation, or another primitive exposes an exception as a\nsignal instead of throwing from the template subtree. The block renders no\nchildren while the source is empty and switches reactively to the matching\nhandler when an exception appears.\n\n```ts\nmatchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', {\n UserNotFoundException: () => p('User not found'),\n UserConsentMissingException: () => p('Consent is required'),\n});\n```\n\n## What Craft handles directly\n\nCraft supports compositions that are not native properties of a standard\nthe host component or directive:\n\n- a Craft directive can declare `meta.styles` and contribute to the stylesheet\n of the component using it; Craft keeps the association with the component;\n- directive styles remain encapsulated with `@scope`, without rewriting\n selectors or adding a wrapper;\n- multiple directives can compose their logic, template, host classes, and\n styles through `.pipe(...)`;\n- styles are deduplicated and reference-counted across instances, then removed\n when the last instance is destroyed.\n\nThe directive runtime owns stylesheet injection, scoping, and cleanup, so those\nresponsibilities do not leak into application code.\n\n## Choosing the right level\n\n- `host`: identity, attributes, classes, or behavior of the root element;\n- `styles`: local, reusable component appearance; the stylesheet is shared\n across instances, while its rules remain limited to the component roots;\n- `craftDirective`: behavior or customization reusable across components;\n- the factory: component-specific state and dependencies.\n\n### Understanding style scope\n\nInside `meta.styles`, `:scope` targets every root produced by the template:\n\n\n\n\nCraft puts an internal token on the roots and generates a scope equivalent to:\n\n```css\n@scope ([data-craft-root~=\"Card\"]) to ([data-craft-root] *) {\n /* Card rules */\n}\n```\n\nIn practice:\n\n- `:scope` targets the root itself;\n- `.title` targets `Card` descendants;\n- when a child Craft component is encountered, its root becomes a boundary:\n parent rules can reach the root, but not its internal\n DOM;\n- ordinary elements do not become boundaries and do not receive an additional\n token;\n- a template returning multiple roots scopes each root, but cannot express a\n relationship between sibling roots such as `header + main`;\n- a root that is directly another Craft component can carry multiple tokens.\n The containing component can then reach into the child component: this is a\n known limitation of the current model.\n\nScoping is structural, not based on selector rewriting: modern selectors such\nas `:is()`, `:where()`, `&`, and nested rules are not transformed by Craft.\n`@media`, `@supports`, and `@container` remain inside the scope; rules that\ncannot be nested there, such as `@keyframes`, `@font-face`, `@import`, and\n`@namespace`, are hoisted outside the `@scope` block.\n\nDirective styles use the scope of their owning component because a directive\ndoes not introduce a separate root node. A directive can add `.highlight` or\nmodify `:scope`, but `:scope` then refers to the host component’s roots, not to\na directive wrapper.\n\nNames passed to `craftComponent` and `craftDirective` must be unique and match\ntheir declaration names. The dedicated ESLint rules detect missing or\ninconsistent names.\n\n## See Also\n\n- [Encapsulated styles](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Content projection](/guide/components/content-projection)\n"
|
|
130
|
+
"body": "# Customizing components and directives\n\nCraft splits customization into three layers, and which one you reach for\ndepends on how far the change should travel:\n\n| Layer | Changes |\n| --------------------- | ------------------------------------- |\n| Root-element `host` | The component's own root defaults |\n| A `*.style.ts` sheet | Its appearance |\n| Composable directives | Behaviour, reusable across components |\n\n**Start with `host`** for one component's defaults, and move to a directive only\nwhen the same customization needs to apply somewhere else too.\n\n## Customizing the root element\n\nThe component meta `host` properties define defaults for the component’s root\nelement. The caller can extend or override them:\n\n\n\n\n\nClasses, attributes, styles, and events recognized as host properties are\napplied to the component root. Other properties remain factory props. A caller's\n`class` is **added** to the host's, so the two classes must not write the same\nproperty — `featured` writes the border, which `root` leaves alone. Everything\nelse, `attrs` included, replaces the host's value.\n\nValues can be reactive. The class stays constant; what moves is an attribute the\nsheet reads as an axis:\n\n```ts\nCard({\n class: cardSheet.featured,\n 'data-cardActive': function* () {\n return String(yield* active());\n },\n});\n```\n\n## Customizing the appearance\n\nA component's look lives in a sheet beside it and nowhere else — see\n[Styling a component: the only way](/guide/components/styles). The template\nbinds the sheet's classes:\n\n```typescript\nimport { panel } from './panel.style';\n\nconst Panel = craftComponent(\n 'Panel',\n {},\n () => ({}),\n () =>\n div({ class: panel.root }, [\n h2({ class: panel.title }, 'Panel'),\n button('save', { class: panel.action, type: 'button' }, 'Save'),\n ]),\n);\n```\n\nA sheet's classes are atomic: they apply where they are bound and nowhere else,\nso there is no scope to manage and nothing leaks into a child component.\n\n## Adding reusable customization with a directive\n\nA directive transforms a component’s factory and template. It is applied from\nleft to right with `.pipe(...)`:\n\n```ts\nimport { highlight } from './highlight.style';\n\nconst Highlight = craftDirective(\n 'Highlight',\n {},\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) =>\n baseTemplate(context, { class: highlight.root }),\n);\n\nconst HighlightedPanel = Panel.pipe(Highlight);\n```\n\nA directive can also add context and public props:\n\n```ts\nconst WithPermission = craftDirective(\n 'WithPermission',\n {},\n (baseLogic) => (user: Input<User>) => ({\n ...baseLogic(user),\n canEdit: () => user().permissions.includes('edit'),\n }),\n (baseTemplate) => (context) =>\n context.canEdit() ? baseTemplate(context) : [],\n);\n\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nA directive brings its own sheet and adds its class to the host's, so the same\ndirective can be reused by several components without introducing an HTML\nwrapper.\n\n## Composing providers and exception handlers\n\n`withProviders` configures the provider scope of a component before it is\ninvoked. `catchTag.exhaustive` is a logic boundary: each handler is a\ngenerator that can call a service or perform another logic operation. It must\nnot return template children. Use `catchNode.exhaustive` or\n`matchNode.exhaustive` when the exception should produce DOM.\n\n```ts\nimport { abstract, craftException, craftService } from '@craft-ts/core';\nimport {\n catchTag,\n craftComponent,\n p,\n withProviders,\n} from '@craft-ts/component';\n\nconst noAccess = craftException({ _tag: 'NO_ACCESS' });\nconst { RestrictedData, provideRestrictedData } = craftService(\n { name: 'restrictedData', scope: 'abstract' },\n abstract<string | typeof noAccess>(),\n);\n\nconst MyRestrictedCraftComponent = craftComponent(\n 'MyRestrictedCraftComponent',\n {},\n function* () {\n return { value: yield* RestrictedData() };\n },\n ({ value }) => p(`Private data: ${value}`),\n);\n\nconst Restricted = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* () {\n // yield* ToastService.show(() => 'No access');\n },\n }),\n);\n\nRestricted();\n```\n\nProviders are evaluated before the component template. If a provider reads a\nsignal, changing that signal recreates the composed rendering, including the\nprovider scope. The handler generator runs for the exception state. Since\n`catchTag` does not render a template, use `catchNode` or `matchNode` for a\nvisual fallback.\n\nThe component adapter reuses the exhaustive `catchTag` rules from the core and\nthe composed component carries the exception codes produced by its initializer\nand providers. The providers also participate in the normal Craft DI graph, so\nthey can satisfy dependencies used by the component and its children. The\nvariadic component `.pipe(...)` overload is currently kept permissive to avoid\nexcessive TypeScript instantiation depth; runtime dispatch still rejects an\nunhandled exception code.\n\n## Choosing an exception utility\n\nCraft exposes three complementary utilities. The important distinction is\nwhether the exception is handled in logic or rendered in a template:\n\n- `catchTag.exhaustive` handles component initialization exceptions in logic;\n- `catchNode.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchNode.exhaustive` renders a fallback from an exception value or signal.\n\n### `catchTag.exhaustive`: logic only\n\nHandlers are generator functions. They can call services and yield other Craft\noperations, but they cannot return `p(...)`, an element, or any other template\nchildren. A DOM fallback belongs to `catchNode` or `matchNode`.\n\n```ts\nconst SafeComponent = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* (exception) {\n yield* ToastService.show(() => `Access denied: ${exception._tag}`);\n },\n }),\n);\n```\n\n### `catchNode.exhaustive`: preserve a source block\n\nApply it to a rendered VNode when the source subtree may throw. The source is\nkept and the fallback is inserted at the requested position. Applying it to a\ncomponent in `.pipe(...)` also creates a residual component boundary and\nremoves the handled codes from the component and route contracts.\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive(\n {\n UserNotFoundException: () => p('User not found'),\n },\n { position: 'after' },\n ),\n);\n```\n\nFor a template boundary, the source block remains visible by default. When\n`catchNode` is piped onto a component and the exception comes from its\ncomposed scope, a function handler keeps the existing component behavior and\nreplaces the source. A handler can keep that source visible by using the object\nform and setting `showSource: true`:\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchNode.exhaustive({\n UserNotFoundException: {\n render: () => p('User not found'),\n showSource: true,\n position: 'after',\n },\n }),\n);\n```\n\nWith `showSource: true`, the source and fallback are both rendered. Use\n`showSource: false` to hide the source explicitly. `position` can be set on\neach handler (`before` or `after`); the second argument remains available as a\ndefault for handlers that do not specify their own position. Existing function\nhandlers keep their previous behavior. If the component factory or a provider\nfails before the template is created, there is no source block to preserve, so\nthe fallback is rendered alone.\n\n### `matchNode.exhaustive`: render a resource exception\n\nUse it when a query, mutation, or another primitive exposes an exception as a\nsignal instead of throwing from the template subtree. The block renders no\nchildren while the source is empty and switches reactively to the matching\nhandler when an exception appears.\n\n```ts\nmatchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', {\n UserNotFoundException: () => p('User not found'),\n UserConsentMissingException: () => p('Consent is required'),\n});\n```\n\n## What Craft handles directly\n\nCraft supports compositions that are not native properties of a standard\nthe host component or directive:\n\n- a Craft directive can add its own classes to the root of the component using\n it, without a wrapper;\n- multiple directives can compose their logic, template and host classes\n through `.pipe(...)`;\n- the CSS itself is emitted once, at build time, by the `@craft-ts/style`\n plugin: nothing is injected or reference-counted at runtime.\n\n## Choosing the right level\n\n- `host`: identity, attributes, classes, or behavior of the root element;\n- a `*.style.ts` sheet: the component's appearance, its variants as axes, its\n runtime values as typed variables;\n- `craftDirective`: behavior or customization reusable across components;\n- the factory: component-specific state and dependencies.\n\n### How a parent reaches a child\n\nA parent never styles a child's internals. It has two doors, both visible in\nthe child's contract: a class it passes to the child's host, and a variable\ndeclared with `{ inherits: true }` that the child's sheet reads.\n\n\n\n\n\nThe card sets `data-cardActive`, its sheet writes `cardVars.ink`, and the title,\na separate component, reads it. Nothing in the card knows how the title is\nbuilt.\n\nNames passed to `craftComponent` and `craftDirective` must be unique and match\ntheir declaration names. The dedicated ESLint rules detect missing or\ninconsistent names.\n\n## See Also\n\n- [Styling a component](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Content projection](/guide/components/content-projection)\n"
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"path": "/guide/components/directives",
|
|
134
134
|
"title": "Directives and `.pipe(...)`",
|
|
135
|
-
"body": "# Directives and `.pipe(...)`\n\nA Craft directive decorates **both** a component's logic factory and its\ntemplate — so behaviour and markup travel together, and compose.\n\n**Use one when** the same behaviour must be added to several components:\na tooltip, a highlight, focus management, analytics on interaction.\n**Not when** the behaviour belongs to one component — put it in that component's\nfactory.\n\nDirectives are applied from left to right.\n\n```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- [
|
|
135
|
+
"body": "# Directives and `.pipe(...)`\n\nA Craft directive decorates **both** a component's logic factory and its\ntemplate — so behaviour and markup travel together, and compose.\n\n**Use one when** the same behaviour must be added to several components:\na tooltip, a highlight, focus management, analytics on interaction.\n**Not when** the behaviour belongs to one component — put it in that component's\nfactory.\n\nDirectives are applied from left to right.\n\n## Event actions and DOM modifiers\n\n`eventAction(...)` is an element directive. Use it when an element must adjust\na DOM event before invoking one action. The action stays on the element; no\n`craftMethod` wrapper is needed:\n\n```ts\nimport { button, eventAction } from '@craft-ts/component';\n\nbutton(\n 'navToggle',\n {\n type: 'button',\n 'aria-expanded': navOpen,\n },\n navOpen.navToggleLabel,\n).pipe(\n eventAction({\n click: { action: navOpen.toggle, stopPropagation: true },\n }),\n);\n```\n\nEach event entry requires `action` and can set `preventDefault`,\n`stopPropagation`, or `stopImmediatePropagation` to `true`. The modifiers run\nbefore the action in the same DOM listener. The action remains in Craft's normal\nevent pipeline, including event hooks and generator callbacks. Use the event\nname as the key, such as `click`, `submit`, or `keydown`. Do not also put that\nevent in the element's props; `eventAction` rejects duplicate handlers.\n\nThe recommended ESLint rule `craft-ts/no-event-only-craft-method` and the\ndefault architecture rule `no-event-only-craft-method` report a `craftMethod`\nthat only modifies an event and delegates to one action, even when that method\nis declared in a different file from the element.\n\n```ts\nimport {\n button,\n craftComponent,\n craftDirective,\n div,\n p,\n type HostRequiredLogic,\n type HostTemplate,\n type Input,\n} from '@craft-ts/component';\n```\n\n## `InteractivePermissions`\n\nThe examples below use a directive that adds a `permissions` object to the\ncomponent context. Its configuration is internal to the directive; the\ncomponent caller only provides the original `user` input.\n\n\n\n## Basic composition\n\nA directive transforms the existing logic and template:\n\n```ts\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions);\n```\n\nThe result of `InteractivePermissions` becomes the logic actually executed by\n`Card`:\n\n```text\ncomponent inputs\n ↓\noriginal logic\n ↓\nlogic added by the directive\n ↓\nfinal context\n ↓\nfinal template\n```\n\n## Directive configuration input\n\nA fixed configuration can be supplied when the directive is created:\n\n```ts\nconst hasPermission = (permission: Permission) =>\n craftDirective(\n 'hasPermission',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permissions: {\n canAccess: () => user().permissions.includes(permission),\n },\n };\n },\n\n (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>\n context.permissions.canAccess() ? baseTemplate(context) : [],\n );\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermission('edit'));\n```\n\n`edit` is internal configuration. The caller of `Card` does not provide it.\n\n## Input supplied by the component caller\n\nA directive can also add a public input to the component:\n\n```ts\nconst hasPermissionInput = craftDirective(\n 'hasPermissionInput',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) =>\n (user: Input<User>, permission: Input<Permission>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permission,\n permissions: {\n canAccess: () => user().permissions.includes(permission()),\n },\n };\n },\n\n (\n baseTemplate: HostTemplate<{\n user: Input<User>;\n permission: Input<Permission>;\n permissions: {\n canAccess: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canAccess() ? baseTemplate(context) : []),\n);\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermissionInput);\n\nCard({\n user: () => currentUser,\n permission: () => 'edit',\n});\n```\n\nThe directive adds `permission` to the final logic and to `Card`'s public\nprops. The renderer passes factory arguments in prop order, following the\nexisting convention for functional component factories.\n\n## Structural directive\n\nA structural directive decides whether the template produces nodes:\n\n\n\nWhen `when()` becomes false, the renderer removes the template output. When it\nbecomes true again, the template is rendered again.\n\nA structural directive can consume context added by a previous directive:\n\n```ts\nconst onlyEditable = craftDirective(\n 'onlyEditable',\n {},\n (\n baseLogic: HostRequiredLogic<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) => baseLogic,\n\n (\n baseTemplate: HostTemplate<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canEdit() ? baseTemplate(context) : []),\n);\n\nconst EditableCard = craftComponent(\n 'EditableCard',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions, onlyEditable);\n```\n\nThe context flows from left to right:\n\n```text\noriginal logic\n → InteractivePermissions\n → { user, permissions }\n → onlyEditable\n → template or []\n```\n\n## Directives on elements\n\nA component template can also apply a structural directive to a hyperscript\nnode:\n\n```ts\nconst message = p('Message').pipe(whenDirective);\n```\n\nThe component context is passed to the decorated template. Craft structural\ndirectives can therefore transform Craft output without introducing an\nintermediate component.\n\nFunctional DOM directives can also receive their configuration directly and be\napplied with `.pipe(...)`. The configuration is owned by the directive instead\nof becoming a DOM attribute:\n\n```ts\na({}, 'Tasks').pipe(CraftRouterLink(link));\n```\n\nA field configured with `insertSelectFormTree` must be selected before it is\nbound, so its lazy insertions (including validators) are registered:\n\n```ts\ninput({ type: 'email' }).pipe(\n CraftFieldDirective(loginForm.form.selectEmail()),\n);\n```\n\n## Composition rules\n\n- Create a configurable directive with `craftDirective(...)`, then pass it to\n `.pipe(...)`.\n- A directive can add public inputs; they appear in the final component props.\n- A directive placed after another receives the already decorated logic and\n template, so it can consume context added by the previous directive.\n- Generator factories continue to be executed by the Craft runtime. Dependencies\n from both the original and decorated factories remain part of the component\n dependency contract.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Styling a component](/guide/components/styles)\n- [Testing components](/guide/testing/components)\n"
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
"path": "/guide/components/fine-grained-reactivity",
|
|
139
139
|
"title": "Fine-grained reactivity",
|
|
140
|
-
"body": "# Fine-grained reactivity\n\nCraft templates are reactive at the **binding** level. When a signal changes,\nCraft updates the text node, DOM property, class, style, or host binding that\nread it. It does not need to execute the surrounding component template again.\n\n```ts\n({ counter }) =>\n div([\n h2('Counter'),\n p({ class:
|
|
140
|
+
"body": "# Fine-grained reactivity\n\nCraft templates are reactive at the **binding** level. When a signal changes,\nCraft updates the text node, DOM property, class, style, or host binding that\nread it. It does not need to execute the surrounding component template again.\n\n```ts\n// `counterSheet` is the component's sheet, from counter.style.ts.\n({ counter }) =>\n div([\n h2('Counter'),\n p({ class: counterSheet.value }, counter),\n button({ click: counter.increment }, '+'),\n ]);\n```\n\nHere, `counter` is passed to `p` as a yieldable reader. The renderer drives the\nread for that text binding. Incrementing the counter evaluates that binding and\npatches its text node; the `div`, heading, button, and component template remain\nuntouched.\n\n## The binding is the reactive boundary\n\nA function in a rendered position declares a binding. The callback only reads\nvalues already derived by the primitive layer; comparisons, formatting, and UI\ndecisions stay out of the template:\n\n```ts\np(items.totalLabel);\n\nbutton(\n {\n disabled: items.isEmpty,\n title: items.clearTitle,\n },\n 'Clear',\n);\n\ndiv({\n class: items.emptyClass,\n style: items.emptyStyle,\n});\n```\n\n`totalLabel`, `isEmpty`, `clearTitle`, `emptyClass`, and `emptyStyle` are named\nderived values exposed by the state, query, insertion, or component context.\nPass the reader. If only an item-related dependency changes, Craft evaluates\nonly the affected bindings. A sibling binding depending on another reader does\nnot run.\n\nStatic values do not need callbacks:\n\n```ts\nh2('Shopping cart');\nbutton({ type: 'button' }, 'Clear');\n```\n\n## Do not read reactive values while building the template\n\nA direct read happens while the component constructs its VNodes. It cannot be\nassigned to one precise DOM binding and becomes a structural template\ndependency instead:\n\n```ts\n// Avoid: these reads happen in the component template.\np(items.totalLabel());\nbutton({ disabled: items.isEmpty() }, 'Clear');\ndiv({ class: items.emptyClass() });\n```\n\nMove each read into the binding that consumes it:\n\n```ts\np(items.totalLabel);\nbutton({ disabled: items.isEmpty }, 'Clear');\ndiv({ class: items.emptyClass });\n```\n\nThis is also the rule for component inputs. Pass a yieldable reader directly\nwhen the child must observe a changing value. When the child needs fields of\nan object, explicitly adapt the input with `deepYieldable`:\n\n```ts\nUserCard({ user: selectedUser });\n```\n\nThe reader is lazy: constructing the parent template does not read\n`selectedUser`. Craft installs it as the source of the child's `user` input.\nThe child then decides which granular binding observes it:\n\n```ts\nconst UserCard = craftComponent(\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => h2(user.displayName),\n);\n```\n\nWhen the `h2` binding first evaluates, `yield* user()` invokes the reader,\nwhich reads `selectedUser`. That text binding becomes the signal consumer.\nWhen the selected user changes, only the binding evaluates again and patches\nthe existing `h2`; neither the parent template nor the child component template\nruns again.\n\nReading the input eagerly in the child would move the dependency back to the\ncomponent boundary and is rejected by `require-reactive-template-bindings`:\n\n```ts\n// Avoid: resolving the input while the child template is built.\nh2(craftUse(user()).displayName);\n```\n\n## Structure has its own reactive scopes\n\nBindings update an existing node. Blocks own changes to the shape of the tree:\n\n```ts\nifNode(\n hasItems,\n () => CartItems({ items: () => items() }),\n () => p('Your cart is empty.'),\n);\n\nforNode(items, { track: (item) => item.id }, (item) => p(item.name));\n```\n\n`ifNode`, `forNode`, `matchNode.exhaustive`, and `deferNode` isolate their own\nstructural work. A branch or list can change without making the parent\ncomponent rebuild unrelated siblings. Use these helpers for structure and\nbinding callbacks for values on existing nodes.\n\n### Progressive `forNode` rendering\n\nSee the dedicated [Progressive `forNode` rendering](/guide/components/schedule-for)\nguide for the complete usage and trade-offs.\n\n`forNode` is synchronous by default. For a large collection, opt into frame-based\nbatching on the `forNode` node itself:\n\n```ts\nforNode(items, { track: (item) => item.id }, (item) => p(item.name)).pipe(\n scheduleFor({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nUse `frame` when the list should begin appearing quickly while leaving room for\ninput and painting between batches. `enabled: false` restores synchronous\nrendering, regardless of the selected strategy. The first delivery supports\n`sync` and `frame`; `idle` is reserved for a later scheduler implementation.\n\nScheduling improves perceived responsiveness, but it does not reduce the total\nwork needed to create or update the list. For very large or continuously\nscrolling collections, virtualisation is still preferable because it reduces\nthe number of DOM nodes and bindings that exist at once.\n\n## Keep bindings pure and free of logic\n\nA binding reads a value already derived by the primitive layer. It does not\nformat data, make business decisions, or write state:\n\n```ts\n// Correct: the primitive exposes the render-ready reader.\np(cart.formattedTotal);\n\n// Incorrect: rendering changes application state.\np(function* () {\n yield* counter.update((value) => value + 1);\n return yield* counter();\n});\n```\n\nPerform writes from DOM events, outputs, mutations, or explicit business\neffects. Purity makes a binding safe to evaluate whenever one of its\ndependencies changes.\n\n## Enforce the model with ESLint\n\nEnable both renderer rules with type-aware ESLint configuration:\n\n```js\nexport default [\n {\n files: ['**/*.ts'],\n languageOptions: {\n parserOptions: { projectService: true },\n },\n rules: {\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-render-writes': 'error',\n },\n },\n];\n```\n\n- `require-reactive-template-bindings` rejects direct reads of signals,\n Craft values, and component inputs during VNode construction.\n- `no-render-writes` rejects detectable `set`, `update`, and `mutate` calls from\n templates and binding callbacks while allowing event and output handlers.\n\nSee the [ESLint rules reference](/guide/routing/eslint-rules) for the complete\nconfiguration.\n\n## What you should observe\n\nAfter a binding dependency changes:\n\n- the affected DOM value changes;\n- the node keeps its identity;\n- unrelated bindings do not evaluate;\n- the component template does not emit a new `component / update` trace.\n\nThe current template trace reports component and structural renders, not each\nindividual text or property effect. The absence of a component update therefore\nconfirms that the change stayed below the component boundary; a DOM assertion\nconfirms that the expected binding was patched.\n\nEffects are owned by their rendered nodes. Removing a branch, list item, or\ncomponent destroys its binding effects, so their dependencies are released with\nthe DOM they served.\n\n## Migration checklist\n\n1. Move comparisons, formatting, and display decisions into named derived\n primitive values such as `items.isEmpty`.\n2. Pass yieldable readers to text bindings (`p(counter)`), or use a generator\n when the binding must format: `p(function* () { return \\`Count: ${yield* counter()}\\`; })`.\n3. Pass yieldable readers to DOM properties such as `value`, `disabled`, and\n `title`.\n4. Return complete reactive class and style readers from the primitive.\n5. Pass changing component inputs as yieldable readers.\n6. Express structural changes with `ifNode`, `forNode`,\n `matchNode.exhaustive`, or `deferNode`.\n7. Enable the two ESLint rules and remove every direct reactive template read.\n\nContinue with [Components](/guide/components/) for the complete\n`craftComponent` model or [Observability](/guide/advanced/observability) to\ninspect rendering and correlated interactions.\n"
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
"path": "/guide/components/pending-node",
|
|
@@ -151,8 +151,8 @@
|
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"path": "/guide/components/styles",
|
|
154
|
-
"title": "
|
|
155
|
-
"body": "#
|
|
154
|
+
"title": "Styling a component: the only way",
|
|
155
|
+
"body": "# Styling a component: the only way\n\nA component is styled through [`@craft-ts/style`](../style/), and through\nnothing else. Its visual rules live in a `*.style.ts` sheet beside it; the\ntemplate binds the sheet's classes, sets `data-*` attributes for its variants,\nand writes typed variables for what changes at runtime. There is no CSS string\non the meta, no `.css` import, no class assembled at render time and no raw\n`style`.\n\nThat is not a preference. A class built in the browser, or a rule shipped as a\nstring, is a visual state nothing recorded: the [visual matrix](/guide/style/testing)\nenumerates what the sheets declare, the [static contrast check](/guide/style/contrast)\nmeasures what the sheets write, and anything outside them is invisible to both.\nESLint and the architecture suite therefore refuse every other route, and the\none real exception is written down, with its reason, for someone to decide on.\n\n## The shape\n\nThe sheet declares the classes, the axis a variant moves along, and the\nvariables a template may write:\n\n\n\nThe component imports it and binds **one constant class per element**:\n\n\n\n- `class` is always a sheet key (`card.root`), an array of them, or a typed\n input carrying one. Never a string, a template literal or a conditional.\n- The variant is an **attribute**. `data-cardTone` is on the element, the sheet\n reads it through `when(cardTone.danger, …)`, and a `null` removes it.\n- `style` accepts `assign(variable, value)` and nothing else — one call, several\n spread into an object, or a function returning them.\n\n## Where each thing goes\n\n| You want | Write |\n| ------------------------------------------ | --------------------------------------------------------------------------------------- |\n| the component's own look | `craftStyles('name', { root: [...] })` in `name.style.ts` |\n| a variant (tone, size, selected) | `defineStateAxis(...)`, then `when(axis.point, [...])`; the template sets `data-*` |\n| a state the platform already announces | `ariaCurrent`, `ariaPressed`, `ariaInvalid`, `interaction.hover` / `.focus` / `.disabled` |\n| a value known only at runtime | `cssVars(...)` in the sheet, `assign(...)` in the template |\n| a child that follows its parent's state | a variable declared with `{ inherits: true }`, set by the parent, read by the child |\n| page defaults (`body`, links, the theme) | [`craftGlobalStyles`](/guide/style/foundation) |\n| a web font | [`defineFont`](/guide/style/foundation) |\n| `::before`, `@keyframes`, transitions | [`pseudo.*`, `keyframes`, `animate`](/guide/style/pseudo-elements) |\n\nThe reset and the good defaults — focus ring, reduced motion, colour scheme —\ncome from `@craft-ts/style` itself. An app has no `styles.css` to write.\n\n## What refuses the other routes\n\nPer file, in `craftRules.configs.recommended`\n([details](/guide/routing/eslint-rules)):\n\n- `no-raw-class` — a `class` that does not trace back to a sheet imported from a\n `*.style` module;\n- `no-inline-style` — a `style` that is not `assign(...)`;\n- `no-component-css` — `meta.styles`, `meta.stylesUrl`, `meta.contentStyles`, and\n any `.css` import other than `virtual:craft-style.css`;\n- `style-file-boundary` — a sheet importing anything but style vocabulary;\n- `no-raw-css-value`, `no-free-has` — a raw value or a hand-written `:has()`\n inside a sheet.\n\nAcross the application, in the base architecture rules\n([details](/guide/testing/architecture)):\n\n- `style-only-design-system` — an element whose class reaches no sheet the build\n emits;\n- `no-global-stylesheet` — an entry file importing a `.css`, or `index.html`\n linking a stylesheet;\n- `style-obligations-discharged`, `no-dangling-css-vars` — a `requires` nobody\n provides, a variable read and never declared.\n\n`styles`, `stylesUrl`, `contentStyles` and `cssVars` still exist on the meta's\ntype, marked `@deprecated`. They are kept so that the exception below stays\npossible, not as an alternative.\n\n## The one real exception\n\nContent you do not author — HTML rendered from markdown, a third-party widget\nthat ships its own stylesheet — cannot be styled through a sheet. It is the only\ncase, and it takes an explicit, reasoned bypass:\n\n```ts\n// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet\nimport 'vendor-date-picker/dist/picker.css';\n```\n\n`no-forbidden-eslint-disable` refuses the directive without its reason. On the\narchitecture side, the bypass is a waiver in `architecture/waivers.ts`:\n\n```ts\n{\n rule: 'no-global-stylesheet',\n target: 'file:src/main.ts',\n reason: 'The vendor date picker ships its stylesheet.',\n}\n```\n\nA waiver names one target, not a rule wholesale, and one that no longer waives\nanything fails the check. Both kinds of bypass appear in the **Bypasses** view of\n[Review Attest](/guide/style/attestation), one subject per directive or waiver,\nto be accepted or rejected like any other evidence.\n\n## See Also\n\n- [`@craft-ts/style`](/guide/style/) — the design system, from tokens to the matrix\n- [Axes and the visual matrix](/guide/style/variants)\n- [Customization](/guide/components/customization) — host properties and caller overrides\n"
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
"path": "/guide/components/template-migrator",
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
{
|
|
198
198
|
"path": "/guide/create-project",
|
|
199
199
|
"title": "Create a CraftTS project",
|
|
200
|
-
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### `npm run style:check`\n\nWith typed CSS enabled, the project gets one more, and it is the only one that\nneeds explaining:\n\n```bash\nnpm run style:check\n```\n\nIt builds once — which is how the style plugin writes\n`.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for\nevery element the graph can show holds text, in every state your axes can\nproduce, with no browser involved. It is in the generated CI workflow.\n\nThe starter is set up to pass it out of the box: the palette is named, so a\nfailure can say `ui.accent.dangerHover` rather than a hexadecimal string, and\nthe generated link writes its hovered colour through `interaction.hover`\nrather than a hand-written selector, so the hovered state is a state the check\ncan actually measure.\n\nTwo things to know before your first failure:\n\n- **A result the analysis cannot prove fails the run.** `--allow-indeterminate`\n turns those into warnings and you have to type it. A check whose default\n treats \"I could not tell\" as \"fine\" reports a clean bill on the part of the\n application it did not understand.\n- **A clean run is a contrast proof, not an accessibility audit.**\n\n[Text contrast](./style/contrast.md) has the full coverage contract: what is\nproven, what comes back as `indeterminate`, and how to close a gap honestly.\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
200
|
+
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nEvery project styles through `@craft-ts/style`, whatever the options: there is\nno plain-CSS starter and no `src/styles.css`. The element defaults and the app\nshell live in `src/app/app.style.ts`; `--design-system=basic` adds the starter\ndesign system in `src/app/ui/ui.style.ts`. `--no-typed-css` is no longer\naccepted.\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### `npm run style:check`\n\nWith typed CSS enabled, the project gets one more, and it is the only one that\nneeds explaining:\n\n```bash\nnpm run style:check\n```\n\nIt builds once — which is how the style plugin writes\n`.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for\nevery element the graph can show holds text, in every state your axes can\nproduce, with no browser involved. It is in the generated CI workflow.\n\nThe starter is set up to pass it out of the box: the palette is named, so a\nfailure can say `ui.accent.dangerHover` rather than a hexadecimal string, and\nthe generated link writes its hovered colour through `interaction.hover`\nrather than a hand-written selector, so the hovered state is a state the check\ncan actually measure.\n\nTwo things to know before your first failure:\n\n- **A result the analysis cannot prove fails the run.** `--allow-indeterminate`\n turns those into warnings and you have to type it. A check whose default\n treats \"I could not tell\" as \"fine\" reports a clean bill on the part of the\n application it did not understand.\n- **A clean run is a contrast proof, not an accessibility audit.**\n\n[Text contrast](./style/contrast.md) has the full coverage contract: what is\nproven, what comes back as `indeterminate`, and how to close a gap honestly.\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
"path": "/guide/deployment",
|
|
@@ -337,7 +337,7 @@
|
|
|
337
337
|
{
|
|
338
338
|
"path": "/guide/routing/eslint-rules",
|
|
339
339
|
"title": "ESLint rules",
|
|
340
|
-
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nThe recommended preset bans every TypeScript assertion in authored Craft code,\nincluding `as const`:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];\n```\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-explicit-craft-template-return-type': 'error',\n 'craft-ts/no-extracted-craft-component-parts': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/require-form-for-input-action': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/max-craft-component-lines': 'warn',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-explicit-resource-loader-type': 'error',\n 'craft-ts/no-explicit-craft-insertion-type': 'error',\n 'craft-ts/no-craft-primitive-type-assertion': 'error',\n 'craft-ts/prefer-insert-deep-yieldable': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/require-craft-computed-for-dynamic-template-lookup`: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a named `craftComputed()` in the component logic factory and bind that value directly\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-explicit-craft-template-return-type`: forbids explicit return annotations on render callbacks inside `craftComponent(...)`. A broad annotation such as `(): CraftNodeChildren` widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:\n\n ```ts\n const pendingStatusMessage = (message: string) => p(message);\n\n // ❌ The annotation erases the concrete node/dependency information.\n pendingNode({\n fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),\n reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),\n });\n\n // ✅ The concrete `p(...)` node stays visible to Craft's inference.\n pendingNode({\n fallback: () => pendingStatusMessage('Loading…'),\n reloading: () => pendingStatusMessage('Reloading…'),\n });\n ```\n\n The rule is autofixable with `eslint --fix`. Return annotations on DOM event\n and output callbacks remain allowed because those callbacks do not produce\n rendered children.\n\n- `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and\n template passed to `craftComponent(...)` to stay inline. Keeping both parts at\n the component boundary preserves contextual type inference and makes the\n component's behaviour readable in one place. The rule reports both extracted\n identifiers independently.\n\n Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's\n two halves behind names at the call site:\n\n ```ts\n // ❌ craft-ts/no-extracted-craft-component-parts\n const ReviewLogic = craftGen(function* () {\n return { review, decide };\n });\n\n const ReviewTemplate = craftTemplate(({ decide }) =>\n div([button({ click: decide }, 'Review')]),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n ReviewLogic,\n ReviewTemplate,\n );\n ```\n\n After — keep the logic and template callback in the component call:\n\n ```ts\n // ✅\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n craftGen(function* () {\n return { review, decide };\n }),\n ({ decide }) => div([button({ click: decide }, 'Review')]),\n );\n ```\n\n The rule only rejects identifiers in the logic and template argument\n positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)`\n expressions remain valid. A direct template callback is usually the simplest\n form because `craftComponent(...)` can contextually type it from the inline\n logic factory.\n\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/require-form-for-input-action`: rejects a button's direct `mutate(...)` or `method(...)` call when it consumes an input-bound value, including through a local record or variable; use `insertForm`, `insertFormAttributes`, and `insertFormSubmit` for mutation-backed forms, then submit a native `form(...)` with a `type: 'submit'` button\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/max-craft-component-lines`: reports a file that declares a `craftComponent(...)` once it exceeds **700 non-import lines** (`import` statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:\n\n ```ts\n // ❌ craft-ts/max-craft-component-lines\n // review-app.ts — 3894 lines: filtering, sorting, diff computation,\n // pagination, and the full markup tree all inlined in one logic factory\n // and one template.\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filtered = craftComputed(() => /* 80 lines of filtering */ []);\n const diff = craftComputed(() => /* 150 lines of diffing */ null);\n // …dozens more computeds and craftMethods…\n return { subjects, filtered, diff /* … */ };\n },\n ({ filtered, diff /* … */ }) =>\n div(\n {},\n /* a thousand-plus lines of markup for the filter bar, the diff\n viewport, the review card list, and the pagination controls */\n ),\n );\n\n // ✅ Business logic moves to a craftService; independent template\n // regions become their own craftComponent, each testable and readable\n // on its own.\n export const ReviewFilters = craftService(\n { name: 'ReviewFilters', scope: 'global' },\n () => ({\n filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],\n }),\n );\n\n export const SubjectDiffViewport = craftComponent(\n 'SubjectDiffViewport',\n {},\n (subject: Input<Subject>) => ({ subject }),\n ({ subject }) => div({} /* … */),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filters = injectX(ReviewFilters);\n const filtered = craftComputed(() =>\n filters.filter(subjects(), criteria()),\n );\n return { filtered /* … */ };\n },\n ({ filtered }) =>\n div(\n {},\n forNode(filtered, (subject) => SubjectDiffViewport({ subject })),\n ),\n );\n ```\n\n Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is\n still too generous for your team.\n\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-type-assertions-in-craft-code`: forbids TypeScript type assertions in authored Craft code, including `as const` and angle-bracket assertions; the narrow `undefined as T | undefined` seed is allowed for intentionally optional state values. Use correct API typing or `satisfies` for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.\n- `craft-ts/no-explicit-resource-loader-type`: forbids explicit parameter and return annotations on `query`, `mutation`, and `asyncProcess` loaders; let the resource infer its contract from `params`, `method`, and the yielded operations instead of writing `Generator<...>` or `{ params: string }`\n- `craft-ts/no-explicit-craft-insertion-type`: forbids explicit parameter and return annotations on callbacks passed to `insert*Pipe`; let the primitive infer the insertion context and derived output\n- `craft-ts/no-craft-primitive-type-assertion`: forbids chained assertions such as `as unknown as Generator<...>` around Craft primitive generators, which can hide the inferred output and dependency contract\n- `craft-ts/prefer-insert-deep-yieldable`: rejects adapting a property of a primitive result with `deepYieldable(...)`; add `insertDeepYieldable()` to the primitive and read the property directly\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\nThe loader signature should also stay inferred:\n\n```ts\n// Incorrect: these annotations can mask a mismatch in the resource contract.\nloader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {\n return yield* client({ token: params });\n}\n\n// Correct: infer params and the generator result from the resource and body.\nloader: function* ({ params }) {\n return yield* client({ token: params });\n}\n```\n\n`no-explicit-resource-loader-type` reports only annotations on the loader\nsignature. Type annotations for local variables and function contracts outside\nthe loader remain allowed.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn (\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n }))\n);\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\n### Preserve primitive inference\n\nThe insertion callback already receives a contextual type, and the primitive\nalready knows the complete type of its generator. Do not repeat either type at\nthe boundary:\n\n```ts\n// ❌ craft-ts/no-explicit-craft-insertion-type\ninsertQueryPipe(\n ({ resource }): SpaceQueryView => ({\n items: craftComputed(() => resource.value()),\n }),\n);\n\n// ❌ craft-ts/no-craft-primitive-type-assertion\nconst generator = query('spaceItems', config) as unknown as Generator<\n unknown,\n SpaceQueryRef,\n unknown\n>;\n\n// ✅\nconst generator = query(\n 'spaceItems',\n config,\n insertQueryPipe(({ resource }) => ({\n items: craftComputed(() => resource.value()),\n })),\n);\n```\n\nThe assertion is especially harmful around a composed insertion pipe: it\nreplaces the type that carries the derived properties and their dependencies.\n\n### Prefer primitive deep-yieldable insertions\n\nWhen a property is read from the result of a primitive, expose the deep view at\nthe primitive boundary. This keeps the property reader connected to the\nprimitive and avoids an extra adapter:\n\n```ts\n// ❌ craft-ts/prefer-insert-deep-yieldable\nconst spaceQuery = yield * spaceQueryGenerator;\nconst deepItems = deepYieldable(spaceQuery.items);\n\n// ✅ add insertDeepYieldable() to the query call, then:\nconst spaceQuery = yield * spaceQueryGenerator;\nconst items = spaceQuery.items;\n```\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog =\n yield * state('catalog', { products }, insertDeepYieldable('products'));\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
340
|
+
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nThe recommended preset bans every TypeScript assertion in authored Craft code,\nincluding `as const`:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];\n```\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-explicit-craft-template-return-type': 'error',\n 'craft-ts/no-extracted-craft-component-parts': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/require-form-for-input-action': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/no-external-state-transition': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/max-craft-component-lines': 'warn',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-explicit-resource-loader-type': 'error',\n 'craft-ts/no-explicit-craft-insertion-type': 'error',\n 'craft-ts/no-craft-primitive-type-assertion': 'error',\n 'craft-ts/prefer-insert-deep-yieldable': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/require-craft-computed-for-dynamic-template-lookup`: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a named `craftComputed()` in the component logic factory and bind that value directly\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/no-external-state-transition`: rejects generic `replace`, `set`, `update`, or `patch` calls on a value returned by Craft `state(...)` outside its state insertion. Put the transition behind a named state method that accepts intent and computes the next value internally.\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-explicit-craft-template-return-type`: forbids explicit return annotations on render callbacks inside `craftComponent(...)`. A broad annotation such as `(): CraftNodeChildren` widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:\n\n ```ts\n const pendingStatusMessage = (message: string) => p(message);\n\n // ❌ The annotation erases the concrete node/dependency information.\n pendingNode({\n fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),\n reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),\n });\n\n // ✅ The concrete `p(...)` node stays visible to Craft's inference.\n pendingNode({\n fallback: () => pendingStatusMessage('Loading…'),\n reloading: () => pendingStatusMessage('Reloading…'),\n });\n ```\n\n The rule is autofixable with `eslint --fix`. Return annotations on DOM event\n and output callbacks remain allowed because those callbacks do not produce\n rendered children.\n\n- `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and\n template passed to `craftComponent(...)` to stay inline. Keeping both parts at\n the component boundary preserves contextual type inference and makes the\n component's behaviour readable in one place. The rule reports both extracted\n identifiers independently.\n\n Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's\n two halves behind names at the call site:\n\n ```ts\n // ❌ craft-ts/no-extracted-craft-component-parts\n const ReviewLogic = craftGen(function* () {\n return { review, decide };\n });\n\n const ReviewTemplate = craftTemplate(({ decide }) =>\n div([button({ click: decide }, 'Review')]),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n ReviewLogic,\n ReviewTemplate,\n );\n ```\n\n After — keep the logic and template callback in the component call:\n\n ```ts\n // ✅\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n craftGen(function* () {\n return { review, decide };\n }),\n ({ decide }) => div([button({ click: decide }, 'Review')]),\n );\n ```\n\n The rule only rejects identifiers in the logic and template argument\n positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)`\n expressions remain valid. A direct template callback is usually the simplest\n form because `craftComponent(...)` can contextually type it from the inline\n logic factory.\n\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/require-form-for-input-action`: rejects a button's direct `mutate(...)` or `method(...)` call when it consumes an input-bound value, including through a local record or variable; use `insertForm`, `insertFormAttributes`, and `insertFormSubmit` for mutation-backed forms, then submit a native `form(...)` with a `type: 'submit'` button\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/max-craft-component-lines`: reports a file that declares a `craftComponent(...)` once it exceeds **700 non-import lines** (`import` statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:\n\n ```ts\n // ❌ craft-ts/max-craft-component-lines\n // review-app.ts — 3894 lines: filtering, sorting, diff computation,\n // pagination, and the full markup tree all inlined in one logic factory\n // and one template.\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filtered = craftComputed(() => /* 80 lines of filtering */ []);\n const diff = craftComputed(() => /* 150 lines of diffing */ null);\n // …dozens more computeds and craftMethods…\n return { subjects, filtered, diff /* … */ };\n },\n ({ filtered, diff /* … */ }) =>\n div(\n {},\n /* a thousand-plus lines of markup for the filter bar, the diff\n viewport, the review card list, and the pagination controls */\n ),\n );\n\n // ✅ Business logic moves to a craftService; independent template\n // regions become their own craftComponent, each testable and readable\n // on its own.\n export const ReviewFilters = craftService(\n { name: 'ReviewFilters', scope: 'global' },\n () => ({\n filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],\n }),\n );\n\n export const SubjectDiffViewport = craftComponent(\n 'SubjectDiffViewport',\n {},\n (subject: Input<Subject>) => ({ subject }),\n ({ subject }) => div({} /* … */),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filters = injectX(ReviewFilters);\n const filtered = craftComputed(() =>\n filters.filter(subjects(), criteria()),\n );\n return { filtered /* … */ };\n },\n ({ filtered }) =>\n div(\n {},\n forNode(filtered, (subject) => SubjectDiffViewport({ subject })),\n ),\n );\n ```\n\n Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is\n still too generous for your team.\n\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-event-only-craft-method`: errors by default when a `craftMethod` only calls `preventDefault()`, `stopPropagation()`, or `stopImmediatePropagation()` and then delegates to one action. Bind the action with [`eventAction(...)`](/guide/components/directives#event-actions-and-dom-modifiers) on the element. The default architecture check enforces the same rule across files.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-type-assertions-in-craft-code`: forbids TypeScript type assertions in authored Craft code, including `as const` and angle-bracket assertions; the narrow `undefined as T | undefined` seed is allowed for intentionally optional state values. Use correct API typing or `satisfies` for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.\n- `craft-ts/no-explicit-resource-loader-type`: forbids explicit parameter and return annotations on `query`, `mutation`, and `asyncProcess` loaders; let the resource infer its contract from `params`, `method`, and the yielded operations instead of writing `Generator<...>` or `{ params: string }`\n- `craft-ts/no-explicit-craft-insertion-type`: forbids explicit parameter and return annotations on callbacks passed to `insert*Pipe`; let the primitive infer the insertion context and derived output\n- `craft-ts/no-craft-primitive-type-assertion`: forbids chained assertions such as `as unknown as Generator<...>` around Craft primitive generators, which can hide the inferred output and dependency contract\n- `craft-ts/prefer-insert-deep-yieldable`: rejects adapting a property of a primitive result with `deepYieldable(...)`; add `insertDeepYieldable()` to the primitive and read the property directly\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: requires every `class:` binding — on an element, in `attrs`, on a component `host` — to trace back to a sheet imported from a `*.style` module: `sheet.key`, a `const` bound to one, an array of them, a typed input (a parameter or a member of one), or a function that only returns one. A string, a template literal, a conditional or an object of booleans is refused. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else; and a sheet declared outside a `*.style.ts` is never evaluated by the build, so its class has no CSS. Make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-inline-style`: restricts `style:` to `assign(...)` from `@craft-ts/style` — or an array of them, an object spreading only them, a conditional whose branches are all of them, or a function that only returns them. What varies at runtime is a typed variable (`cssVars` + `assign`), read by a sheet; `attrs.style` is always refused\n- `craft-ts/no-component-css`: forbids `meta.styles`, `meta.stylesUrl` and `meta.contentStyles` on `craftComponent` / `craftDirective`, and every `.css` import except `virtual:craft-style.css`. Global rules go in [`craftGlobalStyles`](/guide/style/foundation), fonts in `defineFont`\n- `craft-ts/no-forbidden-eslint-disable`: requires a reason on every directive that disables a design-system rule — `// eslint-disable-next-line craft-ts/no-raw-class -- markdown output carries its own classes`. The reason is what the reviewer decides on in Review Attest. It also forbids disabling the rules listed in `.craft/eslint-disable-policy.json`. A blanket `eslint-disable` silences this rule too, so it cannot be reported here; Review Attest lists it\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value. Part of the `legacyComponentCss` preset (see below)\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\nThe loader signature should also stay inferred:\n\n```ts\n// Incorrect: these annotations can mask a mismatch in the resource contract.\nloader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {\n return yield* client({ token: params });\n}\n\n// Correct: infer params and the generator result from the resource and body.\nloader: function* ({ params }) {\n return yield* client({ token: params });\n}\n```\n\n`no-explicit-resource-loader-type` reports only annotations on the loader\nsignature. Type annotations for local variables and function contracts outside\nthe loader remain allowed.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn (\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n }))\n);\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\n### Preserve primitive inference\n\nThe insertion callback already receives a contextual type, and the primitive\nalready knows the complete type of its generator. Do not repeat either type at\nthe boundary:\n\n```ts\n// ❌ craft-ts/no-explicit-craft-insertion-type\ninsertQueryPipe(\n ({ resource }): SpaceQueryView => ({\n items: craftComputed(() => resource.value()),\n }),\n);\n\n// ❌ craft-ts/no-craft-primitive-type-assertion\nconst generator = query('spaceItems', config) as unknown as Generator<\n unknown,\n SpaceQueryRef,\n unknown\n>;\n\n// ✅\nconst generator = query(\n 'spaceItems',\n config,\n insertQueryPipe(({ resource }) => ({\n items: craftComputed(() => resource.value()),\n })),\n);\n```\n\nThe assertion is especially harmful around a composed insertion pipe: it\nreplaces the type that carries the derived properties and their dependencies.\n\n### Prefer primitive deep-yieldable insertions\n\nWhen a property is read from the result of a primitive, expose the deep view at\nthe primitive boundary. This keeps the property reader connected to the\nprimitive and avoids an extra adapter:\n\n```ts\n// ❌ craft-ts/prefer-insert-deep-yieldable\nconst spaceQuery = yield * spaceQueryGenerator;\nconst deepItems = deepYieldable(spaceQuery.items);\n\n// ✅ add insertDeepYieldable() to the query call, then:\nconst spaceQuery = yield * spaceQueryGenerator;\nconst items = spaceQuery.items;\n```\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`) —\n superseded by the `craft.base` layer of [`@craft-ts/style`](/guide/style/foundation),\n which lays both once for the document; they are no longer in `recommended`\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog =\n yield * state('catalog', { products }, insertDeepYieldable('products'));\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe design system is the only way to style a component. The style rules —\n`no-raw-class`, `no-inline-style`, `no-component-css`, `no-raw-css-value`,\n`no-free-has`, `style-file-boundary` — are in `craftRules.configs.recommended`\nat `'error'`, in **every** file: none of them waits for a file to import\n`@craft-ts/style`.\n\nTo migrate a project in steps, turn the three binding rules off in **its own**\nESLint config, with a `TODO` comment the migration removes, and keep\nthe rules that read the legacy component CSS on in the meantime:\n\n```js\n{\n // TODO: remove once this project is migrated to @craft-ts/style.\n files: ['**/src/**/*.ts'],\n rules: {\n ...craftRules.configs.legacyComponentCss.rules,\n 'craft-ts/no-raw-class': 'off',\n 'craft-ts/no-inline-style': 'off',\n 'craft-ts/no-component-css': 'off',\n },\n},\n```\n\n`legacyComponentCss` groups the rules that read a component's CSS text —\n`craft-css-vars-contract`, `craft-styles-scope-safe`, `craft-css-var-naming`,\n`craft-css-token-registry`, `no-hardcoded-design-values`,\n`no-important-in-component-styles`, `require-focus-visible`,\n`require-reduced-motion`. They left `recommended` because `no-component-css`\nleaves them nothing to read.\n\nA genuine bypass — a third-party widget that ships its own CSS, HTML rendered\nfrom markdown — stays possible, one line at a time, with a reason:\n`// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet`.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
341
341
|
},
|
|
342
342
|
{
|
|
343
343
|
"path": "/guide/routing/exception-handling",
|
|
@@ -357,12 +357,12 @@
|
|
|
357
357
|
{
|
|
358
358
|
"path": "/guide/routing/pending-ui",
|
|
359
359
|
"title": "Non-blocking navigation",
|
|
360
|
-
"body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Token | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CRAFT_PENDING_COMPONENT` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CRAFT_LOADING_TEXT` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CRAFT_STAY_MS` / `CRAFT_BLANK_MS` / `CRAFT_PENDING_MIN_MS` | `300` / `300` / `0` |\n| `withErrorComponent` | `CRAFT_ERROR_COMPONENT` | `null` |\n| `withRouteLoadError` | `CRAFT_ROUTE_LOAD_ERROR_COMPONENT` / `CRAFT_ROUTE_LOAD_RETRY` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CRAFT_VIEW_TRANSITIONS_ENABLED` / `CRAFT_VIEW_TRANSITION_SKIP_BLANK` | `false` / `false` |\n| `withA11yNavigationFocus` | `CRAFT_A11Y_NAVIGATION_FOCUS` | `false` |\n\nThe default pending component renders `CRAFT_LOADING_TEXT`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren`; its routed\ncomponents carry their own per-route DI checks.\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton receives `photoId` as a route-bound input and reads the payload through the\n**route-generated typed helper**:\n\n```ts\nimport { input } from '@angular/core';\n\nexport default class PhotoSkeleton {\n protected readonly photoId = input.required<string>();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …). It is verified independently with the per-component, O(1)\n[`RouteCheckedDI`](/guide/routing/setup) check:\n\n```ts\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the route uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the\nauto-provided service names from the route's path params + payload.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
|
|
360
|
+
"body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Service helper(s) | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CraftPendingComponent` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CraftLoadingText` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CraftStayMs` / `CraftBlankMs` / `CraftPendingMinMs` | `300` / `300` / `0` |\n| `withErrorComponent` | `CraftErrorComponent` | `null` |\n| `withRouteLoadError` | `CraftRouteLoadErrorConfig` / `CraftRouteLoadRetry` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CraftViewTransitionsEnabled` / `CraftViewTransitionSkipBlank` | `false` / `false` |\n| `withA11yNavigationFocus` | `CraftA11yNavigationFocus` | `false` |\n\nThe default pending component renders `CraftLoadingText`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren`; its routed\ncomponents carry their own per-route DI checks.\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton receives `photoId` as a route-bound input and reads the payload through the\n**route-generated typed helper**:\n\n```ts\nimport { input } from '@angular/core';\n\nexport default class PhotoSkeleton {\n protected readonly photoId = input.required<string>();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …). It is verified independently with the per-component, O(1)\n[`RouteCheckedDI`](/guide/routing/setup) check:\n\n```ts\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the route uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the\nauto-provided service names from the route's path params + payload.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"path": "/guide/routing/route-load-errors",
|
|
364
364
|
"title": "Route load errors",
|
|
365
|
-
"body": "# Route load errors\n\nThis is the failure mode nothing else covers: the route is valid, the guards\npassed, and the **JavaScript chunk itself** never arrives — a stale hash after a\ndeploy, a flaky network, an offline user.\n\n**Use it when** your app is lazy-loaded and deployed more than once. Which is to\nsay: use it.\n\n`withRouteLoadError(...)` handles failures that happen before Craft can mount the target route:\nlazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale\ndeployments, CDN errors, or offline transitions.\n\nThis is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business\nexceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is\ntrying to fetch the JavaScript needed to activate the route.\n\n## Register the route-load error screen\n\nPass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and\nother craft loading features:\n\n```ts\nimport {\n provideCraftRouter,\n withRouteLoadError,\n withErrorComponent,\n} from '@craft-ts/core';\n\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 1,\n delayMs: 250,\n },\n }),\n);\n```\n\nThe component must be eager. Do not configure the route-load error screen with `loadComponent`: the\nfailure case is precisely that lazy JavaScript may be unavailable.\n\n## Runtime behaviour\n\nWhen a lazy route load fails, Craft:\n\n1. runs the configured retry strategy;\n2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`;\n3. renders the configured route-load error component;\n4. keeps the browser URL on the original target URL.\n\nThe last point matters. Internally, Craft activates a technical recovery route so there is\nsomething safe to render, but `browserUrl` keeps the visible URL as the intended route:\n\n```text\n/mutation/123\n→ lazy chunk fails\n→ retry fails\n→ route-load error screen is shown\n→ browser URL stays /mutation/123\n→ F5 reloads /mutation/123 and retries the real route\n```\n\n::: info No dedicated loading UI during JavaScript fetches yet\nWhile Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the\nconfigured retry strategy, Craft does not currently display the route's `pendingComponent` or another\ndedicated loading component. The pending component starts only after the JavaScript has loaded and the\nroute has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running.\n\nExtending the pending timeline to cover slow chunk downloads and retries is planned as a future\nevolution. Until then, the previous route may remain visible while the JavaScript request is pending;\nthe route-load error component appears only after all configured retries fail.\n:::\n\n::: warning Browser-cached module failures\nBrowsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each\nCraft lazy route import with the loader's `withRetry` helper:\n\n```ts\nloadComponent: ({ withRetry }) => withRetry(import('./detail')),\nloadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n```\n\nThe initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed\nproduction chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser\nerror and adds `__craft_route_retry` only to the failed request. A successful retry module is kept\nfor the lifetime of the application and reused by later route activations.\n\nThis recovery depends on the browser including the failed module URL in the dynamic-import error.\nWhen it does not, `reload()` remains the reliable recovery path. Do not write\n`import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from\nbeing statically discovered.\n:::\n\n## Build the error component\n\nThe component can inject both the active technical exception and the recovery API:\n\n```ts\nimport { button, craftComponent, div, h2, p } from '@craft-ts/component';\nimport {\n CraftRouteLoadError,\n CraftRouteLoadRecovery,\n provideHostName,\n} from '@craft-ts/core';\n\nexport const MyRouteLoadErrorScreen = craftComponent(\n 'MyRouteLoadErrorScreen',\n {\n providers: [provideHostName('component:MyRouteLoadErrorScreen')],\n styles: `\n :scope { padding: 2rem; border: 1px solid #f97316; border-radius: 8px }\n .actions { display: flex; gap: .75rem; margin-top: 1rem }\n `,\n },\n function* () {\n return {\n error: yield* CraftRouteLoadError(),\n recovery: yield* CraftRouteLoadRecovery(),\n };\n },\n ({ error, recovery }) => {\n const current = error();\n\n return div([\n h2('Route could not be loaded'),\n p(\n current\n ? `Failed to load ${current.payload.phase} for route \"${current.payload.routePath}\" after ${current.payload.attempt} attempts.`\n : 'The requested route chunk could not be loaded.',\n ),\n div({ class: 'actions' }, [\n button({ click: () => void recovery.retry() }, 'Retry route load'),\n button({ click: () => recovery.reload() }, 'Reload app'),\n ]),\n ]);\n },\n);\n```\n\n`CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes:\n\n- `phase`: `'component'` or `'children'`;\n- `routePath`: the route definition path that failed;\n- `targetUrl`: the URL the user tried to reach;\n- `cause`: the final error thrown by the loader/retry strategy;\n- `attempt`: the number of load attempts made.\n\n`injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the\nbrowser.\n\n## Configure retry globally\n\nThe default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 2,\n delayMs: 500,\n },\n});\n```\n\n`attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most\nthree loader calls total: the initial call plus two retries.\n\nUse callbacks when retry behaviour depends on the error:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 3,\n shouldRetry: (error, context) => {\n // Only retry dynamic import / chunk loading failures.\n if (!(error instanceof TypeError)) return false;\n\n // Stop earlier for a route where retrying is known to be useless.\n return context.routePath !== 'admin';\n },\n delayMs: (_error, context) => {\n // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, …\n return 250 * (context.attempt - 1);\n },\n },\n});\n```\n\nThe retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and\n`error`. The `attempt` value is the load attempt about to run. After the first failed load, the\nfirst retry callback receives `attempt: 2` and `error` set to the initial failure.\n\nFor custom logic, pass a retry strategy:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n async execute(loader, context) {\n console.warn('route load failed, retrying', context);\n return loader();\n },\n },\n});\n```\n\nThe strategy can also be an injectable class implementing `CraftRouteLoadRetry`.\n\n## Override per route\n\nBoth the retry strategy and the rendered component are regular DI providers. Override them on a\nspecific route when the failure should have local behaviour:\n\n```ts\nimport {\n provideRouteLoadErrorComponent,\n provideRouteLoadRetry,\n} from '@craft-ts/core';\n\ncraftRoute('admin', {\n providers: [\n provideRouteLoadRetry({\n attempts: 3,\n delayMs: 1_000,\n }),\n provideRouteLoadErrorComponent({\n component: AdminRouteLoadErrorScreen,\n componentDeps:\n {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen,\n }),\n ],\n loadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n});\n```\n\nThe local component receives the same `injectCraftRouteLoadError()` and\n`injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector.\n\n## DI checks\n\nRoute-load error components participate in the same generated DI checks as other error surfaces.\nThe ESLint rule `craft-ts/require-exception-component-di-check` generates\n`RouteExceptionComponentCheckedDI` checks for:\n\n- global `withRouteLoadError(...)` components;\n- route-local `provideRouteLoadErrorComponent(...)` components.\n\nRun ESLint with `--fix` after adding or changing a route-load error component:\n\n```bash\nnpx nx lint your-app --fix\n```\n\nDo not hand-maintain the generated `_Check*DI` blocks.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs)\n(`assertRouteDiProofs`) fail if a registered route-load error screen has no\narmed `RouteExceptionComponentCheckedDI`.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports\n- [Global error component](/guide/routing/global-error-component)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
|
|
365
|
+
"body": "# Route load errors\n\nThis is the failure mode nothing else covers: the route is valid, the guards\npassed, and the **JavaScript chunk itself** never arrives — a stale hash after a\ndeploy, a flaky network, an offline user.\n\n**Use it when** your app is lazy-loaded and deployed more than once. Which is to\nsay: use it.\n\n`withRouteLoadError(...)` handles failures that happen before Craft can mount the target route:\nlazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale\ndeployments, CDN errors, or offline transitions.\n\nThis is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business\nexceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is\ntrying to fetch the JavaScript needed to activate the route.\n\n## Register the route-load error screen\n\nPass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and\nother craft loading features:\n\n```ts\nimport {\n provideCraftRouter,\n withRouteLoadError,\n withErrorComponent,\n} from '@craft-ts/core';\n\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 1,\n delayMs: 250,\n },\n }),\n);\n```\n\nThe component must be eager. Do not configure the route-load error screen with `loadComponent`: the\nfailure case is precisely that lazy JavaScript may be unavailable.\n\n## Runtime behaviour\n\nWhen a lazy route load fails, Craft:\n\n1. runs the configured retry strategy;\n2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`;\n3. renders the configured route-load error component;\n4. keeps the browser URL on the original target URL.\n\nThe last point matters. Internally, Craft activates a technical recovery route so there is\nsomething safe to render, but `browserUrl` keeps the visible URL as the intended route:\n\n```text\n/mutation/123\n→ lazy chunk fails\n→ retry fails\n→ route-load error screen is shown\n→ browser URL stays /mutation/123\n→ F5 reloads /mutation/123 and retries the real route\n```\n\n::: info No dedicated loading UI during JavaScript fetches yet\nWhile Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the\nconfigured retry strategy, Craft does not currently display the route's `pendingComponent` or another\ndedicated loading component. The pending component starts only after the JavaScript has loaded and the\nroute has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running.\n\nExtending the pending timeline to cover slow chunk downloads and retries is planned as a future\nevolution. Until then, the previous route may remain visible while the JavaScript request is pending;\nthe route-load error component appears only after all configured retries fail.\n:::\n\n::: warning Browser-cached module failures\nBrowsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each\nCraft lazy route import with the loader's `withRetry` helper:\n\n```ts\nloadComponent: ({ withRetry }) => withRetry(import('./detail')),\nloadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n```\n\nThe initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed\nproduction chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser\nerror and adds `__craft_route_retry` only to the failed request. A successful retry module is kept\nfor the lifetime of the application and reused by later route activations.\n\nThis recovery depends on the browser including the failed module URL in the dynamic-import error.\nWhen it does not, `reload()` remains the reliable recovery path. Do not write\n`import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from\nbeing statically discovered.\n:::\n\n## Build the error component\n\nThe component can inject both the active technical exception and the recovery API:\n\n```ts\nimport { button, craftComponent, div, h2, p } from '@craft-ts/component';\nimport {\n CraftRouteLoadError,\n CraftRouteLoadRecovery,\n provideHostName,\n} from '@craft-ts/core';\n// A sheet beside the screen: its frame, and the row of actions.\nimport { loadError } from './route-load-error.style';\n\nexport const MyRouteLoadErrorScreen = craftComponent(\n 'MyRouteLoadErrorScreen',\n {\n providers: [provideHostName('component:MyRouteLoadErrorScreen')],\n },\n function* () {\n return {\n error: yield* CraftRouteLoadError(),\n recovery: yield* CraftRouteLoadRecovery(),\n };\n },\n ({ error, recovery }) => {\n const current = error();\n\n return div({ class: loadError.root }, [\n h2('Route could not be loaded'),\n p(\n current\n ? `Failed to load ${current.payload.phase} for route \"${current.payload.routePath}\" after ${current.payload.attempt} attempts.`\n : 'The requested route chunk could not be loaded.',\n ),\n div({ class: loadError.actions }, [\n button({ click: () => void recovery.retry() }, 'Retry route load'),\n button({ click: () => recovery.reload() }, 'Reload app'),\n ]),\n ]);\n },\n);\n```\n\n`CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes:\n\n- `phase`: `'component'` or `'children'`;\n- `routePath`: the route definition path that failed;\n- `targetUrl`: the URL the user tried to reach;\n- `cause`: the final error thrown by the loader/retry strategy;\n- `attempt`: the number of load attempts made.\n\n`injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the\nbrowser.\n\n## Configure retry globally\n\nThe default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 2,\n delayMs: 500,\n },\n});\n```\n\n`attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most\nthree loader calls total: the initial call plus two retries.\n\nUse callbacks when retry behaviour depends on the error:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 3,\n shouldRetry: (error, context) => {\n // Only retry dynamic import / chunk loading failures.\n if (!(error instanceof TypeError)) return false;\n\n // Stop earlier for a route where retrying is known to be useless.\n return context.routePath !== 'admin';\n },\n delayMs: (_error, context) => {\n // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, …\n return 250 * (context.attempt - 1);\n },\n },\n});\n```\n\nThe retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and\n`error`. The `attempt` value is the load attempt about to run. After the first failed load, the\nfirst retry callback receives `attempt: 2` and `error` set to the initial failure.\n\nFor custom logic, pass a retry strategy:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n async execute(loader, context) {\n console.warn('route load failed, retrying', context);\n return loader();\n },\n },\n});\n```\n\nThe strategy can also be an injectable class implementing `CraftRouteLoadRetry`.\n\n## Override per route\n\nBoth the retry strategy and the rendered component are regular DI providers. Override them on a\nspecific route when the failure should have local behaviour:\n\n```ts\nimport {\n provideRouteLoadErrorComponent,\n provideRouteLoadRetry,\n} from '@craft-ts/core';\n\ncraftRoute('admin', {\n providers: [\n provideRouteLoadRetry({\n attempts: 3,\n delayMs: 1_000,\n }),\n provideRouteLoadErrorComponent({\n component: AdminRouteLoadErrorScreen,\n componentDeps:\n {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen,\n }),\n ],\n loadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n});\n```\n\nThe local component receives the same `injectCraftRouteLoadError()` and\n`injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector.\n\n## DI checks\n\nRoute-load error components participate in the same generated DI checks as other error surfaces.\nThe ESLint rule `craft-ts/require-exception-component-di-check` generates\n`RouteExceptionComponentCheckedDI` checks for:\n\n- global `withRouteLoadError(...)` components;\n- route-local `provideRouteLoadErrorComponent(...)` components.\n\nRun ESLint with `--fix` after adding or changing a route-load error component:\n\n```bash\nnpx nx lint your-app --fix\n```\n\nDo not hand-maintain the generated `_Check*DI` blocks.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs)\n(`assertRouteDiProofs`) fail if a registered route-load error screen has no\narmed `RouteExceptionComponentCheckedDI`.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports\n- [Global error component](/guide/routing/global-error-component)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "/guide/routing/route-providers",
|
|
@@ -397,17 +397,17 @@
|
|
|
397
397
|
{
|
|
398
398
|
"path": "/guide/state/local-state",
|
|
399
399
|
"title": "Local state",
|
|
400
|
-
"body": "# Local state\n\n`state` holds a value you own, in memory, as a signal — with its methods and\nderived values attached to it rather than scattered around it.\n\n**Use it when** the value's home is your application: a form draft, a selection,\na toggle, a counter.\n**Not when** the value lives on a server ([`query`](/guide/state/server-state)),\nin the URL ([`queryParams`](/guide/state/url-state)), or is the result of an\nasync action ([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst counter = yield* state('counter', 0, ({ state, update, set }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n reset: () => set(0),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n\nyield* counter(); // 0\nyield* counter.increment();\nyield* counter.isEven(); // false\nyield* counter.reset();\n```\n\nThe insertion context gives you `state` (the current value as a yieldable\nreader), `set` and `update`. Non-generator methods may return `update(...)`\ndirectly — the insertion wrapper consumes the write. `isEven` yields `state()`\nbecause the computed does not own that reader. In a template, pass the reader\nor the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a\nsynchronous boundary, `craftUse(counter.increment())`.\n\n::: tip New to the shape?\nThe name, the destructuring, the `yield*` driver and the single-use rule are\nthe same for all five primitives — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n:::\n\n## Deriving from another reader\n\nThe initial value can be a Craft reader, in which case the state follows it:\n\n```typescript\nconst origin = yield* state('origin', 5);\n\nconst doubled = yield* state(\n 'doubled',\n craftComputed('originDoubled', function* () {\n return (yield* origin()) * 2;\n }),\n);\n\nyield* doubled(); // 10\n```\n\n## Composing several insertions\n\nOne insertion function gets crowded. Split it and compose with `insertStatePipe`:\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((current) => current + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n\nyield* counter.increment();\nyield* counter.isOdd(); // true\n```\n\nEach function receives the same context and contributes its own slice. See\n[Insertions](/guide/concepts/insertions).\n\n## Driving it from events\n\nBind a method to a [`source$`](/guide/reactivity/source) with\n[`on$`](/guide/reactivity/on) when the trigger is an event rather than a call:\n\n```typescript\nconst increment = source$<void>('increment');\nconst reset = source$<void>('reset');\n\nconst myState = yield* state('myState', 0, ({ update, set }) => ({\n onIncrement: on$(increment, () => update((v) => v + 1)),\n onReset: on$(reset, () => set(0)),\n}));\n\nincrement.emit(); // after yield* / craftUse, myState is 1\nreset.emit(); // after yield* / craftUse, myState is 0\n```\n\nLike every craft primitive, a source is **named**, and the name must match the\nvariable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule\nenforces it and autofixes it.\n\nNote that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods\nbound to a source work internally only.\n\n## Yielding dependencies\n\nAn insertion can be a `function*`, so it can pull in services:\n\n```typescript\nyield* state('counter', 0, function* ({ state }) {\n const log = yield* Console.log;\n return {\n logValue: function* () {\n yield* log(`State value: ${yield* state()}`);\n },\n };\n });\n```\n\nPrefer yielding a craft service over reaching into a runtime container — yielding is\nwhat makes the dependency visible to the route DI check and to test registers.\n\n## Pitfalls\n\n**Don't duplicate derived state.** If a value is a function of another, it is a\n`craftComputed` inside an insertion that `yield*`s its readers, or a `state`\nwhose second argument is that source — not a second `state` kept in sync by an\neffect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync)\nfails the architecture suite when an effect writes another primitive.\n\n**Keep slices granular.** One `state` per coherent concern. A single object\nholding five unrelated things makes every consumer depend on all five.\n\n::: details Advanced — scoping providers to one state\nUse the object form with `$self` when a state needs its own provider scope:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n {\n $self: function* () {\n return yield* CounterPreferences.initialValue();\n },\n providers: [provideCounterPreferences(), provideCounterAnalytics()],\n },\n ({ update }) => ({\n increment: function* () {\n yield* CounterAnalytics.track('increment');\n return yield* update((value) => value + 1);\n },\n }),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods also provide `injectStateMethodRuntimeContext()`, which\nrecovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers,\nWebMCP tools, and other advanced patterns — everyday insertions already\nreceive those methods as arguments. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Insertions](/guide/concepts/insertions)\n- [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary\n"
|
|
400
|
+
"body": "# Local state\n\n`state` holds a value you own, in memory, as a signal — with its methods and\nderived values attached to it rather than scattered around it.\n\n**Use it when** the value's home is your application: a form draft, a selection,\na toggle, a counter.\n**Not when** the value lives on a server ([`query`](/guide/state/server-state)),\nin the URL ([`queryParams`](/guide/state/url-state)), or is the result of an\nasync action ([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst counter = yield* state('counter', 0, ({ state, update, set }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n reset: () => set(0),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n\nyield* counter(); // 0\nyield* counter.increment();\nyield* counter.isEven(); // false\nyield* counter.reset();\n```\n\nThe insertion context gives you `state` (the current value as a yieldable\nreader), `set` and `update`. Non-generator methods may return `update(...)`\ndirectly — the insertion wrapper consumes the write. `isEven` yields `state()`\nbecause the computed does not own that reader. In a template, pass the reader\nor the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a\nsynchronous boundary, `craftUse(counter.increment())`.\n\n::: tip New to the shape?\nThe name, the destructuring, the `yield*` driver and the single-use rule are\nthe same for all five primitives — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n:::\n\n## Deriving from another reader\n\nThe initial value can be a Craft reader, in which case the state follows it:\n\n```typescript\nconst origin = yield* state('origin', 5);\n\nconst doubled = yield* state(\n 'doubled',\n craftComputed('originDoubled', function* () {\n return (yield* origin()) * 2;\n }),\n);\n\nyield* doubled(); // 10\n```\n\n## Composing several insertions\n\nOne insertion function gets crowded. Split it and compose with `insertStatePipe`:\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((current) => current + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n\nyield* counter.increment();\nyield* counter.isOdd(); // true\n```\n\nEach function receives the same context and contributes its own slice. See\n[Insertions](/guide/concepts/insertions).\n\n## Keep transitions with their state\n\nPass an intent to the state that owns a value. Do not read the value in a\ncaller, calculate a replacement there, and pass the replacement back through\na generic method such as `replace` or `update`.\n\n```typescript\nconst todos = yield* state('todos', initialTodos, ({ update }) => ({\n move: (todoId: string, direction: 'up' | 'down') => update((current) => {\n const from = current.findIndex((todo) => todo.id === todoId);\n const to = from + (direction === 'up' ? -1 : 1);\n if (from < 0 || to < 0 || to >= current.length) return current;\n const next = [...current];\n const [todo] = next.splice(from, 1);\n if (!todo) return current;\n next.splice(to, 0, todo);\n return next;\n }),\n}));\n\n// The caller supplies only the intent.\nyield* todos.move(todoId, 'up');\n```\n\nThe recommended and Effect ESLint presets enable\n`craft-ts/no-external-state-transition`. It flags calls such as\n`todos.replace(nextTodos)` or `todos.update(transition)` outside the state\ninsertion. Named state commands remain available to callers; simple values\nsuch as an input's `setValue(value)` are allowed. Prefer not to expose generic\nwhole-state mutators from domain states.\n\nThe rule checks generic mutator method names on values created by Craft's\n`state(...)` primitive. It does not infer whether an arbitrarily named method\nsuch as `replaceTodos(nextTodos)` is a generic replacement; the state interface\nshould make its command semantics clear.\n\n## Driving it from events\n\nBind a method to a [`source$`](/guide/reactivity/source) with\n[`on$`](/guide/reactivity/on) when the trigger is an event rather than a call:\n\n```typescript\nconst increment = source$<void>('increment');\nconst reset = source$<void>('reset');\n\nconst myState = yield* state('myState', 0, ({ update, set }) => ({\n onIncrement: on$(increment, () => update((v) => v + 1)),\n onReset: on$(reset, () => set(0)),\n}));\n\nincrement.emit(); // after yield* / craftUse, myState is 1\nreset.emit(); // after yield* / craftUse, myState is 0\n```\n\nLike every craft primitive, a source is **named**, and the name must match the\nvariable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule\nenforces it and autofixes it.\n\nNote that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods\nbound to a source work internally only.\n\n## Yielding dependencies\n\nAn insertion can be a `function*`, so it can pull in services:\n\n```typescript\nyield* state('counter', 0, function* ({ state }) {\n const log = yield* Console.log;\n return {\n logValue: function* () {\n yield* log(`State value: ${yield* state()}`);\n },\n };\n });\n```\n\nPrefer yielding a craft service over reaching into a runtime container — yielding is\nwhat makes the dependency visible to the route DI check and to test registers.\n\n## Pitfalls\n\n**Don't duplicate derived state.** If a value is a function of another, it is a\n`craftComputed` inside an insertion that `yield*`s its readers, or a `state`\nwhose second argument is that source — not a second `state` kept in sync by an\neffect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync)\nfails the architecture suite when an effect writes another primitive.\n\n**Keep slices granular.** One `state` per coherent concern. A single object\nholding five unrelated things makes every consumer depend on all five.\n\n::: details Advanced — scoping providers to one state\nUse the object form with `$self` when a state needs its own provider scope:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n {\n $self: function* () {\n return yield* CounterPreferences.initialValue();\n },\n providers: [provideCounterPreferences(), provideCounterAnalytics()],\n },\n ({ update }) => ({\n increment: function* () {\n yield* CounterAnalytics.track('increment');\n return yield* update((value) => value + 1);\n },\n }),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods also provide `injectStateMethodRuntimeContext()`, which\nrecovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers,\nWebMCP tools, and other advanced patterns — everyday insertions already\nreceive those methods as arguments. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Insertions](/guide/concepts/insertions)\n- [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary\n"
|
|
401
401
|
},
|
|
402
402
|
{
|
|
403
403
|
"path": "/guide/state/mutations",
|
|
404
404
|
"title": "Mutations",
|
|
405
|
-
"body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone. A loader must not be `async` or\nreturn a native `Promise`; use `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\
|
|
405
|
+
"body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone. A loader must not be `async` or\nreturn a native `Promise`; use `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
|
|
406
406
|
},
|
|
407
407
|
{
|
|
408
408
|
"path": "/guide/state/pagination-placeholder",
|
|
409
409
|
"title": "Pagination placeholders",
|
|
410
|
-
"body": "# Pagination placeholders\n\n`insertPaginationPlaceholderData` keeps the previous page on screen while the\nnext one loads, so paging through a list never flashes an empty state.\n\n**Use it when** a query is paginated with an `identifier` per page.\n**Not when** you just want to avoid a flicker on a non-paginated query — a query\nalready keeps its previous value while loading, with no configuration\n([query](/guide/state/server-state)).\n\n```typescript\nimport { insertPaginationPlaceholderData } from '@craft-ts/core';\n```\n\n## The common case\n\nIt is a **higher-order insertion**: call it with a config and pass the result to\n`query`. `config.initialValue` is both the default value and the page type —\nwhich is why `currentPageData` is a `Signal<T>` that is **never `undefined`**.\n\n```typescript\nconst pagination = yield* state('pagination', 1);\n\nconst { userQuery } = yield* query(\n 'userQuery',\n {\n params: pagination,\n identifier: (params) => '' + params,\n loader: function* ({ params }) {\n const response = yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users?page=${params}`,\n success: response<User[]>(),\n }));\n return response.json();\n },\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n);\n\n// Access the current page data (or placeholder data during loading)\nconst data = userQuery.currentPageData();\n\n// Check the loading status of the current page\nconst status = userQuery.currentPageStatus();\n\n// Determine if placeholder data is being shown\nconst isPlaceholder = userQuery.isPlaceHolderData();\n\n// Get the current page identifier\nconst identifier = userQuery.currentIdentifier();\n```\n\n## Returned Properties\n\n| Property | Type | Description |\n| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `currentPageData` | `Signal<T>` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). |\n| `currentPageStatus` | `Signal<ResourceStatus>` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) |\n| `isPlaceHolderData` | `Signal<boolean>` | `true` when showing previous page data as a placeholder |\n| `currentIdentifier` | `Signal<string>` | The identifier of the current page |\n\n## Custom Outputs (`build` callback)\n\nPass an optional second argument to attach your own computed values or methods next to\nthe pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the\n**current page** (the displayed data), so mutations only affect the page the user is\nlooking at — other cached pages are left untouched.\n\n```typescript\nconst { usersQuery } = query(\n 'usersQuery',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertPaginationPlaceholderData(\n { initialValue: [] as Data[] },\n ({ state, settledState, set }) => ({\n // a computed derived from the current page\n totalOfUnCompletedData: craftComputed(function* () {\n return (yield* state()).filter((d) => !d.completed).length;\n }),\n settledCount: craftComputed(function* () {\n return (yield* settledState()).length;\n }),\n markAsCompleted: function* (id: string) {\n const current = yield* state();\n return yield* set(\n current.map((d) => (d.id === id ? { ...d, completed: true } : d)),\n );\n },\n }),\n ),\n);\n\nyield* usersQuery.totalOfUnCompletedData(); // number\nyield* usersQuery.markAsCompleted('42');\n```\n\nThe `build` context exposes:\n\n| Helper | Type | Description |\n| -------- | --------------------------------------- | ---------------------------------------------------- |\n| `state` | yieldable reader for `T` | The current page data (or `initialValue`) |\n| `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition |\n| `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) |\n| `update` | yieldable write returning `T` | Update the current page data from its previous value |\n| `patch` | yieldable write returning `T` | Patch the current page data with a partial value |\n\nThe pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`,\n`currentIdentifier`) are also available in the `build` context.\n\n::: details A full paginated component\n\n```typescript\nimport { button, craftComponent, div, forNode, ifNode, span } from '@craft-ts/component';\nimport { craftComputed, query, state } from '@craft-ts/core';\n\nexport const UsersList = craftComponent(\n 'UsersList',\n {},\n function* () {\n const page = yield* state('page', 1, ({ state, update, set }) => ({\n next: () => update((value) => value + 1),\n previous: function* () {\n const current = yield* state();\n return yield* set(Math.max(1, current - 1));\n },\n isFirst: craftComputed(function* () {\n return (yield* state()) === 1;\n }),\n label: craftComputed(function* () {\n return `Page ${yield* state()}`;\n }),\n }));\n\n const userQuery = yield* query(\n 'userQuery',\n {\n params: page,\n identifier: (page) => `page-${page}`,\n loader: async ({ params }) =>\n (await fetch(`/api/users?page=${params}`)).json() as Promise<User[]>,\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n );\n\n return { page, userQuery };\n },\n ({ page, userQuery }) => [\n div(\n {\n class: function* () {\n return (yield* userQuery.isPlaceHolderData())\n ? 'users-list loading'\n : 'users-list';\n },\n },\n forNode(\n userQuery.currentPageData,\n { track: (user) => user.id },\n (user) => UserCard({ user }),\n ),\n ),\n\n div({ class:
|
|
410
|
+
"body": "# Pagination placeholders\n\n`insertPaginationPlaceholderData` keeps the previous page on screen while the\nnext one loads, so paging through a list never flashes an empty state.\n\n**Use it when** a query is paginated with an `identifier` per page.\n**Not when** you just want to avoid a flicker on a non-paginated query — a query\nalready keeps its previous value while loading, with no configuration\n([query](/guide/state/server-state)).\n\n```typescript\nimport { insertPaginationPlaceholderData } from '@craft-ts/core';\n```\n\n## The common case\n\nIt is a **higher-order insertion**: call it with a config and pass the result to\n`query`. `config.initialValue` is both the default value and the page type —\nwhich is why `currentPageData` is a `Signal<T>` that is **never `undefined`**.\n\n```typescript\nconst pagination = yield* state('pagination', 1);\n\nconst { userQuery } = yield* query(\n 'userQuery',\n {\n params: pagination,\n identifier: (params) => '' + params,\n loader: function* ({ params }) {\n const response = yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users?page=${params}`,\n success: response<User[]>(),\n }));\n return response.json();\n },\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n);\n\n// Access the current page data (or placeholder data during loading)\nconst data = userQuery.currentPageData();\n\n// Check the loading status of the current page\nconst status = userQuery.currentPageStatus();\n\n// Determine if placeholder data is being shown\nconst isPlaceholder = userQuery.isPlaceHolderData();\n\n// Get the current page identifier\nconst identifier = userQuery.currentIdentifier();\n```\n\n## Returned Properties\n\n| Property | Type | Description |\n| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `currentPageData` | `Signal<T>` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). |\n| `currentPageStatus` | `Signal<ResourceStatus>` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) |\n| `isPlaceHolderData` | `Signal<boolean>` | `true` when showing previous page data as a placeholder |\n| `currentIdentifier` | `Signal<string>` | The identifier of the current page |\n\n## Custom Outputs (`build` callback)\n\nPass an optional second argument to attach your own computed values or methods next to\nthe pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the\n**current page** (the displayed data), so mutations only affect the page the user is\nlooking at — other cached pages are left untouched.\n\n```typescript\nconst { usersQuery } = query(\n 'usersQuery',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertPaginationPlaceholderData(\n { initialValue: [] as Data[] },\n ({ state, settledState, set }) => ({\n // a computed derived from the current page\n totalOfUnCompletedData: craftComputed(function* () {\n return (yield* state()).filter((d) => !d.completed).length;\n }),\n settledCount: craftComputed(function* () {\n return (yield* settledState()).length;\n }),\n markAsCompleted: function* (id: string) {\n const current = yield* state();\n return yield* set(\n current.map((d) => (d.id === id ? { ...d, completed: true } : d)),\n );\n },\n }),\n ),\n);\n\nyield* usersQuery.totalOfUnCompletedData(); // number\nyield* usersQuery.markAsCompleted('42');\n```\n\nThe `build` context exposes:\n\n| Helper | Type | Description |\n| -------- | --------------------------------------- | ---------------------------------------------------- |\n| `state` | yieldable reader for `T` | The current page data (or `initialValue`) |\n| `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition |\n| `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) |\n| `update` | yieldable write returning `T` | Update the current page data from its previous value |\n| `patch` | yieldable write returning `T` | Patch the current page data with a partial value |\n\nThe pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`,\n`currentIdentifier`) are also available in the `build` context.\n\n::: details A full paginated component\n\n```typescript\nimport { button, craftComponent, div, forNode, ifNode, span } from '@craft-ts/component';\nimport { craftComputed, query, state } from '@craft-ts/core';\n\nexport const UsersList = craftComponent(\n 'UsersList',\n {},\n function* () {\n const page = yield* state('page', 1, ({ state, update, set }) => ({\n next: () => update((value) => value + 1),\n previous: function* () {\n const current = yield* state();\n return yield* set(Math.max(1, current - 1));\n },\n isFirst: craftComputed(function* () {\n return (yield* state()) === 1;\n }),\n label: craftComputed(function* () {\n return `Page ${yield* state()}`;\n }),\n }));\n\n const userQuery = yield* query(\n 'userQuery',\n {\n params: page,\n identifier: (page) => `page-${page}`,\n loader: async ({ params }) =>\n (await fetch(`/api/users?page=${params}`)).json() as Promise<User[]>,\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n );\n\n return { page, userQuery };\n },\n ({ page, userQuery }) => [\n div(\n {\n class: function* () {\n return (yield* userQuery.isPlaceHolderData())\n ? 'users-list loading'\n : 'users-list';\n },\n },\n forNode(\n userQuery.currentPageData,\n { track: (user) => user.id },\n (user) => UserCard({ user }),\n ),\n ),\n\n // `pagerSheet` is the page's sheet, from users-page.style.ts.\n div({ class: pagerSheet.bar }, [\n button({ click: page.previous, disabled: page.isFirst }, 'Previous'),\n span(page.label),\n button({ click: page.next }, 'Next'),\n ]),\n\n ifNode(userQuery.isPlaceHolderData, () =>\n div({ class: pagerSheet.loading }, 'Loading new page…'),\n ),\n ],\n);\n```\n\n:::\n\n## How it works\n\n1. When the page parameters change, the insertion checks whether the new page's\n data is already cached.\n2. If the new page is loading and has no data yet, it serves the previous page's\n data as a placeholder.\n3. `isPlaceHolderData` tells you that is what is on screen — use it to dim the\n list or show a spinner.\n4. Once the real data arrives, it switches over automatically.\n\n## Pitfalls\n\n**It needs an `identifier`.** Without one page identity, there is no \"previous\npage\" to fall back to.\n\n**`initialValue` defines the page type.** Passing `[]` untyped collapses\n`currentPageData` to `never[]` — write `[] as User[]`.\n\n**Mutating through the `build` helpers only affects the current page.** Other\ncached pages are untouched, which is usually what you want, but means a global\nchange needs a reload.\n\n## See Also\n\n- [query](/guide/state/server-state) — the base primitive\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Insertions](/guide/concepts/insertions)\n"
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
"path": "/guide/state/persistence",
|
|
@@ -467,22 +467,32 @@
|
|
|
467
467
|
{
|
|
468
468
|
"path": "/guide/style/define",
|
|
469
469
|
"title": "Defining your design system",
|
|
470
|
-
"body": "# Defining your design system\n\nThe other pages in this section spend `bp`, `scheme`, `palette`, `v`, `space`\nand `unit` as if they were already in scope. They are not built in — except\n`scheme`, which is. This page is where the rest come from.\n\nEverything here goes in one sheet, conventionally `foundation.style.ts`. A\n`*.style.ts` may import vocabulary and nothing else — the `style-file-boundary`\nrule enforces it — which is exactly what makes it safe for the build plugin to\nimport the file in Node: there is no application code in it to run.\n\n\n\n## The palette\n\n`definePalette` takes a group-of-tokens shape where every token carries **both**\nof its values at once:\n\n\n\n`@craft-ts/style` already exports a `palette` built the same way — the same four\ngroups, with neutral defaults. Spend it as-is to get moving, and call\n`definePalette` when you want your own colours; the pages that follow use the\nname `palette` for whichever one is in scope.\n\nA token is not a colour string; it is a pair plus a role, and the role comes from\nthe group it sits in (`surface`, `text`, `border`, `accent`). `darkOf(token)` is\nhow a sheet reaches the other side of the pair, and the role travels with it —\nthe dark side of a surface is still a surface.\n\nComponents never read the palette directly. They read a **theme variable**, and\nthe theme is the single place that decides what a variable holds in light and in\ndark. That indirection is what makes dark mode one rule instead of one rule per\ncomponent.\n\n## Axes\n\nA component may only vary along an axis you declared. There are four ways to\ndeclare one, and the choice is about what drives the variation.\n\n### `defineBreakpoints` — the viewport\n\n\n\nBreakpoints are an **ordered** axis: two points can be compared, which is what\nlets the matrix reduce by interval instead of by product, and what makes a rule\nthat can never apply detectable. `above(...)` and `below(...)` turn a point into\nan explicit bound.\n\n### `defineStateAxis` — an attribute you set\n\n\n\nEach point carries the driver that reaches it — here `data-tone='danger'` — so a\nscenario the matrix enumerates is a scenario a test can actually produce. The\nattribute-_value_ form, rather than one attribute per state, is what makes the\nstates mutually exclusive by construction: an element cannot be two of them at\nonce, so the matrix does not have to be told.\n\n### `defineAxis` — a state axis with a write constraint\n\n\n\n`onlyVarsOfKind(kind.color)` says this axis may write `<color>` custom\nproperties and nothing else. An axis that can only write colours cannot move a\nbox, so it crosses **additively** with the axes that do rather than multiplying\nthem. The constraint is checked where it is cheap — at the `when` call site —\ninstead of by reading the emitted CSS afterwards.\n\n`defineStateAxis` is `defineAxis` without the options object; it stays a\nseparate name because the unconstrained case is the common one and reads better\nwithout them.\n\n### `defineContainer` — the size of a box, not of the window\n\n\n\nA container axis answers \"how wide is _my_ box\", which nobody above the container\ncan change. So it is closed at the element that declares the container: the\nmatrix prunes it there rather than letting every ancestor inherit scenarios it\nhas no way to affect.\n\n### The standard axes\n\n`scheme`, `motion`, `forcedColors`, `contrast`, `scrollState` and `descendant`\nship with `@craft-ts/style` and need no declaration — they are driven by the\nuser agent or by the element's own state, not by an attribute you own. `scheme`\nis the one the theme below uses.\n\n### `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`
|
|
470
|
+
"body": "# Defining your design system\n\nThe other pages in this section spend `bp`, `scheme`, `palette`, `v`, `space`\nand `unit` as if they were already in scope. They are not built in — except\n`scheme`, which is. This page is where the rest come from.\n\nEverything here goes in one sheet, conventionally `foundation.style.ts`. A\n`*.style.ts` may import vocabulary and nothing else — the `style-file-boundary`\nrule enforces it — which is exactly what makes it safe for the build plugin to\nimport the file in Node: there is no application code in it to run.\n\n\n\n## The palette\n\n`definePalette` takes a group-of-tokens shape where every token carries **both**\nof its values at once:\n\n\n\n`@craft-ts/style` already exports a `palette` built the same way — the same four\ngroups, with neutral defaults. Spend it as-is to get moving, and call\n`definePalette` when you want your own colours; the pages that follow use the\nname `palette` for whichever one is in scope.\n\nA token is not a colour string; it is a pair plus a role, and the role comes from\nthe group it sits in (`surface`, `text`, `border`, `accent`). `darkOf(token)` is\nhow a sheet reaches the other side of the pair, and the role travels with it —\nthe dark side of a surface is still a surface.\n\nComponents never read the palette directly. They read a **theme variable**, and\nthe theme is the single place that decides what a variable holds in light and in\ndark. That indirection is what makes dark mode one rule instead of one rule per\ncomponent.\n\n## Axes\n\nA component may only vary along an axis you declared. There are four ways to\ndeclare one, and the choice is about what drives the variation.\n\n### `defineBreakpoints` — the viewport\n\n\n\nBreakpoints are an **ordered** axis: two points can be compared, which is what\nlets the matrix reduce by interval instead of by product, and what makes a rule\nthat can never apply detectable. `above(...)` and `below(...)` turn a point into\nan explicit bound.\n\n### `defineStateAxis` — an attribute you set\n\n\n\nEach point carries the driver that reaches it — here `data-tone='danger'` — so a\nscenario the matrix enumerates is a scenario a test can actually produce. The\nattribute-_value_ form, rather than one attribute per state, is what makes the\nstates mutually exclusive by construction: an element cannot be two of them at\nonce, so the matrix does not have to be told.\n\n### `defineAxis` — a state axis with a write constraint\n\n\n\n`onlyVarsOfKind(kind.color)` says this axis may write `<color>` custom\nproperties and nothing else. An axis that can only write colours cannot move a\nbox, so it crosses **additively** with the axes that do rather than multiplying\nthem. The constraint is checked where it is cheap — at the `when` call site —\ninstead of by reading the emitted CSS afterwards.\n\n`defineStateAxis` is `defineAxis` without the options object; it stays a\nseparate name because the unconstrained case is the common one and reads better\nwithout them.\n\n### `defineContainer` — the size of a box, not of the window\n\n\n\nA container axis answers \"how wide is _my_ box\", which nobody above the container\ncan change. So it is closed at the element that declares the container: the\nmatrix prunes it there rather than letting every ancestor inherit scenarios it\nhas no way to affect.\n\n### The standard axes\n\n`scheme`, `motion`, `forcedColors`, `contrast`, `scrollState` and `descendant`\nship with `@craft-ts/style` and need no declaration — they are driven by the\nuser agent or by the element's own state, not by an attribute you own. `scheme`\nis the one the theme below uses.\n\n`interaction` (`hover`, `focus`, `active`, `disabled`) reads the element's own\npseudo-classes. Three more read an ARIA attribute instead of a `data-*` one, so\nthe look and what assistive technology announces cannot disagree:\n\n| Axis | Opens on | Typical element |\n| --------------------- | ------------------------- | ----------------------------- |\n| `ariaCurrent.page` | `[aria-current='page']` | the router's active link |\n| `ariaCurrent.true` | `[aria-current='true']` | the current item of a list |\n| `ariaPressed.pressed` | `[aria-pressed='true']` | a toggle button that is on |\n| `ariaInvalid.true` | `[aria-invalid='true']` | a field whose value was refused |\n\nSet the attribute for accessibility; the sheet follows it. A second `data-*`\nattribute for the same state would be one more thing to keep in sync.\n\n### `axisPoint` — the escape hatch\n\n`axisPoint(axis, point, open, driver, extra)` builds a point by hand, for a\nselector none of the four constructors produce. You give up nothing type-side,\nbut you take on the part the constructors were doing for you: the `driver` must\nreally reach the `open` selector, and nothing checks that for you.\n\n## The theme\n\n`cssVars(prefix, specs)` declares the typed custom properties. Each is registered\nthrough `@property`, so the browser validates it: assigning a length where a\ncolour belongs paints nothing rather than painting wrong.\n\n\n\nTwo things on that block are worth stopping on.\n\n**`inherits: true` belongs to theme variables and to nothing else.** The default\nis `false`, and that is the right default for a variable an element sets on\nitself and reads on itself — it bounds invalidation to that element. A theme\nvariable is the opposite case: set once on a wrapper, read by everything below.\nA non-inheriting theme hands every descendant the initial value instead, which\nlooks exactly like dark mode not working, with no error anywhere.\n\n**`kind.length(unit.px(16))`, not `unit.rem(1)`.** `@property` requires a\ncomputationally independent initial value; a relative one makes the browser drop\nthe registration entirely, and silently. The theme writes the `rem` value in the\nrule below.\n\n::: tip One `cssVars` for the theme and for a component\nThe same `cssVars` declares a design system's theme and a single component's\nstyling API — per-instance variants, inheritance, forwarding, runtime values.\nSee [Typed CSS variables](../components/css-variables.md). The deprecated\n`meta.cssVars` on `craftComponent` is a different, CSS-string mechanism.\n:::\n\n## `seal` — closing a tree\n\n`seal(node)` is the only place a [context obligation](./obligations.md) becomes\nan error. Up to that point an unanswered requirement keeps travelling, because an\nancestor still has the right to answer it. `seal` is you saying: from here up,\nnobody else will.\n\nPut it at the root of a route, not around the component that raised the\nrequirement — that is the whole point of letting obligations travel.\n\n## Next\n\n- [Tokens and typed variables](./tokens.md) — spending what you just declared.\n- [Axes and the visual matrix](./variants.md) — `when`, and the matrix the axes\n above generate.\n- [Context obligations](./obligations.md) — `requires`, `provides`, and `seal`.\n"
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
"path": "/guide/style/foundation",
|
|
474
|
+
"title": "Global foundation and fonts",
|
|
475
|
+
"body": "# Global foundation and fonts\n\nAn app built on `@craft-ts/style` has no `styles.css`. What used to go there\nfalls into three layers, and craft-ts writes the first two for you:\n\n| layer | written by | what it holds |\n| -------------- | ---------------------------- | ------------------------------------------------------------------------ |\n| `craft.reset` | craft-ts, on by default | a modern reset |\n| `craft.base` | craft-ts, on by default | colour scheme, focus ring, reduced motion, selection, form accent |\n| `craft.global` | your app, `craftGlobalStyles` | your theme variables on `:root`, element defaults (`body`, `a`, …) |\n\nThey come before the component layers. The full order is fixed by the emitter,\nwhatever order your modules are imported in:\n\n```css\n@layer craft.reset, craft.base, craft.tokens, craft.global,\n craft.components, craft.variants, craft.overrides;\n```\n\nEvery layer is named `craft.*`. A third-party stylesheet that arrives unlayered\nwins over all of them — that is how CSS treats unlayered styles — which is\nexactly why one should be rare, deliberate and attested.\n\n## The reset\n\nOn by default. It sets `box-sizing: border-box` everywhere, removes default\nmargins, makes media blocks that never overflow (`max-inline-size: 100%`), lets\nform controls inherit the document font, balances headings and avoids orphans\nin paragraphs (`text-wrap`), wraps long words (`overflow-wrap: anywhere`) and\nstops mobile browsers from inflating text.\n\nIt is written with the typed vocabulary, like any sheet\n([`libs/style/src/lib/global/reset.ts`](https://github.com/craft-ts/craft-ts/blob/main/libs/style/src/lib/global/reset.ts)).\nTurning it off is a deliberate choice:\n\n```ts\ncraftStyle({ reset: false });\n```\n\n## The base\n\nAlso on by default (`base: false` to opt out). It states once, for the whole\ndocument, what components used to have to remember one by one:\n\n- `color-scheme` follows the `scheme` axis, so scrollbars and form controls\n turn dark with the page;\n- every `:focus-visible` gets a visible ring;\n- scrolling is smooth only for users who did not ask for less motion, and\n under `prefers-reduced-motion` **every** animation and transition collapses\n to an instant. The guard is `!important` in the earliest layer, the one\n place that beats every later layer, so no component can forget it;\n- `accent-color` and `::selection` come from the theme.\n\nThe colours and sizes are typed theme variables, exported as `craftBase`:\n`accent`, `focusRing`, `focusWidth`, `focusOffset`, `selectionBg`,\n`selectionInk`. Re-theme them from your own global styles (below).\n\n## Your app's globals\n\n\n\n`root` goes on `:root`, `elements` on tag names — the only selectors accepted.\nAnything narrower than an element belongs to a component sheet. The items are\nthe same as in a sheet: generated properties, `set(...)`, `when(...)`, and\n`pseudo.*`. `when` on a state axis becomes an attribute on `:root`, which is\nhow a theme toggle is written (`when(theme.dark, [...])` →\n`:root[data-theme='dark']`).\n\n`no-raw-css-value` applies here as everywhere else: no literal reaches a\nhelper.\n\n## Fonts\n\n\n\n`defineFont` replaces the three things a `styles.css` used to carry:\n\n- **the `@import url(fonts.googleapis…)`** — the plugin injects a `preconnect`\n to both Google origins, then the stylesheet preloaded and applied, into\n `<head>` of `index.html`. An `@import` inside the CSS could only be\n discovered once the CSS itself had downloaded;\n- **the `@font-face` blocks** — `localFont({ files: [...] })` emits them, and\n preloads the `.woff2` files;\n- **the `* { font-family: … !important }`** — the returned token is a family\n stack, used once with `fontFamily(bodyFont)` on `body` and inherited from\n there. Form controls inherit it through the reset.\n\nA server renderer that writes `<head>` itself reads the same tags as HTML:\n\n```ts\nimport head from 'virtual:craft-style-head';\n```\n\n### Fallback without layout shift\n\nWhile the web font loads, the fallback font is shown, and the text jumps when\nthe real one arrives. `adjustFallback` builds a `\"<family> Fallback\"` face from\na local font, resized so both occupy the same space:\n\n```ts\ndefineFont('body', {\n family: 'Chivo',\n source: googleFont({ weights: [400, 700] }),\n fallback: 'system-ui',\n adjustFallback: {\n // The web font's metrics, in font units. Capsize publishes them for every\n // Google font (@capsizecss/metrics).\n metrics: {\n unitsPerEm: 1000,\n ascent: 954,\n descent: -250,\n lineGap: 0,\n xWidthAvg: 505,\n },\n // Optional, Arial by default: `face: { local: 'Helvetica', metrics }`.\n },\n});\n```\n\n`size-adjust` matches the average glyph width, so lines break at the same\nplace; `ascent-override`, `descent-override` and `line-gap-override` keep the\nline height.\n\n## What this replaces in the ESLint rules\n\n`require-focus-visible` and `require-reduced-motion` used to read each\ncomponent's CSS text to check that a focus ring and a reduced-motion branch\nwere there. The base layer guarantees both for the whole document, so a\ncomponent no longer has anything to prove.\n"
|
|
471
476
|
},
|
|
472
477
|
{
|
|
473
478
|
"path": "/guide/style/obligations",
|
|
474
479
|
"title": "Context obligations",
|
|
475
480
|
"body": "# Context obligations\n\nLevel 3. **Adopted per whole route**, and that granularity is the whole point of\nthis page.\n\n## What it is for\n\nA sticky element needs a scroll port. A scroll-state query needs an element\ndeclaring the container. Get either wrong and nothing errors — the component\nsimply never appears, or sticks to the wrong box, and you find out in a\nscreenshot weeks later.\n\n```ts\nimport {\n craftStyles,\n display,\n insetBlockEnd,\n position,\n provides,\n requires,\n scrollPort,\n space,\n} from '@craft-ts/style';\n\nexport const backToTop = craftStyles('backToTop', {\n anchor: [\n requires(scrollPort.block),\n position.sticky,\n insetBlockEnd(space(4)),\n ],\n});\n\nexport const shell = craftStyles('appShell', {\n main: [provides(scrollPort.block), display.block],\n});\n```\n\n`requires` is attached to the **class**, not the sheet, so the error names a\nrule rather than a file. And `provides(...)` returns the CSS effect **and** the\ndischarge in the same object: since `overflow` is not in the property table,\nthis is the only road to `overflow-block: auto`. Claiming to provide without\nlaying the CSS is not something anyone can write.\n\n## Where it becomes an error\n\nNowhere, until a component seals:\n\n```ts\nimport { craftComponent } from '@craft-ts/component';\n\ncraftComponent('AppShell', { seals: [true] }, factory, template);\n```\n\nUntil then the requirement **travels** — an ancestor still has the right to\nanswer it, and complaining early would be wrong. Sealing says \"from here up,\nnobody will\".\n\nRemove the provider and the typecheck fails:\n\n> `ERROR_unmet_context_requirement: \"'scrollPort.block' is required by this\nsubtree and nothing above it provides one. declare it on the layout component\nthat owns the scrollable area. An overflow on the direct parent would create a\nsecond scroll port, and the sticky element would stick to the wrong container.\"`\n\nWhat is missing, where to put it, and what the obvious wrong fix would do.\n\n## Why the granularity is the route\n\nThe requirement is carried by the type of the render tree. It crosses a\ncomponent boundary only where the tree is typed all the way through. One\ncomponent in the path that hands back a loosely typed subtree, and the demand\nstops travelling — silently, because nothing is wrong with _that_ component.\n\nSo level 3 is not something you get for the components you migrated. You get it\nfor a route once the route is migrated, and not before. The dependency graph\nreports which components are not covered rather than reporting a clean bill:\n\n```ts\nimport { extractionGaps, undischargedObligations } from '@craft-ts/dev-tools';\n\nextractionGaps(graph); // components no sheet is known to style\nundischargedObligations(graph); // required somewhere, discharged nowhere\n```\n\n## The marked way out\n\n```ts\nimport { scrollPort, unsafeAssume } from '@craft-ts/style';\n\nunsafeAssume(scrollPort.block, 'the host page owns the scroll port');\n```\n\nDischarges without laying the CSS, for the cases the model cannot see — a shell\nowned by someone else. It propagates `unproven`, so the graph counts it as debt.\nAn escape hatch that did not bubble up would be a design bug, not a convenience.\n"
|
|
476
481
|
},
|
|
482
|
+
{
|
|
483
|
+
"path": "/guide/style/pseudo-elements",
|
|
484
|
+
"title": "Pseudo-elements and animations",
|
|
485
|
+
"body": "# Pseudo-elements and animations\n\nA component sheet can style what used to need a hand-written selector —\n`::before`, `::placeholder`, `@keyframes`, `transition` — with the same typed\nvocabulary as the rest of the sheet.\n\n## Pseudo-elements\n\n\n\n`pseudo.before([...])` is an item of a class, and nests like `when(...)`. The\nemitter always puts the pseudo-element **last** in the selector, where CSS\nrequires it, whatever the nesting order: `when(status.failed, [pseudo.before(...)])`\nand `pseudo.before([when(status.failed, ...)])` both give\n`.x[data-status='failed']::before`.\n\nAvailable: `pseudo.before`, `pseudo.after`, `pseudo.placeholder`,\n`pseudo.marker`, `pseudo.selection`, `pseudo.backdrop`.\n\n### `content` is required, and typed\n\n`::before` and `::after` are not generated without a `content`, and nothing\nthey declare applies. Rather than a decoration that silently disappears, it is\na type error:\n\n```ts\npseudo.before([display.block]);\n// ~~~~~~~~~ ERROR_a_generated_pseudo_element_needs_content\n```\n\nThe content itself comes from `pseudo.content`:\n\n| helper | CSS |\n| ---------------------------------------- | ---------------------- |\n| `pseudo.content.empty` | `content: \"\"` |\n| `pseudo.content.none` | `content: none` |\n| `pseudo.content.text(cssString('→'))` | `content: \"→\"` |\n| `pseudo.content.counter(ident('step'))` | `content: counter(step)` |\n\nThe check reads the top level of the block: a `content` placed only under a\n`when(...)` leaves the base scenario without one, which is the same bug.\n\n### Pseudo-classes are axes\n\nThere is no `pseudo.hover`. `:hover`, `:focus-visible` or `:disabled` are\n**states**, and a state is an axis (`interaction.hover`, `defineStateAxis`).\nThat is what puts it in the variant contract, the visual matrix and the\ncontrast proof — a hand-written `:hover` would be invisible to all three.\n\n## Keyframes and animations\n\n\n\n`keyframes(name, steps)` returns a **token**; `animate(token, options)` plays\nit. An animation cannot point at keyframes that do not exist. The steps are\n`from`, `to` or percentages, and each step holds declarations from the\ngenerated table.\n\n`animate` writes longhands (`animation-name`, `animation-duration`, …), so a\nvariant can change the duration alone. It is not called `animation` because\nthat name is the generated shorthand helper.\n\n## Transitions\n\n\n\nThe properties are named from the table (`prop.backgroundColor`). There is no\n`all`: `transition: all` animates whatever a later variant happens to change,\nlayout included, and nobody decided that.\n\n`easing` holds the keywords (`linear`, `ease`, `easeIn`, `easeOut`,\n`easeInOut`, `stepStart`, `stepEnd`) and two constructors,\n`easing.cubicBezier(x1, y1, x2, y2)` and `easing.steps(count, position)`.\n\n## Reduced motion\n\nYou write none of it. Under `prefers-reduced-motion: reduce`, the\n[global foundation](./foundation.md) collapses every animation and transition\nof the document to an instant.\n"
|
|
486
|
+
},
|
|
477
487
|
{
|
|
478
488
|
"path": "/guide/style/setup",
|
|
479
489
|
"title": "Activating `@craft-ts/style`",
|
|
480
|
-
"body": "# Activating `@craft-ts/style`\n\nThe typed style system is not a runtime library you import and call. It is a\n**build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates\nwhat they registered, and emits one stylesheet. Without that plugin the\nvocabulary still typechecks and still compiles — and the page renders with no\nCSS at all.\n\nThis page is the one to follow before the other four.\n\n## Install\n\n```bash\nnpm install @craft-ts/style\nnpm install --save-dev @craft-ts/dev-tools\n# Optional, for visual scenario matrices and attestation:\nnpm install --save-dev @craft-ts/style-testing\n```\n\n`@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom\nproperties, axes, sheets, obligations. `@craft-ts/dev-tools` carries the\n`craft-graph` contrast command and the ESLint guard rails.\n`@craft-ts/style-testing` carries the optional scenario matrix and the drivers\nthat reach each of its points; it never ships to the browser, so it belongs in\n`devDependencies` when visual scenarios or attestation are enabled.\n\n`@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and\n`@craft-ts/style-testing` declares `@craft-ts/style`. Keep all installed\n`@craft-ts/*` packages on the same release line. The runtime style package and\nthe optional testing package are both `sideEffects: false`.\n\n## Wire the plugin\n\n\n\n`craftStyle` takes
|
|
490
|
+
"body": "# Activating `@craft-ts/style`\n\nThe typed style system is not a runtime library you import and call. It is a\n**build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates\nwhat they registered, and emits one stylesheet. Without that plugin the\nvocabulary still typechecks and still compiles — and the page renders with no\nCSS at all.\n\nThis page is the one to follow before the other four.\n\n## Install\n\n```bash\nnpm install @craft-ts/style\nnpm install --save-dev @craft-ts/dev-tools\n# Optional, for visual scenario matrices and attestation:\nnpm install --save-dev @craft-ts/style-testing\n```\n\n`@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom\nproperties, axes, sheets, obligations. `@craft-ts/dev-tools` carries the\n`craft-graph` contrast command and the ESLint guard rails.\n`@craft-ts/style-testing` carries the optional scenario matrix and the drivers\nthat reach each of its points; it never ships to the browser, so it belongs in\n`devDependencies` when visual scenarios or attestation are enabled.\n\n`@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and\n`@craft-ts/style-testing` declares `@craft-ts/style`. Keep all installed\n`@craft-ts/*` packages on the same release line. The runtime style package and\nthe optional testing package are both `sideEffects: false`.\n\n## Wire the plugin\n\n\n\n`craftStyle` takes six options, all optional:\n\n| option | default | what it decides |\n| ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| `suffix` | `'.style.ts'` | the filename suffix that marks a module as a sheet |\n| `ignore` | `['node_modules', 'dist', '.git', '.nx', 'tmp']` | directory names the walk never descends into |\n| `dumpPath` | none — no dump is written | where to write the graph dump |\n| `alias` | none | module aliases for the **Node** evaluation of the sheets |\n| `reset` | `true` | ship the craft-ts reset — see [Global foundation](./foundation.md) |\n| `base` | `true` | ship colour scheme, focus ring, reduced motion, selection |\n\n`alias` exists because the sheets are evaluated by a real bundler in a separate\npass, before your app's own resolution applies. In a published project, Node\nresolution finds `@craft-ts/style` on its own and you can leave `alias` out. In\nthis monorepo the demo passes the workspace source paths — see\n[`apps/demo/vite.config.ts`](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/vite.config.ts),\nwhich is the working reference for everything on this page.\n\nThen import the emitted sheet once, at the app entry:\n\n```ts\nimport 'virtual:craft-style.css';\n```\n\nIf your `tsconfig` does not already know that id, declare it next to your other\nambient types:\n\n```ts\ndeclare module 'virtual:craft-style.css';\n```\n\n## What the plugin produces\n\nTwo artefacts, from one evaluation.\n\n**The CSS.** Every `when(...)` and `set(...)` the sheets registered, rendered as\natomic rules and `@property` registrations, deduplicated across files, and\nserved under `virtual:craft-style.css`. This is the whole stylesheet: no class\nis ever assembled in the browser, so what the browser gets is exactly what the\nemitter proved.\n\n**The dump**, when `dumpPath` is set. A JSON picture of the registry — classes,\natoms, and typed variables — written on _every_ emission, so it can never\ndescribe a sheet older than the CSS that was served alongside it. The dump is\nthe style half of the dependency graph: it is what\n[`style_impact`, `style_matrix` and `style_debt`](./testing.md#what-the-graph-adds)\nread, and what the `@craft-ts/dev-tools` style queries read.\n\nThe plugin re-derives the whole sheet when a `*.style.ts` changes rather than\npatching it. Atomic output is small and the emission is one bundle away; an\nincremental path here would be a second source of truth about what the CSS says.\n\n## What breaks without it\n\n| missing | symptom |\n| ---------------------------------- | ---------------------------------------------------------------------------- |\n| the plugin | no CSS at all — the classes exist as strings, nothing ever wrote their rules |\n| `import 'virtual:craft-style.css'` | same, and less obviously: the plugin runs but nothing pulls its output |\n| `dumpPath` | `style_matrix` and the other graph queries have nothing to read and say so |\n\nThe MCP server names the fix in its own error message: it points you back at\n`craftStyle({ dumpPath })`. If you are reading this because you saw that\nmessage, `dumpPath` is the line you are missing.\n\n## Emitting without a Vite server\n\n`@craft-ts/style/vite` exports the emitter itself, so a test or a script can get\nthe same two artefacts without standing up a dev server:\n\n\n\n`vite` is a peer of that entry point, not a dependency: a project that builds\nwith something else can still call `emitStyles` without pulling Vite's types\ninto its own program.\n\n## Next\n\n- [Define your design system](./define.md) — palette, axes, theme: where `bp`,\n `scheme` and `palette` come from.\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n"
|
|
481
491
|
},
|
|
482
492
|
{
|
|
483
493
|
"path": "/guide/style/template-obligations",
|
|
484
494
|
"title": "Template obligations",
|
|
485
|
-
"body": "# Template obligations\n\nTests and visual captures describe things that were observed. A component\ntemplate describes something earlier: what the component promises to display\nand what actions it exposes. CraftTS can derive those promises directly from\nthe dependency graph and record a human judgement about each one.\n\n```sh\ncraft-ts attest status \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nNo test or browser report is required. The command reads each\n`craftComponent` template and derives two kinds of obligation.\n\n## Render and command\n\nA **render** obligation starts at a reactive binding in the template and follows\nthe graph to the state, computed value, query, or property that produces it.\n\n```ts\n({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]);\n```\n\nThis template promises to render `user.isAdmin` and `total`. Two occurrences of\nthe same target are one promise, not two.\n\nA **command** obligation starts at a handler on an interactive element and\nfollows the call chain it triggers.\n\n```ts\n({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove');\n```\n\nThis template promises that the named button invokes `users.remove`. The\nelement tag and its literal name are part of the promise, so moving the action\nto a different control asks for a new judgement.\n\nComputed or dynamic accesses that cannot be addressed are printed as\n`template-obligation-unresolved` diagnostics. They are known extraction gaps;\nthey are never silently treated as if the template made no promise.\n\nThe derived obligation keeps two presentation forms. Its `statement` is a\ncanonical English sentence and remains available in the API, CLI and agency\nhandoffs. The review application uses the accompanying structured statement\nparts to render that same promise in the selected language. Neither form is\npart of the attested evidence hash, so wording changes do not invalidate a\ndecision.\n\n## What `renewed` means\n\nEvery obligation has two independent keys:\n\n| key | meaning
|
|
495
|
+
"body": "# Template obligations\n\nTests and visual captures describe things that were observed. A component\ntemplate describes something earlier: what the component promises to display\nand what actions it exposes. CraftTS can derive those promises directly from\nthe dependency graph and record a human judgement about each one.\n\n```sh\ncraft-ts attest status \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nNo test or browser report is required. The command reads each\n`craftComponent` template and derives two kinds of obligation.\n\n## Render and command\n\nA **render** obligation starts at a reactive binding in the template and follows\nthe graph to the state, computed value, query, or property that produces it.\n\n```ts\n({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]);\n```\n\nThis template promises to render `user.isAdmin` and `total`. Two occurrences of\nthe same target are one promise, not two.\n\nA **command** obligation starts at a handler on an interactive element and\nfollows the call chain it triggers.\n\n```ts\n({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove');\n```\n\nThis template promises that the named button invokes `users.remove`. The\nelement tag and its literal name are part of the promise, so moving the action\nto a different control asks for a new judgement.\n\nFor a command that resolves to a `craftMethod`, the obligation also records\nthe method's top-level call statements in source order. Calls inside a branch or a\nnested callback are omitted because they are not guaranteed on every click.\nThe ordered calls are part of the readable evidence, so changing them asks for\na new judgement. The review card shows this sequence below the promise.\n\nThe review queue contains the promise and its short effect list, but no source\ncode. When a template review card opens, the review app requests its button\nand method snippets from `/api/template-detail` and shows both by default.\n\nComputed or dynamic accesses that cannot be addressed are printed as\n`template-obligation-unresolved` diagnostics. They are known extraction gaps;\nthey are never silently treated as if the template made no promise.\n\nThe derived obligation keeps two presentation forms. Its `statement` is a\ncanonical English sentence and remains available in the API, CLI and agency\nhandoffs. The review application uses the accompanying structured statement\nparts to render that same promise in the selected language. Neither form is\npart of the attested evidence hash, so wording changes do not invalidate a\ndecision.\n\n## What `renewed` means\n\nEvery obligation has two independent keys:\n\n| key | meaning |\n| ---------------- | ------------------------------------------------------------------------------------------------------------- |\n| code fingerprint | the transitive code slice behind the bound or invoked target |\n| evidence | the canonical shape of the promise: direction, element, name, target, target kind, and direct command effects |\n\nWhen implementation code changes but the template still promises the same\nthing, the state is `renewed`. The previous judgement carries forward without\nasking a person to review it again. When the template binds or invokes a\ndifferent target, or a command's direct effects change, the evidence changes\nand the state is `review`.\n\nAn attested obligation is **not a passing test**. It says that a person confirmed\nthe promise was intentional. It does not prove that the implementation fulfils\nthat promise at runtime.\n\n## Removing a promise is a decision\n\nIf an attested template obligation disappears, `status --kind template` exits\nwith a failure until the removal is signed. The ledger line is retained and\nmarked with why the promise went away:\n\n```sh\ncraft-ts attest retire \\\n --kind template \\\n --subject 'template:component:src/card.ts:Card#command:property:src/card.ts:save' \\\n --reason superseded \\\n --note 'Saving is automatic now.'\n```\n\nThe reasons are:\n\n- `superseded`: the product now fulfils the need another way;\n- `defect`: the former promise was wrong;\n- `derivation`: the extractor produced an obligation it should not have. Track\n this count as a quality signal for the extractor.\n\nThe note is mandatory because absence is otherwise indistinguishable from an\naccidental deletion. If a retired obligation later reappears, it returns to the\nreview queue; the next human verdict clears the retirement.\n\n## Measured rename noise\n\nThe target identity remains part of the evidence because the implementation\nmeasurement stayed inside its review budget. Replaying the latest 20 commits\nthat touched `apps/demo` produced a median of **0 actionable obligation changes\nper commit** (one commit removed four obligations, one added three, and the\nother eighteen changed none), with **0 changes attributable to a pure target\nrename**. This is below the threshold of three, so mass renames can continue to\nbe handled by review clustering without weakening the promise recorded in the\nevidence.\n"
|
|
486
496
|
},
|
|
487
497
|
{
|
|
488
498
|
"path": "/guide/style/testing",
|
|
@@ -492,17 +502,17 @@
|
|
|
492
502
|
{
|
|
493
503
|
"path": "/guide/style/tokens",
|
|
494
504
|
"title": "Tokens and typed variables",
|
|
495
|
-
"body": "# Tokens and typed variables\n\nLevel 1. Useful from the first component, and it does not require anything else\nin the app to change.\n\n## No value is a string\n\nEvery value is a **nominal object**, not a branded string:\n\n```ts\nimport { bg, p, palette, space, unit } from '@craft-ts/style';\n\np(space(4)); // ✅\np(unit.rem(1.5)); // ✅\np('12px'); // ❌ a length is not a string\np(`${4}px`); // ❌ not even one of the right shape\nbg('red'); // ❌ a colour is not a keyword\np(palette.text.strong); // ❌ a colour is not a length\n```\n\nThe shape matters more than it looks. With `string & { __length?: true }` —\nan _optional_ phantom on a primitive base — `'blabla'` stays assignable, every\ntest stays green, and the guarantee written on this page is false.\n\n## The scales are closed\n\n`space(7)` does not compile. When a step is missing, add it to the scale; there\nis no `[17px]` arbitrary-value syntax on purpose.\n\nThe one way out is marked:\n\n```ts\nimport { unsafeLength } from '@craft-ts/style';\n\nunsafeLength('13px', 'aligns with a legacy image');\n```\n\nIt compiles, and it propagates `unproven` to the dependency graph, where the\ndebt is counted. Without this door a blocked agent bypasses the design system\nentirely; with it unmarked, it bypasses it in silence.\n\n## The property table is generated\n\n477 properties, generated from MDN data — which is what guarantees no keyword\nwas invented. A closed keyword set is a namespace, never a string:\n\n```ts\nimport { display, position } from '@craft-ts/style';\n\ndisplay.inlineFlex; // ✅\ndisplay.inlineFlexx; // ❌ Property 'inlineFlexx' does not exist\nposition('sticky'); // ❌ a keyword is not something you pass in\n```\n\nTwo consequences worth knowing:\n\n- **`overflow` is not in the table.** The only road to `overflow-block: auto` is\n `provides(scrollPort.block)` — see [obligations](./obligations.md). The wrong\n fix is not discouraged, it cannot be written.\n- **124 helpers are narrower than CSS.** A grammar alternative the generator\n cannot close is dropped rather than approximated, so a helper may refuse a\n form CSS would accept. It can never produce CSS a browser rejects. The list is\n exported as `NARROWED_PROPERTIES`.\n\n## Typed custom properties\n\n```ts\nimport { color, cssVars, kind, p, palette, unit } from '@craft-ts/style';\n\nexport const v = cssVars('badge', {\n ink: kind.color(palette.text.strong),\n pad: kind.length(unit.px(16)),\n});\n\ncolor(v.ink); // ✅ the token carries its kind's brand\np(v.ink); // ❌ a <color> variable is not a length\nv.ink.or(space(4)); // ❌ the fallback is typed against the same kind\n```\n\nTwo rules the browser enforces and the types cannot:\n\n**A registered `initial-value` must be computationally independent.**\n`initial-value: 1rem` makes the whole `@property` rule invalid and the browser\ndrops it _silently_ — the variable stops being registered, `var(--x)` resolves to\nnothing, and whatever reads it computes to zero. `cssVars` refuses a relative\nunit there and names the fix.\n\n**`inherits: false` is the right default, and wrong for a theme.** A variable an\nelement sets and reads on itself should not inherit: it bounds invalidation. A\ntheme variable is the opposite — set once on a wrapper, read by everything\nbelow — so pass `{ inherits: true }`. A non-inheriting theme hands every\ndescendant the initial value, which looks exactly like dark mode not working,\nwith no error anywhere.\n"
|
|
505
|
+
"body": "# Tokens and typed variables\n\nLevel 1. Useful from the first component, and it does not require anything else\nin the app to change.\n\n## No value is a string\n\nEvery value is a **nominal object**, not a branded string:\n\n```ts\nimport { bg, p, palette, space, unit } from '@craft-ts/style';\n\np(space(4)); // ✅\np(unit.rem(1.5)); // ✅\np('12px'); // ❌ a length is not a string\np(`${4}px`); // ❌ not even one of the right shape\nbg('red'); // ❌ a colour is not a keyword\np(palette.text.strong); // ❌ a colour is not a length\n```\n\nThe shape matters more than it looks. With `string & { __length?: true }` —\nan _optional_ phantom on a primitive base — `'blabla'` stays assignable, every\ntest stays green, and the guarantee written on this page is false.\n\n## The scales are closed\n\n`space(7)` does not compile. When a step is missing, add it to the scale; there\nis no `[17px]` arbitrary-value syntax on purpose.\n\nThe one way out is marked:\n\n```ts\nimport { unsafeLength } from '@craft-ts/style';\n\nunsafeLength('13px', 'aligns with a legacy image');\n```\n\nIt compiles, and it propagates `unproven` to the dependency graph, where the\ndebt is counted. Without this door a blocked agent bypasses the design system\nentirely; with it unmarked, it bypasses it in silence.\n\n## The property table is generated\n\n477 properties, generated from MDN data — which is what guarantees no keyword\nwas invented. A closed keyword set is a namespace, never a string:\n\n```ts\nimport { display, position } from '@craft-ts/style';\n\ndisplay.inlineFlex; // ✅\ndisplay.inlineFlexx; // ❌ Property 'inlineFlexx' does not exist\nposition('sticky'); // ❌ a keyword is not something you pass in\n```\n\nTwo consequences worth knowing:\n\n- **`overflow` is not in the table.** The only road to `overflow-block: auto` is\n `provides(scrollPort.block)` — see [obligations](./obligations.md). The wrong\n fix is not discouraged, it cannot be written.\n- **124 helpers are narrower than CSS.** A grammar alternative the generator\n cannot close is dropped rather than approximated, so a helper may refuse a\n form CSS would accept. It can never produce CSS a browser rejects. The list is\n exported as `NARROWED_PROPERTIES`.\n\nA few values the table cannot type on its own have constructors:\n\n- `gradient.linear`, `.radial`, `.repeatingLinear`, `.repeatingConic`, written\n with `bgImage(...)` — several images make several layers, and `bgSize` /\n `bgPosition` take one inline and one block value;\n- `uaScheme.light` / `.dark` for `color-scheme`, so native controls follow the\n theme;\n- `spanAllColumns` for `grid-column: 1 / -1`;\n- `pseudo.content.attr(ident('data-x'))` for a `::before` / `::after` whose text\n is an attribute the template writes.\n\n## Typed custom properties\n\n```ts\nimport { color, cssVars, kind, p, palette, unit } from '@craft-ts/style';\n\nexport const v = cssVars('badge', {\n ink: kind.color(palette.text.strong),\n pad: kind.length(unit.px(16)),\n});\n\ncolor(v.ink); // ✅ the token carries its kind's brand\np(v.ink); // ❌ a <color> variable is not a length\nv.ink.or(space(4)); // ❌ the fallback is typed against the same kind\n```\n\nTwo rules the browser enforces and the types cannot:\n\n**A registered `initial-value` must be computationally independent.**\n`initial-value: 1rem` makes the whole `@property` rule invalid and the browser\ndrops it _silently_ — the variable stops being registered, `var(--x)` resolves to\nnothing, and whatever reads it computes to zero. `cssVars` refuses a relative\nunit there and names the fix.\n\n**`inherits: false` is the right default, and wrong for a theme.** A variable an\nelement sets and reads on itself should not inherit: it bounds invalidation. A\ntheme variable is the opposite — set once on a wrapper, read by everything\nbelow — so pass `{ inherits: true }`. A non-inheriting theme hands every\ndescendant the initial value, which looks exactly like dark mode not working,\nwith no error anywhere.\n"
|
|
496
506
|
},
|
|
497
507
|
{
|
|
498
508
|
"path": "/guide/style/variants",
|
|
499
509
|
"title": "Axes and the visual matrix",
|
|
500
|
-
"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
|
|
510
|
+
"body": "# Axes and the visual matrix\n\nLevel 2. Adopted per component, and it is what turns \"I think that is all the\nstates\" into a list.\n\n## A variant is an axis, not a class name\n\n```ts\nimport {\n bg,\n craftStyles,\n defineStateAxis,\n palette,\n set,\n when,\n} from '@craft-ts/style';\n// `v` is your own sheet's typed variables, `bp` your own breakpoints — see\n// [Defining a design system](./define.md).\nimport { bp, v } from './foundation.style';\n\nexport const tone = defineStateAxis('tone', ['neutral', 'danger']);\n\nexport const badge = craftStyles('badge', {\n root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])],\n});\n```\n\nThe template sets **one static class** and a `data-tone` attribute. Nothing\nconcatenates a class at render time, which is what makes the set of states\nenumerable. The `no-raw-class` rule enforces it in every file.\n\nConjunction is nesting, and only nesting:\n\n```ts\nimport { fontWeight, scheme, when } from '@craft-ts/style';\n\nwhen(scheme.dark, [when(bp.md, [fontWeight.bold])]);\n```\n\nOne way to write each thing, so two identical components cannot produce two\ndifferent contracts.\n\n## Only the points you actually cross\n\n`bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes\n**two** cells, not four. The contract records what the sheet uses, never what the\naxis offers.\n\nAn interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` —\nthrows when the sheet is registered, which under the build plugin is a build\nfailure.\n\n## Hover, and every pseudo-class after it\n\n```ts\nwhen(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]);\n```\n\n`interaction.hover` emits the same `&:hover` rule you would write by hand.\nWhat it adds is that the point enters the class's contract — so the matrix\nenumerates the hovered state, and the\n[static contrast check](./contrast.md) crosses the colours it writes with the\ntext on top of them. A `:hover` typed into a string emits identical CSS and is\ninvisible to both, which is how a button ends up readable at rest and\nunreadable under the pointer. `prefer-hover-axis` refuses it.\n\nIt is a real axis with a real price: it doubles the sheet's matrix, so it has\nto be in the budget below. Its driver is `{ kind: 'selfState', state: 'hover' }`,\nand `applyScenario` honours it by asking the page to move a pointer — a\ndispatched `mouseover` sets no pseudo-class and would capture the base state\nwhile looking correct.\n\n## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size, interaction] })\n```\n\nAn axis outside the budget is a compile error naming it. Without this, an axis\nadded deep in a leaf shows up as a doubled capture bill three levels up and\nnobody decided that. A declared axis that goes unused is reported, not rejected.\n\n## The matrix\n\n```ts\nimport { visualMatrix, branch } from '@craft-ts/style-testing';\n\nvisualMatrix(card);\n// [{ id: 'base', … }, { id: 'viewport=md', … }]\n```\n\nIt takes **sheets**, not a component: a component's classes are only knowable by\nrendering it, and a matrix that silently missed a child's sheet would be the\nworst possible outcome.\n\nIdentifiers name only the axes away from `base`, so adding an axis elsewhere in\nthe app does not invalidate every baseline in the suite.\n\nTwo reductions are applied, and both are exactly true rather than probably true:\n\n- **A branch adds, it does not multiply.** The two sides of an `ifNode` are\n never on screen together, so declare it — `branch('footer', footerSheet)` —\n and the absent side stops carrying the footer's axes.\n- **A container axis stops at its owner.** An ancestor cannot change how wide\n that box is, so only the component naming the container keeps the axis.\n\nNothing else is reduced. A coverage that claims to be complete without being\ncomplete is worse than no coverage.\n"
|
|
501
511
|
},
|
|
502
512
|
{
|
|
503
513
|
"path": "/guide/testing/architecture",
|
|
504
514
|
"title": "Architecture rules",
|
|
505
|
-
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must model input-driven work as a form** — a button must not send input state directly into a mutation or async process;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInputActionForms,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## The graph vocabulary\n\nThink of the graph as a typed inventory of architectural facts, not as a\nsecond runtime. A **node** is a thing the architecture can name; an **edge** is\nan observed relationship between two nodes. The graph is intentionally more\nfine-grained than a project graph: one app can contain many services,\ncomponents, primitives and HTTP endpoints.\n\n### Node families\n\nNot every application produces every kind of node. The built-in vocabulary is\ngrouped below by the questions it helps answer:\n\n| Family | Node kinds | What they represent |\n| --- | --- | --- |\n| Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. |\n| Reactive structure | `primitive`, `property`, `source`, `template-element` | A `state`, `query`, `mutation`, `craftComputed`, `craftEffect`, `craftMethod`, `queryParams`, or an exposed member/source/template element. A primitive's `details.name` keeps its concrete primitive name. |\n| Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. |\n| Server functions | `server-function-family`, `server-function-contract`, `server-function-client`, `server-function-server`, `server-function-misnamed`, `server-function-middleware`, `server-function-middleware-misnamed`, `client-function-middleware`, `client-function-middleware-misnamed` | The client/server contract, implementation, middleware and naming checks around server functions. |\n| Protocol and extensions | `handshake`, plus adapter/contributed kinds such as `effect-service`, `effect-operation`, `effect-layer`, `data-classification`, and `external-output` | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |\n\nFor example, a page can be represented as these facts: a `route` **loads** a\n`component`; the component **contains** a `query`; a `service` **calls** the\n`GET users` `http-endpoint`; a consumer service **depends-on** a browser\nboundary; and a `mutation` **triggers** a query. These are independent,\ntyped relations that a rule can inspect directly.\n\nThe labels are deliberately semantic. A rule can ask “which service calls this\nendpoint?” or “which mutation triggers this query?” without matching file text\nor reconstructing the dependency tree itself.\n\n### Edge families\n\nThe built-in edge kinds describe different types of fact; they should not all\nbe treated as interchangeable dependency arrows:\n\n| Edge kinds | Meaning | Typical architecture question |\n| --- | --- | --- |\n| `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |\n| `depends-on`, `calls` | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |\n| `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |\n| `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |\n| Extension relations | Backend-specific facts, for example `requires-service`, `provided-by-layer`, `composes-layer`, `exposes-data`, `flows-data` | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |\n\nThe direction matters: `from --kind--> to` is the fact asserted by the\nanalyzer. A `depends-on` edge is therefore different from a `provides` edge,\nand a structural `contains` edge should not be mistaken for a runtime cycle.\nThis is why `assertNoDependencyCycles` follows `depends-on` rather than every\nedge in the graph.\n\n### What the graph is based on\n\nThe analyzer works from the TypeScript program selected by the analysis\n`tsconfig`:\n\n- **AST evidence** records syntax that is visible in the source: a route\n loading a component, a component rendering an element, or a service calling\n an HTTP client.\n- **Type evidence** records relationships resolved through TypeScript: an\n injected/yielded service, a provider, or a route proof connected to its\n target.\n- **Source proofs** keep the file, line, symbol and pattern that explain an\n edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a\n failing rule can point back to the declaration that created the fact.\n\nThe result is static and deterministic: architecture tests do not boot the\napplication, instantiate services, make HTTP requests or observe user\nbehaviour. They prove that the source still has an allowed shape. Runtime\nbehaviour belongs in [service tests](/guide/testing/services), [component\ntests](/guide/testing/components) and e2e tests.\n\n### Choosing the granularity of a rule\n\nStart at the smallest graph level that expresses the invariant, then widen only\nwhen the invariant is genuinely architectural:\n\n| Granularity | Example assertion | Best for |\n| --- | --- | --- |\n| Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules |\n| Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership |\n| Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies |\n| Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles |\n| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |\n\nThe public API mirrors those levels: use `graph.nodes(kind)` and\n`graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()`\nfor neighbourhoods, and `graph.pathsBetween()` when the rule is about\nreachability. Built-in `assert*` helpers package recurring whole-graph checks;\ncustom rules should state the product or team invariant before describing the\ntraversal.\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| -------------------------------------------------- | ------------------------------------------------ |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertInputActionForms`](/guide/testing/architecture/declarative-baseline) | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary, including when the primitive is declared in a service |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertMetricThresholds`](/guide/testing/architecture/metric-thresholds) | **opt-in:** a selected node exceeds a team-defined complexity, size or coupling threshold |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertVisualHappyPathArchitecture`\n\nThe visual overview contract connects routed pages, the default mobile and\ndesktop viewports, and deterministic API datasets. It consumes the config\ncreated with `defineVisualAppConfig` and fails if a routed page is absent, a\nconfigured component is unknown, or any `CraftHttpClient` /\n`CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated\n`*.happy-path.ts` file.\n\n```typescript\nimport { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';\nimport { visualTestConfig } from '../../e2e/visual-test.config';\n\nit('covers every page and HTTP endpoint in the visual happy path', () => {\n assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);\n});\n```\n\nThe assertion is separate from `assertDeclarativeArchitecture` because it\nneeds the application's visual config. Dynamic URL segments are represented by\n`*`, so a template URL such as `` `/api/users/${id}` `` is indexed as\n`/api/users/*` and uses the same key in its fixture.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive _has_ an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Metric thresholds\n\nEvery node of the graph carries `metrics`: cyclomatic complexity (its own and\nwith everything it contains), line count, fan-in and fan-out. No threshold\napplies by default; put the ones your team agrees on in the suite. See the\n[focused rule guide](/guide/testing/architecture/metric-thresholds) for\nbefore-and-after examples:\n\n```typescript\nimport { assertMetricThresholds } from '@craft-ts/dev-tools/architecture-graph';\n\nit('keeps services and primitives small', () => {\n assertMetricThresholds(graph.graph, {\n kinds: ['service', 'primitive'],\n max: { cyclomaticOwn: 15, fanOut: 12 },\n allow: ['src/legacy/**', 'ReportingService'],\n });\n});\n```\n\n`allow` takes node ids, labels, or path globs. A metric the graph could not\ncompute — a node without a source range — is skipped, never treated as `0`;\n`graph.diagnostics` lists the unmeasured kinds. The assertion refuses a graph\nthat carries no metrics at all, such as a JSON file written by an older\nversion. `metricThresholdViolations` returns the same findings as data.\n\nSee [Graph insights](/guide/testing/graph-insights) for how the metrics are\ncomputed, the hotspot ranking and the report.\n\n## Documentation rules\n\nThe graph reads the JSDoc of each declaration and, with the opt-in Markdown\ncollector, the pages that cite a node. `assertNodesDocumented` turns that into\na rule:\n\n```typescript\nimport { assertNodesDocumented } from '@craft-ts/dev-tools/architecture-graph';\nimport {\n analyzeDependencyGraph,\n createMarkdownDocsCollector,\n} from '@craft-ts/dev-tools/dependency-graph';\n\nconst documented = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [createMarkdownDocsCollector({ include: ['docs/**/*.md'] })],\n});\n\nit('documents every service', () => {\n assertNodesDocumented(documented, {\n kinds: ['service'],\n requireDocPage: true,\n allow: ['src/legacy/**'],\n });\n});\n```\n\nA node fails without a JSDoc summary, and with `requireDocPage` when no page\ncites it in inline code. A node without a source range is skipped: its\ndocumentation is unknown, not missing. `requireDocPage` refuses a graph built\nwithout the collector. `undocumentedNodeViolations` returns the findings as\ndata.\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind).toBe(\n 'unique',\n );\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | --------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML + report |\n| `report` | `.report.md` and `.report.json` |\n\n`--include <text>` restricts analysis to matching source paths.\n`--feature-glob`, `--churn-since` and `--coverage` shape the\n[report](/guide/testing/graph-insights#report). Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
515
|
+
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must model input-driven work as a form** — a button must not send input state directly into a mutation or async process;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\nThe default baseline also rejects event-only `craftMethod` wrappers through\n`assertNoEventOnlyCraftMethods`. It scans every TypeScript source file in the\napplication's graph project, so moving the wrapper to another file does not\navoid the rule. Use [`eventAction(...)`](/guide/components/directives#event-actions-and-dom-modifiers)\non the element to apply DOM event modifiers and invoke the action directly.\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInputActionForms,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertNoEventOnlyCraftMethods,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## The graph vocabulary\n\nThink of the graph as a typed inventory of architectural facts, not as a\nsecond runtime. A **node** is a thing the architecture can name; an **edge** is\nan observed relationship between two nodes. The graph is intentionally more\nfine-grained than a project graph: one app can contain many services,\ncomponents, primitives and HTTP endpoints.\n\n### Node families\n\nNot every application produces every kind of node. The built-in vocabulary is\ngrouped below by the questions it helps answer:\n\n| Family | Node kinds | What they represent |\n| --- | --- | --- |\n| Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. |\n| Reactive structure | `primitive`, `property`, `source`, `template-element` | A `state`, `query`, `mutation`, `craftComputed`, `craftEffect`, `craftMethod`, `queryParams`, or an exposed member/source/template element. A primitive's `details.name` keeps its concrete primitive name. |\n| Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. |\n| Server functions | `server-function-family`, `server-function-contract`, `server-function-client`, `server-function-server`, `server-function-misnamed`, `server-function-middleware`, `server-function-middleware-misnamed`, `client-function-middleware`, `client-function-middleware-misnamed` | The client/server contract, implementation, middleware and naming checks around server functions. |\n| Protocol and extensions | `handshake`, plus adapter/contributed kinds such as `effect-service`, `effect-operation`, `effect-layer`, `data-classification`, and `external-output` | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |\n\nFor example, a page can be represented as these facts: a `route` **loads** a\n`component`; the component **contains** a `query`; a `service` **calls** the\n`GET users` `http-endpoint`; a consumer service **depends-on** a browser\nboundary; and a `mutation` **triggers** a query. These are independent,\ntyped relations that a rule can inspect directly.\n\nThe labels are deliberately semantic. A rule can ask “which service calls this\nendpoint?” or “which mutation triggers this query?” without matching file text\nor reconstructing the dependency tree itself.\n\n### Edge families\n\nThe built-in edge kinds describe different types of fact; they should not all\nbe treated as interchangeable dependency arrows:\n\n| Edge kinds | Meaning | Typical architecture question |\n| --- | --- | --- |\n| `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |\n| `depends-on`, `calls` | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |\n| `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |\n| `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |\n| Extension relations | Backend-specific facts, for example `requires-service`, `provided-by-layer`, `composes-layer`, `exposes-data`, `flows-data` | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |\n\nThe direction matters: `from --kind--> to` is the fact asserted by the\nanalyzer. A `depends-on` edge is therefore different from a `provides` edge,\nand a structural `contains` edge should not be mistaken for a runtime cycle.\nThis is why `assertNoDependencyCycles` follows `depends-on` rather than every\nedge in the graph.\n\n### What the graph is based on\n\nThe analyzer works from the TypeScript program selected by the analysis\n`tsconfig`:\n\n- **AST evidence** records syntax that is visible in the source: a route\n loading a component, a component rendering an element, or a service calling\n an HTTP client.\n- **Type evidence** records relationships resolved through TypeScript: an\n injected/yielded service, a provider, or a route proof connected to its\n target.\n- **Source proofs** keep the file, line, symbol and pattern that explain an\n edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a\n failing rule can point back to the declaration that created the fact.\n\nThe result is static and deterministic: architecture tests do not boot the\napplication, instantiate services, make HTTP requests or observe user\nbehaviour. They prove that the source still has an allowed shape. Runtime\nbehaviour belongs in [service tests](/guide/testing/services), [component\ntests](/guide/testing/components) and e2e tests.\n\n### Choosing the granularity of a rule\n\nStart at the smallest graph level that expresses the invariant, then widen only\nwhen the invariant is genuinely architectural:\n\n| Granularity | Example assertion | Best for |\n| --- | --- | --- |\n| Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules |\n| Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership |\n| Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies |\n| Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles |\n| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |\n\nThe public API mirrors those levels: use `graph.nodes(kind)` and\n`graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()`\nfor neighbourhoods, and `graph.pathsBetween()` when the rule is about\nreachability. Built-in `assert*` helpers package recurring whole-graph checks;\ncustom rules should state the product or team invariant before describing the\ntraversal.\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n mergeStyleDump,\n} from '@craft-ts/dev-tools';\nimport { loadStyleDump } from '@craft-ts/style/vite';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport async function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n // The style half: every *.style.ts, evaluated by the same code the build\n // runs. The catalog stays built from the code graph alone.\n const styleDump = await loadStyleDump(join(workspaceRoot, 'apps/your-app/src'));\n return createArchitectureGraph(\n mergeStyleDump(graph, styleDump),\n architectureCatalog,\n );\n}\n```\n\nLoad it once in a `beforeAll(async () => { graph = await loadArchitectureGraph(); })`.\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| -------------------------------------------------- | ------------------------------------------------ |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\nTo propose project-specific source folders, see the\n[folder layout organizer guide](/guide/testing/folder-layout).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertInputActionForms`](/guide/testing/architecture/declarative-baseline) | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary, including when the primitive is declared in a service |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertMetricThresholds`](/guide/testing/architecture/metric-thresholds) | **opt-in:** a selected node exceeds a team-defined complexity, size or coupling threshold |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertVisualHappyPathArchitecture`\n\nThe visual overview contract connects routed pages, the default mobile and\ndesktop viewports, and deterministic API datasets. It consumes the config\ncreated with `defineVisualAppConfig` and fails if a routed page is absent, a\nconfigured component is unknown, or any `CraftHttpClient` /\n`CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated\n`*.happy-path.ts` file.\n\n```typescript\nimport { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';\nimport { visualTestConfig } from '../../e2e/visual-test.config';\n\nit('covers every page and HTTP endpoint in the visual happy path', () => {\n assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);\n});\n```\n\nThe assertion is separate from `assertDeclarativeArchitecture` because it\nneeds the application's visual config. Dynamic URL segments are represented by\n`*`, so a template URL such as `` `/api/users/${id}` `` is indexed as\n`/api/users/*` and uses the same key in its fixture.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nimport { architectureWaiverList } from './waivers';\n\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, {\n allow: ['logout'],\n waivers: architectureWaiverList,\n });\n});\n```\n\nThe aggregate includes four **style rules**. They make `@craft-ts/style` the\nonly way to style a component, and they need the style dump merged into the\ngraph (step 4):\n\n| rule | fails when |\n| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `style-only-design-system` | an element's class does not reach a sheet of a `*.style.ts` (a string, a computed class, a sheet declared elsewhere), or a component carries `meta.styles` or imports a `.css` |\n| `style-obligations-discharged` | a sheet `requires(...)` an obligation nothing `provides(...)` |\n| `no-dangling-css-vars` | a variable is read and never declared, or declared and never read (a read from `craftGlobalStyles` counts) |\n| `no-global-stylesheet` | an entry file imports a `.css`, or `index.html` links a stylesheet — the only one is `virtual:craft-style.css` |\n\nA component of pure composition, with no class at all, is not at fault. A class\npassed through an input typed `CraftClass` is accepted.\n\n### Waivers\n\nA deliberate bypass — a third-party widget, HTML rendered from markdown — is a\n**waiver**: a rule, a target, and the reason. Declare them in\n`architecture/waivers.ts`, typed against the catalog, so a target that does not\nexist does not compile:\n\n```typescript\nimport { defineArchitectureWaivers } from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nexport const architectureWaiverList = defineArchitectureWaivers(\n architectureCatalog,\n [\n {\n rule: 'style-only-design-system',\n target: 'MarkdownArticle',\n reason: 'The HTML rendered from markdown carries its own classes.',\n },\n ],\n);\n```\n\nThe target is a component name, `file:<path>`, `obligation:<id>`,\n`css-var:<name>`, or `'*'` for the whole rule — the only form a rule that\nchecks the whole graph (`no-dependency-cycles`, …) accepts, and the one a\nproject uses while it migrates. Two things keep the list honest: an **empty\nreason** is refused, and a waiver that no longer waives anything is **stale**\nand fails the check. Review Attest lists every waiver for a decision.\n\n`craft-architecture-check` reads the same file (statically, without running the\napp) and takes the dump the build wrote with `--style-dump <path>`.\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive _has_ an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Metric thresholds\n\nEvery node of the graph carries `metrics`: cyclomatic complexity (its own and\nwith everything it contains), line count, fan-in and fan-out. No threshold\napplies by default; put the ones your team agrees on in the suite. See the\n[focused rule guide](/guide/testing/architecture/metric-thresholds) for\nbefore-and-after examples:\n\n```typescript\nimport { assertMetricThresholds } from '@craft-ts/dev-tools/architecture-graph';\n\nit('keeps services and primitives small', () => {\n assertMetricThresholds(graph.graph, {\n kinds: ['service', 'primitive'],\n max: { cyclomaticOwn: 15, fanOut: 12 },\n allow: ['src/legacy/**', 'ReportingService'],\n });\n});\n```\n\n`allow` takes node ids, labels, or path globs. A metric the graph could not\ncompute — a node without a source range — is skipped, never treated as `0`;\n`graph.diagnostics` lists the unmeasured kinds. The assertion refuses a graph\nthat carries no metrics at all, such as a JSON file written by an older\nversion. `metricThresholdViolations` returns the same findings as data.\n\nSee [Graph insights](/guide/testing/graph-insights) for how the metrics are\ncomputed, the hotspot ranking and the report.\n\n## Documentation rules\n\nThe graph reads the JSDoc of each declaration and, with the opt-in Markdown\ncollector, the pages that cite a node. `assertNodesDocumented` turns that into\na rule:\n\n```typescript\nimport { assertNodesDocumented } from '@craft-ts/dev-tools/architecture-graph';\nimport {\n analyzeDependencyGraph,\n createMarkdownDocsCollector,\n} from '@craft-ts/dev-tools/dependency-graph';\n\nconst documented = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [createMarkdownDocsCollector({ include: ['docs/**/*.md'] })],\n});\n\nit('documents every service', () => {\n assertNodesDocumented(documented, {\n kinds: ['service'],\n requireDocPage: true,\n allow: ['src/legacy/**'],\n });\n});\n```\n\nA node fails without a JSDoc summary, and with `requireDocPage` when no page\ncites it in inline code. A node without a source range is skipped: its\ndocumentation is unknown, not missing. `requireDocPage` refuses a graph built\nwithout the collector. `undocumentedNodeViolations` returns the findings as\ndata.\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind).toBe(\n 'unique',\n );\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | --------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML + report |\n| `report` | `.report.md` and `.report.json` |\n\n`--include <text>` restricts analysis to matching source paths.\n`--feature-glob`, `--churn-since` and `--coverage` shape the\n[report](/guide/testing/graph-insights#report). Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
506
516
|
},
|
|
507
517
|
{
|
|
508
518
|
"path": "/guide/testing/architecture/computed-purity",
|
|
@@ -617,7 +627,7 @@
|
|
|
617
627
|
{
|
|
618
628
|
"path": "/guide/testing/components",
|
|
619
629
|
"title": "Testing components",
|
|
620
|
-
"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
|
|
630
|
+
"body": "# Testing components\n\nCraft components are tested in two independent halves: the **logic factory**\n(plain values, no DOM) and the **template** (real DOM, explicit locators). You\ncan test one without paying for the other.\n\n**Use the logic test** for what the factory computes and exposes.\n**Use the template test** for what actually renders, and for interaction.\n\nThe utilities live in a dedicated submodule:\n\n```ts\nimport {\n setupCraftComponentLogicTest,\n setupCraftComponentTemplateTest,\n setupCraftDirectiveLogicTest,\n setupCraftDirectiveTemplateTest,\n} from '@craft-ts/component/testing';\n```\n\nThey deliberately separate the factory from rendering. Each utility also\nexposes a `.byRegister(...)` form, which makes the services used by the tested\ncode explicit.\n\n## Component logic\n\nThe logic test executes only the factory and returns its context together with\nthe installed mocks:\n\n```ts\nconst { context, mocks, destroy } =\n await setupCraftComponentLogicTest.byRegister(FullDemoCraft, {\n register: {\n TodoStore: {\n todos: {\n status: () => 'resolved',\n value: () => [],\n },\n },\n },\n });\n\nexpect(context.store.todos.value()).toEqual([]);\nexpect(mocks.TodoStore).toBeDefined();\ndestroy();\n```\n\nFactory arguments can be provided through `args` when the component declares\ninputs:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(StatusComponent, {\n args: [statusInput],\n register: {},\n});\n```\n\n## Component template\n\nThe template test receives an already-built context. The component logic is not\nexecuted:\n\n```ts\nconst test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {\n context: { status: () => 'resolved' },\n register: {},\n});\n\nexpect(test.nativeElement.textContent).toContain('Loaded');\ntest.detectChanges();\ntest.updateContext({ status: () => 'error' });\nexpect(test.nativeElement.textContent).toContain('Error');\ntest.destroy();\n```\n\nThe result exposes `nativeElement`, `element`, `mocks`, `detectChanges`,\n`updateContext`, and `destroy`. Craft styles, child components, Craft\ndirectives, and reactivity are rendered by the normal renderer.\n\n### Explicit DOM locators\n\nTemplate tests also expose `locator(tag, criteria)`. The tag determines the\nDOM element type, while `class`, `data-*`, and `aria-*` criteria are matched\nagainst the rendered element. Prefer `data-*` and `aria-*`: a class comes from a\nsheet and is a list of atoms, not a name a test should depend on.\n\n\n\n\n\nThe notation `tag('name', props, children)` is generic: `tag` means the HTML\nhelper for the element you want. There is no separate `tag` function. For a\nbutton, write the three arguments explicitly:\n\n```ts\nconst saveButton = button(\n 'save', // name: stable local name\n { type: 'button' }, // props: DOM properties and attributes\n 'Save', // children: rendered content\n);\n```\n\nThe same pattern works with every built-in helper:\n\n```ts\nimport { input } from '@craft-ts/component';\n\nconst searchInput = input('search', { 'aria-label': 'Search' }, []);\n```\n\nThe name is rendered as `data-craft-name=\"save\"` and can be used as a\ncomplementary named locator when a class is not sufficiently discriminating.\n\n### Locating branded content\n\nWhen an element directly renders a branded Craft value, use the brand name as\nthe `content` criterion. The locator does not inspect the rendered value, so\nthis also works for non-text values and remains independent of formatting:\n\n```typescript\nimport { craftSignal as signal } from '@craft-ts/core';\nimport { span, craftComponent } from '@craft-ts/component';\nimport { markYieldableValue, state } from '@craft-ts/core';\n\nconst Status = craftComponent(\n 'Status',\n {},\n function* () {\n const brandedStatus = yield* state('brandedStatus', 'ready');\n return { brandedStatus };\n },\n ({ brandedStatus }) => span(brandedStatus),\n);\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Status, {\n context: {\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nexpect(brandedStatusElement.textContent).toBe('ready');\ntest.destroy();\n```\n\n\n\nThis template has no `ifNode`, `forNode`, or `deferNode`, so\n`brandedStatusElement` is an `HTMLSpanElement`, never `undefined`; optional\nchaining is not needed here.\n\nThe brand name is part of the template type. An unknown value such as\n`{ content: 'missing' }` is rejected by TypeScript. The return type is the\ninferred DOM type when the element is always rendered. Under `ifNode`, `forNode`,\nor `deferNode`, it is `MaybeDefined<HTMLSpanElement>` (equivalent to\n`HTMLSpanElement | undefined`), so callers must handle the absent branch.\n\nUse static, discriminating markers for locators. A literal class or attribute\ndeclared in the template is a stable proof; a value produced by a binding is\nnot. Attributes declared through `attrs` are queried using their rendered\nattribute name:\n\n```ts\ninput({ attrs: { 'aria-label': 'Search' } });\ntest.locator('input', { 'aria-label': 'Search' });\n```\n\nThe locator searches the complete rendered subtree, including Craft child\ncomponents. A branch that is currently absent returns `undefined`; a runtime\nresult with more than one matching element throws an explicit cardinality\nerror. Call the locator again after `updateContext` and `detectChanges` when a\nconditional branch changes.\n\nWhen a class is not sufficiently discriminating, keep using the existing\nnamed locators (`tag('name', props, children)`) and query their\n`data-craft-name` marker. A future collection API will cover repeated targets;\nthe singular locator should remain reserved for one expected element.\n\nTo verify that a DOM property is connected to the correct context member, add a\ncontract assertion next to the template test:\n\n\n\n\nTypeScript performs this check. It fails if the branded `counter.disabled` read\nis no longer exposed by the rendered template. It does not replace the\nrendering test; it verifies the template contract without a DOM.\n\n## Context and service dependencies\n\nThe `context` is a factory value and is not a registry dependency. In this\nexample, `store` is provided directly to the template:\n\n```ts\nawait setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {\n context: { store: todoStoreMock },\n register: {},\n});\n```\n\nConversely, if `StatusComponent` or a child component uses a\n`FormatterService`, the template registry contains `FormatterService`, never\nthe child component:\n\n```ts\nregister: {\n FormatterService: formatterMock,\n}\n```\n\nThe `CraftComponentLogicDepsOf<Component>` and\n`CraftComponentTemplateDepsOf<Component>` projections keep these two graphs\nseparate. A template registry therefore accepts only services; child components\nare never entries in `register`.\n\n## Registry values and providers\n\nResolution follows the same rules as service tests:\n\n- an object is a mock and is available in `mocks`;\n- `'real'` keeps the real service;\n- `'notReached'` documents a branch removed by a parent mock;\n- `'provided'` requests the value provided by the parent injector;\n- a `provideX(...)` provider explicitly configures a service.\n\nProviders declared in `meta.providers` are available in the component scope.\nUpstream providers go in `providers`:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(Component, {\n providers: [provideApiService({ baseUrl: '/test' })],\n register: {\n ApiService: 'provided',\n },\n});\n```\n\n`appStart` decisions (`'run'` or `'ignore'`) are available in the options when\nthe tested graph contains a service with `appStart: true`.\n\n## Testing a directive\n\nDirective logic receives its `baseLogic` and arguments explicitly:\n\n```ts\nconst { context } = await setupCraftDirectiveLogicTest.byRegister(\n hasPermissionInput,\n {\n baseLogic,\n args: [userInput, permissionInput],\n register: {},\n },\n);\n```\n\nFor the template, provide `baseTemplate` and the final context:\n\n```ts\nconst test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {\n baseTemplate: (context) => p(context.message()),\n context: { when: () => true, message: () => 'ready' },\n register: {},\n});\n\ntest.updateContext({ when: () => false, message: () => 'hidden' });\ntest.destroy();\n```\n\nStructural directives follow the same path and can verify that rendering is\nreplaced with `[]`. Calling `destroy()` cleans up views, injectors, listeners,\nand acquired styles.\n\n## Type-level tests\n\nThe template's contract can also be checked **without rendering anything** —\nthat an element only appears under a condition, that a binding is really the one\nyou think, that a list item renders its label. That is its own page:\n**[Type-level tests](/guide/testing/type-level)**.\n\n## See Also\n\n- [Testing services](/guide/testing/services)\n- [Browser boundaries](/guide/testing/browser-boundaries)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Routing setup](/guide/routing/setup) — where `GenDeps_*` comes from\n"
|
|
621
631
|
},
|
|
622
632
|
{
|
|
623
633
|
"path": "/guide/testing/craft-graph-vs-nx",
|
|
@@ -629,10 +639,15 @@
|
|
|
629
639
|
"title": "Extensible architecture graph",
|
|
630
640
|
"body": "# Extensible architecture graph\n\nThe Craft architecture graph is extensible by **TypeScript backend**, without\nmaking the graph engine import that backend. Use this when an application has\nserver-side concepts that the built-in Craft graph cannot express: services,\nlayers, message brokers, data classifications, external outputs, or another\ndependency-injection system.\n\nThe extension has two separate parts:\n\n- a type extension, which gives rules typed `kind` details;\n- a runtime collector, which reads the TypeScript program and contributes\n nodes, relations, diagnostics, and source proofs.\n\nImporting the types never activates a collector.\n\n## 1. Extend the vocabulary\n\nAdd entries to the node and relation registries with module augmentation. The\naugmentation must target the graph subpath:\n\n```typescript\nimport type {\n DependencyGraphCollector,\n DependencyGraphEdgeRegistry,\n DependencyGraphNodeRegistry,\n} from '@craft-ts/dev-tools/dependency-graph';\nimport { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph';\n\ndeclare module '@craft-ts/dev-tools/dependency-graph' {\n interface DependencyGraphNodeRegistry {\n 'repository-service': {\n runtime: 'repository';\n repositoryName: string;\n };\n }\n\n interface DependencyGraphEdgeRegistry {\n 'requires-repository': {\n operation: string;\n };\n }\n}\n```\n\nThe registry determines the types of the public graph API:\n\n```typescript\nconst repositories = graph.nodes('repository-service');\nconst name: string = repositories[0]!.details!.repositoryName;\n\nconst requirements = graph.edges('requires-repository');\nconst operation: string = requirements[0]!.details!.operation;\n```\n\nKeep a new node kind for a concept with its own semantics, renderer, or\narchitecture rules. Put incidental information in the typed `details` object\ninstead of creating a kind for every local symbol.\n\n## 2. Write a collector\n\nA collector receives the shared `ts-morph` project and the source files already\nselected by the graph's tsconfig. It returns a contribution; it does not call\narchitecture rules or mutate a renderer.\n\n```typescript\nconst repositoryCollector: DependencyGraphCollector = {\n name: 'repository-backend',\n\n collect({ rootDir, sourceFiles }) {\n const nodes = [];\n const edges = [];\n\n for (const sourceFile of sourceFiles) {\n // Inspect declarations and calls with ts-morph here.\n // Add a node or relation only when the syntax/type evidence is clear.\n void rootDir;\n void sourceFile;\n }\n\n return { nodes, edges };\n },\n};\n```\n\nEvery contributed node needs a stable `id`, a `kind`, and a human-readable\n`label`. Every relation must point to nodes in the contribution or to nodes\nalready in the graph. A conflicting identity is rejected during the merge.\n\n## 3. Attach source proofs\n\nA collector should explain why a fact exists. Add a `proof` to relations when\npossible:\n\n```typescript\nedges.push({\n from: handlerId,\n to: repositoryId,\n kind: 'requires-repository',\n evidence: 'ast',\n details: { operation: 'list' },\n proof: {\n filePath: sourceFile.getFilePath(),\n line: call.getStartLineNumber(),\n symbol: 'listUsers',\n pattern: 'repository.list()',\n },\n});\n```\n\nProofs are available through `graph.proofs(edge)` and are included in paths:\n\n```typescript\nconst paths = graph.pathsBetween(handlerId, repositoryId);\nfor (const path of paths) {\n console.log(path.nodes.map((node) => node.label));\n console.log(path.proofs);\n}\n```\n\nIf a dependency cannot be resolved statically, emit a diagnostic or an\nexplicit unknown relation. Do not publish an unresolved dependency as if it\nwere proven.\n\n## 4. Activate the collector explicitly\n\nRegister the collector in the application's graph loader:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [repositoryCollector],\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data'],\n },\n});\n\nreturn createArchitectureGraph(graph, architectureCatalog);\n```\n\nThis keeps runtime analysis separate from declaration merging. A type import\ncannot accidentally enable an expensive backend analysis or its rules.\n\n## Effect backend\n\nThe repository currently includes an Effect adapter. It recognizes Effect\n`Context.Service` declarations and server-function requirements such as:\n\n```typescript\nexport class UserRepository extends Context.Service<\n UserRepository,\n UserRepositoryShape\n>()('demo/UserRepository') {}\n\nexport const listUsers = serverFunction('demo.users.list', inputSchema, {\n exposure: 'client',\n}).handler(({ input }) =>\n Effect.gen(function* () {\n const repository = yield* UserRepository;\n return yield* repository.list(input.filter);\n }),\n);\n```\n\nThe graph exposes `effect-service`, `effect-operation`, and `effect-layer`\nnodes, plus typed `requires-service`, `provided-by-layer`, and\n`composes-layer` relations with source proofs. `Layer.succeed`, `Layer.sync`,\n`Layer.effect`, `Layer.mergeAll`, and `Layer.provide` are followed only when\ntheir relevant symbols are statically visible. Dynamic composition is marked\npartial or unknown.\n\n## Sensitive data and output policies\n\nEffect Schema annotations can seed a conservative data-flow graph:\n\n```typescript\nconst Email = Schema.String.pipe(\n Schema.annotations({ sensitivity: 'personal-data' }),\n);\n```\n\nWhen an annotated schema is used as the output of a client-exposed server\nfunction, the graph emits a `data-classification` node, an `external-output`\nnode, and an `exposes-data` relation carrying the classification and proof.\nClassification is retained when propagation is uncertain; the analyser never\nassumes that an arbitrary transform made data safe.\n\nRepositories declare middleware capabilities beside the graph configuration:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data', 'secret'],\n },\n});\n```\n\nThen enforce the policy with:\n\n```typescript\nassertSensitiveOutputsProtected(graph.graph, {\n categories: ['personal-data', 'secret'],\n});\n```\n\nThe rule reports the output, classification, expected capability, and source\nproof when no attached server middleware provides the declared protection.\nUnknown protection remains blocking unless `allowUnknown: true` is chosen\nexplicitly.\n\n## Rendering and JSON compatibility\n\nThe JSON graph remains tolerant of kinds a renderer does not know. Generic\nrenderers display the kind and label as a fallback; they do not discard the\nnode. A backend-specific renderer or architecture rule can consume the typed\ndetails through declaration merging.\n\nThe format stays `version: 1` and every addition is an optional field. Nodes\ncarry `metrics` (`cyclomaticOwn`, `cyclomaticTotal`, `lines`, `fanIn`,\n`fanOut`), computed after every collector has run. A node without `endLine` has\nno complexity or line count: those fields are absent, not `0`. Fan-in and\nfan-out count the nodes of your collector too, so a typed relation shows up in\nthe [hotspots](/guide/testing/graph-insights#hotspots). `graphHash` still reads\nonly node ids and relations, so the metrics never move it.\n\nNodes may also carry `doc` (`summary`, `tags`, `rationale`) read from their\ndeclaration. The opt-in Markdown collector adds `doc-page` nodes and `documents`\nrelations; both are part of the built-in vocabulary, so a renderer or a rule can\nrely on them without augmenting the registries.\n\nWhen the vocabulary changes, regenerate the committed architecture catalog and\nrun the architecture suite:\n\n```shell\nnpx craft-graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out apps/shop/architecture/catalog \\\n --format json\n\nnpx nx architecture shop\n```\n\nSee [Architecture rules](/guide/testing/architecture) for the application\nloader, catalog generation, and baseline rules.\n"
|
|
631
641
|
},
|
|
642
|
+
{
|
|
643
|
+
"path": "/guide/testing/folder-layout",
|
|
644
|
+
"title": "Folder layout organizer",
|
|
645
|
+
"body": "# Folder layout organizer\n\n`craft organize` analyzes a fresh Craft dependency graph and proposes a folder\nlayout. It does not move files. The default placement follows route ownership\nand application-shell reachability; project-specific rules can add known\narchitectural constraints without changing those defaults for other projects.\n\n## Tune an organizer run\n\nThe programmatic entry point is `organizeProject`, exported by\n`@craft-ts/dev-tools` and `@craft-ts/dev-tools/folder-layout`:\n\n```ts\nimport { organizeProject } from '@craft-ts/dev-tools/folder-layout';\n\norganizeProject({\n project: 'apps/shop/tsconfig.graph.json',\n graph: 'craft-dependency-graph.json',\n out: 'apps/shop/folder-layout',\n weights: { calls: 4 },\n thresholds: { maxDepth: 2 },\n placementRules: [\n {\n id: 'app-start-services',\n when: {\n nodeKind: 'service',\n nodeDetails: { appStart: true },\n },\n scope: 'core',\n folder: 'core/app-start',\n reason: 'Runs during application bootstrap.',\n },\n ],\n});\n```\n\n`weights` adjusts the relative influence of graph relations. `thresholds`\ncontrols the maximum proposed feature depth and the fan-in/fan-out values that\nmark hubs. Omitted values keep their defaults.\n\n## Keep project rules in JSON\n\nThe CLI accepts the same `weights`, `thresholds`, and `placementRules` in a\nJSON file passed with `--config`:\n\n```json\n{\n \"placementRules\": [\n {\n \"id\": \"app-start-services\",\n \"when\": {\n \"nodeKind\": \"service\",\n \"nodeDetails\": { \"appStart\": true }\n },\n \"scope\": \"core\",\n \"folder\": \"core/app-start\",\n \"reason\": \"Runs during application bootstrap.\"\n }\n ]\n}\n```\n\n```bash\ncraft organize \\\n --project apps/shop/tsconfig.graph.json \\\n --graph craft-dependency-graph.json \\\n --config apps/shop/organizer.config.json \\\n --out apps/shop/folder-layout\n```\n\nThe organizer evaluates rules in array order, before its default ownership\nclassification. A rule matches a file when at least one Craft graph node in\nthat file has the selected `nodeKind` and all selected `nodeDetails` values.\nThe first matching rule sets the file's scope and destination. `folder` is\nrelative to the organizer's target root, which defaults to the app's `src/`\nfolder when present.\n\nRules need a unique `id`, a `nodeKind`, optional `nodeDetails`, a scope\n(`feature-local`, `parent-shared`, `global-shared`, `core`, or `unresolved`), a\nsafe relative `folder`, and a human-readable `reason`. In TypeScript,\n`nodeKind` narrows `nodeDetails`; for `service`, keys and values are checked\nagainst the service metadata (`appStart` and `browserBoundary` are booleans).\nThe JSON CLI validates known service detail keys and value types at runtime.\nThe reason appears with the file's proposal. Resolved rules are written to\n`folder-layout-analysis.json` and contribute to the proposal's `configHash`, so\nchanging policy produces a distinct review artifact. A matching explicit rule\nhas confidence `1`; destination collisions still require review.\n\n### File-level behavior\n\nThe organizer proposes moves for whole files. If one service in a file matches\na rule, the entire file receives that placement. Keep declarations with\ndifferent folder policies in separate files. If several rules match a file,\nthe first rule wins; put narrower matchers first.\n\n## Demo policy: app-start services\n\nThe dependency graph records `appStart: true` on Craft service nodes. The demo\nuses that fact in `apps/demo/organizer.config.json` to place matching source\nfiles under `src/core/app-start/`. This policy is passed only by the demo's\n`attest:demo:folder-layout` command, so it does not change the organizer's\ndefaults for other applications.\n\nTo regenerate the proposal:\n\n```bash\nnpm run attest:demo:folder-layout\n```\n\nThe command refreshes the read-only analysis and proposal artifacts. Apply a\nproposal separately with `craft organize apply` after reviewing it.\n"
|
|
646
|
+
},
|
|
632
647
|
{
|
|
633
648
|
"path": "/guide/testing/graph-insights",
|
|
634
649
|
"title": "Graph insights",
|
|
635
|
-
"body": "# Graph insights\n\nThe dependency graph behind the [architecture rules](/guide/testing/architecture)\nalso measures what it models. Complexity, size and coupling are attached to the\nnodes a CraftTS developer reasons about — a route, a service, a primitive —\nrather than to files, and they feed a report, metric thresholds and the\n[graph MCP server](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp).\n\nEverything is computed statically and deterministically from the TypeScript\nprogram. Nothing is sampled at runtime and nothing is guessed.\n\n## Metrics\n\nEach node carries a `metrics` field:\n\n| Metric | Meaning |\n| ----------------- | ------------------------------------------------------------------------------------------ |\n| `cyclomaticOwn` | `1 +` the decision points of the node itself |\n| `cyclomaticTotal` | `1 +` the decision points of the node and of everything it `contains` |\n| `lines` | Lines of the declaration |\n| `fanIn` | Distinct nodes that depend on it (`loads`, `renders`, `depends-on`, `calls`, `reads`…) |\n| `fanOut` | Distinct nodes it depends on |\n\nDecision points are `if`, `?:`, `case`, `for`, `for…of`, `for…in`, `while`,\n`do`, `catch`, `&&`, `||` and `??`. Each one is credited to the **innermost**\nnode whose source contains it. A `craftComputed` declared inside a component\nkeeps its own branches; the component counts them only in its total. Nothing is\ncounted twice.\n\n`contains` is structure, not coupling: a service owning a primitive does not\nraise its fan-out.\n\n### Unknown is not zero\n\nSome nodes have no source range of their own: an HTTP endpoint aggregates call\nsites, a synthesised route check shares its alias, an Effect layer comes from a\nseparate collector. Those nodes have `fanIn` and `fanOut` but no complexity and\nno line count — the fields are absent. The graph adds one\n`CRAFT_GRAPH_METRICS_UNKNOWN` diagnostic per unmeasured kind, and every\nconsumer below leaves unknown values out instead of treating them as simple.\n\n## God nodes and hotspots {#hotspots}\n\n**God nodes** are the nodes most depended upon: highest `fanIn` first.\n\n**Hotspots** combine complexity, centrality and change:\n\n```text\nscore = cyclomaticTotal × (1 + fanIn) × (1 + churn)\n```\n\n`churn` is the number of commits touching the node's file since a date, read\nfrom git. Without it, the score ranks complex central code. Template elements\nare left out of the report rankings by default: an element's total includes\nevery element nested in it, so a single component's markup would fill the list.\n\n```typescript\nimport { godNodes, graphHotspots } from '@craft-ts/dev-tools/graph-metrics';\n\ngraphHotspots(graph, { limit: 5, kinds: ['service', 'component'] });\n```\n\n## Report {#report}\n\n```shell\nnpx craft graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format report \\\n --feature-glob 'apps/shop/src/features/:feature/**' \\\n --churn-since '3 months ago'\n```\n\n`--format report` writes `craft-dependency-graph.report.md` and\n`craft-dependency-graph.report.json`; `--format all` writes them next to the JSON\ngraph and the HTML explorer. The report contains:\n\n- a summary: nodes and relations per kind, diagnostics per code;\n- god nodes and hotspots;\n- dependency cycles and unused primitive methods;\n- relations between features, when `--feature-glob` names the feature with a\n `:name` capture;\n- the violations of the rules `assertArchitecture` enforces, grouped by rule;\n- coverage per route, when a coverage report is applied with `--coverage`;\n- documentation per kind, when JSDoc or Markdown pages were collected.\n\nEvery section is sorted and every id is relative to the root, so two reports of\nthe same code are identical whatever the checkout path. Commit the Markdown file\nif you want architecture changes to show up in review.\n\nThe same data is available in code:\n\n```typescript\nimport { formatGraphReportMarkdown, graphReport } from '@craft-ts/dev-tools/graph-report';\nimport { architectureViolations } from '@craft-ts/dev-tools/architecture-graph';\n\nconst report = graphReport(graph, { featureGlob: 'src/features/:feature/**' });\nconst violations = architectureViolations(graph); // [{ rule, messages }]\n```\n\n## Coverage per node and per route\n\nThe graph reads test coverage; it does not produce it. Write an Istanbul report\nwith Vitest, then hand it to the graph:\n\n```shell\nnpx vitest run --coverage --coverage.reporter=json\nnpx craft graph --project apps/shop/tsconfig.graph.json --root . \\\n --format all --coverage coverage/coverage-final.json\n```\n\nEach statement is credited to the innermost node whose lines contain it, and\nthe node gets `metrics.coverage = { statements, covered }` for its own\nstatements. A route's coverage sums the nodes of its code slice — everything\nthat can change what it renders — without counting a statement twice.\n\nCoverage is never guessed:\n\n- a node without a line range, or in a file the report does not mention, has\n no `coverage` field. `CRAFT_GRAPH_COVERAGE_UNKNOWN` diagnostics count them per\n kind;\n- a route lists how many nodes of its slice are unknown next to its percentage,\n which only describes the measured part.\n\n```typescript\nimport { applyCoverage, routeCoverage } from '@craft-ts/dev-tools/graph-coverage';\n\nconst covered = applyCoverage(graph, JSON.parse(readFileSync(reportPath, 'utf8')));\nrouteCoverage(covered); // [{ label, statements, covered, unknownNodes, … }]\n```\n\n## Documentation\n\nEach node carries a `doc` field when its declaration has something to say:\n\n```typescript\n/**\n * Loads and caches the signed-in user.\n * @remarks Shared by every page.\n */\nexport const { injectUserService } = craftService(/* … */, function* () {\n // WHY: the session expires silently, so reload on focus.\n const user = yield* query(/* … */);\n});\n```\n\n- `summary` and `tags` come from the JSDoc of the declaration, or of the\n statement around it (`export const x =
|
|
650
|
+
"body": "# Graph insights\n\nThe dependency graph behind the [architecture rules](/guide/testing/architecture)\nalso measures what it models. Complexity, size and coupling are attached to the\nnodes a CraftTS developer reasons about — a route, a service, a primitive —\nrather than to files, and they feed a report, metric thresholds and the\n[graph MCP server](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp).\n\nEverything is computed statically and deterministically from the TypeScript\nprogram. Nothing is sampled at runtime and nothing is guessed.\n\n## Metrics\n\nEach node carries a `metrics` field:\n\n| Metric | Meaning |\n| ----------------- | ------------------------------------------------------------------------------------------ |\n| `cyclomaticOwn` | `1 +` the decision points of the node itself |\n| `cyclomaticTotal` | `1 +` the decision points of the node and of everything it `contains` |\n| `lines` | Lines of the declaration |\n| `fanIn` | Distinct nodes that depend on it (`loads`, `renders`, `depends-on`, `calls`, `reads`…) |\n| `fanOut` | Distinct nodes it depends on |\n\nDecision points are `if`, `?:`, `case`, `for`, `for…of`, `for…in`, `while`,\n`do`, `catch`, `&&`, `||` and `??`. Each one is credited to the **innermost**\nnode whose source contains it. A `craftComputed` declared inside a component\nkeeps its own branches; the component counts them only in its total. Nothing is\ncounted twice.\n\n`contains` is structure, not coupling: a service owning a primitive does not\nraise its fan-out.\n\n### Unknown is not zero\n\nSome nodes have no source range of their own: an HTTP endpoint aggregates call\nsites, a synthesised route check shares its alias, an Effect layer comes from a\nseparate collector. Those nodes have `fanIn` and `fanOut` but no complexity and\nno line count — the fields are absent. The graph adds one\n`CRAFT_GRAPH_METRICS_UNKNOWN` diagnostic per unmeasured kind, and every\nconsumer below leaves unknown values out instead of treating them as simple.\n\n## God nodes and hotspots {#hotspots}\n\n**God nodes** are the nodes most depended upon: highest `fanIn` first.\n\n**Hotspots** combine complexity, centrality and change:\n\n```text\nscore = cyclomaticTotal × (1 + fanIn) × (1 + churn)\n```\n\n`churn` is the number of commits touching the node's file since a date, read\nfrom git. Without it, the score ranks complex central code. Template elements\nare left out of the report rankings by default: an element's total includes\nevery element nested in it, so a single component's markup would fill the list.\n\n```typescript\nimport { godNodes, graphHotspots } from '@craft-ts/dev-tools/graph-metrics';\n\ngraphHotspots(graph, { limit: 5, kinds: ['service', 'component'] });\n```\n\n## Report {#report}\n\n```shell\nnpx craft graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format report \\\n --feature-glob 'apps/shop/src/features/:feature/**' \\\n --churn-since '3 months ago'\n```\n\n`--format report` writes `craft-dependency-graph.report.md` and\n`craft-dependency-graph.report.json`; `--format all` writes them next to the JSON\ngraph and the HTML explorer. The report contains:\n\n- a summary: nodes and relations per kind, diagnostics per code;\n- god nodes and hotspots;\n- dependency cycles and unused primitive methods;\n- relations between features, when `--feature-glob` names the feature with a\n `:name` capture;\n- the violations of the rules `assertArchitecture` enforces, grouped by rule;\n- coverage per route, when a coverage report is applied with `--coverage`;\n- documentation per kind, when JSDoc or Markdown pages were collected.\n\nEvery section is sorted and every id is relative to the root, so two reports of\nthe same code are identical whatever the checkout path. Commit the Markdown file\nif you want architecture changes to show up in review.\n\nThe same data is available in code:\n\n```typescript\nimport { formatGraphReportMarkdown, graphReport } from '@craft-ts/dev-tools/graph-report';\nimport { architectureViolations } from '@craft-ts/dev-tools/architecture-graph';\n\nconst report = graphReport(graph, { featureGlob: 'src/features/:feature/**' });\nconst violations = architectureViolations(graph); // [{ rule, messages }]\n```\n\n## Coverage per node and per route\n\nThe graph reads test coverage; it does not produce it. Write an Istanbul report\nwith Vitest, then hand it to the graph:\n\n```shell\nnpx vitest run --coverage --coverage.reporter=json\nnpx craft graph --project apps/shop/tsconfig.graph.json --root . \\\n --format all --coverage coverage/coverage-final.json\n```\n\nEach statement is credited to the innermost node whose lines contain it, and\nthe node gets `metrics.coverage = { statements, covered }` for its own\nstatements. A route's coverage sums the nodes of its code slice — everything\nthat can change what it renders — without counting a statement twice.\n\nCoverage is never guessed:\n\n- a node without a line range, or in a file the report does not mention, has\n no `coverage` field. `CRAFT_GRAPH_COVERAGE_UNKNOWN` diagnostics count them per\n kind;\n- a route lists how many nodes of its slice are unknown next to its percentage,\n which only describes the measured part.\n\n```typescript\nimport { applyCoverage, routeCoverage } from '@craft-ts/dev-tools/graph-coverage';\n\nconst covered = applyCoverage(graph, JSON.parse(readFileSync(reportPath, 'utf8')));\nrouteCoverage(covered); // [{ label, statements, covered, unknownNodes, … }]\n```\n\n## Documentation\n\nEach node carries a `doc` field when its declaration has something to say:\n\n```typescript\n/**\n * Loads and caches the signed-in user.\n * @remarks Shared by every page.\n */\nexport const { injectUserService } = craftService(/* … */, function* () {\n // WHY: the session expires silently, so reload on focus.\n const user = yield* query(/* … */);\n});\n```\n\n- `summary` and `tags` come from the JSDoc of the declaration, or of the\n statement around it (`export const x = query(…)`).\n- `rationale` collects the `// WHY:`, `// NOTE:` and `// HACK:` comments, each\n credited to the innermost node that contains it: the comment above `user`\n belongs to the query, not to the service.\n\nMarkdown pages join the graph through an opt-in collector, from the CLI with\n`--docs 'docs/**/*.md'` (repeatable) or in code with\n`createMarkdownDocsCollector({ include })`. Each page becomes a `doc-page` node\nlabelled by its first heading. A page `documents` a node when it cites the\nnode's label in inline code — `` `UserService` `` — and that label names exactly\none route, component, service or primitive. A label shared by several nodes\nproduces a `markdown-docs/CRAFT_GRAPH_DOC_AMBIGUOUS` diagnostic and no relation.\nFenced code blocks are ignored.\n\nSee [Documentation rules](/guide/testing/architecture#documentation-rules) to\nrequire them.\n\n## Thresholds\n\nMetrics become rules with `assertMetricThresholds`, opt-in and scoped by kind.\nSee [Metric thresholds](/guide/testing/architecture/metric-thresholds) for\nbefore-and-after examples.\n\n## Explorer\n\n`craft graph --format html` (or `all`) writes a self-contained explorer. On top\nof the route view it shows:\n\n- in the details panel, the node's metrics, coverage, JSDoc, justification\n comments and the pages that document it — unknown values read \"inconnu\",\n never `0`;\n- a heat map selector (complexity, fan-in, coverage) that colours the node\n cards, with a striped pattern for unknown values;\n- \"path from\" and \"path to\" buttons that highlight the shortest chain of\n relations between two nodes, following their direction;\n- the ten hotspots in the sidebar, and their count next to the uncovered nodes\n in the header.\n\n## Agents\n\n`@craft-ts/graph-mcp` exposes the graph, the metrics, the report and the impact\nof a change to an AI agent working in your project. See\n[MCP tools](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp).\n"
|
|
636
651
|
},
|
|
637
652
|
{
|
|
638
653
|
"path": "/guide/testing/services",
|
|
@@ -707,12 +722,12 @@
|
|
|
707
722
|
{
|
|
708
723
|
"path": "/learn/01-first-state",
|
|
709
724
|
"title": "1. Your first state",
|
|
710
|
-
"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
|
|
725
|
+
"body": "# 1. Your first state\n\n**Goal:** get a reactive value on screen, and meet the two building blocks you\nwill use in every step — `craftComponent` and a primitive.\n\n## Install\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nThe packages are currently published on the `beta` channel. The component\npackage contains the functional renderer, while `core` contains the reactive\nprimitives used by the component factory.\n\n## A component with state\n\nA Craft component is a **function**, not a class. It takes a name, meta, a logic\nfactory, and a template:\n\n\n\nFour arguments, and each has one job:\n\n| Argument | What it is |\n| ------------ | ----------------------------------------------------------- |\n| `'Tasks'` | the component's name — used by the tooling and by host tags |\n| `{}` | meta: providers, styles, host properties (empty for now) |\n| `function*` | the **logic factory** — builds and returns the context |\n| `({ … }) =>` | the **template** — receives that context, returns nodes |\n\nThere is no class, no decorator, no separate HTML file, and no host element\nwrapped around your markup.\n\n## Inputs and outputs\n\nA component's inputs and outputs are just **parameters of the logic factory**,\ntyped with `Input<T>` and `Output<Handler>`:\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. Project nested fields with `deepYieldable` so `user.name` stays a\nreader. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nAt the call site you pass the reader itself, not a getter:\n\n```typescript\nUserCard({\n user: currentUser,\n onRemove: removeUser,\n});\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\nBecause it's a function call, there is no template-binding layer between caller\nand component: a wrong input name or type is a plain TypeScript error.\n\n## Styling the component\n\nStyles go in a sheet beside the component — `tasks.style.ts` — written with\n`@craft-ts/style`:\n\n```typescript\nimport {\n craftStyles,\n defineStateAxis,\n display,\n gap,\n space,\n textDecorationLine,\n when,\n} from '@craft-ts/style';\n\nexport const taskState = defineStateAxis('task', ['done']);\n\nexport const tasks = craftStyles('tasks', {\n root: [display.grid, gap(space(2))],\n item: [when(taskState.done, [textDecorationLine.lineThrough])],\n});\n```\n\nThe template binds one constant class per element and says which state it is\nin with an attribute — `li({ class: tasks.item, 'data-task': 'done' }, …)`.\nA class is never built at render time: that is how every visual state stays\nlisted in the sheet. The build emits the CSS once; nothing is injected when the\ncomponent mounts. See [Styling a component](/guide/components/styles).\n\n## Mounting the root\n\nThe app's root is a Craft component too. `provideCraftRootComponent(App)`\ndesignates it, and the Craft host bootstraps the application:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app/app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>`.\n\n## The two rules of a primitive\n\n**1. A primitive is named.** `state('tasks', …)` — the first argument is always\nthe name. It is not decoration: it tags the primitive's injector (`state:tasks`)\nand is what identifies this piece of state in logs, snapshots and observability.\n\n**2. It resolves to the state reference itself**:\n\n```typescript\nconst tasks = yield * state('tasks', []);\n```\n\n`tasks` is a yieldable reader: `yield* tasks()` in a generator, `craftUse(tasks())`\nat a synchronous boundary, or pass `tasks` directly to a template binding.\n\n## What is `yield*` doing there?\n\nThe factory is a generator, and `yield*` is how **this** factory drives\neverything it does not own — primitives and services alike. The same rule\napplies later to every computed and method: each entity yields its own\ndependencies so they show up on **its** graph.\n\nFor now, treat it as \"the way to use a primitive inside a factory\".\n[Step 4](/learn/04-compose) explains what it buys you.\n\n## The template\n\nThe template is a plain function returning nodes built with hyperscript helpers\n— `div`, `ul`, `li`, `button`, and one `h(tag, …)` escape hatch for anything\nwithout a helper:\n\n```typescript\n({ tasks }) => [\n h1('Tasks'),\n ul(\n forNode(tasks, { track: (task) => task.id }, (task) => li(task.title)),\n ),\n];\n```\n\nPass the reader (`tasks`) to the binding that consumes it. The renderer drives\nthe read; wrapping `() => tasks()` is a synchronous call the yield rules reject.\nUse `forNode(...)` when the collection controls a node per item. No `*ngFor`, no\nchange detection to think about.\n\n## Writing to it\n\nRight now the state is read-only from the outside. Give it a writer:\n\n```typescript\nconst tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set }));\n\nyield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]);\n```\n\nThat third argument is an **insertion** — the mechanism you'll use in every step\nfrom here on. Step 2 is entirely about it.\n\n## What you gained\n\nA component and a reactive value, both declared as functions, both named, both\nvisible to the tooling — with no class, no constructor and no subscription.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn/)\n\n[2. Derive instead of duplicate →](/learn/02-derive)\n\n</div>\n"
|
|
711
726
|
},
|
|
712
727
|
{
|
|
713
728
|
"path": "/learn/02-derive",
|
|
714
729
|
"title": "2. Derive instead of duplicate",
|
|
715
|
-
"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 [
|
|
730
|
+
"body": "# 2. Derive instead of duplicate\n\n**Goal:** attach methods and derived values to your state, instead of scattering\nthem across the component.\n\n## The insertion argument\n\nThe last argument of a primitive is an **insertion**: a function that receives\nthe primitive's internals and returns whatever you want exposed on it.\n\n\n\nEverything you return is now on the ref:\n\n```typescript\nyield* tasks(); // the array\nyield* tasks.add('Learn insertions');\nyield* tasks.remaining(); // 1\n```\n\nThe context gives you `state` (the current value as a yieldable reader), `set`\nand `update`. Non-generator insertion methods may return `update(...)` directly\n— the wrapper consumes the write. `remaining` is a `craftComputed`: it does not\nown `state()`, so it yields it. That is how the computed's own dependency graph\nrecords the read.\n\n## The whole component\n\n```typescript\nimport {\n button,\n craftComponent,\n forNode,\n h1,\n input,\n li,\n ul,\n} from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* state('tasks', [] as Task[], /* … as above … */);\n return { tasks };\n },\n ({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n\n input({\n type: 'text',\n placeholder: 'New task…',\n *keydown(event) {\n if (event.key !== 'Enter') return;\n const field = event.target as HTMLInputElement;\n yield* tasks.add(field.value);\n field.value = '';\n },\n }),\n\n ul(\n forNode(\n tasks,\n { track: (task) => task.id, empty: () => li('Nothing to do 🎉') },\n (task) =>\n li([\n input({\n type: 'checkbox',\n checked: task.done,\n *change() {\n yield* tasks.toggle(task.id);\n },\n }),\n task.title,\n button({\n *click() {\n yield* tasks.remove(task.id);\n },\n }, '×'),\n ]),\n ),\n ),\n ],\n);\n```\n\nTwo template things worth noting. `forNode(source, options, render)` takes a\n`track` — the stable identity the renderer uses to reuse, move and remove\nnodes — and an optional `empty` branch. Pass the reader itself (`tasks`) rather\nthan `() => tasks()`. When a binding must format or call a method, use a\ngenerator and `yield*`.\n\nThe logic factory is now three lines. That's the point: **behaviour lives on the\nstate, not around it.**\n\n## Control flow\n\nCraft templates are TypeScript, so control flow is made of functions rather than\nsyntax. Each block is a typed function with an explicit contract:\n\n| Block | Purpose |\n| --- | --- |\n| `forNode` | Renders a collection with stable tracking and an optional empty branch |\n| `ifNode` | Preserves a conditional branch in the render contract |\n| `matchNode.exhaustive` | Matches every member of a discriminated union |\n| `deferNode` | Loads a branch lazily |\n\n`matchNode.exhaustive` matches on a **discriminant key** of a union and the\nhandler map must cover every member — a missing case is a compile error.\n\n```typescript\nmatchNode.exhaustive(() => tasksQuery.exceptions().loader, '_tag', {\n TASK_NOT_FOUND: () => p('This task no longer exists.'),\n TASK_FORBIDDEN: () => p('You do not have access to it.'),\n});\n```\n\n### Why not a plain ternary or `switch`?\n\nBecause a raw TypeScript conditional **collapses**. The template type ends up\nholding the *result* of the branch, not the fact that a branch existed:\n\n```typescript\n// works at runtime, but the contract is now opaque\ntasks.isEmpty() ? p('Nothing to do') : ul(/* … */);\n```\n\n`ifNode` and `matchNode` keep the condition **and both branches** in the node\ncontract. That is what lets you assert, at compile time, that an element renders\n*only* when a condition holds, or that a label renders for every item of a\nnon-empty list — see [Type-level tests](/guide/testing/type-level). With a\nternary those assertions have nothing to inspect.\n\nThe renderer also uses the block structure to update surgically instead of\nrebuilding the subtree.\n\n::: tip When a ternary is fine\nFor a leaf value — a class name, a piece of text, an attribute — a ternary is\nthe right tool. The rule concerns **structure**: whenever a branch decides\nwhether an element exists, reach for `ifNode` or `matchNode`.\n:::\n\n`ifNode` takes a **named** reactive value as its condition (a primitive ref, or\na value marked with `markYieldableValue`), because that name is what the\nvisibility contract records.\n\n## Reusing behaviour across components\n\nAn insertion factors logic out of a **primitive**. Its counterpart for\n**components** is a directive: `craftDirective` decorates both a component's\nlogic factory and its template, and you attach it with `.pipe(...)`:\n\n```typescript\nexport const Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => div(user.name),\n).pipe(InteractivePermissions);\n```\n\nThe directive can add to the context the template receives — here a\n`permissions` object the component never had to declare — and directives compose\nleft to right. That is how a tooltip, focus management or interaction analytics\nget added to several components without any of them knowing about it.\n\nThe full pattern — writing a directive, what it can require from its host, and\nhow styles compose — is on\n[Directives and `.pipe(...)`](/guide/components/directives). See also\n[Customization](/guide/components/customization) for the three layers of\ncomponent customization, and [Styling a component](/guide/components/styles).\n\n## Every exception a component picks up must be handled\n\nIf a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's contract. It has to\nbe dealt with, and the compiler is the one that says so:\n\n```typescript\nexport const Restricted = MyComponent.pipe(\n catchNode.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\n`catchNode.exhaustive` is the one you want most of the time: it renders a\n**fallback**. When the failure happens in the factory or a provider — before the\ntemplate exists — the fallback simply renders alone.\n\nHandle it here and the code disappears from the contract. Leave it and it flows\nup to the route, where `handleExceptions` **must** cover it — a reachable code\nwith no handler doesn't compile, and neither does a handler for a code nothing\ncan produce.\n\n::: warning Where the error actually lands today\nThe compile-time enforcement is at the **route**\n(`assertExhaustiveRouteExceptions`). The component `.pipe(...)` overload is\ncurrently kept permissive to avoid excessive TypeScript instantiation depth, so\nan unhandled code there is caught by runtime dispatch instead. Practical\nconsequence: a component rendered outside any route gets no compile-time\nreminder — handle its codes explicitly.\n\nThe whole rule is on [An unhandled exception doesn't just\ndisappear](/guide/concepts/exceptions).\n:::\n\n`matchNode.exhaustive` is the sibling for rendering from an exception *value*\nor signal. Reach for `catchTag.exhaustive` only when the reaction is pure\nlogic — a toast, a log — and produces no DOM.\n\n## Several insertions at once\n\nOne insertion function gets crowded fast. Split it and compose with `insertStatePipe`:\n\n\n\nEach function in the pipe receives the same context and contributes its own\nslice. This is what makes behaviour **reusable**: an insertion is just a\nfunction, so it can be extracted, parameterised and shared.\n\n::: tip That's what \"insertions\" are\nThe library ships ready-made ones — storage persistence, optimistic updates,\npagination placeholders, forms. They are the exact same shape as the functions\nyou just wrote. See [Insertions](/guide/concepts/insertions).\n:::\n\n## What you gained\n\nState that carries its own behaviour, a template that only renders, and a\ncomposition mechanism that scales past the first three methods.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Your first state](/learn/01-first-state)\n\n[3. Move logic out of the component →](/learn/03-service)\n\n</div>\n"
|
|
716
731
|
},
|
|
717
732
|
{
|
|
718
733
|
"path": "/learn/03-service",
|
|
@@ -767,7 +782,7 @@
|
|
|
767
782
|
{
|
|
768
783
|
"path": "/reference",
|
|
769
784
|
"title": "API index",
|
|
770
|
-
"body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertInputActionForms`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, `assertMetricThresholds` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
|
|
785
|
+
"body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftSsrPolicy`, `provideCraftSsrPolicy` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertInputActionForms`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, `assertMetricThresholds` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
|
|
771
786
|
},
|
|
772
787
|
{
|
|
773
788
|
"path": "/resources/ai-agents",
|