# Learn @craft-ts This is the guided path. You build **one app**, from an empty component to a routed, tested feature — adding exactly one idea per step. If you are looking for a specific answer instead, go to the [Guide](/guide/) (organised by task) or search. ## What you will build A task list. It starts as three lines in a component and ends up with server data, optimistic updates, URL state, a validated form, a typed route and tests. | Step | What you add | | ------------------------------------------------------- | ------------------------------------------- | | [1. Your first state](/learn/01-first-state) | `craftComponent`, `state` | | [2. Derive instead of duplicate](/learn/02-derive) | computed + methods | | [3. Move logic out of the component](/learn/03-service) | `craftService` | | [4. Compose services](/learn/04-compose) | generators, `yield*` | | [5. Load server data](/learn/05-load-data) | `query` | | [6. Write server data](/learn/06-mutate-data) | `mutation`, optimistic updates | | [7. Put state in the URL](/learn/07-url-state) | `queryParams` | | [8. Build a form](/learn/08-forms) | `insertForm`, validators | | [9. Wire up routing](/learn/09-routing) | `craftRoute`, compile-time DI check | | [10. Test what you wrote](/learn/10-testing) | testing by register, architecture rules | Then: [Where to go next](/learn/next). ## Before you start You need a TypeScript application and Node.js 20.19+ (or 22.12+). No prior knowledge of generators, RxJS or signals internals is required — each is introduced when it first earns its place. ::: tip Read in order Every step builds on the previous one's code. Skipping ahead works, but step 4 is where the mental model clicks — don't skip that one. ::: ::: warning Experimental `@craft-ts` and this documentation are both experimental. APIs can still move between minor versions. ::: [Start → Your first state](/learn/01-first-state) --- --- url: https://craft-ts.github.io/craft/learn/01-first-state.md --- # 1. Your first state **Goal:** get a reactive value on screen, and meet the two building blocks you will use in every step — `craftComponent` and a primitive. ## Install ```shell npm i @craft-ts/core@beta @craft-ts/component@beta npm i -D @craft-ts/dev-tools@beta ``` The packages are currently published on the `beta` channel. The component package contains the functional renderer, while `core` contains the reactive primitives used by the component factory. ## A component with state A Craft component is a **function**, not a class. It takes a name, meta, a logic factory, and a template: ```ts import { craftComponent, forNode, h1, li, ul } from '@craft-ts/component'; import { state } from '@craft-ts/core'; type Task = { id: string; title: string; done: boolean }; export const Tasks = craftComponent( 'Tasks', // name: stable component name used by tooling and host tags {}, // meta: providers, styles and host configuration function* () { // logic factory: creates the component context const tasks = yield* state('tasks', [ // name: state identifier { id: '1', title: 'Read step 1', done: false }, ] as Task[]); // initial value: the seeded task list return { tasks }; }, ({ tasks }) => [ // template: turns the context into rendered nodes h1('Tasks'), ul( forNode( tasks, // source: the reactive collection to render { track: (task) => task.id }, // options: stable identity for each item // render: creates one node per task (task) => li(function* () { return (yield* task()).title; }), ), ), ], ); ``` Four arguments, and each has one job: | Argument | What it is | | ------------ | ----------------------------------------------------------- | | `'Tasks'` | the component's name — used by the tooling and by host tags | | `{}` | meta: providers, styles, host properties (empty for now) | | `function*` | the **logic factory** — builds and returns the context | | `({ … }) =>` | the **template** — receives that context, returns nodes | There is no class, no decorator, no separate HTML file, and no host element wrapped around your markup. ## Inputs and outputs A component's inputs and outputs are just **parameters of the logic factory**, typed with `Input` and `Output`: ```ts import { Input, Output, button, craftComponent, div, span, } from '@craft-ts/component'; import { deepYieldable } from '@craft-ts/core'; type User = { name: string }; const UserCard = craftComponent( 'UserCard', {}, (user: Input, onRemove: Output<(user: User) => void>) => ({ user: deepYieldable(user), onRemove, }), ({ user, onRemove }) => div([ span(user.name), button( 'remove', { type: 'button', *click() { yield* onRemove(yield* user()); }, }, 'Remove', ), ]), ); ``` An `Input` **is a yieldable reader** — `yield* user()` reads the current value. Project nested fields with `deepYieldable` so `user.name` stays a reader. An `Output` is a yieldable callback; delegate to it with `yield*`. At the call site you pass the reader itself, not a getter: ```typescript UserCard({ user: currentUser, onRemove: removeUser, }); ``` | Contract | Craft | | --- | --- | | Input | an `Input` factory parameter | | Output | an `Output` parameter, called directly | | Component call | `UserCard({ user: u, onRemove: fn })` | | Missing required input | **compile error** | Because it's a function call, there is no template-binding layer between caller and component: a wrong input name or type is a plain TypeScript error. ## Styling the component Styles go in a sheet beside the component — `tasks.style.ts` — written with `@craft-ts/style`: ```typescript import { craftStyles, defineStateAxis, display, gap, space, textDecorationLine, when, } from '@craft-ts/style'; export const taskState = defineStateAxis('task', ['done']); export const tasks = craftStyles('tasks', { root: [display.grid, gap(space(2))], item: [when(taskState.done, [textDecorationLine.lineThrough])], }); ``` The template binds one constant class per element and says which state it is in with an attribute — `li({ class: tasks.item, 'data-task': 'done' }, …)`. A class is never built at render time: that is how every visual state stays listed in the sheet. The build emits the CSS once; nothing is injected when the component mounts. See [Styling a component](/guide/components/styles). ## Mounting the root The app's root is a Craft component too. `provideCraftRootComponent(App)` designates it, and the Craft host bootstraps the application: ```typescript // app.config.ts export const appConfig = craftAppConfig({ providers: [provideCraftRootComponent(App)], }); ``` ```typescript // main.ts import { bootstrapCraft } from '@craft-ts/component'; import { appConfig } from './app/app.config'; bootstrapCraft({ config: appConfig }); ``` `bootstrapCraft` builds the root injector, runs the app-start hooks, then mounts the root component into ``. ## The two rules of a primitive **1. A primitive is named.** `state('tasks', …)` — the first argument is always the name. It is not decoration: it tags the primitive's injector (`state:tasks`) and is what identifies this piece of state in logs, snapshots and observability. **2. It resolves to the state reference itself**: ```typescript const tasks = yield * state('tasks', []); ``` `tasks` is a yieldable reader: `yield* tasks()` in a generator, `craftUse(tasks())` at a synchronous boundary, or pass `tasks` directly to a template binding. ## What is `yield*` doing there? The factory is a generator, and `yield*` is how **this** factory drives everything it does not own — primitives and services alike. The same rule applies later to every computed and method: each entity yields its own dependencies so they show up on **its** graph. For now, treat it as "the way to use a primitive inside a factory". [Step 4](/learn/04-compose) explains what it buys you. ## The template The template is a plain function returning nodes built with hyperscript helpers — `div`, `ul`, `li`, `button`, and one `h(tag, …)` escape hatch for anything without a helper: ```typescript ({ tasks }) => [ h1('Tasks'), ul( forNode(tasks, { track: (task) => task.id }, (task) => li(task.title)), ), ]; ``` Pass the reader (`tasks`) to the binding that consumes it. The renderer drives the read; wrapping `() => tasks()` is a synchronous call the yield rules reject. Use `forNode(...)` when the collection controls a node per item. No `*ngFor`, no change detection to think about. ## Writing to it Right now the state is read-only from the outside. Give it a writer: ```typescript const tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set })); yield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]); ``` That third argument is an **insertion** — the mechanism you'll use in every step from here on. Step 2 is entirely about it. ## What you gained A component and a reactive value, both declared as functions, both named, both visible to the tooling — with no class, no constructor and no subscription. [← Overview](/learn/) [2. Derive instead of duplicate →](/learn/02-derive) --- --- url: https://craft-ts.github.io/craft/learn/02-derive.md --- # 2. Derive instead of duplicate **Goal:** attach methods and derived values to your state, instead of scattering them across the component. ## The insertion argument The last argument of a primitive is an **insertion**: a function that receives the primitive's internals and returns whatever you want exposed on it. ```ts import { craftComputed, craftService, state } from '@craft-ts/core'; type Task = { id: string; title: string; done: boolean }; export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const tasks = yield* state('tasks', [] as Task[], ({ state, set, update }) => ({ add: (title: string) => update((current) => [ ...current, { id: crypto.randomUUID(), title, done: false }, ]), toggle: (id: string) => update((current) => current.map((task) => task.id === id ? { ...task, done: !task.done } : task, ), ), remove: function* (id: string) { const current = yield* state(); return yield* set(current.filter((task) => task.id !== id)); }, remaining: craftComputed(function* () { return (yield* state()).filter((task) => !task.done).length; }), })); return tasks; }, ); ``` Everything you return is now on the ref: ```typescript yield* tasks(); // the array yield* tasks.add('Learn insertions'); yield* tasks.remaining(); // 1 ``` The context gives you `state` (the current value as a yieldable reader), `set` and `update`. Non-generator insertion methods may return `update(...)` directly — the wrapper consumes the write. `remaining` is a `craftComputed`: it does not own `state()`, so it yields it. That is how the computed's own dependency graph records the read. ## The whole component ```typescript import { button, craftComponent, forNode, h1, input, li, ul, } from '@craft-ts/component'; export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* state('tasks', [] as Task[], /* … as above … */); return { tasks }; }, ({ tasks }) => [ h1(function* () { return `Tasks — ${yield* tasks.remaining()} left`; }), input({ type: 'text', placeholder: 'New task…', *keydown(event) { if (event.key !== 'Enter') return; const field = event.target as HTMLInputElement; yield* tasks.add(field.value); field.value = ''; }, }), ul( forNode( tasks, { track: (task) => task.id, empty: () => li('Nothing to do 🎉') }, (task) => li([ input({ type: 'checkbox', checked: task.done, *change() { yield* tasks.toggle(task.id); }, }), task.title, button({ *click() { yield* tasks.remove(task.id); }, }, '×'), ]), ), ), ], ); ``` Two template things worth noting. `forNode(source, options, render)` takes a `track` — the stable identity the renderer uses to reuse, move and remove nodes — and an optional `empty` branch. Pass the reader itself (`tasks`) rather than `() => tasks()`. When a binding must format or call a method, use a generator and `yield*`. The logic factory is now three lines. That's the point: **behaviour lives on the state, not around it.** ## Control flow Craft templates are TypeScript, so control flow is made of functions rather than syntax. Each block is a typed function with an explicit contract: | Block | Purpose | | --- | --- | | `forNode` | Renders a collection with stable tracking and an optional empty branch | | `ifNode` | Preserves a conditional branch in the render contract | | `matchNode.exhaustive` | Matches every member of a discriminated union | | `deferNode` | Loads a branch lazily | `matchNode.exhaustive` matches on a **discriminant key** of a union and the handler map must cover every member — a missing case is a compile error. ```typescript matchNode.exhaustive(() => tasksQuery.exceptions().loader, '_tag', { TASK_NOT_FOUND: () => p('This task no longer exists.'), TASK_FORBIDDEN: () => p('You do not have access to it.'), }); ``` ### Why not a plain ternary or `switch`? Because a raw TypeScript conditional **collapses**. The template type ends up holding the *result* of the branch, not the fact that a branch existed: ```typescript // works at runtime, but the contract is now opaque tasks.isEmpty() ? p('Nothing to do') : ul(/* … */); ``` `ifNode` and `matchNode` keep the condition **and both branches** in the node contract. That is what lets you assert, at compile time, that an element renders *only* when a condition holds, or that a label renders for every item of a non-empty list — see [Type-level tests](/guide/testing/type-level). With a ternary those assertions have nothing to inspect. The renderer also uses the block structure to update surgically instead of rebuilding the subtree. ::: tip When a ternary is fine For a leaf value — a class name, a piece of text, an attribute — a ternary is the right tool. The rule concerns **structure**: whenever a branch decides whether an element exists, reach for `ifNode` or `matchNode`. ::: `ifNode` takes a **named** reactive value as its condition (a primitive ref, or a value marked with `markYieldableValue`), because that name is what the visibility contract records. ## Reusing behaviour across components An insertion factors logic out of a **primitive**. Its counterpart for **components** is a directive: `craftDirective` decorates both a component's logic factory and its template, and you attach it with `.pipe(...)`: ```typescript export const Card = craftComponent( 'Card', {}, (user: Input) => ({ user: deepYieldable(user) }), ({ user }) => div(user.name), ).pipe(InteractivePermissions); ``` The directive can add to the context the template receives — here a `permissions` object the component never had to declare — and directives compose left to right. That is how a tooltip, focus management or interaction analytics get added to several components without any of them knowing about it. The full pattern — writing a directive, what it can require from its host, and how styles compose — is on [Directives and `.pipe(...)`](/guide/components/directives). See also [Customization](/guide/components/customization) for the three layers of component customization, and [Styling a component](/guide/components/styles). ## Every exception a component picks up must be handled If a component's factory — or one of its providers — can raise a `craftException`, that code becomes part of the component's contract. It has to be dealt with, and the compiler is the one that says so: ```typescript export const Restricted = MyComponent.pipe( catchNode.exhaustive({ NO_ACCESS: () => p('You do not have access to this data.'), }), ); ``` `catchNode.exhaustive` is the one you want most of the time: it renders a **fallback**. When the failure happens in the factory or a provider — before the template exists — the fallback simply renders alone. Handle it here and the code disappears from the contract. Leave it and it flows up to the route, where `handleExceptions` **must** cover it — a reachable code with no handler doesn't compile, and neither does a handler for a code nothing can produce. ::: warning Where the error actually lands today The compile-time enforcement is at the **route** (`assertExhaustiveRouteExceptions`). The component `.pipe(...)` overload is currently kept permissive to avoid excessive TypeScript instantiation depth, so an unhandled code there is caught by runtime dispatch instead. Practical consequence: a component rendered outside any route gets no compile-time reminder — handle its codes explicitly. The whole rule is on [An unhandled exception doesn't just disappear](/guide/concepts/exceptions). ::: `matchNode.exhaustive` is the sibling for rendering from an exception *value* or signal. Reach for `catchTag.exhaustive` only when the reaction is pure logic — a toast, a log — and produces no DOM. ## Several insertions at once One insertion function gets crowded fast. Split it and compose with `insertStatePipe`: ```ts import { insertStatePipe, craftComputed, craftService, state } from '@craft-ts/core'; export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const tasks = yield* state( 'tasks', [] as Task[], insertStatePipe( ({ update }) => ({ add: (title: string) => update((c) => [...c, newTask(title)]), }), ({ state }) => ({ remaining: craftComputed(function* () { return (yield* state()).filter((t) => !t.done).length; }), isEmpty: craftComputed(function* () { return (yield* state()).length === 0; }), }), ), ); return tasks; }, ); ``` Each function in the pipe receives the same context and contributes its own slice. This is what makes behaviour **reusable**: an insertion is just a function, so it can be extracted, parameterised and shared. ::: tip That's what "insertions" are The library ships ready-made ones — storage persistence, optimistic updates, pagination placeholders, forms. They are the exact same shape as the functions you just wrote. See [Insertions](/guide/concepts/insertions). ::: ## What you gained State that carries its own behaviour, a template that only renders, and a composition mechanism that scales past the first three methods. [← 1. Your first state](/learn/01-first-state) [3. Move logic out of the component →](/learn/03-service) --- --- url: https://craft-ts.github.io/craft/learn/03-service.md --- # 3. Move logic out of the component **Goal:** turn your task state into a service other components can use. ## From component factory to `craftService` The factory body moves out almost unchanged — it was already a generator: ```ts import { craftComputed, craftService, state } from '@craft-ts/core'; export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const tasks = yield* state('tasks', [] as Task[], ({ state, update }) => ({ add: (title: string) => update((current) => [...current, newTask(title)]), toggle: (id: string) => update((current) => current.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), ), remaining: craftComputed(function* () { return (yield* state()).filter((t) => !t.done).length; }), })); return tasks; }, ); ``` A service is the same shape as a component's logic factory: a generator that yields what it needs and returns a context. The only additions are a **name** and a **scope**. ## Using it The component now yields the service instead of declaring the state: ```typescript export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* TaskList(); return { tasks }; }, ({ tasks }) => [ /* unchanged */ ], ); ``` `craftService` returns a helper named after the service — here `TaskList`. There is no `injectTaskList` and no class to import. ## Picking a scope `scope` is the one decision to make. Four you will actually use: | Scope | Instance | Use it when | | ----------- | -------------------------- | ---------------------------------------------------------- | | `function` | fresh on every injection | the service belongs to a single component (**start here**) | | `toProvide` | one per `provideX()` mount | a parent, or a route, shares it with children | | `global` | one for the whole app | genuinely app-wide state | | `abstract` | none — a contract | the implementation is decided elsewhere | Default to `function`. It needs no provider and it says out loud "this instance is not shared". Move to `toProvide` the day a child component needs the *same* instance, and provide it at the component or the route: ```typescript export const Tasks = craftComponent( 'Tasks', { providers: [provideTaskList()] }, function* () { const tasks = yield* TaskList(); return { tasks }; }, ({ tasks }) => [ /* … */ ], ); ``` ::: warning `toProvide` needs an explicit provider The route DI check verifies that the provider is present, and [architecture tests](/guide/testing/architecture#assertroutediproofs) keep the proof in place. ::: The two remaining scopes (`manuallyProvidedAtRoot`, and the details of `abstract`) are covered in [Service scopes](/guide/app/service-scopes). ## Parameterising an instance A service can take **inputs**: the factory's first parameter is an object the call site supplies. Changing inputs are yieldable readers (`CraftServiceInput`) — yield them so the input-to-service edge stays in the graph: ```typescript export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* (inputs: { projectId: CraftServiceInput }) { const tasks = yield* state('tasks', [] as Task[] /* … */); const projectId = yield* inputs.projectId(); return tasks; }, ); ``` ```typescript const tasks = yield* TaskList({ projectId: currentProjectId }); ``` Inputs are how you get several configured instances out of one `function`-scoped service, instead of duplicating it. ## Giving the service its own providers The service config also takes `providers`, for dependencies that should be scoped to this service rather than to whoever mounts it: ```typescript export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function', providers: [provideTaskApi()], }, function* () { const api = yield* TaskApi(); // … }, ); ``` Note this is a different thing from `provideTaskList()`, which is the helper *other* code uses to mount a `toProvide` service. ::: tip There is more to both Inputs interact with the property shortcuts (`X.property()` is deliberately blocked when a service has inputs, so a missing dependency can't hide behind a default — `X.OmitInputs.property()` opts out). Providers can also be declared per primitive, and abstract services turn "who provides this" into a decision of the mounting site. All of it is on [craftService](/guide/app/craft-service) and [Shaping the public API](/guide/app/expose-api) — come back once the tutorial is done. ::: ## Exposing less than everything A service returns whatever it wants to be public. Here `TaskList` returns the whole `tasks` ref. If a consumer only needs one property, it can say so: ```typescript const remaining = yield* TaskList.remaining(); ``` The dependency graph then records that only `remaining` was used — which makes tests smaller, and is why [step 10](/learn/10-testing) is short. ## What you gained Logic that is reusable, injectable and testable, declared as a function with a name and a scope — no `@Injectable`, no constructor. [← 2. Derive instead of duplicate](/learn/02-derive) [4. Compose services →](/learn/04-compose) --- --- url: https://craft-ts.github.io/craft/learn/04-compose.md --- # 4. Compose services **Goal:** understand `yield*` — the one idea the whole library is built on. This is the step that makes everything else obvious. Take your time here. ## The problem `yield*` solves Dependencies are easy to hide when a service reaches into a runtime container. Craft makes each dependency **visible in the type** by yielding it: ```typescript export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const api = yield* TaskApi(); // ← tracked const tasks = yield* state('tasks', [] as Task[], /* … */); return tasks; }, ); ``` Now `TaskList`'s type carries `TaskApi` as a dependency. Everything downstream — the DI check on routes, the testing register, the dependency snapshot — reads that type. ## Why a generator? A generator is just a function that can hand control back to its caller at each `yield`. Craft uses it as a **collection channel**: each `yield*` reports "I need this" to the runtime driving the factory, which resolves it and folds it into the graph. You don't manage that channel yourself. In practice the whole rule is: > Every named entity yields what it does not own. A factory, a computed, a > method — each one records **its** dependencies with `yield*`. ```typescript const api = yield* TaskApi(); // a service const tasks = yield* state('tasks', []); // a primitive ``` ::: warning A primitive is single-use Each `state(...)` / `query(...)` call produces one generator, consumed exactly once. Don't store one and `yield*` it twice. ::: ## Composing two services ```typescript const { TaskApi } = craftService( { name: 'TaskApi', providedIn: 'global' }, () => ({ // raw fetch, only to keep this example about composition — // see the note below fetchAll: () => fetch('/api/tasks').then((r) => r.json()), }), ); const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const api = yield* TaskApi(); const tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ // For this demo only; we'll later see why this belongs in a mutation instead. load: function* () { return yield* set(yield* api.fetchAll()); }, })); return tasks; }, ); const { TaskStats } = craftService( { name: 'TaskStats', providedIn: 'function' }, function* () { const tasks = yield* TaskList(); return { done: craftComputed('done', function* () { return (yield* tasks()).filter((t) => t.done).length; }), }; }, ); ``` Note the factory of `TaskApi` is a plain arrow — a service with no dependencies doesn't need to be a generator. `TaskStats` does not own `TaskList`. The computed yields `tasks` so **that** read is recorded on `done`, not silently closed over from the factory. ::: warning Don't call `fetch` directly in real code It is used here only to keep the example about composition. HTTP goes through **`CraftHttpClient`**, which is yieldable — so the request is tracked like any other dependency, it is mockable at the [browser boundary](/guide/testing/browser-boundaries) in tests, and above all it is what turns a failed response into a typed `craftException` you can handle. A raw `fetch` gives you none of that: no tracking, no boundary, and a rejected promise instead of a declared failure. [Step 5](/learn/05-load-data) uses `CraftHttpClient` for real, and [step 6](/learn/06-mutate-data) shows the exceptions it produces. The `craft-ts/prefer-craft-http-client` ESLint rule flags direct `HttpClient` usage for the same reason. ::: ## Taking only what you need `TaskStats` only reads the array. Say so, and the graph records only that: ```typescript const { TaskStats } = craftService( { name: 'TaskStats', providedIn: 'function' }, function* () { const fetchAll = yield* TaskApi.fetchAll(); // one property // … }, ); ``` A test for `TaskStats` then has to mock `fetchAll` and nothing else. ## What you gained The mental model: **declare with a name, drive with `yield*`, derive the rest.** Every remaining step is a variation on it — `query` yields, `mutation` yields, guards yield, route providers yield. ::: tip Going deeper `craftGen` lets you write a standalone generator outside a service — useful for guards and route helpers. See [Generators](/guide/concepts/generators). ::: [← 3. Move logic out of the component](/learn/03-service) [5. Load server data →](/learn/05-load-data) --- --- url: https://craft-ts.github.io/craft/learn/05-load-data.md --- # 5. Load server data **Goal:** replace the hand-rolled `load()` from step 4 with `query`, and get loading, error and exception state for free. ## The query primitive ```ts import { CraftHttpClient, craftService, query } from '@craft-ts/core'; export const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const tasksQuery = yield* query('tasksQuery', { // The initial params value immediately triggers the loader. params: () => ({ done: false }), loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/tasks?done=${params.done}`, success: response(), })); }, }); return tasksQuery; }, ); ``` Three things to read here. **`params`** is reactive. When what it returns changes, the loader re-runs. It can be a signal, a function, or a generator that yields other services. **`loader`** is a generator, so it can `yield*` — here `CraftHttpClient`, which is the craft-tracked HTTP client. A plain `async` function works too when there is nothing to yield. **The result** is a ref carrying the full async state: ```typescript tasksQuery.value(); // Task[] | undefined — never throws tasksQuery.isLoading(); // boolean tasksQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception' tasksQuery.exception(); // craftException | undefined ``` ::: tip `value()` is safe to read in templates and computed signals: it returns `undefined` when the query has no resolved value. ::: ## In the template `ifNode` / `matchNode` are the structural conditionals (see [step 2](/learn/02-derive#control-flow)). For a first pass a ternary chain reads fine — just remember it makes the branch invisible to the [type-level assertions](/guide/testing/type-level): ```typescript import { craftComponent, forNode, li, p, ul } from '@craft-ts/component'; export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* TaskList(); return { tasks }; }, ({ tasks }) => tasks.isLoading() ? p('Loading…') : tasks.hasException() ? p('Could not load tasks.') : ul( forNode( () => tasks.value() ?? [], { track: (task) => task.id }, (task) => li(task.title), ), ), ); ``` When the branches depend on an exception **code** rather than a boolean, reach for `matchNode.exhaustive(...)` — the compiler then checks you covered every code: ```typescript matchNode.exhaustive(() => tasks.exceptions().loader, '_tag', { TASKS_FORBIDDEN: () => p('You do not have access to this list.'), TASKS_NOT_FOUND: () => p('This list no longer exists.'), }); ``` See [Exceptions as values](/guide/concepts/exceptions). ## Triggering it yourself `params` re-runs the loader automatically. When the trigger is a user action instead, use `method`: ```ts import { CraftHttpClient, craftService, query } from '@craft-ts/core'; export const { TaskSearch } = craftService( { name: 'TaskSearch', providedIn: 'function' }, function* () { const searchQuery = yield* query('searchQuery', { method: (term: string) => term, loader: function* ({ params: term }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/tasks?q=${term}`, success: response(), })); }, }); return { searchQuery }; }, ); ``` ## Adding derived values Same insertion mechanism as step 2 — third argument: ```typescript const { tasksQuery } = yield * query( 'tasksQuery', { /* … */ }, ({ value, isLoading }) => ({ count: craftComputed(function* () { return (yield* value())?.length ?? 0; }), isEmpty: craftComputed(function* () { return !(yield* isLoading()) && (yield* value())?.length === 0; }), }), ); yield* tasksQuery.count(); ``` ## About the flicker There isn't one: when `params` change, the previous value stays on screen until the new one resolves. That is the **default**, so paginating never blanks the list. If you actually want the value cleared while loading, opt out explicitly: ```typescript query('tasksQuery', { params: () => ({ page: page() }), preservePreviousValue: () => false, loader: /* … */, }); ``` ## What you gained Server state with the same shape as local state — named, insertable, tracked — and no manual `isLoading` flag. ::: details Beyond the basics Parallel queries per identifier, business exceptions raised from `params`, typed HTTP exception matchers, and reacting to mutations are all on [query](/guide/state/server-state). ::: [← 4. Compose services](/learn/04-compose) [6. Write server data →](/learn/06-mutate-data) --- --- url: https://craft-ts.github.io/craft/learn/06-mutate-data.md --- # 6. Write server data **Goal:** create a task on the server, and make the list update before the request even comes back. ## The mutation primitive `mutation` is `query`'s counterpart for writes. Same shape, triggered explicitly. ```ts import { CraftHttpClient, craftService, mutation } from '@craft-ts/core'; export const { TaskWrites } = craftService( { name: 'TaskWrites', providedIn: 'function' }, function* () { const createTask = yield* mutation('createTask', { method: (payload: { title: string }) => payload, loader: function* ({ params }) { return yield* CraftHttpClient.post(({ response }) => ({ url: '/api/tasks', payload: params, success: response(), })); }, }); return { createTask }; }, ); ``` `method` is the entry point: it takes what the caller passes and returns what the loader receives as `params`. It is also where you can reject input before any request happens (see below). ## Making the list react The interesting part is not the mutation, it's wiring it to the query. That's an insertion — `insertReactOnMutation`: ```ts import { CraftHttpClient, craftService, insertReactOnMutation, mutation, query, } from '@craft-ts/core'; export const { TaskSync } = craftService( { name: 'TaskSync', providedIn: 'function' }, function* () { const createTask = yield* mutation('createTask', { method: (payload: { title: string }) => payload, loader: function* ({ params }) { return yield* CraftHttpClient.post(({ response }) => ({ url: '/api/tasks', payload: params, success: response(), })); }, }); const tasksQuery = yield* query( 'tasksQuery', { params: () => ({ done: false }), loader: function* () { return yield* CraftHttpClient.get(({ response }) => ({ url: '/api/tasks', success: response(), })); }, }, insertReactOnMutation(createTask, { reload: { onMutationResolved: true }, }), ); return { createTask, tasksQuery }; }, ); ``` The query now reloads itself whenever `createTask` succeeds. No subscription, no event bus, no manual `refetch()` call at the call site. ## Optimistic updates Reloading costs a round-trip. `optimisticPatch` applies the change immediately and reverts it if the mutation fails: ```typescript insertReactOnMutation(renameTask, { optimisticPatch: { title: ({ mutationParams }) => mutationParams.title, }, reload: { onMutationException: true }, }); ``` While `renameTask` is in flight, `tasksQuery.value()` already shows the new title. If it throws, the query reloads to get the truth back. ## Rejecting bad input You rarely want to send a request you know will fail. Return a `craftException` from `method` and the loader never runs: ```typescript import { craftException } from '@craft-ts/core'; const createTask = yield* mutation('createTask', { method: (payload: { title: string }) => payload.title.trim().length === 0 ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title }) : payload, loader: /* … */, }); yield* createTask.mutate({ title: ' ' }); createTask.hasException(); // true createTask.exceptions().params?.TITLE_REQUIRED; ``` Note the shape: `exceptions()` is split by **origin** — `params` for what your `method` rejected, `loader` for what the request produced. Both are typed from the codes you declared, so the compiler knows `TITLE_REQUIRED` exists and that `TITLE_TOO_LONG` doesn't. ### Or let a schema do it Hand-written guards get long as soon as there are several fields. Declare a schema instead and the primitive validates the argument for you: ```typescript import { z } from 'zod'; const CreateTaskSchema = z.object({ title: z.string().trim().min(1).max(80), }); const createTask = yield* mutation('createTask', { methodSchema: CreateTaskSchema, method: (payload) => payload, // already validated and typed by the schema loader: /* … */, }); ``` `methodSchema` validates what `mutate(...)` receives, and `method` then gets the schema's **output** value — so a coercion or a `.trim()` in the schema is reflected in the type. Any library implementing `StandardSchemaV1` works — Zod, Valibot, ArkType, or a hand-written `{ '~standard': … }` object; Effect Schema works too, after one [`Schema.toStandardSchemaV1`](/guide/state/schema-validation#effect-schema) call. None of them becomes a dependency of `@craft-ts`. Queries have the same hooks for their reactive params (`paramsSchema`) and their result (`loaderSchema`). **Use a schema** when the shape itself is the rule, **a `craftException` from `method`** when the rule is business logic — "this title already exists in the current project" is not something a schema can know. See [Schema validation](/guide/state/schema-validation). ::: tip Exceptions as values A craft *exception* is a value you declared and expect to handle. An *error* is the unexpected kind. Keeping the two apart is what makes the exhaustiveness checks later possible — see [Exceptions](/guide/concepts/exceptions). ::: ## What you gained A write path that owns its loading and failure state, and a declarative link between writes and reads. [← 5. Load server data](/learn/05-load-data) [7. Put state in the URL →](/learn/07-url-state) --- --- url: https://craft-ts.github.io/craft/learn/07-url-state.md --- # 7. Put state in the URL **Goal:** make the "show done tasks" filter and the page number survive a refresh and a copy-pasted link — without syncing anything by hand. ## `queryParams` is a state that lives in the URL ```ts import { craftService, queryParams } from '@craft-ts/core'; export const { TaskFilters } = craftService( { name: 'TaskFilters', providedIn: 'function' }, function* () { const numberCodec = { decode: (value: string) => parseInt(value, 10), encode: (value: number) => String(value), }; const booleanCodec = { decode: (value: string) => value === 'true', encode: (value: boolean) => String(value), }; const filters = yield* queryParams( 'filters', { state: { page: { fallbackValue: 1, codec: numberCodec }, showDone: { fallbackValue: false, codec: booleanCodec }, }, }, ({ set, patch, reset }) => ({ set, patch, reset }), ); return filters; }, ); ``` Reading and writing look like any other state — the URL follows: ```typescript filters(); // { page: 1, showDone: false } filters.page(); // 1 filters.patch({ showDone: true }); // navigates to ?showDone=true filters.reset(); ``` And `?page=3&showDone=true` becomes `{ page: 3, showDone: true }` on load. There is no effect to write, no subscription to the `ActivatedRoute`, no `skipLocationChange` dance. ## Codecs are mandatory, and that's on purpose A URL only holds strings. Every parameter must declare how it converts both ways: ```typescript { fallbackValue: 1, codec: { decode, encode } } ``` `fallbackValue` is what you get when the parameter is absent — so the state type is never `undefined`. The decoded type is your application type; the encoded one is what appears in the URL. It works the same for dates, enums, arrays (`value.split(',')`) and JSON blobs. Codecs are synchronous, because they run inside the reactive URL computation. When a `decode` throws, the parameter keeps its fallback and the failure surfaces instead of corrupting your state: ```typescript if (filters.hasException()) { filters.exceptions().parse.page?.code; // 'QueryParamDecodeError' } ``` ## Feeding the query Now connect it to step 5 — the query's `params` reads the URL state: ```typescript const tasksQuery = yield* query('tasksQuery', { params: () => ({ page: filters.page(), done: filters.showDone() }), loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/tasks?page=${params.page}&done=${params.done}`, success: response(), })); }, }); ``` Clicking "next page" now changes the URL, which re-runs the loader, which re-renders the list. One direction of data flow, and the back button works. ## Custom methods, same as always ```typescript queryParams( 'filters', { /* … */ }, ({ state, patch }) => ({ nextPage: function* () { const current = yield* state(); return yield* patch({ page: current.page + 1 }); }, previousPage: function* () { const current = yield* state(); return yield* patch({ page: current.page - 1 }); }, setPageSize: function* (pageSize: number) { return yield* patch({ pageSize, page: 1 }); }, }), ); ``` ## What you gained Shareable, refresh-proof UI state, with no synchronisation code. ::: details Declaring query params on the route itself `queryParams` can live directly in a `craftRoutes(...)` entry, so the parameters belong to the route rather than to a component, and can then be retrieved through dependency injection. We'll see this after step 9, which introduces routes. See [queryParams](/guide/state/url-state) for the full reference. ::: [← 6. Write server data](/learn/06-mutate-data) [8. Build a form →](/learn/08-forms) --- --- url: https://craft-ts.github.io/craft/learn/08-forms.md --- # 8. Build a form **Goal:** a "new task" form with validation and a typed submit — derived from state, not declared next to it. ## A form is a state There is no `FormBuilder` here. You start from the state you already know, and `insertForm` derives the form from it: ```ts import { craftService, state } from '@craft-ts/core'; import { cRequired, cMaxLength, insertForm, insertFormAttributes, insertNoopTypingAnchor, insertSelectFormTree, } from '@craft-ts/core'; export const { TaskForm } = craftService( { name: 'TaskForm', providedIn: 'function' }, function* () { const taskForm = yield* state( 'taskForm', { title: '', notes: '' }, insertForm( insertSelectFormTree( 'title', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cMaxLength({ maxLength: 80 })], })), ), insertSelectFormTree( 'notes', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [] })), ), ), ); return taskForm; }, ); ```*the form is this shape, and here is what each field requires.* The field tree, the validity, and the exception types are all derived from the state type — you never restate them. ```typescript const form = taskForm.form; const title = form.selectTitle(); title()().exceptions.list; // typed list of this field's exceptions title()().exceptions.byValidator['cRequired']; ``` `insertSelectFormTree` is lazy. Calling `selectTitle()` materializes the branch and registers its validators. Use the returned selected field for DOM binding; reading the raw `form.title` field does not activate the branch insertions. ::: warning `insertNoopTypingAnchor` It adds no behaviour. It is a TypeScript anchor that the inference needs to type the field and its exceptions. Every `insertSelectFormTree` needs one — it's a known wart, not a step you can skip. ::: ## Validators Built-ins cover the usual ground: `cRequired`, `cEmail`, `cMin` / `cMax`, `cMinLength` / `cMaxLength`, `cPattern`. Custom ones use `cValidate`, and `cAsyncValidate` for server-side checks. Details on [Validation](/guide/forms/validation). For rules that cover the complete value, add one Standard Schema insertion: ```typescript insertForm( insertFormSchema(taskSchema), /* field insertions */ ); ``` Schema issues are projected onto fields by path. The form keeps its input value; if the schema transforms values, apply that schema again as the mutation's `methodSchema` at submit time. Attributes are derived too, so conditional UI is a function, not an effect: ```typescript insertFormAttributes(() => ({ validators: [cRequired()], disable: () => createTask.isLoading(), hidden: () => !showAdvanced(), })); ``` ## Submitting Submission is wired to the mutation you wrote in step 6 — that is the whole declaration: ```typescript insertFormSubmit(createTask); ``` ```typescript form('TaskForm', { *submit(event) { event.preventDefault(); yield* taskForm.form.submit(); }, }, [ /* fields */ ]); ``` The form now knows when it is submitting (`form.submitting()`), whether a submit was attempted (`form.hasAttemptedSubmit()`), and — the point — **which exceptions submission can produce**, inferred from the mutation: ```typescript taskForm.form.submitExceptions(); ``` If your mutation declares a `TITLE_ALREADY_EXISTS` exception, that code is in the union. Rename it and the compiler tells you where you were handling it. ## Reshaping submit exceptions Server codes are rarely what the UI wants to show. Refine them in an ordered pipeline: ```typescript insertFormSubmit(createTask, { exceptions: [ ({ omit }) => omit(['TITLE_ALREADY_EXISTS']), ({ submitCraftResource }) => { const clash = submitCraftResource.exceptions()?.loader ?.TITLE_ALREADY_EXISTS; if (!clash) return undefined; return craftException({ _tag: 'PICK_ANOTHER_TITLE' }, clash.payload); }, ], }); ``` Returning an array replaces the list; returning one exception appends it. ::: warning `success` is not a "then" callback The config also accepts `success`, but it runs **inside the derivation of the submit exception list** and its return value is appended to that list. It exists to raise an exception the server reported with a 200 — not to run side effects. Resetting the form, navigating or showing a toast from there means mutating state inside a computation, and it re-runs whenever the exceptions recompute. Drive those from your own code after `submit()`, or from the mutation. ::: ## What you gained A form whose validity, field tree and error types are consequences of your state and your mutation — so they cannot drift out of sync with them. ::: details Nested and parallel forms Sub-forms with `insertSubFormField`, several independent forms over the same state, and the full validator reference are on [Forms](/guide/forms/). ::: [← 7. Put state in the URL](/learn/07-url-state) [9. Wire up routing →](/learn/09-routing) --- --- url: https://craft-ts.github.io/craft/learn/09-routing.md --- # 9. Wire up routing **Goal:** put the tasks page behind a route, and make a missing provider a **compile error** instead of a blank screen. The headline is this: **navigation only accepts routes that exist**. Not a `string` you hope is right — a value checked against the paths your app actually declares. A typo, a removed route, a missing param: all compile errors, at the call site. This is where the dev tooling earns its keep. ## Declare the route A Craft component is mounted with `loadCraftComponent(...)`, spread into the route: ```ts import { loadCraftComponent } from '@craft-ts/component'; import { craftRoutes } from '@craft-ts/core'; export const { appRoutes } = craftRoutes('app', [ { path: 'tasks', ...loadCraftComponent(({ withRetry }) => withRetry(import('./tasks/tasks')).then( ({ default: component }) => component, ), ), }, ]); ``` `withRetry` wraps the dynamic import, so a chunk that fails to download is retried instead of dead-ending the navigation. Keep the import specifier literal — a computed one can't be statically discovered by the bundler. ## Register the paths Declaring the collection's paths is what makes navigation type-safe across the app: ```typescript declare module '@craft-ts/core' { interface CraftRouterRoutesRegistry { App: typeof appRoutes.META_PATHS; } } ``` From here on, every navigation target is checked against that registry. ## Navigating Two ways, both checked against the registry above. **As a link**, with the `CraftRouterLink` directive: ```ts import { a, craftComponent } from '@craft-ts/component'; import { CraftRouterLink } from '@craft-ts/core'; export const TasksLink = craftComponent( 'TasksLink', {}, () => ({}), () => a('tasks', {}, 'Tasks').pipe(CraftRouterLink({ to: 'tasks' })), ); ``` **Imperatively**, by yielding the router: ```ts import { craftComponent } from '@craft-ts/component'; import { CraftRouter, craftMethod } from '@craft-ts/core'; export const TaskOpener = craftComponent( 'TaskOpener', {}, function* () { const router = yield* CraftRouter(undefined, ({ navigate }) => ({ navigate })); const goToTask = craftMethod('goToTask', function* (taskId: string) { void router.navigate({ to: 'tasks/:taskId', params: { taskId } }); }); return { goToTask }; }, () => [], ); ``` The target is `{ to, params?, queryParams? }`, and all of it is checked: ```typescript router.navigate({ to: 'taks' }); // ✗ not a known path router.navigate({ to: 'tasks/:taskId' }); // ✗ params.taskId is missing router.navigate({ to: 'tasks/:taskId', params: { id: '1' } }); // ✗ wrong param router.navigate({ to: 'tasks/:taskId', params: { taskId: '1' } }); // ✓ ``` Note that `navigate` comes from **yielding** `CraftRouter`, not from injecting it — so the dependency is tracked and the route check can see it. ## The check that pays for all of this Each route component gets its own check: `RouteCheckedDI` compares what the component needs against what is actually available at that path, and `CanRun` turns a mismatch into a TypeScript error. The `tasks` route created above remains visible as the source of truth; the check below validates that route's component and its `path: 'tasks'` context. An AI can also create this CraftTS routing boilerplate very well — including the lazy import, retry handling, route registry and DI check — from the component and path you provide. Declare one local alias for your app's context, then one `CanRun` per route: ```ts import type { CanRun, ComponentDepsOf, RouteCheckedDI } from '@craft-ts/core'; type AppRouteCheckedDI< Component, RouteInputs extends string = never, Context extends string = 'app route component', > = RouteCheckedDI< ComponentDepsOf, 'CraftRouter', CraftRouter | ActivatedRoute, Context, RouteInputs >; type _CanRunTasks = CanRun< AppRouteCheckedDI< (typeof import('./tasks/tasks'))['default'], never, 'path: "tasks"' > >; ``` The alias fixes the ambient context once — what the app provides by name (`'CraftRouter'`) and by value (`Router | ActivatedRoute`). Each route then supplies three things: the component, the **route inputs** it may bind (a path param like `'taskId'`, or `never`), and a label used in error messages. A mismatch reads like this: ``` The TaskList service is not provided in path: "tasks" Input "taskId" is not provided in path: "tasks" ``` Remember step 3, where `toProvide` was flagged as failing only at runtime? This is what closes that hole — **provided the proof stays in the file**. A `CanRun` alias that nobody references still compiles; [architecture tests](/guide/testing/architecture#assertroutediproofs) are what turn omitting it into a failing suite. ::: tip Why one check per route `RouteCheckedDI` validates a single component with no recursion between routes, so the cost is flat: a file with two hundred routes costs two hundred independent checks and never hits TypeScript's instantiation ceiling. See [Scaling routes](/guide/routing/scaling). ::: ## Prove the exceptions are handled Guards, matchers and resolvers can raise a `craftException` — and so can a **component's own factory or providers**, whose unhandled codes flow up into the route. One call asserts that every reachable code has a handler, and that no handler exists for a code nothing produces: ```typescript assertExhaustiveRouteExceptions(appRoutes); ``` The ESLint rule `craft-ts/require-assert-exhaustive-route-exceptions` adds it for you. A component can also handle its own codes with `.pipe(catchTag.exhaustive(...))`, which removes them from the route's union — see [An unhandled exception doesn't just disappear](/guide/concepts/exceptions). Everything else is on [Route exception handling](/guide/routing/exception-handling). ## Wire it into the app ```ts import { craftAppConfig } from '@craft-ts/core'; export const appConfig = craftAppConfig({ providers: [provideRouter(appRoutes.toRoutes(), )], }); ``` `toRoutes()` returns the runtime routes; `META_DATA` carries the compile-time graph to `craftAppConfig`. ## Make the DI contract enforceable The proofs above look ceremonial: unused type aliases, a `CanRun` wrapper, a cascade that does not descend into `loadChildren`, a separate check for pending and error screens, another for `app.config`. Each piece is small; omitting one is silent. TypeScript still compiles. Architecture tests collapse that checklist into a single assertion. `assertRouteDiProofs` walks the static graph and fails unless every routed component — including lazy child collections — and every `craftAppConfig` error screen is hooked to an armed mapper. TypeScript still judges whether a dependency is provided; the architecture suite judges whether that judgement was invoked. Add it next to `e2e/`, in `architecture/`, then run it in CI. Full setup: [Architecture rules](/guide/testing/architecture). ## What the user sees while a route loads A guard or a resolver that does real work leaves the app frozen on the previous page. Swap `provideRouter` for `provideCraftRouter` and render `CraftRouterOutlet()` instead of ``, and the URL commits immediately while the chain runs behind it: ```ts import { craftAppConfig, provideCraftRouter, withTransitionTimings } from '@craft-ts/core'; export const appConfig = craftAppConfig({ providers: [ provideCraftRouter( appRoutes.toRoutes(), withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }), ), ], }); ``` Those three numbers are the whole waiting story, and they exist so a fast navigation shows **nothing at all**: | Phase | What is on screen | | ----------------------------------- | ------------------------------------------------- | | `0 → stayMs` | the previous page — most navigations resolve here | | `stayMs → +blankMs` | a blank surface: something is coming | | beyond, for `pendingMinMs` at least | the pending component (a spinner, a skeleton) | `pendingMinMs` is the anti-flicker floor: once the loader appears it stays put, so it can't flash for 40ms. ### Changing the pending component The default spinner is replaceable globally: ```typescript provideCraftRouter( appRoutes.toRoutes(), withPendingComponent(MyBrandedSpinner), ); ``` …or per route, which is where it gets interesting — a skeleton shaped like the page it is standing in for reads far better than a spinner: ```typescript { path: 'tasks', ...loadCraftComponent(/* … */), pendingComponent: () => import('./tasks/tasks-skeleton'), stayMs: 150, // this route is slower: get to the skeleton sooner blankMs: 0, // and skip the blank phase entirely } ``` Route-level values override the global ones, so you tune only the routes that need it. ::: tip See it running The `slow-page` demo exists for exactly this: two deliberately slow steps (~1.5s each) so you can watch the stay → blank → loader phases play out. The first visit is slow, a revisit is instant thanks to the query cache, and a "clear cache" button replays it. Source: [slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts). Full details — the phase diagram, per-route overrides, view transitions and the DI check on skeletons — are on [Non-blocking navigation](/guide/routing/pending-ui). ::: ## Let the CLI write it Hand-writing these pieces gets old. The CLI does it for you and the output stays ordinary, editable TypeScript: ```shell npx craft route add /tasks --create-component tasks/tasks ``` It picks the right collection, creates a lazy routes file per feature, adds the loader, the check block and the registry entry, then runs ESLint and `tsc`. Use `--dry-run` first. ## What you gained Routing where a forgotten provider, a misspelled input, an unhandled exception or a route pointing at nothing stops the build instead of reaching production — and architecture tests keep those proofs from quietly disappearing. ::: details The parts you'll want later Route-scoped providers, guards as bare generators and centralised exception handling all live under [Routing](/guide/routing/setup). Splitting a growing collection across lazy child files is [Scaling routes](/guide/routing/scaling). Architecture tests that keep the DI proofs armed are [Architecture rules](/guide/testing/architecture). ::: [← 8. Build a form](/learn/08-forms) [10. Test what you wrote →](/learn/10-testing) --- --- url: https://craft-ts.github.io/craft/learn/10-testing.md --- # 10. Test what you wrote **Goal:** test `TaskList` and the `Tasks` component without guessing what to mock — the dependency graph tells you. ## The idea Most test setups let you forget a dependency and find out at runtime. Craft inverts it: you pass a **register** covering the whole graph, and the compiler refuses to run the test until every node is accounted for. Each node is one of four things: `'real'`, its own `provideX(...)`, a mock object, or `'notReached'`. ## Testing a service Here is the service under test — the one from [step 4](/learn/04-compose), with its scope changed to `toProvide` so it has a `provideTaskStats()` to mount in the test: ```ts export const { TaskStats, provideTaskStats } = craftService( { name: 'TaskStats', providedIn: 'toProvide' }, function* () { const tasks = yield* TaskList(); return { done: craftComputed('done', function* () { return (yield* tasks()).filter((task) => task.done).length; }), }; }, ); ``` It depends on one thing, `TaskList`, and exposes one thing, `done`. The test mirrors that exactly: ```ts const { sut, mocks } = await setupCraftServiceTestingByRegister(TaskStats, { // the SUT itself, mounted through its own provider TaskStats: provideTaskStats(), // its only dependency, replaced by a mock TaskList: { $self: vi.fn(function* () { return [ { id: '1', title: 'a', done: true }, { id: '2', title: 'b', done: false }, ]; }), }, }); expect(craftUse(sut.done())).toBe(1); expect(mocks.TaskList).toBeDefined(); ``` `sut` is the service under test; `mocks` gives you back the mocks you supplied, already typed, so `mocks.TaskList.$self` is assertable. ::: tip Which register entry to use `provideX()` for a `toProvide` or `manuallyProvidedAtRoot` service, `'real'` for a reachable `global` or `function` one, a plain object to mock it, and `'notReached'` for a branch this test never touches. ::: `$self` is the service's own returned value — the ref itself, as opposed to a property hanging off it. ## Why the register is small Because of step 4. `TaskStats` yielded only what it needed, so the register only asks for that. Had it yielded the whole `TaskApi`, the register would demand `TaskApi` too. **Precise yields make short tests** — that's the payoff for the `yield*` discipline. ## Testing a component Here is the component under test, from steps 2 and 3 — a factory that yields `TaskList`, and a template that renders it: ```ts import { craftComponent, forNode, h1, li, ul } from '@craft-ts/component'; export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* TaskList(); return { tasks }; }, ({ tasks }) => [ h1(function* () { return `Tasks — ${yield* tasks.remaining()} left`; }), ul( forNode(tasks, { track: (task) => task.id }, (task) => li(function* () { return (yield* task()).title; }), ), ), ], ); ``` Those two halves are tested **independently**: the factory produces a context without touching the DOM, and the template renders a context without running the factory. The logic test runs the factory only — no DOM: ```ts const { context, mocks, destroy } = await setupCraftComponentLogicTest.byRegister(Tasks, { register: { TaskList: { $self: () => [{ id: '1', title: 'a', done: false }], remaining: () => 1, }, }, }); expect(context.tasks.remaining()).toBe(1); destroy(); ``` The template test does the opposite — it renders with a context you hand it, and never runs the factory: ```ts const test = await setupCraftComponentTemplateTest.byRegister(Tasks, { context: { tasks: Object.assign( function* () { return [{ id: '1', title: 'Write tests', done: false }]; }, { remaining: function* () { return 1; }, add: () => undefined, toggle: () => undefined, remove: () => undefined, }, ), }, register: {}, }); expect(test.nativeElement.textContent).toContain('Tasks — 1 left'); test.destroy(); ``` That separation is why component tests stay fast: you only pay for the DOM when the DOM is what you're asserting on. ## Finding elements Template tests expose `locator(tag, criteria)` rather than raw CSS selectors: ```typescript const removeButton = test.locator('button', { 'data-testid': 'remove' }); removeButton?.click(); ``` ## Proving it at the type level Some of what craft guarantees isn't observable at runtime at all — it's in the types. Those get their own kind of test, resolved by the compiler with no `TestBed`, no DOM and no factory: ```typescript type TasksTemplateTest = SetupTestComponentTemplate; ``` The resolver walks elements, directives, `forNode`, `deferNode` and child components, and a child missing from the tuple becomes a type diagnostic. Companion assertions — `TemplateHasElement`, `TemplateHasElementWithProps`, `TemplateHasYieldableEvent`, `TemplateRendersStateWhen` — check that the template really renders what you think, including event argument types. This is how you pin down a template contract that a runtime test would only catch by accident. Full reference: [Type-level tests](/guide/testing/type-level). ## Tests that stay close to reality Mocking everything makes tests that pass while the app is broken. `boundaryOnly` keeps the real graph and lets you replace only what actually touches the outside world — the services marked `browserBoundary: true` (HTTP, storage, location): ```typescript const { sut } = await setupCraftServiceTestingByRegister(TaskList, register, { boundaryOnly: true, }); ``` Everything in between runs for real. See [Browser boundaries](/guide/testing/browser-boundaries). ## Architecture of the whole app The register proves one service's graph is complete. Architecture rules prove invariants **across** services: this feature must not depend on that one, this HTTP endpoint is owned once, this `craftUnique` storage key appears once. They live next to `e2e/`, analyze TypeScript without starting the application, and are ordinary Vitest assertions on a typed graph. Look a node up, walk its edges, assert. A precise rule — HTTP may only be called from a `browserBoundary` service — is an `it()`: ```typescript it('only browser-boundary services call HTTP', () => { const boundaryIds = new Set( graph.services({ browserBoundary: true }).map((node) => node.id), ); const leaked = graph .usingHttp() .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id)); expect(leaked.map((node) => node.label)).toEqual([]); }); ``` Anything you can see on the graph is a rule you can write: folder lanes, exclusive feature branches, a method that must not both be called and write a `source$`. Built-in helpers cover unique `craftUnique` identities, unique HTTP verb+URL, pure `craftComputed`, no `depends-on` cycles, `assertPathBoundaries`, `noExclusiveLink`, `assertMutationHasReactOn`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, and the route DI proofs from [step 9](/learn/09-routing). Those proofs (`CanRun`, `RouteCheckedDI`) are unused type aliases — omit one and the project still compiles. `assertRouteDiProofs` fails the suite unless every routed component and every `app.config` error screen stays hooked to an armed mapper. TypeScript still judges injection; the architecture suite judges whether that judgement was invoked. Full setup: [Architecture rules](/guide/testing/architecture). Why that graph is not Nx's project graph: [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx). The demo app already imports the helpers. From the repository root: ```shell npx nx architecture demo ``` ## What you gained Tests whose setup is derived from the real dependency graph, so "I forgot to mock that" becomes a compile error — and architecture rules on that same graph, so the app can be taught its boundaries. [← 9. Wire up routing](/learn/09-routing) [Where to go next →](/learn/next) --- --- url: https://craft-ts.github.io/craft/learn/next.md --- # Where to go next You have the whole mental model: **declare with a name, yield what you do not own, derive the rest.** Everything below is a variation on it. ## Fill the gaps in what you built | You have | Next thing worth adding | | ----------------------- | ------------------------------------------------------------------------------------ | | A query and a mutation | [Persistence](/guide/state/persistence) — storage persistence as an insertion (localStorage by default) | | A list | [Collections](/guide/state/collections) — entity storage, selectors, updates | | A form | [Validation](/guide/forms/validation) — custom and async validators | | Routes | [Route guards](/guide/routing/guards) and [Route providers](/guide/routing/route-providers) | | A running app | [Non-blocking navigation](/guide/routing/pending-ui) — pending UI instead of a freeze | ## Concepts worth a dedicated read * [The mental model](/guide/concepts/mental-model) — the design principles behind the API you just used * [Exceptions as values](/guide/concepts/exceptions) — declared failures, exhaustively handled * [Insertions](/guide/concepts/insertions) — writing your own and composing them * [Typed insertion pipes](/guide/concepts/insertion-pipes) — readable composition for each primitive * [Generators](/guide/concepts/generators) — `craftGen` outside a service ## Teach the app its boundaries The graph you just tested is also a map you can constrain. [Architecture rules](/guide/testing/architecture) are ordinary Vitest assertions on the static Craft graph: unique identities, unique HTTP, pure `craftComputed`, folder lanes, exclusive feature branches — and any neighbourhood you can look up is a rule you can write. Nx still owns the workspace graph (imports, affected, cache); Craft judges the app. [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) is the split. Setup is in that guide. The demo suite already runs them: ```shell npx nx architecture demo ``` ## When your app grows * [Service scopes](/guide/app/service-scopes) — when `function` stops being enough * [Scaling routes](/guide/routing/scaling) — splitting collections before TypeScript's instantiation ceiling bites * [Lazy services](/guide/app/lazy-services) and [App start](/guide/app/app-start) * [Observability](/guide/advanced/observability) — logging and tracing that follow the dependency graph ## Reference Looking for one symbol? The [API index](/reference/) lists every export with a one-line description and a link. ## See it running [Examples](/resources/examples) points at the demo application, which exercises most of the above end to end. Importing Craft into an app that an agent will edit? Point it at [coding agents](/resources/ai-agents) — `llms.txt`, the `@craft-ts/mcp` server, and the Agent Skills. [← 10. Test what you wrote](/learn/10-testing) --- --- url: https://craft-ts.github.io/craft/learn-effect/00-start-here.md --- # Effect users: start here This page is for teams that already use Effect and are evaluating CraftTS for the frontend. The important distinction is this: > You do not need to replace your domain model or your Effect programs. You do > need to adopt Craft's UI model for components, templates, reactive state, > forms and routing. Effect remains the place for domain programs, typed failures, services and `Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity, loading, cancellation and URL state. ## The boundary in one picture The two sides have different responsibilities: | Concern | Effect | CraftTS | | --- | --- | --- | | Domain rules | `Effect` | consumes the result | | Services | `Context.Service` + `Layer` | provides the Layer at a Craft scope | | Business failures | tagged errors in `E` | typed exceptions to render or handle | | UI state | not the owner | `state`, `queryParams`, derived readers | | Loading and cancellation | Effect runtime | `queryEffect`, `mutationEffect`, `asyncProcessEffect` | | Components and templates | not the owner | `craftComponent` and typed hyperscript | `yield*` appears on both sides, but it does not mean the same thing. Inside an Effect program it reads an Effect service or runs another Effect. Inside a Craft factory it declares a Craft dependency or crosses the boundary through an Effect adapter. ## A 15-minute quickstart The goal is one page that loads a user from an Effect program and renders the result through a Craft query. ### 1. Install the matching packages — 2 minutes ```shell npm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta npm i effect@rc npm i -D @craft-ts/dev-tools@beta ``` Keep the Craft packages on the same version. See the [compatibility and maturity matrix](/resources/effect-compatibility) before using this in a production application. ### 2. Define the domain program — 4 minutes This code is ordinary Effect code. It does not import Craft. ```ts import { Context, Data, Effect, Layer } from 'effect'; export type User = { readonly id: string; readonly name: string }; export class UserNotFound extends Data.TaggedError('UserNotFound')<{ readonly userId: string; }> {} export type UserRepository = { readonly find: (userId: string) => Effect.Effect; }; export class UserRepositoryService extends Context.Service< UserRepositoryService, UserRepository >()('quickstart/UserRepository') {} export const UserRepositoryLive = Layer.succeed(UserRepositoryService, { find: (userId) => userId === 'user-ada' ? Effect.succeed({ id: userId, name: 'Ada Lovelace' }) : Effect.fail(new UserNotFound({ userId })), }); export const loadUser = Effect.fnUntraced(function* (userId: string) { const repository = yield* UserRepositoryService; return yield* repository.find(userId); }); ``` The component will call `loadUser`, but it will not resolve `UserRepositoryService`. The nearest `Layer` will provide it. ### 3. Cross the boundary with `queryEffect` — 4 minutes The adapter turns `Effect` into a Craft resource with loading, value and exception readers. ```ts import { craftComponent, p } from '@craft-ts/component'; import { queryEffect } from '@craft-ts/effect'; export const Profile = craftComponent( 'EffectQuickstartProfile', {}, function* () { const profile = yield* queryEffect('profile', { params: () => 'user-ada', loader: ({ params }) => loadUser(params), }); return { profile }; }, ({ profile }) => [ p(function* () { return (yield* profile.value())?.name ?? 'Loading…'; }), ], ); ``` Do not call `Effect.runPromise` or subscribe inside the component. The resource owns execution, cancellation and the transition between loading, success and failure. ### 4. Provide the Layer and install the bridge — 3 minutes Install the bridge once at application bootstrap. Provide the Effect Layer at the same Craft scope where the operation is used. ```ts import { provideCraftRootComponent, bootstrapCraft } from '@craft-ts/component'; import { craftAppConfig, provideAppInitializer } from '@craft-ts/core'; export const appConfig = craftAppConfig({ providers: [ provideCraftRootComponent(Profile), provideLayer(UserRepositoryLive), provideAppInitializer(() => { installCraftEffectBridge(); }), ], }); export const start = () => bootstrapCraft({ config: appConfig }); ``` Run the application with your normal frontend command. The executable version of this example is also covered by the docs test suite. ### 5. Verify the boundary — 2 minutes ```shell npx nx test docs npx nx typecheck demo-effect npx nx test demo-effect ``` The docs test target now performs three checks: it transpiles every TypeScript or TSX code fence in `learn-effect`, type-checks the complete snippets under `tests/snippets/learn-effect`, and executes their Vitest tests. The transpilation check is intentionally syntax-focused because several excerpts are meant to be copied into an existing Craft or Effect generator; complete examples receive the stronger typecheck and runtime coverage. The Effect demo covers success, typed business errors, defects, application Layers and route-scoped Layers. For a runnable starter that keeps this boundary intentionally small, use the repository's [`quickstart-effect`](https://github.com/craft-ts/craft-ts/tree/main/apps/quickstart-effect) application. It is wired into the same ESLint, EffectTS diagnostics and architecture checks that a new Effect frontend should adopt. ## Which adapter should I choose? | Situation | Adapter | | --- | --- | | Local toggle, draft or selection | `state` | | Server or domain read | `queryEffect` | | Explicit write | `mutationEffect` | | Synchronous business calculation from an Effect service | `computedEffect` | | Export, refresh or other explicit command | `asyncProcessEffect` | | One Effect in a guard or resolver | `runEffect` | | URL filters and pagination state | native Craft `queryParams` | There is intentionally no `stateEffect`: local UI state belongs to Craft; an Effect is introduced when a computation, I/O operation or service dependency crosses into the UI. ## Continue from here * Read the [full Effect learning path](/learn-effect/). * Check [compatibility and maturity](/resources/effect-compatibility). * Follow the [progressive adoption plan](/resources/effect-adoption). * For the detailed API contract, read [Using Effect with CraftTS](/guide/advanced/effect). --- --- url: https://craft-ts.github.io/craft/learn-effect.md --- # Learn CraftTS with Effect This is the guided path for teams that already use [Effect](https://effect.website/) and want CraftTS to own the UI, reactivity and application graph. If you are evaluating CraftTS from an existing Effect codebase, start with [Effect users: start here](/learn-effect/00-start-here). It explains what stays in Effect, what moves to Craft's UI model, and how to try the integration in fifteen minutes. You start with a Craft component, then move the domain work into Effect programs: `Layer` provides services, `Effect` carries success, typed failures and requirements, and Craft adapters expose those programs as reactive resources. ## What you will build | Step | What you add | | --- | --- | | [0. Effect users: start here](/learn-effect/00-start-here) | boundary, quickstart and adapter choice | | [1. Start with a Craft component](/learn-effect/01-first-component) | `craftComponent`, templates, native Craft state | | [2. Derive UI state](/learn-effect/02-derive) | `craftComputed`, `yield*`, precise dependencies | | [3. Put the domain in Effect](/learn-effect/03-effect-domain) | `Effect`, tagged errors, `Context.Service`, `Layer`, `SyncOp` | | [4. Load data with Effect](/learn-effect/04-load-data) | `queryEffect`, typed errors and defects | | [5. Write data with Effect](/learn-effect/05-write-data) | `mutationEffect`, `asyncProcessEffect`, reactive updates | | [6. Provide Layers and route the app](/learn-effect/06-layers-routing) | app/route Layers, DI proofs, type-safe routes | | [7. Build forms and validate boundaries](/learn-effect/07-forms-validation) | Effect Schema, forms, typed submit errors | | [8. Test the graph](/learn-effect/08-testing) | Effect service mocks, Craft registers, architecture tests | | [9. Call server functions — POC](/learn-effect/09-server-functions) | client/server boundary, `serverFunction`, `executeEffect` | ## Before you start You need a TypeScript application, Node.js 20.19+ (or 22.12+), and basic knowledge of generators. The guide uses Effect 4 RC and the beta CraftTS packages: ```shell npm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta npm i effect@rc npm i -D @craft-ts/dev-tools@beta ``` Keep `@craft-ts/core`, `@craft-ts/component` and `@craft-ts/effect` on the same CraftTS version. `@craft-ts/effect` has `effect` as a peer dependency. ::: warning Experimental APIs CraftTS and this Effect integration are still experimental. The Effect bridge, the server-function API and their types can change between beta releases. The server-function chapter is deliberately labelled **proof of concept**: use it to explore the model, not as a final production contract. ::: ::: tip The central rule Craft owns the reactive boundary. Effect owns domain programs and their dependencies. Do not create a `stateEffect`: use native Craft `state` for UI state, and use `queryEffect`, `mutationEffect` or `asyncProcessEffect` when an Effect program crosses into a Craft resource. ::: [Start → Craft component](/learn-effect/01-first-component) --- --- url: https://craft-ts.github.io/craft/learn-effect/01-first-component.md --- # 1. Start with a Craft component **Goal:** render a reactive task list before introducing Effect. Effect users do not need to replace their domain model or Effect programs. They do need to adopt Craft's UI model: a component is a function with a generator logic factory and a typed template: ```typescript import { craftComponent, div, h1, li, ul, forNode } from '@craft-ts/component'; import { state } from '@craft-ts/core'; type Task = { readonly id: string; readonly title: string; readonly done: boolean }; export const Tasks = craftComponent( 'Tasks', // name: stable component name used by tooling and the graph {}, // meta: providers, styles and host configuration function* () { // logic factory: creates the component context const tasks = yield* state('tasks', [ // name: state identifier { id: '1', title: 'Learn Craft components', done: true }, { id: '2', title: 'Add the first Effect program', done: false }, ] satisfies Task[]); // initial value: the seeded task list return { tasks }; }, ({ tasks }) => [ // template: turns the context into rendered nodes h1('Tasks'), ul( forNode( tasks, // source: the reactive collection to render { track: (task) => task.id }, // options: stable identity for each item (task) => li(task.title), // render: creates one node per task ), ), ], ); ``` There is no class, decorator, selector or separate HTML file. The template is typed hyperscript. A component has four responsibilities: | Argument | Responsibility | | --- | --- | | `'Tasks'` | stable name used by tooling and the graph | | `{}` | providers, styles and host configuration | | `function*` | create the component context and yield dependencies | | template | turn that context into nodes | `tasks` is a Craft reader. Yield it when a generator reads it; pass it directly to a template binding. The renderer tracks the exact binding that reads it. ## Bootstrap once The root component is provided in the app config and mounted by `bootstrapCraft`: ```typescript // app.config.ts import { provideCraftRootComponent } from '@craft-ts/component'; import { craftAppConfig } from '@craft-ts/core'; import { App } from './app'; export const appConfig = craftAppConfig({ providers: [provideCraftRootComponent(App)], }); ``` ```typescript // main.ts import { bootstrapCraft } from '@craft-ts/component'; import { appConfig } from './app/app.config'; bootstrapCraft({ config: appConfig }); ``` ## Where Effect fits Craft owns reactive UI state and rendering. Effect owns domain operations — work that may fail with typed errors or depend on services. In the next step, we will connect an Effect program to Craft so the component can render its result without managing subscriptions or fibers. ## What you gained A selectorless, typed component with fine-grained rendering. The next step adds derived UI state without duplicating data. [← Overview](/learn-effect/) [2. Derive UI state →](/learn-effect/02-derive) --- --- url: https://craft-ts.github.io/craft/learn-effect/02-derive.md --- # 2. Derive UI state **Goal:** calculate UI state from the source of truth, and understand the `yield*` rule that Craft and Effect share. Use `computedEffect` for synchronous Effect-backed derivations. Its factory is a generator when it reads a Craft value and returns the Effect to run: ```typescript import { Effect } from 'effect'; import { state } from '@craft-ts/core'; import { computedEffect } from '@craft-ts/effect'; const tasks = yield* state('tasks', [] as Task[]); const remaining = computedEffect('remaining', function* () { const currentTasks = yield* tasks(); return Effect.succeed( currentTasks.filter((task) => !task.done).length, ); }); ``` The template can bind `remaining` directly. `computedEffect` runs the returned synchronous Effect in place, and Craft re-runs only the binding that depends on it. ## The shared dependency vocabulary Both runtimes use generators, but they solve different problems: ```typescript const tasks = yield* TaskList(); // Craft service const access = yield* AccessPolicyService; // Effect service inside an Effect const value = yield* resource.value(); // Craft reader inside a derivation ``` The rule is the same: yield what the current function does not own. A Craft factory yields Craft dependencies; an Effect program yields Effect dependencies. The adapter connects the two at a deliberate boundary. ## Do not duplicate domain state in the component The component should not subscribe to an Effect, convert an Effect to a signal by hand, or start a fiber in a template callback. Those approaches hide loading, cancellation and failure state from Craft. Instead: 1. Keep the domain operation as `Effect`. 2. Expose it through a Craft Effect-aware primitive. 3. Derive the display state from the resulting Craft resource. The next step defines the domain operation and the services it requires. ## What you gained Derived state that stays reactive and a clear division: Craft derives the UI; Effect composes the domain program. [← 1. Start with a Craft component](/learn-effect/01-first-component) [3. Put the domain in Effect →](/learn-effect/03-effect-domain) --- --- url: https://craft-ts.github.io/craft/learn-effect/03-effect-domain.md --- # 3. Put the domain in Effect **Goal:** define typed business failures and services without making the Craft component know how they are provided. ## Typed failures are values Effect's tagged errors map naturally to Craft's exception channel: ```typescript import { Context, Data, Effect } from 'effect'; export class UserNotFound extends Data.TaggedError('UserNotFound')<{ readonly userId: string; }> {} export class Unauthorized extends Data.TaggedError('Unauthorized')<{ readonly reason: string; }> {} type User = { readonly id: string; }; type UserRepository = { readonly find: (userId: string) => Effect.Effect; }; export class UserRepositoryService extends Context.Service< UserRepositoryService, UserRepository >()('app/UserRepository') {} export const loadUser = Effect.fnUntraced(function* (userId: string) { const repository = yield* UserRepositoryService; const user = yield* repository.find(userId); if (!user) return yield* new UserNotFound({ userId }); return user; }); ``` The program has the shape `Effect`. `yield* UserRepositoryService` gets the repository from the Effect context; `yield* repository.find(userId)` then runs the `Effect` returned by its method. `UserNotFound` is a business outcome that the UI can handle. An unexpected defect raised by `Effect.die` remains a technical error; it is not turned into a business exception. `Data.TaggedError` creates a **yieldable error** in Effect v4, so this is the idiomatic form inside `Effect.gen`: ```typescript if (!user) return yield * new UserNotFound({ userId }); ``` The explicit equivalent is `yield* Effect.fail(new UserNotFound({ userId }))`; there is no `Effect.failed` constructor. At the Craft boundary, yield the effect through `runEffect(...)` instead of yielding the error instance directly. ## Define an Effect service Use `Context.Service` for the contract and a `Layer` for the implementation: ```typescript import { Context, Effect, Layer } from 'effect'; type AccessPolicy = { readonly decide: ( userId: string, ) => Effect.Effect; }; export class AccessPolicyService extends Context.Service< AccessPolicyService, AccessPolicy >()('app/AccessPolicyService') {} export const AccessPolicyLive = Layer.sync(AccessPolicyService)(() => ({ decide: (userId) => findAccessDecision(userId), })); export const checkUserAccess = Effect.fnUntraced(function* (userId: string) { const policy = yield* AccessPolicyService; return yield* policy.decide(userId); }); ``` The component calls `checkUserAccess`; it does not call `AccessPolicyService` and does not know which Layer implements it. When a Craft factory genuinely needs a service member, narrow it explicitly with `effectService` rather than resolving an untracked value: ```typescript import { effectService } from '@craft-ts/effect'; const { decide } = yield * effectService(AccessPolicyService, ({ decide }) => ({ decide })); ``` Prefer exposing a domain operation such as `checkUserAccess` to a component. The selector form is useful for a Craft service or adapter that deliberately owns the boundary and wants the graph to record only the members it uses. ## Derive Craft state from the Effect service `craftComputed` stays synchronous: it derives a Craft reader. Let `queryEffect` execute the Effect operation, then derive a display value from the query resource: ```typescript import { craftComputed } from '@craft-ts/core'; import { queryEffect } from '@craft-ts/effect'; const accessQuery = yield * queryEffect('accessQuery', { params: () => 'user-ada', loader: ({ params }) => checkUserAccess(params), }); const accessLabel = craftComputed('accessLabel', function* () { return (yield* accessQuery.value())?.label ?? 'Loading…'; }); ``` The chain is: `queryEffect` runs `checkUserAccess`, the active `Layer` provides `AccessPolicyService`, and `accessLabel` reacts to the query's Craft value. The computed does not call the Effect service or start an Effect itself. ## Declare a synchronous member That last sentence used to be a hard rule: no Effect at all inside `params`, `craftComputed(...)` or `craftMethod(...)`. Those run on Craft's **synchronous** driver, which completes on one tick and cannot wait — and `Effect` does not say whether running it will suspend. It is worse than it looks for a service member. A `Layer` closes over the member's dependencies when it builds the service, so a member that calls the network and a member that adds two numbers *both* surface as `R = never`: ```ts import { Context, Effect, Layer } from 'effect'; import { SyncOp } from '@craft-ts/effect'; export type CartLine = { readonly sku: string; readonly qty: number; readonly unitCents: number; }; export type CartPricingShape = { // Asynchronous: `R` is `never` and it still goes to the network — the Layer // closed over the transport at construction. Nothing in the type says so. readonly fetchCatalog: ( skus: readonly string[], ) => Effect.Effect>; // Synchronous: `SyncOp` in `R` is the whole difference. readonly lineTotal: (line: CartLine) => Effect.Effect; readonly formatPrice: (cents: number) => Effect.Effect; }; export class CartPricing extends Context.Service< CartPricing, CartPricingShape >()('learn-effect/CartPricing') {} export const CartPricingLive = Layer.sync(CartPricing)(() => ({ fetchCatalog: Effect.fnUntraced(function* (skus: readonly string[]) { yield* Effect.sleep('50 millis'); return new Map(skus.map((sku) => [sku, 1_000])); }), // The shape already declares these synchronous, so the implementations need // no ceremony: Effect is assignable to Effect. lineTotal: (line) => Effect.succeed(line.qty * line.unitCents), formatPrice: (cents) => Effect.succeed(`${(cents / 100).toFixed(2)} €`), })); ``` The information does not exist in the type, so you write it there. `SyncOp` is a phantom requirement — never provided, no runtime cost — and `R` is the one channel Effect accumulates across composition. An Effect that requires `SyncOp` is one its author declares never suspends. Requirements union through `Effect.gen`, so the declaration propagates on its own. A standalone program that only calls declared-synchronous members inherits the marker; one that calls nothing marked spells it out with `yield* SyncOp`: ```ts /** * `R` is inferred here: `CartPricing` from the tag, `SyncOp` from the members. * Composition propagates the declaration — nothing to maintain by hand. */ export const cartTotalLabel = Effect.fnUntraced(function* ( lines: readonly CartLine[], ) { const pricing = yield* CartPricing; let cents = 0; for (const line of lines) { cents += yield* pricing.lineTotal(line); } return yield* pricing.formatPrice(cents); }); /** No marked call to inherit from, so the marker is spelled out. */ export const cartWeight = Effect.fnUntraced(function* ( lines: readonly CartLine[], ) { yield* SyncOp; return lines.reduce((total, line) => total + line.qty * 250, 0); }); ``` `CartPricing` in `R` is not a problem: the level in force satisfies it, exactly as it does for a loader. The only thing checked is that `SyncOp` is among the requirements. ## Use it: computedEffect, then syncEffect For a derived value, reach for `computedEffect` — the Effect counterpart of `craftComputed`. The factory reads Craft dependencies and **returns** the Effect; the adapter runs it in place, so you get a plain reactive value: ```ts import { craftComponent, p } from '@craft-ts/component'; import { state } from '@craft-ts/core'; import { computedEffect } from '@craft-ts/effect'; export const CartTotal = craftComponent( 'LearnEffectCartTotal', {}, function* () { const lines = yield* state('lines', [ { sku: 'sku-1', qty: 2, unitCents: 1_000 }, { sku: 'sku-2', qty: 1, unitCents: 1_000 }, ] as CartLine[]); // The factory RETURNS the Effect; `computedEffect` runs it in place. const totalLabel = computedEffect('totalLabel', function* () { return cartTotalLabel(yield* lines()); }); return { totalLabel }; }, ({ totalLabel }) => [p(totalLabel)], ); ``` Anything nobody declared synchronous is refused at the call, before anything runs: ```ts const catalogProgram = Effect.gen(function* () { const pricing = yield* CartPricing; return yield* pricing.fetchCatalog(['sku-1']); }); export function cartSummary() { // ✅ declared synchronous — `SyncOp` is in its requirements. const weightLabel = computedEffect('weightLabel', () => cartWeight([])); // ❌ `fetchCatalog` suspends and nobody declared otherwise: a computation // cannot run it. Use queryEffect. const catalogLabel = computedEffect( 'catalogLabel', // @ts-expect-error NotDeclaredSynchronous () => catalogProgram, ); return { weightLabel, catalogLabel }; } ``` For a synchronous Effect exposed as a callable method, use `methodEffect`, the Effect counterpart of `craftMethod`. For lower-level positions such as a `params` factory or a `state` updater, `syncEffect(...)` is the same door, opened by hand: ```typescript queryEffect('shippingQuote', { params: function* () { return yield* syncEffect(cartWeightGrams(yield* lines())); }, loader: ({ params }) => quoteShipping(params), }); ``` The relationship mirrors the asynchronous side: `computedEffect` is to `methodEffect` what `queryEffect` is to `asyncProcessEffect` — a value or method with no resource lifecycle. `syncEffect` remains the lower-level escape hatch. ::: tip Three lines of defence `SyncOp` is a claim, not a proof — nothing stops a body from declaring itself synchronous and awaiting anyway. Three mechanisms check it, and none is redundant: 1. **the type** — `computedEffect` and `syncEffect(...)` refuse an Effect nobody declared, at the call site; 2. **`craft-ts/sync-effect-body`** — reads the body, every branch at once, and rejects a declared-synchronous body that yields something async. A unit test cannot do this: it only proves the inputs it was given; 3. **the runtime** — both run through `Effect.runSyncExitWith`, which cannot suspend. A broken declaration throws `CraftEffectNotSynchronous` immediately, at the first call, instead of freezing the UI. ::: Keep asynchronous work where it belongs: a loader. `SyncOp` opens one narrow, explicit door for business calculations, not a way around the adapters. ## Run a standalone Effect For a low-level bridge, `runEffect` lets a Craft generator yield an Effect while preserving its typed error channel: ```typescript import { Effect } from 'effect'; import { runEffect } from '@craft-ts/effect'; const name = yield * runEffect(Effect.succeed('Ada')); ``` Use the adapters in the next chapters for application data. They resolve the Effect requirement `R` through the nearest `provideLayer(...)` and keep loading, value and exception state in the Craft resource. ## Install the bridge once The bridge teaches Craft how to execute a yielded Effect. Install it during app bootstrap, not in every loader: ```typescript import { provideAppInitializer } from '@craft-ts/core'; import { installCraftEffectBridge } from '@craft-ts/effect'; export const appConfig = craftAppConfig({ providers: [ provideAppInitializer(() => { installCraftEffectBridge(); }), ], }); ``` In tests, call `installCraftEffectBridge()` in `beforeEach` and dispose the returned function in `afterEach`. ## What you gained An Effect domain with typed failures, explicit service requirements and swappable Layers. The next step puts that program behind a reactive `queryEffect`. [← 2. Derive UI state](/learn-effect/02-derive) [4. Load data with Effect →](/learn-effect/04-load-data) --- --- url: https://craft-ts.github.io/craft/learn-effect/04-load-data.md --- # 4. Load data with Effect **Goal:** expose an `Effect` as a Craft query. ## The operation being loaded `queryEffect` receives a domain function that returns an Effect. Here is the `loadUserProfile` used by the query below; the data is mocked so the example can show each result channel: ```typescript // profile-domain.ts import { Data, Effect } from 'effect'; export type ProfileScenario = | 'success' | 'not-found' | 'session-expired' | 'database-down'; type Profile = { readonly name: string }; export class UserNotFound extends Data.TaggedError('UserNotFound')<{ readonly userId: string; }> {} export class Unauthorized extends Data.TaggedError('Unauthorized')<{ readonly reason: string; }> {} export const loadUserProfile = Effect.fnUntraced(function* ( scenario: ProfileScenario, ) { // Simulate the latency of a backend request. yield* Effect.sleep('400 millis'); switch (scenario) { case 'not-found': return yield* new UserNotFound({ userId: 'user-404' }); case 'session-expired': return yield* new Unauthorized({ reason: 'session expired' }); case 'database-down': return yield* Effect.die(new Error('database unavailable')); case 'success': return { name: 'Ada Lovelace' } satisfies Profile; } }); ``` `loadUserProfile` does not run when it is declared. It returns an `Effect`, which represents a backend request and which the query runs whenever its parameters trigger the loader. ## `queryEffect` The adapter has the same lifecycle as `query`, but its loader returns an Effect: ```typescript import { type Input, craftComponent, ifNode, matchNode, p, } from '@craft-ts/component'; import { craftComputed } from '@craft-ts/core'; import { queryEffect } from '@craft-ts/effect'; import { loadUserProfile, type ProfileScenario } from './profile-domain'; const Profile = craftComponent( 'Profile', {}, function* (profileScenarioInput: Input) { const profile = yield* queryEffect( 'profile', { params: profileScenarioInput, loader: ({ params }) => loadUserProfile(params), }, ({ resource, exceptions }) => ({ hasProfile: craftComputed('hasProfile', () => resource.hasValue()), currentError: craftComputed('currentError', function* () { return (yield* exceptions()).loader; }), }), ); return { profile }; }, ({ profile }) => [ ifNode(profile.isLoading, () => p('Loading…')), /* bind profile.value() or match profile.exceptions().loader here */ ], ); ``` `queryEffect` is a Craft query with an Effect loader. It owns cancellation, loading state, the last value and typed exceptions. Its `Effect` requirements are resolved by the active Layer. Here, `profileScenarioInput` is the reactive input source: changing it reruns `loadUserProfile`; there is no `method` or manual `profile.call(...)` because the input drives the query. ## The three result channels | Effect outcome | Craft outcome | | -------------------------- | -------------------------------------------------- | | `Effect.succeed(value)` | query value; the generator resumes with `value` | | typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | | `Effect.die(defect)` | technical resource error, not a business exception | Interruption is cancellation. It does not become a user-facing exception. Handle typed errors exhaustively with `matchNode.exhaustive` or with a route exception handler: ```typescript matchNode.exhaustive(profile.exception, '_tag', { UserNotFound: () => p('No profile matches that user.'), Unauthorized: () => p('Your session has expired.'), }); ``` When the Effect is used in a route guard or resolver directly, prefer `yield* runEffect(program)`. A bare `yield* program` executes at runtime but does not advertise `E` to Craft's compile-time route exception analysis. ## Reactive Effect computations When the derived value comes from an Effect that **cannot suspend**, use `computedEffect`. It is the Effect counterpart of `craftComputed`, and the symmetry is the contract: ``` craftComputed : computedEffect :: query : queryEffect ``` ```typescript import { computedEffect } from '@craft-ts/effect'; const totalLabel = computedEffect('totalLabel', function* () { const lines = yield* cartLines(); return cartTotalLabel(lines); // returns the Effect, never runs it }); ``` The factory reads Craft dependencies with `yield*` and **returns** an Effect; `computedEffect` runs it in place against the nearest `provideLayer(...)`. The result is a plain reactive value — no `value`, no `isLoading`, no `settled(...)`, no `pendingNode`. Read it like any `craftComputed`. Which is why the Effect must be declared synchronous with [`SyncOp`](/learn-effect/03-effect-domain#declare-a-synchronous-member). A computation is asked for its value now and cannot suspend to produce it, so an Effect whose `R` does not carry `SyncOp` is refused at the call site: ```typescript computedEffect('profile', function* () { const userId = yield* currentUserId(); return loadUserProfile(userId); // ✗ hits the network // ^ Argument of type 'Effect' is not // assignable to '… & NotDeclaredSynchronous' }); ``` That is not a gap: the suspending case is what `queryEffect` is for. A typed failure remains fine — failing is not suspending, and it travels on Craft's exception channel. ## Synchronous params and methods The `params` factory remains synchronous: it may read Craft dependencies, and it may run a declared-synchronous Effect through `syncEffect(...)`, but it must never construct a suspending Effect — move that to the loader. A `method` only maps its arguments to params; the loader is the only callback allowed to suspend: ```typescript const profile = yield * queryEffect('profile', { params: function* () { const input = yield* currentUserInput(); return resolveProfileParams(input); }, loader: ({ params }) => loadUserProfile(params), }); const profileByMethod = yield * queryEffect('profileByMethod', { method: (input: UserInput) => resolveProfileParams(input), loader: ({ params }) => loadUserProfile(params), }); ``` The Effect ESLint rule rejects Effect values and Effect service reads inside `params`, methods, `craftComputed(...)`, and `craftEffect(...)`, keeping the query boundary synchronous and deterministic. Only the loader may return an Effect. For purely synchronous local state, use native Craft values and `state`; there is intentionally no `stateEffect`: ```typescript const request = yield * state('request', 'support'); ``` Use Effect for computations, I/O and service dependencies. ## What you gained Effect's typed result becomes a reactive Craft resource without a manual subscription or signal conversion. [← 3. Put the domain in Effect](/learn-effect/03-effect-domain) [5. Write data with Effect →](/learn-effect/05-write-data) --- --- url: https://craft-ts.github.io/craft/learn-effect/05-write-data.md --- # 5. Write data with Effect **Goal:** model writes and explicit processes as Effects while retaining Craft's mutation lifecycle. ## `mutationEffect` Use `method` for the synchronous argument-to-params mapping and `loader` for the Effect program: ```typescript const saveTask = yield* mutationEffect('saveTask', { method: (input: { readonly title: string }) => input, loader: ({ params }) => saveTaskEffect(params), }); yield* saveTask.mutate({ title: 'Ship the Effect guide' }); ``` The mutation exposes loading, value and typed `exceptions().loader` just like a native Craft mutation. Compose it with the list query using the normal Craft insertion: ```typescript const tasksQuery = yield* queryEffect( 'tasks', { params: () => ({ done: false }), loader: ({ params }) => listTasksEffect(params), }, insertReactOnMutation(saveTask, { reload: { onMutationSuccess: true }, }), ); ``` Use an optimistic insertion when the result can be predicted locally: ```typescript const tasksQuery = yield* queryEffect( 'tasks', { params: () => ({ done: false }), loader: ({ params }) => listTasksEffect(params), }, insertReactOnMutation(saveTask, { optimisticPatch: { title: ({ mutationParams }) => mutationParams.title, }, reload: { onMutationException: true }, }), ); ``` Effect remains responsible for the operation and its typed errors; Craft remains responsible for when the resource is refreshed and what the UI renders. ## `asyncProcessEffect` Use `asyncProcessEffect` for an explicit process that is not a read cache or a write resource: ```typescript const refresh = yield* asyncProcessEffect('refresh', { method: (userId: string) => userId, loader: ({ params }) => refreshProfile(params), }); yield* refresh.method('user-ada'); ``` This is useful for refresh actions, exports, background commands and similar flows. Do not turn every Effect into an async process: choose `queryEffect` for server state, `mutationEffect` for writes and `asyncProcessEffect` for explicit commands. ## Validate arguments before the Effect Effect Schema can be used anywhere Craft accepts a Standard Schema. Convert it once: ```typescript import { Schema } from 'effect'; const SaveTask = Schema.toStandardSchemaV1( Schema.Struct({ title: Schema.String }), ); const saveTask = yield* mutationEffect('saveTask', { methodSchema: SaveTask, method: (input) => input, loader: ({ params }) => saveTaskEffect(params), }); ``` For a schema failure, Craft reports a parse exception. For a business rule such as “this title already exists”, return a tagged Effect error from the Effect program instead. See [Effect Schema](/guide/state/schema-validation#effect-schema) for synchronous and asynchronous decoding rules. ## What you gained Typed Effect writes with Craft's loading, cancellation, cache invalidation and optimistic-update machinery. Next, provide the services that those programs require at app and route scope. [← 4. Load data with Effect](/learn-effect/04-load-data) [6. Provide Layers and route the app →](/learn-effect/06-layers-routing) --- --- url: https://craft-ts.github.io/craft/learn-effect/06-layers-routing.md --- # 6. Provide Layers and route the app **Goal:** make Effect requirements explicit at the same scopes as your Craft injectors. ## Provide one application Layer `provideLayer` builds an Effect context and stores it on the Craft injector: ```typescript import { Layer } from 'effect'; import { provideLayer } from '@craft-ts/effect'; export const appConfig = craftAppConfig({ providers: [ provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive)), ], }); ``` The Layer is built once at that injector level. Child injectors reuse the parent context and add their own services. ## Add a route Layer Inline route providers in `loadCraftComponent(...)`; the compile-time proof preserves and inspects the tuple from the typed route collection: ```typescript const routes = craftRoutes('app', [ { path: 'team', ...loadCraftComponent( () => import('./team').then(({ default: component }) => component), [provideLayer(SupportTeamLive)] as const, ), }, ]); ``` The team query can require `SessionService | TeamContextService` while the component only sees `TeamOverview`. Route-scoped resources are closed when the route injector is destroyed, so Layer scopes do not leak across navigation. ## Layer scopes follow Craft provider scopes `provideLayer(...)` is a normal Craft provider, so the same Effect context can be attached at every Craft scope that accepts providers: | Scope | Where to put `provideLayer(...)` | Lifetime and visibility | | --- | --- | --- | | Application | `appConfig.providers` | shared by the whole application | | Route | the route's `providers` array | shared by that route and its children | | Component | `craftComponent` meta `providers` | limited to that component subtree | | Primitive | a primitive config's `providers` | limited to that primitive | | Insertion | the containing primitive's `providers` | inherited by its insertion callbacks and methods | For example, a component or a primitive can provide a local implementation without changing the application Layer: ```typescript const Profile = craftComponent( 'Profile', { providers: [provideLayer(AccessPolicyLive)] }, /* … */ ); const profile = yield* queryEffect('profile', { providers: [provideLayer(AccessPolicyLive)], params: () => 'user-ada', loader: ({ params }) => checkUserAccess(params), }); ``` An insertion receives the primitive's injector, so its generators and methods see the primitive's Layer as well. There is no separate `provideLayer` argument on an insertion today; use the containing primitive's `providers` to scope it. If two services must be provided at the same scope, merge them into one Layer: ```typescript providers: [provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive))] ``` Child scopes inherit the parent context and can add a more local implementation of a service. Their scopes are closed with the corresponding Craft injector. ## Prove requirements at compile time Effect requirements are not regular Craft services, so add an explicit proof: ```typescript import type { Effect } from 'effect'; import type { AppProvidedDependencyValuesOf, CanRun } from '@craft-ts/core'; import type { EffectRequirementsCheckedDI, ProvidedEffectServicesOfRoute, } from '@craft-ts/effect'; type AppProvidedEffectServices = AppProvidedDependencyValuesOf< typeof appConfig >; type CheckTeam = EffectRequirementsCheckedDI< Effect.Services, AppProvidedEffectServices | ProvidedEffectServicesOfRoute >; type CanRunTeam = CanRun; ``` Remove `SupportTeamLive` and `CanRunTeam` becomes a useful type error naming the missing Effect service. This is the Effect equivalent of Craft's `RouteCheckedDI`. ## Route errors are still exhaustive `queryEffect` and `runEffect` make tagged Effect errors visible to Craft's route exception analysis. Keep the route map exhaustive: ```typescript const { routes } = craftRoutes('app', [ { path: '', ...loadCraftComponent(() => import('./profile')), handleExceptions: { UserNotFound: craftExceptionHandler(/* … */), Unauthorized: craftExceptionHandler(/* … */), }, }, ]); assertExhaustiveRouteExceptions(routes); ``` ## Keep URL state in Craft URL state is a UI concern, so it stays a native `queryParams` primitive even in an Effect application: ```typescript import { Schema } from 'effect'; const Search = Schema.String; const searchCodec = { decode: Schema.decodeUnknownSync(Search), encode: Schema.encodeSync(Search), }; const filters = yield* queryParams('filters', { state: { search: { fallbackValue: '', codec: searchCodec, }, }, }); const users = yield* queryEffect('users', { params: () => filters(), loader: ({ params }) => searchUsers(params), }); ``` The codec remains synchronous, as required by `queryParams`, but validation and encoding now come from an Effect `Schema`. Replace `Schema.String` with a transformation schema when the URL representation differs from the value used by the component. There is no `queryParamsEffect`: Craft synchronises the URL, while the Effect loader reacts to the resulting typed params. ## What you gained Effect Layers now follow Craft's app and route scopes, their requirements are checked, and typed Effect failures cannot silently disappear at a route. [← 5. Write data with Effect](/learn-effect/05-write-data) [7. Build forms and validate boundaries →](/learn-effect/07-forms-validation) --- --- url: https://craft-ts.github.io/craft/learn-effect/07-forms-validation.md --- # 7. Build forms and validate boundaries **Goal:** use Craft forms for interaction and Effect Schema for data that crosses a boundary. ## Forms remain Craft state A form derives from the state it edits. Use `insertForm`, validators and `insertFormSubmit` exactly as in the regular Craft path: ```typescript import { Schema } from 'effect'; const CreateTaskInput = Schema.toStandardSchemaV1( Schema.Struct({ title: Schema.String, description: Schema.String, }), ); const createTask = yield* mutationEffect('createTask', { methodSchema: CreateTaskInput, method: (input) => input, loader: ({ params }) => createTaskEffect(params), }); const draft = yield* state( 'draft', { title: '', description: '' }, insertForm( insertFormSchema(CreateTaskInput), insertFormSubmit(createTask), ), ); const form = draft.form; ``` The exact field insertions depend on the shape of your component, but the ownership rule does not change: form values and validity are Craft state; the submit operation is an Effect-backed mutation. ## For more advanced form validation This Effect example is enough when the main concern is validating the payload at the boundary. For richer form behaviour — field-level rules, conditional validators, cross-field validation, nested forms or custom typed exceptions — use Craft's dedicated form API with `insertFormAttributes`, `cValidate` and `insertSelectFormTree`. See the [Forms guide](/guide/forms/) for this alternative. Effect Schema can still be kept on `methodSchema` to validate the final payload before the Effect runs. ## Effect Schema at the boundary Effect Schema is not passed directly to Craft. Convert it to Standard Schema: ```typescript import { Schema } from 'effect'; const CreateTaskInput = Schema.toStandardSchemaV1( Schema.Struct({ title: Schema.String, description: Schema.String, }), ); ``` Use it for `methodSchema`, `paramsSchema` or `loaderSchema`. The decoded output is what the rest of the application sees. A synchronous schema is safe for method arguments and local writes; asynchronous decoding belongs in `loaderSchema` or in the Effect loader itself. ## Submit failures Validation failures are parse exceptions. Domain failures stay in Effect's typed error channel and become `exceptions().loader` on the mutation. The form can therefore distinguish: * invalid input, before the Effect runs; * a business rejection returned by the server/domain; * an unexpected defect that should reach the technical error boundary. Do not put navigation, toasts or state writes inside a computed exception list. Drive those actions after `submit()` or from an explicit process. ## What you gained Craft owns the interaction model and Effect owns the domain validation or write; their error channels remain distinct and typed. [← 6. Provide Layers and route the app](/learn-effect/06-layers-routing) [8. Test the graph →](/learn-effect/08-testing) --- --- url: https://craft-ts.github.io/craft/learn-effect/08-testing.md --- # 8. Test the graph **Goal:** test Effect programs, their Layers and the Craft boundary without mocking the whole application. ## Test an Effect service with a partial mock `mockEffectService` provides a Layer and makes every unstubbed member fail loudly if the test accidentally uses it: ```typescript import { Effect } from 'effect'; import { mockEffectService } from '@craft-ts/effect'; const register = { AccessPolicyService: mockEffectService(AccessPolicyService, { decide: () => Effect.succeed(expectedDecision), }), }; ``` For production-like tests, provide the real Layer. For focused tests, stub only the selected members and let `UnstubbedEffectMember` expose an unexpected read. ## Test the Craft service or component by register Craft tests still use a register derived from the Craft dependency graph: ```typescript const { sut } = await setupCraftServiceTestingByRegister( AccessDecisionService, { AccessPolicyService: mockEffectService(AccessPolicyService, { decide: () => Effect.succeed(expectedDecision), }), }, ); ``` The exact register also includes regular Craft services, `'real'`, `'notReached'` or `provideX()` entries when those nodes are reachable. Effect service mocks cover the Effect side; the Craft register proves the full graph is accounted for. ## Test the bridge and adapters Install and dispose the bridge per test suite: ```typescript let dispose: () => void; beforeEach(() => { dispose = installCraftEffectBridge(); }); afterEach(() => { dispose(); }); ``` Cover at least one example of each channel: a typed failure becomes a Craft exception, `Effect.die` rejects as a technical error, and aborting the owning resource interrupts the Effect. ## Architecture checks The static graph includes the Effect backend automatically when `analyzeDependencyGraph` runs. It exposes typed nodes for `effect-service`, `effect-operation` and `effect-layer`, together with `requires-service`, `provided-by-layer` and `composes-layer` relations. You can therefore write rules against Effect concepts just as you do against Craft nodes: ```typescript const effectServices = graph.nodes('effect-service'); const effectOperations = graph.nodes('effect-operation'); const effectLayers = graph.nodes('effect-layer'); const serviceRequirements = graph.edges('requires-service'); ``` The built-in checks can then be kept beside the app's architecture tests: ```typescript assertCraftEffectNoNetwork(graph.graph); assertCraftEffectNoImperativeSync(graph.graph); assertDeclarativeArchitecture(graph.graph); ``` For a project-specific invariant, inspect these typed nodes and relations in a custom assertion and fail the architecture test when the rule is violated. If you need to add concepts that the built-in Effect backend does not model, use the [`DependencyGraphNodeRegistry` and `DependencyGraphCollector`](/guide/testing/extensible-architecture-graph). The Effect graph is already collected; no extra collector is needed just to apply rules to its services, operations or Layers. ## What you gained Tests that mirror the real Craft and Effect graphs: real composition by default, narrow mocks at the boundary, and architecture rules for the invariants that types alone cannot keep armed. [← 7. Build forms and validate boundaries](/learn-effect/07-forms-validation) [9. Call server functions — POC →](/learn-effect/09-server-functions) --- --- url: https://craft-ts.github.io/craft/learn-effect/09-server-functions.md --- # 9. Call server functions — proof of concept ::: danger Not a final contract The server-function integration is currently a **proof of concept**. The file conventions, transport, middleware composition and production integration are not definitive yet and may change. Use this chapter to understand the current experiment and to build demos; do not treat it as a stable deployment API. ::: For the recommended public/protected access path, start with the [server functions guide](/guide/app/server-functions). This chapter keeps the lower-level POC details; the guide highlights the existing middleware, `clientContext`, session verification and refusal tests. **Goal:** understand the current client → registry → Effect server path. ## The current shape An exposed function has a server implementation and a client facade: ```text users/list.fn-client.ts users/list.fn-serveur.ts │ └── HTTP/RPC → createServer registry → Effect handler ``` The client imports only the server function's type. It must not import the server implementation at runtime. ## Define the server implementation The current experimental API takes an identifier, an input schema and an exposure mode. The handler returns an Effect: ```typescript // users/list.fn-serveur.ts import { serverFunction } from '@craft-ts/core'; import { Effect, Schema } from 'effect'; const inputSchema = Schema.toStandardSchemaV1( Schema.Struct({ filter: Schema.String }), ); export const listUsers = serverFunction('demo.users.list', inputSchema, { exposure: 'client', }).handler(({ input }) => Effect.gen(function* () { const repository = yield* UserRepository; return yield* repository.list(input.filter); }), ); ``` The server handler's success, typed errors and Effect requirements are the source of truth. Do not duplicate a result type or an error list manually. ## Define the client facade ```typescript // users/list.fn-client.ts import { createServerFunctionClient } from '@craft-ts/core'; import type { listUsers as ServerListUsers } from './list.fn-serveur'; export const getUsers = createServerFunctionClient('demo.users.list'); ``` The component uses the facade like a typed function. Wrap it in `queryEffect` or `mutationEffect` if the call belongs to a resource lifecycle: ```typescript import { isCraftException } from '@craft-ts/core'; const users = yield * queryEffect('users', { params: () => ({ filter: search() }), loader: ({ params }) => Effect.gen(function* () { const result = yield* Effect.promise(() => getUsers(params)); if (isCraftException(result)) return yield* Effect.fail(result); return result; }), }); ``` The exact transport adapter is still experimental. The repository's `demo-with-server-function` app currently uses `createServer`, `executeEffect` and a local HTTP bridge. ### Transport failures are typed The client transport always converts failures that prevent a usable server function response into a `CraftException` with `_tag: 'HttpError'` and `scope: 'ServerFunctionClient'`. This includes a lost connection, an aborted request, a missing `fetch` implementation, a rejected custom transport and an unreadable response body. As with `CraftHttpClient`, a network failure has `payload.status === 0`; the original failure is kept in `payload.body`. Server-side business failures remain their declared tags, so connection loss and domain errors can be handled separately by the same resource or mutation. ## Register and execute on the server ```typescript const application = createServer({ functions: [listUsers], execute: executeEffect(runtimeLayer).run, }); ``` The runtime Layer supplies server-only services such as a repository or the current user. Never import secrets, credentials or server implementations into a client module. ## Middleware and security The current demo also shows Effect middleware: ```typescript const audited = effectServerMiddleware('demo.audit', ({ next }) => Effect.gen(function* () { yield* Effect.log('before'); const result = yield* Effect.exit(next()); yield* Effect.log('after'); return yield* result; }), ); ``` Middleware may add typed failures, resolve Effect services and run before/after hooks. Client claims remain untrusted; authenticate and authorize again on the server, then publish only verified values to the handler context. ## Current limitations Treat these as constraints of the POC, not promises of the final design: * the browser transport and development plugin are local experimental adapters; * client/server file boundaries are checked by the current architecture graph, but deployment integration is still evolving; * middleware APIs and the server registry may be renamed or reshaped; * the server must re-check authorization even if the client has a matching Effect Layer. See the running examples in [`apps/demo-with-server-function`](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function) and the server-function architecture plan in the repository when this work is promoted out of the prototype area. ## What you gained You can experiment with typed Effect server calls while keeping an explicit client/server boundary. Keep this chapter isolated from stable application contracts until the POC is replaced by a final server-function API. [← 8. Test the graph](/learn-effect/08-testing) [Back to the overview →](/learn-effect/) --- --- url: https://craft-ts.github.io/craft/guide/ai.md --- # AI agents This section gathers everything that gives an AI agent access to CraftTS knowledge, application state, runtime behavior, or debugging context. There are three complementary layers: 1. **Knowledge**: documentation, examples, `llms.txt`, and Agent Skills. 2. **Observation and control**: MCP tools for the live browser, the runtime registry, and application logs. 3. **Application context**: `provideSendContextToAi`, which lets a developer assemble a selected screen, timeline, snapshot, and optional DOM/CSS capture before copying or sending it to an AI service. ## Choose the right surface | Need | Start here | Access | | ----------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------ | | Learn CraftTS conventions or find an API | [Coding agents](/resources/ai-agents) | `@craft-ts/mcp` | | Fill, click, navigate, or inspect the running app | [Live page MCP](/guide/ai/dev-page) | `@craft-ts/function-registry-mcp` → `page` | | Read or change a published primitive during development | [MCP tools](/guide/ai/mcp-tools) | `registry.*` tools | | Search logs from a reproducible flow | [MCP tools](/guide/ai/mcp-tools) | `@craft-ts/log-mcp` → `logs.*` | | Ask what a node depends on, or what a change can break | [MCP tools](/guide/ai/mcp-tools) | `@craft-ts/graph-mcp` → `graph.*` | | Give an AI a human-selected debugging context | [Send context to AI](/guide/ai/send-context-webhook) | `provideSendContextToAi` | | Understand the tracing and snapshot data behind the context | [Observability](/guide/advanced/observability) | Craft providers and runtime hooks | The tools are deliberately separated by boundary. The documentation MCP is read-only and works offline. The registry MCP can mutate development state and must only be connected to a local development app. The logs MCP reads local JSONL files; its `logs.clear` operation is destructive. The graph MCP reads the static dependency graph of the project and only writes the graph file it rebuilds. ## MCP servers CraftTS has four MCP servers, each with a different responsibility: * [`@craft-ts/mcp`](https://www.npmjs.com/package/@craft-ts/mcp) gives an agent the published documentation, examples, skills, and LLM entry points. * [`@craft-ts/function-registry-mcp`](/guide/ai/mcp-tools) bridges a running browser tab to `page` and `registry.*` tools. * [`@craft-ts/log-mcp`](/guide/ai/mcp-tools) exposes the local log store through `logs.*` tools. * [`@craft-ts/graph-mcp`](/guide/ai/mcp-tools) answers architecture questions from the static dependency graph through `graph.*` tools. See [MCP tools](/guide/ai/mcp-tools) for the complete tool inventory and the boundary between read-only and mutating operations. ## Context integrations This section is also the home for context providers and future agent-facing integrations. `provideSendContextToAi` is the first application-facing context surface: it turns an interaction into structured context that can be copied or sent to a protected webhook. Lucene and Context Workbook are not present as packages, tools, or documented integrations in this repository yet. When they are introduced, their setup, permissions, context model, and MCP tools should be documented under this section and added to the table above rather than creating another AI-related navigation branch. --- --- url: https://craft-ts.github.io/craft/resources/ai-agents.md description: >- Point Cursor, Claude, Copilot, and other coding agents at CraftTS docs, MCP tools, and Agent Skills after you import @craft-ts/core. --- # Coding agents CraftTS has a deliberate vocabulary. After you import `@craft-ts/core`, give the agent three layered entry points — `llms.txt`, the MCP server, and Agent Skills — so it can use the documented primitives and conventions. | Layer | What it is | When the agent uses it | | --- | --- | --- | | **LLM files** | [`/llms.txt`](https://craft-ts.github.io/craft/llms.txt), [`/llms-full.txt`](https://craft-ts.github.io/craft/llms-full.txt), and a `.md` sibling for every docs page | Discovery on the internet, no install | | **MCP server** | [`@craft-ts/mcp`](https://www.npmjs.com/package/@craft-ts/mcp) — `get_best_practices`, `search_documentation`, `find_examples`, skills | Live lookup in Cursor, Claude Code, VS Code, Copilot | | **Agent Skills** | `skills/` inside `@craft-ts/mcp`, plus an [Agent Plugin](https://agent-plugins.org/) manifest | Multi-step workflows (architecture tests, routes, spec → primitives, migration) | | **Live page MCP** | Local `@craft-ts/function-registry-mcp` tool `page` — fill, click, and inspect the development tab already open | Dev only, on the running app. Not shipped in `@craft-ts/mcp`. See [Live page MCP](/guide/ai/dev-page) | Do not scrape the HTML docs. Start from `llms.txt` or the MCP tools. ## 1. LLM files These follow the [llms.txt](https://llmstxt.org/) spec and are generated from this VitePress site at build time. * Index (curated links): https://craft-ts.github.io/craft/llms.txt * Concatenated docs: https://craft-ts.github.io/craft/llms-full.txt * One page, as markdown: append `.md` to any docs URL, for example [local state](https://craft-ts.github.io/craft/guide/state/local-state.md) Paste this into an `AGENTS.md` (or `CLAUDE.md`) at the root of the app that imports Craft: ```md # CraftTS This application uses `@craft-ts/core`. - Docs index: https://craft-ts.github.io/craft/llms.txt - MCP: `npx -y @craft-ts/mcp@beta` (`get_best_practices`, `search_documentation`) - Skills: `node_modules/@craft-ts/mcp/skills` yield* every Craft reader. Keep authored code within Craft's primitives and service model. craftRoutes files need componentDeps and a per-file DI check. The architecture/ suite is the graph contract: scaffold at bootstrap, run it during a feature. Do not add an architecture rule for the feature. ``` The same snippet is returned by the MCP tool `get_best_practices` (field `agentsMd`) and lives in the package as `content/agents.md`. ## 2. MCP server ```bash npm install -D @craft-ts/mcp@beta ``` Add a project `.mcp.json` (Cursor, Claude Code, and VS Code all understand it): ```json { "mcpServers": { "craft-ts": { "command": "npx", "args": ["-y", "@craft-ts/mcp@beta"] } } } ``` Claude Code, from the app directory: ```shell claude mcp add craft-ts -- npx -y @craft-ts/mcp@beta ``` ### Tools | Tool | Use it to | | --- | --- | | `get_best_practices` | Load the coding-agent guide and the `AGENTS.md` snippet | | `search_documentation` | Find a Guide / Learn / Reference page by API or task | | `get_documentation_page` | Read one page as markdown (`/guide/state/local-state`) | | `find_examples` | Find Learn + demo examples | | `list_skills` / `get_skill` | Load a workflow skill and its `references/*.md` | | `get_llms_txt` | Get the public `llms.txt` URLs and the bundled path index | The server is **read-only**. It searches documentation bundled at publish time, so it works offline. It is not the runtime registry MCP used to mutate a live demo tab, and it does not expose the `page` tool. Driving the open development tab is [Live page MCP](/guide/ai/dev-page) (dev only, function-registry MCP). ## 3. Agent Skills Skills follow the [Agent Skills](https://agentskills.io/specification) layout (`SKILL.md` + optional `references/`). The package is also an Agent Plugin (`plugin.json` + `mcp.json` + `skills/`). | Skill | Trigger | | --- | --- | | `craft-ts` | Any authored Craft code | | `craft-ts-architecture-tests` | Scaffold or run `architecture/`, or freeze a graph smell | | `translate-spec-to-craft-ts` | Spec / CRUD / filters / forms → primitives | | `craft-ts-routes` | `craftRoutes`, `componentDeps`, `TS2589` | | `craft-ts-service-migration` | legacy services → `craftService` | | `migrate-to-craft-ts` | `craft-migrate` then manual diagnostics | The [architecture suite](/guide/testing/architecture) is the app's graph contract (unique HTTP, unique identities, armed route DI proofs, folder lanes). Scaffold it at app start or at the end of `craft-migrate`. During a feature, run the suite that already exists. Do not add an architecture rule for the feature. Add a new `it()` only when a bad pattern is spotted, so it cannot recur. If `architecture/` is missing mid-feature, offer the scaffold; do not impose it. Point the agent at `node_modules/@craft-ts/mcp/skills`, or let it call `get_skill`. Cursor can also install a skill from that folder. ## Verify the agent can see Craft Ask it to add a `state` counter, a paged `query`, or a `craftRoutes` file. It should `yield*` readers, compose insertions with `craftPipe`, and put a DI check in the routes file. If the app already has `architecture/`, it should run that suite rather than invent a new rule. If it emits legacy runtime APIs or a plain routes array, the MCP server or `AGENTS.md` snippet is not in context. ## See also * [Which primitive should I use?](/guide/concepts/choose-primitive) * [The mental model](/guide/concepts/mental-model) * [Architecture rules](/guide/testing/architecture) * [CLI automation](/guide/routing/automation) * [Migration](/resources/migration) --- --- url: https://craft-ts.github.io/craft/guide/ai/mcp-tools.md --- # MCP tools CraftTS exposes separate MCP servers for documentation, a running application, local logs, and the static dependency graph. Connect only the server required for the task. ## Documentation MCP: `@craft-ts/mcp` Install it in an application or run it without adding a dependency: ```bash npm install -D @craft-ts/mcp@beta npx -y @craft-ts/mcp@beta ``` Register it in `.mcp.json`: ```json { "mcpServers": { "craft-ts": { "command": "npx", "args": ["-y", "@craft-ts/mcp@beta"] } } } ``` | Tool | Purpose | | ------------------------ | ------------------------------------------------------ | | `get_best_practices` | Load CraftTS rules and the `AGENTS.md` snippet | | `search_documentation` | Search Learn, Guide, Reference, Resources, or examples | | `get_documentation_page` | Read one documentation page as Markdown | | `find_examples` | Find tutorial and demo examples for a task | | `list_skills` | List the Agent Skills shipped with the package | | `get_skill` | Load a skill or one of its reference files | | `get_llms_txt` | Get the public `llms.txt` URLs and bundled page index | This server is read-only and searches the documentation bundled at publish time. It is the right starting point when an agent needs to understand the CraftTS API or conventions. ## Live page and registry MCP `@craft-ts/function-registry-mcp` connects over stdio to an MCP client and over WebSocket to the running development tab: ```bash npm run registry:mcp ``` The [Live page MCP](/guide/ai/dev-page) page explains the named-control contract, client selection, and `page` actions. ### Browser surface | Tool | Purpose | | ------------------ | -------------------------------------------------------------------------------------- | | `page` | Read named controls, then `goto`, `fill`, `click`, or `press` and return the new state | | `registry.clients` | List connected tabs and their `ready`, `connecting`, or `reloading` state | If multiple tabs are ready, pass the explicit `clientId`. Never guess based on which tab connected most recently. ### Registry surface | Tool | Purpose | | ------------------------ | ---------------------------------------------------------------------------- | | `registry.list` | List active registry entries | | `registry.get` | Read one registry entry | | `registry.call` | Invoke an active registry entry | | `registry.logs` | Read registry and bridge events | | `registry.override` | Replace a primitive method at runtime for development | | `registry.restore` | Remove a runtime override | | `registry..*` | Read or mutate `query`, `mutation`, `asyncProcess`, and `queryParams` values | The primitive-specific tools are: ```text registry.query.get / set / update / patch registry.mutation.get / set / update / patch registry.asyncProcess.get / set / update / patch registry.queryParams.get / set / update / patch ``` `query`, `mutation`, and `asyncProcess` support an optional `id` for grouped instances. `queryParams` does not use grouped instance IDs. Runtime overrides and mutating value tools are development-only operations and should never be connected to an untrusted or production browser. ## Logs MCP: `@craft-ts/log-mcp` The logs server reads JSONL files written by the Craft log server. It does not talk to the browser or ingest logs itself: ```bash npm run logs:mcp ``` | Tool | Purpose | | ------------- | --------------------------------------------------------------------------- | | `logs.stats` | Summarise levels, host tags, client IDs, files, and time range | | `logs.search` | Filter by text, level, host ancestry, correlation ID, client, or time range | | `logs.tail` | Read the most recent entries | | `logs.clear` | Delete all active and rotated log files | Use `logs.stats` before `logs.search` to understand what is available. The `from` filter follows Craft host-tag ancestry, and `correlationId` finds all entries from one correlated flow. `logs.clear` is the only destructive tool in this server. Use it only when a clean reproduction is intentional. ## Graph MCP: `@craft-ts/graph-mcp` The graph server answers architecture questions about **your** project from the same static analysis as the [architecture rules](/guide/testing/architecture): routes, components, services, primitives and the proven relations between them. It needs no running application. It reads `craft-dependency-graph.json` when the file exists and otherwise analyses the TypeScript program. ```bash npm install -D @craft-ts/graph-mcp ``` Projects created with `craft create` already register it. In an existing project, add it to `.mcp.json`: ```json { "mcpServers": { "craft-ts-graph": { "command": "npx", "args": ["craft-ts-graph-mcp"] } } } ``` | Tool | Purpose | | ------------------ | -------------------------------------------------------------------------------- | | `graph.status` | Source and build time of the graph, counts per kind, diagnostics | | `graph.rebuild` | Re-analyse the program and overwrite the graph file | | `graph.search` | Find nodes by label or id | | `graph.node` | One node: metrics, relations with their proofs, optionally its source | | `graph.neighbors` | The subgraph around a node, up to three relations away | | `graph.path` | The shortest relation chains between two nodes | | `graph.impact` | Every node whose output may change when this node changes | | `graph.hotspots` | God nodes and hotspots, optionally weighted by git churn | | `graph.report` | The [graph report](/guide/testing/graph-insights#report) as JSON | | `graph.violations` | The rules `assertArchitecture` enforces, by name, with their messages | Every answer carries `stale`: `true` when a source file changed after the graph was built, `unknown` when no tsconfig is available to check. An agent calls `graph.rebuild` before answering about recent code. | Variable | Default | | ---------------------- | --------------------------------------------------------------------- | | `CRAFT_GRAPH_ROOT` | The current directory | | `CRAFT_GRAPH_TSCONFIG` | First of `tsconfig.graph.json`, `tsconfig.app.json`, `tsconfig.json` | | `CRAFT_GRAPH_FILE` | `craft-dependency-graph.json` | | `CRAFT_GRAPH_COVERAGE` | Unset; a `coverage-final.json` to attach coverage per node and route | | `CRAFT_GRAPH_DOCS` | Unset; comma-separated Markdown globs linked to the nodes they cite | | `CRAFT_GRAPH_READONLY` | Unset; `1` removes `graph.rebuild`, for CI or shared environments | All tools except `graph.rebuild` are read-only, and `graph.rebuild` only writes the graph file. ### Projects created earlier `craft agents sync` adds today's agent wiring to a project generated by an older CraftTS: ```bash npx craft agents sync --dry-run # list what would change npx craft agents sync npm install && npm run graph ``` It adds the skills and hooks of the agents the project already uses (detected from `.agents/`, `.claude/`, `.cursor/` and `.gemini/`, or named with `--agents`), registers the graph server in `.mcp.json` without touching the servers already there, adds the `graph` and `graph:mcp` scripts and the dependency, and ignores the graph output. It never writes application code, and a second run reports that nothing changed. Do not use `craft create --force` for this: it rewrites every generated file, `src/` included. ## Which server should an agent use? | Situation | Server | | ------------------------------------------- | ------------------------------------------------------- | | “How should I write this Craft code?” | Documentation MCP | | “What is visible on the running page?” | Function registry MCP → `page` | | “What value does this query have?” | Function registry MCP → `registry.query.get` | | “What happened during this flow?” | Logs MCP → `logs.stats`, then `logs.search` | | “What does this service depend on?” | Graph MCP → `graph.search`, then `graph.node` | | “What can this change break?” | Graph MCP → `graph.impact` | | “What context should I send to an AI?” | `provideSendContextToAi`, then copy or send the payload | --- --- url: https://craft-ts.github.io/craft/guide/ai/dev-page.md --- # Live page MCP The running development tab publishes its named controls. A coding agent fills, clicks, and inspects **that** page — no second browser, no DOM reverse-engineering. **Use it when** a Cursor agent must drive or inspect the `ng serve` tab you already have open. **Not when** you are writing Craft away from a running app — use [`@craft-ts/mcp`](/resources/ai-agents) for docs and skills. **Not when** you want to mutate a primitive without the UI — use the `registry.*` tools on the same local MCP. ## Connect the local MCP The tool lives on `@craft-ts/function-registry-mcp`, not on the published `@craft-ts/mcp` docs server. From the craft-ts repo: ```sh npm run registry:mcp ``` Point Cursor at that stdio server. It already listens on `ws://127.0.0.1:3333` for the demo tab. Each tab keeps a stable `clientId` in `sessionStorage`. ## One ready tab Each tab has a `clientId` in `sessionStorage`. Duplicating a tab copies it; the broker assigns a new id (`hello/ok`) so the two tabs do not fight. Omit `clientId` when **exactly one tab is `ready`**. A ghost `reloading` card (HMR, F5) does not count. Two `ready` tabs → pass `clientId` from `registry.clients` (id, status, url). Never pick “latest”. The error is `Multiple ready page clients; clientId is required. Available clients: ready , ready `. Zero ready with several ghosts is `No ready page client. Reloading: (last url ), (last url )`. Zero cards is `page client is not connected`. Closing the tab sends `page/goodbye`; the card is dropped. Opening a new tab is a new id. If `page client "" is not connected`, call `registry.clients` and retry without id when a single ready remains. Closing without goodbye (crash) looks like reload for up to 20s. ## One tool: `page` Omit `act` to read the current surface. The broker **always asks the live tab** — a Craft `value:` that changed without a DOM mutation is still current. Pass `act` to run a batch, then receive the **new** state in the same round-trip. Default `detail` is `"controls"`: the named interactive surface (id, role, accessible name, value, enabled, index, and `track` when the node is inside `forNode`). Pass `detail: "dom-styles"` only to debug layout or CSS — it is large and opt-in. `id` is the literal local name from the helper: ```ts import { button, craftComponent } from '@craft-ts/component'; const SaveToolbar = craftComponent( 'SaveToolbar', {}, () => ({}), () => button('save', { type: 'button' }, 'Save'), ); ``` That name is unique in the app graph (`assertInteractiveElementNamed`). The renderer writes `data-craft-name="save"`. Do not prefix it with the component name. When `forNode` repeats the same id, pass `match.index` or `match.track`. ## Fill, click, goto, ready `act: [{ "goto": "/login-form" }]` navigates in the tab (Craft router). The WebSocket stays up. Prefer `goto` over clicking `navLink` — every nav item shares that id. Paths like `/login-form` and full URLs both work. A `fill` sets the control and dispatches one `input` or `change` (then blur), so `CraftFieldDirective` validation and touched state run. A click is `act` with only `id`. The batch runs in order and stops on the first error. While `ng serve` rebuilds, the socket drops but the broker **keeps** the client card. `page` waits until the tab is `ready` again (up to `timeoutMs`, default 20s). You do not poll. ## See also * [Coding agents](/resources/ai-agents) — which MCP to use for docs vs the live tab * [Architecture rules](/guide/testing/architecture) — unique interactive names * [Observability](/guide/advanced/observability) — primitive traces, not DOM --- --- url: https://craft-ts.github.io/craft/guide/ai/send-context-webhook.md --- # Send application context to AI `provideSendContextToAi` adds a developer-oriented context inspector to a Craft application. It is useful when an AI assistant needs more than a copied error message: the selected component, the recent user journey, the relevant app state, and optionally the DOM and computed CSS. Typical uses include: * asking an AI assistant to explain or fix a broken screen; * preparing a reproducible bug report or a support ticket; * investigating a failed HTTP request, navigation, mutation, or query; * sending a consistent, structured context to an internal debugging agent. The feature is user-driven. It does not call an AI service by itself. Without an `endpoint`, everything stays in the browser and the user copies the prompt or the timeline when they choose to. ## Minimal setup Register the provider once in the application providers. The default UI then adds an `AI context` launcher in the bottom-right corner and a context menu to Craft component hosts. ```ts export const minimalAiContextProviders = [provideSendContextToAi()]; ``` The two entry points are equivalent: * open the launcher to start with an empty context, then interact with the app; * right-click a component to start with that component already selected. From the chat, the user can add more elements with another right-click, remove selected elements, write an instruction, record or clear the timeline, and choose which sections to include in the generated Markdown prompt. ## What is collected The session combines several kinds of context: * **Selected elements**: tag name, text, outer HTML, and optionally a selector. * **Component information**: the host name, Craft host tags, click coordinates, the clicked element, and truncated host HTML. * **Timeline**: DOM interactions, HTTP requests, router activity, primitive activity, and app snapshot reports. HTTP and navigation entries are linked by operation and correlation IDs when those services provide them. * **App snapshots**: the reports emitted by the app snapshot registry while the context is being prepared. * **DOM and CSS captures**: the selected component or the full page, including computed styles. These captures are optional because they can be large and briefly pause the page while they are collected. The default prompt contains selected elements, component information, the timeline summary, and app snapshots when they exist. Timeline JSON and DOM/CSS captures are opt-in. The checkboxes in the chat change the Markdown prompt; the webhook also receives the structured fields so an agent can process them without parsing Markdown. The chat also supports recording a named **clip**. A clip is a subset of the timeline, which is useful when an investigation contains several unrelated interactions. `Copy JSON` exports the visible timeline (or the selected clip), whereas `Copy prompt` builds the AI-oriented Markdown document. ## Send the context to an agent Pass a browser-accessible webhook URL to enable the `Send` action: ```ts export const aiContextProviders = provideSendContextToAi({ endpoint: 'https://agent.example.com/hooks/context', }); ``` The browser sends a JSON `POST` with this versioned shape: ```json { "version": 1, "prompt": "# Instruction\nInvestigate this screen", "instruction": "Investigate this screen", "selectedElements": [], "events": [], "snapshot": [], "captures": {}, "component": { "hostName": "OrdersPage", "tagList": ["component:OrdersPage#1"], "coords": { "x": 120, "y": 80 }, "outerHTML": "
…
" } } ``` `prompt` is generated from the instruction, the selected options, and the structured fields. `component` is omitted when the chat was opened from the launcher without a captured component. `captures.component` and `captures.page` are present only when their corresponding DOM/CSS options were selected. Events generated by the webhook request itself are excluded from the context sent to that webhook. Any `2xx` response is successful, including `200`, `202`, and `204`. Network failures, timeouts, and non-`2xx` responses are shown in the chat. `Retry` reuses the exact same payload, and `Copy payload` copies that payload only after a failed request. The endpoint is application configuration shipped to the browser, not a secret. It must allow the application's origin through CORS and accept JSON `POST` requests. Put authentication and secret management in a protected same-origin proxy or agent gateway. ## Configure the webhook `provideSendContextToAi` currently accepts the browser-accessible `endpoint`. Omit it to keep the experience copy-only, or pass a URL to enable sending: ```ts export const customizedAiContextProviders = provideSendContextToAi({ endpoint: '/internal/ai/context', }); ``` Treat that URL as public application configuration. Put authentication, redaction, and any tenant-specific policy in a protected same-origin proxy or agent gateway; never put credentials in the browser bundle. The session and its record controller are also injectable: ```ts import { SEND_CONTEXT_RECORD_CONTROLLER, SEND_CONTEXT_SESSION, } from '@craft-ts/component'; import { ɵinject as inject } from '@craft-ts/core'; const record = inject(SEND_CONTEXT_RECORD_CONTROLLER); record.startRecord('Checkout failure'); // Later, from the same application flow: record.stopRecord(); const summary = record.exportSummary(); const json = record.exportJson(); const session = inject(SEND_CONTEXT_SESSION); session.capture('custom', 'emitted', { name: 'checkout.validation', state: { step: 'payment' }, }); ``` Use the record controller for clips and the session for low-level event emission, subscription, clearing, and programmatic export. ## Customize the UI with DI There are three levels of UI customization: | Provider | What it replaces or adds | When to use it | | ------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ | | `provideSendContextChatComponent(() => MyChat)` | The default chat panel | Keep the built-in launcher and context menu, but replace the panel | | `provideSendContextUiRenderer(() => MyRenderer)` | The complete renderer | Own the launcher/chat lifecycle and render the whole experience | | `SEND_CONTEXT_LAUNCHER_COMPONENT` / `SEND_CONTEXT_CONTEXT_MENU_COMPONENT` | The floating launcher or right-click menu | Match the application's controls or visual language | The complete renderer receives `SendContextUiContext`. It exposes the live session, events, clips, selected targets, captured payload, DOM capture element, recording state, endpoint, and operations such as `addTarget`, `removeTarget`, `selectClip`, and `close`. ```ts import { provideSendContextChatComponent, provideSendContextUiRenderer, type SendContextUiContext, } from '@craft-ts/component'; // A chat replacement keeps the default surrounding behavior. provideSendContextChatComponent(() => MyChat); // A complete renderer receives the live context as its `context` input. provideSendContextUiRenderer(() => MyRenderer); // MyRenderer's input contract is: // { context: Input; onClose: Output<() => void> } ``` `provideSendContextChatSection`, `provideSendContextChatAction`, and `provideSendContextExportSection` are multi providers intended for a custom renderer. They let feature libraries contribute sections, commands, or export views without depending on one global renderer. The built-in chat does not render those extension entries itself; a custom renderer reads them from `SendContextUiContext`. For example, a feature can contribute an action that starts a named clip: ```ts import { provideSendContextChatAction } from '@craft-ts/component'; const providers = [ provideSendContextChatAction({ id: 'record-checkout', label: 'Record checkout flow', run: (context) => context.session.startRecord('Checkout flow'), }), ]; ``` The UI provider tokens are regular DI contracts, so a custom launcher or menu can be registered directly: ```ts import { SEND_CONTEXT_CONTEXT_MENU_COMPONENT, SEND_CONTEXT_LAUNCHER_COMPONENT, } from '@craft-ts/component'; const providers = [ { provide: SEND_CONTEXT_LAUNCHER_COMPONENT, useValue: MyLauncher, }, { provide: SEND_CONTEXT_CONTEXT_MENU_COMPONENT, useValue: MyContextMenu, }, ]; ``` ## Complete integration example An application can combine the default UI, a protected endpoint, stricter retention, and application-specific event filtering: ```ts import { craftAppConfig } from '@craft-ts/core'; import { provideSendContextEventFilter, provideSendContextToAi, SEND_CONTEXT_RETENTION_POLICY, } from '@craft-ts/component'; export const appConfig = craftAppConfig({ providers: [ provideSendContextToAi({ endpoint: '/internal/ai/context', }), { provide: SEND_CONTEXT_RETENTION_POLICY, useValue: { maxEvents: 250, maxBytes: 1024 * 1024 }, }, provideSendContextEventFilter((event) => event.name !== 'healthcheck'), ], }); ``` The server-side endpoint should validate `version`, authenticate the user, apply any additional server-side redaction, and then forward either `prompt` or the structured context to the selected agent. --- --- url: https://craft-ts.github.io/craft/guide.md --- # Guide The guide is organised by **what you are trying to do**. If you are starting out, the [Learn path](/learn/) is a better entry point — it introduces the same material one idea at a time. ## Start here Four pages carry most of the weight. Reading them in this order is worth an afternoon: 1. [The mental model](/guide/concepts/mental-model) — the principles the API follows and the guarantees they provide 2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the five-way decision you make constantly 3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all five share 4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel everything is built on 5. [Insertions](/guide/concepts/insertions) — how behaviour is composed ## Project setup [Create a CraftTS project](/guide/create-project) — interactive and non-interactive starters, configuration options, and first checks ## By topic ### Managing state [Local state](/guide/state/local-state) · [State machines](/guide/state/state-machines) · [query](/guide/state/server-state) · [Mutations](/guide/state/mutations) · [queryParams](/guide/state/url-state) · [asyncProcess](/guide/state/async-process) · [Collections](/guide/state/collections) · [Persistence](/guide/state/persistence) · [Selecting](/guide/state/select) · [Reacting to mutations](/guide/state/react-on-mutation) · [Schema validation](/guide/state/schema-validation) ### Structuring the app [craftService](/guide/app/craft-service) · [Service scopes](/guide/app/service-scopes) · [Shaping the public API](/guide/app/expose-api) · [Abstract services](/guide/app/abstract-services) · [App start](/guide/app/app-start) · [Lazy services](/guide/app/lazy-services) [Server functions](/guide/app/server-functions) ### Recommended approaches [Inject at the point of use](/guide/patterns/inject-at-point-of-use) ### Routing and type-safe DI [Setup](/guide/routing/setup) · [CLI automation](/guide/routing/automation) · [ESLint rules](/guide/routing/eslint-rules) · [Route providers](/guide/routing/route-providers) · [Guards](/guide/routing/guards) · [Exception handling](/guide/routing/exception-handling) · [Pending UI](/guide/routing/pending-ui) · [Route load errors](/guide/routing/route-load-errors) · [Scaling routes](/guide/routing/scaling) ### Components and templates [Components](/guide/components/) · [Fine-grained reactivity](/guide/components/fine-grained-reactivity) · [Progressive `forNode`](/guide/components/schedule-for) · [Directives and `.pipe(...)`](/guide/components/directives) · [Customization](/guide/components/customization) · [Content projection](/guide/components/content-projection) · [Styling a component](/guide/components/styles) · [Accessibility](/guide/components/accessibility) ### Forms [Overview](/guide/forms/) · [Validators](/guide/forms/validation) · [Submitting](/guide/forms/submit) · [Nested forms](/guide/forms/nested) ### Testing [Services](/guide/testing/services) · [Components](/guide/testing/components) · [Type-level tests](/guide/testing/type-level) · [Browser boundaries](/guide/testing/browser-boundaries) · [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) ### Reactivity utilities [craftComputed](/guide/reactivity/craft-computed) · [craftEffect](/guide/reactivity/craft-effect) · [craftMethod](/guide/reactivity/craft-method) · [source$](/guide/reactivity/source) · [on$](/guide/reactivity/on) ### Going further [SSR and hydration](/guide/advanced/ssr-hydration) · [Program operators](/guide/advanced/program-operators) · [Pattern matching](/guide/advanced/pattern-matching) · [Observability](/guide/advanced/observability) ### AI agents [AI agents overview](/guide/ai/) · [Coding agents](/resources/ai-agents) · [MCP tools](/guide/ai/mcp-tools) · [Live page MCP](/guide/ai/dev-page) · [Send context to AI](/guide/ai/send-context-webhook) ## Looking for one symbol? The [API index](/reference/) lists every export with a one-line description. --- --- url: https://craft-ts.github.io/craft/guide/create-project.md --- # Create a CraftTS project Use `craft create` to generate a framework-independent CraftTS application with routing, a typed API example, linting, tests, and the architecture contract already wired up. ## Prerequisites The beta toolchain requires Node.js 20.19 or newer. The `craft` executable is published by `@craft-ts/dev-tools`; it is not provided by the unrelated npm package named `craft`. For a new project, invoke the executable explicitly through `npx`: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app ``` The first `--yes` belongs to `npx`: it accepts the temporary package installation. The command remains interactive because `craft create` itself was not given `--yes`. The command uses the published `beta` package. A checkout of CraftTS can contain a newer creation flow than the version currently published on npm; check the resolved version with `npm view @craft-ts/dev-tools@beta version` if the prompts shown by your terminal do not match this page. ## Interactive creation Run the command in a real terminal without `craft create --yes`: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app ``` The generator presents menus in this order: * the application type: frontend-only or full-stack; * for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS v4 is recommended); * the frontend runtime: `plain` or `effect`; * type-safe i18n, its locales, and its default locale; * the design system; * typed CSS; * a standalone or Nx workspace; * integrations for Codex, Cursor, or Claude Code. The frontend and backend choices are independent. To create a plain browser application whose server functions use Effect v4, choose `plain` for the frontend and `effect` for the backend. Use `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and agent integrations, use `Space` to select or deselect several items, then `Enter` to confirm. The project directory remains a text field because it is a free-form path. If the directory is omitted, the generator asks for it too: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create ``` The agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to select or deselect an integration, and `Enter` to confirm. Codex starts selected, preserving the default used by scripted creation. Every starter receives an `AGENTS.md` project guide describing its selected runtimes and features; selected integrations additionally receive their editor-specific project instructions and skills. Claude Code receives `CLAUDE.md` and skills under `.claude/skills/`. ## Agent-assisted creation When an agent starts a new project, it should first ask what kind of application is being built and what its main features are, without collecting detailed requirements yet. If those features imply a backend, it should propose EffectTS v4 for the backend and explain that its typed services, Layers and errors fit CraftTS's typed server boundary. The user can confirm that stack, reject it, or name another backend; the agent must not add an EffectTS backend after an explicit rejection. The agent should create a domain-ready but empty starter with the design system, typed CSS and strict i18n enabled, and without the explanatory demo pages: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app \ --yes --no-demos --domain app \ --frontend-runtime=plain --backend-runtime=effect \ --i18n=strict --design-system=basic \ --references=all --agents=codex ``` Use `--backend-runtime=none` when the user declines a backend, or the explicit requested backend when it is supported. When no Effect runtime is selected, use `--references=craft-ts` instead of `--references=all`. The `--no-demos` starter still contains the architecture/tooling baseline and a domain boundary, but no prefilled product pages or demo content. ### Creating inside an existing Git repository An existing `.git` directory makes the destination non-empty. Generate into the current repository with `--force`: ```bash cd pet-foster-family npx --yes --package @craft-ts/dev-tools@beta craft create . --force ``` `--force` only permits writing into a non-empty destination; it does not turn off the configuration prompts. Review generated file changes before committing when the repository already contains application code. During the interactive flow, reference sources are vendored automatically with `git subtree`: * CraftTS sources go into `.references/craft-ts`; * EffectTS sources are also vendored when an Effect frontend or backend is selected; * the sources are committed in the project repository for agents without replacing the installed npm packages. There is no reference confirmation prompt. The same defaults apply in non-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an Effect frontend or backend is selected. Use `--references=none` to opt out, or `--references=craft-ts` / `--references=all` to choose explicitly. The vendored repositories are read-only reference material for coding agents only. The generated application always imports the published CraftTS and EffectTS npm packages from `package.json`; it does not use `file:` dependencies or TypeScript/Vite aliases to the references. Use `npm run update:references` to run `git subtree pull` and refresh the recorded source SHA. ## Non-interactive creation Pass `--yes` after `create` to use defaults and disable all prompts. Combine it with explicit options when the generated configuration must be reproducible: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app \ --yes --effect=none --agents=codex ``` For a minimal plain starter: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app \ --yes --effect=none --i18n=none --design-system=none \ --agents=none ``` To create a backend-only Effect project and vendor both reference sources: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create my-app \ --yes --frontend-runtime=plain --backend-runtime=effect \ --references=all ``` The main configuration options are: | Option | Values | Purpose | | -------------------- | ------------------------------------- | ------------------------------------------------------------------------- | | `--effect` | `v4`, `none` | Select the Effect v4 or plain starter | | `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime | | `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions | | `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement | | `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated | | `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n | | `--design-system` | `basic`, `none` | Include the design-system starter | | `--workspace` | `standalone`, `nx` | Choose the workspace layout | | `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) | | `--no-demos` | flag | Generate a domain feature without explanatory demo pages | | `--domain` | slug | Name the first domain feature when using `--no-demos` | | `--force` | flag | Allow an existing non-empty destination | | `--json` | flag | Print the effective configuration as JSON | Every project styles through `@craft-ts/style`, whatever the options: there is no plain-CSS starter and no `src/styles.css`. The element defaults and the app shell live in `src/app/app.style.ts`; `--design-system=basic` adds the starter design system in `src/app/ui/ui.style.ts`. `--no-typed-css` is no longer accepted. Use `craft create --help` to see the complete list: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create --help ``` For a domain-first starting point, omit the explanatory home/services/about pages and name the feature explicitly: ```bash npx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \ --yes --no-demos --domain animal --frontend-runtime=effect \ --backend-runtime=effect ``` The generated feature lives under `src/app/features/animal/`. Add a form to that feature with the existing primitives and its unit/submission test: ```bash craft add form animal # advanced nested/schema variant: craft add form animal --advanced ``` ## After generation The generator creates a Git repository when the destination is not already inside another repository. When references are enabled, it adds them as tracked Git subtrees and creates the minimal Git history required by `git subtree` when the destination is a new repository. The generated `.gitignore` excludes `node_modules/`, build outputs, and test reports. Install dependencies and start the generated application: ```bash cd my-app npm install npm run dev ``` The generated project also includes the following checks: ```bash npm run lint npm run typecheck npm test npm run architecture npm run build ``` ### `npm run style:check` With typed CSS enabled, the project gets one more, and it is the only one that needs explaining: ```bash npm run style:check ``` It builds once — which is how the style plugin writes `.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for every element the graph can show holds text, in every state your axes can produce, with no browser involved. It is in the generated CI workflow. The starter is set up to pass it out of the box: the palette is named, so a failure can say `ui.accent.dangerHover` rather than a hexadecimal string, and the generated link writes its hovered colour through `interaction.hover` rather than a hand-written selector, so the hovered state is a state the check can actually measure. Two things to know before your first failure: * **A result the analysis cannot prove fails the run.** `--allow-indeterminate` turns those into warnings and you have to type it. A check whose default treats "I could not tell" as "fine" reports a clean bill on the part of the application it did not understand. * **A clean run is a contrast proof, not an accessibility audit.** [Text contrast](./style/contrast.md) has the full coverage contract: what is proven, what comes back as `indeterminate`, and how to close a gap honestly. ### Generated architecture rules The generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules` and activates the selected `recommended` or `effect` preset. These presets enforce the same architecture as the generated project guide: * remote reads and writes stay directly in query or mutation loaders; they must not be hidden in `craftMethod`; * `query`, `mutation` and `asyncProcess` loaders are generator functions; express asynchronous work with `yield*`, never with `async` or a native `Promise` return; * resource loaders infer their result instead of using casts such as `as PromiseLike<...>`; * route-visible filters, search, sort and pagination use route-level `queryParams`, not component-local `state`; * template event handlers emit one `source$`; query, mutation and state react through `on$` instead of chaining imperative method calls. The generated agent skill repeats these boundaries so new features follow the same rules. Run `npm run lint` after generation to verify the project. With a backend, `src/server/application.ts` owns the registry and runtime Layer, while `src/server/node-http.ts` is only the Node stream adapter. `server.ts` re-exports both for compatibility. In the backend-only Effect profile, the browser remains plain CraftTS; Effect services, middleware and error projections stay under the server boundary. ## Troubleshooting ### `could not determine executable to run` If the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm package named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta` form shown above. If `@craft-ts/dev-tools` is already installed in the project, its local binary can also be called with: ```bash npx craft create my-app ``` The explicit form is still the safest command when bootstrapping a project that has no `package.json` yet. --- --- url: https://craft-ts.github.io/craft/guide/concepts/mental-model.md --- # The mental model Three words describe everything `@craft-ts` does: **declare, yield, derive.** You declare state with a name. Every named entity — a factory, a computed, a method — pulls in with `yield*` what it does not own, so the compiler can see that entity's dependencies. Everything else — validity, loading flags, form trees, error unions — is derived rather than restated. This page is the *why*. If you want the *how*, the [Learn path](/learn/) walks the same ideas through a working app. ## Declare State is declared where it is used, close to the component or service that owns it, with a name that the tooling can see: ```typescript const counter = yield* state('counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), })); ``` The name isn't a label — it tags the injector (`state:counter`) and is how the primitive shows up in logs, snapshots and observability. Five primitives cover every home a value can have: memory, server (read and write), URL, and async action. They share one shape, so learning one teaches the other four — see [Anatomy of a primitive](/guide/concepts/primitive-anatomy). ## Yield Classic injection hides the dependency graph. `inject(TaskApi)` is invisible from the outside, so the compiler cannot tell you when a provider is missing, and a test cannot tell you what to mock. Yielding makes the same call visible **in the type** of the entity that yielded it: ```typescript const api = yield* TaskApi(); ``` A factory that yields `TaskApi` has `TaskApi` in its graph. A computed that reads another primitive must yield that primitive too — otherwise the dependency is invisible on the computed, even if the surrounding factory already yielded it: ```typescript const doneCount = craftComputed('doneCount', function* () { return (yield* tasks()).filter((task) => task.done).length; }); ``` `tasks` does not belong to `doneCount`. Closing over `tasks()` would read the value and skip the graph. Everything downstream — the route DI check, the testing register, the dependency snapshot — reads the type of **that** entity. And because you can yield *part* of a service — `yield* TaskApi.fetchAll()` — the graph records only what you actually used, which is what keeps test setups small. That is the whole trade: one keyword, in exchange for a dependency graph the compiler can check. See [Generators and `yield*`](/guide/concepts/generators). ## Derive The third principle is the one that removes the most code: **if a value is a function of another value, don't store it — derive it.** * Derived values are `craftComputed`, inside an insertion, and they `yield*` the readers they depend on. * Loading and error state is derived by the async primitives, not tracked by hand. * A form's field tree, validity and error types are derived from its state and its mutation ([Forms](/guide/forms/)). * Route exceptions are derived into a union the compiler forces you to handle exhaustively ([Exceptions](/guide/concepts/exceptions)). The payoff is that derived things cannot drift out of sync with their source. The cost is that you have to resist keeping a second copy "just for the template". ## What follows from this ### Composition instead of configuration Behaviour is added by **insertions** — plain functions that receive a primitive's internals and return what to expose. Storage persistence, optimistic updates and forms are all the same shape as one you'd write yourself. Storage persistence uses the backend selected through DI: ```typescript const { myState } = state( 'myState', 0, insertStoragePersister(craftUnique({ storeName: 'myStore', key: 'myState', })), ); const { myQuery } = query( 'myQuery', { params: () => 1, loader: /* … */ }, insertStoragePersister(craftUnique({ storeName: 'myStore', key: 'myUserQuery', })), ); ``` Compose several with the primitive-specific helpers in [Typed insertion pipes](/guide/concepts/insertion-pipes). Keep [`craftPipe`](/guide/concepts/insertions) for universal or nested compositions. ### Methods or events, your choice A method can be called directly, or bound to a source and driven by an event. Both coexist in the same declaration: ```typescript const resetSource$ = source$('resetSource$'); const counter = yield* state('counter', 0, ({ set, update }) => ({ increment: () => update((v) => v + 1), // called reset: on$(resetSource$, () => set(0)), // driven by an event, not exposed })); ``` This is what makes one `resetSource$.emit()` reset several independent states at once, without any of them knowing about the others: ```typescript const search = yield* state('search', '', ({ set }) => ({ set, reset: on$(resetSource$, () => set('')), })); const page = yield* state('page', 1, ({ set, update }) => ({ increment: () => update((v) => v + 1), reset: on$(resetSource$, () => set(1)), })); ``` See [`on$`](/guide/reactivity/on). ### Granular state, granular tests Small, focused states isolate change. Combined with partial yields, a consumer depends on exactly what it reads — and a test provides exactly that, no more. ### Services as functions A service is a factory with a name and a scope, not a class: [`craftService`](/guide/app/craft-service) for the ones you write, and small host adapters for dependencies owned by the runtime. Both participate in the same typed composition and the same testing workflow. ```typescript const { UserProfile } = craftService( { name: 'UserProfile', providedIn: 'global' }, function* () { const api = yield* UserApi(); const userId = yield* state('userId', '5', ({ set }) => ({ set })); const updateEmail = yield* mutation('updateEmail', { method: (payload: { id: string; email: string }) => payload, loader: function* ({ params }) { return yield* api.updateEmail(params); }, }); const user = yield* query( 'user', { params: userId, loader: function* ({ params }) { return yield* api.getUser(params); }, }, insertReactOnMutation(updateEmail, { optimisticPatch: { email: ({ mutationParams }) => mutationParams.email }, reload: { onMutationException: true }, }), ); return { userId, user, updateEmail }; }, ); ``` ### Signals, not RxJS 100% signal-based. RxJS is optional and only appears where you ask for it. ### Declarative code is legible code The three principles add up to something that is rarely stated outright: the app becomes **declared data** rather than control flow to be reconstructed. Two consequences follow, and both are worth more than they look. **Observability stops being instrumentation.** Because every dependency resolution and every crafted function passes through one system, that system is where you wrap them — structured logs through a yieldable `Console`, correlation ids across the graph, per-service timing, snapshots of the live dependency tree — with no change to business code. Retrofitting the same thing onto imperative code means touching every call site. See [Observability](/guide/advanced/observability). **And what a tool can read, a tool can help with.** The dependency graph, the reachable exceptions and the route contract are all declared, so an agent — or a future WebMCP-style integration — can reason about the app without inferring it from execution. The same property that makes the compiler able to check your providers makes the codebase tractable to something that isn't you. ### Exceptions are values, errors are surprises A craft *exception* is a failure you declared and expect to handle; an *error* is the unexpected kind. Keeping them apart is what allows the compiler to check that you handled every declared case. This is **error-as-value**: a declared failure is *returned*, not thrown, so it propagates through types rather than escaping through the stack. A `try/catch` tells you nothing about what it might catch; a returned `craftException` carries its code and payload all the way to whoever handles it — and the compiler knows if nobody does. See [Exceptions as values](/guide/concepts/exceptions). ## See Also * [Which primitive should I use?](/guide/concepts/choose-primitive) * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) * [Learn: the guided path](/learn/) --- --- url: https://craft-ts.github.io/craft/guide/concepts/choose-primitive.md --- # Which primitive should I use? There are five primitives. They share the same shape — a name, a configuration, optional insertions — and differ only in **where the value comes from** and **what triggers it**. ## The decision | Where does the value live? | Use | | ---------------------------------------- | ---------------------------------------------- | | In memory, you own it | [`state`](/guide/state/local-state) | | On a server, read | [`query`](/guide/state/server-state) | | On a server, written | [`mutation`](/guide/state/mutations) | | In the URL's query string | [`queryParams`](/guide/state/url-state) | | Nowhere — it's an action with a lifecycle | [`asyncProcess`](/guide/state/async-process) | ## The same table, by symptom **"I need a value the user can change."** → `state`. It is the default. Reach for anything else only when the value's home is somewhere other than memory. **"I need to display data from an API."** → `query`. It re-runs when its `params` change and carries `isLoading` / `status` / `exception` for you. Don't put a `query` result into a `state` — that's two sources of truth. **"I need to send something to an API."** → `mutation`. Triggered explicitly with `.mutate(...)`. Connect it back to the read side with [`insertReactOnMutation`](/guide/state/react-on-mutation) rather than reloading by hand. **"This filter should survive a refresh and be shareable."** → `queryParams`. The URL becomes the source of truth; your query's `params` read from it. **"I need to run an async thing and know if it's running."** → `asyncProcess`. Use it for operations that are not a server read or write: a file export, a share sheet, a delay, a Web API call. ## Things that are *not* a primitive * **Derived values** — use `craftComputed` inside an insertion. Craft keeps the reader dependency visible in the graph. * **Reusable logic across primitives** — that's an [insertion](/guide/concepts/insertions), not a primitive. * **A group of primitives with a name and a scope** — that's a [`craftService`](/guide/app/craft-service). ## What they have in common Whichever you pick, the mechanics are identical: the name comes first, the result is the primitive reference itself, `yield*` drives it inside any craft generator, and the last argument is an insertion. That shared shape is one page: **[Anatomy of a primitive](/guide/concepts/primitive-anatomy)**. Read it once and every primitive page becomes just its own specifics. Advanced patterns that need to write a primitive from DI — wrappers, registries, WebMCP tools, seeding a query result — use the [injectable runtime context](/guide/concepts/primitive-anatomy#injectable-runtime-context). ## See Also * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) * [Learn: your first state](/learn/01-first-state) * [Insertions](/guide/concepts/insertions) --- --- url: https://craft-ts.github.io/craft/guide/concepts/primitive-anatomy.md --- # Anatomy of a primitive The five primitives — `state`, `query`, `mutation`, `queryParams`, `asyncProcess` — share one shape. Learn it once here; each primitive's page then only covers what is specific to it. ## The shape ```typescript primitive(name, config, insertion?); ``` * **`name`** — always first, always a string literal. * **`config`** — what the primitive needs to do its job (an initial value, a loader, a codec map…). This is the part that differs between primitives. * **`insertion`** — optional, adds methods and computed values to the result. ## Naming is not decoration The name tags the primitive's injector — `state:tasks`, `query:userQuery` — and is what identifies it in logs, snapshots and the observability tooling. Two primitives with the same name in the same scope are two different things wearing one label, and the tooling cannot tell them apart. ## Driving it with `yield*` A primitive does not run itself. Inside any generator host — a `craftComponent` logic factory, a `craftService` factory, a `craftComputed`, a `craftMethod`, `craftGen`, a route helper — `yield*` is the driver. The entity that yields records the dependency on **its** graph: ```typescript const tasks = yield* state('tasks', []); ``` `yield*` also folds whatever the primitive depends on into the enclosing dependency tree, which is what the route DI check and the test registers read. ## It resolves to the primitive reference Every named primitive returns its reference directly: ```typescript const tasks = yield* state('tasks', []); ``` A factory arrow can return a single primitive directly. `craftService` drives it and exposes the primitive reference itself: ```typescript const { MyService } = craftService( { name: 'MyService', providedIn: 'global' }, () => state('counter', 0), ); ``` When a factory exposes several primitives, wrap the record with `craftYieldRecord`. It yields each primitive generator and keeps the record keys in the returned value: ```typescript import { craftComputed, craftService, craftYieldRecord, query, state, type CraftServiceInput, } from '@craft-ts/core'; const { UserQuery } = craftService( { name: 'UserQueryWithState', providedIn: 'global' }, (inputs: { userId: CraftServiceInput }) => craftYieldRecord({ userQuery: query('userQuery', { params: function* () { return yield* inputs.userId(); }, loader: ({ params }) => ApiService.getItemById(params), }), refresh: state('refresh', 0, ({ update }) => ({ increment: () => update((value) => value + 1), })), }), ); ``` Use the direct return for one primitive and `craftYieldRecord` for a record of primitives. Inside a generator factory, the equivalent explicit form remains available: `const userQuery = yield* query(...)`. ## Insertions add to the result The last argument receives the primitive's internals and returns what to expose: ```typescript state('counter', 0, ({ state, update }) => ({ increment: () => update((value) => value + 1), isEven: craftComputed(function* () { return (yield* state()) % 2 === 0; }), })); ``` Compose several with the primitive-specific helpers described in [Typed insertion pipes](/guide/concepts/insertion-pipes). An insertion can also be a `function*`, in which case it can `yield*` services. A derived value or generator method must yield readers it does not own — including this primitive's `state()` / `update()` when the member is a generator. Keep [`craftPipe`](/guide/concepts/insertions) for universal or nested compositions. ## Scoped providers Every primitive config accepts `providers`, for dependencies that should be scoped to this primitive alone rather than to the whole service: ```typescript query('userQuery', { providers: [provideUserApiService()], loader: function* () { return yield* UserApiService.get(); }, }); ``` ## Injectable runtime context Everyday insertions already receive `set`, `update`, and `patch` as arguments. Keep using that. Each primitive also **provides those same writes through Craft DI** on every insertion method. Wrappers, registries, tests, WebMCP tools, and other advanced patterns can recover them without being passed the insertion context — for example to seed a query result, patch a mutation value, or drive a `state` from a [`provideFnWrapper`](/guide/advanced/observability#providefnwrapper). That is also the surface a WebMCP client uses to inspect and mutate a live primitive: `get` / `set` / `update` / `patch` on a query result, a `state`, a mutation, an `asyncProcess`, or `queryParams`, without editing TypeScript or reloading the page. The helpers return `undefined` outside an insertion-method injection context. Use the one that matches the primitive, or the generic helper and branch on `kind`: | Primitive | Helper | | -------------- | ------------------------------------------- | | `state` | `injectStateMethodRuntimeContext()` | | `query` | `injectQueryMethodRuntimeContext()` | | `mutation` | `injectMutationMethodRuntimeContext()` | | `queryParams` | `injectQueryParamsMethodRuntimeContext()` | | `asyncProcess` | `injectAsyncProcessMethodRuntimeContext()` | | any of them | `injectPrimitiveMethodRuntimeContext()` | The context is the same shape everywhere: ```typescript { kind: 'state' | 'query' | 'mutation' | 'queryParams' | 'asyncProcess'; get(): unknown; set(value: unknown): unknown; update(updater: (current: unknown) => unknown): unknown; patch(updater: (current: unknown) => object): unknown; originalSource: string; } ``` `patch` merges objects. Use `update` to replace arrays or primitives. Nested [`insertSelect`](/guide/state/select) methods receive the selected slice, not the root. ```typescript import { injectQueryMethodRuntimeContext, provideFnWrapper, } from '@craft-ts/core'; provideFnWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (factory, thisArg, args) { const query = injectQueryMethodRuntimeContext(); const result = yield* factory.apply(thisArg, args); query?.patch((current) => ({ ...current, viewed: true })); return result; }, ); ``` `query`, `mutation`, `asyncProcess`, and `queryParams` also publish the **primitive value itself** — not only its methods — through `providePrimitiveResourceRuntimeObserver`. Register it on the primitive's `providers` (or higher). The observer runs at creation; keep the context if you need to write later. Grouped resources take an optional `id` equivalent to `.select(id)`. `state` has no resource observer: only the method context. ```typescript import { providePrimitiveResourceRuntimeObserver, query, type PrimitiveResourceRuntimeContext, } from '@craft-ts/core'; let usersRuntime: PrimitiveResourceRuntimeContext | undefined; const users = yield* query('users', { providers: [ providePrimitiveResourceRuntimeObserver((context) => { if (context.kind === 'query') { usersRuntime = context; } }), ], params: () => true, loader: function* () { return yield* UserApi.list(); }, }); usersRuntime?.set([{ id: 'stub', name: 'Preview' }]); ``` The internal token behind these helpers is not part of the public API. Use the helpers; do not look up the token yourself. ## Reading a value that may have failed The async primitives (`query`, `mutation`, `asyncProcess`) expose one value reader: * `value()` — never throws, returns `undefined` when no value is available. ::: tip Pass `query.value` to a template binding. Inside a generator, `yield* query.value()`. ::: ## Pitfalls **A primitive invocation is single-use.** Each call produces one generator, to be consumed exactly once. Storing one and `yield*`-ing it twice does not give you two primitives — it fails. **It must run in an injection context.** A field initialiser, a constructor, a craft factory. Called outside one, a primitive returns only its configuration under `_config` instead of a live ref — which usually surfaces later as a confusing "not a function" error. **Methods bound to a source with `on$` are not exposed on the result.** They work internally, driven by the source, and do not appear on the ref. **Don't inject the runtime context from feature insertions.** The insertion already receives typed `set` / `update` / `patch` as arguments. The injectable helpers are untyped and exist for wrappers, registries, WebMCP tools, and other advanced patterns. ## See Also * [Which primitive should I use?](/guide/concepts/choose-primitive) * [Insertions](/guide/concepts/insertions) * [Generators and `yield*`](/guide/concepts/generators) * [Observability](/guide/advanced/observability) — `provideFnWrapper` as a consumer of the runtime context --- --- url: https://craft-ts.github.io/craft/guide/concepts/generators.md --- # Generators and `yield*` `yield*` is the one mechanism the whole library rests on. This page explains what it actually does, then covers `craftGen`, which lets you write a tracked generator outside a service. ## Why a generator at all Dependencies hidden in a runtime container are invisible from the outside: nothing in the consumer's type says they exist. The compiler can't catch a missing provider, and a test can't tell you what to mock. A generator gives the runtime a channel. Each `yield*` reports "I need this", the driver resolves it, and the dependency is recorded **in the type**: ```typescript const { TaskList } = craftService( { name: 'TaskList', providedIn: 'function' }, function* () { const api = yield* TaskApi(); // tracked const tasks = yield* state('tasks', []); // tracked return tasks; }, ); ``` Everything downstream reads that type: the route DI check, the testing register, the dependency snapshot. ## The rule > Every named entity yields what it does not own. A factory, a `craftComputed`, > a `craftMethod`, a generator insertion member — each one records **its** > dependencies with `yield*`. Owning means: the primitive internals handed to **this** insertion (`state`, `set`, `update`, `patch`). Everything else — another primitive, a service, a sibling method, an input, a nested resource reader — is yielded. ```typescript const counter = yield* state('counter', 0, ({ state, update }) => ({ increment: () => update((value) => value + 1), doubled: craftComputed(function* () { return (yield* state()) * 2; }), })); const stats = craftComputed('stats', function* () { return (yield* counter()) + (yield* counter.doubled()); }); ``` `increment` may return `update(...)` directly: it is not a generator, and the insertion wrapper consumes the write. `doubled` does not own `state()`, so it yields it. `stats` does not own `counter`, so it yields both readers. In a Craft template, pass the reader or the method instead of wrapping a synchronous call: ```typescript p(counter); button({ click: counter.increment }, '+'); ``` `craftUse(...)` is the synchronous boundary: when there is no generator to yield from, a field or callback can drive the primitive with it instead. That is the end of the graph, which is why `craftUse` has nothing to track. Use it in tests and other synchronous boundaries too: `craftUse(counter.increment())`. Yield only what you use: `yield* TaskApi.fetchAll()` records one property instead of the whole service, which is what keeps test registers small. See [Shaping the public API](/guide/app/expose-api). The `craft-ts/require-yieldable-reactive-read`, `craft-ts/require-yieldable-insertion-write` and `craft-ts/require-yieldable-template-method` ESLint rules enforce this. See [ESLint rules](/guide/routing/eslint-rules). ## `craftGen` — a tracked generator outside a service Build reusable generator factories that can be composed with `yield*` and that short-circuit through typed `craftException` values. `craftGen(factory)` wraps a generator factory and returns an invoker you delegate to with `yield*`. It keeps the inner generator model intact: * dependency yields still flow to the outer driver; * the success value is returned through `yield*`; * `craftException(...)` results are converted into a `CraftGenShortCircuit`; * the reachable exception codes remain visible at the type level. That makes it the right tool for reusable route logic — role checks, feature flags, onboarding gates. ### The common case ```typescript import { craftException, craftGen } from '@craft-ts/core'; export const roleGuard = craftGen(function* (...roles: Role[]) { const { user } = yield* Auth(undefined, ({ user }) => ({ user })); const currentUser = yield* user(); if (!currentUser) { return craftException({ _tag: 'NOT_AUTHENTICATED' }); } return roles.includes(currentUser.role) ? true : craftException({ _tag: 'FORBIDDEN_ROLE' }); }); export const noPizzeriaGuard = craftGen(function* () { const { pizzeria } = yield* Auth(undefined, ({ pizzeria }) => ({ pizzeria })); return (yield* pizzeria()) ? craftException({ _tag: 'HAS_PIZZERIA' }) : true; }); ``` Used from a route: ```typescript canActivate: function* () { yield* roleGuard(ROLES.PIZZERIA_ADMIN); yield* noPizzeriaGuard(); return true; }, ``` ### Why it matters Without it, reusable guards turn into copy-pasted generator blocks with repeated branching and ad hoc exception handling. `craftGen` lets you: * parameterise one guard and reuse it across routes; * keep the route logic readable by composing with `yield*`; * preserve exhaustiveness, because every reachable exception code stays typed; * keep route dependency tracking intact, because the yielded dependencies still surface to the surrounding route. In practice this is the difference between "a guard that works" and "a guard you can safely reuse and evolve". ### How it behaves * A normal return value comes back from `yield*` unchanged. * A returned `craftException` makes the wrapper throw `CraftGenShortCircuit`. * Yielded dependencies are relayed unchanged to the caller. * When you compose several guards, the first exception wins. ## Pitfalls **A primitive invocation is single-use.** Each call produces one generator, to be consumed exactly once — don't store one and `yield*` it twice. ## See Also * [Route guards](/guide/routing/guards) * [ESLint rules](/guide/routing/eslint-rules) — `require-yieldable-reactive-read` and siblings * [Program operators](/guide/advanced/program-operators) — `catchTag` and `retry` * [Exceptions as values](/guide/concepts/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/concepts/insertions.md --- # Insertions An insertion is a function that receives a primitive's internals and returns what to expose on it. It is how behaviour gets attached to state — and how it gets reused. **Use one** whenever a primitive needs methods, computed values, or a ready-made behaviour like storage persistence. Every primitive accepts one insertion directly. For several insertions, prefer the typed helper for that primitive; see [Typed insertion pipes](/guide/concepts/insertion-pipes). Use `craftPipe` when you need a universal pipe or an explicit nested context. ## The common case The library's insertions and the ones you write are the same shape, so they compose in the same pipe: ```typescript import { craftUnique, insertStoragePersister, insertPaginationPlaceholderData, insertReactOnMutation, insertQueryPipe, insertStatePipe, query, } from '@craft-ts/core'; const users = yield* query( 'users', { params: pagination, identifier: (params) => `${params.page}-${params.pageSize}`, loader: function* ({ params }) { return yield* ApiService.getDataList(params); }, }, insertQueryPipe( insertStoragePersister(craftUnique({ storeName: 'app', key: 'users', })), insertPaginationPlaceholderData({ initialValue: [] as User[] }), insertReactOnMutation(deleteUser, { filter: ({ mutationIdentifier, queryResource }) => !!queryResource.value()?.some((u) => u.id === mutationIdentifier), optimisticUpdate: ({ queryResource, mutationIdentifier }) => removeOne({ entities: queryResource.value(), id: mutationIdentifier, }), }), ), ); ``` The typed helper supplies the query context to each member and keeps the primitive call free of context plumbing. ## Deep-yieldable collection items When a component reads several properties from the same `forNode` item, expose an explicit deep-yieldable view of the collection. The original collection keeps its existing contract, while the named view gives the item callback lazy readers for the item's properties: Before: ```typescript forNode(catalog.products, { track: (product) => product.id }, (product) => article([ span(function* () { return (yield* product()).category; }), span(function* () { return (yield* product()).name; }), ]), ); ``` After: ```typescript import { insertDeepYieldable, state } from '@craft-ts/core'; const catalog = yield* state( 'catalog', { products }, insertDeepYieldable('products'), ); forNode( catalog.deepYieldableProducts, { track: (product) => product.id }, (product) => article([span(product.category), span(product.name)]), ); ``` `insertDeepYieldable('products')` leaves `catalog.products` unchanged and adds `catalog.deepYieldableProducts`. `forNode` applies the item projection only to the named deep reader; ordinary lists keep their existing `yield* item()` contract. The `craft-ts/prefer-deep-yieldable-for-item` ESLint rule warns when the before pattern is repeated in a component template. ## Deep projections of query values When a query returns an object, use `insertDeepYieldableValue()` when its properties are consumed by the template. The insertion targets `value` only, so the primitive keeps its normal API while the resolved object exposes lazy, yieldable property readers: ```typescript import { insertDeepYieldableValue, query, } from '@craft-ts/core'; const productQuery = yield* query( 'productDetails', { method: (id: string) => id, loader: ({ params }) => api.getProduct(params), }, insertDeepYieldableValue(), ); // In a template: productQuery.value.name // In a generator: yield* productQuery.value.name() ``` For an identified query, the same insertion is applied to the selected resource values: ```typescript const product = productQuery.select(productId); if (product) { yield* product.value.name(); } ``` This is deliberately different from `insertDeepYieldable()`, which adapts the primitive's root value and is still useful for object-valued `state`. ::: tip A single insertion needs no pipe Pass it directly: ```typescript const user = yield* query('user', config, insertStoragePersister({ … })); ``` ::: ## Writing your own There is nothing special about a library insertion. Yours is a function of the same shape: ```typescript const counter = yield* state( 'counter', 0, insertStatePipe( ({ update, set }) => ({ increment: () => update((c) => c + 1), reset: () => set(0), }), ({ state }) => ({ isOdd: craftComputed(function* () { return (yield* state()) % 2 === 1; }), }), ), ); ``` Extract it to a named function the moment two primitives want the same behaviour — that is the whole extension mechanism. A member can also be a `function*`, in which case it can `yield*` services and those dependencies fold into the enclosing graph. A `craftComputed` or generator method must yield every reader it does not own — including this primitive's `state()` / `update()` / sibling methods on `insertions`. ## What piping guarantees Piping is strictly equivalent to attaching members one by one: * members run **left to right**; * each member sees the previous members' outputs on `context.insertions`; * the outputs are the **intersection** of all members' — on a key conflict, the rightmost wins at runtime; * tracked dependencies are the **union** of all members', so `ExtractDeps` sees every one; * each member is **wrapped individually**, so correlation-id tracking and app snapshots observe them separately. ## Nesting Pipes nest freely, including inside `insertSelect` — each level re-passes its own context: ```typescript const board = yield* state( 'board', { ui: { activeColor: 'black' }, grid: createInitialGrid() }, insertStatePipe( insertStoragePersister(craftUnique({ storeName: 'app', key: 'board', })), () => ({ resetAll$: source$('resetAll$') }), insertSelect('grid', (gridContext) => craftPipe( gridContext, ({ state, update }) => ({ addRow: () => update((grid) => [...grid, createNextRow(grid)]), rowIndexes: craftComputed(function* () { return (yield* state()).map((_row, index) => index); }), }), insertSelect('row', ({ update }) => ({ /* … */ })), ), ), ), ); ``` ## Pitfalls **Choosing the wrong pipe.** Use the primitive-specific helper for a direct composition. `craftPipe` still requires an explicit context and is the right choice for universal or nested compositions. **Two members exporting the same key.** The rightmost wins silently at runtime. Name your outputs so they don't collide. ::: details Why the context is explicit It is what makes one universal pipe possible for all five primitives. The outer `(context) => …` lambda is contextually typed *by the primitive*, so TypeScript knows the exact context shape before it resolves the `craftPipe` call. Inline lambdas keep full contextual typing, higher-order factories like `insertReactOnMutation(...)` match as before, and the primitive's `Exceptions` inference is never degraded. ::: ## See Also * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) * [Injectable runtime context](/guide/concepts/primitive-anatomy#injectable-runtime-context) — recovering `set` / `update` / `patch` from DI, including for WebMCP * [Selecting](/guide/state/select) — `insertSelect` and nested insertions * [Reacting to mutations](/guide/state/react-on-mutation) --- --- url: https://craft-ts.github.io/craft/guide/concepts/insertion-pipes.md --- # Typed insertion pipes Each primitive accepts one insertion directly. When a primitive needs several insertions, use the pipe named after that primitive: | Primitive | Typed pipe | | -------------- | ------------------------ | | `state` | `insertStatePipe` | | `query` | `insertQueryPipe` | | `mutation` | `insertMutationPipe` | | `queryParams` | `insertQueryParamsPipe` | | `asyncProcess` | `insertAsyncProcessPipe` | | `craftStateMachine` | `insertStateMachinePipe` | The typed pipe keeps the primitive call readable and gives every member the correct contextual type. Members run from left to right, and each member can read the outputs of the members before it through `insertions`. ## State ```typescript import { craftComputed, insertStatePipe, state } from '@craft-ts/core'; const { counter } = yield * state( 'counter', 0, insertStatePipe( ({ update }) => ({ increment: () => update((value) => value + 1), }), ({ state, insertions }) => ({ isOdd: craftComputed(function* () { return (yield* state()) % 2 === 1; }), incrementAndReport: function* () { yield* insertions.increment(); return yield* state(); }, }), ), ); ``` ## Query ```typescript import { insertStoragePersister, insertQueryPipe, query, } from '@craft-ts/core'; const { users } = yield * query( 'users', { params: () => ({ page: 1 }), loader: ({ params }) => api.getUsers(params), }, insertQueryPipe( insertStoragePersister(craftUnique({ storeName: 'app', key: 'users', })), ({ resource }) => ({ reloadUsers: function* () { return yield* resource.reload(); }, }), ), ); ``` ## Mutation ```typescript import { insertMutationPipe, mutation } from '@craft-ts/core'; const { saveUser } = yield * mutation( 'saveUser', { method: (user: User) => user, loader: ({ params }) => api.saveUser(params), }, insertMutationPipe( ({ resource }) => ({ reload: function* () { return yield* resource.reload(); }, }), ({ insertions }) => ({ reloadTwice: function* () { yield* insertions.reload(); yield* insertions.reload(); }, }), ), ); ``` ## URL state ```typescript import { craftComputed, insertQueryParamsPipe, queryParams } from '@craft-ts/core'; const { filters } = yield * queryParams( 'filters', { state: { page: { fallbackValue: 1 }, search: { fallbackValue: '' }, }, }, insertQueryParamsPipe( ({ state }) => ({ hasSearch: craftComputed(function* () { return (yield* state()).search.length > 0; }), }), ({ state, patch }) => ({ nextPage: function* () { const current = yield* state(); return yield* patch({ page: current.page + 1 }); }, }), ), ); ``` ## Async process ```typescript import { insertAsyncProcessPipe, asyncProcess } from '@craft-ts/core'; const { search } = yield * asyncProcess( 'search', { method: (term: string) => term, loader: ({ params }) => api.search(params), }, insertAsyncProcessPipe( () => ({ source: 'search-box' as const }), ({ insertions }) => ({ prefixTerm: (term: string) => `${insertions.source}:${term}`, }), ), ); ``` ## When to use `craftPipe` Use a single insertion directly when there is no composition: ```typescript state('counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), })); ``` Keep [`craftPipe`](/guide/concepts/insertions) for universal compositions that need an explicit context, especially nested insertions such as `insertSelect`: ```typescript state('board', initialBoard, (context) => craftPipe( context, insertSelect('grid', (gridContext) => craftPipe(gridContext, ({ update }) => ({ reset: () => update(() => []), })), ), ({ state }) => ({ rowCount: craftComputed(function* () { return (yield* state()).grid.length; }), }), ), ); ``` The typed pipes delegate to `craftPipe`, so their runtime semantics remain the same: insertion outputs are merged left to right, generator insertions are driven, and each member keeps its own observability wrapper. --- --- url: https://craft-ts.github.io/craft/guide/concepts/exceptions.md --- # Exceptions as values A declared failure is a **value you return**, not something you throw. It travels through types instead of escaping through the stack — so the compiler can see it, follow it, and tell you when nobody handled it. That is the whole idea, and it rests on a line most codebases leave blurry: * an **exception** is a failure you declared, expect, and intend to handle — "this email is taken", "the session expired", "that id is malformed"; * an **error** is everything else — the unexpected kind, which should surface loudly rather than be silently absorbed. A `try/catch` tells you nothing about what it might catch. A returned `craftException` carries its code and payload all the way to whoever handles it, and the set of reachable codes is a **type** — which is what makes exhaustive checking possible at all. ## Declaring one ```typescript import { craftException } from '@craft-ts/core'; craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title }); ``` The first argument carries the `code` (and an optional `scope`); the second is a free-form **payload**, whose type flows all the way to whoever handles it. ## Where they come from An exception is a **returned value**, not a thrown one. Return it from the place that detects the failure and the rest of the pipeline stops on its own: ```typescript const createTask = yield* mutation('createTask', { // rejected before any request is sent — the loader never runs method: (payload: { title: string }) => payload.title.trim().length === 0 ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title }) : payload, loader: function* ({ params }) { return yield* CraftHttpClient.post(({ response }) => ({ url: '/api/tasks', body: params, success: response(), // recognised from the response exceptions: [ function* ({ status }) { if (!(yield* status(409))) return; return craftException({ _tag: 'TITLE_ALREADY_EXISTS' }); }, ], })); }, }); ``` Guards, matchers and resolvers raise them the same way — see [Route guards](/guide/routing/guards). ## Propagating through a shared utility The interesting case isn't one primitive failing — it's a rule that lives in one place and travels. Wrap it in a [`craftGen`](/guide/concepts/generators) and it becomes a reusable unit that **short-circuits its callers**: ```typescript import { craftException, craftGen, craftUntilSettled } from '@craft-ts/core'; // one business rule, declared once export const loadReport = craftGen(function* () { const reportRef = yield* Report(); const report = yield* craftUntilSettled(reportRef); return report.totalUsers === 0 ? craftException({ _tag: 'REPORT_EMPTY' }) : report; }); ``` Consumers just `yield*` it. If the rule rejects, everything after the yield is skipped — no `if (result.isError)` at each level: ```typescript const { ReportFacade } = craftService( { name: 'ReportFacade', providedIn: 'global' }, function* () { const report = yield* loadReport(); // narrowed: never the exception return { total: report.totalUsers }; }, ); ``` `report` is the success value only. The exception left through the generator channel, and — this is the point — **`REPORT_EMPTY` is now part of `ReportFacade`'s reachable codes.** It keeps travelling up until someone deals with it. ### Stopping the propagation Two ways, and the difference matters: **Recover locally** with `catchTag`, and the code **leaves the union** — nobody upstream has to know about it: ```typescript resolve: craftResolve(function* () { return yield* loadReport().pipe( catchTag('REPORT_EMPTY', function* () { return { totalUsers: 0, generatedAt: null }; }), ); }); ``` **Let it reach the route**, and `handleExceptions` must have a handler for it — the compiler says so. That's the right choice when the failure should change what the user sees, rather than being papered over with a default value. ::: tip Composition rule When several utilities are composed, the **first exception wins** — the rest of the program doesn't run. See [Program operators](/guide/advanced/program-operators) for `catchTag` and `retry`. ::: Working example: the `slow-page` demo raises `REPORT_EMPTY` from a `craftGen` resolver and recovers it locally, so the route never declares a handler for it — [slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts). ## Reading them Every async primitive exposes its exceptions **split by origin**, and typed from the codes you declared: ```typescript createTask.hasException(); // boolean createTask.exceptions().params?.TITLE_REQUIRED; // rejected by `method` createTask.exceptions().loader?.TITLE_ALREADY_EXISTS; // produced by the request ``` The origin matters: `params` means nothing left the browser, `loader` means the server was involved. The union is closed, so the compiler knows `TITLE_ALREADY_EXISTS` exists and that `TITLE_TOO_LONG` doesn't. `queryParams` follows the same shape with a `parse` origin for decode failures. ## Handling them Where you handle an exception depends on how far it needs to travel: | The failure concerns… | Handle it… | | --------------------------- | ------------------------------------------------------------------------ | | One primitive's own UI | Read `exceptions()` where you render it | | A form's submission | [`insertFormSubmit`](/guide/forms/submit) — reshape the mutation's codes | | Whether a route can render | [Route exception handling](/guide/routing/exception-handling) | | Nothing in particular | Let it be an error — the global error component catches it | ## An unhandled exception doesn't just disappear This is the rule that ties the whole system together, and it is easy to miss. When a component's factory — or one of its providers — can raise a `craftException`, that code becomes part of the component's **initialization exceptions**. It stays attached to the component until something handles it. Most of the time what you want is a **fallback to render**, which is `catchNode.exhaustive`: ```typescript const Restricted = MyRestrictedComponent.pipe( withProviders([provideRestrictedData(/* … */)]), catchNode.exhaustive({ NO_ACCESS: () => p('You do not have access to this data.'), }), ); ``` Here the failure comes from a provider, before the template exists — so there is no source block to preserve and the fallback renders alone. When the source *does* exist and should stay visible, use the object form: ```typescript catchNode.exhaustive({ NO_ACCESS: { render: () => p('Restricted'), showSource: true, position: 'after' }, }); ``` Reach for `catchTag.exhaustive` only when the reaction is **logic** and produces no DOM — logging it, notifying a service: ```typescript catchTag.exhaustive({ NO_ACCESS: function* () { yield* ToastService.show(() => 'No access'); }, }); ``` Either way, handling a code at the component **removes it** from the component's contract and from the route's. Whatever you don't handle is **residual**, and it flows up into the route's exception union — where `handleExceptions` must cover it: ``` component factory + providers ↓ (codes not handled by .pipe) residual exceptions ↓ route exception union ── handleExceptions must be exact ``` At the route, the check is exhaustive **in both directions**: a reachable code with no handler is a type error, and a handler for a code nothing can produce is a type error too. ::: warning Where the compile error actually appears Today the enforcement is at the **route**, not at the component. The variadic component `.pipe(...)` overload is deliberately kept permissive to avoid excessive TypeScript instantiation depth, so an unhandled code there is rejected by **runtime** dispatch rather than by the compiler. The compile-time proof is [`assertExhaustiveRouteExceptions(routes)`](/guide/routing/exception-handling#exhaustiveness). Practical consequence: a component rendered outside any route — in a test, or nested inside another component — gets no compile-time reminder. Handle its codes explicitly. ::: Three utilities do the handling, and which one you want depends on whether the result is logic or DOM: | Utility | Handles in | Produces | | ----------------------- | ---------- | --------------------------------------------- | | `catchNode.exhaustive` | template | a fallback around a source block — **the default choice** | | `matchNode.exhaustive` | template | a fallback rendered from an exception value or signal | | `catchTag.exhaustive` | logic | nothing renderable — call a service, log, … | Details on [Customization](/guide/components/customization#choosing-an-exception-utility). ## Why exhaustiveness is worth the ceremony Because the set of reachable codes is a **type**, the compiler can compare it against the set you handled. At the route level that comparison is an assertion you place once per collection: ```typescript assertExhaustiveRouteExceptions(demoRoutes); ``` A code that can be produced but isn't handled is a compile error. So is a handler for a code nothing produces. Add a `craftException` to a guard six months from now and the routes file tells you exactly which routes must decide what to do about it. The assertion itself is an unused call unless it stays in the file. [Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a collection omits it. ## Pitfalls **Throwing instead of returning.** A thrown value is an *error*: it bypasses the typed union and lands in the global error path. Return the `craftException`. **Reusing one code for two meanings.** The code is the identity the handlers match on. Two different failures deserve two codes, with payloads carrying the detail. ## See Also * [Route exception handling](/guide/routing/exception-handling) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place * [query](/guide/state/server-state) — typed HTTP exception matchers * [Form exception handling](/guide/forms/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/state/local-state.md --- # Local state `state` holds a value you own, in memory, as a signal — with its methods and derived values attached to it rather than scattered around it. **Use it when** the value's home is your application: a form draft, a selection, a toggle, a counter. **Not when** the value lives on a server ([`query`](/guide/state/server-state)), in the URL ([`queryParams`](/guide/state/url-state)), or is the result of an async action ([`asyncProcess`](/guide/state/async-process)). ## The common case ```typescript import { craftComputed, state } from '@craft-ts/core'; const counter = yield* state('counter', 0, ({ state, update, set }) => ({ increment: () => update((value) => value + 1), decrement: () => update((value) => value - 1), reset: () => set(0), isEven: craftComputed(function* () { return (yield* state()) % 2 === 0; }), })); yield* counter(); // 0 yield* counter.increment(); yield* counter.isEven(); // false yield* counter.reset(); ``` The insertion context gives you `state` (the current value as a yieldable reader), `set` and `update`. Non-generator methods may return `update(...)` directly — the insertion wrapper consumes the write. `isEven` yields `state()` because the computed does not own that reader. In a template, pass the reader or the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a synchronous boundary, `craftUse(counter.increment())`. ::: tip New to the shape? The name, the destructuring, the `yield*` driver and the single-use rule are the same for all five primitives — see [Anatomy of a primitive](/guide/concepts/primitive-anatomy). ::: ## Deriving from another reader The initial value can be a Craft reader, in which case the state follows it: ```typescript const origin = yield* state('origin', 5); const doubled = yield* state( 'doubled', craftComputed('originDoubled', function* () { return (yield* origin()) * 2; }), ); yield* doubled(); // 10 ``` ## Composing several insertions One insertion function gets crowded. Split it and compose with `insertStatePipe`: ```typescript import { craftComputed, insertStatePipe, state } from '@craft-ts/core'; const counter = yield* state( 'counter', 0, insertStatePipe( ({ update, set }) => ({ increment: () => update((current) => current + 1), reset: () => set(0), }), ({ state }) => ({ isOdd: craftComputed(function* () { return (yield* state()) % 2 === 1; }), }), ), ); yield* counter.increment(); yield* counter.isOdd(); // true ``` Each function receives the same context and contributes its own slice. See [Insertions](/guide/concepts/insertions). ## Keep transitions with their state Pass an intent to the state that owns a value. Do not read the value in a caller, calculate a replacement there, and pass the replacement back through a generic method such as `replace` or `update`. ```typescript const todos = yield* state('todos', initialTodos, ({ update }) => ({ move: (todoId: string, direction: 'up' | 'down') => update((current) => { const from = current.findIndex((todo) => todo.id === todoId); const to = from + (direction === 'up' ? -1 : 1); if (from < 0 || to < 0 || to >= current.length) return current; const next = [...current]; const [todo] = next.splice(from, 1); if (!todo) return current; next.splice(to, 0, todo); return next; }), })); // The caller supplies only the intent. yield* todos.move(todoId, 'up'); ``` The recommended and Effect ESLint presets enable `craft-ts/no-external-state-transition`. It flags calls such as `todos.replace(nextTodos)` or `todos.update(transition)` outside the state insertion. Named state commands remain available to callers; simple values such as an input's `setValue(value)` are allowed. Prefer not to expose generic whole-state mutators from domain states. The rule checks generic mutator method names on values created by Craft's `state(...)` primitive. It does not infer whether an arbitrarily named method such as `replaceTodos(nextTodos)` is a generic replacement; the state interface should make its command semantics clear. ## Driving it from events Bind a method to a [`source$`](/guide/reactivity/source) with [`on$`](/guide/reactivity/on) when the trigger is an event rather than a call: ```typescript const increment = source$('increment'); const reset = source$('reset'); const myState = yield* state('myState', 0, ({ update, set }) => ({ onIncrement: on$(increment, () => update((v) => v + 1)), onReset: on$(reset, () => set(0)), })); increment.emit(); // after yield* / craftUse, myState is 1 reset.emit(); // after yield* / craftUse, myState is 0 ``` Like every craft primitive, a source is **named**, and the name must match the variable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule enforces it and autofixes it. Note that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods bound to a source work internally only. ## Yielding dependencies An insertion can be a `function*`, so it can pull in services: ```typescript yield* state('counter', 0, function* ({ state }) { const log = yield* Console.log; return { logValue: function* () { yield* log(`State value: ${yield* state()}`); }, }; }); ``` Prefer yielding a craft service over reaching into a runtime container — yielding is what makes the dependency visible to the route DI check and to test registers. ## Pitfalls **Don't duplicate derived state.** If a value is a function of another, it is a `craftComputed` inside an insertion that `yield*`s its readers, or a `state` whose second argument is that source — not a second `state` kept in sync by an effect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync) fails the architecture suite when an effect writes another primitive. **Keep slices granular.** One `state` per coherent concern. A single object holding five unrelated things makes every consumer depend on all five. ::: details Advanced — scoping providers to one state Use the object form with `$self` when a state needs its own provider scope: ```typescript const counter = yield* state( 'counter', { $self: function* () { return yield* CounterPreferences.initialValue(); }, providers: [provideCounterPreferences(), provideCounterAnalytics()], }, ({ update }) => ({ increment: function* () { yield* CounterAnalytics.track('increment'); return yield* update((value) => value + 1); }, }), ); ``` ::: ::: tip Advanced — injectable writes Insertion methods also provide `injectStateMethodRuntimeContext()`, which recovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers, WebMCP tools, and other advanced patterns — everyday insertions already receive those methods as arguments. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ::: ## See Also * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) * [Insertions](/guide/concepts/insertions) * [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary --- --- url: https://craft-ts.github.io/craft/guide/state/state-machines.md --- # State machines `craftStateMachine` models a finite workflow as a named, typed, reactive primitive. It is useful when a feature has a small set of meaningful modes — for example, an editor that is either reading or editing, a form that moves through validation and submission, or a resource that moves through loading, success and failure. The important part is not only that the machine has states. It is that the states and the transitions are **100% declarative**: the machine describes which events can enter each state, and Craft derives the current state from those declarations at runtime. ## A different perspective on transitions Many state-machine APIs describe a transition from the current state: ```text while in reading, when edit happens, go to editing ``` That perspective makes the target explicit in the transition itself. You look at the `reading` state's handlers to discover where an `edit` event goes. Craft reverses the perspective. Each entry in the transitions record describes **how to enter that step**. The record key is the target step, and `transit()` inside that step's block means “attempt to enter this step”. It does not take a state name because the surrounding key already supplies it. If you are used to XState or a similar state-machine API, the difference is the direction in which you read the same workflow graph. You may usually start from `reading` and ask “where does `edit` go?”. In Craft, you start from `editing` and ask “which event makes the machine enter `editing`?”. The graph is still explicit; its declarations are owned by their destination step. ```typescript function* (context, transit) { return { reading: transitionStep(function* () { yield* initStateMachine(() => transit()); yield* on$(context.commit$, () => transit()); yield* on$(context.cancel$, () => transit()); }), editing: transitionStep(function* () { yield* on$(context.edit$, () => transit()); }), }; } ``` Reading this declaration tells you immediately: * `reading` is entered during initialisation, after `commit`, or after `cancel`; * `editing` is entered after `edit`. There is no `currentStep = ...`, no imperative transition table, and no string such as `transit('editing')`. The destination is the step whose block declared the event. If an event attempts to enter the step that is already active, the attempt is a no-op. This makes the transition logic especially easy to inspect: to answer “when can the machine enter `reading`?”, read the `reading` block and look at the events it listens to. The machine's transition behavior is visible in the declarations themselves. The same principle applies to the steps themselves. Each step registered in a `craftStateMachine` can be 100% declarative: its context can be assembled from Craft primitives, its event reactions can be expressed with `on$`, and its view can be selected from the typed step context. A step does not need an imperative “enter” function that manually changes the machine or coordinates the rest of the feature. ## The text editor example The demo application contains a complete [declarative text editor example](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts). It has two steps: ```text reading ← initialisation, commit, cancel editing ← edit ``` The machine's context owns the events and the text state. The transitions only declare which events enter which step: ```typescript const machine = yield * craftStateMachine( 'textEditor', function* () { const edit$ = yield* source$('text.edit'); const commit$ = yield* source$('text.commit'); const cancel$ = yield* source$('text.cancel'); const text = yield* state( 'text', { committedValue: '', value: '' }, insertStatePipe(insertDeepYieldable(), ({ patch }) => ({ change: (value: string) => patch(() => ({ value })), commit: on$(commit$, () => patch((current) => ({ committedValue: current.value })), ), cancel: on$(cancel$, () => patch((current) => ({ value: current.committedValue })), ), })), ); return { edit$, commit$, cancel$, text }; }, function* (context, transit) { return { reading: transitionStep(function* () { yield* initStateMachine(() => transit()); yield* on$(context.commit$, () => transit()); yield* on$(context.cancel$, () => transit()); }), editing: transitionStep(function* () { yield* on$(context.edit$, () => transit()); }), }; }, function* ({ text, cancel$, commit$, edit$ }) { return { reading: { text, edit$ }, editing: { text, commit$, cancel$ }, }; }, ); ``` The first factory creates the shared context. The second factory declares the machine's steps and their incoming events. The third factory gives each step a typed context for its view: the reading view can edit, while the editing view can commit or cancel. `insertDeepYieldable()` makes the object-valued `text` state deeply readable. The template can therefore bind to `reading.text.value` and `reading.text.committedValue` without creating a separate `craftComputed` for each property. ## Rendering the current step The machine exposes `currentStep` as a union of step names and `currentStepWithContext` as a discriminated union. Use the latter when each step needs different data or actions: ```typescript matchNode.exhaustive(machine.currentStepWithContext, 'step', { reading: (reading) => div([ p(['Committed value: ', reading.text.committedValue]), p(['Current value: ', reading.text.value]), button({ click: () => reading.edit$.emit() }, 'Edit'), ]), editing: (editing) => div([ input({ value: editing.text.value, input: function* (event) { yield* editing.text.change(event.target.value); }, }), button({ click: () => editing.commit$.emit() }, 'Commit'), button({ click: () => editing.cancel$.emit() }, 'Cancel'), ]), }); ``` `matchNode.exhaustive` checks that every step is handled, and narrows the handler argument to that step's context. Adding a new step therefore produces compile-time feedback both in the machine's transition record and in the rendering code. If the view only needs the name, use the shorter scalar form: ```typescript matchNode.exhaustive(machine.currentStep, { reading: () => p('Reading'), editing: () => p('Editing'), }); ``` ## Guards The event declaration says when a transition is attempted. A `transitionGuard` says whether that attempt is accepted. Guards can be local to one step, global to the machine, or attached to one particular attempt: ```typescript editing: transitionStep(function* () { yield* on$(context.edit$, () => transit().pipe( transitionGuard(({ context }) => context.form.isValid()), ), ); }), ``` A guard can also be a generator and yield Craft services. Those dependencies become part of the machine's dependency graph. This keeps the condition declarative as well: the transition is still described by its event and its accepted predicate, rather than by an imperative event handler that manually coordinates state. ### Effect services in a guard In an Effect-enabled frontend, use `transitionGuardEffect` when the decision is declared synchronous and needs an Effect service. It is the Effect-aware form of `transitionGuard`: ```typescript import { Context, Effect, Layer } from 'effect'; import { SyncOp, transitionGuardEffect } from '@craft-ts/effect'; type CheckoutPolicyShape = { readonly canSubmit: (total: number) => Effect.Effect; }; class CheckoutPolicy extends Context.Service< CheckoutPolicy, CheckoutPolicyShape >()('app/CheckoutPolicy') {} const CheckoutPolicyLive = Layer.succeed(CheckoutPolicy, { canSubmit: (total) => Effect.gen(function* () { yield* SyncOp; return total > 0; }), }); editing: transitionStep(function* () { yield* on$(context.submit$, () => transit().pipe( transitionGuardEffect(() => Effect.gen(function* () { yield* SyncOp; const policy = yield* CheckoutPolicy; return yield* policy.canSubmit(context.total()); }), ), ), ); }); ``` `transitionGuardEffect` runs through `syncEffect`, so `SyncOp` is required at compile time and an Effect that suspends is rejected. The Effect service used by the guard is also carried into the machine's dependency graph. Provide its implementation with `provideLayer(...)` at the application, component or route scope: ```typescript const routes = craftRoutes('checkout', [ { path: '', ...loadCraftComponent( () => import('./checkout').then(({ default: component }) => component), [provideLayer(CheckoutPolicyLive)] as const, ), }, ]); ``` For an asynchronous policy check, do not put the Effect in a guard. Use `asyncProcessEffect`, `queryEffect` or `mutationEffect`, then model the `pending`, success and failure outcomes as explicit machine steps. A transition guard must answer synchronously; an asynchronous decision is a workflow state. ## Composition and extensions The final machine insertion has the same role as an insertion on `state`, `query`, or `mutation`. Use it for derived values, view helpers, selectors, or reusable behavior. For several machine insertions, use `insertStateMachinePipe`: ```typescript const machine = yield * craftStateMachine( 'editor', contextFactory, transitions, stepContextFactory, insertStateMachinePipe( withStateMachineHistory({ persist: { storeName: 'demo', key: 'editor' }, }), withBackNavigation(), ({ currentStep }) => ({ isReading: craftComputed('isReading', function* () { return (yield* currentStep()) === 'reading'; }), }), ), ); ``` History, back/forward navigation, and derived flags are therefore extensions of the machine rather than hidden responsibilities of its core. The machine remains focused on declaring steps and the events that enter them. ## When to use a state machine Use `craftStateMachine` when: * the feature has a finite set of named workflow steps; * different steps expose different actions or view data; * events, recomputations, or initialisation determine when a step is entered; * exhaustive handling of steps is valuable; * guards or reusable workflow extensions belong on the state-machine boundary. For a single independent value, use [`state`](/guide/state/local-state). For a server read or write, use [`query`](/guide/state/server-state) or [`mutation`](/guide/state/mutations). A state machine can compose those primitives in its context when the workflow needs them. ## API summary | API | Role | | ------------------------ | ------------------------------------------------------ | | `craftStateMachine` | Creates the named state-machine primitive | | `transitionStep` | Declares how one step is entered | | `transit()` | Creates an attempt to enter the surrounding step | | `initStateMachine` | Declares the attempt that establishes the initial step | | `transitionGuard` | Accepts or rejects a transition attempt | | `transitionGuardEffect` | Effect-aware guard for declared-synchronous Effects | | `currentStep` | Reactive union of step names | | `currentStepWithContext` | Reactive discriminated union of step contexts | | `insertStateMachinePipe` | Composes machine insertions | ## See also * [Local state](/guide/state/local-state) * [Typed insertion pipes](/guide/concepts/insertion-pipes) * [Fine-grained reactivity](/guide/components/fine-grained-reactivity) * [The text editor example on GitHub](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts) --- --- url: https://craft-ts.github.io/craft/guide/state/server-state.md --- # query `query` fetches data and owns its whole lifecycle — loading, resolved, exception — re-running itself when its inputs change. **Use it when** you display data that lives on a server. **Not when** you write to the server ([`mutation`](/guide/state/mutations)) or run a one-off async action that isn't a fetch ([`asyncProcess`](/guide/state/async-process)). ::: warning One source of truth Don't copy a query's result into a `state`. The query *is* the state. Don't reload it from a `craftEffect` either — put the inputs in `params` so the loader re-runs when they change. ::: ## The common case ```typescript import { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core'; const { userQuery } = yield * query('userQuery', { params: () => ({ userId: currentUserId() }), loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/users/${params.userId}`, success: response(), })); }, }); ``` ## Uploading a raw binary body `CraftHttpClient` is the JSON transport: it serializes `payload` as JSON. For an upload whose body is already a `Blob`, `ArrayBuffer`, `FormData`, or another `BodyInit`, use `CraftBinaryHttpClient.put(...)` instead: ```typescript import { CraftBinaryHttpClient, mutation, response, } from '@craft-ts/core'; type UploadResult = { id: string }; const { uploadFile } = yield * mutation('uploadFile', { method: (file: Blob) => file, loader: function* ({ params: file }) { return yield* CraftBinaryHttpClient.put(({ response }) => ({ url: '/api/files', payload: file, success: response(), })); }, }); ``` The request remains owned by the mutation: loading state, cancellation, response decoding, typed exceptions, tracing, and dependency tracking work the same way as with `CraftHttpClient`. `CraftBinaryHttpClient` currently exposes `PUT` for raw bodies; use `CraftHttpClient.post(...)`, `.put(...)`, or `.patch(...)` when the server expects a JSON payload. Do not replace this with `fetch(...)` in the loader. Direct transport loses the Craft request lifecycle and is rejected by `prefer-craft-http-transport`. `params` is reactive: when what it returns changes, the loader runs again. The result carries the full async state: ```typescript userQuery.value(); // User | undefined — never throws userQuery.isLoading(); // boolean userQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception' userQuery.exception(); // craftException | undefined ``` ::: tip `value()` is safe to read in templates and computed signals: it returns `undefined` when the query has no resolved value. ::: ## Reading only settled data Use `settledValue` when a template or derived computation requires a real value. It suspends to the nearest `pendingNode` while the first value is unavailable, propagates query exceptions to a `catchNode`, and keeps the previous value during a reload. ```typescript const userName = craftComputed('userName', function* () { return (yield* settled(userQuery)).name; }); const user = craftUse(userQuery.settledValue()); ``` Insertion contexts keep the existing fallback behaviour of `state()`. Use `settledState()` when `yield*` (or `craftUse`) should return a non-nullable value and suspend until the current resource is available. ## Triggering it yourself When the trigger is a user action rather than a reactive input, use `method` instead of `params`: ```typescript const { searchQuery } = yield * query('searchQuery', { method: (term: string) => term, loader: function* ({ params: term }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/search?q=${term}`, success: response>(), })); }, }); // In a tracked generator, consume the trigger with yield*. yield * searchQuery.call('craft'); ``` From an ordinary UI callback, the imperative form remains valid: `click: () => searchQuery.call(term)`. Do not put either form in a `craftEffect` dependency graph; use reactive `params` for data loading. ## Adding derived values Same insertion mechanism as any primitive: ```typescript const { todosQuery } = yield * query( 'todosQuery', { params: () => ({ completed: showCompleted() }), loader: async ({ params }) => (await fetch(`/api/todos?completed=${params.completed}`)).json(), }, ({ value, isLoading }) => ({ count: craftComputed(function* () { return (yield* value())?.length ?? 0; }), isEmpty: craftComputed(function* () { return !(yield* isLoading()) && (yield* value())?.length === 0; }), }), ); yield* todosQuery.count(); ``` An insertion can also be a `function*` when it needs to yield services. ## Enriching every item in a list When a query returns an array, `insertQuerySelect` attaches an insertion to each selected item. The selector keeps the item type, so derived values can use its properties without casting: ```typescript import { craftComputed as computed } from '@craft-ts/core'; import { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core'; type User = { id: string; firstName: string; lastName: string; role: 'admin' | 'member'; }; const { usersQuery } = yield * query( 'usersQuery', { params: () => ({ teamId: currentTeamId() }), loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/teams/${params.teamId}/users`, success: response(), })); }, }, insertQuerySelect('user', ({ state }) => ({ displayName: craftComputed(function* () { const user = yield* state(); return `${user.firstName} ${user.lastName}`; }), roleLabel: craftComputed(function* () { return (yield* state()).role === 'admin' ? 'Administrator' : 'Member'; }), })), ); // `selectUser` targets one item in the returned array. const firstUser = usersQuery.selectUser(0); yield* firstUser?.displayName(); // 'Ada Lovelace' yield* firstUser?.roleLabel(); // 'Administrator' ``` The same pattern supports selecting a nested object property with `insertQuerySelect`, while preserving the selected property's type. ## Avoiding the flicker when inputs change **This is already the default.** When `params` change, the previous value stays visible until the new one resolves, so a paginated list never blanks out mid-navigation. You only touch the option to turn it **off**: ```typescript query('postsQuery', { params: () => ({ page: currentPage() }), preservePreviousValue: () => false, // clear the value while loading loader: async ({ params }) => (await fetch(`/api/posts?page=${params.page}`)).json(), }); ``` ::: tip Not consulted for parallel queries With an `identifier`, each key keeps its own resource, so there is no "previous value" to preserve — the option is ignored on that path. ::: ## Reacting to a mutation Rather than reloading by hand after a write, declare the link: ```typescript import { insertQueryPipe, insertReactOnMutation, insertStoragePersister, } from '@craft-ts/core'; const userQuery = yield* query( 'userQuery', { params: () => ({ userId: currentUserId() }), loader: /* … */, }, insertQueryPipe( insertReactOnMutation(updateUserMutation, { // apply the change immediately, before the server answers optimisticPatch: { name: ({ mutationParams }) => mutationParams.name, email: ({ mutationParams }) => mutationParams.email, }, // and go get the truth back if the mutation failed reload: { onMutationException: true }, }), insertStoragePersister(craftUnique({ storeName: 'demo-app', key: 'user-query', })), ), ); ``` Full options on [Reacting to mutations](/guide/state/react-on-mutation). ## Exceptions `exceptions()` is split by **origin** and typed from the codes you declared — `params` for what your `method` rejected before any request, `loader` for what the request produced: ```typescript import { craftException, query } from '@craft-ts/core'; const { userQuery } = yield * query('userQuery', { method: (value: string) => value.length < 3 ? craftException( { _tag: 'SEARCH_TERM_TOO_SHORT' }, { min: 3, received: value.length }, ) : value, loader: async ({ params }) => params === 'forbidden' ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params }) : { id: params, name: 'John Doe' }, }); yield * userQuery.call('ab'); userQuery.hasException(); // true userQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT; yield * userQuery.call('forbidden'); userQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN; ``` Returning a `craftException` from `method` means the loader never runs — you don't send a request you already know will fail. ## Pitfalls **No value is available yet.** Check `hasValue()` or handle the `undefined` result while the query is loading or in exception. **`params` must be cheap and pure.** It runs inside a reactive computation; side effects belong in the loader. ::: details Advanced — parallel queries by identifier `identifier` keeps one resource per key, so several runs coexist instead of replacing each other: ```typescript const userId = signal(undefined); const { userQuery } = yield * query('userQuery', { params: userId, identifier: (id) => id, loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/users/${params}`, success: response(), })); }, }); userId.set(1); userId.set(2); userQuery.select('1').value(); // user 1 userQuery.select('2').value(); // user 2 ``` ::: ::: details Advanced — typed HTTP exceptions Loader exceptions are matched declaratively: each matcher yields predicates on the response and returns a `craftException` when it recognises the failure. ```typescript loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/users/${params}`, success: response(), exceptions: [ function* ({ status, code, content }) { if (!(yield* status(400))) return; if (!(yield* code('PASSWORD_REQUIRED'))) return; if (!(yield* content('Password is required'))) return; return craftException({ _tag: 'PASSWORD_REQUIRED', scope: 'UsersFeatureForDependencies', }); }, function* ({ body, header }) { const payload = yield* body<{ errors?: Array<{ field: 'password' }>; }>(); if (!payload.errors?.some((error) => error.field === 'password')) return; if (!(yield* header('x-error-kind', 'validation'))) return; return craftException({ _tag: 'VALIDATION_HEADER_ERROR', scope: 'UsersFeatureForDependencies', }); }, ], })); } ``` Working source: [exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts). ::: ::: details Advanced — yielding dependencies from `params` `params` can be a generator, and so can an insertion: ```typescript const { userQuery } = yield * query( 'userQuery', { providers: [provideUserService(), provideUserApiService()], params: function* () { return yield* UserService.userId(); }, loader: function* ({ params: userId }) { return yield* UserApiService.get(userId); }, }, function* () { const queryTools = yield* QueryTools(); return { queryKey: `${queryTools.prefix()}:details` }; }, ); ``` ::: ::: tip Advanced — injectable writes Insertion methods provide `injectQueryMethodRuntimeContext()`, and the query value itself is published to `providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and other advanced patterns can seed or replace a result without going through the insertion callback. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ::: ## See Also * [Mutations](/guide/state/mutations) — the write side * [Reacting to mutations](/guide/state/react-on-mutation) * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) --- --- url: https://craft-ts.github.io/craft/guide/state/mutations.md --- # Mutations `mutation` is `query`'s counterpart for writes: same shape, triggered explicitly, owning its own loading and failure state. **Use it when** you send something to a server — POST, PUT, PATCH, DELETE. **Not when** you read ([`query`](/guide/state/server-state)) or run an async action that isn't a server write ([`asyncProcess`](/guide/state/async-process)). ## The common case ```typescript import { CraftHttpClient, mutation } from '@craft-ts/core'; const { createUser } = yield * mutation('createUser', { method: (payload: { name: string; email: string }) => payload, loader: function* ({ params: user }) { return yield* CraftHttpClient.post(({ response }) => ({ url: '/api/users', body: user, success: response(), })); }, }); // In a tracked generator, consume the trigger with yield*. yield * createUser.mutate({ name: 'John', email: 'john@example.com' }); createUser.isLoading(); createUser.value(); // never throws createUser.exception(); ``` `method` is the entry point: it takes what the caller passes and returns what the loader receives as `params`. It is also where you reject bad input before any request happens. ::: tip `value()` is safe to read in templates and computed signals: it returns `undefined` when the mutation has no resolved value. ::: ## Connecting it to the read side A mutation on its own leaves your list stale. Declare the link on the query rather than reloading by hand: ```typescript insertReactOnMutation(createUser, { reload: { onMutationSuccess: true } }); ``` That, plus optimistic updates, is on [Reacting to mutations](/guide/state/react-on-mutation). ## Triggering from an event Use a [`source$`](/guide/reactivity/source) as the trigger instead of calling `.mutate(...)`: ```typescript const deleteUserSource = source$<{ name: string; email: string; id: string }>(); const { deleteUser } = yield * mutation('deleteUser', { method: on$(deleteUserSource, (payload) => payload), loader: function* ({ params: user }) { return yield* CraftHttpClient.delete(({ response }) => ({ url: '/api/users', body: user, success: response(), })); }, }); deleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' }); ``` ## Rejecting bad input, and reading exceptions `exceptions()` is split by **origin** — `params` for what `method` rejected before any request, `loader` for what the request produced — and typed from the codes you declared: ```typescript const { deleteUser } = yield * mutation('deleteUser', { method: (payload: { userId: string }) => payload.userId.length < 18 ? craftException( { _tag: 'INVALID_ID' }, { min: 18, received: payload.userId.length }, ) : payload.userId, loader: function* ({ params }) { return yield* CraftHttpClient.delete(({ response }) => ({ url: '/api/user', body: params, success: response(), exceptions: [ function* ({ status }) { if (!(yield* status(403))) return; return craftException( { _tag: 'USER_ACCESS_FORBIDDEN' }, { payload: params }, ); }, ], })); }, }); yield * deleteUser.mutate({ userId: 'ab' }); deleteUser.hasException(); // true deleteUser.exceptions().params?.INVALID_ID; yield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' }); deleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN; ``` Returning a `craftException` from `method` means the loader never runs. ## Pitfalls **One in-flight run replaces the previous one** unless you declare an `identifier` (below). Deleting three rows at once without one gives you the state of the last delete only. **No value is available yet.** Check `hasValue()` or handle the `undefined` result while the mutation is loading or in exception. ::: details Advanced — parallel mutations by identifier `identifier` keeps one resource per key, so each row tracks its own state: ```typescript const { deleteUser } = yield * mutation('deleteUser', { method: (payload: { name: string; email: string; id: string }) => payload, identifier: ({ id }) => id, loader: function* ({ params: user }) { return yield* CraftHttpClient.delete(({ response }) => ({ url: '/api/users', body: user, success: response(), })); }, }); yield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' }); deleteUser.select('5')?.isLoading(); deleteUser.select('5')?.exception(); deleteUser.select('5')?.value(); ``` ::: ::: details Advanced — yielding dependencies `method`, `loader` and the insertion can all be generators, and `providers` scopes dependencies to this mutation alone. A loader must not be `async` or return a native `Promise`; use `yield*` for asynchronous Craft operations: ```typescript const { saveUser } = yield * mutation('saveUser', { providers: [provideMutationLogger(), provideUserApiService()], method: function* (user: { id: string; name: string }) { yield* MutationLogger.log(`mutate:${user.id}`); return user; }, loader: function* ({ params }) { return yield* UserApiService.save(params); }, }); ``` ::: ::: tip Advanced — injectable writes Insertion methods provide `injectMutationMethodRuntimeContext()`, and the mutation value itself is published to `providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`, and `patch` for wrappers, WebMCP tools, and other advanced patterns. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ::: ## See Also * [query](/guide/state/server-state) — the read side * [Reacting to mutations](/guide/state/react-on-mutation) * [Submitting a form](/guide/forms/submit) — wiring a form to a mutation --- --- url: https://craft-ts.github.io/craft/guide/state/url-state.md --- # queryParams `queryParams` is a state whose home is the URL's query string. Reading and writing look like any other state; the address bar follows, and so does the back button. **Use it when** the value should survive a refresh and be shareable by copying the link: filters, pagination, a selected tab. **Not when** the value is ephemeral or private — that's [`state`](/guide/state/local-state). ::: tip No synchronisation code There is no effect to write and no `ActivatedRoute` subscription. If you find yourself syncing a `state` with the URL, you want this primitive instead. ::: ## The common case ```typescript import { queryParams } from '@craft-ts/core'; const numberCodec = { decode: (value: string) => parseInt(value, 10), encode: (value: number) => String(value), }; const booleanCodec = { decode: (value: string) => value === 'true', encode: (value: boolean) => String(value), }; const pagination = yield * queryParams( 'pagination', { state: { page: { fallbackValue: 1, codec: numberCodec }, showArchived: { fallbackValue: false, codec: booleanCodec }, }, }, ({ set, update, patch, reset }) => ({ set, update, patch, reset }), ); pagination(); // { page: 1, showArchived: false } pagination.page(); // 1 pagination.patch({ showArchived: true }); // navigates to ?showArchived=true pagination.set({ page: 4, showArchived: false }); pagination.update((current) => ({ ...current, page: current.page + 1 })); pagination.reset(); ``` `?page=3&showArchived=true` becomes `{ page: 3, showArchived: true }` on load. ## Codecs are mandatory A URL only holds strings, so every parameter declares how it converts both ways. The decoded type is your application type; the encoded one is what appears in the address bar. `fallbackValue` is what you get when the parameter is absent — which is why the state type is never `undefined`. Codecs stay synchronous because they run inside the reactive URL computation. `@craft-ts/core` deliberately doesn't depend on a validation library: supply a small `{ decode, encode }` pair directly, or adapt one from the library you already use. ```typescript // arrays tags: { fallbackValue: [], codec: { decode: (value) => value.split(',').filter(Boolean), encode: (value) => value.join(','), }, }, // plain strings q: { fallbackValue: '', codec: { decode: String, encode: String } }, ``` The same pattern covers dates, enums and JSON-encoded objects. ## Effect Schema adapter When the application already uses Effect Schema, keep `queryParams` as the URL owner and adapt the schema's synchronous entry points. This keeps URL parsing typed without introducing a second URL primitive: ```typescript import { Schema } from 'effect'; const Search = Schema.String; const searchCodec = { decode: Schema.decodeUnknownSync(Search), encode: Schema.encodeSync(Search), }; const filters = yield * queryParams('filters', { state: { search: { fallbackValue: '', codec: searchCodec }, }, }); ``` Use the same adapter shape for numbers, booleans, dates, enums and arrays. A transformation schema is useful when the URL representation differs from the value used by the component; its `decode` and `encode` functions must still be synchronous. Missing values use `fallbackValue`, malformed values keep that fallback and expose `QueryParamDecodeError`, and `reset()` removes the keys from the URL. This covers the common filter, sort and pagination cases while preserving stable serialization in one place. There is deliberately no `queryParamsEffect`: URL synchronisation belongs to Craft, while an Effect query or loader reacts to the resulting typed state. ## Custom methods ```typescript yield * queryParams( 'pagination', { state: { page: { fallbackValue: 1, codec: numberCodec } }, }, ({ state, patch }) => ({ nextPage: function* () { const current = yield* state(); return yield* patch({ page: current.page + 1 }); }, previousPage: function* () { const current = yield* state(); return yield* patch({ page: current.page - 1 }); }, setPageSize: function* (pageSize: number) { return yield* patch({ pageSize, page: 1 }); }, }), ); ``` ## Feeding a query The point of URL state is usually to drive a fetch. Read it from the query's `params`: ```typescript yield* query('tasksQuery', { params: () => ({ page: pagination.page() }), loader: /* … */, }); ``` One direction of data flow: click → URL → loader → view. ## Decode failures A `decode` that throws keeps the fallback value rather than corrupting your state, and surfaces the failure: ```typescript if (mode.hasException()) { mode.exceptions().list; mode.exceptions().parse.mode?.code; // 'QueryParamDecodeError' mode.exceptions().parse.mode?.payload; } ``` An encode failure raises `QueryParamEncodeError` before router navigation starts. ## Pitfalls **Every parameter needs a `codec`** — there is no implicit string passthrough. **Methods bound to a source with `on$` are not exposed** on the result, same as every primitive. ::: details Advanced — declaring query params on the route Query parameters can live in the route rather than in a component, so they belong to the URL definition itself: ```typescript export const { demoRoutes, injectDemoQueryParamsQueryParams } = craftRoutes( 'demo', [ { path: 'query-params', ...loadCraftComponent(({ withRetry }) => withRetry(import('./qp-list-with-pagination')).then( ({ default: component }) => component, ), ), queryParams: function* () { const pagination = yield* queryParams( 'pagination', { state: { page: { fallbackValue: 1, codec: numberCodec }, pageSize: { fallbackValue: 4, codec: numberCodec }, }, }, ({ patch, state }) => ({ nextPage: function* () { const current = yield* state(); return yield* patch({ page: current.page + 1 }); }, previousPage: function* () { const current = yield* state(); return yield* patch({ page: current.page - 1 }); }, updatePageSize: function* (pageSize: number) { return yield* patch({ pageSize, page: 1 }); }, }), ); return pagination; }, }, ], ); ``` Working source: [exception-query-params.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exception-query-params.ts). ::: ::: details Advanced — yielding dependencies The insertion can be a generator, so a rule can come from a service: ```typescript yield * queryParams( 'pagination', { state: { page: { fallbackValue: 1, codec: numberCodec } } }, function* ({ patch, state }) { const maxPage = yield* PaginationRules.maxPage(); return { nextPage: function* () { const current = yield* state(); if (current.page >= maxPage()) return; return yield* patch(({ page }) => ({ page: page + 1 })); }, }; }, ); ``` ::: ::: tip Advanced — injectable writes Insertion methods provide `injectQueryParamsMethodRuntimeContext()`, and the URL state itself is published to `providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`, and `patch` for wrappers, WebMCP tools, and other advanced patterns. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ::: ## See Also * [Local state](/guide/state/local-state) — for non-URL state * [query](/guide/state/server-state) — consuming URL state from a loader * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) --- --- url: https://craft-ts.github.io/craft/guide/state/async-process.md --- # asyncProcess `asyncProcess` runs an async operation and tracks its status, for work that is neither a server read nor a server write. **Use it when** you need to know whether something asynchronous is running: a debounced search, a share sheet, a file export, a delay, a browser API call. **Not when** you fetch ([`query`](/guide/state/server-state)) or write ([`mutation`](/guide/state/mutations)) — those give you caching, params reactivity and mutation wiring on top. ## The common case ```typescript import { asyncProcess, craftComputed } from '@craft-ts/core'; const { delay } = yield * asyncProcess('delay', { method: (successResult: string) => successResult, loader: async ({ params: successResult }) => { await new Promise((resolve) => setTimeout(resolve, 300)); return successResult; }, }); // In a tracked generator, consume the trigger with yield*. yield * delay.method('success'); delay.status(); // 'idle' | 'loading' | 'resolved' | 'exception' delay.isLoading(); delay.hasValue(); delay.value(); // never throws ``` ::: warning `method` always takes exactly one parameter Pass an object when you need several values. ::: ## Wrapping a browser API This is the case `asyncProcess` exists for — turning a promise-returning native API into something with an observable status: ```typescript const { shareContent } = yield * asyncProcess( 'shareContent', { method: (payload: { title: string; url: string }) => payload, loader: function* ({ params }) { return (yield* BrowserNavigator.share(params)) as Promise; }, }, ({ resource }) => ({ isMenuOpen: craftComputed(function* () { return (yield* resource.status()) === 'loading'; }), }), ); yield * shareContent.method({ title: 'Hello AI!', url: 'https://example.com' }); yield* shareContent.isMenuOpen(); ``` Yielding the browser API through a service — rather than touching `navigator` directly — is also what makes it mockable in tests. See [Browser boundaries](/guide/testing/browser-boundaries). ## Triggering from an event Use a [`source$`](/guide/reactivity/source) when the process should run on an event rather than on a call — which is also where debouncing belongs: ```typescript import { on$, source$ } from '@craft-ts/core'; const searchSource = source$('searchSource'); const { delayedSearch } = yield * asyncProcess('delayedSearch', { method: on$(searchSource, (term) => term), loader: async ({ params: term }) => { await new Promise((resolve) => setTimeout(resolve, 300)); return term; }, }); searchSource.emit('query text'); // runs automatically delayedSearch.source; // ReadonlySource delayedSearch.status(); ``` ## Exceptions Split by origin, exactly like `query` and `mutation` — `params` for what `method` rejected, `loader` for what the operation produced: ```typescript const { loadUser } = yield * asyncProcess('loadUser', { method: (value: string) => value.length < 3 ? craftException( { _tag: 'SEARCH_TERM_TOO_SHORT' }, { min: 3, received: value.length }, ) : value, loader: async ({ params }) => params === 'blocked' ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params }) : { id: params, name: 'John Doe' }, }); yield * loadUser.method('ab'); loadUser.hasException(); // true loadUser.exceptions().params?.SEARCH_TERM_TOO_SHORT; yield * loadUser.method('blocked'); loadUser.exceptions().loader?.USER_ACCESS_FORBIDDEN; ``` ## Pitfalls **`method` needs its one parameter**, even when you have nothing to pass. `value()` is safe to read in templates and computed signals — it returns `undefined` when the process has no resolved value. **Reaching for it to fetch data.** If it's an HTTP read, `query` gives you reactive `params` and mutation wiring you'd otherwise rebuild by hand. ::: details Advanced — parallel runs by identifier `identifier` keeps one resource per key so several runs coexist: ```typescript const { debouncedById } = yield * asyncProcess('debouncedById', { method: (payload: { successResult: string; id: string }) => payload, identifier: ({ id }) => id, loader: async ({ params: { successResult } }) => { await new Promise((resolve) => setTimeout(resolve, 300)); return successResult; }, }); yield * debouncedById.method({ id: '1', successResult: data1 }); yield * debouncedById.method({ id: '2', successResult: data2 }); debouncedById.select('1')?.value(); // data1 debouncedById.select('2')?.value(); // data2 ``` ::: ::: details Advanced — yielding dependencies `method` and `loader` are generators, and `providers` scopes dependencies to this process alone. A loader must not be `async` or return a native `Promise`: use `yield*` for asynchronous Craft operations: ```typescript const { loadProfile } = yield * asyncProcess('loadProfile', { providers: [provideAsyncLogger(), provideProfileGateway()], method: function* (userId: string) { yield* AsyncLogger.log(`load:${userId}`); return userId; }, loader: function* ({ params }) { return yield* ProfileGateway.load(params); }, }); ``` ::: ::: tip Advanced — injectable writes Insertion methods provide `injectAsyncProcessMethodRuntimeContext()`, and the process value itself is published to `providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`, and `patch` for wrappers, WebMCP tools, and other advanced patterns. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ::: ## See Also * [Which primitive should I use?](/guide/concepts/choose-primitive) * [Browser boundaries](/guide/testing/browser-boundaries) — mocking native APIs * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) --- --- url: https://craft-ts.github.io/craft/guide/state/select.md --- # Selecting a sub-state `insertSelect` targets a nested part of a state and attaches insertions **to that part**, so the logic lives next to the data it operates on rather than at the top of a deeply nested object. **Use it when** a state is a tree and a method only concerns one branch: a cell in a grid, a row in a table, one section of a settings object. **Not when** the whole state is the subject — a plain insertion is simpler. One API covers both shapes: the parent can be an **object** or an **array**, and you don't switch helpers based on which. ::: info `state` only This insertion works with the `state` primitive. ::: ```typescript import { insertSelect, insertStatePipe, insertStoragePersister, state, } from '@craft-ts/core'; ``` ## The common case — selecting an object property ```typescript const board = yield* state( 'board', { cell: { color: 'white', paintCount: 0, }, }, insertSelect('cell', ({ update, state }) => ({ paint: () => update((cell) => ({ ...cell, color: 'black', paintCount: cell.paintCount + 1, })), paintCountStr: function* () { return `Painted ${(yield* state()).paintCount} times`; }, })), ); yield* board.selectCell().paint(); yield* board.selectCell().paintCountStr(); // "Painted 1 times" ``` ## Selecting into an array ```typescript const cells = yield* state( 'cells', [{ color: 'white', paintCount: 0 }], insertSelect('cell', ({ update }) => ({ paint: () => update((cell) => ({ ...cell, color: 'black', paintCount: cell.paintCount + 1, })), })), ); const cell = cells.selectCell(0); if (cell) yield* cell.paint(); console.log(cells.selectCell(0)?.paintCount); // 1 ``` ## Yielding dependencies ```typescript insertSelect('cell', function* ({ patch }) { const color = yield* ColorService(); return { paint: () => patch(() => ({ color, })), }; }); ``` The dependencies are tracked at the primitive level. ## Pitfalls **Only object properties can be selected.** On an object state, targeting a property that is not itself an object — a `string`, `number` or `boolean` — is not supported yet, and currently breaks type inference rather than failing cleanly. An improvement is planned. **A select takes a single nested insertion**, like any primitive. Use `craftPipe` for more than one nested insertion (below). ::: tip Nested typing needs no anchor Use `craftPipe` when composing nested `insertSelect` levels because each level has its own explicit context. The historical `insertNoopTypingAnchor` workaround is not needed here — it remains necessary for the [form-tree helpers](/guide/forms/nested). ::: ## Attaching several insertions Like the primitives, `insertSelect` accepts a **single** nested insertion. To attach several, re-pass the selected context through [craftPipe](/guide/concepts/insertions): ```ts state( 'board', { grid: createInitialGrid() }, insertSelect('grid', (gridContext) => craftPipe( gridContext, ({ state, update }) => ({ addRow: () => update((grid) => [...grid, createNextRow(grid)]), }), insertSelect('row', ({ update }) => ({ // ... })), ), ), ); ``` `insertSelect` also composes as a **member** of a pipe: ```ts state('cells', initialCells, insertStatePipe( insertStoragePersister(craftUnique({ storeName: 'app', key: 'cells', })), insertSelect('cell', ({ update }) => ({ paint: () => update((cell) => ({ ...cell, painted: true })), })), )); ``` ::: details Working examples — pixel art Two demos built almost entirely on nested selects: * [Pixel Art (1D grid)](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/pixel-art/pixel-art.ts) * [Pixel Art Matrix (2D grid)](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts) ::: ## See Also * [Insertions](/guide/concepts/insertions) — composing several on one primitive * [Local state](/guide/state/local-state) * [Collections](/guide/state/collections) — for entity lists specifically * [Architecture rules](/guide/testing/architecture) — `assertInsertSelectUnique` when two selects share a key on one host --- --- url: https://craft-ts.github.io/craft/guide/state/react-on-mutation.md --- # Reacting to mutations `insertReactOnMutation` declares the link between a write and the reads it affects: patch the query optimistically, reload it, or both — without calling `refetch()` from the mutation's call site. **Use it when** a mutation makes some query stale. **Not when** the two are unrelated — a reaction that fires on every write is just a hidden coupling. ```typescript import { insertReactOnMutation } from '@craft-ts/core'; ``` ## The common case ```typescript const updateUser = yield* mutation('updateUser', { method: (user: User) => user, loader: function* ({ params: user }) { return yield* CraftHttpClient.patch(({ response }) => ({ url: `/api/users/${user.id}`, body: user, success: response(), })); }, }); const queryRef = yield* query( 'queryRef', { params: () => '5', loader: async ({ params }) => ({ id: params, name: 'John' }), }, insertReactOnMutation(updateUser, { patch: { name: ({ mutationParams: { name } }) => name, }, }), ); ``` Three levers, combinable: | Option | Effect | | ------------------ | ------------------------------------------------------------ | | `patch` | Apply a field-by-field change once the mutation resolves | | `optimisticPatch` | Apply it **immediately**, before the server answers | | `optimisticUpdate` | Same, but you compute the whole new value | | `reload` | Re-run the loader — `onMutationSuccess` / `onMutationException` / `onMutationResolved` | | `filter` | Only react when this predicate passes | The usual pairing is an optimistic change plus `reload: { onMutationException: true }` — show the result instantly, and go get the truth back if the write failed. ## Targeting the right parallel query With `identifier`, several query instances coexist. Use `filter` so the reaction only touches the one the mutation concerns: ```typescript const queryRef = yield* query( 'queryRef', { params: userId, identifier: (userId) => userId, loader: function* ({ params }) { return yield* CraftHttpClient.get(({ response }) => ({ url: `/api/users/${params}`, success: response(), })); }, }, insertReactOnMutation(updateUser, { filter: ({ queryIdentifier, mutationParams }) => mutationParams.id === queryIdentifier, patch: { name: ({ mutationParams: { name } }) => name, }, }), ); ``` ## Several reactions on one query A query accepts a single insertion, so compose them with [`insertQueryPipe`](/guide/concepts/insertion-pipes) to keep this composition readable: ```typescript import { insertQueryPipe, insertReactOnMutation, insertStoragePersister, } from '@craft-ts/core'; const { users } = query( 'users', { params: pagination, identifier: (params) => `${params.page}-${params.pageSize}`, loader: function* ({ params }) { return yield* ApiService.getDataList(params); }, }, insertQueryPipe( insertStoragePersister(craftUnique({ storeName: 'app', key: 'users', })), insertReactOnMutation(deleteUser, { filter: ({ mutationIdentifier, queryResource }) => !!queryResource.value()?.some((u) => u.id === mutationIdentifier), optimisticUpdate: ({ queryResource, mutationIdentifier }) => removeOne({ entities: queryResource.value(), id: mutationIdentifier, }), reload: { onMutationException: true }, }), insertReactOnMutation(deleteUser, { // reload the current page when it becomes empty filter: ({ queryResource }) => queryResource.value()?.length === 0, reload: { onMutationResolved: true }, }), insertReactOnMutation(bulkDelete, { filter: ({ queryResource }) => (queryResource.value()?.length ?? 0) > 0, optimisticUpdate: ({ queryResource, mutationParams }) => removeMany({ entities: queryResource.value(), ids: mutationParams }), }), ), ); ``` ## Pitfalls **Optimistic without a fallback.** `optimisticPatch` / `optimisticUpdate` show a change that has not happened yet. Pair them with `reload: { onMutationException: true }` so a failed write is corrected rather than silently left on screen. **Forgetting `filter` on parallel queries.** Without it, a mutation on one entity patches every cached instance. `queryResource.value()` returns `undefined` when the query is in exception; handle that case inside a `filter`. ## See Also * [query](/guide/state/server-state) — the read side * [Mutations](/guide/state/mutations) — the write side * [Insertions](/guide/concepts/insertions) — composing several reactions * [Architecture rules](/guide/testing/architecture) — `assertMutationHasReactOn` flags a mutation no query reacts to --- --- url: https://craft-ts.github.io/craft/guide/state/collections.md --- # Collections `insertEntities` generates typed collection methods — add, remove, update, upsert — directly on a primitive holding an array of entities, including arrays nested inside an object. **Use it when** a state, query or queryParams holds a list you mutate by id. **Not when** the list is read-only, or when the operation concerns one nested branch rather than the collection — that is [`insertSelect`](/guide/state/select). ## Import ```typescript import { insertEntities } from '@craft-ts/core'; import { addOne, addMany, removeOne, removeMany, setOne, setMany, setAll, updateOne, updateMany, upsertOne, upsertMany, removeAll, } from '@craft-ts/core'; ``` ## Overview `insertEntities` bridges entity utility functions with reactive primitives by: * **Adding methods** - Automatically generates typed methods from entity utilities * **Path support** - Works with nested properties using dot notation * **Custom identifiers** - Supports custom ID selectors beyond default `id` property * **Parallel queries** - Enables entity manipulation in query instances with `select` parameter * **Type inference** - Full TypeScript support with automatic method name generation ::: warning This API currently promotes state imperative change. I am planning to improve this in the future, in order to keep state as much as I can declarative. ::: ## Entity Utilities The following entity utility functions can be used with `insertEntities`: | Utility | Description | | ------------ | ----------------------------------------- | | `addOne` | Adds a single entity to the end | | `addMany` | Adds multiple entities to the end | | `setOne` | Replaces or adds an entity by ID | | `setMany` | Replaces or adds multiple entities by ID | | `setAll` | Replaces the entire collection | | `updateOne` | Partially updates an entity by ID | | `updateMany` | Partially updates multiple entities by ID | | `upsertOne` | Updates if exists, otherwise adds | | `upsertMany` | Updates multiple if exist, otherwise adds | | `removeOne` | Removes a single entity by ID | | `removeMany` | Removes multiple entities by ID | | `removeAll` | Clears the entire collection | ## Signature ```typescript function insertEntities(config: { methods: EntityHelperFns; identifier?: IdSelector; path?: Path; // For nested arrays in objects }): Insertion; ``` ## Parameters ### `methods` Array of entity utility functions to expose as methods on the state/query. ### `identifier` (optional) Custom function to extract the unique identifier from entities. Defaults to: * For objects with `id` property: `(entity) => entity.id` * For primitives (string/number): `(entity) => entity` ### `path` (optional) Dot-notation path to a nested array property. When provided, method names are prefixed with the camelCase path. **Example:** `path: 'catalog.products'` → methods like `catalogProductsAddOne()` ## Method Naming * **Without path**: Method names match utility function names (e.g., `addOne`, `removeMany`) * **With path**: Method names are prefixed with camelCase path (e.g., `productsAddOne`, `catalogProductsRemoveMany`) ## The common case ```typescript import { state, insertEntities, addOne, addMany, removeOne, } from '@craft-ts/core'; const { tags } = state( 'tags', [] as string[], insertEntities({ methods: [addOne, addMany, removeOne], }), ); // Add single tag tags.addOne({ entity: 'typescript' }); console.log(tags()); // ['typescript'] // Add multiple tags tags.addMany({ newEntities: ['craft', 'signals'] }); console.log(tags()); // ['typescript', 'craft', 'signals'] // Remove tag tags.removeOne({ id: 'typescript' }); console.log(tags()); // ['craft', 'signals'] ``` ::: details More examples — nested paths, queries, CRUD, URL state #### Managing objects with default ID ```typescript import { state, insertEntities, addOne, setOne, removeOne, } from '@craft-ts/core'; interface Product { id: string; name: string; price: number; } const { products } = state( 'products', [] as Product[], insertEntities({ methods: [addOne, setOne, removeOne], }), ); // Add product products.addOne({ entity: { id: '1', name: 'Laptop', price: 999 }, }); // Replace or update product products.setOne({ entity: { id: '1', name: 'Laptop Pro', price: 1299 }, }); console.log(products()); // [{ id: '1', name: 'Laptop Pro', price: 1299 }] // Remove product products.removeOne({ id: '1' }); console.log(products()); // [] ``` #### Using custom identifier ```typescript import { state, insertEntities, setOne, removeOne } from '@craft-ts/core'; interface User { uuid: string; name: string; email: string; } const { users } = state( 'users', [] as User[], insertEntities({ methods: [setOne, removeOne], identifier: (user) => user.uuid, }), ); users.setOne({ entity: { uuid: 'abc-123', name: 'Alice', email: 'alice@example.com' }, }); users.setOne({ entity: { uuid: 'abc-123', name: 'Alice Smith', email: 'alice@example.com' }, }); console.log(users()); // [{ uuid: 'abc-123', name: 'Alice Smith', email: 'alice@example.com' }] users.removeOne({ id: 'abc-123' }); console.log(users()); // [] ``` #### Working with nested arrays using path ```typescript import { state, insertEntities, addMany, removeOne } from '@craft-ts/core'; interface Catalog { total: number; products: Array<{ id: string; name: string }>; } const { catalog } = state( 'catalog', { total: 0, products: [], } as Catalog, insertEntities({ methods: [addMany, removeOne], path: 'products', }), ); // Methods are prefixed with "products" catalog.productsAddMany({ newEntities: [ { id: '1', name: 'Item 1' }, { id: '2', name: 'Item 2' }, ], }); console.log(catalog()); // { total: 0, products: [{ id: '1', name: 'Item 1' }, { id: '2', name: 'Item 2' }] } catalog.productsRemoveOne({ id: '1' }); console.log(catalog()); // { total: 0, products: [{ id: '2', name: 'Item 2' }] } ``` #### Deep nested path with dot notation ```typescript import { state, insertEntities, addMany } from '@craft-ts/core'; interface State { catalog: { featured: { products: Array<{ id: string; name: string }>; }; }; } const { store } = state( 'store', { catalog: { featured: { products: [], }, }, } as State, insertEntities({ methods: [addMany], path: 'catalog.featured.products', }), ); // Method is prefixed with camelCase: catalogFeaturedProducts store.catalogFeaturedProductsAddMany({ newEntities: [{ id: '1', name: 'Featured Item' }], }); console.log(store().catalog.featured.products); // [{ id: '1', name: 'Featured Item' }] ``` #### Using with query primitive ```typescript import { query, insertEntities, addMany, removeOne } from '@craft-ts/core'; interface Product { id: string; name: string; } const { productsQuery } = query( 'productsQuery', { params: () => 'all', loader: async () => { const response = await fetch('/api/products'); return response.json() as Product[]; }, }, insertEntities({ methods: [addMany, removeOne], }), ); // After query loads, manipulate the cached data await productsQuery.load(); // Add optimistic product productsQuery.addMany({ newEntities: [{ id: 'temp-1', name: 'New Product' }], }); // Remove product from cache productsQuery.removeOne({ id: 'temp-1' }); ``` #### Working with parallel queries ```typescript import { query, insertEntities, addOne } from '@craft-ts/core'; const { userQuery } = query( 'userQuery', { params: () => 'userId', identifier: (params) => params, // Track multiple query instances loader: async ({ params }) => { const response = await fetch(`/api/users/${params}/posts`); return response.json(); }, }, insertEntities({ methods: [addOne], }), ); // Manipulate specific query instance with select parameter userQuery.addOne({ select: 'user-123', // Target specific query instance entity: { id: 'post-1', title: 'New Post' }, }); ``` #### Update operations ```typescript import { state, insertEntities, updateOne, updateMany } from '@craft-ts/core'; interface Todo { id: string; title: string; completed: boolean; } const { todos } = state( 'todos', [ { id: '1', title: 'Learn Craft', completed: false }, { id: '2', title: 'Build app', completed: false }, ] as Todo[], insertEntities({ methods: [updateOne, updateMany], }), ); // Update single todo todos.updateOne({ update: { id: '1', changes: { completed: true }, }, }); console.log(todos()[0].completed); // true // Update multiple todos todos.updateMany({ updates: [ { id: '1', changes: { title: 'Learn Craft Signals' } }, { id: '2', changes: { completed: true } }, ], }); ``` #### Upsert operations ```typescript import { state, insertEntities, upsertOne, upsertMany } from '@craft-ts/core'; interface Settings { key: string; value: string; } const { settings } = state( 'settings', [{ key: 'theme', value: 'dark' }] as Settings[], insertEntities({ methods: [upsertOne, upsertMany], identifier: (setting) => setting.key, }), ); // Updates existing or adds new settings.upsertOne({ entity: { key: 'theme', value: 'light' }, }); console.log(settings()); // [{ key: 'theme', value: 'light' }] settings.upsertMany({ newEntities: [ { key: 'theme', value: 'auto' }, { key: 'language', value: 'en' }, ], }); console.log(settings()); // [ // { key: 'theme', value: 'auto' }, // { key: 'language', value: 'en' } // ] ``` #### Complete CRUD example ```typescript import { state, insertEntities, addOne, setOne, updateOne, removeOne, setAll, } from '@craft-ts/core'; interface Task { id: string; title: string; completed: boolean; priority: 'low' | 'medium' | 'high'; } const { tasks } = state( 'tasks', [] as Task[], insertEntities({ methods: [addOne, setOne, updateOne, removeOne, setAll], }), ); // Create tasks.addOne({ entity: { id: '1', title: 'Review code', completed: false, priority: 'high', }, }); // Read - use tasks() to access the array // Update tasks.updateOne({ update: { id: '1', changes: { completed: true }, }, }); // Replace tasks.setOne({ entity: { id: '1', title: 'Review and merge code', completed: true, priority: 'high', }, }); // Delete tasks.removeOne({ id: '1' }); // Replace all tasks.setAll({ newEntities: [ { id: '2', title: 'New task', completed: false, priority: 'medium' }, ], }); ``` #### Using with queryParams ```typescript import { queryParams, insertEntities, addOne, removeOne } from '@craft-ts/core'; const { filters } = queryParams( 'filters', { state: { selectedIds: { fallbackValue: [] as string[], codec: { decode: (value) => value.split(',').filter(Boolean), encode: (value) => (value as string[]).join(','), }, }, }, }, insertEntities({ methods: [addOne, removeOne], path: 'selectedIds', }), ); // Methods update queryParams state and URL filters.selectedIdsAddOne({ entity: 'item-1' }); // URL: ?selectedIds=item-1 filters.selectedIdsAddOne({ entity: 'item-2' }); // URL: ?selectedIds=item-1,item-2 filters.selectedIdsRemoveOne({ id: 'item-1' }); // URL: ?selectedIds=item-2 ``` ::: ## Pitfalls **Declaring every utility "just in case".** `methods` decides the generated API; listing all twelve gives every consumer twelve methods to ignore. List what you use. **Picking the wrong operation.** `add` appends blindly, `set` replaces by id, `upsert` does whichever applies. Choosing `addOne` where you meant `upsertOne` produces duplicates that only show up with real data. **Mutating the array directly.** The generated methods are immutable updates; bypassing them breaks change detection. **Deep `path` values.** If the path is getting long, the state shape is probably the problem — consider flattening it. ## Type Safety `insertEntities` provides full type inference: ```typescript interface Product { id: string; name: string; price: number; } const { products } = state( 'products', [] as Product[], insertEntities({ methods: [addOne, updateOne], }), ); // ✅ TypeScript knows entity must be Product products.addOne({ entity: { id: '1', name: 'Item', price: 100 } }); // ❌ TypeScript error - missing required properties products.addOne({ entity: { id: '1' } }); // ✅ TypeScript knows changes are Partial products.updateOne({ update: { id: '1', changes: { price: 120 } }, }); // ❌ TypeScript error - invalid property products.updateOne({ update: { id: '1', changes: { invalid: true } }, }); ``` ## See Also * [Collection utilities](/guide/state/collections-utils) — the underlying functions * [Selecting a sub-state](/guide/state/select) — for one branch rather than the list * [Insertions](/guide/concepts/insertions) --- --- url: https://craft-ts.github.io/craft/guide/state/collections-utils.md --- # Collection utilities The immutable array helpers behind [`insertEntities`](/guide/state/collections) — `addOne`, `updateMany`, `upsertOne` and friends. The shapes are the ones NgRx Entity popularised. **Use them directly when** you manipulate an array outside a primitive: inside an `optimisticUpdate`, a loader, or a plain computed. **Otherwise** let [`insertEntities`](/guide/state/collections) generate the methods for you — same functions, attached to your state. This page is a reference: scan the API list below, or jump to the [usage example](#usage-example). ## Types ### IdSelector A function type that extracts the identifier from an entity. ```typescript type IdSelector = (entity: T) => K; ``` ### Update A type for partial updates containing an id and the changes to apply. ```typescript type Update = { id: K; changes: Partial; }; ``` ## Optional Identifier For entities that have an `id` property, the `identifier` parameter is **optional**. The functions will automatically use the `id` property. For entities without an `id` property, you must provide a custom `identifier` function. ```typescript // Entity with id property - identifier is optional interface User { id: number; name: string; } const users: User[] = [{ id: 1, name: 'Alice' }]; removeOne({ id: 1, entities: users }); // ✅ OK - no identifier needed // Entity without id property - identifier is required interface Product { sku: string; name: string; } const products: Product[] = [{ sku: 'A1', name: 'Widget' }]; removeOne({ id: 'A1', entities: products, identifier: (p) => p.sku }); // ✅ OK ``` ## Usage Example ```typescript import { addOne, addMany, updateOne, removeOne, upsertOne, } from '@anthropic/craft'; interface User { id: number; name: string; email: string; } let users: User[] = []; // Add a single user users = addOne({ entity: { id: 1, name: 'Alice', email: 'alice@example.com' }, entities: users, }); // Add multiple users users = addMany({ newEntities: [ { id: 2, name: 'Bob', email: 'bob@example.com' }, { id: 3, name: 'Charlie', email: 'charlie@example.com' }, ], entities: users, }); // Update a user (no identifier needed - User has id property) users = updateOne({ update: { id: 1, changes: { name: 'Alice Updated' } }, entities: users, }); // Upsert a user (update if exists, add if not) users = upsertOne({ entity: { id: 4, name: 'David', email: 'david@example.com' }, entities: users, }); // Remove a user users = removeOne({ id: 2, entities: users }); ``` ## API reference ### removeAll Removes all elements from the list. ```typescript function removeAll(): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; const result = removeAll(); // [] ``` *** ### addOne Adds an element to the end of the list. ```typescript function addOne({ entity, entities }: { entity: T; entities: T[] }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; const result = addOne({ entity: { id: 2, name: 'Bob' }, entities: users, }); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] ``` *** ### addMany Adds multiple elements to the end of the list. ```typescript function addMany({ newEntities, entities, }: { newEntities: T[]; entities: T[]; }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; const result = addMany({ newEntities: [ { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }, ], entities: users, }); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }] ``` *** ### setAll Replaces the entire list with new elements. ```typescript function setAll({ newEntities }: { newEntities: T[] }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; const result = setAll({ newEntities: [{ id: 2, name: 'Bob' }] }); // [{ id: 2, name: 'Bob' }] ``` *** ### setOne Replaces an element if it exists (based on id), otherwise adds it. If the entity has an `id` property, the `identifier` is optional. ```typescript // With identifier (required for entities without id property) function setOne(params: { entity: T; entities: T[]; identifier: IdSelector; }): T[]; // Without identifier (for entities with id property) function setOne(params: { entity: T; entities: T[]; identifier?: IdSelector; }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; // Without identifier (User has id property) const result1 = setOne({ entity: { id: 1, name: 'Alice Updated' }, entities: users, }); // [{ id: 1, name: 'Alice Updated' }] // With custom identifier const products = [{ sku: 'A1', name: 'Widget' }]; const result2 = setOne({ entity: { sku: 'A1', name: 'Widget Updated' }, entities: products, identifier: (p) => p.sku, }); // [{ sku: 'A1', name: 'Widget Updated' }] ``` *** ### setMany Replaces or adds multiple elements (based on id). If the entity has an `id` property, the `identifier` is optional. ```typescript function setMany(params: { newEntities: T[]; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; // Without identifier const result = setMany({ newEntities: [ { id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }, ], entities: users, }); // [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }] ``` *** ### updateOne Partially updates an existing element. Does nothing if the element is not found. If the entity has an `id` property, the `identifier` is optional. ```typescript function updateOne(params: { update: Update; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }]; // Without identifier const result = updateOne({ update: { id: 1, changes: { name: 'Alice Updated' } }, entities: users, }); // [{ id: 1, name: 'Alice Updated', email: 'alice@example.com' }] ``` *** ### updateMany Partially updates multiple existing elements. If the entity has an `id` property, the `identifier` is optional. ```typescript function updateMany(params: { updates: Update[]; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; // Without identifier const result = updateMany({ updates: [ { id: 1, changes: { name: 'Alice Updated' } }, { id: 2, changes: { name: 'Bob Updated' } }, ], entities: users, }); // [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob Updated' }] ``` *** ### upsertOne Updates an element if it exists (merging properties), otherwise adds it. If the entity has an `id` property, the `identifier` is optional. ```typescript function upsertOne(params: { entity: T; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }]; // Update existing (merges properties) - without identifier const result1 = upsertOne({ entity: { id: 1, name: 'Alice Updated' }, entities: users, }); // [{ id: 1, name: 'Alice Updated', email: 'alice@example.com' }] // Add new const result2 = upsertOne({ entity: { id: 2, name: 'Bob' }, entities: users, }); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] ``` *** ### upsertMany Updates multiple elements if they exist, otherwise adds them. If the entity has an `id` property, the `identifier` is optional. ```typescript function upsertMany(params: { newEntities: T[]; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [{ id: 1, name: 'Alice' }]; // Without identifier const result = upsertMany({ newEntities: [ { id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }, ], entities: users, }); // [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }] ``` *** ### removeOne Removes an element by its id. If the entity has an `id` property, the `identifier` is optional. ```typescript function removeOne(params: { id: K; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; // Without identifier const result = removeOne({ id: 1, entities: users }); // [{ id: 2, name: 'Bob' }] // With custom identifier for entities without id const products = [{ sku: 'A1', name: 'Widget' }]; const result2 = removeOne({ id: 'A1', entities: products, identifier: (p) => p.sku, }); // [] ``` *** ### removeMany Removes multiple elements by their ids. If the entity has an `id` property, the `identifier` is optional. ```typescript function removeMany(params: { ids: K[]; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }, ]; // Without identifier const result = removeMany({ ids: [1, 2], entities: users }); // [{ id: 3, name: 'Charlie' }] ``` *** ### map Applies a transformation function to all elements. ```typescript function map({ mapFn, entities, }: { mapFn: (entity: T) => T; entities: T[]; }): T[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'alice' }, { id: 2, name: 'bob' }, ]; const result = map({ mapFn: (u) => ({ ...u, name: u.name.toUpperCase() }), entities: users, }); // [{ id: 1, name: 'ALICE' }, { id: 2, name: 'BOB' }] ``` *** ### mapOne Applies a transformation function to a single element by its id. If the entity has an `id` property, the `identifier` is optional. ```typescript function mapOne(params: { id: K; mapFn: (entity: T) => T; entities: T[]; identifier?: IdSelector; // Optional if T has id }): T[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'alice' }, { id: 2, name: 'bob' }, ]; // Without identifier const result = mapOne({ id: 1, mapFn: (u) => ({ ...u, name: u.name.toUpperCase() }), entities: users, }); // [{ id: 1, name: 'ALICE' }, { id: 2, name: 'bob' }] ``` *** ### computedTotal Returns the total count of entities. ```typescript function computedTotal({ entities }: { entities: T[] }): number; ``` **Example:** ```typescript const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; const total = computedTotal({ entities: users }); // 2 ``` *** ### computedIds Returns all ids from the entities list. If the entity has an `id` property, the `identifier` is optional. ```typescript function computedIds(params: { entities: T[]; identifier?: IdSelector; // Optional if T has id }): K[]; ``` **Example:** ```typescript const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; // Without identifier const ids = computedIds({ entities: users }); // [1, 2] // With custom identifier const products = [{ sku: 'A1', name: 'Widget' }]; const skus = computedIds({ entities: products, identifier: (p) => p.sku, }); // ['A1'] ``` ## See Also * [Collections](/guide/state/collections) — generating these as methods on a primitive * [Reacting to mutations](/guide/state/react-on-mutation) — the usual place to call them by hand --- --- url: https://craft-ts.github.io/craft/guide/state/persistence.md --- # Persistence `insertStoragePersister` saves a primitive's value through the configured storage backend and restores it on the next visit — with expiry, background revalidation and a validation hook, so stale or corrupt entries don't leak into your app. **Use it when** a value should survive a reload: a draft, a preference, a list you'd rather show instantly than fetch again. **Not when** the value is sensitive, or when it must be correct rather than fast — a restored value is by definition a value from the past. Works with `state()`, `query()`, `mutation()` and `asyncProcess()`. ```typescript import { craftUnique, insertStoragePersister } from '@craft-ts/core'; ``` Configure the storage backend once in `appConfig`. The default application selection remains `localStorage`; a child route, feature or test can select `sessionStorage` instead. ```typescript import { LocalStoragePersister, SessionStoragePersister, provideLocalStoragePersister, provideSessionStoragePersister, provideStoragePersister, } from '@craft-ts/core'; providers: [ provideLocalStoragePersister(), provideSessionStoragePersister(), provideStoragePersister(function* () { return yield* LocalStoragePersister(); }), ]; ``` The `StoragePersister` provider is required by `craftAppConfig` and follows the normal Craft DI hierarchy. A child route, feature or test can override the active backend: ```typescript providers: [ provideStoragePersister(function* () { return yield* SessionStoragePersister(); }), ]; ``` ## The common case ```typescript const { myState } = state( 'myState', 0, insertStoragePersister(craftUnique({ storeName: 'myApp', key: 'myState', })), ); const { myQuery } = query( 'myQuery', { params: () => 'test', loader: async () => { return { data: 'testData' }; }, }, insertStoragePersister(craftUnique({ storeName: 'myApp', key: 'myQuery', })), ); ``` ## Options The identity (`storeName` + `key`) is the first argument, wrapped in `craftUnique` so the static graph can guarantee it appears only once. Options are the second argument. | Option | Type | Default | Description | | ------------------------------------------ | ----------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cacheTime` | `number` | `300000` | Time in ms after which cached data is deleted from the configured storage backend (garbage collection). Set to `0` to disable expiration. | | `staleTime` | `number` | `undefined` | Time in ms after which cached data is considered stale. The cached value is still restored immediately, but a background `reload()` is triggered (SWR pattern). Must be less than `cacheTime`. | | `validate` | `(value: unknown) => boolean` | `undefined` | Called on the deserialized value before restoring it. Return `false` to discard the entry and load fresh. Useful when the data model has changed. | | `waitForParamsSrcToBeEqualToPreviousValue` | `boolean` | `true` | If `true`, waits for the params signal to stabilize before trying to restore the cache. Useful when params start as `undefined`. Not applicable to `state()`. | ## cacheTime vs staleTime | | Data deleted? | Reload triggered? | | ------------------------ | ------------------------------------- | ------------------------------ | | **`cacheTime`** exceeded | Yes — entry removed from the configured backend | No | | **`staleTime`** exceeded | No — data is still restored | Yes — `reload()` in background | `cacheTime` always takes priority: if `cacheTime` is exceeded, the entry is discarded entirely, regardless of `staleTime`. ## SWR Pattern (staleTime) Use `staleTime` to display cached data immediately while silently refreshing in the background — the same pattern used by SWR and TanStack Query. ```typescript const { userQuery } = query( 'userQuery', { params: () => currentUserId(), loader: async ({ params }) => fetchUser(params), }, insertStoragePersister(craftUnique({ storeName: 'myApp', key: 'user', }), { cacheTime: 10 * 60_000, // delete from the configured backend after 10 min staleTime: 60_000, // show cached + reload in background after 1 min, }), ); // On page load: // - If cache is < 1 min old → status: 'local', no reload // - If cache is 1–10 min old → status: 'loading', value still visible (SWR) // - If cache is > 10 min old → entry deleted, loads fresh ``` ## Validation Use `validate` to guard against corrupt or outdated data in the configured storage backend (e.g. after a model change or manual user edit). Works with Zod or any type guard. ```typescript import { z } from 'zod'; const UserSchema = z.object({ id: z.string(), name: z.string() }); type User = z.infer; const { userQuery } = query( 'userQuery', { params: () => currentUserId(), loader: async ({ params }) => fetchUser(params), }, insertStoragePersister(craftUnique({ storeName: 'myApp', key: 'user', }), { validate: (v): v is User => UserSchema.safeParse(v).success, }), ); // If the stored value fails validation → entry is discarded, resource loads fresh // If it passes → restored normally ``` ## Parallel resources With `query(name, { identifier })`, each instance is cached individually under its identifier — no extra configuration: ```typescript const postsQuery = yield* query( 'postsQuery', { params: () => currentPostId(), identifier: (id) => id, loader: async ({ params }) => fetchPost(params), }, insertStoragePersister(craftUnique({ storeName: 'myApp', key: 'posts', }), { cacheTime: 15 * 60_000, staleTime: 2 * 60_000, }), ); ``` ## Pitfalls **`staleTime` must be smaller than `cacheTime`.** Otherwise the entry is deleted before it ever gets a chance to be revalidated. **A shipped model change invalidates nothing by itself.** Users carry the old shape in their configured storage backend. Use `validate` — that is what it is for. **Restoring a value is not the same as having loaded it.** Check `isPlaceHolderData` / the status before treating a restored value as fresh. ::: details Managing stored data globally Clearing, inspecting or migrating persisted entries across the whole app goes through [GlobalPersisterHandler](/guide/state/persistence-handler). It delegates to the active `StoragePersister`, so the built-in localStorage and sessionStorage backends clear their own persisted entries. ::: ## See Also * [GlobalPersisterHandler](/guide/state/persistence-handler) * [query](/guide/state/server-state) * [Insertions](/guide/concepts/insertions) — composing with other insertions * [Architecture rules](/guide/testing/architecture) — `assertCraftUnique` on storage identities, `assertPersistedPrimitiveHasUnique` when a persister has no identity --- --- url: https://craft-ts.github.io/craft/guide/state/persistence-handler.md --- # GlobalPersisterHandler Clears everything `@craft-ts` has persisted through the active `StoragePersister`, in one call. **Use it when** cached data must not outlive a session boundary: logout, switching accounts, a "reset the app" action. **Not when** you want to invalidate one resource — reload that query, or give it a shorter `cacheTime` in [Persistence](/guide/state/persistence). ::: danger It clears everything There is no per-key variant. Every persisted query, mutation and async process goes. ::: ```typescript import { GlobalPersisterHandlerService, provideGlobalPersisterHandlerService, } from '@craft-ts/core'; providers: [provideGlobalPersisterHandlerService()]; ``` ## How it works The handler delegates to the active `StoragePersister`. The built-in localStorage and sessionStorage implementations remove every key that starts with the `craft-ts-` prefix from their respective backend. This ensures complete cleanup of all data cached by `@craft-ts`, including: * Persisted queries * Persisted mutations * Persisted async processes * Any other data cached by the `@craft-ts` persistence layer ## The common case — clearing on logout ```ts import { craftService, GlobalPersisterHandlerService } from '@craft-ts/core'; const { LogoutHandler } = craftService( { name: 'LogoutHandler', providedIn: 'toProvide' }, function* () { const persister = yield* GlobalPersisterHandlerService(); return { logout: () => persister.clearAllCache(), }; }, ); ``` ## Force refresh all data ```typescript const { CacheActions } = craftService( { name: 'CacheActions', providedIn: 'toProvide' }, function* () { const persister = yield* GlobalPersisterHandlerService(); return { clearCache: () => persister.clearAllCache() }; }, ); ``` ## Clear cache when switching accounts ```ts import { GlobalPersisterHandlerService, craftService } from '@craft-ts/core'; const { AccountSwitcher } = craftService( { name: 'AccountSwitcher', providedIn: 'toProvide' }, function* () { const persister = yield* GlobalPersisterHandlerService(); return { switchAccount: (accountId: string) => { persister.clearAllCache(); // Load the selected account... return accountId; }, }; }, ); ``` ::: details Other situations where this comes up ### 1. User Logout Remove all user-specific cached data when a user logs out to prevent data leakage to the next user. ```typescript logout() { this.persisterHandler.clearAllCache(); this.authService.logout(); } ``` ### 2. Privacy Compliance Ensure no sensitive data remains in the selected storage backend after a user session ends. ```typescript ngOnDestroy() { if (this.isPrivateMode) { this.persisterHandler.clearAllCache(); } } ``` ### 3. Development/Testing Quickly clear all cached data during development or testing. ```typescript resetCache() { if (environment.development) { this.persisterHandler.clearAllCache(); console.log('Cache cleared'); } } ``` ### 4. Data Corruption Recovery Clear potentially corrupted cached data and force fresh data loading. ```typescript handleDataError() { this.persisterHandler.clearAllCache(); this.showMessage('Cache cleared. Please refresh the page.'); } ``` ::: ## See Also * [Local Storage Persister](/guide/state/persistence) * [Query](/guide/state/server-state) * [Mutation](/guide/state/mutations) --- --- url: https://craft-ts.github.io/craft/guide/state/pagination-placeholder.md --- # Pagination placeholders `insertPaginationPlaceholderData` keeps the previous page on screen while the next one loads, so paging through a list never flashes an empty state. **Use it when** a query is paginated with an `identifier` per page. **Not when** you just want to avoid a flicker on a non-paginated query — a query already keeps its previous value while loading, with no configuration ([query](/guide/state/server-state)). ```typescript import { insertPaginationPlaceholderData } from '@craft-ts/core'; ``` ## The common case It is a **higher-order insertion**: call it with a config and pass the result to `query`. `config.initialValue` is both the default value and the page type — which is why `currentPageData` is a `Signal` that is **never `undefined`**. ```typescript const pagination = yield* state('pagination', 1); const { userQuery } = yield* query( 'userQuery', { params: pagination, identifier: (params) => '' + params, loader: function* ({ params }) { const response = yield* CraftHttpClient.get(({ response }) => ({ url: `/api/users?page=${params}`, success: response(), })); return response.json(); }, }, insertPaginationPlaceholderData({ initialValue: [] as User[] }), ); // Access the current page data (or placeholder data during loading) const data = userQuery.currentPageData(); // Check the loading status of the current page const status = userQuery.currentPageStatus(); // Determine if placeholder data is being shown const isPlaceholder = userQuery.isPlaceHolderData(); // Get the current page identifier const identifier = userQuery.currentIdentifier(); ``` ## Returned Properties | Property | Type | Description | | ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `currentPageData` | `Signal` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). | | `currentPageStatus` | `Signal` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) | | `isPlaceHolderData` | `Signal` | `true` when showing previous page data as a placeholder | | `currentIdentifier` | `Signal` | The identifier of the current page | ## Custom Outputs (`build` callback) Pass an optional second argument to attach your own computed values or methods next to the pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the **current page** (the displayed data), so mutations only affect the page the user is looking at — other cached pages are left untouched. ```typescript const { usersQuery } = query( 'usersQuery', { params: pagination, identifier: (params) => `${params.page}-${params.pageSize}`, loader: function* ({ params }) { return yield* ApiService.getDataList(params); }, }, insertPaginationPlaceholderData( { initialValue: [] as Data[] }, ({ state, settledState, set }) => ({ // a computed derived from the current page totalOfUnCompletedData: craftComputed(function* () { return (yield* state()).filter((d) => !d.completed).length; }), settledCount: craftComputed(function* () { return (yield* settledState()).length; }), markAsCompleted: function* (id: string) { const current = yield* state(); return yield* set( current.map((d) => (d.id === id ? { ...d, completed: true } : d)), ); }, }), ), ); yield* usersQuery.totalOfUnCompletedData(); // number yield* usersQuery.markAsCompleted('42'); ``` The `build` context exposes: | Helper | Type | Description | | -------- | --------------------------------------- | ---------------------------------------------------- | | `state` | yieldable reader for `T` | The current page data (or `initialValue`) | | `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition | | `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) | | `update` | yieldable write returning `T` | Update the current page data from its previous value | | `patch` | yieldable write returning `T` | Patch the current page data with a partial value | The pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`, `currentIdentifier`) are also available in the `build` context. ::: details A full paginated component ```typescript import { button, craftComponent, div, forNode, ifNode, span } from '@craft-ts/component'; import { craftComputed, query, state } from '@craft-ts/core'; export const UsersList = craftComponent( 'UsersList', {}, function* () { const page = yield* state('page', 1, ({ state, update, set }) => ({ next: () => update((value) => value + 1), previous: function* () { const current = yield* state(); return yield* set(Math.max(1, current - 1)); }, isFirst: craftComputed(function* () { return (yield* state()) === 1; }), label: craftComputed(function* () { return `Page ${yield* state()}`; }), })); const userQuery = yield* query( 'userQuery', { params: page, identifier: (page) => `page-${page}`, loader: async ({ params }) => (await fetch(`/api/users?page=${params}`)).json() as Promise, }, insertPaginationPlaceholderData({ initialValue: [] as User[] }), ); return { page, userQuery }; }, ({ page, userQuery }) => [ div( { class: function* () { return (yield* userQuery.isPlaceHolderData()) ? 'users-list loading' : 'users-list'; }, }, forNode( userQuery.currentPageData, { track: (user) => user.id }, (user) => UserCard({ user }), ), ), // `pagerSheet` is the page's sheet, from users-page.style.ts. div({ class: pagerSheet.bar }, [ button({ click: page.previous, disabled: page.isFirst }, 'Previous'), span(page.label), button({ click: page.next }, 'Next'), ]), ifNode(userQuery.isPlaceHolderData, () => div({ class: pagerSheet.loading }, 'Loading new page…'), ), ], ); ``` ::: ## How it works 1. When the page parameters change, the insertion checks whether the new page's data is already cached. 2. If the new page is loading and has no data yet, it serves the previous page's data as a placeholder. 3. `isPlaceHolderData` tells you that is what is on screen — use it to dim the list or show a spinner. 4. Once the real data arrives, it switches over automatically. ## Pitfalls **It needs an `identifier`.** Without one page identity, there is no "previous page" to fall back to. **`initialValue` defines the page type.** Passing `[]` untyped collapses `currentPageData` to `never[]` — write `[] as User[]`. **Mutating through the `build` helpers only affects the current page.** Other cached pages are untouched, which is usually what you want, but means a global change needs a reload. ## See Also * [query](/guide/state/server-state) — the base primitive * [Reacting to mutations](/guide/state/react-on-mutation) * [Insertions](/guide/concepts/insertions) --- --- url: https://craft-ts.github.io/craft/guide/state/schema-validation.md --- # Schema validation Primitives accept any schema implementing `StandardSchemaV1`, so Zod, Valibot, ArkType, Effect Schema or a hand-written schema all work — and none of them becomes a dependency of `@craft-ts`. (Effect needs one conversion call; see [Effect Schema](#effect-schema).) **Use it when** data crosses a boundary you don't control: a method argument, a server response, a restored value. **Not when** the value never leaves your own typed code — TypeScript covers that already. ## Resource schemas Resource schemas correspond to different configurations. They are shown separately here so the documentation does not suggest that they can be combined in one declaration. ### Validating a method argument ```typescript const search = yield* query('search', { methodSchema: SearchInputSchema, method: (input) => ({ term: input.term }), loader: async ({ params }) => fetchResults(params), }); ``` `methodSchema` validates the argument received by `call`, `mutate` or `method`; the method then receives the schema output value. ### Validating reactive params ```typescript const products = yield* query('products', { paramsSchema: FiltersSchema, params: () => ({ page: 1, term: searchTerm() }), loader: async ({ params }) => fetchProducts(params), }); ``` `paramsSchema` validates the value produced by `params` or a reactive source. ### Validating the loader result This is the one that matters most: the loader is where **data you don't control** enters the app. ```typescript const products = yield* query('products', { loaderSchema: ProductsSchema, params: () => ({ page: 1 }), loader: async ({ params }) => fetchProducts(params), }); ``` `loaderSchema` covers more than the initial fetch — it validates loader results, **stream values**, and **local writes** through `set`, `update` and `patch`. So a value that enters the resource later, by any path, is checked the same way. If the schema transforms (a `.trim()`, a coercion, a rename), the resource publishes the **output** type — the rest of your code sees the transformed shape, not the raw one. ::: warning `response()` is a claim, not a check With `CraftHttpClient`, the type parameter only *asserts* what the endpoint returns. Nothing verifies it at runtime: ```typescript loader: function* () { return yield* CraftHttpClient.get(({ response }) => ({ url: '/api/products', success: response(), // trusted, never verified })); } ``` Two ways to make it real. Add `loaderSchema` to the query, which validates whatever the loader returns: ```typescript yield* query('products', { loaderSchema: ProductsSchema, loader: /* the CraftHttpClient call above */, }); ``` Or decode at the request itself — `response(...)` takes any `{ decode(input: unknown) }`, which every schema library provides: ```typescript success: response({ decode: (input) => ProductsSchema.parse(input) }), ``` Use `loaderSchema` when you want the failure to surface as a craft exception under `exceptions().parse.loader` and to obey the validation policy; use `decode` when the decoding belongs to the endpoint's own contract. ::: ## State State schemas are declared beside `$self` and validate initial values, writes, insertions and values produced by `craftComputed`: ```typescript const user = yield* state('user', { $self: { id: 123, name: 'Alice' }, schema: UserSchema, }); ``` The input type constrains `$self`; the exposed reader uses the schema output type. Invalid derived values keep the last valid value when the policy rejects them. ### Derived state A schema also validates every new value produced by a `craftComputed` while keeping the dependency reactive: ```typescript const price = yield* state('price', 10); const quantity = yield* state('quantity', 2, ({ set }) => ({ set })); const total = yield* state('total', { $self: craftComputed('totalSelf', function* () { return (yield* price()) * (yield* quantity()); }), schema: NonNegativeNumberSchema, }); console.log(yield* total()); // 20 yield* quantity.set(3); console.log(yield* total()); // 30 ``` When a derived value fails validation, the configured policy decides whether the last valid value is retained or the new value is accepted. ## Policy and exceptions The default policy rejects invalid values in development and accepts them in production. It can be replaced globally or locally: ```typescript provideCraftSchemaValidationPolicy(({ exception }) => { monitoring.captureException(exception); return { action: isDevMode() ? 'reject' : 'accept' }; }); ``` ```typescript query('products', { loaderSchema: ProductsSchema, schemaValidationPolicy: () => ({ action: 'reject' }), // ... }); ``` Rejected parses produce a `SCHEMA_VALIDATION_ERROR` with `scope: 'parse'`. Resource exceptions expose the stage through `exceptions().parse.method`, `exceptions().parse.params` and `exceptions().parse.loader`; states expose `exceptions().parse.state`. All four primitives expose `hasSchema()`, which is `true` when at least one schema is configured. ## Effect Schema Effect Schema works, but not by handing the schema over directly. An `effect/Schema` is **not** itself a Standard Schema — you convert it once with `Schema.toStandardSchemaV1`, and the result goes anywhere a schema goes: ```typescript import { Schema } from 'effect'; const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }); const people = yield* query('people', { loaderSchema: Schema.toStandardSchemaV1(Schema.Array(Person)), loader: async () => fetchPeople(), }); ``` Nothing in `@craft-ts/core` knows about Effect, and `@craft-ts/effect` ships no adapter for this: the whole interop is the Standard Schema spec, which both sides already implement. You do not need `@craft-ts/effect` installed to validate with Effect Schema. Failures behave like any other schema failure. Effect's issues become a `SCHEMA_VALIDATION_ERROR` on the parse channel — they are never thrown, and never surface as an Effect `Cause`: ```typescript craftUse(person.exceptions()).parse.state?._tag; // 'SCHEMA_VALIDATION_ERROR' ``` ### The one thing to watch: async decoding `paramsSchema`, `methodSchema` and the local writes (`set`, `update`, `patch`) are **synchronous** stages. They throw if a schema returns a `Promise`: > The query:people params schema returned a Promise where a synchronous result > is required. A plain Effect schema decodes synchronously, so it is fine at every stage. But a schema with an asynchronous transformation is only usable in `loaderSchema`, which is the one stage that awaits. If you need to validate against something async — a uniqueness check, a remote lookup — do it in the loader as an Effect and let its typed error flow through `runEffect`, rather than hiding it in a schema. ### Decoded output, not encoded input Craft publishes the schema **output**. When the Effect schema decodes into a different type than it accepts, it is the decoded type the rest of your code sees: ```typescript // `Schema.Date` accepts a Date and REJECTS a string. The one that decodes is // `Schema.DateFromString` — encoded: string, decoded: Date. const createdAt = yield* state('createdAt', { $self: rawFromServer, // string schema: Schema.toStandardSchemaV1(Schema.DateFromString), }); craftUse(createdAt()); // Date ``` ## See Also * [Anatomy of a primitive](/guide/concepts/primitive-anatomy) * [Persistence](/guide/state/persistence) — validating restored values * [Exceptions as values](/guide/concepts/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/patterns/inject-at-point-of-use.md --- # Inject at the point of use This page introduces the first **recommended approach** for structuring a Craft application. The useful rule is simple: > **Get what you need where you need it.** Declare a dependency in the smallest factory that actually uses it. If a query needs an API method, the query yields that method. If a route guard needs the current user, the guard yields the user service. There is no need to add an intermediary method to a component just to forward the call. ## The forwarding shape to avoid Even with Craft, do not resolve an API in the component factory only to forward it into a query: ```typescript export const Tasks = craftComponent( 'Tasks', {}, function* () { const api = yield* TaskApi(); const tasks = yield* query('tasks', { params: () => true, loader: function* () { return yield* api.list(); }, }); return { tasks }; }, ({ tasks }) => /* … */, ); ``` The loader closes over `api`, so the query itself does not declare the operation it uses. The dependency is attached to the component factory instead of to the smallest factory that performs the request. ## Craft puts the dependency next to the work With Craft, the component declares the query directly, and the query yields exactly the API operation it needs. In this example, `TaskApi` is a crafted service (or a small boundary adapter): ```typescript import { craftComponent, forNode, ifNode, li, p, ul } from '@craft-ts/component'; import { query } from '@craft-ts/core'; export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* query('tasks', { params: () => true, loader: function* () { return yield* TaskApi.list(); }, }); return { tasks }; }, ({ tasks }) => ifNode( tasks.isLoading, () => p('Loading…'), () => ul( forNode( () => tasks.value() ?? [], { track: (task) => task.id }, (task) => li(task.title), ), ), ), ); ``` `TaskApi.list()` is yielded directly from the `query` loader. The query owns the server state, while the template owns only the rendering of that state. There is no `loadTasks()` method, no subscription, and no extra service whose only job is to forward this request. ## Why this is useful ### The dependency graph is explicit `yield* TaskApi.list()` is part of the loader's dependency type. Craft can use the same information for route DI checks, test registers, and dependency snapshots. A missing provider or mock is found at the boundary where it matters. ### Dependencies stay granular When a consumer needs one operation, yield that operation instead of the whole service: ```typescript const list = yield* TaskApi.list(); ``` The graph records the property that was used. Tests only need to provide `list`, and future changes to unrelated API methods do not expand this consumer's contract. ### Async behaviour has one owner `query` derives the loading, value, and exception state. The component does not need a second signal, subscription, or manual error flag that could drift away from the request. ## The rule of thumb * If a query or mutation needs an API operation, yield it in that query or mutation. * If a service needs another service, yield the dependency in that service's factory. * If a component needs a dependency directly, yield it in the component's factory. * Create a dedicated service when it owns reusable behaviour or a meaningful boundary — not merely to forward one method call. Direct does not mean unstructured. The dependency is still named, tracked, scoped, mockable, and exposed through a deliberate public API. It simply lives close to the code that uses it. ## See also * [The mental model](/guide/concepts/mental-model) — declare, yield, derive * [`craftService`](/guide/app/craft-service) — define and compose services * [Shaping a service's public API](/guide/app/expose-api) — expose only what a consumer needs * [Testing services](/guide/testing/services) — test the same dependency graph * [Architecture rules](/guide/testing/architecture) — constraints across that graph --- --- url: https://craft-ts.github.io/craft/guide/app/craft-service.md --- # craftService A service is a factory with a **name** and a **scope** — not a class. It packages primitives and dependencies behind an explicit API, and keeps the whole dependency graph visible to the compiler. **Use it when** logic outgrows a single component field, or when two places need the same behaviour. Use a small adapter when a dependency is owned by the runtime environment rather than by your application. The contrast with `inject(...)` scattered across classes is the point: dependencies here are explicit and **type-visible**, which is what the route DI check and the test registers read. ```typescript import { craftService } from '@craft-ts/core'; ``` Service inputs that can change should be consumed as yieldable readers (`CraftServiceInput`), the service counterpart of a component `Input`. Yield them so the input-to-service edge stays in the dependency graph: ```typescript import { craftService, query, type CraftServiceInput } from '@craft-ts/core'; const { UserQuery } = craftService( { name: 'UserQuery', providedIn: 'global' }, (inputs: { userId: CraftServiceInput }) => query('userQuery', { params: function* () { return yield* inputs.userId(); }, loader: ({ params }) => ApiService.getItemById(params), }), ); ``` The call site still accepts a resolved value, a signal, or a Craft reader — the service boundary adapts it into that reader. Inside the factory, always `yield* inputs.x()`. ## What you get Declaring a service gives you a set of generated helpers. For one named `Counter`: * `Counter(...)` — consume or compose it inside a craft generator * `Counter.someProperty(...)` — derive one public property directly * `provideCounter(...)` — for provider-capable scopes * `COUNTER_META_DATA` — for metadata-driven tooling * `CounterRequirement` — for `abstract` services * `provideCounter(factory)` — on `abstract` services, to implement the contract inline Which of those exist depends on the scope. ::: warning Breaking change — no more `injectX` The generated helper is the service name itself: `X`. `craftService` no longer exports `injectX`, and the former `XToYield` helper is gone. Use `X()` in a craft generator and compose with `yield* X()`. ::: ## Supported scopes A service declares how many instances of it exist through `scope`: `function`, `toProvide`, `global`, `manuallyProvidedAtRoot` or `abstract`. Default to `function`. Each scope and when to pick it: **[Service scopes](/guide/app/service-scopes)**. ## The common case ```ts import { craftService, state } from '@craft-ts/core'; const { Counter } = craftService( { name: 'Counter', providedIn: 'global' }, function* () { const counter = yield* state('counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), decrement: () => update((value) => value - 1), })); return counter; }, ); const { CounterConsumer } = craftService( { name: 'CounterConsumer', providedIn: 'global' }, function* () { const counter = yield* Counter(); yield* counter.increment(); return counter; }, ); ``` ## Returning one primitive directly When a service exposes only one primitive, the factory can return its generator directly. `craftService` drives it and the generated service helper returns the primitive reference: ```typescript import { craftService, query, type CraftServiceInput } from '@craft-ts/core'; const { UserQuery } = craftService( { name: 'UserQuery', providedIn: 'global' }, (inputs: { userId: CraftServiceInput }) => query('userQuery', { params: function* () { return yield* inputs.userId(); }, loader: ({ params }) => ApiService.getItemById(params), }), ); ``` For several primitives, use `craftYieldRecord`. It resolves every generator in the record and preserves the record keys: ```typescript import { craftService, craftYieldRecord, query, state, type CraftServiceInput, } from '@craft-ts/core'; const { UserQuery } = craftService( { name: 'UserQueryWithState', providedIn: 'global' }, (inputs: { userId: CraftServiceInput }) => craftYieldRecord({ userQuery: query('userQuery', { params: function* () { return yield* inputs.userId(); }, loader: ({ params }) => ApiService.getItemById(params), }), refresh: state('refresh', 0, ({ update }) => ({ increment: () => update((value) => value + 1), })), }), ); ``` Inside a generator factory, the equivalent explicit form remains available: `const userQuery = yield* query(...)`. ## Scoping providers to the service Use `providers` in the service config when the service factory itself needs locally-scoped dependencies: ```typescript const { UserFacade } = craftService( { name: 'UserFacade', providedIn: 'global', providers: [provideUserApi(), provideUserLogger()], }, function* () { const api = yield* UserApi(); const logger = yield* UserLogger(); return { rename: (user: { id: string; name: string }, name: string) => { logger.log(`rename:${user.id}`); return api.updateUser({ ...user, name }); }, }; }, ); ``` This is separate from `provideUserFacade()`, which is only generated for provider-capable scopes like `toProvide`. ## Composing services ```ts import { craftService, state } from '@craft-ts/core'; const { Counter } = craftService( { name: 'Counter', providedIn: 'global' }, function* () { const counter = yield* state('counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), })); return counter; }, ); const { CounterFacade } = craftService( { name: 'CounterFacade', providedIn: 'global' }, function* () { const counter = yield* Counter(); return { read: function* () { return yield* counter(); }, increment: function* () { return yield* counter.increment(); }, }; }, ); ``` ## Shaping the public API `yield* X()` can expose only part of a dependency, and `X.property()` derives a single one. See **[Shaping a service's public API](/guide/app/expose-api)**. ## Contracts without an implementation `scope: 'abstract'` declares a contract that a provider must satisfy later. See **[Abstract services](/guide/app/abstract-services)**. ## Startup work `craftService` also supports startup hooks through `appStart: true` and `yield* onAppStart(...)`. The callback can be a plain function or a generator function. Use the generator form when startup logic needs to `yield*` crafted dependencies: ```ts import { Console, craftAppConfig, craftService, onAppStart, } from '@craft-ts/core'; const { AppStartLog } = craftService( { name: 'AppStartLog', providedIn: 'global', appStart: true, }, function* () { yield* onAppStart(function* () { yield* Console.log('startup log'); return Promise.resolve(); }); return true; }, ); declare module '@craft-ts/core' { interface CraftAppStartRegistry { AppStartLog: typeof AppStartLog; } } export const appConfig = craftAppConfig({ appStart: { AppStartLog }, }); ``` Dependencies used only inside that callback are still tracked on the parent service. ## Pitfalls **Reaching for `global` by default.** A global service is a singleton for the whole app, whether or not that was intended. Start at `function` — see [Service scopes](/guide/app/service-scopes). **`toProvide` without the provider.** A missing provider is reported by the route at compile time; the failure appears at runtime. The [route DI check](/guide/routing/setup) is what closes that hole. [Architecture tests](/guide/testing/architecture#assertroutediproofs) keep that check from quietly disappearing — a `CanRun` alias that nobody references still compiles. **Returning the whole world.** What a service returns is its API. Return the narrow thing; consumers that need more can yield more. ## See Also * [Service scopes](/guide/app/service-scopes) — the one decision to make * [Shaping the public API](/guide/app/expose-api) * [Testing services](/guide/testing/services) --- --- url: https://craft-ts.github.io/craft/guide/app/service-scopes.md --- # Service scopes `scope` decides how many instances of a `craftService` exist and who has to provide it. It is the one decision to make when declaring a service. ::: tip Short version Default to `function`. Move to `toProvide` the day a child component needs the same instance. Use `global` only for genuinely app-wide state. ::: ## Supported Scopes ### `global` * singleton provided at root * ideal for app-wide services and shared state * no explicit `provideX()` helper ### `toProvide` * requires `provideX()` where the service is mounted * useful for feature-local service trees * works well with tests that need explicit providers ### `manuallyProvidedAtRoot` * explicit provider helper, but designed to be mounted at root * exposes the generated `provideX()` helper for explicit root composition * allows this scope to be yielded by global services, which is not possible with `toProvide` (it still requires explicit setup when testing with `setupCraftServiceTestingByRegister`). ### `function` * creates a fresh instance on each injection * useful for reusable factories with bindings and inputs ### `abstract` * declares a contract without implementation * exposes a requirement token to force a concrete implementation later ## Recommendations For Choosing a Scope * 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. * 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. * 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. * Use `global` when the instance is intentionally shared application-wide. * 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. ## See Also * [craftService](/guide/app/craft-service) * [Route providers](/guide/routing/route-providers) — providing a service from a route * [Testing services](/guide/testing/services) --- --- url: https://craft-ts.github.io/craft/guide/app/expose-api.md --- # Shaping a service's public API A service returns whatever should be public. These are the ways to consume less than everything a dependency exposes — which keeps the dependency graph precise, and therefore keeps inference and test registers small. ## Single Property Shortcut When only one public property is needed, `X.property()` is a shortcut for a one-property derivation. ```ts import { craftService } from '@craft-ts/core'; const { UsersApi } = craftService( { name: 'UsersApi', providedIn: 'global' }, () => ({ updateUser: (user: { id: string; name: string }) => Promise.resolve(user), getUsers: () => Promise.resolve([]), }), ); const { UserUpdater } = craftService( { name: 'UserUpdater', providedIn: 'global' }, function* () { const updateUser = yield* UsersApi.updateUser(); return { rename: (user: { id: string; name: string }, name: string) => updateUser({ ...user, name }), }; }, ); ``` For method properties on services without public inputs, the shortcut can call the method directly: ```typescript return yield * UsersApi.updateUser({ id: '1', name: 'Romain' }); ``` The shortcut accepts the same bindings as `X(...)`: ```typescript const increment = yield* Counter.increment({ initialValue: startAt }); ``` Use the full `X(bindings, expose)` form when deriving several properties, creating aliases, exposing `$self`, using symbol keys, or when a service property collides with a native function property such as `name`. ## Property Shortcut The same shortcut notation is available on the generated `X` helper. Use it inside a craft generator when only one property is needed: ```ts import { craftService, state } from '@craft-ts/core'; const { UsersApi } = craftService( { name: 'UsersApi', providedIn: 'global' }, function* () { const currentUser = yield* state('currentUser', { id: '1', name: 'Ada', }); return { updateUser: (user: { id: string; name: string }) => Promise.resolve(user), currentUser, }; }, ); const { CurrentUser } = craftService( { name: 'CurrentUser', providedIn: 'global' }, function* () { return yield* UsersApi.currentUser(); }, ); ``` The result carries the same dependency tracking as `yield* UsersApi()`, so testing utilities see exactly which property was accessed. For method properties on services without public inputs, the shortcut calls the method directly: ```typescript const update = yield * UsersApi.updateUser({ id: '1', name: 'New' }); ``` ## Nested Property Shortcuts When only a sub-property of a service output is needed, add a second `.property` before calling: ```ts import { craftService, state } from '@craft-ts/core'; const { SearchApi } = craftService( { name: 'SearchApi', providedIn: 'global' }, function* () { const isLoading = yield* state('isLoading', false); const data = yield* state('data', [] as string[]); return { usersQuery: { isLoading, data, }, }; }, ); const { SearchFacade } = craftService( { name: 'SearchFacade', providedIn: 'global' }, function* () { const isLoading = yield* SearchApi.usersQuery.isLoading(); return { isLoading }; }, ); ``` The dependency graph records only the accessed nested property (`derivedPropertiesUsed: { usersQuery: { isLoading: ... } }`), not the full `usersQuery` object. Testing utilities therefore only require the used sub-property in mock objects. The result of `yield* X.parent.child()` carries the same tracked dependency metadata, so `ExtractDeps` correctly surfaces the service dependency. ## OmitInputs When a service has public inputs, the no-arg form of a property shortcut is intentionally disabled at the type level, because calling without bindings would silently use default values and mask a missing dependency: ```ts import { craftService, type CraftServiceInput } from '@craft-ts/core'; const { Counter } = craftService( { name: 'Counter', providedIn: 'function' }, function* (inputs: { initialValue?: CraftServiceInput }) { const initialValue = inputs.initialValue ? yield* inputs.initialValue() : 0; return { count: initialValue }; }, ); ``` ```typescript // Fine — bindings are explicit const count = yield* Counter.count({ initialValue: startAt }); // Type error — no-arg call is forbidden when inputs exist // Counter.count(); ``` Use `X.OmitInputs.property()` to explicitly opt out of input bindings and use the defaults: ```typescript const count = yield* Counter.OmitInputs.count(); const count2 = yield* Counter.OmitInputs.count(); ``` `OmitInputs` is purely a type-level gate — at runtime it is transparent. `OmitInputs` composes with nested shortcuts: ```typescript const isLoading = yield* Counter.OmitInputs.userQuery.isLoading(); ``` ## Partial Exposure `yield* X()` can expose only the part of a dependency that should remain public. ```ts import { craftService, state } from '@craft-ts/core'; const { Counter } = craftService( { name: 'Counter', providedIn: 'toProvide' }, function* () { const counter = yield* state('counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), decrement: () => update((value) => value - 1), })); return counter; }, ); const { CounterExtended, provideCounterExtended } = craftService( { name: 'CounterExtended', providedIn: 'toProvide' }, function* () { return yield* Counter(undefined, ({ $self, increment }) => ({ $self, incrementCounter: increment, })); }, ); ``` This keeps the dependency graph precise, which is important for both type inference and testing. ## See Also * [craftService](/guide/app/craft-service) * [Testing services](/guide/testing/services) --- --- url: https://craft-ts.github.io/craft/guide/app/abstract-services.md --- # Abstract services An `abstract` service declares a **contract** with no implementation, and forces a concrete one to be supplied downstream. This is what makes a service's implementation a decision of the mounting site — a route, a feature config, a test — instead of a hard import. ## Abstract Requirements Use `providedIn: 'abstract'` to declare a contract that must be implemented elsewhere. ```ts import { abstract, craftService } from '@craft-ts/core'; type CounterContract = { (): number; increment(): void; }; const { CounterRequirement } = craftService( { name: 'Counter', providedIn: 'abstract' }, abstract(), ); ``` Concrete services can then depend on `CounterRequirement`. ## Abstract Providers An `abstract` service also exposes a `provideX(factory)` helper. It takes a **factory** — a plain function or a generator — produces a value matching the contract, and binds it to the requirement token. This lets you implement the contract **inline at the providing site** (a route, a component, a feature config) instead of declaring a separate concrete `craftService`. ```typescript import { abstract, craftService } from '@craft-ts/core'; type User = { name: string }; const { User, provideUser } = craftService( { name: 'User', providedIn: 'abstract' }, abstract(), ); // Implement the contract inline: const providers = [provideUser(() => ({ name: 'Ada' }))]; // Anywhere downstream, inside a craft generator: const user = yield * User(); ``` The factory can be a **generator** that yields other services. Everything it yields is tracked, so the resulting provider participates in the cascade DI check just like a regular service: ```typescript const { Greeting } = craftService( { name: 'Greeting', providedIn: 'global' }, () => ({ prefix: 'Hello' }), ); const providers = [ provideUser(function* () { const greeting = yield* Greeting(); return { name: `${greeting.prefix} Ada` }; }), ]; ``` This is the foundation of route-scoped providers: a route can implement an abstract contract from its own guarded data / params. See [Type-safe DI/Routes → Route Providers](/guide/routing/route-providers). ## See Also * [Service scopes](/guide/app/service-scopes) * [Route providers](/guide/routing/route-providers) --- --- url: https://craft-ts.github.io/craft/guide/app/app-start.md --- # App start `onAppStart` declares work that must run — and finish — before the application renders, owned by the service that needs it rather than by a global bootstrap file. **Use it when** something must be true before the first paint: a loaded config, a restored session, a feature-flag fetch. **Not when** the work can happen after render — that is just an effect, and blocking on it costs your users a blank screen. ## Import ```typescript import { onAppStart } from '@craft-ts/core'; ``` ## Overview `onAppStart(...)` is used inside a `craftService(..., function* () {})` generator to declare logic that should run when the application starts. Important constraints: * the owning service must be declared with `appStart: true` * a service can declare `yield* onAppStart(...)` only once * the callback can be a plain function or a generator function * nested `onAppStart(...)` calls inside the callback are not supported `craftAppConfig(...)` runs registered app-start services during application initialization. ## Signature ```typescript function onAppStart( run: () => Observable | Promise | void, ): Generator; function onAppStart( run: () => Generator< Yielded, Observable | Promise | void, unknown >, ): Generator; ``` ## Plain Callback Use a plain callback when startup logic does not need to `yield*` crafted dependencies. ```ts import { craftService, onAppStart } from '@craft-ts/core'; export const { StartupFlag } = craftService( { name: 'StartupFlag', providedIn: 'global', appStart: true, }, function* () { yield* onAppStart(() => { console.log('app started'); return Promise.resolve(); }); return true; }, ); ``` ## Generator Callback Use a generator callback when startup logic needs to `yield*` crafted dependencies. ```typescript import { Console, craftService, onAppStart } from '@craft-ts/core'; export const { AppStartLog } = craftService( { name: 'AppStartLog', providedIn: 'toProvide', appStart: true, }, function* () { yield* onAppStart(function* () { yield* Console.log('This is a log from the appStart callback'); return new Promise((resolve) => setTimeout(resolve, 1000)); }); return 1; }, ); ``` The callback generator supports the same dependency-yield semantics as a normal crafted generator for: * `yield* X(...)` * `yield*` exposure tokens returned by derivation callbacks * browser boundaries such as `yield* Console.log(...)` Dependencies used only inside this callback are merged into the parent service dependency graph. ## Registering it with `craftAppConfig` Declaring `onAppStart` is only half of it — nothing runs until the service is **registered**. Two steps, and both are mechanical. Augment the app-start registry so the service is known by name: ```typescript declare module '@craft-ts/core' { interface CraftAppStartRegistry { AppStartLog: typeof AppStartLog; } } ``` Then list it in `craftAppConfig`: ```typescript export const appConfig = craftAppConfig({ appStart: { AppStartLog, }, providers: [ /* … */ ], }); ``` `craftAppConfig` runs every registered app-start service during the application's application initialization, and the app renders once they have settled. Here it is end to end: ```ts import { Console, craftAppConfig, craftService, onAppStart, } from '@craft-ts/core'; const { AppStartLog } = craftService( { name: 'AppStartLog', providedIn: 'global', appStart: true, }, function* () { yield* onAppStart(function* () { yield* Console.log('startup log'); return Promise.resolve(); }); return true; }, ); declare module '@craft-ts/core' { interface CraftAppStartRegistry { AppStartLog: typeof AppStartLog; } } export const appConfig = craftAppConfig({ appStart: { AppStartLog }, }); ``` ::: tip The registry augmentation is generated The `declare module` block is written for you by the craft-ts ESLint plugin — you rarely type it by hand. ::: ::: warning A declared hook that is never registered simply never runs It is not an error: `appStart: true` and `yield* onAppStart(...)` describe the service, the `appStart` map in `craftAppConfig` is what activates it. If startup logic silently doesn't happen, check the map first. ::: ## Dependency Tracking Generator callbacks are type-visible. If the callback only uses `Console`, the owning service dependency graph includes `ConsoleService` as a normal dependency node, with `browserBoundary: true`. This means startup-only dependencies are still visible to: * `GetServiceDependencies` * route/app DI checks built on top of service metadata * test helpers that inspect crafted dependency graphs ## Runtime Behavior `onAppStart(...)` does not run when the service instance is created. It registers a startup hook that is executed when the application initializer runs that service, typically through `craftAppConfig(...)`. If the callback returns: * `void`: startup continues immediately * `Promise`: startup waits for the promise to resolve * `Observable`: startup waits until the observable completes Generator callbacks preserve the same waiting behavior. The generator itself resolves first, then its returned `Promise` / `Observable` / `void` is used as the startup result. ## Common Errors ### Missing `appStart: true` ```typescript yield * onAppStart(() => undefined); ``` This throws at runtime if the owning service was not declared with `appStart: true`. ### Nested `onAppStart(...)` ```typescript yield * onAppStart(function* () { yield* onAppStart(() => undefined); // unsupported return undefined; }); ``` Nested declarations are rejected at runtime. ## See Also * [`craftService`](/guide/app/craft-service) * [`Browser Boundaries`](/guide/testing/browser-boundaries) --- --- url: https://craft-ts.github.io/craft/guide/app/lazy-services.md --- # Lazy services `craftLazy(load)` code-splits a **service**, the way `loadComponent` code-splits a component — and reuses the same retry and cache-busting engine. **Use it when** an expensive dependency is only needed on some paths: a PDF renderer, a chart library, an admin-only API client. **Not for** a service every route needs — the extra round-trip buys nothing. `craftLazy(load)` lazily imports a module **on demand** from inside an async craft driver — an [`asyncProcess`](/guide/state/async-process) loader, or a route guard/resolver — reusing the exact same retry + cache-busting engine as route lazy loading ([`loadComponent` / `loadChildren`](/guide/routing/route-load-errors)). Use it when you want to code-split a **service function** (an exported `craftGen`, an API helper, a heavy computation) and only fetch its chunk when it is actually needed, while keeping Craft's `status` / `exception` / `reload` semantics. ## Why not a manual dynamic import? The reflex is to reach for a manual `import()` and inject/call the result imperatively (an `injectAsync`-style helper): ```ts // ❌ manual: no status, no typed exceptions, no retry, not reactive async function runSearch(q: string) { const { search } = await import('./search'); // may throw on a stale chunk return search(q); // exceptions are untyped, failures are unhandled } ``` `craftLazy` replaces that with a first-class craft program: | Concern | Manual `import()` | `craftLazy` | | ------------------------------------ | ---------------------- | -------------------------------------------------------- | | Loading / resolved / exception state | you wire it by hand | inherited from the enclosing `asyncProcess` (`status()`) | | Stale-chunk retry after a redeploy | none | shared `withRetry` cache-busting engine | | Import failure | an unhandled rejection | a typed `CRAFT_LAZY_LOAD_ERROR` exception | | The module's own business exceptions | erased to `any` | preserved and propagated through the type system | | Recovery | manual `try/catch` | `.pipe(catchTag(...))` or route `handleExceptions` | ## Signature ```ts craftLazy(load: (helpers: CraftLazyLoadHelpers) => Promise): CraftGenInvocation; interface CraftLazyLoadHelpers { // Wrap the dynamic import so a chunk whose hashed URL went stale after a // redeploy is re-fetched with a cache-busting query param. withRetry(moduleImport: Promise): Promise; } ``` * `craftLazy(...)` is a [`craftGen`](/guide/concepts/generators) program: `yield*`-composable and [`.pipe(...)`](/guide/advanced/program-operators)-able. * Its resolved value is the module `T`, **untouched** — the module's exported `craftGen`s keep their own exception unions. * On a final import failure it returns a `CraftLazyLoadError` (`code: 'CRAFT_LAZY_LOAD_ERROR'`), which `craftGen` surfaces as a short-circuit → the enclosing resource's `status()` becomes `'exception'`. ::: warning It must run in an async driver `craftLazy` awaits its import through the async program pump, so it can only be `yield*`-ed from an **`asyncProcess` loader** or a **route guard/resolver**. It cannot be used inside a synchronous [`craftMethod`](/guide/reactivity/craft-method) (that driver throws on an await request). A `craftMethod` may only *trigger* the enclosing `asyncProcess`. ::: ## With `asyncProcess` The module to split — an exported `craftGen`: ```ts // search.ts (its own chunk) import { craftGen } from '@craft-ts/core'; import { SearchApi } from './search-api'; export const search = craftGen(function* (q: string) { const api = yield* SearchApi(); return yield* api.search(q); // may raise E1 | E2 }); ``` Load it from an `asyncProcess` loader. The simplest form triggers on demand with the generated `method`: ```ts import { asyncProcess, craftLazy } from '@craft-ts/core'; const searchModule = yield* asyncProcess('searchModule', { method: () => undefined, // call searchModule.method() to start loading loader: function* () { return yield* craftLazy(({ withRetry }) => withRetry(import('./search'))); }, }); ``` `searchModule.status()` walks `idle → loading → resolved` (or `exception`), exactly like any other `asyncProcess`, so the template can drive the UI: ```html @switch (searchModule.status()) { @case ('loading') { } @case ('exception') { } } ``` To **prefetch** as soon as some event fires (the reactive equivalent of an eager `injectAsync`), bind the process to a source instead of a `method`: ```ts import { asyncProcess, craftLazy, on$ } from '@craft-ts/core'; const searchModule = yield* asyncProcess('searchModule', { // load at the first emission of the source (e.g. on focus of the search box) method: on$(searchFocused$, () => undefined), loader: function* () { return yield* craftLazy(({ withRetry }) => withRetry(import('./search'))); }, }); ``` ### Load once, use many The canonical pattern: one `asyncProcess` owns the module, a second one awaits it with [`craftUntilSettled`](/guide/routing/guards) and calls the loaded function. Wrapping both in a [`craftService`](/guide/app/craft-service) exposes a clean API: ```typescript import { asyncProcess, craftLazy, craftService, craftUntilSettled, on$, } from '@craft-ts/core'; const { Search } = craftService({ name: 'Search', scope: 'component' }, () => { // prefetch the module at the first emission of the source const searchModule = yield* asyncProcess('searchModule', { method: on$(searchFocused$, () => undefined), loader: function* () { return yield* craftLazy(({ withRetry }) => withRetry(import('./search')), ); }, }); // run a search on a user action — triggerSearch(q) sets the params const searchResult = yield* asyncProcess('searchResult', { method: (q: string) => q, loader: function* ({ params: q }) { const { search } = yield* craftUntilSettled(searchModule); // wait for the chunk return yield* search(q); }, }); return { searchModule, searchResult }; }); ``` Exception propagation is fully typed, with **no** manual plumbing: * `craftLazy` may add `CRAFT_LAZY_LOAD_ERROR`; * `craftUntilSettled(searchModule)` relays it to `searchResult`; * `search(q)` relays its own `E1 | E2`. So `searchResult.exception()?._tag` is exactly `'CRAFT_LAZY_LOAD_ERROR' | 'E1' | 'E2'`, and `searchResult.value()` keeps the return type of `search`. ## In routes Guards and resolvers are async drivers too, so you can `yield* craftLazy(...)` directly inside them. A failed import surfaces as `CRAFT_LAZY_LOAD_ERROR` and flows into the route's [exception handlers](/guide/concepts/exceptions), exactly like any other guard/resolver exception: ```ts craftRoute( 'search', { resolve: craftResolve(function* () { const { search } = yield* craftLazy(({ withRetry }) => withRetry(import('./search')), ); return yield* search('*'); }), }, { CRAFT_LAZY_LOAD_ERROR: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'offline' }); }), E1: craftExceptionHandler(function* () { return [] as Result[]; }), // E2 left unhandled → surfaces as a route exception }, ); ``` This is the code-splitting counterpart of a lazy `loadComponent`: instead of splitting the *component*, you split the *data-loading logic* it depends on, with the same retry + error screen guarantees as [Route Load Errors](/guide/routing/route-load-errors). ## Handling the load error `CRAFT_LAZY_LOAD_ERROR` is an ordinary craft exception, so all the usual tools apply. **Catch it at the source** (fall back to another module or a default), which removes it from the exception union: ```ts loader: function* () { return yield* craftLazy(({ withRetry }) => withRetry(import('./search'))).pipe( catchTag('CRAFT_LAZY_LOAD_ERROR', function* () { return yield* craftLazy(({ withRetry }) => withRetry(import('./search-fallback'))); }), ); } ``` **Catch a business exception of the loaded function** — `search(q)` is itself a pipeable `craftGen`: ```ts loader: function* ({ params: q }) { const { search } = yield* craftUntilSettled(searchModule); return yield* search(q).pipe( catchTag('E1', function* () { return [] as Result[]; }), // E2 stays in searchResult.exceptions() ); } ``` **Read it reactively** — anything left uncaught keeps `status()` at `'exception'` and shows up in `exceptions()` / `hasException()`, ready to render in the template. See [Program Operators](/guide/advanced/program-operators) for `catchTag` / `catchTag.exhaustive`. ## Retry & cache-busting `withRetry(import(...))` is what makes a stale chunk recover after a redeploy: on failure the chunk URL is re-fetched with a cache-busting query param. The attempt/back-off policy is injectable and defaults to the shared craft loader retry (one retry, 250 ms): ```ts import { provideCraftLazyLoadRetry } from '@craft-ts/core'; providers: [ provideCraftLazyLoadRetry({ attempts: 2, delayMs: (error, ctx) => 250 * ctx.attempt, shouldRetry: (error) => isRecoverable(error), }), ]; ``` The dynamic `import(url)` used for cache-busting is itself overridable through `CRAFT_DYNAMIC_IMPORT` (useful in tests). This is the very same engine as [route load retry](/guide/routing/route-load-errors), so a `craftLazy` import and a lazy route load behave identically under a bad deployment. ## API | Export | Purpose | | ------------------------------------------------------------- | ---------------------------------------------------------------- | | `craftLazy(load)` | Lazily import a module from an async craft driver. | | `CraftLazyLoadHelpers` | The `{ withRetry }` helpers passed to `load`. | | `CraftLazyLoadError` / `CRAFT_LAZY_LOAD_ERROR_CODE` | The exception (and its code) returned on a final import failure. | | `provideCraftLazyLoadRetry(config)` / `CRAFT_LAZY_LOAD_RETRY` | Configure the `craftLazy` retry policy. | | `CRAFT_DYNAMIC_IMPORT` | Override the dynamic `import(url)` (cache-busting / tests). | ## See Also * [craftService](/guide/app/craft-service) * [asyncProcess](/guide/state/async-process) — the usual driver for `craftLazy` * [Route load errors](/guide/routing/route-load-errors) — the same retry engine --- --- url: https://craft-ts.github.io/craft/guide/app/server-functions.md --- # Public and protected server functions `serverFunction` is a transport contract. Authentication and authorization are middleware concerns: do not add an `access: 'admin'` option or a parallel `protectedServerFunction` helper. ## The canonical path ```text client middleware → handshake / client context → existing server middleware → verified session and role → server-function handler → Effect service and Layer ``` The browser may announce an identifier through `clientContext`, but that value is untrusted. The server must load the session again and compare the claim with the verified identity. A missing, expired or revoked session must stop the middleware chain before the handler runs. ## Transport failures Every client-side server-function transport failure is returned as a typed `HttpError`, including a lost connection, an aborted request, an unavailable `fetch` implementation or an unreadable response. Network failures use `status: 0` and `statusText: 'Unknown Error'`, following the same convention as `CraftHttpClient`; the original failure is available in the error payload's `body` field. This means callers can handle connection loss through the normal Craft exception path instead of adding a raw Promise rejection handler: ```ts const result = yield * getAnimals({}); if (isCraftException(result) && result._tag === 'HttpError') { // show a retry action or an offline state } ``` The same normalization is applied to custom transports registered with `provideServerFunctionTransport(...)`. Business failures returned by the server keep their own typed tags and are not converted to `HttpError`. ## Public function ```ts export const listPublicAnimals = serverFunction( 'animals.public-list', inputSchema, { exposure: 'client', output: outputSchema }, ).handler(({ input }) => Effect.gen(function* () { const repository = yield* AnimalRepository; return yield* repository.list(input.filter); }), ); ``` ## Protected function Reuse the same middleware mechanism for the protected path: ```ts export const listAdminAnimals = serverFunction( 'animals.admin-list', inputSchema, { exposure: 'client', output: outputSchema }, ) .use(requireAdminSession) .handler(({ input }) => Effect.gen(function* () { const repository = yield* AnimalRepository; return yield* repository.listForAdmin(input.filter); }), ); ``` `requireAdminSession` is a `craftMiddleware(...).server(...)` value. It should return an explicit authentication/authorization failure when there is no valid session or the role is insufficient. The handler is then only responsible for the business operation. For middleware shared by many functions, the optional `createServerFunctionFactory([middleware])` factory applies those existing `.use(...)` calls in order; it does not introduce a new security policy API. See the executable public/protected example in [`apps/demo-with-server-function`](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function), especially `craftMiddleware`, `clientContext`, `craftHandshake` and `.use(...)`. Its server tests cover no session, a valid session and revocation. Keep the server test beside the function so the middleware wiring remains visible. The demo maps the two session failures to explicit 401 responses and maps a non-admin session to a 403 response. ## Find the pieces * [`serverFunction`](/learn-effect/09-server-functions) * [`craftMiddleware`](/learn-effect/09-server-functions#middleware-and-security) * [`clientContext`](/learn-effect/09-server-functions#middleware-and-security) * [`craftHandshake`](/learn-effect/09-server-functions#middleware-and-security) * [Effect Layers and requirements](/learn-effect/06-layers-routing) Useful search terms are `auth`, `authentication`, `authorization`, `session`, `role`, `access policy` and `middleware`. --- --- url: https://craft-ts.github.io/craft/guide/app/register.md --- # craftRegisterFor `craftRegisterFor` exposes, within a Craft injection scope, the services, components and directives that are **currently alive** in it. **Use it when** a parent must drive several children without each child having to push a bespoke API upwards: counters, audio players, selected items, validation across a form section. **Not when** one known child is involved — pass it a service or an input instead. A registry trades explicitness for reach. ## Declaring a registry The registry is typed from the Craft targets it accepts: ```ts import { craftComputed, craftRegisterFor } from '@craft-ts/core'; const { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor( 'Counter', [Counter, CounterChild], ); ``` The first argument is the registry's mandatory name. It generates the two public helpers `RegisterForCounter` and `provideRegisterForCounter`, a convention that lets several registries coexist in one scope without name collisions. With a single target, the array can be omitted: ```ts const { RegisterForCounter } = craftRegisterFor( 'Counter', Counter, ({ Counter }) => ({ total: craftComputed('total', function* () { return (yield* Counter())?.length ?? 0; }), }), ); const counters = yield* RegisterForCounter(); const total = craftComputed('total', function* () { return (yield* counters())?.length ?? 0; }); ``` If a projection uses several groups, every target must be declared: ```ts craftRegisterFor( 'Counter', [Counter, CounterChild], ({ Counter, CounterChild }) => ({ total: craftComputed('total', function* () { return (yield* Counter())?.length ?? 0; }), incrementAll: function* () { for (const { ref } of (yield* CounterChild()) ?? []) { yield* ref.increment(); } }, }), ); ``` Then add the providers returned by `provideRegisterForCounter()` to the scope that should observe the instances: ```ts export const RegisterForDemo = craftComponent({ name: 'RegisterForDemo', providers: [provideRegisterForCounter()], // ... }); ``` By default the registry also includes `global` services resolved under that scope. To restrict observation to services whose scope matches the parent: ```ts craftRegisterFor('Counter', [Counter], { includeGlobal: false }); ``` The first declared target is reachable through `RegisterForCounter()` directly; additional targets get their own property, e.g. `RegisterForCounter.CounterChild()`. ## The common case — driving child components Each child creates a `toProvide` service, and the parent providing the registry observes them: ```ts const { Counter, provideCounter } = craftService( { name: 'Counter', providedIn: 'toProvide' }, function* () { const counter = yield* state( 'counter', 0, ({ update }) => ({ increment: () => update((value) => value + 1), decrement: () => update((value) => value - 1), }), ); return counter; }, ); const CounterChild = craftComponent( 'CounterChild', { providers: [provideCounter()] }, function* () { return yield* Counter(); }, ({ counter }) => div(counter), ); const { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor( 'Counter', [Counter, CounterChild], ); const CounterBoard = craftComponent( 'CounterBoard', { providers: [provideRegisterForCounter()] }, function* () { const counters = yield* RegisterForCounter(); const children = yield* RegisterForCounter.CounterChild(); return { incrementAll: function* () { for (const { ref } of (yield* counters()) ?? []) { yield* ref.increment(); } }, childCount: craftComputed('childCount', function* () { return (yield* children())?.length ?? 0; }), }; }, ({ incrementAll, childCount }) => section([ button({ click: incrementAll }, 'Increment every child'), p(function* () { return `Active children: ${yield* childCount()}`; }), forNode([1, 2, 3], () => CounterChild({})), ]), ); ``` When a child is added, its `Counter` appears in the group. When it leaves the DOM, the group updates on its own. ## Reading a group Groups are yieldable from a Craft factory. Their signal is `undefined` while no instance is registered, and returns to `undefined` when the last one is destroyed: ```ts const counters = yield* RegisterForCounter(); const incrementAll = function* () { for (const { ref } of (yield* counters()) ?? []) { yield* ref.increment(); } }; ``` Each entry carries: * `ref` — the value produced by the service, or the context returned by the component/directive factory; * `hostName` — the name of the host scope that created the entry. The signal is live: the parent never re-subscribes when a child appears or disappears. ## Partial exposure As with `craftService`, a group can expose only the façade the parent needs. The first argument stays `undefined` to keep the yieldable-helper syntax, and `$self` is the group's full signal: ```ts const childComponents = yield* RegisterForCounter.CounterChild( undefined, ({ $self }) => ({ total: craftComputed(function* () { return (yield* $self())?.length ?? 0; }), incrementAll: function* () { for (const { ref } of (yield* $self()) ?? []) { yield* ref.increment(); } }, decrementAll: function* () { for (const { ref } of (yield* $self()) ?? []) { yield* ref.decrement(); } }, }), ); ``` The parent then keeps only `total`, `incrementAll` and `decrementAll`. The dependency stays precise — the computed values read the group's signal, and instances are still added and removed automatically. ## Derived registry properties To share common projections, the second parameter of `craftRegisterFor` receives the groups' signals directly: ```ts const { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor( 'Counter', [Counter, CounterChild], ({ Counter, CounterChild }) => ({ totalCounter: craftComputed('totalCounter', function* () { return (yield* Counter())?.length ?? 0; }), incrementAllCounterChild: function* () { for (const { ref } of (yield* CounterChild()) ?? []) { yield* ref.increment(); } }, decrementAllCounterChild: function* () { for (const { ref } of (yield* CounterChild()) ?? []) { yield* ref.decrement(); } }, }), ); ``` Each derived property becomes a yieldable helper: ```ts const totalCounter = yield* RegisterForCounter.totalCounter(); const incrementAll = yield* RegisterForCounter.incrementAllCounterChild(); console.log(yield* totalCounter()); yield* incrementAll(); ``` For a single-target registry, the main call also returns the signal enriched with those derived properties — so the value stays callable for the raw entries while exposing `total` and the added methods: ```ts const childComponents = yield* RegisterForCounterChild(); const entries = yield* childComponents(); const total = yield* childComponents.total(); yield* childComponents.incrementAllChildCounter(); yield* childComponents.decrementAllChildCounter(); ``` In a Craft template, pass a method straight to an event and call signals inside a reactive callback: ```ts button({ click: childComponents.incrementAllChildCounter }, 'Increment all'); span(function* () { return `Children: ${yield* childComponents.total()}`; }); ``` The main group, the additional groups and the derived properties can all be used together. Derived properties are computed once per registry injector and keep the reactive signals the groups provide. ## Registering a directive Craft directives can be targets too: ```ts const { RegisterForCounter } = craftRegisterFor('Counter', [ CounterChild, CounterDebugDirective, ]); const debugEntries = yield* RegisterForCounter.CounterDebugDirective(); debugEntries()?.forEach(({ hostName, ref }) => { console.debug('directive active', hostName, ref); }); ``` A functional directive has no class instance, so `ref` is the factory context of the decorated component. Its `hostName` remains specific to the directive and its instance, which is what lets you tell several identical directives apart on the same screen. ## Lifecycle and references Services are registered when their yield resolves. The runtime attaches their removal to the destruction of the injector that carries them. Craft components and directives are functional factories with no class instance, so `ref` is their factory context. For a directive used with `.pipe(...)`, the final component's context is exposed, because that is the execution scope the directive shares. Every Craft component automatically gets a host tag of the form `component:#`, so `provideHostName` is not needed in a component's providers — it stays useful only to override that automatic name. Directives applied to an element get their own `hostName`, generated from the directive name and an instance id. These names distinguish two identical instances and are usable for diagnostics and observability. Entries are removed automatically in every case: destruction of the component/directive, destruction of its DI scope, or replacement of a composition. ## Pitfalls ::: warning An empty registry is not an error Compilation checks that the target you pass to `craftRegisterFor` is a valid Craft service, component or directive — but it cannot check that an instance will ever be created. If no registered target exists in the executed code, there is no compile error and no runtime error: the signal is simply `undefined`. ::: ::: warning Craft targets only `craftRegisterFor` does not detect arbitrary classes. It targets `craftService`, `craftComponent` and `craftDirective`, whose scope and lifecycle the runtime knows. ::: **Declaring the same target twice** in the list is not supported — each target appears once. **Treating the group signal as always populated.** It is `undefined` before the first instance and after the last one; the `?.` is not optional. ::: details Extending the mechanism — target and yield wrappers The registry rests on two separate pieces: 1. a **yield wrapper** observes services as they are actually resolved; 2. the component/directive **runtime** reports their creation and ties cleanup to their lifecycle. The first is `provideCraftTargetWrapper`, documented on [Target wrapper](/guide/app/target-wrapper). The second is `provideServiceYieldWrapper`, the low-level hook `craftRegisterFor` uses to wrap every Craft service resolution in the scope where the yield runs — deliberately close to `provideFnWrapper`, but limited to service yields: ```ts import { provideServiceYieldWrapper, type ServiceYieldContext, } from '@craft-ts/core'; function* reportServiceYield( context: ServiceYieldContext, next: () => Generator, ) { const startedAt = performance.now(); const value = yield* next(); console.debug('service resolved', { name: context.name, hostScope: context.hostScope, duration: performance.now() - startedAt, }); return value; } export const providers = [ provideServiceYieldWrapper( 'Warning: the wrapper runs in the current Craft injection context.', reportServiceYield, ), ]; ``` `context.resolve()` resolves the real service; `next()` keeps the wrapper chain intact. Wrappers compose in registration order — the first is the outermost. The context provides `name`, `scope`, `hostScope`, `injector` and `resolve`. Like `provideFnWrapper`, this hook suits cross-cutting concerns — registries, metrics, traces, diagnostics — not business logic. A new tool can reuse `provideServiceYieldWrapper` to observe services without `craftRegisterFor` at all. For functional Craft targets the runtime also exposes its internal registration primitives, so another specialised view can be built — but `craftRegisterFor` stays the recommended application-level API. ::: ## See Also * [Target wrapper](/guide/app/target-wrapper) — the extension point underneath * [craftService](/guide/app/craft-service) * [Customization](/guide/components/customization) --- --- url: https://craft-ts.github.io/craft/guide/app/target-wrapper.md --- # Target wrapper `provideCraftTargetWrapper` wraps the registration of **every Craft component or directive created in the current injector**, giving you a hook at the moment each one comes to life. **Use it when** you need to observe or enrich target registration across a subtree: a specialised registry, observability, host names decorated with tags. **Not when** you just need a parent to drive its children — [`craftRegisterFor`](/guide/app/register) is built on this and already does it. ::: warning Dependency injection here is not type-checked The callback runs in a runtime chain, outside the usual DI inference. A service that is not provided in the current injector fails **at runtime**, and the wrapper's type cannot catch it. That is why the first argument is a mandatory warning string. ::: ## The common case ```ts import { provideCraftTargetWrapper } from '@craft-ts/core'; const provideTargetCustomization = provideCraftTargetWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (context, next) { return yield* next(); }, ); ``` The callback is a generator, so it can yield a Craft service: ```ts const provideTargetAudit = provideCraftTargetWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (context, next) { const audit = yield* TargetAuditService(); audit.recordCreatedTarget(context.kind, context.name); return yield* next(); }, ); ``` ## The context ```ts type CraftTargetContext = { target: unknown; kind: 'component' | 'directive'; name: string; ref: unknown; hostName: string; injector: Injector; }; ``` `target`, `kind`, `name` and `ref` describe the real instance and are immutable. **`hostName` is the only field you can change**, by passing it to `next(...)`. ## Tagging the target name ```ts import { HOST_TAG_LIST, provideCraftTargetWrapper } from '@craft-ts/core'; const provideTagBasedTargetRegistration = provideCraftTargetWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (context, next) { const tags = context.injector.get(HOST_TAG_LIST, []); const hostName = tags.length === 0 ? context.hostName : `${tags.join('/')}/${context.hostName}`; return yield* next({ hostName }); }, ); ``` Install it in the component's scope: ```ts const RegisterForDemo = craftComponent( 'RegisterForDemo', { providers: [provideTagBasedTargetRegistration], }, // ... ); ``` Order matters — a wrapper that modifies the `hostName` a registry consumes must be declared **before** that registry's wrapper: ```ts providers: [ provideTagBasedTargetRegistration, provideRegisterForCounter(), ], ``` Wrappers chain in declaration order; the first is the outermost, exactly like `provideFnWrapper`. ## `next()` and cleanup `next()` continues the chain and **returns a release function**, because the wrappers after yours may have added a registration or a resource of their own. A wrapper that only adapts the `hostName` just delegates: ```ts function* wrapper(context, next) { return yield* next({ hostName: `tag:${context.hostName}` }); } ``` A wrapper that creates its own resource must combine both cleanups: ```ts const provideObserver = provideCraftTargetWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (context, next) { const releaseNext = yield* next(); const releaseObserver = observeTarget(context); return () => { releaseObserver(); releaseNext(); }; }, ); ``` The runtime calls the cleanup automatically when the component's injector is destroyed. For a directive, it runs when the rendered node is removed. ## Pitfalls **Dropping the release function from `next()`.** Everything registered further down the chain then leaks. Always return it, alone or combined with your own. **Declaring the wrapper after the registry it should influence.** The registry will have already consumed the unmodified `hostName`. **Assuming a yielded service exists.** Nothing checks it here — a missing provider is a runtime failure. ::: details Building a specialised registry A registry can use the wrapper directly, without depending on `craftRegisterFor`: ```ts const provideSpecializedRegistry = provideCraftTargetWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (context, next) { const registry = yield* SpecializedRegistry(); const releaseNext = yield* next(); const releaseRegistry = registry.add({ kind: context.kind, name: context.name, ref: context.ref, hostName: context.hostName, }); return () => { releaseRegistry(); releaseNext(); }; }, ); ``` This is how you build registries by tag, by component kind, by scope or by business need, while reusing the same lifecycle as `craftRegisterFor`. ::: ## See Also * [craftRegisterFor](/guide/app/register) — the built-in registry on top of this * [Observability](/guide/advanced/observability) --- --- url: https://craft-ts.github.io/craft/guide/routing/setup.md --- # Routing setup Six steps turn plain route definitions into routes the compiler checks: a missing provider, a misspelled input or a route pointing at nothing becomes a build error instead of a blank screen. Architecture tests then keep those proofs from quietly disappearing. **Do this once per app**, then let [the CLI](/guide/routing/automation) write new routes for you. This guide assumes an app that consumes `@craft-ts/core`. ::: tip Prefer the guided version [Learn step 9](/learn/09-routing) walks through the same setup on a single route, with the reasoning attached. ::: ## Prerequisites Install the runtime package and the dev tooling in your app: ```bash npm install @craft-ts/core npm install -D @craft-ts/dev-tools ``` ## 1. Add a DI check to every routed component DI is checked next to the route it covers. Every routed component must pair its `RouteCheckedDI` check with `CanRun`; checks do not cross a `loadChildren` boundary. ```ts import { craftRoutes, type CanRun, type RouteCheckedDI, } from '@craft-ts/core'; export const { appRoutes } = craftRoutes('app', [ /* routes */ ]); type _CheckAppDI = RouteCheckedDI< { deps: Record; provided: Record; publicProperties: Record; }, never, CraftRouter, 'app route' >; type _CanRunApp = CanRun<_CheckAppDI>; ``` `RouteCheckedDI` compares: * the dependencies declared by the routed component * the providers available from the app, parent mount, route and component If a route depends on a service that is not provided, or if a routed component expects an input that the route does not supply, `_CanRunApp` turns that mismatch into a TypeScript error in the routes file. Typical errors look like: * `The Counter service is not provided in path: "some-path"` * `Input "userId" is not provided in path: "some-path"` ## 2. Define routes with `craftRoute` and collect them with `craftRoutes` Do not export a plain untyped routes array directly. Define each typed route with `craftRoute(...)`, collect them with `craftRoutes(...)`, and declare `componentDeps` on each route component. ::: warning Breaking rename The former `route(...)` helper has been renamed to `craftRoute(...)`. There is no compatibility alias: update both the import and every call site. ::: ```ts import { craftRoute, craftRoutes } from '@craft-ts/core'; export const { appRoutes } = craftRoutes('app', [ craftRoute('', { loadComponent: ({ withRetry }) => withRetry(import('./test')), componentDeps: {} as import('./test').GenDeps_TestComponent, }), ]); ``` The important part is: ```ts componentDeps: {} as import('./test').GenDeps_TestComponent, ``` That line connects the component dependency metadata to its per-route DI check. ### Prefer the route CLI for day-to-day authoring The CLI is the primary writing façade while the generated result remains ordinary editable TypeScript: ```bash npx craft route add npx craft route add /users/:userId --component src/app/users/user-detail.ts#UserDetailComponent npx craft route add /users/:userId --create-component users/user-detail ``` By default it detects the project and `craftRoutes` collections, creates one lazy routes file per feature, adds `componentDeps`, `withRetry`, `.withParent`, the parent mount assertion and the same-file DI check, then runs ESLint and TypeScript diagnostics. Use `--dry-run` to inspect the plan, `--yes` for non-interactive scripts and `--json` for machine-readable output. Static redirects stay in the selected collection: ```bash npx craft route add /old-users --redirect-to /users --parent src/app/app.routes.ts#appRoutes ``` Existing flat groups can be split explicitly: ```bash npx craft route split \ --parent src/app/app.routes.ts#appRoutes \ --prefix users \ --target src/app/users/users.routes.ts ``` The split command only moves statically analyzable routes. It reports local declarations or dynamic paths without mutating files, so business logic is never guessed. Then wire the crafted routes into your application config: ```ts import { craftAppConfig, provideCraftRouter } from '@craft-ts/core'; import { appRoutes } from './app.routes'; export const appConfig = craftAppConfig({ providers: [provideCraftRouter(appRoutes.toRoutes())], }); ``` Notes: * `appRoutes.toRoutes()` gives the router the real runtime routes. * Route params are bound to component inputs by name; there is nothing to opt into. * `provideCraftRouter(...)` also takes the craft loading features (`withErrorComponent`, `withRouteLoadError`, `withTransitionTimings`, …) in the same call, e.g. `provideCraftRouter(appRoutes.toRoutes(), withErrorComponent({ component: MyGlobalErrorScreen }))`. * Render `CraftRouterOutlet()` from `@craft-ts/component` inside your Craft component tree: the URL commits immediately and the outlet drives the pending UI and centralised exception handling. (The features also work standalone via `provideCraftLoading(...)`.) `withRouteLoadError(...)` must stay in `provideCraftRouter(...)` because it also registers an a navigation error handler and an internal recovery route. See [Non-blocking navigation & pending UI](/guide/routing/pending-ui) and [Route Load Errors](/guide/routing/route-load-errors). * For lazy routes, `loadChildren` should return the named route tree exported by the child collection, for example `childRoutes.childRoutes`. ### When a routes file gets big `RouteCheckedDI` checks one component at a time, so its cost does not grow with the number of sibling routes. Split routes with `loadChildren` when code splitting or ownership boundaries make that useful — each child still needs its own per-route checks. See **[Scaling routes](/guide/routing/scaling)**. ## 3. Generate dependency metadata Add a script in your app: ```json { "scripts": { "craft:brand": "craft-brand --root src" } } ``` Then run: ```bash npm run craft:brand ``` This is the step that creates the initial `GenDeps_*` aliases in your component files, for example: ```ts export type GenDeps_TestComponent = GetDeps<{ deps: { TaskList: GetServiceDependencies; }; provided: {}; publicProperties: GetPublicComponentProperties; }>; ``` Adjust `--root` to your real source root: * `src` for an application * `projects/my-app/src` for a workspace app * `libs/my-feature/src` for a library If you use a project-level `craft-brand.config.ts`, you can extend the script: ```json { "scripts": { "craft:brand": "craft-brand --root src --config ./craft-brand.config.ts" } } ``` ## 4. Install the ESLint rules Several checks in this guide rely on code a rule generates or keeps in sync — `GenDeps_*` aliases, the same-file DI proof, the exhaustiveness assert. Others enforce the architecture itself. Installing the plugin and the rule list is its own page: **[ESLint rules](/guide/routing/eslint-rules)**. ## 5. When a component changes, regenerate `GenDeps` with the Quick Fix After changing a component's DI-related shape, refresh its generated alias. Typical triggers: * adding or removing `inject(...)` * changing constructor injection * changing component `imports` * changing `providers` * changing `viewProviders` Recommended workflow: * first generation or bulk refactor: `npm run craft:brand` * one file without `GenDeps_*`: run the dependency generator for the relevant source root * one file with `GenDeps_*`: run `eslint --fix` for the file * CLI alternative for one file: `eslint --fix src/app/feature/my-component.ts` Important limits: * the Quick Fix only handles the current file * if you rename the component class, rerun the generator so the `GenDeps_*` alias name stays aligned :::warning An Eslint error does not trigger a compilation error, so make sure to run the Quick Fix or `eslint --fix` after changing a component's DI shape. Otherwise, `main.ts` will not see the updated `GenDeps_*` and may miss real DI errors. ::: ## 6. Make the DI contract enforceable The proofs in this guide are unused type aliases unless they stay in the file: comment out a `CanRun` and the project still compiles. That is the one fragile step in an otherwise compile-time guarantee. Architecture tests close it. `assertRouteDiProofs` walks the static graph and fails unless every routed component — including lazy `loadChildren` collections — every pending or error screen, and every `craftAppConfig` error surface is hooked to an armed mapper. TypeScript still judges whether a dependency is provided; the architecture suite judges whether that judgement was invoked. Copy the demo layout (`apps/demo/architecture/`) and add: ```typescript it('requires a DI proof on every routed component and app-config error screen', () => { assertRouteDiProofs(graph.graph); }); ``` Full setup — analysis tsconfig, catalog, Nx target — is on [Architecture rules](/guide/testing/architecture). ## See Also * [CLI automation](/guide/routing/automation) — let the CLI write routes for you * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the proofs armed * [Route guards](/guide/routing/guards) — the next thing you'll add * [Scaling routes](/guide/routing/scaling) — when one routes file gets too big --- --- url: https://craft-ts.github.io/craft/guide/routing/automation.md --- # CLI automation Writing a typed route by hand means four pieces that must agree: the route, its `componentDeps`, the `withRetry` wrapper and the DI check. The CLI writes all four, and the output stays ordinary editable TypeScript. **Use it for** day-to-day route authoring and for migrating an existing app. **Then edit the result** — nothing here is generated code you must not touch. `@craft-ts/dev-tools` provides codemods to migrate an existing application to Craft primitives, services, type-safe routes, and selectorless Craft Components. ## Install the migration tool ```shell npm install @craft-ts/core npm install --save-dev @craft-ts/dev-tools@beta ``` The migration binaries are available starting with `0.5.1-beta.0` and are currently published on the `beta` tag. The `latest` version and older beta versions do not include `craft-migrate`. If the package was installed before that release, update it and verify the resolved version: ```shell npm install --save-dev @craft-ts/dev-tools@beta npm ls @craft-ts/dev-tools ``` Commit or stash the current application changes before running a migration in write mode. ## Run the complete migration Preview all migrations first: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --dry-run ``` Then apply them: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --write ``` `craft-migrate` runs the migrations in the required order: 1. `craft-migrate-primitives` 2. `craft-migrate-services` 3. `craft-migrate-routes` 4. `craft-migrate-components` 5. `craft-migrate-architecture` The `--write` command also runs ESLint fixes on the touched files. Use `--no-eslint` only when your project runs this step separately. ## Run a targeted migration Use an individual codemod when the earlier stages have already been migrated: ```shell npx craft-migrate-routes \ --project tsconfig.app.json \ --root src \ --dry-run npx craft-migrate-routes \ --project tsconfig.app.json \ --root src \ --write npx craft-migrate-components \ --project tsconfig.app.json \ --root src \ --write npx craft-migrate-architecture \ --project tsconfig.app.json \ --root src \ --write ``` The route migration converts supported route collections to `craftRoutes(...)`, adds type-safe route metadata, and reports transformations that require a manual decision. For a nested route collection, provide its mount context when it cannot be inferred safely: ```shell npx craft-migrate-routes src/app/admin/admin.routes.ts \ --project tsconfig.app.json \ --parent-mount admin \ --parent-names CurrentUser,Permissions \ --write ``` ## Review diagnostics Write the complete report to a JSON file: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --dry-run \ --json migration-report.json ``` Resolve every manual diagnostic before considering the migration complete. In particular, verify generated `componentDeps`, inherited route providers, lazy child collections, and the file-level DI checks. ## Add a CI check After applying and reviewing the migration, prevent supported legacy patterns and unresolved manual diagnostics from returning: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --check \ --fail-on-manual ``` Finish with the application's normal lint, type-check, test, and build commands. See the [complete migration guide](/resources/migration) for the post-codemod checklist. ## Make the DI contract enforceable The CLI writes the route, `componentDeps`, `withRetry` and the DI proof. Those proofs are unused type aliases: omit one and the project still compiles. That is the one fragile step in an otherwise compile-time guarantee. [Architecture tests](/guide/testing/architecture#assertroutediproofs) close it. `assertRouteDiProofs` walks the static graph and fails unless every routed component — including lazy `loadChildren` collections — every pending or error screen, and every `craftAppConfig` error surface is hooked to an armed mapper. TypeScript still judges whether a dependency is provided; the architecture suite judges whether that judgement was invoked. Add `assertRouteDiProofs` to the app's architecture suite and run it in CI. That is the application-facing check for the routing contract. ## Compiler fixture suite (optional) `craft route verify` is a separate, heavier check: it type-checks the project, then writes temporary valid and invalid fixtures covering route DI, `toProvide` providers, lazy child checks, route params and inputs, Craft templates, templates, pending/error components, lazy loading, guard/resolve/component exceptions, local recovery and exhaustive handlers. Invalid fixtures are expected to fail, and their diagnostics are matched with the expected `path`, `pending component` or `exception component` context. Use it when you need to regression-test the type machinery itself — not as the app's proof that *your* routes still carry `CanRun`. Architecture tests cover that. ```json { "scripts": { "craft:verify-routes": "craft route verify --project tsconfig.app.json" } } ``` ```shell npm run craft:verify-routes ``` Fixtures are removed in a `finally` block. Use `--json` for a machine-readable report, `--root` when the application source root is not detected automatically, and `--keep-fixtures` only while diagnosing a failed verification. `--project` and `--tsconfig` are aliases for selecting the app tsconfig. This validates compile-time and ESLint bookkeeping guarantees. Runtime chunk-loading scenarios remain covered by the browser tests. ## See Also * [Routing setup](/guide/routing/setup) — what the CLI generates for you * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` is the app-facing routing check * [Scaling routes](/guide/routing/scaling) — `craft route split` --- --- url: https://craft-ts.github.io/craft/guide/routing/eslint-rules.md --- # ESLint rules The rule set is not decoration: several checks in this documentation only work because a rule generated or maintained the code they read. Others enforce the architecture — no hidden runtime dependencies or direct transport calls — and most of them **autofix**. **Install them once** when you set up routing and type-safe DI. **Then lean on the quick fixes** rather than writing the boilerplate by hand. ::: warning An ESLint error is not a compile error A missing autofix does not break the build. If you skip the quick fix after changing a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and can miss a real DI error. Run `eslint --fix` in CI. ::: The plugin is exposed from `@craft-ts/dev-tools/eslint-rules`. The recommended preset bans every TypeScript assertion in authored Craft code, including `as const`: ```ts import craftRules from '@craft-ts/dev-tools/eslint-rules'; export default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }]; ``` For a project using `@craft-ts/effect`, the published preset enables the Craft rules and the Effect adapter rule in one entry: ```ts import craftRules from '@craft-ts/dev-tools/eslint-rules'; export default [ { files: ['**/*.ts'], ...craftRules.configs.effect, }, ]; ``` Use `craftRules.configs.recommended` for projects that do not use Effect. Add it to your ESLint flat config: ```ts import craftRules from '@craft-ts/dev-tools/eslint-rules'; export default [ // keep your existing ESLint config entries { files: ['**/*.ts'], plugins: { 'craft-ts': craftRules, }, rules: { 'craft-ts/prefer-craft-template-blocks': 'error', 'craft-ts/no-render-writes': 'error', 'craft-ts/require-reactive-template-bindings': 'error', 'craft-ts/no-craft-use': 'error', 'craft-ts/no-craft-component-return-type': 'error', 'craft-ts/require-craft-component-for-exported-node-factory': 'error', 'craft-ts/no-raw-craft-router-url': 'error', 'craft-ts/no-type-assertions-in-template': 'error', 'craft-ts/no-explicit-craft-template-return-type': 'error', 'craft-ts/no-extracted-craft-component-parts': 'error', 'craft-ts/no-ephemeral-template-form-state': 'error', 'craft-ts/require-form-for-input-action': 'error', 'craft-ts/template-element-name-unique': 'error', 'craft-ts/no-craft-computed-side-effects': 'error', 'craft-ts/no-external-state-transition': 'error', 'craft-ts/require-craft-method-for-yieldable-callback': 'error', 'craft-ts/prefer-direct-yieldable-callback': 'error', 'craft-ts/prefer-deep-yieldable-for-item': 'warn', 'craft-ts/require-yieldable-reactive-read': 'error', 'craft-ts/require-yieldable-template-method': 'error', 'craft-ts/require-yieldable-insertion-write': 'error', 'craft-ts/no-craft-service-component-same-file': 'error', 'craft-ts/max-craft-declarations-per-file': 'error', 'craft-ts/max-craft-component-lines': 'warn', 'craft-ts/prefer-craft-http-transport': 'error', 'craft-ts/no-injection-token': 'error', 'craft-ts/require-primitive-derived-property': 'error', 'craft-ts/no-reused-primitive-method': 'error', 'craft-ts/no-async-await': 'error', 'craft-ts/no-throw': 'error', 'craft-ts/no-imperative-craft-resource-trigger': 'error', 'craft-ts/no-imperative-craft-method-actions': 'error', 'craft-ts/no-remote-work-in-craft-method': 'error', 'craft-ts/no-type-assertions-in-resource-loader': 'error', 'craft-ts/no-explicit-resource-loader-type': 'error', 'craft-ts/no-explicit-craft-insertion-type': 'error', 'craft-ts/no-craft-primitive-type-assertion': 'error', 'craft-ts/prefer-insert-deep-yieldable': 'error', 'craft-ts/no-imperative-template-action-chain': 'error', 'craft-ts/prefer-route-query-params-for-filter-state': 'warn', 'craft-ts/no-imperative-storage-in-craft-method': 'error', 'craft-ts/no-transition-actions': 'error', 'craft-ts/require-craft-resource-trigger-yield': 'error', 'craft-ts/require-assert-exhaustive-route-exceptions': 'error', 'craft-ts/require-craft-exception-handler': 'error', 'craft-ts/require-exception-component-di-check': 'error', 'craft-ts/require-pending-component-di-check': 'error', 'craft-ts/require-child-route-mount-check': 'error', 'craft-ts/require-lazy-load-with-retry': 'error', 'craft-ts/global-exception-registry-match': 'error', }, }, ]; ``` What each rule does: * `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(...)` * `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 * `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 * `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. * `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 * `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 * `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 Small node factories are valid when they stay private to the file: ```ts function filterButton(filter: TodoFilter, label: string) { return button('todoFilterButton', { type: 'button' }, label); } ``` Once the function is exported, use a Craft component so directives and composition can be applied at the module boundary: ```ts // ❌ craft-ts/require-craft-component-for-exported-node-factory export function filterButton(filter: TodoFilter, label: string) { return button('todoFilterButton', { type: 'button' }, label); } // ✅ export const FilterButton = craftComponent( 'FilterButton', {}, (filter: Input, label: Input) => ({ filter, label }), ({ label }) => button('todoFilterButton', { type: 'button' }, label), ); ``` The rule also follows named exports such as `export { filterButton }` and checks exported arrow functions. * `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 * `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: ```ts const pendingStatusMessage = (message: string) => p(message); // ❌ The annotation erases the concrete node/dependency information. pendingNode({ fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'), reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'), }); // ✅ The concrete `p(...)` node stays visible to Craft's inference. pendingNode({ fallback: () => pendingStatusMessage('Loading…'), reloading: () => pendingStatusMessage('Reloading…'), }); ``` The rule is autofixable with `eslint --fix`. Return annotations on DOM event and output callbacks remain allowed because those callbacks do not produce rendered children. * `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and template passed to `craftComponent(...)` to stay inline. Keeping both parts at the component boundary preserves contextual type inference and makes the component's behaviour readable in one place. The rule reports both extracted identifiers independently. Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's two halves behind names at the call site: ```ts // ❌ craft-ts/no-extracted-craft-component-parts const ReviewLogic = craftGen(function* () { return { review, decide }; }); const ReviewTemplate = craftTemplate(({ decide }) => div([button({ click: decide }, 'Review')]), ); export const ReviewApp = craftComponent( 'ReviewApp', {}, ReviewLogic, ReviewTemplate, ); ``` After — keep the logic and template callback in the component call: ```ts // ✅ export const ReviewApp = craftComponent( 'ReviewApp', {}, craftGen(function* () { return { review, decide }; }), ({ decide }) => div([button({ click: decide }, 'Review')]), ); ``` The rule only rejects identifiers in the logic and template argument positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)` expressions remain valid. A direct template callback is usually the simplest form because `craftComponent(...)` can contextually type it from the inline logic factory. * `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 * `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 * `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' }, ...)` * `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). * `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 * `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` * `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 * `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 * `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof * `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 * `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model * `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 * `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 * `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: ```ts // ❌ craft-ts/max-craft-component-lines // review-app.ts — 3894 lines: filtering, sorting, diff computation, // pagination, and the full markup tree all inlined in one logic factory // and one template. export const ReviewApp = craftComponent( 'ReviewApp', {}, (subjects: Input) => { const filtered = craftComputed(() => /* 80 lines of filtering */ []); const diff = craftComputed(() => /* 150 lines of diffing */ null); // …dozens more computeds and craftMethods… return { subjects, filtered, diff /* … */ }; }, ({ filtered, diff /* … */ }) => div( {}, /* a thousand-plus lines of markup for the filter bar, the diff viewport, the review card list, and the pagination controls */ ), ); // ✅ Business logic moves to a craftService; independent template // regions become their own craftComponent, each testable and readable // on its own. export const ReviewFilters = craftService( { name: 'ReviewFilters', scope: 'global' }, () => ({ filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [], }), ); export const SubjectDiffViewport = craftComponent( 'SubjectDiffViewport', {}, (subject: Input) => ({ subject }), ({ subject }) => div({} /* … */), ); export const ReviewApp = craftComponent( 'ReviewApp', {}, (subjects: Input) => { const filters = injectX(ReviewFilters); const filtered = craftComputed(() => filters.filter(subjects(), criteria()), ); return { filtered /* … */ }; }, ({ filtered }) => div( {}, forNode(filtered, (subject) => SubjectDiffViewport({ subject })), ), ); ``` Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is still too generous for your team. * `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract())` * `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient` * `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 * `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)` * `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 * `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 * `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 * `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 * `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 * `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). * `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. * `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. * `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. * `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. * `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. * `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 }` * `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 * `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 * `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 * `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$`. * `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). * `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. * `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. * `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls * `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)` * `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`) * `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 * `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 * `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`) * `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method * `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net * `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration * `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent` * `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent` * `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 * `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 * `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` * `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 * `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) * `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 * `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 * `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) * `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) * `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 * `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 * `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 * `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()` * `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 * `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 * `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact ## Promise and transport boundaries These rules protect the same boundary: asynchronous work must remain visible to the Craft resource that owns it. A native `Promise` may eventually resolve, but it does not describe which Craft dependencies were read, where suspension occurred, or which resource should be cancelled and receive the exception. ### Keep resource loaders generator-based ```ts // Incorrect: the native Promise hides the request from the Craft lifecycle. query('usersQuery', { loader: async () => (await fetch('/api/users')).json(), }); // Correct: the resource owns a tracked, yieldable request. query('usersQuery', { loader: function* () { return yield* CraftHttpClient.get(({ response }) => ({ url: '/api/users', success: response(), })); }, }); ``` `no-async-await` rejects `async`, `await`, and `for await...of` in Craft code. `require-generator-resource-loader` additionally checks that `query`, `mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft operations so every suspension stays tracked. The loader signature should also stay inferred: ```ts // Incorrect: these annotations can mask a mismatch in the resource contract. loader: function* ({ params }: { params: string }): Generator { return yield* client({ token: params }); } // Correct: infer params and the generator result from the resource and body. loader: function* ({ params }) { return yield* client({ token: params }); } ``` `no-explicit-resource-loader-type` reports only annotations on the loader signature. Type annotations for local variables and function contracts outside the loader remain allowed. ### Keep transport and types honest ```ts // Incorrect: direct fetch bypasses Craft response/error tracking. const result = await fetch('/api/users'); // Correct: use the Craft client in the owning resource loader. return ( yield * CraftHttpClient.get(({ response }) => ({ url: '/api/users', success: response(), })) ); ``` For a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type assertion to force `CraftHttpClient` to accept a `Blob`. An assertion only silences TypeScript — it does not change the runtime value or transport. That is why `prefer-craft-http-transport` and `no-type-assertions-in-resource-loader` report these patterns. ### Preserve primitive inference The insertion callback already receives a contextual type, and the primitive already knows the complete type of its generator. Do not repeat either type at the boundary: ```ts // ❌ craft-ts/no-explicit-craft-insertion-type insertQueryPipe( ({ resource }): SpaceQueryView => ({ items: craftComputed(() => resource.value()), }), ); // ❌ craft-ts/no-craft-primitive-type-assertion const generator = query('spaceItems', config) as unknown as Generator< unknown, SpaceQueryRef, unknown >; // ✅ const generator = query( 'spaceItems', config, insertQueryPipe(({ resource }) => ({ items: craftComputed(() => resource.value()), })), ); ``` The assertion is especially harmful around a composed insertion pipe: it replaces the type that carries the derived properties and their dependencies. ### Prefer primitive deep-yieldable insertions When a property is read from the result of a primitive, expose the deep view at the primitive boundary. This keeps the property reader connected to the primitive and avoids an extra adapter: ```ts // ❌ craft-ts/prefer-insert-deep-yieldable const spaceQuery = yield * spaceQueryGenerator; const deepItems = deepYieldable(spaceQuery.items); // ✅ add insertDeepYieldable() to the query call, then: const spaceQuery = yield * spaceQueryGenerator; const items = spaceQuery.items; ``` Expected failures should use `craftException(...)` so they remain typed and available through the resource's exception state. `no-throw` keeps technical throws limited to explicit adapter boundaries, where they can be translated into the Craft exception channel. ### Accessibility (`craft-ts/a11y`) Spread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as `error`. The rules walk **all** hyperscript in the file (`craftTemplate`, extracted factories, `h('tag')`), not only `craftComponent` argument 3. * `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists * `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}"` * `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href` * `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content` * `no-noninteractive-element-interactions`, `no-positive-tabindex` * `valid-aria`, `role-has-required-aria`, `target-blank-noopener` * `prefer-relative-heading`, `require-route-heading-outline`, `require-outlet-heading-section`, `no-heading-level-skip` * `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`) — superseded by the `craft.base` layer of [`@craft-ts/style`](/guide/style/foundation), which lays both once for the document; they are no longer in `recommended` See [Accessibility](/guide/components/accessibility). The 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. The template and reactivity rules are intentionally diagnostic-only: replacing a resource or subscription can change lifecycle and error semantics, so the rule points at the Craft primitive without applying a potentially unsafe rewrite. ### Why templates use blocks Craft template blocks preserve the branch structure in the type-level render contract. A ternary or `condition && node` produces only a computed value, so the type checker cannot assert which branch renders which content. Keep derived values and business decisions in the component's state/query layer, then make the template express visibility explicitly: ```ts ifNode( isReady, () => p('Ready'), () => p('Loading…'), ); matchNode.exhaustive(query.exceptions, '_tag', { NOT_FOUND: () => p('Not found'), FORBIDDEN: () => p('Forbidden'), }); ``` This rule is for Craft's TypeScript templates. It does not rewrite external template languages. The same restriction applies to boolean expressions. A negation is still application logic, even when it is used only for a DOM property: ```ts // Incorrect: the template derives the disabled state. button( { disabled: function* () { return !(yield* machine.canGoBack()); }, }, 'Back', ); // Correct: derive it in the logic factory and bind the result. const backDisabled = craftComputed('backDisabled', function* () { return !(yield* history.canGoBack()); }); return { backDisabled }; ``` Keep the template to layout and binding. Move labels, formatted values, validation state, and other decisions into `state()` or `craftComputed()`. ### Derived values belong to their primitive When a computed reads only one local primitive, declare it in that primitive's insertion. This keeps the dependency visible and lets pending/exception boundaries name the actual source: ```ts const users = yield * query('users', config, ({ resource }) => ({ total: craftComputed('total', function* () { return (yield* settled(resource)).length; }), })); ``` Do not create `craftComputed('total', ...)` beside the query when the computation depends only on `users`. ### Keep casts and synchronous reads out of templates Craft templates reject both `as ...` / angle-bracket assertions and `craftUse(...)`. Fix the type or perform the synchronous-to-reactive conversion in the component logic, then expose a typed reader or generator to the template: ```ts const typedStep = machine.stepState as unknown as () => { step: Step }; return { typedStep }; // Template: no cast and no craftUse. matchNode.exhaustive(typedStep, 'step', steps); ``` `no-craft-use` applies to Craft TypeScript files, not only the fourth `craftComponent(...)` argument. A synchronous integration boundary may opt out locally when its external API cannot consume a generator, but application state and templates should use `yield*`. ### Form and accessibility diagnostics The accessibility preset also checks the static structure of hyperscript: * give every `label` an `htmlFor` matching the control `id`, or wrap the control; * give named controls and helpers a unique string local name; * use `button` or `a` for interactions instead of adding `click` to a `div`; * add a `prefers-reduced-motion` branch whenever component CSS defines an animation or transition. These checks run on Craft TypeScript templates and extracted helper factories, so moving markup into a local function does not bypass them. ### Reactive values belong in binding callbacks `require-reactive-template-bindings` uses TypeScript type information to find reactive reads. Reading a signal while constructing a VNode would make it a dependency of the structural component render, so the rule rejects this form: ```ts // Incorrect: count is read by the component template. p(`Count: ${count()}`); button({ disabled: isDisabled() }, 'Save'); div({ class: { active: isActive() } }); ``` Keep each read inside the callback owned by its DOM binding. Pass a yieldable reader, or use a generator when the binding must format: ```ts p(count); p(function* () { return `Count: ${yield* count()}`; }); button({ disabled: isDisabled }, 'Save'); div({ class: isActiveClass }); ``` Literal and otherwise static values are still allowed, as are reads performed from DOM events and `onXxx` output callbacks. Because the rule is type-aware, the ESLint parser must use `projectService: true` or a TypeScript `project`. ### Pass simple yieldable callbacks directly `prefer-direct-yieldable-callback` removes a generator wrapper when the template only delegates one zero-argument callback. It handles both a value binding and a generator method: ```ts // Before: redundant wrappers around the callbacks. button( { *click() { yield* press(); }, }, function* () { return yield* label(); }, ); // After `eslint --fix`. button({ click: press }, label); ``` Member callbacks are supported as well when the access is static and has no arguments: ```ts // Before. span(function* () { return yield* counter.increment(); }); // After. span(counter.increment); ``` The rule leaves callbacks with parameters, extra statements, or additional computation unchanged. In those cases the generator contains behavior that cannot be represented by passing the callback reference alone. ### Prefer deep-yieldable `forNode` items `prefer-deep-yieldable-for-item` detects when a component reads several properties from the same `forNode` item through repeated `yield* item()` calls. Keep the original collection available, and expose a named deep-yieldable view for the component: ```ts import { insertDeepYieldable, state } from '@craft-ts/core'; // Before: every property read yields the whole item again. forNode(catalog.products, { track: (product) => product.id }, (product) => article([ span(function* () { return (yield* product()).category; }), span(function* () { return (yield* product()).name; }), ]), ); // After: the named view keeps each property read lazy and reactive. const catalog = yield * state('catalog', { products }, insertDeepYieldable('products')); forNode( catalog.deepYieldableProducts, { track: (product) => product.id }, (product) => article([span(product.category), span(product.name)]), ); ``` The rule is diagnostic-only because choosing the insertion belongs to the primitive that owns the collection. `insertDeepYieldable('products')` leaves `catalog.products` unchanged and adds `catalog.deepYieldableProducts`. ### Yield insertion writes from generator methods `require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and `update(...)` calls to be delegated with `yield*` when they are used inside a generator method: ```ts nextPage: function* () { const current = yield* state(); return yield* patch({ page: current.page + 1 }); }, ``` Insertion callbacks that are not generators may return a write directly; the insertion wrapper consumes that result for them. ## What generates what Three rules do more than complain — they write code you would otherwise maintain by hand: | Rule | Generates | | -------------------------------------------- | ------------------------------------------------------------- | | `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert | | `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import | | `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports | | `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references | ## Adopting them progressively On an existing codebase, enable them in waves rather than all at once: 1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed. 2. **The architecture rules last** — `prefer-craft-service`, `no-craft-service-component-same-file`, `prefer-craft-http-client`, `require-yieldable-reactive-read`, `require-yieldable-template-method`, `require-yieldable-insertion-write`. These ask for real refactors. The design system is the only way to style a component. The style rules — `no-raw-class`, `no-inline-style`, `no-component-css`, `no-raw-css-value`, `no-free-has`, `style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`, in **every** file: none of them waits for a file to import `@craft-ts/style`. To migrate a project in steps, turn the three binding rules off in **its own** ESLint config, with a `TODO` comment the migration removes, and keep the rules that read the legacy component CSS on in the meantime: ```js { // TODO: remove once this project is migrated to @craft-ts/style. files: ['**/src/**/*.ts'], rules: { ...craftRules.configs.legacyComponentCss.rules, 'craft-ts/no-raw-class': 'off', 'craft-ts/no-inline-style': 'off', 'craft-ts/no-component-css': 'off', }, }, ``` `legacyComponentCss` groups the rules that read a component's CSS text — `craft-css-vars-contract`, `craft-styles-scope-safe`, `craft-css-var-naming`, `craft-css-token-registry`, `no-hardcoded-design-values`, `no-important-in-component-styles`, `require-focus-visible`, `require-reduced-motion`. They left `recommended` because `no-component-css` leaves them nothing to read. A genuine bypass — a third-party widget that ships its own CSS, HTML rendered from markdown — stays possible, one line at a time, with a reason: `// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet`. The two migration rules also expose a VS Code quick fix that inserts a temporary local disable comment with the intended migration note, so you can unblock a file before doing the full refactor. ## See Also * [Routing setup](/guide/routing/setup) — where these rules are installed * [CLI automation](/guide/routing/automation) — the codemods they complement * [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see * [Activating the style system](/guide/style/setup) — what the four style rules are guarding --- --- url: https://craft-ts.github.io/craft/guide/routing/route-providers.md --- # Route providers A route can provide services built from **its own URL** — the `:userId` in the path, its `data`, its query params, the value its guard resolved — with full type-safe dependency tracking. **Use it when** a subtree's services depend on which route rendered them: a "current project" service, a tenant-scoped API client. **Not when** the dependency is global — provide it at the app level instead. Build route-level providers from a route's **own auto-provisioned tokens** — path params, `data`, `queryParams`, and `canActivate` guarded data — with full, type-safe dependency tracking. ## The problem A `craftRoutes` route auto-provisions route-scoped services. For a route `query/:userId` in the `demo` collection, `craftRoutes` generates helpers such as `DemoUserIdParams` and the yieldable `DemoQueryUserIdGuardedData`. The params helper is useful **inside a component** and is consumed with `yield*`, exactly like a Craft service. Guarded data is consumed from a generator with `yield* DemoQueryUserIdGuardedData()`. Route `data` is intentionally not exported as a collection-level `inject…Data` helper; inside `withProviders`, consume it through the local `Data` generator. This also lets you take the value resolved by `canActivate` and feed it into a provider that the routed component injects. ## The solution: `craftRoute(...).withProviders(...)` `craftRoute(path, definition)` authors a single route and returns a builder with a `.withProviders(...)` method. The callback receives **route-scoped service generators**, one per auto-provisioned token that exists on the route, and returns a normal providers array. ```ts import { abstract, craftRoutes, craftService, query, craftRoute, } from '@craft-ts/core'; type User = { name: string }; // 1. An abstract contract — implemented per route. const { UserRequirement, provideUser } = craftService( { name: 'User', scope: 'abstract' }, abstract(), ); // 2. A guard that resolves the user. const { Auth } = craftService({ name: 'Auth', providedIn: 'global' }, function* () { const auth = yield* query('auth', { params: () => true, loader: async () => ({}) as User, }); return auth; }); export const { demoRoutes } = craftRoutes('demo', [ craftRoute('query/:userId', { componentDeps: {} as import('./query').GenDeps_GlobalQuery, loadComponent: ({ withRetry }) => withRetry(import('./query')), canActivate: function* () { const user = yield* Auth(); const userValue = user.value(); if (!userValue) { return false; } return safeUser; // becomes the route's guarded data }, }).withProviders(({ GuardedData }) => [ provideUser(function* () { const guarded = yield* GuardedData(); // Signal return guarded(); }), ]), ]); ``` The routed component can now yield `User()` from its Craft component factory and receive the value that the guard resolved — without ever touching the fully-qualified route helper. ## The helpers object The `.withProviders(...)` callback receives an object with **route-local short names** for every auto-provisioned token present on the route: | Helper | Present when… | Yields | | --------------- | --------------------------- | -------------------------------------- | | `GuardedData` | the route has `canActivate` | `Signal` | | `Params` | per path param | `Signal` (e.g. `UserIdParams`) | | `QueryParams` | the route has `queryParams` | the query-params state | | `Data` | the route has `data` | `Signal` | Names are **scoped to the single route**, so the collection prefix and route path are dropped: `GuardedData`, not `DemoQueryUserIdGuardedData`. The path-param name is kept to keep multiple params distinct (`UserIdParams`, `TeamIdParams`, …). Each helper is a generator you consume with `yield*`, exactly like a service's `X()`: ```ts .withProviders(({ UserIdParams, QueryParams }) => [ provideSomething(function* () { const userId = yield* UserIdParams(); // Signal const qp = yield* QueryParams(); // query-params state return { userId, qp }; }), ]) ``` At collection level, a path parameter uses the same service-shaped name. For example, a `craftRoutes('demo', [{ path: 'users/:userId', ... }])` collection exposes `DemoUserIdParams`: ```ts import { DemoUserIdParams } from './demo.routes'; const userId = yield* DemoUserIdParams(); // Signal ``` Path parameters are exposed only through the service-shaped `DemoUserIdParams()` helper, so URL parameters participate in Craft's normal yieldable DI graph. ## Pairing with an abstract service `craftRoute(...).withProviders(...)` shines with `scope: 'abstract'` services. The abstract service declares a contract; each route provides a concrete implementation derived from that route's data. Abstract services now expose a `provideX(factory)` helper that takes a **generator factory**, tracks everything it yields, and binds the result to the requirement token. See [craftService → Abstract Providers](/guide/app/craft-service#abstract-providers). ```ts const { User, provideUser } = craftService( { name: 'User', scope: 'abstract' }, abstract(), ); // In a route: .withProviders(({ GuardedData }) => [ provideUser(function* () { return (yield* GuardedData())(); }), ]) // In the routed component factory: const user = yield* User(); // User ``` ## Dependency tracking & route DI Everything yielded inside a `withProviders` factory is tracked at the type level and folded into the route's dependency graph used by [`RouteCheckedDI`](/guide/routing/setup): * The route's **auto-provisioned** tokens (guarded data, params, query params, data) are recognized as provided by the route itself — yielding them is always valid. * Any **other** service yielded inside the factory that is not provided by the route or the app surfaces as a missing-provider error, e.g.: ``` The SomeService service is not provided in path: "query/:userId" ``` * The provider's own name (`User` above) is registered as **self-provided**, so a component on that route can depend on it without a separate provider declaration. This means the pattern is safe by construction: you cannot wire a route provider against data the route does not actually expose. ## Plain providers still work `.withProviders(...)` is additive. A route can still declare a plain `providers` array, and both are merged (auto-provisioned services first, then `providers`, then the `withProviders` factory output): ```ts craftRoute('admin', { componentDeps: {} as import('./admin').GenDeps_Admin, loadComponent: ({ withRetry }) => withRetry(import('./admin')), providers: [SomeCraftProvider], // plain array, untyped helpers }).withProviders(({ Data }) => [ /* factory-built providers with tracking */ ]); ``` Under the hood the builder stores the factory on a dedicated `providersFn` field, kept separate from the route's `providers` array. ## See Also * [Setup](/guide/routing/setup) — per-route DI checks * [craftService](/guide/app/craft-service) — `abstract` scope, `provideX`, requirements --- --- url: https://craft-ts.github.io/craft/guide/routing/guards.md --- # Route guards A guard is a bare `function*` on `canActivate` / `canMatch`. It yields what it needs, and returns either a value or a `craftException` describing why the route must not render. **Use one when** access to a route depends on state: authentication, a role, a feature flag, an onboarding step. **Not when** the answer is a redirect with no condition — that is a static route. Guards here are **reusable and parameterised**, they **compose** inside a single `canActivate` / `canMatch`, and their failure cases are resolved **exhaustively** — an unhandled case is a **type error**. > **A guard is just a generator function.** `canActivate` / `canMatch` take a bare > `function* () { … }` directly — there is no `craftCanActivate` / `craftCanMatch` wrapper and no > inline `resolvers` argument. Every reachable `craftException` is resolved by a single, exhaustive > **[`handleExceptions`](/guide/concepts/exceptions)** map on the route, applied **after the URL commits** > by the non-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui). ## The problem A `craftRoutes` `canActivate` accepts a single function (or generator function). To apply several authorization rules — role, account state, feature flag… — you have to inline everything into one generator and hand-roll each rejection by returning a `createUrlTree(...)`: ```ts canActivate: function* () { const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user })); if (!user()) { return createUrlTree(['/auth/login']); // not authenticated } if (user()!.role !== 'admin') { return createUrlTree(['/unauthorized']); // wrong role } const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria })); if (pizzeria()) { return createUrlTree(['/dashboard']); // already onboarded } return true; } ``` The rules are not reusable, the redirect logic is tangled with the checks, and nothing forces you to handle every rejection — forget a branch and it silently falls through. ## The solution: `craftGen` + a composing generator guard Split the two concerns: * **`craftGen`** authors a reusable, parameterised guard. It either returns a success value or a typed [`craftException`](#exceptions). * The route's **`canActivate` generator** composes guards with `yield*`; the route's exhaustive [`handleExceptions`](/guide/concepts/exceptions) map must cover **exactly** the reachable exception codes. For a focused overview of `craftGen` itself and why it is useful, see [`craftGen`](/guide/concepts/generators). ```ts import { craftException, craftGen, craftResolve, CraftHttpClient, query, craftRoute, craftUntilSettled, } from '@craft-ts/core'; // Reusable guards — each returns a success value | craftException(...) const roleGuard = craftGen( (...roles: Role[]) => function* () { const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user, })); if (!user()) return craftException({ _tag: 'NOT_AUTHENTICATED' }); return roles.includes(user()!.role) ? true : craftException({ _tag: 'FORBIDDEN_ROLE' }); }, ); const noPizzeriaGuard = craftGen( () => function* () { const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria, })); return pizzeria() ? craftException({ _tag: 'HAS_PIZZERIA' }) : true; }, ); const { pizzeriaDraftQuery } = query('pizzeriaDraftQuery', { params: () => true, loader: function* () { return yield* CraftHttpClient.get(({ response }) => ({ url: '/api/pizzerias/draft', success: response(), exceptions: [ function* ({ status }) { if (!(yield* status(404))) return; return craftException({ _tag: 'PIZZERIA_DRAFT_UNAVAILABLE' }); }, ], })); }, }); craftRoute( 'new', { title: 'Create Pizzeria', canActivate: function* () { yield* roleGuard(ROLES.PIZZERIA_ADMIN); // short-circuits on exception yield* noPizzeriaGuard(); return true; }, resolve: craftResolve(function* () { return yield* craftUntilSettled(pizzeriaDraftQuery); }), loadComponent: ({ withRetry }) => withRetry( import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page'), ).then((m) => m.AdminPizzeriaFormPage), componentDeps: {} as import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page').GenDeps_AdminPizzeriaFormPage, }, { // Resolved centrally — exhaustive over canActivate ∪ canMatch ∪ resolve. NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'auth/login' }); }), FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'unauthorized' }); }), HAS_PIZZERIA: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'pizzerias/admin' }); }), PIZZERIA_DRAFT_UNAVAILABLE: craftExceptionHandler(function* ({ globalError, }) { return globalError(); }), HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }), }, ); // After the collection is defined, assert every route handles exactly its codes: // assertExhaustiveRouteExceptions(adminRoutes); ``` ## Reactive guards While a route is active, its `canActivate` invariant stays **under observation** (live guards, on by default). If a signal the guard reads changes — e.g. the user logs out and `Auth` becomes `null` — the guard re-evaluates synchronously and applies [`handleExceptions`](/guide/concepts/exceptions) with `phase: 'active'`, so the target is never left rendered in an incoherent state. The reactive phase never re-runs `resolve` (no new pending). Opt out per route with `reactiveGuards: false`. ## How composition works `craftGen(factory)` returns a factory you invoke and delegate to with `yield*`: * The guard's **dependency yields** (`CraftAuth`, `CraftRouter`, …) flow up to the route exactly as in a plain generator guard, so [route DI tracking](/guide/routing/setup) still sees them. * As soon as a composed guard produces a `craftException`, the enclosing generator **short-circuits**: `yield* roleGuard(...)` interrupts the whole `function*`, and the exception is propagated to the route's guard boundary — no `if`/`return` plumbing in the composing guard. * The set of exceptions each guard can produce is tracked **at the type level**, so the route's `handleExceptions` map knows precisely which codes it must handle. Order matters: guards run top-to-bottom and the first exception wins (fail-fast). ## The handler context Each route exception handler receives the typed exception and payload, the navigation phase, the typed `Router` helpers, and the five outcome constructors. See [Centralised Exception Handling](/guide/concepts/exceptions#handler-context) for the exhaustive list and examples. Use `redirectTo(...)` for typed internal routes: ```ts { RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) { return yield* redirectTo({ to: 'cooldown', queryParams: { retryAfter: String(payload.retryAfter) }, }); }), } ``` A handler returns a `CraftExceptionOutcome` via `redirectTo`, `redirectUrl`, `renderComponent`, `globalError`, `stay`, or `noop`. The `payload` is taken from `craftException({ _tag }, payload)`'s second argument and typed per code. ## Handlers can yield services A handler may be a **generator** that `yield*`s craft services before building the redirect — for example to read the login URL from a config service. Those yields are tracked exactly like the guards' own dependencies, so a service used only at redirect-time still flows into the route's [route DI](/guide/routing/setup) (yield an unprovided service and it surfaces as a missing-provider error on the route): ```ts craftRoute( 'admin', { canActivate: function* () { yield* roleGuard(ROLES.ADMIN); return true; }, }, { // Generator handler — `RedirectConfig` becomes a tracked route dependency. FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) { const { unauthorizedUrl } = yield* RedirectConfig(); return redirectUrl(unauthorizedUrl); }), }, ); ``` Every handler uses the generator wrapper, including handlers that do not yield a service. ## Exhaustiveness The handler map is typed over the reachable codes, so **every** reachable code must be handled — a missing one is a type error: ```ts craftRoute( 'admin', { canActivate: guard }, { FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'unauthorized' }); }), // Type error: Property 'HAS_PIZZERIA' is missing. }, ); ``` Add a guard that can raise a new code, and every route using it stops compiling until its handler is added. A typo'd code is caught the same way because the correctly-spelled key is then missing. ## Guarded data still flows through A `canActivate` guard's **success value** (anything other than `true`/`UrlTree`/…) becomes the route's [guarded data](/guide/routing/route-providers) — `craftException` returns are never treated as data: ```ts const authGuard = craftGen( () => function* () { const user = yield* Auth(); const userValue = user.value(); return userValue ? userValue : craftException({ _tag: 'NOT_AUTHENTICATED' }); }, ); craftRoute( 'query/:userId', { componentDeps: {} as import('./query').GenDeps_GlobalQuery, loadComponent: ({ withRetry }) => withRetry(import('./query')), canActivate: function* () { return yield* authGuard(); // success value = the user }, }, { NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectUrl }) { return redirectUrl('/login-form'); }), }, ).withProviders(({ GuardedData }) => [ provideUser(function* () { return (yield* GuardedData())(); // Signal → User }), ]); ``` ## `canMatch` `canMatch` is the sibling of `canActivate` — same composition and exhaustive resolution through `handleExceptions`. Unlike `canActivate`, a `canMatch` guard produces no guarded data. ```ts const featureFlagGuard = craftGen( (flag: string) => function* () { const { flags } = yield* CraftConfig(); return flags[flag] ? true : craftException({ _tag: 'FLAG_DISABLED' }); }, ); craftRoute( 'beta', { componentDeps: {} as import('./beta').GenDeps_Beta, loadComponent: ({ withRetry }) => withRetry(import('./beta')), canMatch: function* () { yield* featureFlagGuard('beta'); return true; }, }, { FLAG_DISABLED: craftExceptionHandler(function* ({ redirectUrl }) { return redirectUrl('/home'); }), }, ); ``` ## Async guards {#async-guards} The guards above are **synchronous** — every `craftGen` resolves in one pass. To decide based on data that has to be *fetched first*, suspend the composing guard with `craftUntilSettled` (or `craftUntilDefined`). The guard stays a normal generator: `yield* a(); const x = yield* craftUntilSettled(...); yield* b()` composes across the await, and the awaited operation's `craftException`s flow into the same exhaustive `handleExceptions` map — the compiler still forces you to handle every reachable code. ### `craftUntilSettled` — await a resource or an HTTP call `craftUntilSettled` takes either a craft **resource** (`query` / `mutation` / `asyncProcess`) or a `CraftHttpClient.*` **call** and suspends until it settles, then returns its success value. ```ts craftRoute( 'users/:userId', { componentDeps: {} as import('./user').GenDeps_User, loadComponent: ({ withRetry }) => withRetry(import('./user')), canActivate: function* (route) { const userId = route.params['userId']; // (a) Await an HTTP call directly — no named resource needed. Its declared // `exceptions` flow into the route's handleExceptions below. const user = yield* craftUntilSettled( CraftHttpClient.get(({ response }) => ({ url: `/api/users/${userId}`, success: response(), exceptions: [ function* ({ status, code }) { if (!(yield* status(400))) return; if (!(yield* code('PASSWORD_REQUIRED'))) return; return craftException({ _tag: 'PASSWORD_REQUIRED', scope: 'UsersFeature', }); }, ], })), ); return user.active ? true : craftException({ _tag: 'INACTIVE_USER' }); }, }, { // Both the guard's own exception AND the HTTP call's exception are required. INACTIVE_USER: craftExceptionHandler(function* ({ redirectUrl }) { return redirectUrl('/inactive'); }), PASSWORD_REQUIRED: craftExceptionHandler(function* ({ redirectUrl }) { return redirectUrl('/password'); }), }, ); ``` The **resource** form is identical — pass the ref (an inline `query(name, ...)` works, though it is reactive; prefer the HTTP form for one-shots): ```ts const user = yield * craftUntilSettled( query('user', { params: () => userId, loader: ({ params }) => fetchUser(params), }).user, ); ``` **Settle semantics & exception routing:** * A resource settles when its `status` reaches `'resolved'` or `'error'`. A loader `craftException` **short-circuits** to `handleExceptions`; a thrown loader error is **rethrown**; otherwise the resolved value is returned. * An HTTP call's declared business `exceptions` short-circuit to `handleExceptions`. The generic transport-level `HttpError` (`scope: 'HttpClient'`) is **rethrown** — a network failure is not a resolvable business case. (An opt-in `HttpError` handler may come later.) * The awaited HTTP endpoint is tracked as a route dependency automatically, exactly like one used in a component or loader. ### `craftUntilDefined` — await a readiness signal `craftUntilDefined(signal)` suspends until `signal()` is no longer `undefined`, then returns its non-nullable value. There is no exception channel — use it to wait on a plain readiness signal. ```ts const session = yield * craftUntilDefined(sessionService.current); ``` ### Notes * A guard that never reaches an `craftUntilSettled` / `craftUntilDefined` await still resolves **synchronously** (no forced microtask) — existing synchronous guards are unchanged. * This works for both `canActivate` and `canMatch`; the outlet drives the guard to settlement after the URL commits. ## Exceptions {#exceptions} Guards fail with `craftException({ _tag }, payload?)` — the same typed-exception primitive used by `query` / `mutation`: ```ts craftException({ _tag: 'FORBIDDEN_ROLE' }); craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 }); // payload reaches the handler ``` The `code` drives both the exhaustiveness check and the handler lookup; the optional payload is typed and forwarded to the handler. ## When to reach for it `craftGen` + a `canActivate` / `canMatch` generator fit **sequential, fail-fast gates resolved at a single boundary**: authorization, account-state checks, feature flags, action preconditions. It is **not** the right tool when you want to **collect and surface multiple failures** reactively — that is what `query` / `mutation` `hasException` and the form-submit exception model are for. Guards stop at the first failure and hand off to a handler. ## See Also * [Route Providers](/guide/routing/route-providers) — consume guarded data in route providers * [Setup](/guide/routing/setup) — per-route DI checks * [craftService](/guide/app/craft-service) — services yielded inside guards --- --- url: https://craft-ts.github.io/craft/guide/routing/exception-handling.md --- # Route exception handling When a guard, a matcher or a resolver raises a declared exception, this page is where you say what happens next: redirect, render a dedicated component, stay put, or carry on. One map per route resolves the **union** of every code those three steps can produce — and the compiler checks that the map is exactly complete, no more and no less. **Use it when** a route's guards, matchers or resolvers can fail in ways the user should see. **Not when** the failure is local to one primitive — read it off `exceptions()` instead, see [Exceptions as values](/guide/concepts/exceptions). ::: warning Breaking change Every handler must use `craftExceptionHandler(function* (...) {})`. Internal redirects use `yield* redirectTo({ to, params, queryParams, viewTransition })`; opaque URLs or prebuilt `UrlTree` values use `redirectUrl(...)`. `renderComponent`, route-level `errorComponent` and `withErrorComponent` accept only `{ component | loadComponent, componentDeps }` descriptors. Bare handler functions, `redirect(...)` and bare error components are rejected. ::: ## The common case ```ts USER_DISABLED: craftExceptionHandler(function* ({ renderComponent }) { return renderComponent({ loadComponent: () => import('./user-disabled-error-page').then( (m) => m.UserDisabledErrorPage, ), componentDeps: {} as import('./user-disabled-error-page').GenDeps_UserDisabledErrorPage, }); }), NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'auth/login', queryParams: { reason: 'session-expired' }, }); }), ``` `canActivate` / `canMatch` / `resolve` stay your **writing** API — each may raise a typed [`craftException`](/guide/routing/guards#exceptions). Instead of an inline resolver map per guard, a single **`handleExceptions`** map on the route resolves the union of every code reachable from those three steps. The non-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui) applies the chosen outcome **after the URL has committed**, so a slow guard never freezes navigation. The route result also exposes a route-scoped signal helper per code, such as `injectDemoUserIdUserDisabledException()`. It returns the exact exception and payload for the locally rendered branch, and is cleared on the next navigation. ## A full route, end to end ```ts const { profileQuery } = query('profileQuery', { params: () => true, loader: function* () { return yield* CraftHttpClient.get(({ response }) => ({ url: '/api/profile', success: response(), exceptions: [ function* ({ status, code }) { if (!(yield* status(403))) return; if (!(yield* code('USER_DISABLED'))) return; return craftException({ _tag: 'USER_DISABLED' }); }, ], })); }, }); craftRoute( 'user/:userId', { loadComponent: ({ withRetry }) => withRetry(import('./user-detail')), componentDeps: {} as import('./user-detail').GenDeps_UserDetail, canMatch: function* () { const ff = yield* FeatureFlags(); return ff.userPageEnabled ? true : craftException({ _tag: 'FEATURE_OFF' }); }, canActivate: function* () { const user = yield* Auth(); return user.value() ?? craftException({ _tag: 'NOT_AUTHENTICATED' }); }, resolve: craftResolve(function* () { return yield* craftUntilSettled(profileQuery); }), }, { FEATURE_OFF: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'home' }); }), NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo, phase }) { return yield* redirectTo({ to: 'login', queryParams: phase === 'active' ? { reason: 'session-expired' } : {}, }); }), USER_DISABLED: craftExceptionHandler(function* ({ globalError }) { return globalError(); }), HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }), }, ), ``` `canActivate` / `canMatch` are bare generator functions — there is no guard wrapper and no inline `resolvers` argument. Every reachable code flows to the third `craftRoute(...)` argument. ## Handler context Every handler receives a `CraftExceptionHandlerContext` typed for its exception code: | Field | Type / purpose | | ----------------- | -------------------------------------------------------------------------------------------- | | `exception` | The complete typed `craftException`, including `code`, `scope`, and `payload`. | | `payload` | The typed payload passed as the second argument of `craftException(...)`. | | `phase` | `'enter'` during initial activation, `'active'` during a live guard re-check. | | `router` | The active `Router` instance. | | `createUrlTree` | Bound `Router.createUrlTree`, useful for building a redirect with query params or fragments. | | `navigate` | Bound `Router.navigate`. Imperative; prefer returning `yield* redirectTo(...)`. | | `navigateByUrl` | Bound `Router.navigateByUrl`. Imperative; prefer a redirect outcome. | | `redirectTo` | Typed internal redirect checked against `META_PATHS`; yields `CraftRouter`. | | `redirectUrl` | Explicit escape hatch for an opaque string URL or `UrlTree`. | | `renderComponent` | Builds an outcome that renders a dedicated component. | | `globalError` | Delegates rendering to the application-wide error component. | | `stay` | Restores the previous URL and keeps the triggering page. | | `noop` | Continues to the target despite the exception. Resolve data remains `undefined`. | A handler is always a synchronous generator wrapped with `craftExceptionHandler`. It may resolve services but cannot suspend with `craftUntilSettled` / `craftUntilDefined`. ## Outcomes Each handler receives a context and returns an outcome constructor: | Outcome | Effect | | ----------------------------- | -------------------------------------------------------------------------------------------------------- | | `yield* redirectTo(input)` | Navigate to a registered internal route with typed params/query params/view transition. | | `redirectUrl(target)` | Navigate to an opaque string URL or `UrlTree`. | | `renderComponent(descriptor)` | Render a DI-checked `{ component \| loadComponent, componentDeps }` descriptor. | | `globalError()` | Render the application-wide error component (see [global error component](./global-error-component.md)). | | `stay()` | Cancel the navigation; restore the previous URL (stay on the triggering page). | | `noop()` | Render the target anyway, with `resolve` data left `undefined`. | The context also carries the typed `exception`, its `payload`, the native `redirect` helpers (`createUrlTree` / `navigate` / `navigateByUrl`), and the navigation `phase` (see below). A handler may be a **generator** that `yield*`s craft services before its outcome. ## Examples ### Typed payload and `UrlTree` Use `redirectTo(...)` for registered application routes and `redirectUrl(...)` for a prebuilt `UrlTree`: ```ts { NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) { return yield* redirectTo({ to: 'auth/login' }); }), RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) { return yield* redirectTo({ to: 'cooldown', queryParams: { retryAfter: String(payload.retryAfter) }, }); }), } ``` Here `payload` is inferred from `craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 })`. ### Initial entry versus live guard ```ts { NOT_AUTHENTICATED: craftExceptionHandler(function* ({ phase, redirectTo }) { return yield* redirectTo({ to: 'login', queryParams: phase === 'active' ? { reason: 'session-expired' } : {}, }); }), } ``` ### Local, global, stay, and noop outcomes ```ts { ACCOUNT_LOCKED: craftExceptionHandler(function* ({ renderComponent }) { return renderComponent({ component: AccountLockedPage, componentDeps: {} as import('./account-locked-page').GenDeps_AccountLockedPage, }); }), MAINTENANCE: craftExceptionHandler(function* ({ renderComponent }) { return renderComponent({ loadComponent: () => import('./maintenance-page').then((m) => m.MaintenancePage), componentDeps: {} as import('./maintenance-page').GenDeps_MaintenancePage, }); }), HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }), UNSAVED_CHANGES: craftExceptionHandler(function* ({ stay }) { return stay(); }), OPTIONAL_PROFILE_UNAVAILABLE: craftExceptionHandler(function* ({ noop }) { return noop(); }), } ``` The descriptor is checked independently with the O(1) `RouteExceptionComponentCheckedDI`; it is not part of the routed component's `RouteCheckedDI` proof. [Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if that proof is missing or not armed with `CanRun`. ### Handler using a craft service ```ts { FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) { const config = yield* RedirectConfig(); return redirectUrl(config.unauthorizedUrl); }), } ``` Dependencies yielded by handlers participate in route DI checking, like dependencies yielded by guards and resolvers. ## Exhaustiveness The union is only resolvable once the whole collection is inferred, so exhaustiveness is asserted **after** `craftRoutes` rather than inline on each route: ```ts export const { demoRoutes } = craftRoutes('demo', [ /* … */ ]); // Compile error if any route's handleExceptions misses — or over-covers — a reachable code. assertExhaustiveRouteExceptions(demoRoutes); ``` [Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a `craftRoutes(...)` collection has no `assertExhaustiveRouteExceptions`. A missing code (e.g. `resolve` can throw `USER_DISABLED` but no handler) **and** an extra code (a handler for a code nothing can produce) are both type errors, naming the offending route + codes. ## Pitfalls **`HttpError` appears or disappears depending on the `craftUntilSettled` form.** This is the most common surprise: * `craftUntilSettled(CraftHttpClient.get(...))` **excludes** `HttpError` from the routable union and rethrows it. The outlet sends that navigation error to the global error component. * `craftUntilSettled(queryRef)` routes every exception the query exposes. When its loader returns a `CraftHttpClient` request, that **includes** `HttpError`, so the route must declare an explicit handler such as `HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); })`. Declared business exceptions remain routable in both forms. **A handler cannot suspend.** It may `yield*` services, but not `craftUntilSettled` / `craftUntilDefined`. **Over-covering is an error too.** A handler for a code nothing can produce fails the exhaustiveness assert, same as a missing one. ::: details The `phase` field `phase` distinguishes the initial activation (`'enter'`) from a reactive re-evaluation (`'active'`) of a live `canActivate` guard (see [live guards](/guide/routing/guards#reactive-guards)). Use it to soften a reaction mid-session — a different redirect reason on session expiry, say — or ignore the reactive phase entirely with `noop()`. ::: ## See Also * [Exceptions as values](/guide/concepts/exceptions) — the concept * [Route guards](/guide/routing/guards) — where exceptions are raised * [Global error component](/guide/routing/global-error-component) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place --- --- url: https://craft-ts.github.io/craft/guide/routing/pending-ui.md --- # Non-blocking navigation By default, a slow guard or resolver can leave the current screen unchanged with no feedback. `CraftRouterOutlet()` commits the URL immediately and shows a pending component only if the wait is actually noticeable. **Use it when** guards or resolvers do real work — an HTTP call, a permission check. For synchronous routes, the outlet renders the target immediately. `CraftRouterOutlet()` provides **non-blocking** navigation: the URL commits immediately, a pending component appears only if the guard/resolve chain is slow, and the target component is mounted **only on success** — never while an exception is being resolved. ## Setup Call the outlet inside a Craft component tree: ```ts import { CraftRouterOutlet, craftComponent, main } from '@craft-ts/component'; export const App = craftComponent( 'App', {}, () => ({}), () => main(CraftRouterOutlet()), ); ``` Routes with no craft guard or resolver render immediately. ## Lifecycle For a route with a craft chain, on navigation the outlet lets the URL commit immediately (no blocking guard), then runs **three phases** while the chain is in flight — so a fast navigation never flashes a blank screen or a loader: 1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs in the background; if it settles within this window, the outlet transitions **straight to the target** (no blank, no loader); 2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is changing; 3. **pending** — the **pending component** (loader) is shown until the chain settles. On success the outlet writes the resolved data and mounts the **target**; on exception it applies the route's [`handleExceptions`](/guide/concepts/exceptions) outcome. Lazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount the target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures and render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript download or retry does not currently activate this pending timeline; dedicated loading UI for that earlier phase is a planned evolution. ``` click → URL committed ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect ``` `pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long, so a chain that settles right after it appears does not blink it in and out. The previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a single component slot it leaves untouched until the phase changes, so the old component instance keeps its state for the duration of the window. ## Configuration The loading and error features are plain feature objects. The recommended place for them is **directly in `provideCraftRouter(...)`**: ```ts provideCraftRouter( appRoutes.toRoutes(), withCraftViewTransitions(), // craft loading feature (see below) withErrorComponent({ component: MyGlobalErrorScreen, componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen, }), withRouteLoadError({ component: MyRouteLoadErrorScreen, componentDeps: {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen, retry: { attempts: 1, delayMs: 250 }, }), withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }), withLoadingText(() => computed(() => translate('common.loading'))), withPendingComponent(MyBrandedSpinner), ), ``` Most loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them in a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also registers a navigation error handler and an internal recovery route. ```ts provideCraftLoading( withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }), withLoadingText(() => computed(() => translate('common.loading'))), withPendingComponent(MyBrandedSpinner), withErrorComponent({ component: MyGlobalErrorScreen, componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen, }), ), ``` | Feature | Service helper(s) | Default | | -------------------------- | --------------------------------------------------------------------- | --------------------------------- | | `withPendingComponent` | `CraftPendingComponent` | `DefaultCraftPendingComponent` | | `withLoadingText` | `CraftLoadingText` | locale-aware (en/fr, fallback en) | | `withTransitionTimings` | `CraftStayMs` / `CraftBlankMs` / `CraftPendingMinMs` | `300` / `300` / `0` | | `withErrorComponent` | `CraftErrorComponent` | `null` | | `withRouteLoadError` | `CraftRouteLoadErrorConfig` / `CraftRouteLoadRetry` | `null` / one retry after 250 ms | | `withCraftViewTransitions` | `CraftViewTransitionsEnabled` / `CraftViewTransitionSkipBlank` | `false` / `false` | | `withA11yNavigationFocus` | `CraftA11yNavigationFocus` | `false` | The default pending component renders `CraftLoadingText`, which reads `LOCALE_ID` and picks a built-in translation (`Loading…` / `Chargement…`). ## Per-route overrides Any route may override the defaults via route fields that are stripped before the runtime route is emitted: ```ts craftRoute('user/:userId', { // … stayMs: 150, // shorten the "keep previous page" window blankMs: 0, // skip the blank phase → straight to loader pendingComponent: () => import('./user-skeleton'), // reactiveGuards: false, // opt out of live guards (on by default) }), ``` ## View Transitions The default view-transition feature brackets **only the synchronous URL commit** in `document.startViewTransition()`. With the non-blocking outlet that is the wrong instant: the target component mounts **after** the guard/resolve chain settles, so a shared-element morph captures `previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen loader becomes the captured "old" frame. `withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives `document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the morph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when the API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam. ```ts provideCraftRouter( appRoutes.toRoutes(), withCraftViewTransitions(), ), ``` ### Shared element across a slow chain For the morph to bridge a slow navigation, **something** carrying the shared element's `view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route opts in by **declaring the shared-element payload shape** with `viewTransitionPayload()` — the view-transition analogue of how `queryParams` declares a route's query-params shape. This: * makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate` targeting it (`null` is an explicit opt-out); * exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal` helper; * tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`. ```ts export const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes( 'photos', [ craftRoute( ':photoId', { componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent, loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')), withLoaderViewTransitionImage: viewTransitionPayload<{ name: string; image: string | null; }>(), pendingComponent: () => import('./photo-skeleton'), // The skeleton's DI is verified separately (see "Verifying the skeleton's DI"). canActivate: function* () { /* slow guard */ }, }, { /* … */ }, ), ], ).withParent>(); ``` This collection is a lazy child mounted via `loadChildren`; its routed components carry their own per-route DI checks. Because its components depend on the `:photoId` param **and** the declared view-transition payload, it is only correct under the `photos` route — so it is **pinned** to that mount with `.withParent>()`, and the parent enforces it with `assertChildRouteMounts(...)`. See [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts). The link passes a payload of the **declared type** (required, and shape-checked): ```ts a({}, 'Photo').pipe( CraftRouterLink({ to: 'photos/:photoId', params: { photoId: photo.id }, viewTransition: { name: 'photo-' + photo.id, image: photo.preview }, }), ); ``` The skeleton receives `photoId` as a route-bound input and reads the payload through the **route-generated typed helper**: ```ts import { input } from '@angular/core'; export default class PhotoSkeleton { protected readonly photoId = input.required(); // Signal<{ name: string; image: string | null } | null> — typed by the route. private readonly viewTransition = injectPhotosPhotoIdViewTransition(); protected readonly image = computed( () => this.viewTransition()?.image ?? null, ); // template: … } ``` > The global, untyped `injectCraftViewTransition(): Signal` still exists for ad-hoc reads, but > prefer the route-generated helper when you have a declared payload. The payload travels in navigation `state`, so it is **lost on reload or direct URL access** — there is no previous page to morph from in that case anyway; the app stays functional (skeleton without the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to skip the blank phase for **every** route, not just opted-in ones. ### Verifying the skeleton's DI The pending skeleton is a real component that injects dependencies (route params, the typed payload, monitoring, …). It is verified independently with the per-component, O(1) [`RouteCheckedDI`](/guide/routing/setup) check: ```ts // The skeleton injects the `:photoId` param and the typed payload — both // auto-provided by the route, so list those service names as available; the // parent context (`AppValues` here) is the same one the route uses. type _CheckPendingDI = RouteCheckedDI< import('./photo-skeleton').GenDeps_PhotoSkeletonComponent, 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition', AppValues, 'pending component: photos/:photoId' >; type _CanRunPending = CanRun<_CheckPendingDI>; ``` A service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending` (`The X service is not provided in pending component: photos/:photoId`). The `craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block** from `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the auto-provided service names from the route's path params + payload. [Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail if that pending proof is missing or not armed with `CanRun`. ## See Also * [Route exception handling](/guide/routing/exception-handling) * [Route guards](/guide/routing/guards) — what the outlet is waiting on * [Global error component](/guide/routing/global-error-component) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed --- --- url: https://craft-ts.github.io/craft/guide/routing/global-error-component.md --- # Global error component One component, declared once, for every failure a route decided not to handle locally. **Use it when** several exception codes deserve the same screen, or as the backstop for unexpected errors. **Not when** a specific failure needs its own UI — `renderComponent(...)` in the route's handler is more precise. See [Route exception handling](/guide/routing/exception-handling). When a route exception handler delegates to `globalError()`, the outlet renders one application-wide error component and feeds it the exception. That component can read **all** of its possible exceptions — typed and exhaustive — because the codes routed to it are mirrored in a global registry maintained automatically by ESLint. ## Register the component Pass `withErrorComponent(...)` directly to `provideCraftRouter(...)` (mixed with your router features): ```ts provideCraftRouter( appRoutes.toRoutes(), withComponentInputBinding(), withErrorComponent({ component: MyGlobalErrorScreen, componentDeps: {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen, }), ), ``` It also works standalone via `provideCraftLoading(withErrorComponent({ component, componentDeps }))`. ## Consume the exception ```ts export const MyGlobalErrorScreen = craftComponent( 'MyGlobalErrorScreen', {}, function* () { // Signal const error = yield* CraftGlobalError(); return { message: computed(() => { switch (error()?.code) { case 'USER_DISABLED': return 'This account is disabled.'; default: return 'Something went wrong.'; } }), }; }, ({ message }) => div(h1(() => message())), ); ``` `CraftGlobalError()` is typed as the **union of every exception** any route delegates to the global component, so `switch (error().code)` is exhaustively typed. The outlet writes the active exception into `CRAFT_GLOBAL_ERROR` just before rendering the component. ## The registry (auto-maintained) The union comes from `CraftGlobalExceptionRegistry`, keyed by route path and code: ```ts declare module '@craft-ts/core' { interface CraftGlobalExceptionRegistry { 'user/:userId': { USER_DISABLED: CraftRouteExceptionType< typeof demoRoutes, 'user/:userId', 'USER_DISABLED' >; HttpError: CraftRouteExceptionType< typeof demoRoutes, 'user/:userId', 'HttpError' >; }; } } ``` **Do not edit this block by hand.** The `craft-ts/global-exception-registry-match` ESLint rule detects every `handleExceptions` handler that calls `globalError()` and keeps the registry in sync: ```bash npx nx lint demo --fix ``` A missing entry is reported as an error; `--fix` inserts the `[path][code]` entry. `CraftRouteExceptionType` resolves the typed exception object for a code on a route from the collection's route definitions (no type checker required — the rule builds the reference from the collection variable and the path/code literals). The screen itself still needs an armed `RouteExceptionComponentCheckedDI` in `app.config.ts`. [Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if that proof is missing. ## Default behaviour If no `withErrorComponent` is configured, `globalError()` and unhandled thrown errors leave the outlet in its `error` state without a component. Provide a global error component to render a fallback UI. ## See Also * [Route exception handling](/guide/routing/exception-handling) — where `globalError()` is returned * [Route load errors](/guide/routing/route-load-errors) * [Non-blocking navigation](/guide/routing/pending-ui) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed --- --- url: https://craft-ts.github.io/craft/guide/routing/route-load-errors.md --- # Route load errors This is the failure mode nothing else covers: the route is valid, the guards passed, and the **JavaScript chunk itself** never arrives — a stale hash after a deploy, a flaky network, an offline user. **Use it when** your app is lazy-loaded and deployed more than once. Which is to say: use it. `withRouteLoadError(...)` handles failures that happen before Craft can mount the target route: lazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale deployments, CDN errors, or offline transitions. This is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business exceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is trying to fetch the JavaScript needed to activate the route. ## Register the route-load error screen Pass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and other craft loading features: ```ts import { provideCraftRouter, withRouteLoadError, withErrorComponent, } from '@craft-ts/core'; provideCraftRouter( appRoutes.toRoutes(), withErrorComponent({ component: MyGlobalErrorScreen, componentDeps: {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen, }), withRouteLoadError({ component: MyRouteLoadErrorScreen, componentDeps: {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen, retry: { attempts: 1, delayMs: 250, }, }), ); ``` The component must be eager. Do not configure the route-load error screen with `loadComponent`: the failure case is precisely that lazy JavaScript may be unavailable. ## Runtime behaviour When a lazy route load fails, Craft: 1. runs the configured retry strategy; 2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`; 3. renders the configured route-load error component; 4. keeps the browser URL on the original target URL. The last point matters. Internally, Craft activates a technical recovery route so there is something safe to render, but `browserUrl` keeps the visible URL as the intended route: ```text /mutation/123 → lazy chunk fails → retry fails → route-load error screen is shown → browser URL stays /mutation/123 → F5 reloads /mutation/123 and retries the real route ``` ::: info No dedicated loading UI during JavaScript fetches yet While Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the configured retry strategy, Craft does not currently display the route's `pendingComponent` or another dedicated loading component. The pending component starts only after the JavaScript has loaded and the route has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running. Extending the pending timeline to cover slow chunk downloads and retries is planned as a future evolution. Until then, the previous route may remain visible while the JavaScript request is pending; the route-load error component appears only after all configured retries fail. ::: ::: warning Browser-cached module failures Browsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each Craft lazy route import with the loader's `withRetry` helper: ```ts loadComponent: ({ withRetry }) => withRetry(import('./detail')), loadChildren: ({ withRetry }) => withRetry(import('./admin.routes')).then((m) => m.adminRoutes), ``` The initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed production chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser error and adds `__craft_route_retry` only to the failed request. A successful retry module is kept for the lifetime of the application and reused by later route activations. This recovery depends on the browser including the failed module URL in the dynamic-import error. When it does not, `reload()` remains the reliable recovery path. Do not write `import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from being statically discovered. ::: ## Build the error component The component can inject both the active technical exception and the recovery API: ```ts import { button, craftComponent, div, h2, p } from '@craft-ts/component'; import { CraftRouteLoadError, CraftRouteLoadRecovery, provideHostName, } from '@craft-ts/core'; // A sheet beside the screen: its frame, and the row of actions. import { loadError } from './route-load-error.style'; export const MyRouteLoadErrorScreen = craftComponent( 'MyRouteLoadErrorScreen', { providers: [provideHostName('component:MyRouteLoadErrorScreen')], }, function* () { return { error: yield* CraftRouteLoadError(), recovery: yield* CraftRouteLoadRecovery(), }; }, ({ error, recovery }) => { const current = error(); return div({ class: loadError.root }, [ h2('Route could not be loaded'), p( current ? `Failed to load ${current.payload.phase} for route "${current.payload.routePath}" after ${current.payload.attempt} attempts.` : 'The requested route chunk could not be loaded.', ), div({ class: loadError.actions }, [ button({ click: () => void recovery.retry() }, 'Retry route load'), button({ click: () => recovery.reload() }, 'Reload app'), ]), ]); }, ); ``` `CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes: * `phase`: `'component'` or `'children'`; * `routePath`: the route definition path that failed; * `targetUrl`: the URL the user tried to reach; * `cause`: the final error thrown by the loader/retry strategy; * `attempt`: the number of load attempts made. `injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the browser. ## Configure retry globally The default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`: ```ts withRouteLoadError({ component: MyRouteLoadErrorScreen, componentDeps: {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen, retry: { attempts: 2, delayMs: 500, }, }); ``` `attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most three loader calls total: the initial call plus two retries. Use callbacks when retry behaviour depends on the error: ```ts withRouteLoadError({ component: MyRouteLoadErrorScreen, componentDeps: {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen, retry: { attempts: 3, shouldRetry: (error, context) => { // Only retry dynamic import / chunk loading failures. if (!(error instanceof TypeError)) return false; // Stop earlier for a route where retrying is known to be useless. return context.routePath !== 'admin'; }, delayMs: (_error, context) => { // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, … return 250 * (context.attempt - 1); }, }, }); ``` The retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and `error`. The `attempt` value is the load attempt about to run. After the first failed load, the first retry callback receives `attempt: 2` and `error` set to the initial failure. For custom logic, pass a retry strategy: ```ts withRouteLoadError({ component: MyRouteLoadErrorScreen, componentDeps: {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen, retry: { async execute(loader, context) { console.warn('route load failed, retrying', context); return loader(); }, }, }); ``` The strategy can also be an injectable class implementing `CraftRouteLoadRetry`. ## Override per route Both the retry strategy and the rendered component are regular DI providers. Override them on a specific route when the failure should have local behaviour: ```ts import { provideRouteLoadErrorComponent, provideRouteLoadRetry, } from '@craft-ts/core'; craftRoute('admin', { providers: [ provideRouteLoadRetry({ attempts: 3, delayMs: 1_000, }), provideRouteLoadErrorComponent({ component: AdminRouteLoadErrorScreen, componentDeps: {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen, }), ], loadChildren: ({ withRetry }) => withRetry(import('./admin.routes')).then((m) => m.adminRoutes), }); ``` The local component receives the same `injectCraftRouteLoadError()` and `injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector. ## DI checks Route-load error components participate in the same generated DI checks as other error surfaces. The ESLint rule `craft-ts/require-exception-component-di-check` generates `RouteExceptionComponentCheckedDI` checks for: * global `withRouteLoadError(...)` components; * route-local `provideRouteLoadErrorComponent(...)` components. Run ESLint with `--fix` after adding or changing a route-load error component: ```bash npx nx lint your-app --fix ``` Do not hand-maintain the generated `_Check*DI` blocks. [Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail if a registered route-load error screen has no armed `RouteExceptionComponentCheckedDI`. ## See Also * [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports * [Global error component](/guide/routing/global-error-component) * [Non-blocking navigation](/guide/routing/pending-ui) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed --- --- url: https://craft-ts.github.io/craft/guide/routing/scaling.md --- # Scaling routes `RouteCheckedDI` validates one routed component at a time. Its cost does not grow with the number of sibling routes, so a large route file keeps the same DI safety as a small one. ::: tip Keep route ownership clear Use `loadChildren` when a feature deserves its own lazy boundary or team ownership. Every route file remains independently checked. ::: ## Large route files The per-route check does not recursively instantiate the complete route tuple. Add one `RouteCheckedDI` / `CanRun` pair for each routed component: ```ts import { type CanRun, type RouteCheckedDI } from '@craft-ts/core'; type _CheckItem0 = RouteCheckedDI< import('./item-0').GenDeps_Item0Component, AppProvidedNames, AppProvidedValues, 'Item0Component' >; type _CanRunItem0 = CanRun<_CheckItem0>; type _CheckItem1 = RouteCheckedDI< import('./item-1').GenDeps_Item1Component, AppProvidedNames, AppProvidedValues, 'Item1Component' >; type _CanRunItem1 = CanRun<_CheckItem1>; ``` If a component starts depending on a service that is not provided, or expects an input that the route does not supply, its `CanRun` alias becomes a TypeScript error in the route file. ## Scaling to hundreds of routes Organise routes as a tree of feature files joined by `loadChildren` when that improves lazy loading or ownership: ``` app.routes.ts ├── billing.routes.ts ├── admin.routes.ts └── reporting.routes.ts ``` The parent registers a child collection with a lazy entry: ```ts { path: 'billing', loadChildren: ({ withRetry }) => withRetry(import('./billing.routes')).then((m) => m.billingRoutes), }, ``` The child file declares its own routes and its own per-route checks. A parent proof never covers a component inside a `loadChildren` collection. ::: tip Threading the parent DI context The second and third `RouteCheckedDI` parameters are the names and values provided at the route's mount point — app providers plus ancestor route providers. When an ancestor adds providers, re-export that cumulative context and pass it to the child route checks. ::: ## Pinning a lazy child to its mount path (`.withParent` + `assertChildRouteMounts`) Splitting into `loadChildren` keeps route ownership clear, but nothing yet guarantees a child is wired under the right parent route. A child whose components rely on a specific mount — its `:photoId` param, a declared view-transition payload, or an ancestor's providers — is only correct under that path. Pin a collection to its mount path with `.withParent>()`, then enforce it once in the parent with `assertChildRouteMounts(parentRoutes)`: ```ts // view-transitions.routes.ts — the child declares where it belongs import { craftRoutes, craftRoute, type ParentRoutes } from '@craft-ts/core'; export const { viewTransitionsRoutes } = craftRoutes('viewTransitions', [ craftRoute(':photoId', { componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent, loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')), }), ]).withParent>(); ``` ```ts // app.routes.ts — the parent enforces placement import { assertChildRouteMounts, craftRoutes } from '@craft-ts/core'; export const { demoRoutes } = craftRoutes('demo', [ { path: 'view-transitions', loadChildren: ({ withRetry }) => withRetry(import('./view-transitions.routes')).then( (m) => m.viewTransitionsRoutes, ), }, ]); assertChildRouteMounts(demoRoutes); ``` Mounting the pinned collection under another path fails in the parent file: ``` craftRoutes(...).withParent>() must be loadChildren-mounted under the route with path 'view-transitions', not 'admin' ``` Notes: * A collection without `.withParent` is unpinned and can be mounted anywhere. * `assertChildRouteMounts` reads only the parent's own routes; it does not re-validate the child. * `.withParent<…>()` is type-only and creates no runtime coupling. * `craft-ts/require-child-route-mount-check` adds the missing `assertChildRouteMounts(...)` call and import on `--fix`. ## See Also * [Setup](/guide/routing/setup) * [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` catches a routed component with no check * [Route providers](/guide/routing/route-providers) --- --- url: https://craft-ts.github.io/craft/guide/components.md --- # Components A Craft component is a **function**, not a class. No decorator, no separate template file, no host element wrapped around your markup. **Use it for** application components. [`loadCraftComponent`](/guide/routing/setup) mounts a Craft component on a route. ## Install The component renderer is published as a separate package and is currently on the `beta` channel: ```shell npm i @craft-ts/core@beta @craft-ts/component@beta ``` See [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component). ## The shape ```typescript craftComponent(name, meta, factory, template); ``` | Argument | What it is | | ---------- | ----------------------------------------------------------------- | | `name` | the component's name — used for host tags, snapshots, diagnostics | | `meta` | `providers`, `host` | | `factory` | the **logic**: builds and returns the context | | `template` | receives that context, returns nodes | ```ts import { craftComponent, forNode, h1, li, ul } from '@craft-ts/component'; import { state } from '@craft-ts/core'; type Task = { id: string; title: string; done: boolean }; export const Tasks = craftComponent( 'Tasks', {}, function* () { const tasks = yield* state('tasks', [] as Task[]); return { tasks }; }, ({ tasks }) => [ h1('Tasks'), ul( forNode(tasks, { track: (task) => task.id }, (task) => li(function* () { return (yield* task()).title; }), ), ), ], ); ``` The split matters: the factory produces a context **without touching the DOM**, and the template renders a context **without running the factory**. That is what makes the two [testable independently](/guide/testing/components). ## The logic factory A `function*` when it needs dependencies — every `yield*` is tracked and folds into the component's dependency type: ```typescript function* () { const tasks = yield* TaskList(); return { tasks }; } ``` A plain arrow when it needs none: ```typescript () => ({}); ``` Whatever it returns is the context the template receives. Nothing else is exposed. ## Inputs and outputs They are **parameters of the factory**, typed with `Input` and `Output`: ```ts import { Input, Output, button, craftComponent, div, span, } from '@craft-ts/component'; import { deepYieldable } from '@craft-ts/core'; type User = { name: string }; const UserCard = craftComponent( 'UserCard', {}, (user: Input, onRemove: Output<(user: User) => void>) => ({ user: deepYieldable(user), onRemove, }), ({ user, onRemove }) => div([ span(user.name), button( 'remove', { type: 'button', *click() { yield* onRemove(yield* user()); }, }, 'Remove', ), ]), ); ``` An `Input` **is a yieldable reader** — `yield* user()` reads the current value. An `Output` is a yieldable callback; delegate to it with `yield*`. Rendering a child is a function call, so there is no binding layer to get wrong: ```typescript UserCard({ user: currentUser, onRemove: removeUser }); ``` | Contract | Craft | | --- | --- | | Input | an `Input` factory parameter | | Output | an `Output` parameter, called directly | | Component call | `UserCard({ user: u, onRemove: fn })` | | Missing required input | **compile error** | ## The template Nodes are built with hyperscript helpers — `div`, `ul`, `button`, and `h(tag, …)` for anything without one. Pass a yieldable reader to a binding. Use a generator when the binding must format or call a method: ```typescript ({ tasks }) => [ h1(function* () { return `Tasks — ${yield* tasks.remaining()} left`; }), h1(`Tasks — static`); // static text needs no reader ]; ``` The same binding boundary applies to attributes, DOM properties, classes, styles, and host props. Prefer exposing a derived reader on the primitive (`tasks.isEmpty`) and passing it (`disabled: tasks.isEmpty`) over wrapping a synchronous call. See [Fine-grained reactivity](/guide/components/fine-grained-reactivity) for the complete rendering model, structural scopes, observability expectations, and migration checklist. See [Progressive `forNode` rendering](/guide/components/schedule-for) when a large collection needs frame-based scheduling. Keep render callbacks pure. They may read signals and calculate values, but must not call `set`, `update`, or `mutate`. Perform writes from DOM events, outputs, mutations, or explicit business effects. Enable `craft-ts/no-render-writes` to diagnose common violations. Control flow is made of functions rather than syntax — `forNode`, `ifNode`, `matchNode`, `deferNode`. The relationship between these blocks, and why a raw ternary is the wrong tool for **structure**, is in [Learn step 2](/learn/02-derive#control-flow). ## The meta ```typescript import { card } from './card.style'; craftComponent( 'Card', { providers: [provideCardStore()], host: { class: card.root }, }, /* … */ ); ``` * **`providers`** — the component's own DI scope, evaluated before the template. * **`host`** — default properties for the root element. Its `class` comes from a sheet, like every class. The meta carries no CSS. A component's look lives in a `*.style.ts` sheet beside it — see [Styling a component: the only way](/guide/components/styles). `styles`, `stylesUrl` and `contentStyles` still exist on the type, deprecated, and `craft-ts/no-component-css` refuses them. ## Composing behaviour `.pipe(...)` attaches directives, which decorate **both** the logic factory and the template, left to right: ```typescript const EditablePanel = Panel.pipe(WithPermission); ``` The same mechanism carries `withProviders(...)` and the exception handlers below. See [Directives and `.pipe(...)`](/guide/components/directives). ## Mounting the root The app root is a Craft component too: ```typescript // app.config.ts export const appConfig = craftAppConfig({ providers: [provideCraftRootComponent(App)], }); ``` ```typescript // main.ts import { bootstrapCraft } from '@craft-ts/component'; import { appConfig } from './app.config'; bootstrapCraft({ config: appConfig }); ``` `bootstrapCraft` builds the root injector, runs the app-start hooks, then mounts the root component into `` (or the element you pass as `host`). ## Pitfalls **Reading a reader outside a binding.** `h1(tasks().length)` evaluates once at build time. Pass the reader (`p(tasks.remaining)`) or use a generator: `h1(function* () { return yield* tasks.remaining(); })`. **Forgetting `track` in `forNode`.** Without a stable identity the renderer cannot reuse, move or remove the right node. **Exceptions from the factory or providers don't vanish.** They become the component's initialization exceptions and flow up to the route unless handled with `.pipe(catchNode.exhaustive(...))` — see [Exceptions as values](/guide/concepts/exceptions). **Naming mismatch.** The first argument must match the exported binding; the `craft-component-name-match` rule enforces it. ## See Also * [Learn: your first state](/learn/01-first-state) — the guided version * [Directives and `.pipe(...)`](/guide/components/directives) * [Accessibility](/guide/components/accessibility) * [Testing components](/guide/testing/components) --- --- url: https://craft-ts.github.io/craft/guide/style/setup.md --- # Activating `@craft-ts/style` The typed style system is not a runtime library you import and call. It is a **build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates what they registered, and emits one stylesheet. Without that plugin the vocabulary still typechecks and still compiles — and the page renders with no CSS at all. This page is the one to follow before the other four. ## Install ```bash npm install @craft-ts/style npm install --save-dev @craft-ts/dev-tools # Optional, for visual scenario matrices and attestation: npm install --save-dev @craft-ts/style-testing ``` `@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom properties, axes, sheets, obligations. `@craft-ts/dev-tools` carries the `craft-graph` contrast command and the ESLint guard rails. `@craft-ts/style-testing` carries the optional scenario matrix and the drivers that reach each of its points; it never ships to the browser, so it belongs in `devDependencies` when visual scenarios or attestation are enabled. `@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and `@craft-ts/style-testing` declares `@craft-ts/style`. Keep all installed `@craft-ts/*` packages on the same release line. The runtime style package and the optional testing package are both `sideEffects: false`. ## Wire the plugin ```ts import { defineConfig } from 'vite'; import { craftStyle } from '@craft-ts/style/vite'; export default defineConfig({ plugins: [ craftStyle({ // Written on every emission, so the graph never reads a picture of the // sheet that is older than the CSS the browser got. dumpPath: 'tmp/craft-style-graph.json', }), ], }); ``` `craftStyle` takes six options, all optional: | option | default | what it decides | | ---------- | ------------------------------------------------ | -------------------------------------------------------- | | `suffix` | `'.style.ts'` | the filename suffix that marks a module as a sheet | | `ignore` | `['node_modules', 'dist', '.git', '.nx', 'tmp']` | directory names the walk never descends into | | `dumpPath` | none — no dump is written | where to write the graph dump | | `alias` | none | module aliases for the **Node** evaluation of the sheets | | `reset` | `true` | ship the craft-ts reset — see [Global foundation](./foundation.md) | | `base` | `true` | ship colour scheme, focus ring, reduced motion, selection | `alias` exists because the sheets are evaluated by a real bundler in a separate pass, before your app's own resolution applies. In a published project, Node resolution finds `@craft-ts/style` on its own and you can leave `alias` out. In this monorepo the demo passes the workspace source paths — see [`apps/demo/vite.config.ts`](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/vite.config.ts), which is the working reference for everything on this page. Then import the emitted sheet once, at the app entry: ```ts import 'virtual:craft-style.css'; ``` If your `tsconfig` does not already know that id, declare it next to your other ambient types: ```ts declare module 'virtual:craft-style.css'; ``` ## What the plugin produces Two artefacts, from one evaluation. **The CSS.** Every `when(...)` and `set(...)` the sheets registered, rendered as atomic rules and `@property` registrations, deduplicated across files, and served under `virtual:craft-style.css`. This is the whole stylesheet: no class is ever assembled in the browser, so what the browser gets is exactly what the emitter proved. **The dump**, when `dumpPath` is set. A JSON picture of the registry — classes, atoms, and typed variables — written on *every* emission, so it can never describe a sheet older than the CSS that was served alongside it. The dump is the style half of the dependency graph: it is what [`style_impact`, `style_matrix` and `style_debt`](./testing.md#what-the-graph-adds) read, and what the `@craft-ts/dev-tools` style queries read. The plugin re-derives the whole sheet when a `*.style.ts` changes rather than patching it. Atomic output is small and the emission is one bundle away; an incremental path here would be a second source of truth about what the CSS says. ## What breaks without it | missing | symptom | | ---------------------------------- | ---------------------------------------------------------------------------- | | the plugin | no CSS at all — the classes exist as strings, nothing ever wrote their rules | | `import 'virtual:craft-style.css'` | same, and less obviously: the plugin runs but nothing pulls its output | | `dumpPath` | `style_matrix` and the other graph queries have nothing to read and say so | The MCP server names the fix in its own error message: it points you back at `craftStyle({ dumpPath })`. If you are reading this because you saw that message, `dumpPath` is the line you are missing. ## Emitting without a Vite server `@craft-ts/style/vite` exports the emitter itself, so a test or a script can get the same two artefacts without standing up a dev server: ```ts import { emitStyles } from '@craft-ts/style/vite'; // The same two artefacts the plugin produces, without a Vite server: `css` is // what the browser gets, `dump` is what the graph and the MCP tools read. export const emitOnce = () => emitStyles(['src/app/ui/button.style.ts', 'src/app/ui/foundation.style.ts']); ``` `vite` is a peer of that entry point, not a dependency: a project that builds with something else can still call `emitStyles` without pulling Vite's types into its own program. ## Next * [Define your design system](./define.md) — palette, axes, theme: where `bp`, `scheme` and `palette` come from. * [Tokens and typed variables](./tokens.md) — level 1. * [Axes and the visual matrix](./variants.md) — level 2. --- --- url: https://craft-ts.github.io/craft/guide/style.md --- # Typed styles ::: tip Two style systems, and which to pick This section is `@craft-ts/style`: typed values, CSS emitted at build time, and a visual matrix you can enumerate. It costs a Vite plugin — see [Activating the style system](./setup.md) — and a design system to declare. [`meta.styles`](../components/styles.md) is the other one: a string of CSS shipped with the component and scoped with `@scope`, with no build step. It is the shortest path to a component's own appearance. Pick `meta.styles` for a component whose look is settled and local. Pick this one when the variants are a matrix you need to prove you covered. They coexist. ::: `@craft-ts/style` makes a component's visual surface **derivable** instead of guessed. For any component you can ask what the exhaustive set of visual states is, which of them are impossible, and whether the context it needs exists — and the answers come from the same values the CSS is emitted from, not from a second description that can drift. It buys that in three levels, and they do not adopt the same way. | level | what it gives you | granularity of adoption | | ------------------------------ | ---------------------------------------------------------------------- | --------------------------- | | 1 — tokens and typed variables | no value is a string; no class is built at runtime | **one component at a time** | | 2 — axes and the matrix | the exhaustive list of visual states, with the drivers that reach them | **per component** | | 3 — context obligations | a missing scroll port, container or clipping ancestor fails the build | **per whole route** | Read that last column carefully, because it is the part that is easy to get wrong. Level 3 is not a per-component guarantee: one unmigrated link in a route and the requirement travels past it unanswered, so the compiler has nothing to check. A partial level-3 adoption gives **zero** of the guarantee, not most of it — and the graph reports it rather than hiding it. ## The rule the whole thing turns on **Static goes to a class at build time; dynamic goes through a typed custom property.** No class is ever assembled in the browser. ```ts // tone is an axis: five rules the emitter already wrote. when(tone.danger, [set(v.bg, palette.accent.danger)]); ``` ```ts // a width that depends on a signal cannot be a class — there is no finite set // of widths to emit — so it goes through a registered . style: function* () { return assign(meterVars.value, unit.pct(yield* value())); } ``` That split is what keeps the set of visual states finite, and therefore enumerable. A class built from a signal is a state nothing recorded. ## Where to go next Start with [Activating `@craft-ts/style`](./setup.md): the system is a build step, and none of the pages below produce a single byte of CSS until the Vite plugin is wired. Then [Define your design system](./define.md), which is where `bp`, `palette` and the theme variables the other pages spend come from. * [Tokens and typed variables](./tokens.md) — level 1. * [Axes and the visual matrix](./variants.md) — level 2. * [Context obligations](./obligations.md) — level 3. * [Testing what you built](./testing.md) — drivers, baselines, exhaustiveness. * [Text contrast](./contrast.md) — WCAG AA proven from the sheets and the templates, with no browser, and an explicit list of what it does not cover. A working example lives in the demo, at `apps/demo/src/app/examples/design-system/`, with a README that walks through the same three levels in code. --- --- url: https://craft-ts.github.io/craft/guide/style/define.md --- # Defining your design system The other pages in this section spend `bp`, `scheme`, `palette`, `v`, `space` and `unit` as if they were already in scope. They are not built in — except `scheme`, which is. This page is where the rest come from. Everything here goes in one sheet, conventionally `foundation.style.ts`. A `*.style.ts` may import vocabulary and nothing else — the `style-file-boundary` rule enforces it — which is exactly what makes it safe for the build plugin to import the file in Node: there is no application code in it to run. ```ts import { at, axisPoint, craftStyles, cssVars, darkOf, defineAxis, defineBreakpoints, defineContainer, definePalette, defineStateAxis, kind, onlyVarsOfKind, scheme, seal, set, space, unit, when, } from '@craft-ts/style'; ``` ## The palette `definePalette` takes a group-of-tokens shape where every token carries **both** of its values at once: ```ts export const palette = definePalette({ surface: { page: { light: '#fbfbfd', dark: '#0b0d11' }, raised: { light: '#ffffff', dark: '#151922' }, }, text: { strong: { light: '#111318', dark: '#f2f4f8' }, muted: { light: '#5b6472', dark: '#98a2b3' }, }, accent: { neutral: { light: '#4a5568', dark: '#a6b0c0' }, danger: { light: '#a11b1b', dark: '#ff6b6b' }, }, }); ``` `@craft-ts/style` already exports a `palette` built the same way — the same four groups, with neutral defaults. Spend it as-is to get moving, and call `definePalette` when you want your own colours; the pages that follow use the name `palette` for whichever one is in scope. A token is not a colour string; it is a pair plus a role, and the role comes from the group it sits in (`surface`, `text`, `border`, `accent`). `darkOf(token)` is how a sheet reaches the other side of the pair, and the role travels with it — the dark side of a surface is still a surface. Components never read the palette directly. They read a **theme variable**, and the theme is the single place that decides what a variable holds in light and in dark. That indirection is what makes dark mode one rule instead of one rule per component. ## Axes A component may only vary along an axis you declared. There are four ways to declare one, and the choice is about what drives the variation. ### `defineBreakpoints` — the viewport ```ts export const bp = defineBreakpoints({ sm: at.minInlineSize(unit.rem(30)), md: at.minInlineSize(unit.rem(48)), lg: at.minInlineSize(unit.rem(64)), }); ``` Breakpoints are an **ordered** axis: two points can be compared, which is what lets the matrix reduce by interval instead of by product, and what makes a rule that can never apply detectable. `above(...)` and `below(...)` turn a point into an explicit bound. ### `defineStateAxis` — an attribute you set ```ts /** Drives `data-tone` on the element that carries it. */ export const tone = defineStateAxis('tone', [ 'neutral', 'danger', ] as const); /** Drives `data-size`. */ export const size = defineStateAxis('size', ['sm', 'md', 'lg'] as const); ``` Each point carries the driver that reaches it — here `data-tone='danger'` — so a scenario the matrix enumerates is a scenario a test can actually produce. The attribute-*value* form, rather than one attribute per state, is what makes the states mutually exclusive by construction: an element cannot be two of them at once, so the matrix does not have to be told. ### `defineAxis` — a state axis with a write constraint ```ts // This axis may only ever write colours. A ``-only axis cannot move a // box, so it crosses additively with the axes that do — and the constraint is // checked at the `when` call site, not by reading the emitted CSS afterwards. export const brand = defineAxis('brand', ['acme', 'globex'] as const, { ...onlyVarsOfKind(kind.color), }); ``` `onlyVarsOfKind(kind.color)` says this axis may write `` custom properties and nothing else. An axis that can only write colours cannot move a box, so it crosses **additively** with the axes that do rather than multiplying them. The constraint is checked where it is cheap — at the `when` call site — instead of by reading the emitted CSS afterwards. `defineStateAxis` is `defineAxis` without the options object; it stays a separate name because the unconstrained case is the common one and reads better without them. ### `defineContainer` — the size of a box, not of the window ```ts // Closed at the element that declares the container: nobody above it can change // how wide the box is, so the axis must not travel past it. export const card = defineContainer( { name: 'card', type: 'inline-size' }, { narrow: at.minInlineSize(unit.rem(20)), wide: at.minInlineSize(unit.rem(40)) }, ); ``` A container axis answers "how wide is *my* box", which nobody above the container can change. So it is closed at the element that declares the container: the matrix prunes it there rather than letting every ancestor inherit scenarios it has no way to affect. ### The standard axes `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState` and `descendant` ship with `@craft-ts/style` and need no declaration — they are driven by the user agent or by the element's own state, not by an attribute you own. `scheme` is the one the theme below uses. `interaction` (`hover`, `focus`, `active`, `disabled`) reads the element's own pseudo-classes. Three more read an ARIA attribute instead of a `data-*` one, so the look and what assistive technology announces cannot disagree: | Axis | Opens on | Typical element | | --------------------- | ------------------------- | ----------------------------- | | `ariaCurrent.page` | `[aria-current='page']` | the router's active link | | `ariaCurrent.true` | `[aria-current='true']` | the current item of a list | | `ariaPressed.pressed` | `[aria-pressed='true']` | a toggle button that is on | | `ariaInvalid.true` | `[aria-invalid='true']` | a field whose value was refused | Set the attribute for accessibility; the sheet follows it. A second `data-*` attribute for the same state would be one more thing to keep in sync. ### `axisPoint` — the escape hatch `axisPoint(axis, point, open, driver, extra)` builds a point by hand, for a selector none of the four constructors produce. You give up nothing type-side, but you take on the part the constructors were doing for you: the `driver` must really reach the `open` selector, and nothing checks that for you. ## The theme `cssVars(prefix, specs)` declares the typed custom properties. Each is registered through `@property`, so the browser validates it: assigning a length where a colour belongs paints nothing rather than painting wrong. ```ts // `inherits: true` belongs to theme variables and to nothing else: they are set // once on a wrapper and read by everything below. The default, `false`, bounds // invalidation to the element that both sets and reads the variable. const themed = { inherits: true } as const; export const theme = cssVars('ds', { surface: kind.color(palette.surface.page, themed), ink: kind.color(palette.text.strong, themed), inkMuted: kind.color(palette.text.muted, themed), accent: kind.color(palette.accent.neutral, themed), // Absolute, because `@property` requires a computationally independent // initial value: `1rem` makes the browser drop the registration, silently. gutter: kind.length(unit.px(16), themed), }); export const dsTheme = craftStyles('dsTheme', { root: [ set(theme.surface, palette.surface.page), set(theme.ink, palette.text.strong), set(theme.gutter, space(4)), when(bp.md, [set(theme.gutter, space(6))]), // Dark mode is one rule, here — not one rule per component. when(scheme.dark, [ set(theme.surface, darkOf(palette.surface.page)), set(theme.ink, darkOf(palette.text.strong)), ]), ], }); ``` Two things on that block are worth stopping on. **`inherits: true` belongs to theme variables and to nothing else.** The default is `false`, and that is the right default for a variable an element sets on itself and reads on itself — it bounds invalidation to that element. A theme variable is the opposite case: set once on a wrapper, read by everything below. A non-inheriting theme hands every descendant the initial value instead, which looks exactly like dark mode not working, with no error anywhere. **`kind.length(unit.px(16))`, not `unit.rem(1)`.** `@property` requires a computationally independent initial value; a relative one makes the browser drop the registration entirely, and silently. The theme writes the `rem` value in the rule below. ::: tip One `cssVars` for the theme and for a component The same `cssVars` declares a design system's theme and a single component's styling API — per-instance variants, inheritance, forwarding, runtime values. See [Typed CSS variables](../components/css-variables.md). The deprecated `meta.cssVars` on `craftComponent` is a different, CSS-string mechanism. ::: ## `seal` — closing a tree `seal(node)` is the only place a [context obligation](./obligations.md) becomes an error. Up to that point an unanswered requirement keeps travelling, because an ancestor still has the right to answer it. `seal` is you saying: from here up, nobody else will. Put it at the root of a route, not around the component that raised the requirement — that is the whole point of letting obligations travel. ## Next * [Tokens and typed variables](./tokens.md) — spending what you just declared. * [Axes and the visual matrix](./variants.md) — `when`, and the matrix the axes above generate. * [Context obligations](./obligations.md) — `requires`, `provides`, and `seal`. --- --- url: https://craft-ts.github.io/craft/guide/style/tokens.md --- # Tokens and typed variables Level 1. Useful from the first component, and it does not require anything else in the app to change. ## No value is a string Every value is a **nominal object**, not a branded string: ```ts import { bg, p, palette, space, unit } from '@craft-ts/style'; p(space(4)); // ✅ p(unit.rem(1.5)); // ✅ p('12px'); // ❌ a length is not a string p(`${4}px`); // ❌ not even one of the right shape bg('red'); // ❌ a colour is not a keyword p(palette.text.strong); // ❌ a colour is not a length ``` The shape matters more than it looks. With `string & { __length?: true }` — an *optional* phantom on a primitive base — `'blabla'` stays assignable, every test stays green, and the guarantee written on this page is false. ## The scales are closed `space(7)` does not compile. When a step is missing, add it to the scale; there is no `[17px]` arbitrary-value syntax on purpose. The one way out is marked: ```ts import { unsafeLength } from '@craft-ts/style'; unsafeLength('13px', 'aligns with a legacy image'); ``` It compiles, and it propagates `unproven` to the dependency graph, where the debt is counted. Without this door a blocked agent bypasses the design system entirely; with it unmarked, it bypasses it in silence. ## The property table is generated 477 properties, generated from MDN data — which is what guarantees no keyword was invented. A closed keyword set is a namespace, never a string: ```ts import { display, position } from '@craft-ts/style'; display.inlineFlex; // ✅ display.inlineFlexx; // ❌ Property 'inlineFlexx' does not exist position('sticky'); // ❌ a keyword is not something you pass in ``` Two consequences worth knowing: * **`overflow` is not in the table.** The only road to `overflow-block: auto` is `provides(scrollPort.block)` — see [obligations](./obligations.md). The wrong fix is not discouraged, it cannot be written. * **124 helpers are narrower than CSS.** A grammar alternative the generator cannot close is dropped rather than approximated, so a helper may refuse a form CSS would accept. It can never produce CSS a browser rejects. The list is exported as `NARROWED_PROPERTIES`. A few values the table cannot type on its own have constructors: * `gradient.linear`, `.radial`, `.repeatingLinear`, `.repeatingConic`, written with `bgImage(...)` — several images make several layers, and `bgSize` / `bgPosition` take one inline and one block value; * `uaScheme.light` / `.dark` for `color-scheme`, so native controls follow the theme; * `spanAllColumns` for `grid-column: 1 / -1`; * `pseudo.content.attr(ident('data-x'))` for a `::before` / `::after` whose text is an attribute the template writes. ## Typed custom properties ```ts import { color, cssVars, kind, p, palette, unit } from '@craft-ts/style'; export const v = cssVars('badge', { ink: kind.color(palette.text.strong), pad: kind.length(unit.px(16)), }); color(v.ink); // ✅ the token carries its kind's brand p(v.ink); // ❌ a variable is not a length v.ink.or(space(4)); // ❌ the fallback is typed against the same kind ``` Two rules the browser enforces and the types cannot: **A registered `initial-value` must be computationally independent.** `initial-value: 1rem` makes the whole `@property` rule invalid and the browser drops it *silently* — the variable stops being registered, `var(--x)` resolves to nothing, and whatever reads it computes to zero. `cssVars` refuses a relative unit there and names the fix. **`inherits: false` is the right default, and wrong for a theme.** A variable an element sets and reads on itself should not inherit: it bounds invalidation. A theme variable is the opposite — set once on a wrapper, read by everything below — so pass `{ inherits: true }`. A non-inheriting theme hands every descendant the initial value, which looks exactly like dark mode not working, with no error anywhere. --- --- url: https://craft-ts.github.io/craft/guide/style/foundation.md --- # Global foundation and fonts An app built on `@craft-ts/style` has no `styles.css`. What used to go there falls into three layers, and craft-ts writes the first two for you: | layer | written by | what it holds | | -------------- | ---------------------------- | ------------------------------------------------------------------------ | | `craft.reset` | craft-ts, on by default | a modern reset | | `craft.base` | craft-ts, on by default | colour scheme, focus ring, reduced motion, selection, form accent | | `craft.global` | your app, `craftGlobalStyles` | your theme variables on `:root`, element defaults (`body`, `a`, …) | They come before the component layers. The full order is fixed by the emitter, whatever order your modules are imported in: ```css @layer craft.reset, craft.base, craft.tokens, craft.global, craft.components, craft.variants, craft.overrides; ``` Every layer is named `craft.*`. A third-party stylesheet that arrives unlayered wins over all of them — that is how CSS treats unlayered styles — which is exactly why one should be rare, deliberate and attested. ## The reset On by default. It sets `box-sizing: border-box` everywhere, removes default margins, makes media blocks that never overflow (`max-inline-size: 100%`), lets form controls inherit the document font, balances headings and avoids orphans in paragraphs (`text-wrap`), wraps long words (`overflow-wrap: anywhere`) and stops mobile browsers from inflating text. It is written with the typed vocabulary, like any sheet ([`libs/style/src/lib/global/reset.ts`](https://github.com/craft-ts/craft-ts/blob/main/libs/style/src/lib/global/reset.ts)). Turning it off is a deliberate choice: ```ts craftStyle({ reset: false }); ``` ## The base Also on by default (`base: false` to opt out). It states once, for the whole document, what components used to have to remember one by one: * `color-scheme` follows the `scheme` axis, so scrollbars and form controls turn dark with the page; * every `:focus-visible` gets a visible ring; * scrolling is smooth only for users who did not ask for less motion, and under `prefers-reduced-motion` **every** animation and transition collapses to an instant. The guard is `!important` in the earliest layer, the one place that beats every later layer, so no component can forget it; * `accent-color` and `::selection` come from the theme. The colours and sizes are typed theme variables, exported as `craftBase`: `accent`, `focusRing`, `focusWidth`, `focusOffset`, `selectionBg`, `selectionInk`. Re-theme them from your own global styles (below). ## Your app's globals ```ts import { color, craftBase, craftGlobalStyles, darkOf, definePalette, fontFamily, scheme, set, when, } from '@craft-ts/style'; export const brand = definePalette('brand', { accent: { primary: { light: '#1b5fa1', dark: '#6fb2f0' } }, text: { strong: { light: '#111318', dark: '#f2f4f8' } }, }); craftGlobalStyles('app', { // The foundation's focus ring and form accent, in the app's colours. root: [ set(craftBase.focusRing, brand.accent.primary), set(craftBase.accent, brand.accent.primary), when(scheme.dark, [ set(craftBase.focusRing, darkOf(brand.accent.primary)), set(craftBase.accent, darkOf(brand.accent.primary)), ]), ], elements: { body: [fontFamily(bodyFont), color(brand.text.strong)], }, }); ``` `root` goes on `:root`, `elements` on tag names — the only selectors accepted. Anything narrower than an element belongs to a component sheet. The items are the same as in a sheet: generated properties, `set(...)`, `when(...)`, and `pseudo.*`. `when` on a state axis becomes an attribute on `:root`, which is how a theme toggle is written (`when(theme.dark, [...])` → `:root[data-theme='dark']`). `no-raw-css-value` applies here as everywhere else: no literal reaches a helper. ## Fonts ```ts import { defineFont, googleFont } from '@craft-ts/style'; export const bodyFont = defineFont('body', { family: 'Chivo', source: googleFont({ weights: [400, 600, 700] }), display: 'swap', fallback: 'system-ui', }); ``` `defineFont` replaces the three things a `styles.css` used to carry: * **the `@import url(fonts.googleapis…)`** — the plugin injects a `preconnect` to both Google origins, then the stylesheet preloaded and applied, into `` of `index.html`. An `@import` inside the CSS could only be discovered once the CSS itself had downloaded; * **the `@font-face` blocks** — `localFont({ files: [...] })` emits them, and preloads the `.woff2` files; * **the `* { font-family: … !important }`** — the returned token is a family stack, used once with `fontFamily(bodyFont)` on `body` and inherited from there. Form controls inherit it through the reset. A server renderer that writes `` itself reads the same tags as HTML: ```ts import head from 'virtual:craft-style-head'; ``` ### Fallback without layout shift While the web font loads, the fallback font is shown, and the text jumps when the real one arrives. `adjustFallback` builds a `" Fallback"` face from a local font, resized so both occupy the same space: ```ts defineFont('body', { family: 'Chivo', source: googleFont({ weights: [400, 700] }), fallback: 'system-ui', adjustFallback: { // The web font's metrics, in font units. Capsize publishes them for every // Google font (@capsizecss/metrics). metrics: { unitsPerEm: 1000, ascent: 954, descent: -250, lineGap: 0, xWidthAvg: 505, }, // Optional, Arial by default: `face: { local: 'Helvetica', metrics }`. }, }); ``` `size-adjust` matches the average glyph width, so lines break at the same place; `ascent-override`, `descent-override` and `line-gap-override` keep the line height. ## What this replaces in the ESLint rules `require-focus-visible` and `require-reduced-motion` used to read each component's CSS text to check that a focus ring and a reduced-motion branch were there. The base layer guarantees both for the whole document, so a component no longer has anything to prove. --- --- url: https://craft-ts.github.io/craft/guide/style/pseudo-elements.md --- # Pseudo-elements and animations A component sheet can style what used to need a hand-written selector — `::before`, `::placeholder`, `@keyframes`, `transition` — with the same typed vocabulary as the rest of the sheet. ## Pseudo-elements ```ts import { bg, blockSize, craftStyles, cssString, defineStateAxis, display, inlineSize, palette, pseudo, radii, radius, unit, when, } from '@craft-ts/style'; export const status = defineStateAxis('status', ['failed']); export const indicator = craftStyles('indicator', { root: [ display.inlineFlex, pseudo.before([ // Without `content`, ::before is never generated — a type error here. pseudo.content.empty, display.block, inlineSize(unit.rem(0.5)), blockSize(unit.rem(0.5)), radius(radii.full), bg(palette.accent.info), when(status.failed, [ pseudo.content.text(cssString('⚠')), bg(palette.accent.danger), ]), ]), ], }); ``` `pseudo.before([...])` is an item of a class, and nests like `when(...)`. The emitter always puts the pseudo-element **last** in the selector, where CSS requires it, whatever the nesting order: `when(status.failed, [pseudo.before(...)])` and `pseudo.before([when(status.failed, ...)])` both give `.x[data-status='failed']::before`. Available: `pseudo.before`, `pseudo.after`, `pseudo.placeholder`, `pseudo.marker`, `pseudo.selection`, `pseudo.backdrop`. ### `content` is required, and typed `::before` and `::after` are not generated without a `content`, and nothing they declare applies. Rather than a decoration that silently disappears, it is a type error: ```ts pseudo.before([display.block]); // ~~~~~~~~~ ERROR_a_generated_pseudo_element_needs_content ``` The content itself comes from `pseudo.content`: | helper | CSS | | ---------------------------------------- | ---------------------- | | `pseudo.content.empty` | `content: ""` | | `pseudo.content.none` | `content: none` | | `pseudo.content.text(cssString('→'))` | `content: "→"` | | `pseudo.content.counter(ident('step'))` | `content: counter(step)` | The check reads the top level of the block: a `content` placed only under a `when(...)` leaves the base scenario without one, which is the same bug. ### Pseudo-classes are axes There is no `pseudo.hover`. `:hover`, `:focus-visible` or `:disabled` are **states**, and a state is an axis (`interaction.hover`, `defineStateAxis`). That is what puts it in the variant contract, the visual matrix and the contrast proof — a hand-written `:hover` would be invisible to all three. ## Keyframes and animations ```ts import { animate, easing, keyframes, rotate } from '@craft-ts/style'; export const spin = keyframes('spin', { from: [rotate(unit.deg(0))], to: [rotate(unit.deg(360))], }); export const spinner = craftStyles('spinner', { root: [ ...animate(spin, { duration: unit.ms(800), easing: easing.linear, iterations: 'infinite', }), ], }); ``` `keyframes(name, steps)` returns a **token**; `animate(token, options)` plays it. An animation cannot point at keyframes that do not exist. The steps are `from`, `to` or percentages, and each step holds declarations from the generated table. `animate` writes longhands (`animation-name`, `animation-duration`, …), so a variant can change the duration alone. It is not called `animation` because that name is the generated shorthand helper. ## Transitions ```ts import { prop, transitions } from '@craft-ts/style'; export const button = craftStyles('button', { root: [ ...transitions([prop.backgroundColor, prop.color], { duration: unit.ms(150), easing: easing.easeOut, }), ], }); ``` The properties are named from the table (`prop.backgroundColor`). There is no `all`: `transition: all` animates whatever a later variant happens to change, layout included, and nobody decided that. `easing` holds the keywords (`linear`, `ease`, `easeIn`, `easeOut`, `easeInOut`, `stepStart`, `stepEnd`) and two constructors, `easing.cubicBezier(x1, y1, x2, y2)` and `easing.steps(count, position)`. ## Reduced motion You write none of it. Under `prefers-reduced-motion: reduce`, the [global foundation](./foundation.md) collapses every animation and transition of the document to an instant. --- --- url: https://craft-ts.github.io/craft/guide/style/variants.md --- # Axes and the visual matrix Level 2. Adopted per component, and it is what turns "I think that is all the states" into a list. ## A variant is an axis, not a class name ```ts import { bg, craftStyles, defineStateAxis, palette, set, when, } from '@craft-ts/style'; // `v` is your own sheet's typed variables, `bp` your own breakpoints — see // [Defining a design system](./define.md). import { bp, v } from './foundation.style'; export const tone = defineStateAxis('tone', ['neutral', 'danger']); export const badge = craftStyles('badge', { root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])], }); ``` The template sets **one static class** and a `data-tone` attribute. Nothing concatenates a class at render time, which is what makes the set of states enumerable. The `no-raw-class` rule enforces it in every file. Conjunction is nesting, and only nesting: ```ts import { fontWeight, scheme, when } from '@craft-ts/style'; when(scheme.dark, [when(bp.md, [fontWeight.bold])]); ``` One way to write each thing, so two identical components cannot produce two different contracts. ## Only the points you actually cross `bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes **two** cells, not four. The contract records what the sheet uses, never what the axis offers. An interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` — throws when the sheet is registered, which under the build plugin is a build failure. ## Hover, and every pseudo-class after it ```ts when(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]); ``` `interaction.hover` emits the same `&:hover` rule you would write by hand. What it adds is that the point enters the class's contract — so the matrix enumerates the hovered state, and the [static contrast check](./contrast.md) crosses the colours it writes with the text on top of them. A `:hover` typed into a string emits identical CSS and is invisible to both, which is how a button ends up readable at rest and unreadable under the pointer. `prefer-hover-axis` refuses it. It is a real axis with a real price: it doubles the sheet's matrix, so it has to be in the budget below. Its driver is `{ kind: 'selfState', state: 'hover' }`, and `applyScenario` honours it by asking the page to move a pointer — a dispatched `mouseover` sets no pseudo-class and would capture the base state while looking correct. ## The budget ```ts import { craftStyles } from '@craft-ts/style'; craftStyles('button', { root: [...] }, { axes: [tone, size, interaction] }) ``` An axis outside the budget is a compile error naming it. Without this, an axis added deep in a leaf shows up as a doubled capture bill three levels up and nobody decided that. A declared axis that goes unused is reported, not rejected. ## The matrix ```ts import { visualMatrix, branch } from '@craft-ts/style-testing'; visualMatrix(card); // [{ id: 'base', … }, { id: 'viewport=md', … }] ``` It takes **sheets**, not a component: a component's classes are only knowable by rendering it, and a matrix that silently missed a child's sheet would be the worst possible outcome. Identifiers name only the axes away from `base`, so adding an axis elsewhere in the app does not invalidate every baseline in the suite. Two reductions are applied, and both are exactly true rather than probably true: * **A branch adds, it does not multiply.** The two sides of an `ifNode` are never on screen together, so declare it — `branch('footer', footerSheet)` — and the absent side stops carrying the footer's axes. * **A container axis stops at its owner.** An ancestor cannot change how wide that box is, so only the component naming the container keeps the axis. Nothing else is reduced. A coverage that claims to be complete without being complete is worse than no coverage. --- --- url: https://craft-ts.github.io/craft/guide/style/obligations.md --- # Context obligations Level 3. **Adopted per whole route**, and that granularity is the whole point of this page. ## What it is for A sticky element needs a scroll port. A scroll-state query needs an element declaring the container. Get either wrong and nothing errors — the component simply never appears, or sticks to the wrong box, and you find out in a screenshot weeks later. ```ts import { craftStyles, display, insetBlockEnd, position, provides, requires, scrollPort, space, } from '@craft-ts/style'; export const backToTop = craftStyles('backToTop', { anchor: [ requires(scrollPort.block), position.sticky, insetBlockEnd(space(4)), ], }); export const shell = craftStyles('appShell', { main: [provides(scrollPort.block), display.block], }); ``` `requires` is attached to the **class**, not the sheet, so the error names a rule rather than a file. And `provides(...)` returns the CSS effect **and** the discharge in the same object: since `overflow` is not in the property table, this is the only road to `overflow-block: auto`. Claiming to provide without laying the CSS is not something anyone can write. ## Where it becomes an error Nowhere, until a component seals: ```ts import { craftComponent } from '@craft-ts/component'; craftComponent('AppShell', { seals: [true] }, factory, template); ``` Until then the requirement **travels** — an ancestor still has the right to answer it, and complaining early would be wrong. Sealing says "from here up, nobody will". Remove the provider and the typecheck fails: > `ERROR_unmet_context_requirement: "'scrollPort.block' is required by this > subtree and nothing above it provides one. declare it on the layout component > that owns the scrollable area. An overflow on the direct parent would create a > second scroll port, and the sticky element would stick to the wrong container."` What is missing, where to put it, and what the obvious wrong fix would do. ## Why the granularity is the route The requirement is carried by the type of the render tree. It crosses a component boundary only where the tree is typed all the way through. One component in the path that hands back a loosely typed subtree, and the demand stops travelling — silently, because nothing is wrong with *that* component. So level 3 is not something you get for the components you migrated. You get it for a route once the route is migrated, and not before. The dependency graph reports which components are not covered rather than reporting a clean bill: ```ts import { extractionGaps, undischargedObligations } from '@craft-ts/dev-tools'; extractionGaps(graph); // components no sheet is known to style undischargedObligations(graph); // required somewhere, discharged nowhere ``` ## The marked way out ```ts import { scrollPort, unsafeAssume } from '@craft-ts/style'; unsafeAssume(scrollPort.block, 'the host page owns the scroll port'); ``` Discharges without laying the CSS, for the cases the model cannot see — a shell owned by someone else. It propagates `unproven`, so the graph counts it as debt. An escape hatch that did not bubble up would be a design bug, not a convenience. --- --- url: https://craft-ts.github.io/craft/guide/style/testing.md --- # Testing what you built The matrix says what the states are. This page is how you look at them. ## Drivers Every axis point carries the driver that reaches it. An axis without one would be worse than a missing axis: the matrix would enumerate scenarios nothing can produce and render identical captures — false coverage rather than none. ```ts import { applyScenario, visualMatrix } from '@craft-ts/style-testing'; for (const scenario of visualMatrix(card)) { await applyScenario(page, scenario); await expect(page).toHaveScreenshot(`${scenario.id}.png`); } ``` `page` is described structurally, so Playwright is not a dependency — a Playwright `Page` matches the shape and is passed unchanged. Application order is fixed in one place (`orderedDrivers`): emulation and viewport first because they relayout, container width next, DOM state after, scrolling last. Applying them in declaration order instead would make a capture depend on which axis someone wrote first. ## Exhaustiveness ```ts import { assertExhaustiveVisualMatrix, baselinesIn, visualMatrix, } from '@craft-ts/style-testing'; assertExhaustiveVisualMatrix(visualMatrix(card), baselinesIn(files)); ``` It fails in **both** directions. A baseline nothing produces any more matters as much as a missing one: it is a state the component used to have, and whoever opens the folder still counts it as covered. The check is post-inference on purpose. A self-referential constraint on the component's own declaration resolves the union to `never` and passes while checking nothing — the same shape as `assertExhaustiveRouteExceptions`. ## Content cases The matrix covers *conditions*, not *data* — and the eighty-character title, the empty list and the seven-figure price are what break layouts most often. No type can derive them, so they are declared: ```ts import { contentCases, visualMatrix } from '@craft-ts/style-testing'; contentCases(visualMatrix(card), { longTitle: 'x'.repeat(80), empty: '' }); ``` A data case is rendered at one point of each axis, except on the axes that change the space available — viewport and container — where the crossing is complete. A long title behaves differently at two widths; it does not behave differently in two colour schemes. ## What the graph adds The style dump joins the dependency graph, so the questions that cross layers have answers. `graph` is the dependency graph the dev tools build; the dump half of it is what [`craftStyle({ dumpPath })`](./setup.md#what-the-plugin-produces) writes, so these queries return nothing useful until that option is set. ```ts import { danglingVars, impactedClasses, matrixSizeByComponent, unproven, varsWrittenBy, } from '@craft-ts/dev-tools'; matrixSizeByComponent(graph); // what a component costs to capture impactedClasses(graph, ['--ds-accent']); // what one token change can be seen in varsWrittenBy(graph); // proves a colour axis only repaints danglingVars(graph); // declared and never read unproven(graph); // every escape hatch, with its reason ``` `impactedClasses` is the one that pays for the visual CI: changing a colour should recapture what reaches it, not the whole suite. ### The same questions, from an agent Three of these are exposed as MCP tools by `@craft-ts/mcp`, so an agent can ask them without writing a script: | MCP tool | answers | | -------------- | ----------------------------------------------------------------------- | | `style_impact` | which classes and components one token or variable change is visible in | | `style_matrix` | how many scenarios each component costs to capture | | `style_debt` | every escape hatch — `unsafeLength`, `unsafeAssume` — with its reason | They read the same dump. A `style_matrix` that answers with nothing is the signature of a missing `dumpPath`, and the server says so. --- --- url: https://craft-ts.github.io/craft/guide/style/contrast.md --- # Text contrast, proven without a browser `npm run style:check` reads your sheets and your templates and answers one question, for every element it can prove holds text, in every state your axes can produce: > is this text readable on the background it is actually painted on? It is WCAG 2.2 §1.4.3 level AA — `4.5:1` for normal text, `3:1` for large text — and it needs no browser, no screenshot and no Playwright run. ::: warning What this is not This proves **text contrast**, in a declared subset of CSS. It is not an accessibility audit, and a green run is not a claim that your application is accessible. Focus order, names, roles, motion, target size and everything else are elsewhere. Read [the coverage contract](#the-coverage-contract) before you put a badge on it. ::: ## Running it ```bash npm run style:check ``` In a project generated with typed CSS this is already wired: it builds once so the style plugin writes `.craft/style-graph.json`, then analyses that dump together with your TypeScript program. By hand, on an existing project: ```bash npx craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json ``` `--json` gives a stable machine-readable report for CI. Two runs on unchanged sources produce byte-identical output. Unlike `--style-matrix` and `--style-debt`, this command **does** build the TypeScript program. It has to: a contrast proof needs to know which element carries which class and what sits above it, and no style dump has ever seen a template. ## Reading a failure ```text contrast/fail route: /checkout component: SubmitButton element: button.root scenario: interaction.hover=active+tone=warning foreground: ui.text.onAccent #ffffff (dsButton-root → --dsButton-ink (initial)) background: ui.accent.warning.dark #f5b544 (button.root: dsButton-root → --dsButton-bg) font: 14px / 600 (normal text) ratio: 1.81:1 required: 4.5:1 ``` Every line is there because a report missing it sends you to the wrong file: * **scenario** — the exact combination. Not "the warning button": the warning button *under the pointer*, which is often the only failing one. * **foreground / background** — the token name first, then the value, then the chain that produced it. `ui.accent.warning.dark` tells you which token to change; `dsButton-root → --dsButton-bg` tells you which rule put it there. * **font** — with the threshold it earned. See [large text](#which-threshold-applies). Rows that resolve to the same answer are folded together and list the scenarios they stand for under `also in:`, so a five-tone button does not print five identical lines. ## The two halves, and why only one of them fails a build | | `--palette-contrast` | `--style-contrast` | |---|---|---| | reads | the dump alone | the dump **and** the templates | | answers | every pair your palette can express | every pair an element is actually painted in | | verdict | informative | **blocking** | A palette of twenty tokens has hundreds of pairs and an application renders a few dozen of them. Failing a build on `ui.text.onAccent` over `ui.surface.page` — white on white, and a combination no element uses — trains people to switch the check off, and takes the real failures with it. So the matrix is a table you read while designing, and the analysis is the gate. When you pass the analysis's results to `paletteContrastMatrix`, each pair also gets a `usedBy` list of the elements that render it; without them the field is **absent** rather than empty, because "nobody looked" and "the analysis looked and found nowhere" are different answers. ## Which threshold applies * large if `font-size >= 24px`; * large if `font-size >= 18.5px` **and** the weight is at least `700`; * normal otherwise. Two consequences worth knowing before you argue with a report: * `text.lg` is `1.125rem` — **18px** — so a bold title at that size is *normal* text and needs `4.5:1`. It misses the large threshold by half a pixel. * `600` is not bold. WCAG says "bold" without a number and CSS says bold is 700; reading `600` as bold would lower a threshold on an ambiguity, which is the wrong side to err on. `rem` becomes pixels against a 16px root. If your page sets a different root size outside CraftTS, say so — nothing in the dump can know. The ratio is **never rounded before it is compared**. `4.4999:1` fails a 4.5 threshold, even though the report prints it as `4.49:1`. ## How colour is resolved ### `color` is inherited The analysis walks the element's ancestor chain outside-in, exactly as the cascade does. A paragraph that sets no colour of its own takes the one from its card, or from the theme wrapper above it. ### The background is the first opaque thing underneath Starting at the element and walking outwards: * an element that paints nothing is transparent, and the search continues; * the first opaque colour wins; * an element that paints something the model cannot read — an image, a semi-transparent fill, anything behind an `opacity` — **stops** the search and produces `indeterminate`, because whatever is behind it is no longer what the text is composited against. If nothing in the chain paints, that is `unknown-background`. It is not "assume white": a white assumption is right on one theme and wrong on the other. ### Variables resolve the way `@property` says they do * a registered variable nobody wrote resolves to its **registered initial value**, not to the `var()` fallback; * `inherits: false` really does not cross into a child — a theme variable set on a wrapper reaches the button, a component variable does not. That last one is the trap the design system is full of, and getting it wrong would prove the wrong colour for every component under a themed wrapper. ### The cascade is replayed, not approximated Three tie-breaks, in the browser's order: 1. **Layer.** Unconditional atoms land in `components`, conditional ones in `variants`, so every variant beats every base rule. 2. **Specificity**, inside `variants`. `&[data-tone='warning']:hover` has one more selector fragment than `&[data-tone='warning']`, so the hovered fill wins — whatever the source order. A media query contributes nothing. 3. **Source order**, last, which is atomic class-name order because that is how the emitter sorts the layer. Getting the second wrong is the interesting failure: a solver would resolve a tone-plus-hover button to its resting fill and report `pass` on the state that fails. ## Hover is an axis ```ts when(tone.warning, [ set(buttonVars.bg, ui.accent.warning), when(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]), ]); ``` `interaction.hover` emits the same `:hover` rule a hand-written selector would. What it adds is that the point lands in the class's variant contract — so the visual matrix captures the hovered state, and this analysis crosses the colours it writes with the text that sits on them. A `:hover` typed into a string is invisible to both. That is how a button ends up readable at rest and unreadable under the pointer: the one state nobody screenshots. The `prefer-hover-axis` lint rule refuses it. The axis carries its own driver (`{ kind: 'selfState', state: 'hover' }`), so a capture of the hovered state is something a harness can actually produce. `applyScenario` asks the page to move a real pointer and throws if it cannot — dispatching a `mouseover` event would fire listeners and leave the pseudo-class untouched, producing a screenshot of the base state that passes forever. The cost is real and it is a decision: hover doubled the demo button's matrix from 18 scenarios to 36. That is why the axis has to be in the sheet's budget. ## Naming your palette ```ts export const ui = definePalette('ui', { text: { onAccent: { light: '#ffffff', dark: '#0b0d11' } }, accent: { warning: { light: '#8a5a00', dark: '#f5b544' } }, }); ``` The name travels with every colour, through variables and `darkOf()`, all the way into the report. `definePalette(spec)` without a name still works and still carries the group and the token — you get `(unnamed).accent.warning`, which points at the right entry and asks to be named. Write hovered and pressed fills as **tokens**, not as a `darken()` at the use site. A function hides the resulting colour from the palette, and the palette is where the contrast question gets settled once instead of per component. ## The coverage contract ### Covered in v1 * opaque colours in hexadecimal or `rgb()`/`rgba()`; * inherited `color`; * `background-color`, local or seen through transparent ancestors; * CraftTS variables declared with `cssVars()`, their initial values, their conditional writes and their `var()` fallbacks; * constant classes from `craftStyles()`; * light and dark themes; * every finite state and size axis, `interaction.hover` included; * static and dynamic text, wherever the element can be proven to hold text; * a component evaluated once per surface it is rendered on. ### Seen but not solvable in v1 When one of these constructs reaches the graph, it produces `indeterminate` with its reason — never a pass: * images and gradients recorded as `background-image`; * an `opacity` below 1 on the text or an element behind it; * semi-transparent colours, which would need compositing; * a dynamic class the template graph cannot resolve; * an unknown foreground, background or font size; * a scenario or render context that exceeds the configured analysis limit. ### Outside the observable boundary External stylesheets, inline styles outside CraftTS, `canvas`, text inside SVG, generated pseudo-element content, filters and blend modes do not necessarily enter the typed style dump. The analyser cannot emit an `indeterminate` for a declaration it never receives. The `typedCss` ESLint preset guards the raw component styles it can see, but this is not a general CSS crawler. The large-text threshold is calculated from CSS pixels and weight. The analyser does not inspect the script, font face or cap height, so it does not detect CJK or unusual font geometry and does not emit a CJK-specific diagnostic. Treat those surfaces as outside the v1 proof unless their typography convention has been validated separately. ### What `indeterminate` means, and why it fails by default | reason | what happened | |---|---| | `unknown-foreground` | nothing readable sets the text colour | | `unknown-background` | nothing in the chain paints an opaque surface | | `unknown-font-size` | the size is not a length this can turn into pixels | | `unsupported-background` | an image, a gradient, a blend, or an alpha | | `dynamic-style` | the class is assembled at runtime | | `incomplete-render-context` | the component is rendered somewhere unanalysed | **Indeterminate results fail the run.** `--allow-indeterminate` downgrades them to warnings, and you have to type it. A check whose default treats "I could not tell" as "fine" reports a clean bill on the half of the application it understood — and that half is exactly where the gradients and the runtime colours live. A report with **no violations and open indeterminates is not a proof.** The summary prints all three counts for that reason: ```text Text contrast: 41 pass, 0 fail, 3 indeterminate (44 checked). ``` Zero checked fails even with `--allow-indeterminate`: it nearly always means the dump and the program describe different applications, and there is no result to review or waive. ## Fixing a violation 1. **Read the scenario.** If only the hovered or only the dark row fails, the fix belongs to that one rule, not to the token everything uses. 2. **Read the token names.** A pair that fails in several places is a palette decision — change the token once, and `usedBy` tells you what moves. 3. **Change the token, not the call site.** A local override is a colour the palette no longer describes, and the next component repeats the bug. 4. **If the text is genuinely large**, check that the sheet says so. 18px bold is normal text; 22px bold is large. 5. **Re-run.** The report is deterministic, so a diff of two JSON runs shows exactly what your change moved. ## Clearing an indeterminate You have two honest moves, and inventing a colour is not one of them. * **Bring the surface into the model.** A `background-color` set in raw CSS is the common case; `no-unmodelled-text-color` points at it. * **Give the text a surface it can be measured against.** Text over a hero image has no ratio because it has no single background — put it on a panel, or accept that it cannot be proven. If a surface lives entirely outside the observable graph, it will not create an indeterminate to clear. You may add its path to the `no-unmodelled-text-color` rule's `uncovered` option as an explicit lint exemption. This does **not** add a row to the contrast report. Keep the exclusion visible in review and do not describe the strict check as covering it. ## Migrating an existing project You do not need to recreate the application or add Playwright. Align the installed `@craft-ts/*` packages on the same current version; the contrast CLI is provided by `@craft-ts/dev-tools`, while `@craft-ts/style-testing` is only needed for visual scenarios and attestation. 1. Name each palette: `definePalette('ui', spec)`. Nothing else changes. 2. Turn hand-written `:hover` rules into `when(interaction.hover, …)` and add `interaction` to those sheets' budgets. `prefer-hover-axis` finds them. 3. Enable `craftRules.configs.typedCss.rules` in ESLint. It finds raw hover and contrast properties in component styles that would otherwise be invisible. 4. Add `dumpPath: '.craft/style-graph.json'` to `craftStyle()` in `vite.config.ts` and keep `virtual:craft-style.css` imported at app entry. 5. Make `style:check` build the app, then run the strict analysis: ```bash craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json ``` 6. Run that command with `--allow-indeterminate` **once**, to inventory the visible gaps. Close them and remove the flag before making the check a CI gate. A lint `uncovered` exemption remains outside the proof. --- --- url: https://craft-ts.github.io/craft/guide/style/attestation.md --- # Attestation: a judgement that survives a refactor A snapshot suite records *what the output was*. Renaming a local variable changes no pixel, and yet the whole suite asks to be looked at again — so people run `--update-snapshots`, and the file that was supposed to record a human decision records nothing at all. This records something else: > **A person looked at this output and judged it correct, and that judgement > holds for as long as the code producing it has not moved.** ## Two caches, never one Two questions look alike and are not: | question | keyed on | a wrong answer costs | | ------------------------------ | --------------------------- | -------------------- | | should this be re-run? | fingerprint of a code slice | CPU | | should a human be asked again? | hash of the evidence | somebody's afternoon | From which the rule the whole design rests on: **when the code changes and the evidence does not, the attestation carries itself forward**, marked `renewed`, with a note saying "code changed, output unchanged". That is also why the code fingerprint is allowed to be *cautious*. A slice that is too wide only costs a re-run. Only a slice that is too narrow is dangerous — it misses a regression in silence, and nobody is ever asked about it again. | state | meaning | | --------- | --------------------------------------------- | | `current` | the fingerprint has not moved: nothing to do | | `renewed` | code moved, output did not: carried, no human | | `review` | the output differs: a human has to look | | `missing` | never attested | ## The evidence is a digest, not a picture What a person judges is the **layout digest**: boxes rounded to the half pixel, intrinsic sizes, a closed list of computed styles, and a set of discrete facts — line counts, column counts, what wraps, what clips, what scrolls, what overlaps. Three things follow, and each is why the digest exists rather than a screenshot: * it is **diffable**. `.card padding 8→12` is a sentence a reviewer reads in a second; two images are not, and a reviewer who cannot see what changed approves everything. * it is **assertable**. Overflow, truncation, overlap and contrast are comparisons of numbers, so they are **failures**, not queue items — nobody is asked, and the message names the node and the pixel count. * it is **stable**. Anti-aliasing and font hinting move pixels without moving layout; under an image comparison every one of those is a review item. The PNG is still kept, in the content-addressed store, for the human. It is a review aid, never the reference. ```ts import { assertNoLayoutViolations, collectLayoutDigest, makeDeterministic, } from '@craft-ts/style-testing'; await makeDeterministic(page); await page.goto('/users'); const digest = await collectLayoutDigest(page, { root: '[data-testid=userCard]', }); assertNoLayoutViolations(digest, { scenario: 'locale=de-DE' }); // → userCard/title hides 34px of "Benutzerkontoeinstellungen". ``` ## Determinism is a feature, not hygiene It carries two independent mechanisms. The **carry-forward** reads "the code moved, the output did not"; a wobbling render never produces the same output twice, the queue fills with changes nobody made, and people start stamping. The **bisection** reads a discrete signature as a function of one parameter; a wobble manufactures thresholds that do not exist. `makeDeterministic` freezes the clock, seeds `Math.random`, kills animations, transitions and the caret, and refuses every network request by default. A hundred consecutive renders of one scenario must produce a hundred identical digests before anything else is worth building: ```ts await assertDeterministic( async () => JSON.stringify(await digestOf(page)), 100, ); ``` ## Where it tips over A content axis is continuous and a layout does not care about most of it. What it has are **thresholds**. `findTransitions` samples a coarse grid — word boundaries, digit-count changes — and bisects only inside the intervals where the discrete signature actually moved. Then the report that arrives *before* the bug: ``` userCard/title: 1 → 2 lines at 34 characters. Today's German string is 33. Margin: 1 (3%), below 15%. ``` Nothing is broken. That is the point: it fails in CI, with no human and no pixel, on the translation nobody has written yet. ```ts const search = await findTransitions(signatureAt, { axis: 'title', min: 1, max: 80, }); assertMargins([marginOf(search, longest.longestLength)]); ``` A bisection is not exactly true — it finds the thresholds that exist between the points it looked at. So the sample count goes into the attestation as an **assumption**, and an attestation never says "validated"; it says "validated, under this assumption". A changed assumption sends the subject back to review rather than quietly becoming a lie. ## Translation as a source of axes The catalogue is a TypeScript value, which makes two of these exact: * **the longest locale** for a screen is a computation over the keys that screen uses. One axis point, and the right one — where "test it in German" is only an approximation. * **the plural categories** are already declared and already checked exhaustive per locale. They are axis points by construction. The **pseudo-locale** is the approximation, and it is the one that finds the *future* case: 40% longer, `[[bracketed]]` so truncation is visible, every letter accented so an un-externalised string stands out. ```ts import { longestLocale, pseudoCatalog, findHardCodedText, } from '@craft-ts/i18n/testing'; longestLocale([en, de, ja], usedKeys); // → { id: 'de-DE', longestKey: 'account.settings' } findHardCodedText(visibleStrings); // → ['Submit'] ← never went through the catalogue ``` Two pressures, opposite failure modes, kept apart throughout: a rising `min-content` (an unbreakable word, a URL, a long number) stops a column shrinking; a rising `max-content` (a long but breakable sentence) steals width from its siblings in an `auto` track. A long sentence with spaces in it usually does not move `min-content` at all. ## The command line Capture a real route into a portable report, then let the CLI derive every fingerprint from the current dependency graph: ```sh npm run attest:visual:capture npm run attest:visual:status ``` The capture starts the demo server when needed and writes the report, PNG screenshots, and frozen `.snapshot.html` documents to `.craft/runs/`. The report contains repository-relative graph node ids, digests and screenshot paths. It deliberately contains no code fingerprint: accepting a fingerprint from an old browser run could keep a stale slice current forever. Reports, screenshots and frozen documents are regenerable and ignored; the ledger is not. To choose another report path, set `CRAFT_VISUAL_REPORT` on both commands: ```sh CRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:capture CRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:status ``` ```sh npm run attest:visual:review npx tsx libs/cli/src/bin/craft-ts.ts attest why 'visual:userCard#viewport=md' npx tsx libs/cli/src/bin/craft-ts.ts attest renew \ --subject 'visual:userCard#viewport=md' --verdict ok npx tsx libs/cli/src/bin/craft-ts.ts attest review \ --kind visual \ --report .craft/runs/design-system.json \ --tsconfig apps/demo/tsconfig.graph.json npx tsx libs/cli/src/bin/craft-ts.ts attest unwatched ``` For the unified review surface, use the DevTool. It combines visual captures and template obligations in one queue: ```sh npm run attest:devtools ``` ### The reviewer reviews itself The review application can use the same mechanism on its own UI. It runs in two successive sessions so the queue cannot change while it is being captured: the first instance renders a deterministic fixture queue and freezes representative states; the second instance reviews that visual report together with template obligations derived from the review application's own CraftTS graph. ```sh npm run attest:review-app:capture npm run attest:review-app:status npm run attest:review-app:review ``` The capture includes the review page's happy path at mobile and desktop sizes, the review queue in dark French, the regeneration confirmation, the visual-test inventory, and the template-obligation inventory. Every portable snapshot is replayed immediately and must reproduce the live layout digest. The first run reports missing decisions until a reviewer explicitly accepts or rejects them. Later unchanged evidence is carried forward by the usual ledger. The review sidebar also offers **Regenerate all evidence**. It opens a confirmation describing the current scope and whether previous decisions exist. Regeneration replaces the report, screenshots, and frozen documents, then re-reads the graph and rebuilds the queue. It never clears the ledger: unchanged evidence stays current and only new or changed evidence returns to a reviewer. Any unsaved reason on the open card is discarded. This control is shown only when the CLI session explicitly names an npm script: ```sh craft-ts attest devtools \ --report .craft/runs/project.json \ --tsconfig apps/project/tsconfig.graph.json \ --regenerate-script attest:project:capture ``` Only an npm script name is accepted, not an arbitrary shell command. The script must recreate the report supplied to `--report`; a failed run preserves the existing queue. After rejecting views with comments, use **Prepare Codex iteration** in the review sidebar. It generates, next to the report, a readable `.review-feedback.md`, a structured `.review-feedback.json`, and a copyable `.codex-prompt.md`. The prompt contains the project root, report, ledger, evidence store, graph `tsconfig`, capture script, source file paths, scenarios, measured changes, comments and any digest nodes pointed to by the reviewer. Only the latest `rejected` cards are included. Because these paths come from the CLI session, `apps/demo` and the review application's self-attestation resolve to different, correct project contexts. ### One happy path for every page Application-level coverage is declared once and expanded into mobile and desktop captures by default: ```ts import { defineHappyPathHttpMocks, defineVisualAppConfig, visualAppHappyPaths, } from '@craft-ts/style-testing'; export const homeHappyPath = defineHappyPathHttpMocks( 'home-page.happy-path.ts', { 'GET /api/users': { response: [{ id: '42', name: 'Ada' }] }, }, ); export const visualTestConfig = defineVisualAppConfig({ pages: [ { id: 'home', route: '', url: '/', component: 'component:src/app/home-page.ts:HomePage', mocks: homeHappyPath, }, ], }); for (const scenario of visualAppHappyPaths(visualTestConfig)) { test(scenario.id, async ({ page }) => { await page.setViewportSize(scenario.viewport); await page.goto(scenario.page.url); // Install scenario.page.mocks, wait for the happy UI, then collectCapture. }); } ``` Without an explicit `viewports` value, CraftTS uses `mobile: 390x844` and `desktop: 1440x1000`. Keep each response dataset in a sibling `*.happy-path.ts` file. `matchHappyPathHttpRequest` turns that dataset into a request match suitable for `page.route`; when a `craftRoutes` registry is available, wrap its exhaustive, response-typed `mockHttpRequestForRoute` result with `defineRouteHappyPathHttpMocks('page.happy-path.ts', routeMock)`. Add `assertVisualHappyPathArchitecture(graph.graph, visualTestConfig)` to the application architecture suite. It fails when a routed page, a required viewport, or a Craft HTTP endpoint has no successful happy-path fixture. The fixture feeds a deterministic test environment only; application code keeps all remote work inside its `query`, `mutation`, or `asyncProcess` loader. Set `CRAFT_REVIEW_APP_REPORT` to the same path on all three commands to relocate the default `.craft/runs/review-app.json` report. The implementation notes and the exact workflow live in `libs/review-attestation/attestation-app/README.md` in the repository. Template obligations do not need a Playwright report. They are derived from the current graph and their canonical proof objects are written to `.craft/evidence/`: ```sh npm run attest:templates:status npx tsx libs/cli/src/bin/craft-ts.ts attest review \ --kind template \ --tsconfig apps/demo/tsconfig.graph.json ``` Two of these carry the rest. **`why`** names the graph nodes that moved inside the subject's slice, and when a person last actually looked at it. A review that cannot answer "why am I being asked this?" is a review that gets stamped. **`unwatched`** lists the nodes that moved and belong to no attested subject — *what changed while nobody was looking*. It falls out of the machinery for free. `renew --all` is allowed and is **marked** as a bulk renewal in every attestation it writes, and `status` counts them. A bulk renewal that left no trace would turn the register into a rubber stamp, which is worse than having no register. ## What a reviewer is shown Two artefacts, and the reviewer switches between them. **The frozen page** is the render itself: the DOM, the styles, and the form state, serialised at the moment the digest was taken. It replays as a real document — real boxes, real `:hover` — which is what lets someone click an element and name it instead of clicking a pixel and hoping. Freezing means more than serialising the DOM. Craft injects its styles through `adoptedStyleSheets`, which `outerHTML` cannot see at all. And keeping the stylesheets verbatim would leave every `@media` to be re-evaluated against the *reviewer's* window: on the demo's route the two conditions in play are `(min-width: 48rem)` and `(prefers-color-scheme: dark)` — exactly the two axes of the matrix — so four scenarios would collapse into whatever that laptop said. Media and supports are therefore evaluated at capture time and their winning branch inlined. Container queries are left alone, because they ask about the page's own layout, which the replay reproduces. The snapshot carries **no script**. Inertness is a property of the artefact, not a guard that has to hold: nothing to block, nothing to leak, no `craftMethod` firing on a stray click. The review application does its interactive work from the parent frame, reaching into a same-origin iframe. **The screenshot** is the fallback, and the check on the checker: the digest is blind to anything that does not move a box or a listed style, so a swapped background or a wrong icon passes every automated test and is obvious to an eye. ### The replay is checked, not trusted Before anything is drawn on it, the replay is re-measured with the collector that produced the evidence and compared against the attested digest. A missing font, a media query left conditional, a stylesheet that could be neither read nor fetched — each produces a document that looks plausible and measures differently, and a reviewer would judge it without ever knowing. When it does not match, the card **moves the reviewer to the screenshot by itself** and says why in the same sentence, and the verdict is recorded as `degraded`: judging a photograph and judging the document are different claims. The choice is a fallback, not a lock — asking for the page brings it back, still labelled for what it is. The message names the cause, not its symptoms. A subject the frozen page does not contain reported "36 attested node(s) are absent" followed by forty addresses beginning `html/head/meta`: every consequence of one fact, and none of them stating it. It now reads > The frozen page has no `.design-system-host` in it, so what it shows is not > this component. That happens when the stored snapshot is older than the report > it is paired with, or when the component's root selector changed after it was > captured. which is the same finding with the reviewer's next move in it. Two things the check caught while it was being built, which is what it is for: a marker stylesheet that set `position: relative` on the attested root and moved the tree it was supposed to annotate, and a 1px border on the frame, which is subtracted from the viewport inside it and made every measurement 2px narrow. ## Knowing what is actually being judged A capture shows the whole page — shell, navigation, neighbours — because a component has to be judged in the frame it sits in. So the reviewer has to be able to tell the subject from the decor, or a remark lands on a card that does not cover it. The digest answers this exactly: its paths **are** the attested set. Three tiers follow, and all three come from data that already exists: The screen is laid out in the order the work happens: the queue on the left, the evidence in the middle, the verdict on the right, where it stays in place while a long capture is scrolled. The surface speaks English and French, and follows the system's light or dark preference until the reviewer chooses otherwise — both controls sit in the sidebar, and both are applied before the first paint rather than corrected a frame later. The French dictionary is typed as the English one, so a message added on one side and forgotten on the other does not compile. Neither reaches the frozen page. It is a render that was captured, not an interface: translating it, or repainting its ground, would make it something other than what was measured. Enforcing that turned up a fidelity bug the tool had been hiding by being permanently dark — a page paints its own colours, but not the canvas underneath, and that comes from `color-scheme`, which was the *reviewer's* preference. A component captured on white came back on black for anyone whose machine asks for dark. The replay now declares the scheme its capture was taken in. | tier | source | shown as | | -------- | ------------------------------ | ----------------------------------------- | | changed | the paths in the readable diff | outlined, and the reason the card is here | | attested | the digest's own paths | selectable, highlighted on hover | | decor | everything else | dimmed, never removed | The outlines carry a legend, drawn from the same object that paints them — a key that keeps its own copy of a colour is a key that will one day name the wrong one. Entries for tiers this card has none of are not shown, so the legend describes the page in front of the reviewer rather than the system in general. Selection is a set, not a node. Ctrl-click (cmd on a Mac) adds one, and dragging a box takes everything it touches; the count is stated beside the reason field that is about to name them. A remark covering a row of buttons was otherwise the same sentence retyped once per button, which is also how a queue fills with findings nobody can group afterwards. The band is drawn beside the frame and never inside it: adding an element to the frozen document would break the only claim it makes. ### One reason, several complaints A rejection is rarely about one thing. Right-clicking a selection drops a reference into the reason **where the reviewer is typing**: > The title is cut at 34px in German. `[#1: 2 nodes]` And the row below > overflows its box. `[#2: 5 nodes]` The reason stays one piece of prose, and each reference carries the text written since the one before it — so the second complaint is filed against the second group and not, as a single note against every node would have it, against all seven. Selecting *is* referencing: there is no second gesture. Pointing at part of the page drops the reference straight into the reason, and refining the selection edits that same reference rather than adding another — a click followed by a ctrl-click leaves one saying "2 nodes", not a stale "1 node" beside it. Typing ends the session: the reference belongs to a sentence now, and the next selection starts its own. Emptying the selection takes the reference back out, because a reference to nothing is worse than none. The field is a `contenteditable`, not a `textarea`, so a reference is an element rather than the literal characters `[#1: 2 nodes]`: hovering it lists the addresses it stands for **and paints those nodes in the frozen page**, dashed rather than solid so it cannot be mistaken for the selection. That is the whole reason for the swap. As text, the answer to "which nodes is this one about" had to live in a list somewhere else on the page, and a reviewer reading a sentence had to leave it to find out. The plain text is still the model — everything downstream reads the serialised string — so the chips are a rendering of the reason and never a second version of it. Two things that swap broke, both worth stating because neither is obvious. The field is not scrollable: a clipping context cuts the tooltip off any reference on the first line, and which nodes a reference covers must not depend on where in the sentence it was written. And the keyboard shortcuts had to learn about it — the guard knew `input`, `textarea` and `select`, so typing "And the row is cut" pressed `a`, Accept, and filed a verdict the reviewer never reached. Position settles that, not punctuation. The first rule tried was "the sentence the token stands in", and it was wrong for the way people write: the complaint is typed, ended, and *then* the group is pointed at, so the caret is past the full stop and the token opens the next sentence rather than closing its own. Referencing first and explaining after reads the other way round, so a group with nothing before it takes what follows. The tokens are scaffolding. What is recorded is the prose with them removed, plus the addresses each one pointed at — and the text remains the only state: deleting a reference deletes it, with no second list left holding a claim the reason no longer makes. And the mistake is made unrecordable rather than merely discouraged. A rejection carries the path of the node it is about, so the server can refuse one that names something this subject does not attest: > `demo-nav/toggle` is not attested by this subject. File the remark on the card > that covers it. ### Attested is not the same as looked at The capture also records the gap, because it is large. On the demo's route: **36 nodes attested, 21 off screen, 1 covered** by the page's own fixed button. An attestation that stayed quiet about that would claim a coverage it does not have, so the card states it and the screenshot draws the line where the viewport ended. The verdict buttons carry what they *do*. Three of the five are accepted by the ledger and two are not, and nothing in the words says which — a reviewer choosing between "Known issue" and "Block" is choosing between "stops asking" and "asks every run", which is the only difference that matters and the one they could not see. "Fit to window" applies to the frozen page too, by `transform: scale()` and never by a width: the frame has to stay exactly the viewport the page laid itself out in, or the replay stops being the render that was measured. A transform changes what is painted and nothing about what was measured. "Fit to window" bounds both axes. Bounding the width alone — which is what it did — fits a picture wider than the canvas and does nothing whatsoever to a narrow one, and every capture on the demo route is 375 or 768 wide and 916 tall: the control showed the whole render on one scenario and two thirds of it on the next, for a reason that had nothing to do with what was being judged. It is not offered while the frozen page is on screen, because scaling that page would relayout it and it would stop being the render that was measured. The one covered node is the case only the frozen page can resolve: **lift what is covering it** and see what was underneath. In a screenshot those pixels have already been replaced. The control names what it will lift — `Hide button.clear-cache-btn` — and is offered only when something is actually covering the component. It used to read "Hide 1 overlay", which asked the reviewer what an overlay is and counted the wrong thing: covered *nodes*, when one button sitting on five of them is one thing to lift. Worse, it marked every fixed element on the page whether or not it covered anything, and marked nothing that covered without being fixed — so on most cards it lifted something irrelevant, and on the cards that mattered it could do nothing while the coverage line insisted a node was covered. What is lifted is now decided by probing the replay with the collector's own rule, which is also where the clamping bug in that rule was found: a sample point outside the viewport was pulled back to the edge, so a node straddling the fold was reported as covered by whatever happened to sit on the fold line. Samples outside the viewport are skipped in both places now. ## The review queue Two mechanisms keep it from being abandoned, and neither is optional. The carry-forward keeps everything whose output did not move out of the queue entirely. And the queue **clusters by the shape of the diff**: one border-radius change produces two hundred scenarios with an identical delta, and one decision covers all of them — with the cluster written into every attestation it covered, so "judged" and "judged alongside 199 others" stay distinguishable. The review surface is itself a CraftTS application. A `query` owns the live queue, a `mutation` records each decision, and local `state` owns navigation, notes and evidence zoom. The Node server remains the authority for the ledger and the content-addressed evidence store. A card disappears only after that server confirms the write; failures remain visible and reviewable. `attest review` attempts to open the local URL in the default browser and always prints it so headless or remote environments can open it manually. First-time captures are deliberately **not clustered**. With no approved digest there is no delta proving that two new screenshots represent the same change. The UI shows one decision per scenario, its exact viewport, captured element size, colour scheme, browser version and target selector. Real identical deltas may still be clustered, with every covered scenario listed before the decision. The default shortcuts are `j`/`k` to move, `a` to accept, `n` to accept with a non-empty note and `r` to reject. A rejection requires a non-empty reason. That reason is stored in the ledger and shown prominently if the scenario returns to the review queue, so it can guide the corrective code change. The same actions are available as buttons. ## What this does not replace `visualMatrix` stays. It is the cheap tier for out-of-flow things — modals, popovers, tooltips — which have no neighbourhood, and for purely pictorial axes, which have no layout consequence. Both are enumerable from the sheets alone, with no page in sight. ## Application overview For full-page scenarios (including automatically opened dialogs), configure `visual.app.pages[].scenarios` in `defineReviewAttestConfig`. The default formats are mobile 390×844, tablet 834×1112, desktop 1440×1000 and wide 2560×1440; an explicit viewport record replaces them. Use `captureVisualApp` from `@craft-ts/style-testing/visual-app/playwright` to execute the recipes and mocks. The **Aperçu de l’application** view compares each page/scenario/capture/viewport independently. PNG comparisons use threshold 0.1 and maxDiffPixels 10, configured in the project. Tolerated changes retain the last human-accepted image as their reference. Missing captures, changed sources or failed generation require a new run. Template and matrix attestations stay separate. --- --- url: https://craft-ts.github.io/craft/guide/style/template-obligations.md --- # Template obligations Tests and visual captures describe things that were observed. A component template describes something earlier: what the component promises to display and what actions it exposes. CraftTS can derive those promises directly from the dependency graph and record a human judgement about each one. ```sh craft-ts attest status \ --kind template \ --tsconfig apps/demo/tsconfig.graph.json ``` No test or browser report is required. The command reads each `craftComponent` template and derives two kinds of obligation. ## Render and command A **render** obligation starts at a reactive binding in the template and follows the graph to the state, computed value, query, or property that produces it. ```ts ({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]); ``` This template promises to render `user.isAdmin` and `total`. Two occurrences of the same target are one promise, not two. A **command** obligation starts at a handler on an interactive element and follows the call chain it triggers. ```ts ({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove'); ``` This template promises that the named button invokes `users.remove`. The element tag and its literal name are part of the promise, so moving the action to a different control asks for a new judgement. For a command that resolves to a `craftMethod`, the obligation also records the method's top-level call statements in source order. Calls inside a branch or a nested callback are omitted because they are not guaranteed on every click. The ordered calls are part of the readable evidence, so changing them asks for a new judgement. The review card shows this sequence below the promise. The review queue contains the promise and its short effect list, but no source code. When a template review card opens, the review app requests its button and method snippets from `/api/template-detail` and shows both by default. Computed or dynamic accesses that cannot be addressed are printed as `template-obligation-unresolved` diagnostics. They are known extraction gaps; they are never silently treated as if the template made no promise. The derived obligation keeps two presentation forms. Its `statement` is a canonical English sentence and remains available in the API, CLI and agency handoffs. The review application uses the accompanying structured statement parts to render that same promise in the selected language. Neither form is part of the attested evidence hash, so wording changes do not invalidate a decision. ## What `renewed` means Every obligation has two independent keys: | key | meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------- | | code fingerprint | the transitive code slice behind the bound or invoked target | | evidence | the canonical shape of the promise: direction, element, name, target, target kind, and direct command effects | When implementation code changes but the template still promises the same thing, the state is `renewed`. The previous judgement carries forward without asking a person to review it again. When the template binds or invokes a different target, or a command's direct effects change, the evidence changes and the state is `review`. An attested obligation is **not a passing test**. It says that a person confirmed the promise was intentional. It does not prove that the implementation fulfils that promise at runtime. ## Removing a promise is a decision If an attested template obligation disappears, `status --kind template` exits with a failure until the removal is signed. The ledger line is retained and marked with why the promise went away: ```sh craft-ts attest retire \ --kind template \ --subject 'template:component:src/card.ts:Card#command:property:src/card.ts:save' \ --reason superseded \ --note 'Saving is automatic now.' ``` The reasons are: * `superseded`: the product now fulfils the need another way; * `defect`: the former promise was wrong; * `derivation`: the extractor produced an obligation it should not have. Track this count as a quality signal for the extractor. The note is mandatory because absence is otherwise indistinguishable from an accidental deletion. If a retired obligation later reappears, it returns to the review queue; the next human verdict clears the retirement. ## Measured rename noise The target identity remains part of the evidence because the implementation measurement stayed inside its review budget. Replaying the latest 20 commits that touched `apps/demo` produced a median of **0 actionable obligation changes per commit** (one commit removed four obligations, one added three, and the other eighteen changed none), with **0 changes attributable to a pure target rename**. This is below the threshold of three, so mass renames can continue to be handled by review clustering without weakening the promise recorded in the evidence. --- --- url: https://craft-ts.github.io/craft/guide/components/css-variables.md --- # Typed CSS variables A component's styling API is a set of **typed custom properties** declared with [`cssVars`](../style/define.md#the-theme) in its sheet. Each one has a kind — a colour, a length, a percentage — and a typed initial value, registered with `@property`. None is "required": a variable nobody sets keeps its initial value, and one nobody reads is reported by the architecture rule `no-dangling-css-vars`. ```ts import { bg, color, craftStyles, cssVars, defineStateAxis, inlineSize, kind, p, palette, radius, set, space, unit, when, } from '@craft-ts/style'; const inherited = { inherits: true }; /** The card's public variables. Each has a typed initial value. */ export const cardVars = cssVars('tokenCard', { ink: kind.color(palette.text.strong, inherited), bg: kind.color(palette.surface.raised, inherited), radius: kind.length(unit.px(16), inherited), }); /** A caller picks a look; without one, every variable keeps its initial value. */ export const cardLook = defineStateAxis('cardLook', ['alert']); export const card = craftStyles('tokenCard', { root: [ p(space(4)), color(cardVars.ink), bg(cardVars.bg), radius(cardVars.radius), when(cardLook.alert, [ set(cardVars.ink, palette.accent.danger), set(cardVars.bg, palette.surface.sunken), ]), ], }); /** A panel exposes its own variable and forwards it to the card inside it. */ export const panelVars = cssVars('cardPanel', { ink: kind.color(palette.accent.info, inherited), }); export const panel = craftStyles('cardPanel', { root: [p(space(4)), set(cardVars.ink, panelVars.ink)], }); /** A registered percentage, written at runtime and interpolable. */ export const meterVars = cssVars('cardMeter', { value: kind.percentage(unit.pct(0)), }); export const meter = craftStyles('cardMeter', { fill: [inlineSize(meterVars.value), bg(cardVars.ink)], }); ``` ```ts import { article, craftComponent, div, type Input } from '@craft-ts/component'; import { state } from '@craft-ts/core'; import { assign, unit } from '@craft-ts/style'; import { card, meter, meterVars, panel } from './card.style'; const Card = craftComponent( 'Card', {}, (progress: Input) => ({ progress }), ({ progress }) => article({ class: card.root }, [ 'Card', div({ class: meter.fill, style: function* () { return assign(meterVars.value, unit.pct(yield* progress())); }, }), ]), ); const Page = craftComponent( 'Page', {}, function* () { const alertProgress = yield* state('alertProgress', 20); const panelProgress = yield* state('panelProgress', 80); return { alertProgress, panelProgress }; }, ({ alertProgress, panelProgress }) => [ // Per instance: a variant sets the variables it changes. Card({ progress: alertProgress, 'data-cardLook': 'alert' }), // Forwarded: the panel's variable becomes the card's ink. div({ class: panel.root }, [Card({ progress: panelProgress })]), ], ); ``` ## Per instance: a variant sets what it changes A caller does not hand a component raw values. It picks a variant — here `data-cardLook` — and the sheet sets the variables that variant changes with `set(...)`. The others keep their initial value. The set of looks is therefore closed and enumerable, which is what lets the [visual matrix](/guide/style/variants) capture every one of them. ## Inherited: a parent sets, descendants read `{ inherits: true }` is for a variable set once on a wrapper and read below it — a theme, or a card that tints whatever it contains. The default, `false`, is for a variable an element both sets and reads on itself. ## Forwarded: a parent re-exposes a child's variable A parent that wants its own API writes `set(child, parent)`: the panel above declares `panelVars.ink` and forwards it to `cardVars.ink`. A caller overrides the panel's variable in its own sheet, and the card follows without the panel knowing how the card is built. ## At runtime: `assign` A value known only at runtime — a progress, a position, a colour picked by the user — is written on the element with `assign(variable, value)`, the only thing `style:` accepts. The sheet reads it like any other variable. Because the variable is registered with its kind, the browser can interpolate it: a `transition` on `width` driven by a percentage variable animates. ## `@property` is emitted, not written Every variable declared with `cssVars` is emitted as an `@property` block by the build plugin, with its syntax, its `inherits` flag and its initial value. You never write one by hand. Two rules follow from the registration: * an initial value must be computationally independent — `unit.px(16)`, not `unit.rem(1)` — or the browser drops the whole registration; the architecture suite catches it; * a prefix belongs to one sheet: `cssVars` throws when two sheets declare the same prefix. ## `meta.cssVars` The `cssVars` field of `craftComponent`'s meta — a contract extracted from a CSS string, with `required()`, `inherit`, `omit` and `forward()` at the call site — belongs to the component CSS that `no-component-css` refuses. It is `@deprecated`, kept only so that an [attested bypass](/guide/components/styles#the-one-real-exception) stays possible. --- --- url: https://craft-ts.github.io/craft/guide/components/fine-grained-reactivity.md --- # Fine-grained reactivity Craft templates are reactive at the **binding** level. When a signal changes, Craft updates the text node, DOM property, class, style, or host binding that read it. It does not need to execute the surrounding component template again. ```ts // `counterSheet` is the component's sheet, from counter.style.ts. ({ counter }) => div([ h2('Counter'), p({ class: counterSheet.value }, counter), button({ click: counter.increment }, '+'), ]); ``` Here, `counter` is passed to `p` as a yieldable reader. The renderer drives the read for that text binding. Incrementing the counter evaluates that binding and patches its text node; the `div`, heading, button, and component template remain untouched. ## The binding is the reactive boundary A function in a rendered position declares a binding. The callback only reads values already derived by the primitive layer; comparisons, formatting, and UI decisions stay out of the template: ```ts p(items.totalLabel); button( { disabled: items.isEmpty, title: items.clearTitle, }, 'Clear', ); div({ class: items.emptyClass, style: items.emptyStyle, }); ``` `totalLabel`, `isEmpty`, `clearTitle`, `emptyClass`, and `emptyStyle` are named derived values exposed by the state, query, insertion, or component context. Pass the reader. If only an item-related dependency changes, Craft evaluates only the affected bindings. A sibling binding depending on another reader does not run. Static values do not need callbacks: ```ts h2('Shopping cart'); button({ type: 'button' }, 'Clear'); ``` ## Do not read reactive values while building the template A direct read happens while the component constructs its VNodes. It cannot be assigned to one precise DOM binding and becomes a structural template dependency instead: ```ts // Avoid: these reads happen in the component template. p(items.totalLabel()); button({ disabled: items.isEmpty() }, 'Clear'); div({ class: items.emptyClass() }); ``` Move each read into the binding that consumes it: ```ts p(items.totalLabel); button({ disabled: items.isEmpty }, 'Clear'); div({ class: items.emptyClass }); ``` This is also the rule for component inputs. Pass a yieldable reader directly when the child must observe a changing value. When the child needs fields of an object, explicitly adapt the input with `deepYieldable`: ```ts UserCard({ user: selectedUser }); ``` The reader is lazy: constructing the parent template does not read `selectedUser`. Craft installs it as the source of the child's `user` input. The child then decides which granular binding observes it: ```ts const UserCard = craftComponent( (user: Input) => ({ user: deepYieldable(user) }), ({ user }) => h2(user.displayName), ); ``` When the `h2` binding first evaluates, `yield* user()` invokes the reader, which reads `selectedUser`. That text binding becomes the signal consumer. When the selected user changes, only the binding evaluates again and patches the existing `h2`; neither the parent template nor the child component template runs again. Reading the input eagerly in the child would move the dependency back to the component boundary and is rejected by `require-reactive-template-bindings`: ```ts // Avoid: resolving the input while the child template is built. h2(craftUse(user()).displayName); ``` ## Structure has its own reactive scopes Bindings update an existing node. Blocks own changes to the shape of the tree: ```ts ifNode( hasItems, () => CartItems({ items: () => items() }), () => p('Your cart is empty.'), ); forNode(items, { track: (item) => item.id }, (item) => p(item.name)); ``` `ifNode`, `forNode`, `matchNode.exhaustive`, and `deferNode` isolate their own structural work. A branch or list can change without making the parent component rebuild unrelated siblings. Use these helpers for structure and binding callbacks for values on existing nodes. ### Progressive `forNode` rendering See the dedicated [Progressive `forNode` rendering](/guide/components/schedule-for) guide for the complete usage and trade-offs. `forNode` is synchronous by default. For a large collection, opt into frame-based batching on the `forNode` node itself: ```ts forNode(items, { track: (item) => item.id }, (item) => p(item.name)).pipe( scheduleFor({ enabled: true, strategy: 'frame', frameBudgetMs: 4, }), ); ``` Use `frame` when the list should begin appearing quickly while leaving room for input and painting between batches. `enabled: false` restores synchronous rendering, regardless of the selected strategy. The first delivery supports `sync` and `frame`; `idle` is reserved for a later scheduler implementation. Scheduling improves perceived responsiveness, but it does not reduce the total work needed to create or update the list. For very large or continuously scrolling collections, virtualisation is still preferable because it reduces the number of DOM nodes and bindings that exist at once. ## Keep bindings pure and free of logic A binding reads a value already derived by the primitive layer. It does not format data, make business decisions, or write state: ```ts // Correct: the primitive exposes the render-ready reader. p(cart.formattedTotal); // Incorrect: rendering changes application state. p(function* () { yield* counter.update((value) => value + 1); return yield* counter(); }); ``` Perform writes from DOM events, outputs, mutations, or explicit business effects. Purity makes a binding safe to evaluate whenever one of its dependencies changes. ## Enforce the model with ESLint Enable both renderer rules with type-aware ESLint configuration: ```js export default [ { files: ['**/*.ts'], languageOptions: { parserOptions: { projectService: true }, }, rules: { 'craft-ts/require-reactive-template-bindings': 'error', 'craft-ts/no-render-writes': 'error', }, }, ]; ``` * `require-reactive-template-bindings` rejects direct reads of signals, Craft values, and component inputs during VNode construction. * `no-render-writes` rejects detectable `set`, `update`, and `mutate` calls from templates and binding callbacks while allowing event and output handlers. See the [ESLint rules reference](/guide/routing/eslint-rules) for the complete configuration. ## What you should observe After a binding dependency changes: * the affected DOM value changes; * the node keeps its identity; * unrelated bindings do not evaluate; * the component template does not emit a new `component / update` trace. The current template trace reports component and structural renders, not each individual text or property effect. The absence of a component update therefore confirms that the change stayed below the component boundary; a DOM assertion confirms that the expected binding was patched. Effects are owned by their rendered nodes. Removing a branch, list item, or component destroys its binding effects, so their dependencies are released with the DOM they served. ## Migration checklist 1. Move comparisons, formatting, and display decisions into named derived primitive values such as `items.isEmpty`. 2. Pass yieldable readers to text bindings (`p(counter)`), or use a generator when the binding must format: `p(function* () { return \`Count: ${yield\* counter()}\`; })\`. 3. Pass yieldable readers to DOM properties such as `value`, `disabled`, and `title`. 4. Return complete reactive class and style readers from the primitive. 5. Pass changing component inputs as yieldable readers. 6. Express structural changes with `ifNode`, `forNode`, `matchNode.exhaustive`, or `deferNode`. 7. Enable the two ESLint rules and remove every direct reactive template read. Continue with [Components](/guide/components/) for the complete `craftComponent` model or [Observability](/guide/advanced/observability) to inspect rendering and correlated interactions. --- --- url: https://craft-ts.github.io/craft/guide/components/schedule-for.md --- # Progressive rendering with `scheduleFor` `forNode` renders synchronously by default. That is the right choice for short lists and keeps the initial behavior predictable. For a large collection, `scheduleFor` lets Craft spread fragment creation and updates over animation frames. ## Basic usage ```ts import { forNode, scheduleFor } from '@craft-ts/component'; forNode(cells, { track: (cell) => cell.id }, (cell) => renderCell(cell)).pipe( scheduleFor({ enabled: true, strategy: 'frame', frameBudgetMs: 4, }), ); ``` The directive is attached to the `forNode` node. It does not add a DOM wrapper and does not change the `item`, `index`, dependency, exception, or pending-source contracts of the block. ## When to use it Use `strategy: 'frame'` when the first visible items should appear quickly and the browser must keep handling input and painting while the rest of the list is created. A smaller `frameBudgetMs` yields more often; a larger budget completes the list sooner but can occupy the main thread for longer. Disable it explicitly when a screen needs the synchronous behavior: ```ts forNode(items, { track: (item) => item.id }, renderItem).pipe( scheduleFor({ enabled: false, strategy: 'frame' }), ); ``` `forNode` without `scheduleFor` is already synchronous. The first delivery supports `sync` and `frame`; `idle` will be added with its fallback policy in a later delivery. ## What scheduling does—and does not do Scheduling improves perceived responsiveness by yielding between batches. It does not reduce the total work required to create or update every fragment. Stable keys still control reconciliation, and existing keyed DOM fragments keep their identity when the collection is reordered. For very large or continuously scrolling collections, prefer virtualisation: it reduces the number of DOM nodes and bindings that exist at the same time. Scheduling and virtualisation solve different problems and can eventually be combined. ## Pixel Art Workshop The demo's Pixel Art Workshop uses frame scheduling for its 256-cell grid: ```ts forNode(INDEXES, { track: (index) => index }, renderCell).pipe( scheduleFor({ strategy: 'frame', frameBudgetMs: 4 }), ); ``` The production benchmark can compare the synchronous baseline and frame mode with 256, 1,000, and 10,000 cells. Its commands and metrics are documented in `docs/benchmarks/schedule-for-pixel-art.md` in the repository. --- --- url: https://craft-ts.github.io/craft/guide/components/directives.md --- # Directives and `.pipe(...)` A Craft directive decorates **both** a component's logic factory and its template — so behaviour and markup travel together, and compose. **Use one when** the same behaviour must be added to several components: a tooltip, a highlight, focus management, analytics on interaction. **Not when** the behaviour belongs to one component — put it in that component's factory. Directives are applied from left to right. ## Event actions and DOM modifiers `eventAction(...)` is an element directive. Use it when an element must adjust a DOM event before invoking one action. The action stays on the element; no `craftMethod` wrapper is needed: ```ts import { button, eventAction } from '@craft-ts/component'; button( 'navToggle', { type: 'button', 'aria-expanded': navOpen, }, navOpen.navToggleLabel, ).pipe( eventAction({ click: { action: navOpen.toggle, stopPropagation: true }, }), ); ``` Each event entry requires `action` and can set `preventDefault`, `stopPropagation`, or `stopImmediatePropagation` to `true`. The modifiers run before the action in the same DOM listener. The action remains in Craft's normal event pipeline, including event hooks and generator callbacks. Use the event name as the key, such as `click`, `submit`, or `keydown`. Do not also put that event in the element's props; `eventAction` rejects duplicate handlers. The recommended ESLint rule `craft-ts/no-event-only-craft-method` and the default architecture rule `no-event-only-craft-method` report a `craftMethod` that only modifies an event and delegates to one action, even when that method is declared in a different file from the element. ```ts import { button, craftComponent, craftDirective, div, p, type HostRequiredLogic, type HostTemplate, type Input, } from '@craft-ts/component'; ``` ## `InteractivePermissions` The examples below use a directive that adds a `permissions` object to the component context. Its configuration is internal to the directive; the component caller only provides the original `user` input. ```ts import { HostRequiredLogic, HostTemplate, Input, craftDirective, } from '@craft-ts/component'; import { craftUse } from '@craft-ts/core'; type User = { id?: string; name: string; permissions: readonly string[] }; type RequiresUser = { user: Input; }; type ProvidesPermissions = RequiresUser & { permissions: { canEdit: () => boolean; }; }; const InteractivePermissions = craftDirective( 'InteractivePermissions', {}, (baseLogic: HostRequiredLogic) => (user: Input) => { const context = baseLogic(user); return { ...context, permissions: { canEdit: () => craftUse(user()).permissions.includes('edit'), }, }; }, (baseTemplate: HostTemplate) => (context) => baseTemplate(context), ); ``` ## Basic composition A directive transforms the existing logic and template: ```ts const Card = craftComponent( 'Card', {}, (user: Input) => ({ user }), ({ user }) => div(user().name), ).pipe(InteractivePermissions); ``` The result of `InteractivePermissions` becomes the logic actually executed by `Card`: ```text component inputs ↓ original logic ↓ logic added by the directive ↓ final context ↓ final template ``` ## Directive configuration input A fixed configuration can be supplied when the directive is created: ```ts const hasPermission = (permission: Permission) => craftDirective( 'hasPermission', {}, (baseLogic: HostRequiredLogic) => (user: Input) => { const context = baseLogic(user); return { ...context, permissions: { canAccess: () => user().permissions.includes(permission), }, }; }, (baseTemplate: HostTemplate) => (context) => context.permissions.canAccess() ? baseTemplate(context) : [], ); const Card = craftComponent( 'Card', {}, (user: Input) => ({ user }), ({ user }) => div(user().name), ).pipe(hasPermission('edit')); ``` `edit` is internal configuration. The caller of `Card` does not provide it. ## Input supplied by the component caller A directive can also add a public input to the component: ```ts const hasPermissionInput = craftDirective( 'hasPermissionInput', {}, (baseLogic: HostRequiredLogic) => (user: Input, permission: Input) => { const context = baseLogic(user); return { ...context, permission, permissions: { canAccess: () => user().permissions.includes(permission()), }, }; }, ( baseTemplate: HostTemplate<{ user: Input; permission: Input; permissions: { canAccess: () => boolean; }; }>, ) => (context) => (context.permissions.canAccess() ? baseTemplate(context) : []), ); const Card = craftComponent( 'Card', {}, (user: Input) => ({ user }), ({ user }) => div(user().name), ).pipe(hasPermissionInput); Card({ user: () => currentUser, permission: () => 'edit', }); ``` The directive adds `permission` to the final logic and to `Card`'s public props. The renderer passes factory arguments in prop order, following the existing convention for functional component factories. ## Structural directive A structural directive decides whether the template produces nodes: ```ts import { HostRequiredLogic, HostTemplate, Input, craftComponent, craftDirective, div, p, } from '@craft-ts/component'; import { craftSignal } from '@craft-ts/core'; const isVisible = craftSignal(true); const whenDirective = craftDirective( 'whenDirective', {}, ( baseLogic: HostRequiredLogic<{ when: Input; }>, ) => baseLogic, ( baseTemplate: HostTemplate<{ when: Input; }>, ) => (context) => (context.when() ? baseTemplate(context) : []), ); const Panel = craftComponent( 'Panel', {}, (when: Input) => ({ when }), () => div(p('Conditional content')), ).pipe(whenDirective); Panel({ when: function* () { return isVisible(); }, }); ``` When `when()` becomes false, the renderer removes the template output. When it becomes true again, the template is rendered again. A structural directive can consume context added by a previous directive: ```ts const onlyEditable = craftDirective( 'onlyEditable', {}, ( baseLogic: HostRequiredLogic<{ permissions: { canEdit: () => boolean; }; }>, ) => baseLogic, ( baseTemplate: HostTemplate<{ permissions: { canEdit: () => boolean; }; }>, ) => (context) => (context.permissions.canEdit() ? baseTemplate(context) : []), ); const EditableCard = craftComponent( 'EditableCard', {}, (user: Input) => ({ user }), ({ user }) => div(user().name), ).pipe(InteractivePermissions, onlyEditable); ``` The context flows from left to right: ```text original logic → InteractivePermissions → { user, permissions } → onlyEditable → template or [] ``` ## Directives on elements A component template can also apply a structural directive to a hyperscript node: ```ts const message = p('Message').pipe(whenDirective); ``` The component context is passed to the decorated template. Craft structural directives can therefore transform Craft output without introducing an intermediate component. Functional DOM directives can also receive their configuration directly and be applied with `.pipe(...)`. The configuration is owned by the directive instead of becoming a DOM attribute: ```ts a({}, 'Tasks').pipe(CraftRouterLink(link)); ``` A field configured with `insertSelectFormTree` must be selected before it is bound, so its lazy insertions (including validators) are registered: ```ts input({ type: 'email' }).pipe( CraftFieldDirective(loginForm.form.selectEmail()), ); ``` ## Composition rules * Create a configurable directive with `craftDirective(...)`, then pass it to `.pipe(...)`. * A directive can add public inputs; they appear in the final component props. * A directive placed after another receives the already decorated logic and template, so it can consume context added by the previous directive. * Generator factories continue to be executed by the Craft runtime. Dependencies from both the original and decorated factories remain part of the component dependency contract. ## See Also * [Customization](/guide/components/customization) * [Styling a component](/guide/components/styles) * [Testing components](/guide/testing/components) --- --- url: https://craft-ts.github.io/craft/guide/components/pending-node.md --- # settledValue & pendingNode Reading an async value in a template without ever handling `undefined` — and being told at **compile time** when the loading state has nowhere to go. **Use it when** a template renders data that comes from a `query`. **Not when** you want to drive the loading state yourself: `query.value()` (`T | undefined`) and `query.status()` stay exactly as they were. ## Import ```typescript import { settled } from '@craft-ts/core'; import { pendingNode } from '@craft-ts/component'; ``` ## Overview A resource-like `query`, `mutation` or `asyncProcess` exposes a second read next to `value`: ```typescript users.value(); // User[] | undefined — you handle the wait users.settledValue(); // User[] — the wait is handled for you ``` `settledValue` never returns `undefined` and never returns a value while the source carries an exception. When there is nothing to show it **suspends**: it throws a `CraftNotSettled` that the nearest `pendingNode` turns into a fallback. A business exception throws through the existing channel instead, and lands in the nearest `catchNode`. Because the dependency is visible in the types, a template that renders a suspending value with no `pendingNode` around it does not compile. ## Reading a settled value in a computed Inside a `craftComputed` generator, `yield* settled(ref)` hands back the resource's settled read: ```typescript const teams = craftComputed('teams', function* () { const list = yield* settled(users); // `list()` is `User[]` here — never undefined, never in exception return () => [...new Set(list().map((user) => user.team))].sort(); }); ``` Nothing is awaited and nothing is yielded at runtime: the markers are type-only. What they do is tag `teams` as *depending on the async source `users`*, which is what the template checker reads. ## The boundary The boundary is piped onto any node above the reads: ```typescript div([span(teams), span(total)]).pipe( pendingNode({ fallback: () => p('Chargement…') }), ); ``` One boundary covers every async source in its subtree — the same shape as `Suspense`. When each zone deserves its own skeleton, name the sources instead; the list is checked exhaustively, so a source with no fallback (and a fallback for a source that never suspends here) is a compile error: ```typescript div([...]).pipe( pendingNode.exhaustive({ users: () => SkeletonList(), orders: () => SkeletonRows(), }), ); ``` The handler keys are the **query names**, even when the template only ever sees a computed derived from them. ## What the compiler enforces ```typescript craftComponent( 'teamList', {}, function* () { const users = yield* query('users', { ... }); const teams = craftComputed('teams', function* () { const list = yield* settled(users); return () => list().length; }); return { teams }; }, // ERROR_async_source_rendered_outside_a_pendingNode: "users" ({ teams }) => div([span(teams)]), ); ``` The sources bubble up through the node tree exactly like unhandled exception codes do, and the check fires on the `craftComponent` template argument, naming the sources that have nowhere to show their loading state. Several suspending computeds in one template are all covered by the same rule: every one of them needs a boundary above it. The obligation travels through `forNode`, `ifNode`, `deferNode`, projected content and nested elements — anywhere a node can carry children. ## Stale-while-revalidate A reload that keeps its previous value does **not** suspend: the stale value is served while the new one is in flight, so a refetch never blanks a screen that already has data. Only a source with nothing to show suspends. To make a reload suspend again, clear the value with `preservePreviousValue: () => false`. A refetch throws nothing, so the boundary cannot learn about it from the suspension channel — it watches the source's own status instead. Give a handler its `reloading` slot to report it, rendered **next to the still-visible subtree**: ```typescript pendingNode.exhaustive({ issue: { pending: () => p('Waiting for an invoice…'), reloading: () => p('Re-issuing…'), }, }); // or, for the catch-all form pendingNode({ fallback: () => Skeleton(), reloading: () => Spinner() }); ``` ## Runtime behaviour While a source is pending, the boundary renders its fallback and detaches the suspended subtree's DOM — **detaches, not destroys**. Keeping it alive is what makes resumption work: the suspended bindings stay subscribed to their source's status, so they re-run and release the boundary the moment the data arrives. Two escapes are reported rather than silently swallowed: * a settled read that suspends with no boundary above it throws `CraftUnhandledPendingError`; * a settled read whose source carries an exception with no `catchNode` above it throws `CraftUnhandledExceptionError`. The first is the runtime backstop for what the types cannot see — typically a settled read hidden inside a lambda (`() => users.settledValue().name`), where the brand that carries the obligation is lost. Bind the value **by reference** (`span(users.settledValue)`, `span(teams)`) to keep the compile-time guarantee. ## Two boundaries, two obligations A settled read has two exits and each one has its own boundary: | Exit | Thrown | Boundary | Checked at | | ---- | ------ | -------- | ---------- | | nothing to show yet | `CraftNotSettled` | `pendingNode` | `craftComponent(...)` | | the source carries an exception | `CraftGenShortCircuit` | `catchNode` | `craftComponent(...)` | Both bubble up the node tree until a boundary clears them, and both fail the `craftComponent` template argument when uncovered. A `pendingNode` is not an exception boundary — settled exceptions pass straight through it, and vice versa. These two throws are intentional CraftTS control flow. The shared `isCraftControlFlow(error)` predicate identifies them so observability and error-conversion wrappers can rethrow them without logging or taking an app snapshot. If a pending read escapes its boundary, it becomes `CraftUnhandledPendingError`; that is a real template error and remains observable. ```typescript div([span(summary)]) .pipe(pendingNode.exhaustive({ issue: () => Skeleton() })) .pipe(catchNode.exhaustive({ INVOICE_REJECTED: () => Rejected() })); ``` A `catchNode` handler receives the exception as `AnyCraftException`: its `code` is known, its payload is not. Reach for `matchNode` when the fallback needs the payload itself. ## Current limits * The by-id forms (`select(...)` / `selectOrCreate(...)`) have no settled read yet: a by-id ref holds one status per group member. * A component cannot yet delegate its boundaries to its caller: both checks are enforced on each `craftComponent` template. * A settled read hidden inside a lambda loses its brand, and with it both compile-time obligations — the runtime backstops still fire. The pending fallback is announced to assistive tech (`aria-live`, `aria-busy`). See [Accessibility](/guide/components/accessibility). --- --- url: https://craft-ts.github.io/craft/guide/components/accessibility.md --- # Accessibility Craft already enforces exhaustive exceptions, `pendingNode`, and reactive templates. Accessibility follows the same DNA: **an illegal state doesn't compile, an omission is an ESLint error, the block runtime doesn't wait for the author to remember.** Target: **WCAG 2.2 level AA**. ## The five layers 1. **Types** — `img` and `area` require `alt` (including a decorative `''`). Semantic helpers (`dialog`, `fieldset`, `table`, `iframe`, `h4`–`h6`, `svg`…) exist so the lint applies without going through `h()`. 2. **ESLint `craft-ts/a11y`** — accessible name, labels, ARIA, no click on a `div`, `button` with `type`, `h()` forbidden when a named helper exists. 3. **Block runtime** — `pendingNode` announces the fallback (`aria-live`, `aria-busy`), `catchNode` sets `role="alert"`, `deferNode` renders a keyboard placeholder, `CraftRouterLink` sets `aria-current="page"`. 4. **Primitives** — `heading` / `headingSection` (relative outline), `dialog` (native modal + focus), `liveRegion` (toasts). No **styled** button: `buttonControl` / `fieldControl` / `disclosureControl` inject accessibility props into your native elements. 5. **Tests** — `toBeAccessible()` on the template helper. ```ts import craftRules from '@craft-ts/dev-tools/eslint-rules'; export default [ { files: ['**/*.ts'], plugins: { 'craft-ts': craftRules }, rules: { ...craftRules.configs.a11y.rules, }, }, ]; ``` The rules are `error` in the preset. A disable is a documented deviation, not the default path. ## Hyperscript templates A Craft template is TypeScript, not an `.html` file: accessibility rules operate on the hyperscript calls themselves — `button(...)`, `img(...)` **and** `h('img', …)`. ```ts img({ src: photo.url, alt: photo.title }); // decorative: alt: '' button({ type: 'button' }, 'Save'); a({ href: '/tasks' }, 'Tasks'); label({ htmlFor: 'email' }, 'Email'); input({ id: 'email', type: 'email' }); ``` `h('button')` when a named helper exists is an error (`prefer-named-html-helpers`): it's a bypass of the types. ## Heading outline An `h3` inside a Card is a classic false positive: sometimes under an `h1`, sometimes under an `h2`. The title doesn't choose its rank. **The parent supplies it.** ```ts heading('Task list'); headingSection([ heading('Detail'), TaskCard(), // the internal heading() becomes hN+1 ]); ``` The snippet above is the core of the API. The skip-link and `main` belong to the application shell: * `heading()` reads the current level (1–6) and renders `h1`…`h6`. * `headingSection(...)` increments by one for the subtree — comment fragments, no DOM wrapper, like `ifNode`. * `headingRoot(...)` resets to `h1` (dialog, explicit reset). A `dialog` also sets its own outline root (the dialog title = level 1 **inside** the dialog). SFCs loaded via `loadComponent` stay on `heading()`. * `h1()`…`h6()` remain for raw HTML. The `prefer-relative-heading` rule forbids them inside a `craftComponent` (outside specs). A reusable component exposes `heading()` without a local `headingSection`: the need for an outline **bubbles up** to the parent. Calling this component outside a `headingSection` **doesn't compile** (same DNA as `pendingNode`). Any SFC mounted via `loadComponent` / `loadCraftComponent` calls `heading()` — not `headingRoot()`. The rank (h1 vs h2+) comes from the parent: * **Page** (sibling under the shell): `heading()` is the h1. * **Layout** (SFC with `CraftRouterOutlet`): `heading()` + `headingSection([…, CraftRouterOutlet()])` so the child inherits h2+. * **Shell** (`App`): `skipLink` + `main` + `CraftRouterOutlet`, **without** `heading()` above the outlet. Otherwise two h1s, or children stuck at the same level as the chrome's title. `require-route-heading-outline` reads the lazy target. `require-outlet-heading-section` distinguishes layout from shell. The types don't connect the outlet to the routed child. ```ts // Shell — no heading() above the outlet skipLink('main', 'Skip to content'); main({ id: 'main', tabIndex: -1 }, CraftRouterOutlet()); // Layout — title + outlet inside headingSection heading('Team'); headingSection([CraftRouterOutlet()]); // Page (loadComponent) — heading() only; h1 or h2+ depending on the parent heading('Task list'); headingSection([ heading('Detail'), TaskCard(), ]); ``` ## Blocks `pendingNode` detaches the source from the document while loading (the nodes stay mounted, they aren't CSS `hidden`). The fallback is wrapped in `aria-live="polite"` `aria-atomic="true"` `aria-busy="true"`. On reload, the source stays visible; `aria-busy` signals the refresh. Focus in the source is restored when it resumes. `catchNode` wraps the error message in `role="alert"` if the fallback isn't already a live region. `deferNode` sets `aria-busy` while loading. An `interaction` trigger on a placeholder that isn't already a control gets `role="button"` and `tabIndex="0"`, and only fires on keyboard via Enter / Space. ## Dialog and live region ```ts dialog( { labelledBy: 'title', open: true, onClose }, [heading({ id: 'title' }, 'Confirm'), button({ type: 'button', click: onClose }, 'Close')], ); liveRegion({ politeness: 'polite' }, copied() ? 'Copied' : ''); ``` `dialog` relies on the native `` (`showModal`, Escape, `aria-modal`). `liveRegion` is a `` (or `alert` if `assertive`). ## Control helpers (props to merge) The helpers are renderless: they supply the attributes to merge onto your own HTML elements, without imposing a visual widget. ```ts const email = fieldControl('email'); label(email.label, 'Email'); input({ ...email.input, type: 'email' }); p(email.description, 'We never share your email.'); const faq = disclosureControl('faq-1', isOpen); button({ ...faq.button, click: toggle }, 'What is Craft?'); div(faq.panel, '…'); button(buttonControl({ disabled: isSaving, keepFocusable: true }), 'Save'); ``` A closed panel gets `hidden` and `aria-hidden`, so no focus stays inside it. `keepFocusable` sets `aria-disabled` without `disabled`: the click isn't cut off, the author must no-op the handler. The states are also exposed as `data-*`, which allows a simple CSS convention independent of the component: ```css button[data-disabled] { opacity: 0.5; } input[data-invalid] { border-color: var(--danger); } button[data-open] { font-weight: 600; } ``` A live region must be mounted from the very first render: never condition its node on the message. This lets the screen reader subscribe to it before any event happens. ```ts // correct — region exists at first paint liveRegion({ label: 'Notifications' }, copied() ? 'Copied' : ''); // incorrect — SR never subscribes ifNode(copied, () => liveRegion('Copied')); ``` ## Navigation `provideCraftRouter` registers `CraftTitleStrategy`: the route's `title` is written via `BrowserDocument.setTitle`. `withA11yNavigationFocus()` (opt-in, passed to `provideCraftRouter`) moves focus to `#main` / `
` after each internal navigation — not on first load, the skip-link handles that. `skipLink('main', 'Skip to content')` at the top of the shell, with `main({ id: 'main', tabIndex: -1 }, …)`. To sync the document's language and direction from a generator: ```ts yield* BrowserDocument.setLang('en'); yield* BrowserDocument.setDir('ltr'); ``` `clickFocus` sets focus before running the handler, useful for controls that open a search or a dialog: ```ts button({ type: 'button', click: clickFocus('#search-warmup', openSearch), }, 'Search'); ``` ## Tests ```ts const { getByRole, getByLabel, toBeAccessible } = await setupCraftComponentTemplateTest( Page, { context }, ); await toBeAccessible(); getByRole('button', { name: 'Save' }); getByLabel('Email'); ``` `assertAccessible` / `toBeAccessible()` cover the structural checks (alt, accessible name, tabindex, iframe title). Real contrast and the rest of WCAG 2.2 AA remain a job for axe / AccessLint in application CI. ## CSS The `require-focus-visible` and `require-reduced-motion` rules apply to the `styles` of a `craftComponent`: if you style `button` / `a` / `input`, define `:focus-visible`; if you animate, gate it with `prefers-reduced-motion`. Contrast goes through tokens (`no-hardcoded-design-values`), not a second CSS linter. --- --- url: https://craft-ts.github.io/craft/guide/components/customization.md --- # Customizing components and directives Craft splits customization into three layers, and which one you reach for depends on how far the change should travel: | Layer | Changes | | --------------------- | ------------------------------------- | | Root-element `host` | The component's own root defaults | | A `*.style.ts` sheet | Its appearance | | Composable directives | Behaviour, reusable across components | **Start with `host`** for one component's defaults, and move to a directive only when the same customization needs to apply somewhere else too. ## Customizing the root element The component meta `host` properties define defaults for the component’s root element. The caller can extend or override them: ```ts import { bg, borderColor, borderStyle, borderWidth, craftStyles, cssVars, defineStateAxis, kind, lineWidth, p, palette, radii, radius, set, space, when, } from '@craft-ts/style'; /** A title's ink, set by the card and inherited by whatever sits inside. */ export const cardVars = cssVars('customCard', { ink: kind.color(palette.text.strong, { inherits: true }), }); export const cardActive = defineStateAxis('cardActive', ['true']); export const cardSheet = craftStyles('docsCustomCard', { root: [ p(space(4)), radius(radii.md), bg(palette.surface.raised), when(cardActive.true, [set(cardVars.ink, palette.accent.info)]), ], /** Added by a caller: it writes the border, which `root` leaves alone. */ featured: [ borderWidth(lineWidth.thick), borderStyle.solid, borderColor(palette.accent.info), ], }); ``` ```ts import { craftComponent, div, h2 } from '@craft-ts/component'; import { cardSheet } from './card.style'; const Card = craftComponent( 'Card', { host: { class: cardSheet.root, attrs: { role: 'article' }, }, }, () => ({}), () => div([h2('A card')]), ); const FeaturedCard = craftComponent( 'FeaturedCard', {}, () => ({}), () => // `class` merges with the host's; `attrs` would replace the host's // `attrs` as a whole, so a single attribute is passed as a property. Card({ class: cardSheet.featured, 'data-testid': 'featured-card' }), ); ``` Classes, attributes, styles, and events recognized as host properties are applied to the component root. Other properties remain factory props. A caller's `class` is **added** to the host's, so the two classes must not write the same property — `featured` writes the border, which `root` leaves alone. Everything else, `attrs` included, replaces the host's value. Values can be reactive. The class stays constant; what moves is an attribute the sheet reads as an axis: ```ts Card({ class: cardSheet.featured, 'data-cardActive': function* () { return String(yield* active()); }, }); ``` ## Customizing the appearance A component's look lives in a sheet beside it and nowhere else — see [Styling a component: the only way](/guide/components/styles). The template binds the sheet's classes: ```typescript import { panel } from './panel.style'; const Panel = craftComponent( 'Panel', {}, () => ({}), () => div({ class: panel.root }, [ h2({ class: panel.title }, 'Panel'), button('save', { class: panel.action, type: 'button' }, 'Save'), ]), ); ``` A sheet's classes are atomic: they apply where they are bound and nowhere else, so there is no scope to manage and nothing leaks into a child component. ## Adding reusable customization with a directive A directive transforms a component’s factory and template. It is applied from left to right with `.pipe(...)`: ```ts import { highlight } from './highlight.style'; const Highlight = craftDirective( 'Highlight', {}, (baseLogic) => baseLogic, (baseTemplate) => (context) => baseTemplate(context, { class: highlight.root }), ); const HighlightedPanel = Panel.pipe(Highlight); ``` A directive can also add context and public props: ```ts const WithPermission = craftDirective( 'WithPermission', {}, (baseLogic) => (user: Input) => ({ ...baseLogic(user), canEdit: () => user().permissions.includes('edit'), }), (baseTemplate) => (context) => context.canEdit() ? baseTemplate(context) : [], ); const EditablePanel = Panel.pipe(WithPermission); ``` A directive brings its own sheet and adds its class to the host's, so the same directive can be reused by several components without introducing an HTML wrapper. ## Composing providers and exception handlers `withProviders` configures the provider scope of a component before it is invoked. `catchTag.exhaustive` is a logic boundary: each handler is a generator that can call a service or perform another logic operation. It must not return template children. Use `catchNode.exhaustive` or `matchNode.exhaustive` when the exception should produce DOM. ```ts import { abstract, craftException, craftService } from '@craft-ts/core'; import { catchTag, craftComponent, p, withProviders, } from '@craft-ts/component'; const noAccess = craftException({ _tag: 'NO_ACCESS' }); const { RestrictedData, provideRestrictedData } = craftService( { name: 'restrictedData', scope: 'abstract' }, abstract(), ); const MyRestrictedCraftComponent = craftComponent( 'MyRestrictedCraftComponent', {}, function* () { return { value: yield* RestrictedData() }; }, ({ value }) => p(`Private data: ${value}`), ); const Restricted = MyRestrictedCraftComponent.pipe( withProviders([ provideRestrictedData(() => currentUserCanRead() ? 'available' : noAccess, ), ]), catchTag.exhaustive({ NO_ACCESS: function* () { // yield* ToastService.show(() => 'No access'); }, }), ); Restricted(); ``` Providers are evaluated before the component template. If a provider reads a signal, changing that signal recreates the composed rendering, including the provider scope. The handler generator runs for the exception state. Since `catchTag` does not render a template, use `catchNode` or `matchNode` for a visual fallback. The component adapter reuses the exhaustive `catchTag` rules from the core and the composed component carries the exception codes produced by its initializer and providers. The providers also participate in the normal Craft DI graph, so they can satisfy dependencies used by the component and its children. The variadic component `.pipe(...)` overload is currently kept permissive to avoid excessive TypeScript instantiation depth; runtime dispatch still rejects an unhandled exception code. ## Choosing an exception utility Craft exposes three complementary utilities. The important distinction is whether the exception is handled in logic or rendered in a template: * `catchTag.exhaustive` handles component initialization exceptions in logic; * `catchNode.exhaustive` creates a template boundary and can insert a fallback before or after its source block; * `matchNode.exhaustive` renders a fallback from an exception value or signal. ### `catchTag.exhaustive`: logic only Handlers are generator functions. They can call services and yield other Craft operations, but they cannot return `p(...)`, an element, or any other template children. A DOM fallback belongs to `catchNode` or `matchNode`. ```ts const SafeComponent = MyRestrictedCraftComponent.pipe( withProviders([ provideRestrictedData(() => currentUserCanRead() ? 'available' : noAccess, ), ]), catchTag.exhaustive({ NO_ACCESS: function* (exception) { yield* ToastService.show(() => `Access denied: ${exception._tag}`); }, }), ); ``` ### `catchNode.exhaustive`: preserve a source block Apply it to a rendered VNode when the source subtree may throw. The source is kept and the fallback is inserted at the requested position. Applying it to a component in `.pipe(...)` also creates a residual component boundary and removes the handled codes from the component and route contracts. ```ts const view = SourceComponent({}).pipe( catchNode.exhaustive( { UserNotFoundException: () => p('User not found'), }, { position: 'after' }, ), ); ``` For a template boundary, the source block remains visible by default. When `catchNode` is piped onto a component and the exception comes from its composed scope, a function handler keeps the existing component behavior and replaces the source. A handler can keep that source visible by using the object form and setting `showSource: true`: ```ts const view = SourceComponent({}).pipe( catchNode.exhaustive({ UserNotFoundException: { render: () => p('User not found'), showSource: true, position: 'after', }, }), ); ``` With `showSource: true`, the source and fallback are both rendered. Use `showSource: false` to hide the source explicitly. `position` can be set on each handler (`before` or `after`); the second argument remains available as a default for handlers that do not specify their own position. Existing function handlers keep their previous behavior. If the component factory or a provider fails before the template is created, there is no source block to preserve, so the fallback is rendered alone. ### `matchNode.exhaustive`: render a resource exception Use it when a query, mutation, or another primitive exposes an exception as a signal instead of throwing from the template subtree. The block renders no children while the source is empty and switches reactively to the matching handler when an exception appears. ```ts matchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', { UserNotFoundException: () => p('User not found'), UserConsentMissingException: () => p('Consent is required'), }); ``` ## What Craft handles directly Craft supports compositions that are not native properties of a standard the host component or directive: * a Craft directive can add its own classes to the root of the component using it, without a wrapper; * multiple directives can compose their logic, template and host classes through `.pipe(...)`; * the CSS itself is emitted once, at build time, by the `@craft-ts/style` plugin: nothing is injected or reference-counted at runtime. ## Choosing the right level * `host`: identity, attributes, classes, or behavior of the root element; * a `*.style.ts` sheet: the component's appearance, its variants as axes, its runtime values as typed variables; * `craftDirective`: behavior or customization reusable across components; * the factory: component-specific state and dependencies. ### How a parent reaches a child A parent never styles a child's internals. It has two doors, both visible in the child's contract: a class it passes to the child's host, and a variable declared with `{ inherits: true }` that the child's sheet reads. ```ts import { color, craftStyles, fontWeight } from '@craft-ts/style'; import { cardVars } from './card.style'; /** The title reads the card's variable; it never looks at the card itself. */ export const titleSheet = craftStyles('docsCardTitle', { root: [color(cardVars.ink), fontWeight.bold], }); ``` ```ts import { craftComponent, div, h2, type Input } from '@craft-ts/component'; import { state } from '@craft-ts/core'; import { cardSheet } from './card.style'; import { titleSheet } from './card-2.style'; const CardTitle = craftComponent( 'CardTitle', {}, (text: Input) => ({ text }), ({ text }) => h2({ class: titleSheet.root }, text), ); const Card = craftComponent( 'Card', {}, function* () { const title = yield* state('title', 'Card'); return { title }; }, ({ title }) => // The card sets `data-cardActive`; its sheet writes the inherited // variable; the title, a separate component, reads it. div({ class: cardSheet.root, 'data-cardActive': 'true' }, [ CardTitle({ text: title }), ]), ); ``` The card sets `data-cardActive`, its sheet writes `cardVars.ink`, and the title, a separate component, reads it. Nothing in the card knows how the title is built. Names passed to `craftComponent` and `craftDirective` must be unique and match their declaration names. The dedicated ESLint rules detect missing or inconsistent names. ## See Also * [Styling a component](/guide/components/styles) * [Directives and `.pipe(...)`](/guide/components/directives) * [Content projection](/guide/components/content-projection) --- --- url: https://craft-ts.github.io/craft/guide/components/content-projection.md --- # Content projection Projection is a **rendering context, not a category of component**. The same `craftComponent` can be rendered directly or supplied into a compatible logical slot — its definition doesn't change either way. **Use it when** a component composes content it doesn't own: a card with a caller-supplied body, a toolbar filled with actions, a dialog with its buttons. **Not when** the child is fixed — just render it. Both forms go through one primitive: ```ts renderContent(value); ``` It accepts either deferred DOM content (`RenderableContent`) or a component unit exposing a logical contract. There is **no runtime registry** like `contentChildren`, and no special projection component. ## The common case — free DOM content `ContentSlot` describes optional or free-form DOM content. `RequiredContent` adds a structural contract that TypeScript checks. ```typescript import { content, craftComponent, div, renderContent, section, type ContentSlot, type RequiredContent, } from '@craft-ts/component'; type CardInput = { readonly header?: ContentSlot; readonly body: RequiredContent<{ readonly selector: { readonly tag: 'div'; readonly 'data-slot': 'body'; }; }>; }; const Card = craftComponent( 'Card', {}, (input: CardInput) => input, ({ header, body }) => section([ header ? renderContent('header', header) : 'Default title', renderContent('body', body), ]), ); Card({ header: content(() => div('Title supplied by the caller')), body: content(() => div({ 'data-slot': 'body' }, 'Card content')), }); ``` The selector is analysed **statically**. This is rejected, because it does not contain `div[data-slot="body"]`: ```ts Card({ // @ts-expect-error the content does not satisfy the slot's DOM contract. body: content(() => div({ 'data-slot': 'footer' })), }); ``` The contract names an attribute, not a class. A class comes from a sheet and is a list of atoms — not a name a selector can require — while a `data-*` attribute is a stable, declared part of the markup. Content can be built from arrays, conditions, loops and templates — the analysis looks for the selector in every rendered branch: ```ts const body = content(() => [ showIntro() ? div({ 'data-slot': 'body' }, 'Introduction') : undefined, forNode(rows(), { track: (row) => row.id }, (row) => div({ 'data-slot': 'body' }, row.label), ), renderTemplate(cardRowTemplate, { $implicit: selectedRow() }), ]); Card({ body }); ``` The constraint creates no wrapper and adds no runtime validation. DOM contracts and logical contracts are independent: ```text RequiredContent → the shape of the DOM supplied ProjectionOf → the logical capabilities of a component ``` ## Logical projection by contract A component becomes projectable when its logic factory returns a `contract` property, built and checked with `satisfies`. ```ts import { content, input } from '@craft-ts/component'; import { button, craftComponent, renderContent, type ContentSlot, type ProjectionContractOf, type ProjectionOf, } from '@craft-ts/component'; type ToolbarActionContract = { readonly kind: 'toolbar-action'; readonly trigger: () => void; readonly disabled: () => boolean; }; const ToolbarAction = craftComponent( 'ToolbarAction', {}, (input: { readonly key: string; readonly content: ContentSlot; readonly trigger: () => void; readonly disabled?: () => boolean; }) => ({ key: input.key, contract: { kind: 'toolbar-action', trigger: input.trigger, disabled: input.disabled ?? (() => false), } satisfies ToolbarActionContract, content: input.content, }), ({ contract, content }) => button('action', { type: 'button', disabled: contract.disabled, click: contract.trigger, }, renderContent(content), ), ); type ExtractedContract = ProjectionContractOf; type ToolbarActionUnit = ProjectionOf; ``` `ProjectionContractOf` extracts the type of `logicOutput.contract`. `ProjectionOf` adds the stable key the renderer expects. For generic consumers, `ProjectionSlot` directly describes a collection of compatible units. Projection therefore depends on **neither** the component's name, **nor** a `projection` metadata field, **nor** a runtime registry. ## Explicit collections, order and stable keys The consuming component receives a typed collection explicitly. Each unit must supply a **stable key**, which `forNode` uses to reuse, move or remove the right projection. ```ts import { craftComponent, div, forNode, renderContent, type ProjectionOf, } from '@craft-ts/component'; const Toolbar = craftComponent( 'Toolbar', {}, (input: { readonly actions: readonly ProjectionOf[]; }) => input, ({ actions }) => div( { role: 'toolbar' }, forNode(actions, { track: (action) => action.key }, (action) => renderContent(action), ), ), ); Toolbar({ actions: [ ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }), ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }), ], }); ``` The same `ToolbarAction` stays usable on its own: ```ts const Page = craftComponent( 'Page', {}, () => ({}), () => [ ToolbarAction({ key: 'standalone', content: () => 'Direct action', trigger: save, }), Toolbar({ actions: [ ToolbarAction({ key: 'projected', content: () => 'Projected action', trigger: save, }), ], }), ], ); ``` ## Styling projected content The component styles **its own frame** around the slot; the content is styled by whoever writes it, with their own sheet. What the frame offers its content travels the way everything crosses a component boundary in `@craft-ts/style`: inherited properties — `color`, fonts — and variables declared with `{ inherits: true }` that the content's sheet chooses to read. ```ts import { color, craftStyles, cssVars, display, kind, p, palette, space, } from '@craft-ts/style'; /** What the card offers its content: an inherited accent it may read. */ export const styledCardVars = cssVars('styledCard', { accent: kind.color(palette.accent.info, { inherits: true }), }); export const styledCard = craftStyles('styledCard', { // The frame around the slot. `color` inherits into the content. body: [display.block, p(space(4)), color(palette.text.strong)], }); /** The caller's own sheet, for the content it supplies. */ export const noteSheet = craftStyles('styledCardNote', { root: [color(styledCardVars.accent)], }); ``` ```ts import { content, craftComponent, div, p, renderContent, type ContentSlot, } from '@craft-ts/component'; import { noteSheet, styledCard } from './styledcard.style'; const StyledCard = craftComponent( 'StyledCard', {}, (input: { readonly body: ContentSlot }) => input, ({ body }) => div({ class: styledCard.body }, renderContent('body', body)), ); const Page = craftComponent( 'Page', {}, () => ({}), () => StyledCard({ body: content(() => p({ class: noteSheet.root }, 'Styled by its caller')), }), ); ``` Nothing reaches into the content: a caller that does not read `styledCardVars.accent` is unaffected by it, and a nested Craft component keeps its own classes. The deprecated `contentStyles` meta — CSS strings pushed into a slot — is refused by `no-component-css`. ## Pitfalls **Forgetting the stable key.** Without it the renderer cannot tell one projected unit from another across updates, and reuse breaks. **Expecting a plain component to satisfy a contract slot.** It stays perfectly usable as a direct child, but the slot rejects it: ```ts const PlainCard = craftComponent( 'PlainCard', {}, () => ({}), () => 'Card with no contract', ); Toolbar({ actions: [ // @ts-expect-error PlainCard does not expose ToolbarActionContract. PlainCard({}), ], }); ``` An incomplete contract is rejected where it is declared: ```ts const invalidContract = { kind: 'toolbar-action', // @ts-expect-error trigger and disabled are required. } satisfies ToolbarActionContract; ``` ::: details Combining optional content and contractual actions — a dialog A component can mix optional DOM content with several logical slots in one explicit collection: ```ts const Dialog = craftComponent( 'Dialog', {}, (input: { readonly body?: ContentSlot; readonly actions: readonly ProjectionOf[]; }) => input, ({ body, actions }) => section({ role: 'dialog' }, [ body ? renderContent(body) : [], footer( forNode(actions, { track: (action) => action.key }, (action) => renderContent(action), ), ), ]), ); Dialog({ body: content(() => div(['Delete the account', 'This action cannot be undone.']), ), actions: [ ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: closeDialog }), ToolbarAction({ key: 'delete', content: () => 'Delete', trigger: deleteAccount }), ], }); ``` `closeDialog` and `deleteAccount` are captured by the caller's closures. Projection preserves the lexical context **and the injector** of wherever the unit or the content was declared. ::: ::: details Conditions, reactivity and cleanup Projections are ordinary Craft nodes, so they can sit inside conditions and templates while keeping their identity by key within a collection. Here `visible` is a callable reactive value supplied by the caller: ```ts const OptionalToolbar = craftComponent( 'OptionalToolbar', {}, (input: { readonly visible: () => boolean; readonly actions: readonly ProjectionOf[]; }) => input, ({ visible, actions }) => visible() ? forNode(actions, { track: (action) => action.key }, (action) => renderContent(action), ) : [], ); ``` On update the renderer adds, removes and moves projections by key. On teardown the projected content, its effects and its styles are cleaned up with the rest of the tree. ::: ## API summary * `content(renderer, options?)` — create deferred DOM content * `renderContent(value)` and `renderContent(slotName, value)` — render it * `RenderableContent`, `ContentSlot` — free-form slots * `RequiredContent` — static DOM contracts * `ProjectionContractOf` — extract a logical contract * `ProjectionOf`, `ProjectionSlot` — type projectable collections The older fragment and slot primitives are no longer part of the public API. ## See Also * [Customization](/guide/components/customization) * [Styling a component](/guide/components/styles) * [Directives and `.pipe(...)`](/guide/components/directives) --- --- url: https://craft-ts.github.io/craft/guide/components/styles.md --- # Styling a component: the only way A component is styled through [`@craft-ts/style`](../style/), and through nothing else. Its visual rules live in a `*.style.ts` sheet beside it; the template binds the sheet's classes, sets `data-*` attributes for its variants, and writes typed variables for what changes at runtime. There is no CSS string on the meta, no `.css` import, no class assembled at render time and no raw `style`. That is not a preference. A class built in the browser, or a rule shipped as a string, is a visual state nothing recorded: the [visual matrix](/guide/style/testing) enumerates what the sheets declare, the [static contrast check](/guide/style/contrast) measures what the sheets write, and anything outside them is invisible to both. ESLint and the architecture suite therefore refuse every other route, and the one real exception is written down, with its reason, for someone to decide on. ## The shape The sheet declares the classes, the axis a variant moves along, and the variables a template may write: ```ts import { bg, blockSize, color, craftStyles, cssVars, defineStateAxis, fontWeight, inlineSize, kind, p, palette, radii, radius, set, space, unit, when, } from '@craft-ts/style'; /** A variant is an axis: the template sets `data-cardTone`. */ export const cardTone = defineStateAxis('cardTone', ['danger']); /** A value that changes at runtime is a typed variable, set with `assign`. */ export const cardVars = cssVars('card', { progress: kind.lengthPercentage(unit.pct(0)), accent: kind.color(palette.accent.info), }); export const card = craftStyles('docsCard', { root: [ p(space(4)), radius(radii.md), bg(palette.surface.raised), color(palette.text.strong), when(cardTone.danger, [set(cardVars.accent, palette.accent.danger)]), ], title: [fontWeight.bold], bar: [ inlineSize(cardVars.progress), blockSize(space(1)), bg(cardVars.accent), ], }); ``` The component imports it and binds **one constant class per element**: ```ts import { craftComponent, div, h2 } from '@craft-ts/component'; import { state } from '@craft-ts/core'; import { assign, unit } from '@craft-ts/style'; import { card, cardVars } from './card.style'; const UploadCard = craftComponent( 'UploadCard', {}, function* () { const progress = yield* state('progress', 40); // The variant's point, or null for none: the attribute is then removed. const tone = yield* state('tone', 'danger' as 'danger' | null); return { progress, tone }; }, ({ progress, tone }) => // One constant class per element; the variant is an attribute. div({ class: card.root, 'data-cardTone': tone }, [ h2({ class: card.title }, 'Upload'), div({ class: card.bar, // The only thing `style:` accepts: a typed variable, written by assign. style: function* () { return assign(cardVars.progress, unit.pct(yield* progress())); }, }), ]), ); ``` * `class` is always a sheet key (`card.root`), an array of them, or a typed input carrying one. Never a string, a template literal or a conditional. * The variant is an **attribute**. `data-cardTone` is on the element, the sheet reads it through `when(cardTone.danger, …)`, and a `null` removes it. * `style` accepts `assign(variable, value)` and nothing else — one call, several spread into an object, or a function returning them. ## Where each thing goes | You want | Write | | ------------------------------------------ | --------------------------------------------------------------------------------------- | | the component's own look | `craftStyles('name', { root: [...] })` in `name.style.ts` | | a variant (tone, size, selected) | `defineStateAxis(...)`, then `when(axis.point, [...])`; the template sets `data-*` | | a state the platform already announces | `ariaCurrent`, `ariaPressed`, `ariaInvalid`, `interaction.hover` / `.focus` / `.disabled` | | a value known only at runtime | `cssVars(...)` in the sheet, `assign(...)` in the template | | a child that follows its parent's state | a variable declared with `{ inherits: true }`, set by the parent, read by the child | | page defaults (`body`, links, the theme) | [`craftGlobalStyles`](/guide/style/foundation) | | a web font | [`defineFont`](/guide/style/foundation) | | `::before`, `@keyframes`, transitions | [`pseudo.*`, `keyframes`, `animate`](/guide/style/pseudo-elements) | The reset and the good defaults — focus ring, reduced motion, colour scheme — come from `@craft-ts/style` itself. An app has no `styles.css` to write. ## What refuses the other routes Per file, in `craftRules.configs.recommended` ([details](/guide/routing/eslint-rules)): * `no-raw-class` — a `class` that does not trace back to a sheet imported from a `*.style` module; * `no-inline-style` — a `style` that is not `assign(...)`; * `no-component-css` — `meta.styles`, `meta.stylesUrl`, `meta.contentStyles`, and any `.css` import other than `virtual:craft-style.css`; * `style-file-boundary` — a sheet importing anything but style vocabulary; * `no-raw-css-value`, `no-free-has` — a raw value or a hand-written `:has()` inside a sheet. Across the application, in the base architecture rules ([details](/guide/testing/architecture)): * `style-only-design-system` — an element whose class reaches no sheet the build emits; * `no-global-stylesheet` — an entry file importing a `.css`, or `index.html` linking a stylesheet; * `style-obligations-discharged`, `no-dangling-css-vars` — a `requires` nobody provides, a variable read and never declared. `styles`, `stylesUrl`, `contentStyles` and `cssVars` still exist on the meta's type, marked `@deprecated`. They are kept so that the exception below stays possible, not as an alternative. ## The one real exception Content you do not author — HTML rendered from markdown, a third-party widget that ships its own stylesheet — cannot be styled through a sheet. It is the only case, and it takes an explicit, reasoned bypass: ```ts // eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet import 'vendor-date-picker/dist/picker.css'; ``` `no-forbidden-eslint-disable` refuses the directive without its reason. On the architecture side, the bypass is a waiver in `architecture/waivers.ts`: ```ts { rule: 'no-global-stylesheet', target: 'file:src/main.ts', reason: 'The vendor date picker ships its stylesheet.', } ``` A waiver names one target, not a rule wholesale, and one that no longer waives anything fails the check. Both kinds of bypass appear in the **Bypasses** view of [Review Attest](/guide/style/attestation), one subject per directive or waiver, to be accepted or rejected like any other evidence. ## See Also * [`@craft-ts/style`](/guide/style/) — the design system, from tokens to the matrix * [Axes and the visual matrix](/guide/style/variants) * [Customization](/guide/components/customization) — host properties and caller overrides --- --- url: https://craft-ts.github.io/craft/guide/components/template-migrator.md --- # Template migrator Paste an HTML snippet or a web component from a UI library's documentation. The converter generates the equivalent Craft functional template and the imports it needs from `@craft-ts/component`. **Use it to** bring markup from outside — a design system's docs, a CodePen, an existing template markup — into Craft's template syntax without transcribing it by hand. ## What it produces By default the result is a callback to paste as the fourth argument of `craftComponent(...)`. Fill in a name to generate a complete component instead. Native HTML tags become the matching helpers (`div`, `button`, `section`, …); custom tags become `customElement('my-element', ...)`. ## Pitfalls **Interpolations and bindings are preserved as expressions**, not translated. Adapt them to your Craft context. **Control-flow directives are not converted.** Rewrite conditional and repeated sections with `ifNode` or `forNode`. ## See Also * [Directives and `.pipe(...)`](/guide/components/directives) * [CLI automation](/guide/routing/automation) — codemods for the rest of a migration --- --- url: https://craft-ts.github.io/craft/guide/forms.md --- # Forms There is no `FormBuilder` here. **A form is derived from a state** — its field tree, its validity and its error types are all consequences of that state and of the mutation it submits to, so they cannot drift apart from them. **Use it when** you collect input that needs validation and a typed submission. **Not when** a single input maps to a single state — a plain [`state`](/guide/state/local-state) with a `set` is enough. ::: tip Start with the guided version [Learn step 8](/learn/08-forms) builds a small form end to end before you dig into the individual insertions. ::: ## Choose the insertion When a requirement mentions a form, start from `state` and add `insertForm`. This map keeps the form tree, validation and mutation in one graph: | Need | Recommended API | | ------------------------------- | -------------------------------------- | | Form derived from state | `state` + `insertForm` | | Nested field or object branch | `insertSelectFormTree` | | Field attributes and validation | `insertFormAttributes` | | Whole-form validation | `insertFormSchema` | | Submit to a mutation | `insertFormSubmit` | | Field errors | `field.exceptions` or `fieldErrorNode` | | Submission state | `form().submitting()` | `insertNoopTypingAnchor` is only a type-inference anchor for a selected field; it adds no runtime behaviour. The common field shape is therefore: ```ts insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })), ); ``` ## When an input drives an action The recommended ESLint preset enables `craft-ts/require-form-for-input-action`. It catches the common interaction where an input writes a local value and a button later reads that value to call an action such as `mutation.mutate(...)` or `asyncProcess.method(...)`. The dependency may be nested in a record or pass through a local variable; it does not have to be the only argument. That interaction is a form workflow, even when it currently has only one field: ```ts // ❌ input state is assembled by hand and consumed by a button action const title = yield* state('title', ''); input({ value: title, input: (event) => setTitle(eventValue(event)) }); button({ click: () => addTodo.mutate(title()) }, 'Add'); ``` Model the value and the submit boundary together instead: ```ts // ✅ state + insertForm + form submit const titleForm = yield* state( 'titleForm', '', insertForm( insertFormAttributes(() => ({ validators: [cRequired()] })), insertFormSubmit(addTodo), ), ); form('AddTodoForm', { *submit(event) { event.preventDefault(); yield* titleForm.form.submit(); }, }, [ input('TodoTitleInput', { type: 'text' }).pipe( CraftFieldDirective(titleForm.form), ), button('AddTodoButton', { type: 'submit' }, 'Add'), ]); ``` The rule is intentionally conservative: it does not report an input unless an action in the same Craft template consumes the input's bound value. It also does not replace type checking: validation, `insertFormAttributes`, `CraftFieldDirective`, field errors, and the exact `insertFormSubmit` wiring remain the form author's responsibility. The architecture check adds the cross-file constraint: a mutation-backed form must use `insertFormSubmit` and a native `type: 'submit'` button; the button must not call `mutate(...)` directly. `insertForm()` by itself is a valid form insertion, but it is only the base derivation. Add `insertFormAttributes` when the form has validators or field rules, and add `insertFormSubmit(mutation)` when the form owns a mutation submission. `insertSelectFormTree` is needed for fields inside an object-valued form; it is not needed for a scalar string form. ## Native controls, one binding rule `CraftFieldDirective` supports text inputs, checkboxes, selects and textareas. Keep a stable `id`/`htmlFor` pair and put the directive on the native control: ```ts import { input, option, select, textarea } from '@craft-ts/component'; input('animal-name', { id: 'animal-name' }).pipe( CraftFieldDirective(animal.form.selectName()), ); input('animal-available', { id: 'animal-available', type: 'checkbox' }).pipe( CraftFieldDirective(animal.form.selectAvailable()), ); select('animal-species', { id: 'animal-species' }, [ option('dog', { value: 'dog' }, 'Dog'), option('cat', { value: 'cat' }, 'Cat'), ]).pipe(CraftFieldDirective(animal.form.selectSpecies())); textarea('animal-notes', { id: 'animal-notes' }).pipe( CraftFieldDirective(animal.form.selectNotes()), ); ``` A checkbox maps to a boolean, a select to its option value, and a textarea to a string; nested fields use the corresponding selector chain. ## A complete form in one file This is the shortest complete path: typed state, required/email validation, a mutation, server exceptions, submitting state and errors rendered next to the controls. The text in a real application should come from its i18n catalogue. ```ts import { button, craftComponent, fieldErrorNode, form, input, label, p, } from '@craft-ts/component'; import { cEmail, cRequired, CraftFieldDirective, craftException, insertForm, insertFormAttributes, insertFormSubmit, insertNoopTypingAnchor, insertSelectFormTree, mutation, state, type ValidatedFormValue, } from '@craft-ts/core'; type Animal = { name: string; email: string }; const saveAnimal = mutation('saveAnimal', { method: (value: NonNullable>) => value, loader: ({ params }) => params.email.endsWith('@taken.test') ? craftException({ _tag: 'EMAIL_ALREADY_USED' }, { field: 'email' }) : params, }); export const AnimalForm = craftComponent( 'AnimalForm', {}, function* () { const animal = yield* state( 'animalForm', { name: '', email: '' } satisfies Animal, insertForm( insertSelectFormTree( 'name', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()] })), ), insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })), ), insertFormSubmit(saveAnimal), ), ); return { animal }; }, ({ animal }) => form( 'animal-form', { *submit(event) { event.preventDefault(); yield* animal.form.submit(); }, }, [ label({ htmlFor: 'animal-name' }, 'Name'), input('animal-name', { id: 'animal-name' }) .pipe(CraftFieldDirective(animal.form.selectName())) .pipe( fieldErrorNode.exhaustive({ required: () => p('Name is required.'), }), ), label({ htmlFor: 'animal-email' }, 'Email'), input('animal-email', { id: 'animal-email', type: 'email' }) .pipe(CraftFieldDirective(animal.form.selectEmail())) .pipe( fieldErrorNode.exhaustive({ required: () => p('Email is required.'), email: () => p('Enter a valid email.'), }), ), button( 'animal-submit', { type: 'submit', disabled: animal.form.submitting }, 'Save', ), p(function* () { if (!(yield* animal.form.hasSubmitExceptions())) return ''; return 'The server rejected this animal.'; }), ], ), ); ``` The advanced version uses the same primitives for nested `address` fields, conditional visibility and asynchronous validation. Keep the branch insertion in the feature rather than hiding it in a component library; see [Nested forms](/guide/forms/nested) and [Validation](/guide/forms/validation#casyncvalidate). ## Why it is shaped this way Three pillars, all of which follow from deriving rather than declaring: 1. **Form Insertions** - Modular composition to tackle logic complexity 2. **Type-safe errors** - Synchronous and asynchronous validation with type-safe exceptions (inferred from validators and submit handler) 3. **Parallel Forms** - Support for multiple forms in the same state with automatic scoping All of this is possible because the logic is entirely derived from the state. ## Form Insertions Form insertions enable modular composition of functionality: ### insertForm The primary insertion that derives a typed form from a primitive. ```ts import { craftUse, state } from '@craft-ts/core'; import { insertForm, insertFormAttributes, insertNoopTypingAnchor, insertSelectFormTree, cRequired, cEmail, } from '@craft-ts/core'; const userFormState = craftUse( state( 'userFormState', { name: '', email: '' }, insertForm( insertSelectFormTree( 'name', insertNoopTypingAnchor, // TS limitation insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSelectFormTree( 'email', insertNoopTypingAnchor, // TS limitation insertFormAttributes(() => ({ validators: [cRequired(), cEmail()], })), ), ), ), ); const form = userFormState.form; const nameField = form.selectName(); const emailField = form.selectEmail(); ``` > Note: It only works with the `state` primitive from now. > `insertNoopTypingAnchor` is a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...) ### insertFormAttributes Adds attributes and validators to a form field. ```ts const formState = craftUse( state( 'formState', { email: '' }, insertForm( insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()], disable: () => isLoading(), hidden: () => !showField(), })), ), ), ), ); // Access email field and its exceptions const form = formState.form; const emailField = form.selectEmail(); const errors = emailField()().exceptions.list; // fully typed list of exceptions const emailError = emailField()().exceptions.byValidator['cEmail']; ``` ### Bind a field to the DOM `CraftFieldDirective` is the DOM adapter for a `CraftField`. It binds the field in both directions, marks it touched on blur, and reflects field state through native attributes and `craft-*` CSS classes. In a Craft template, apply the functional directive to the concrete node: ```ts import { CraftFieldDirective } from '@craft-ts/core'; input({ type: 'email', }).pipe(CraftFieldDirective(loginForm.form.selectEmail())); ``` `insertSelectFormTree` materializes its branch lazily. When validators or other insertions are attached through it, bind the field returned by `selectEmail()` (or the corresponding `selectXxx()` method). Binding the raw `loginForm.form.email` field bypasses that materialization, so those insertions are not registered. The directive supports text inputs and textareas, numeric and temporal inputs, checkboxes, radio groups and selects. Validators also project native constraints such as `required`, `min`, `max`, `minlength` and `maxlength`. For a custom control, provide `CRAFT_FIELD_VALUE_CONTROL` or `CRAFT_FIELD_CHECKBOX_CONTROL` on the component root. Native Craft nodes use the functional directive directly. ### Render validation exceptions exhaustively `fieldErrorNode.exhaustive` turns validation cases carried by `CraftFieldDirective` or exposed by the component logic into compile-time UI obligations. Every reachable code must have one handler, and an unreachable handler is also rejected. ```ts import { fieldErrorNode, input, p } from '@craft-ts/component'; input({ id: 'email', type: 'email' }) .pipe(CraftFieldDirective(loginForm.form.selectEmail())) .pipe( fieldErrorNode.exhaustive({ required: () => p('Email is required.'), email: () => p('Enter a valid email.'), }), ); ``` The field stays mounted and invalid while a message is visible. The block adds and merges `aria-invalid` and `aria-describedby`; it does not throw an exception or feed route `handleExceptions`. Use `fieldErrorNode.partial` when only some codes belong near the field. Handled codes are removed from its contract and the remaining codes continue to the next field-exception boundary: ```ts input({ id: 'password', type: 'password' }) .pipe(CraftFieldDirective(loginForm.form.selectPassword())) .pipe( fieldErrorNode.partial({ required: () => p('Password is required.'), }), ); ``` Here `password.required` is handled locally, while `password.minLength` must still be handled by an enclosing `partial` or `exhaustive` block. A partial block may omit reachable codes, but an unreachable handler remains a TypeScript error. At a component boundary, group handlers by static field path. Identical codes on different fields remain separate obligations: ```ts const SafeLoginForm = BaseLoginForm.pipe( fieldErrorNode.exhaustive({ email: { required: () => p('Email is required.'), email: () => p('Enter a valid email.'), }, password: { required: () => p('Password is required.'), minLength: ({ exception }) => p(`Use at least ${exception.payload} characters.`), }, }), ); ``` Object branches may also carry group or cross-field validators. Materialize the branch in the component logic and return it from the factory: ```ts const credentials = registration.form.selectCredentials(); return { registration, credentials }; ``` Its cases, for example `credentials.passwordMismatch`, are part of the component contract even when the group itself is not passed to `CraftFieldDirective`. Handle the grouped path on an enclosing template VNode or with `BaseComponent.pipe(fieldErrorNode.exhaustive(...))`. If it remains unhandled, rendering, mounting, and `loadCraftComponent` reject the component at compile time. See [Form exception handling](/guide/forms/exceptions) for the complete group example. By default the block reads the field's `visibleExceptions` directly. The form owns that visibility policy; the default is touched or submitted: ```ts insertFormAttributes(() => ({ validators: [cRequired(), cEmail()], exceptionVisibility: { anyOf: ['touched', 'submitted'] }, })); ``` After a blur, only that field's visible exceptions are rendered. A submit attempt reveals the remaining exceptions for every field. Available states are `dirty`, `touched`, and `submitted`; a block can override the inherited policy with `visibility: 'always'`, another `anyOf` combination, or a predicate. `mode` is `first` (validator order) or `all`, and `position` is `before` or `after`. Resetting the form clears dirty, touched, and submitted, so inherited messages are hidden again. Custom and async validators participate through their declared exception union exactly like built-ins: their codes must be handled even when the current visibility policy hides them. ### insertFormSchema Adds a form-level `StandardSchemaV1` validator. Issues are projected onto the matching fields by their schema path, while root and unmaterialized issues stay available through `schemaExceptions()`. ```ts const formState = craftUse( state( 'formState', { email: '' }, insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)), ), ); const form = formState.form; form.email.errors(); form.hasSchemaExceptions(); form.schemaExceptions(); ``` The form keeps the schema input value. Schema transformations belong at the submit boundary, for example through the mutation's `methodSchema`. ### insertFormSubmit `insertFormSubmit` connects the form to a mutation. It submits only validated form values and exposes the mutation's loading and typed exception state on the form. See [Submitting a form](/guide/forms/submit) for the complete submission workflow, including success handling and exception transformations. ## The pages * **[Validation](/guide/forms/validation)** — built-in, custom and async validators * **[Submitting](/guide/forms/submit)** — wiring a form to a mutation, typed submit exceptions * **[Nested forms](/guide/forms/nested)** — sub-trees and sub-form fields * **[Exception handling](/guide/forms/exceptions)** — reading and shaping form errors * **[Complete examples](/guide/forms/examples)** — two forms end to end ## See Also * [Validators](/guide/forms/validation) * [Submitting](/guide/forms/submit) * [Learn step 8](/learn/08-forms) — a form built end to end --- --- url: https://craft-ts.github.io/craft/guide/forms/validation.md --- # Validators Validators are declared on a field and produce **typed exceptions** — so the errors a field can raise are known to the compiler, not discovered at runtime. **Start with the built-ins** below; reach for `cValidate` / `cAsyncValidate` when a rule is specific to your domain. @craft-ts provides a complete set of validators with structured exception handling: ## Schema validation Use `insertFormSchema` when the rules describe the complete form value rather than one field at a time. It accepts any schema compatible with `StandardSchemaV1`, including current versions of Zod, Valibot, ArkType and Effect Schema — the latter through [`Schema.toStandardSchemaV1`](/guide/state/schema-validation#effect-schema). ```ts import { z } from 'zod'; import { craftUse, insertForm, insertFormSchema, state } from '@craft-ts/core'; const userSchema = z.object({ name: z.string().min(1), email: z.string().email(), address: z.object({ zip: z.string().length(5), }), }); const userFormState = craftUse( state( 'userForm', { name: '', email: '', address: { zip: '' }, }, insertForm(insertFormSchema(userSchema)), ), ); const form = userFormState.form; ``` Issues with a Standard Schema path are projected onto the matching field: ```ts form.email.errors(); form.address.zip.errors(); form.schemaExceptions(); // also includes root/unmaterialized issues ``` Issues without a path remain on the form root. The form is invalid while any schema issue exists, so `validatedFormValue()` is `undefined` and `insertFormSubmit` does not call its mutation. Schema validation is synchronous in forms. Use `cAsyncValidate` for an asynchronous field rule or an async resource for a server-side check. ### Schema transformations Following the Standard Schema form convention, validation does not replace the form's input value with the schema output: ```ts const schema = z.object({ age: z.string().transform(Number), }); ``` The form keeps `age` as a string. If the submit payload needs the transformed number, put the same schema on the mutation's `methodSchema`; the mutation method then receives the parsed output: ```ts const saveUser = mutation('saveUser', { methodSchema: schema, method: (user) => user, // user.age is number loader: saveUserRequest, }); ``` `schemaExceptions()` returns typed `SCHEMA_VALIDATION_ERROR` exceptions. Each exception contains the original Standard Schema issue and its path in `payload.issues`. ## Built-in Validators ### cRequired Checks that a value is present (not empty). ```ts insertFormAttributes(() => ({ validators: [cRequired()], })); // With condition insertFormAttributes(() => ({ validators: [cRequired({ when: () => fieldIsRequired() })], })); ``` ### cEmail Checks that a string is a valid email. ```ts insertFormAttributes(() => ({ validators: [cEmail()], })); ``` ### cMin / cMax Checks that a numeric value is within a range. ```ts insertFormAttributes(() => ({ validators: [cMin({ min: 18 }), cMax({ max: 100 })], })); // Dynamic values insertFormAttributes(() => ({ validators: [cMin({ min: () => minimumValue() })], })); ``` ### cMinLength / cMaxLength Checks the length of a string or collection. ```ts insertFormAttributes(() => ({ validators: [cMinLength({ minLength: 8 }), cMaxLength({ maxLength: 500 })], })); ``` ### cPattern Checks that a string matches a regex pattern. ```ts insertFormAttributes(() => ({ validators: [cPattern({ pattern: /^\d{10}$/ })], })); ``` ## Custom Validators ### cValidate Creates a custom synchronous validator. ```ts insertFormAttributes(() => ({ validators: [ cValidate({ name: 'passwordStrength', validWhen: () => { const pwd = password(); return pwd.length >= 8 && /[A-Z]/.test(pwd); }, exception: () => craftException( { _tag: 'weak-password' }, { message: 'Password must contain 8 characters and an uppercase letter', }, ), }), ], })); ``` ### Group and cross-field validation `insertFormAttributes` can target an object branch as well as a leaf field. Use that branch when one rule depends on several values, such as password and confirmation: ```ts function* registrationLogic() { const registration = yield* state( 'registration', { credentials: { password: '', confirmation: '', }, }, insertForm( insertSelectFormTree( 'credentials', insertNoopTypingAnchor, insertFormAttributes(({ field }) => ({ validators: [ cValidate({ name: 'passwordsMatch', validWhen: () => field.value().password === field.value().confirmation, exception: () => craftException({ _tag: 'passwordMismatch' }, undefined), }), ], })), ), ), ); const credentials = registration.form.selectCredentials(); return { registration, credentials }; } ``` Calling `selectCredentials()` materializes the branch insertion. Returning the selected group from component logic exposes the typed case `credentials.passwordMismatch` to the component contract. It must then be handled in the template or at a component boundary before the component can be rendered, mounted, or loaded by a route. The group does not need its own DOM control or `CraftFieldDirective`. Bind its leaf fields normally and render the group message on an enclosing boundary. See [Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator) for both rendering options. ### cAsyncValidate Creates an asynchronous validator based on a resource (query or mutation). ::: warning It is not working yet. We are still working on it. The API is not final and may change. ::: ```ts const { checkEmailQuery } = query('checkEmailQuery', { params: () => ({ email: emailInput() }), loader: async ({ params }) => { const response = await fetch(`/api/check-email?email=${params.email}`); return response.json(); }, }); insertFormAttributes(() => ({ validators: [ cAsyncValidate(checkEmailQuery, { name: 'emailAvailability', exceptionsOnSuccess: ({ validateAsyncCraftResource }) => { if (!validateAsyncCraftResource.value()?.available) { return craftException({ _tag: 'email-taken' }, undefined); } return undefined; }, }), ], })); ``` ## See Also * [Forms overview](/guide/forms/) * [Form exceptions](/guide/forms/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/forms/submit.md --- # Submitting a form `insertFormSubmit` connects the form to a [mutation](/guide/state/mutations), so submission gets its loading state, its failure state and — the point — a **typed union of the exceptions submission can produce**, inferred from that mutation. **Use it when** the form writes somewhere. **Reshape the codes** with the `exceptions` pipeline when the server's vocabulary isn't the one your UI should show. ```ts const { updateUserMutation } = mutation('updateUserMutation', { method: (data: ValidatedFormValue) => data, loader: function* ({ params: user }) { return yield* CraftHttpClient.patch(({ response, status }) => ({ url: '/api/users', body: user, success: response(), exceptions: [ function* ({ status }) { if (!(yield* status(409))) { return; } return craftException( { _tag: 'USER_EMAIL_ALREADY_EXISTS' }, { message: 'This email is already used' as const }, ); }, ], })); }, }); const { userFormState } = state( 'userFormState', { name: '', email: '' }, insertForm( insertSelectFormTree( 'name', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()] })), ), insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })), ), insertFormSubmit(updateUserMutation, { success: () => { console.log('Form submitted successfully'); return undefined; }, exceptions: [ ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']), ({ submitCraftResource }) => { const emailConflict = submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS; if (!emailConflict) return undefined; return craftException( { _tag: 'EMAIL_NOT_AVAILABLE' }, emailConflict.payload, ); }, ], }), ), ); // Submit the form userFormState.form.submit(); // Automatically triggers the mutation // Submit exceptions are inferred from the mutation and the `exceptions` rules. const submitErrors = userFormState.form.submitExceptions(); const firstSubmitError = submitErrors[0]?.code; // 'EMAIL_NOT_AVAILABLE' ``` ::: warning What `success` is for `success` runs inside the **derivation of the submit exception list**, and its return value is appended to that list. Its purpose is to raise an exception the server reported alongside a successful response — not to run side effects. Resetting the form, navigating or showing a toast from there mutates state inside a computation and re-runs whenever the exceptions recompute. Drive those from your own code after `submit()`, or from the mutation itself. The example above logs from `success` only to show where the hook fires. ::: `insertFormSubmit` preserves mutation exceptions by default. Use `exceptions` as an ordered pipeline when you want to refine the submit exceptions exposed by the form: ```ts insertFormSubmit(updateUserMutation, { exceptions: [ // `omit` autocompletes the exception codes produced by `updateUserMutation`. ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']), // Returning a Craft exception appends it to the current submit exceptions. ({ submitCraftResource }) => { if (submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS) { return craftException( { _tag: 'EMAIL_NOT_AVAILABLE' }, { message: 'This email is already used' as const }, ); } return undefined; }, ], }); ``` Returning an array, like `omit(...)`, replaces the current submit exception list. Returning a single `craftException(...)` adds it. The final inferred union is available through: ```ts const submitExceptions = userFormState.form.submitExceptions(); const aggregatedSubmitExceptions = userFormState.form.exceptions().submit; ``` ## See Also * [Forms overview](/guide/forms/) * [Form exceptions](/guide/forms/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/forms/nested.md --- # Nested forms `insertSelectFormTree` targets a branch of the form, and `insertSubFormField` declares a sub-form inside it — for state that is not flat. **Use them when** the state has nested objects or arrays of objects. **Not when** the form is one level deep — attach [`insertFormAttributes`](/guide/forms/) directly. ## insertSelectFormTree Selects and composes nested sub-forms. ```ts interface ProductForm { name: string; variants: Array<{ color: string; stock: number; }>; } const { productFormState } = state( 'productFormState', { name: '', variants: [] } as ProductForm, insertForm( insertSelectFormTree( 'variant', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cMin({ min: 0 })], })), ), ), ); // Access sub-forms const form = productFormState.form; const variant0 = form.selectVariant(0); const allVariants = form.items(); ``` Selection is lazy: calling `selectVariant(...)`, `items()`, or an object selector such as `selectEmail()` materializes the selected branch and registers its insertions. Pass that selected field to DOM bindings; accessing the raw field tree alone does not run the branch insertions. When an object branch has a group validator but no matching DOM control, materialize it in the component logic and return the selected group from the factory instead: ```ts const credentials = registration.form.selectCredentials(); return { registration, credentials }; ``` Its typed validation cases then belong to the component contract without requiring `CraftFieldDirective(credentials)`. Bind the leaf controls and handle the group path on an enclosing `fieldErrorNode`. See [Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator). ## insertSubFormField Exposes a derived sub-form from a parent value through a lens. This is useful when the form field is not stored as a nested object in the state, but can still be read and written from the parent value. ```ts import { state } from '@craft-ts/core'; import { insertForm, insertFormAttributes, insertSubFormField, splitLens, cRequired, } from '@craft-ts/core'; const { appointmentFormState } = state( 'appointmentFormState', '2026-05-10 12:00', insertForm( insertSubFormField( 'date', splitLens(' ', 0), insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSubFormField('time', splitLens(' ', 1)), ), ); const form = appointmentFormState.form; const dateField = form.selectDate(); const timeField = form.selectTime(); console.log(dateField.value()); // '2026-05-10' console.log(timeField.value()); // '12:00' dateField.set('2026-05-11'); timeField.set('09:30'); console.log(appointmentFormState()); // '2026-05-11 09:30' ``` ## See Also * [Forms overview](/guide/forms/) * [Validation](/guide/forms/validation) --- --- url: https://craft-ts.github.io/craft/guide/forms/exceptions.md --- # Form exception handling Form validation exceptions are typed UI obligations. A component must handle every reachable exception in its template or forward the remaining cases to a component boundary before it can be rendered, mounted, or used by a route. **Read this when** you render validation messages, split them between several locations, or validate a group of fields. ## Reading exceptions as values Validators do not throw. They keep the field and form invalid and expose their exceptions as signals: ```ts const email = loginForm.form.selectEmail(); email.errors(); email.exceptions().list; email.exceptions().byValidator.cRequired; email.firstLeftFailedValidation(); email.lastRightFailedValidation(); ``` Handling an exception only renders its message. It does not remove the exception or make the field valid. ## Case 1: handle every exception beside one field `CraftFieldDirective` carries the field's exact validator cases onto the VNode. An exhaustive block must provide exactly one handler for every reachable code: ```ts input({ id: 'email', type: 'email' }) .pipe(CraftFieldDirective(loginForm.form.selectEmail())) .pipe( fieldErrorNode.exhaustive({ required: () => p('Email is required.'), email: () => p('Enter a valid email.'), }), ); ``` A missing handler and an unreachable extra handler are both TypeScript errors. ## Case 2: handle only some exceptions locally Use `partial` when an exception belongs beside the control while the remaining cases should continue to an enclosing boundary: ```ts input({ id: 'password', type: 'password' }) .pipe(CraftFieldDirective(loginForm.form.selectPassword())) .pipe( fieldErrorNode.partial({ required: () => p('Password is required.'), }), ); ``` If the field also declares `minLength`, that case remains in the component's contract until another `partial` or `exhaustive` block handles it. ## Case 3: handle several fields at a component boundary At a boundary that receives more than one field path, group handlers by their static path. Identical codes on different fields remain separate obligations: ```ts const SafeLoginForm = BaseLoginForm.pipe( fieldErrorNode.exhaustive({ email: { required: () => p('Email is required.'), email: () => p('Enter a valid email.'), }, password: { required: () => p('Password is required.'), minLength: ({ exception }) => p(`Use at least ${exception.payload} characters.`), }, }), ); ``` ## Case 4: handle a group or cross-field validator A group validator is declared on an object branch rather than on one leaf control. Materialize that branch in the component logic and return it from the factory: ```ts function* registrationLogic() { const registration = yield* state( 'registration', { credentials: { password: '', confirmation: '', }, }, insertForm( insertSelectFormTree( 'credentials', insertNoopTypingAnchor, insertFormAttributes(({ field }) => ({ validators: [ cValidate({ name: 'passwordsMatch', validWhen: () => field.value().password === field.value().confirmation, exception: () => craftException({ _tag: 'passwordMismatch' }, undefined), }), ], })), ), ), ); const credentials = registration.form.selectCredentials(); return { registration, credentials }; } ``` The component logic now declares the typed obligation `credentials.passwordMismatch`. The group itself does not need a `CraftFieldDirective`; only its leaf controls need their usual DOM bindings. ### Handle the group in the template A grouped handler on an enclosing VNode consumes the logic-level obligation: ```ts ({ credentials }) => div([ input({ type: 'password' }).pipe(CraftFieldDirective(credentials.password)), input({ type: 'password' }).pipe( CraftFieldDirective(credentials.confirmation), ), ]).pipe( fieldErrorNode.exhaustive({ credentials: { passwordMismatch: () => p('Passwords do not match.'), }, }), ); ``` The exception source is registered from the component logic, independently of a DOM binding for the group. ### Forward the group to the component boundary The template may leave the group case unresolved and let the component boundary handle it: ```ts const SafeRegistrationForm = BaseRegistrationForm.pipe( fieldErrorNode.exhaustive({ credentials: { passwordMismatch: () => p('Passwords do not match.'), }, }), ); ``` If neither location handles it, using the component is a compile-time error: ```ts // TypeScript error: credentials.passwordMismatch remains unhandled. loadCraftComponent(async () => BaseRegistrationForm); ``` ## Visibility: blur and submit By default, a block consumes `visibleExceptions`. A validation exception is visible when its field or group is touched, or after a submit attempt: ```ts insertFormAttributes(() => ({ validators: [cRequired()], exceptionVisibility: { anyOf: ['touched', 'submitted'] }, })); ``` After a blur, only the touched field and its parent groups reveal their remaining exceptions. A submit attempt reveals every remaining exception in the form. Resetting the form clears `dirty`, `touched`, and `submitted`, so the messages become hidden again. Use `visibility: 'always'`, another `anyOf` combination, or a predicate to override this policy on one block. `mode` controls whether the first or all matching exceptions render, and `position` selects `before` or `after`. ## Submission and schema exceptions `insertFormSubmit` exposes submission exceptions separately from field validation cases. `insertFormSchema` projects issues with paths onto matching fields and leaves pathless issues on the form root through `schemaExceptions()`. ## See also * [Validation](/guide/forms/validation) * [Nested forms](/guide/forms/nested) * [Submitting a form](/guide/forms/submit) * [Exceptions as values](/guide/concepts/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/forms/examples.md --- # Complete form examples Two forms end to end, assembling the pieces from the other pages: a flat creation form with validation and submission, then a nested one. **Read this after** [the overview](/guide/forms/) — these examples don't introduce anything new, they show the parts fitting together. ## Creation form with validation ```ts interface User { name: string; email: string; age: number; } const { createUserMutation } = mutation('createUserMutation', { method: (data: ValidatedFormValue) => data, loader: function* ({ params: user }) { return yield* CraftHttpClient.update(({ response }) => ({ url: '/api/users', body: user, success: response(), })); }, }); const { userFormState } = state( 'userFormState', { name: '', email: '', age: 0 } satisfies User, insertForm( insertSelectFormTree( 'name', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()], })), ), insertSelectFormTree( 'age', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cMin({ min: 18 })], })), ), insertFormSubmit(createUserMutation), ), ); ``` ## Complex Nested Form When a selected branch needs more than two insertions, compose them with `craftPipe`. The common two-insertion case (`insertNoopTypingAnchor` followed by `insertFormAttributes`) can be passed directly as the second argument. ```ts interface Address { street: string; city: string; zipCode: string; } interface User { name: string; email: string; addresses: Address[]; } const { userFormState } = state( 'userFormState', { name: '', email: '', addresses: [], } satisfies User, insertForm( insertSelectFormTree( 'name', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSelectFormTree( 'email', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cEmail()], })), ), insertSelectFormTree( 'addresses', (context) => craftPipe( context, insertNoopTypingAnchor, insertSelectFormTree( 'street', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSelectFormTree( 'city', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired()], })), ), insertSelectFormTree( 'zipCode', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cPattern({ pattern: /^\d{5}$/ })], })), ), ), ), insertSelectFormTree( 'address', insertNoopTypingAnchor, insertFormAttributes(() => ({ validators: [cRequired(), cMinLength({ minLength: 5 })], })), ), ), ); ``` ## See Also * [Forms overview](/guide/forms/) * [Validators](/guide/forms/validation) * [Nested forms](/guide/forms/nested) --- --- url: https://craft-ts.github.io/craft/guide/testing/services.md --- # Testing services Most test setups let you forget a dependency and find out at runtime. This one inverts it: you supply a **register** covering the service's whole dependency graph, and the compiler refuses to run the test until every node is accounted for. **Use it for** any `craftService`. **Use `boundaryOnly`** when the test should stay close to reality — it keeps the real graph and only lets you replace [browser boundaries](/guide/testing/browser-boundaries). ::: tip The register is as small as your yields A service that yielded one property needs one property mocked. Precise yields are what keep these tests short — see [Shaping the public API](/guide/app/expose-api). ::: ## Import ```typescript import { setupCraftServiceTestingByRegister, } from '@craft-ts/core'; ``` ## Introduction `setupCraftServiceTestingByRegister` is the exhaustive testing utility for the `craftService` graph. Instead of providing only the overrides you care about, you provide a full typed register where each service is marked as: * real * provided by its raw `provideX(...)` * mocked with a raw object * or pruned with `'notReached'` This is useful when you want explicit control over every node in the service graph. For tests that should stay close to reality, use `boundaryOnly`. It keeps the application graph real by default and only lets you decide the services marked with `browserBoundary: true`. ## Register Workflow The intended workflow is: 1. start from the full dependency graph of the SUT 2. fill each key with a real provider, `'real'`, a mock object, or `'notReached'` 3. pass the register to `await setupCraftServiceTestingByRegister(...)` ## Basic Example ```typescript import { craftService, craftUse, setupCraftServiceTestingByRegister, state, } from '@craft-ts/core'; import { vi } from 'vitest'; const { Counter } = craftService( { name: 'Counter', providedIn: 'global' }, function* () { const counter = yield* state('counter', 10, ({ update }) => ({ increment: () => update((value) => value + 1), })); return counter; }, ); const { CounterConsumer, provideCounterConsumer } = craftService( { name: 'CounterConsumer', providedIn: 'toProvide' }, function* () { const counter = yield* Counter(); return { read: function* () { return yield* counter(); }, increment: function* () { return yield* counter.increment(); }, }; }, ); const { sut, mocks } = await setupCraftServiceTestingByRegister( CounterConsumer, { CounterConsumer: provideCounterConsumer(), Counter: { $self: vi.fn(() => 41), increment: vi.fn(), }, }, ); expect(craftUse(sut.read())).toBe(41); craftUse(sut.increment()); expect(mocks.Counter.increment).toHaveBeenCalledTimes(1); ``` ## Register Semantics ### `'real'` Use `'real'` for reachable non-provider scopes such as `global` or `function`. ```typescript await setupCraftServiceTestingByRegister(CounterConsumer, { CounterConsumer: provideCounterConsumer(), Counter: 'real', }); ``` ### Raw provider Use the provider returned by `provideX(...)` for `toProvide` or `manuallyProvidedAtRoot` services. ```typescript await setupCraftServiceTestingByRegister(RootCounter, { RootCounter: provideRootCounter(), ParentCounter: provideParentCounter(), ChildCounter: provideChildCounter(), }); ``` ### Raw mock object Use a plain object when you want to override the public service shape. ```typescript await setupCraftServiceTestingByRegister(CounterConsumer, { CounterConsumer: provideCounterConsumer(), Counter: { $self: vi.fn(() => 12), increment: vi.fn(), }, }); ``` ### `'notReached'` Use `'notReached'` only when the service is on a branch fully pruned by an ancestor mock. ```typescript await setupCraftServiceTestingByRegister(RootCounter, { RootCounter: provideRootCounter(), ParentCounter: { incrementParent: vi.fn(), }, ChildCounter: 'notReached', }); ``` ## Return Value The function resolves to: * `sut`: the resolved service under test * `mocks`: only the services that were actually mocked in the register Entries marked as `'real'`, `'notReached'`, or provided through raw providers are not exposed in `mocks`. ## App Start Hooks Reachable real services declared with `appStart: true` must be acknowledged explicitly. ```typescript const { sut } = await setupCraftServiceTestingByRegister( Dashboard, { Dashboard: provideDashboard(), AuthSession: 'real', Analytics: 'real', }, { appStart: { AuthSession: 'run', Analytics: 'ignore', }, }, ); ``` `'run'` injects the real service and awaits its `onAppStart(...)` hook. `'ignore'` documents that the test intentionally skips it. Mocked services and `'notReached'` branches do not require `appStart` entries. ## Boundary-Only Mode `setupCraftServiceTestingByRegister.boundaryOnly(...)` is the recommended mode when the test should mock only browser or platform edges. ```typescript const { sut, mocks } = await setupCraftServiceTestingByRegister.boundaryOnly( Dashboard, { toProvideRegister: { Dashboard: provideDashboard(), FeatureConfig: provideFeatureConfig({ env: 'test' }), }, boundaryRegister: { LocalStorageService: { getItem: vi.fn(() => 'cached'), }, ConsoleService: 'real', }, appStart: { AuthSession: 'run', }, }, ); ``` * `toProvideRegister` contains real providers required by reachable services. * `boundaryRegister` contains the explicit decision for each reachable browser boundary. * non-boundary services cannot be mocked in this mode. * descendants of a mocked boundary are pruned and do not need entries. The helper never decides automatically from the test environment. Use `'real'` when the real boundary is appropriate, and provide a mock when the test needs deterministic platform behavior. ## Alias `setupTestingService` is a backward-compatible alias of `setupCraftServiceTestingByRegister`. ## See Also * [craftService](/guide/app/craft-service) * [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph --- --- url: https://craft-ts.github.io/craft/guide/testing/components.md --- # Testing components Craft components are tested in two independent halves: the **logic factory** (plain values, no DOM) and the **template** (real DOM, explicit locators). You can test one without paying for the other. **Use the logic test** for what the factory computes and exposes. **Use the template test** for what actually renders, and for interaction. The utilities live in a dedicated submodule: ```ts import { setupCraftComponentLogicTest, setupCraftComponentTemplateTest, setupCraftDirectiveLogicTest, setupCraftDirectiveTemplateTest, } from '@craft-ts/component/testing'; ``` They deliberately separate the factory from rendering. Each utility also exposes a `.byRegister(...)` form, which makes the services used by the tested code explicit. ## Component logic The logic test executes only the factory and returns its context together with the installed mocks: ```ts const { context, mocks, destroy } = await setupCraftComponentLogicTest.byRegister(FullDemoCraft, { register: { TodoStore: { todos: { status: () => 'resolved', value: () => [], }, }, }, }); expect(context.store.todos.value()).toEqual([]); expect(mocks.TodoStore).toBeDefined(); destroy(); ``` Factory arguments can be provided through `args` when the component declares inputs: ```ts await setupCraftComponentLogicTest.byRegister(StatusComponent, { args: [statusInput], register: {}, }); ``` ## Component template The template test receives an already-built context. The component logic is not executed: ```ts const test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, { context: { status: () => 'resolved' }, register: {}, }); expect(test.nativeElement.textContent).toContain('Loaded'); test.detectChanges(); test.updateContext({ status: () => 'error' }); expect(test.nativeElement.textContent).toContain('Error'); test.destroy(); ``` The result exposes `nativeElement`, `element`, `mocks`, `detectChanges`, `updateContext`, and `destroy`. Craft styles, child components, Craft directives, and reactivity are rendered by the normal renderer. ### Explicit DOM locators Template tests also expose `locator(tag, criteria)`. The tag determines the DOM element type, while `class`, `data-*`, and `aria-*` criteria are matched against the rendered element. Prefer `data-*` and `aria-*`: a class comes from a sheet and is a list of atoms, not a name a test should depend on. ```ts import { button, craftComponent, div } from '@craft-ts/component'; import { setupCraftComponentTemplateTest } from '@craft-ts/component/testing'; const Editor = craftComponent( 'Editor', {}, () => ({}), () => div([ button( 'save', { type: 'button', 'data-testid': 'save' }, 'Save', ), ]), ); it('finds the save button', async () => { const test = await setupCraftComponentTemplateTest.byRegister(Editor, { context: {}, register: {}, }); const saveButton = test.locator('button', { 'data-testid': 'save' }); expect(saveButton?.textContent).toBe('Save'); saveButton?.click(); test.destroy(); }); ``` The notation `tag('name', props, children)` is generic: `tag` means the HTML helper for the element you want. There is no separate `tag` function. For a button, write the three arguments explicitly: ```ts const saveButton = button( 'save', // name: stable local name { type: 'button' }, // props: DOM properties and attributes 'Save', // children: rendered content ); ``` The same pattern works with every built-in helper: ```ts import { input } from '@craft-ts/component'; const searchInput = input('search', { 'aria-label': 'Search' }, []); ``` The name is rendered as `data-craft-name="save"` and can be used as a complementary named locator when a class is not sufficiently discriminating. ### Locating branded content When an element directly renders a branded Craft value, use the brand name as the `content` criterion. The locator does not inspect the rendered value, so this also works for non-text values and remains independent of formatting: ```typescript import { craftSignal as signal } from '@craft-ts/core'; import { span, craftComponent } from '@craft-ts/component'; import { markYieldableValue, state } from '@craft-ts/core'; const Status = craftComponent( 'Status', {}, function* () { const brandedStatus = yield* state('brandedStatus', 'ready'); return { brandedStatus }; }, ({ brandedStatus }) => span(brandedStatus), ); const test = await setupCraftComponentTemplateTest.byRegister(Status, { context: { brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'), }, register: {}, }); const brandedStatusElement = test.locator('span', { content: 'brandedStatus', }); expect(brandedStatusElement.textContent).toBe('ready'); test.destroy(); ``` This template has no `ifNode`, `forNode`, or `deferNode`, so `brandedStatusElement` is an `HTMLSpanElement`, never `undefined`; optional chaining is not needed here. The brand name is part of the template type. An unknown value such as `{ content: 'missing' }` is rejected by TypeScript. The return type is the inferred DOM type when the element is always rendered. Under `ifNode`, `forNode`, or `deferNode`, it is `MaybeDefined` (equivalent to `HTMLSpanElement | undefined`), so callers must handle the absent branch. Use static, discriminating markers for locators. A literal class or attribute declared in the template is a stable proof; a value produced by a binding is not. Attributes declared through `attrs` are queried using their rendered attribute name: ```ts input({ attrs: { 'aria-label': 'Search' } }); test.locator('input', { 'aria-label': 'Search' }); ``` The locator searches the complete rendered subtree, including Craft child components. A branch that is currently absent returns `undefined`; a runtime result with more than one matching element throws an explicit cardinality error. Call the locator again after `updateContext` and `detectChanges` when a conditional branch changes. When a class is not sufficiently discriminating, keep using the existing named locators (`tag('name', props, children)`) and query their `data-craft-name` marker. A future collection API will cover repeated targets; the singular locator should remain reserved for one expected element. To verify that a DOM property is connected to the correct context member, add a contract assertion next to the template test: ```ts import { setupCraftComponentLogicTest } from '@craft-ts/component'; import { craftComputed, craftUse, state } from '@craft-ts/core'; import { craftComponent, button } from '@craft-ts/component'; import type { ComponentTemplateOf, TemplateRendersStateWhen, } from '@craft-ts/component'; import type { Equal, Expect } from 'test-type'; const Counter = craftComponent( 'Counter', {}, function* () { const counter = yield* state('counter', 0, ({ state, update }) => ({ disabled: craftComputed(function* () { return (yield* state()) % 2 === 0; }), increment: () => update((value) => value + 1), })); return { counter }; }, ({ counter }) => button('increment', { type: 'button', disabled: counter.disabled, click: counter.increment, }, '+', ), ); it('tests the derived disabled state', async () => { const { context, destroy } = await setupCraftComponentLogicTest.byRegister( Counter, { register: {}, }, ); try { expect(craftUse(context.counter.disabled())).toBe(true); craftUse(context.counter.increment()); expect(craftUse(context.counter())).toBe(1); expect(craftUse(context.counter.disabled())).toBe(false); } finally { destroy(); } }); type _DisabledBindingIsCorrect = Expect< Equal< TemplateRendersStateWhen< ReturnType>, 'counter.disabled' >, true > >; ``` TypeScript performs this check. It fails if the branded `counter.disabled` read is no longer exposed by the rendered template. It does not replace the rendering test; it verifies the template contract without a DOM. ## Context and service dependencies The `context` is a factory value and is not a registry dependency. In this example, `store` is provided directly to the template: ```ts await setupCraftComponentTemplateTest.byRegister(FullDemoCraft, { context: { store: todoStoreMock }, register: {}, }); ``` Conversely, if `StatusComponent` or a child component uses a `FormatterService`, the template registry contains `FormatterService`, never the child component: ```ts register: { FormatterService: formatterMock, } ``` The `CraftComponentLogicDepsOf` and `CraftComponentTemplateDepsOf` projections keep these two graphs separate. A template registry therefore accepts only services; child components are never entries in `register`. ## Registry values and providers Resolution follows the same rules as service tests: * an object is a mock and is available in `mocks`; * `'real'` keeps the real service; * `'notReached'` documents a branch removed by a parent mock; * `'provided'` requests the value provided by the parent injector; * a `provideX(...)` provider explicitly configures a service. Providers declared in `meta.providers` are available in the component scope. Upstream providers go in `providers`: ```ts await setupCraftComponentLogicTest.byRegister(Component, { providers: [provideApiService({ baseUrl: '/test' })], register: { ApiService: 'provided', }, }); ``` `appStart` decisions (`'run'` or `'ignore'`) are available in the options when the tested graph contains a service with `appStart: true`. ## Testing a directive Directive logic receives its `baseLogic` and arguments explicitly: ```ts const { context } = await setupCraftDirectiveLogicTest.byRegister( hasPermissionInput, { baseLogic, args: [userInput, permissionInput], register: {}, }, ); ``` For the template, provide `baseTemplate` and the final context: ```ts const test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, { baseTemplate: (context) => p(context.message()), context: { when: () => true, message: () => 'ready' }, register: {}, }); test.updateContext({ when: () => false, message: () => 'hidden' }); test.destroy(); ``` Structural directives follow the same path and can verify that rendering is replaced with `[]`. Calling `destroy()` cleans up views, injectors, listeners, and acquired styles. ## Type-level tests The template's contract can also be checked **without rendering anything** — that an element only appears under a condition, that a binding is really the one you think, that a list item renders its label. That is its own page: **[Type-level tests](/guide/testing/type-level)**. ## See Also * [Testing services](/guide/testing/services) * [Browser boundaries](/guide/testing/browser-boundaries) * [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph * [Routing setup](/guide/routing/setup) — where `GenDeps_*` comes from --- --- url: https://craft-ts.github.io/craft/guide/testing/type-level.md --- # Type-level tests Some of what a template guarantees is not observable at runtime — it is in the types. These assertions resolve **entirely at compile time**: no `TestBed`, no DOM, no fixture, no component instantiation. **Use them when** a regression would be silent: an element quietly stops rendering under a condition, a binding is repointed at another context member, a handler becomes imperative, a prop changes shape. **Not instead of** runtime tests — they prove the template's *contract*, not what the user ends up seeing. Pair them with [template tests](/guide/testing/components#component-template). ## The three questions they answer Most of what you'll write falls into one of these. Each is expanded below. | You want to prove… | Use | | ------------------------------------------------------------ | -------------------------------------------------- | | an element renders **only under a condition** | `TemplateRendersNamedElementWhen` with `{ when }` | | a binding is rendered **for every item of a non-empty list** | the same, with `{ when: { items: 'nonEmpty' } }` | | a **property is used** on a named element | `TemplateNamedElementRendersStateWhen` | | an element property delegates to a context method | `TemplateNamedElementDelegatesToContext` | | a component logic field has a specific service output | `ComponentLogicOutputOf` + `ResolvedServiceOutput` | ::: warning Experimental This contract is the least settled part of `@craft-ts`. The assertions below work and are covered by the library's own tests, but their **names and ergonomics are still moving** — expect the DX to get shorter and more readable before it stabilises. Pin the version if you rely on them heavily. ::: ## Setting it up The assertions build on two type helpers, published for applications on a dedicated subpath: ```ts import type { Equal, Expect } from '@craft-ts/dev-tools/testing'; ``` They are **types only** — nothing is emitted, so importing them costs nothing at runtime. An assertion is the pair: a helper that computes a boolean type, wrapped in `Expect>`. If the computed type stops being `true`, the file stops compiling. `ComponentTemplateOf` gets you the template type to assert on: ```ts type CounterTemplate = ReturnType>; ``` `ComponentLogicOutputOf` gets the value returned by the component logic factory. This lets you assert the type of a field returned by the factory, instead of checking only that the component declares a dependency: ```ts import type { ComponentLogicOutputOf } from '@craft-ts/component'; import type { ResolvedServiceOutput } from '@craft-ts/core'; type FullDemoLogic = ComponentLogicOutputOf; type TodoStoreOutput = ResolvedServiceOutput; type StoreIsTodoStore = Expect>; ``` `ResolvedServiceOutput` is used here because it preserves the reactive brands present on the value produced by `yield* TodoStore()`. ### Running them with Vitest Type assertions fail at **compile** time, so they need something to typecheck the file. `tsc --noEmit` is enough, but Vitest can run them alongside your runtime tests: ```shell vitest typecheck ``` Put the assertions in a `*.test-d.ts` file and Vitest reports a failing type as a failing test, in the same run and the same output as everything else. Vitest's own `expectTypeOf` / `assertType` work there too, and compose with the helpers below. ::: tip Give them a home A type assertion nobody typechecks proves nothing. Either keep them in files covered by `vitest typecheck`, or make sure `tsc --noEmit` runs over them in CI. ::: ## The template contract `SetupTestComponentTemplate` resolves the template without `TestBed`, a DOM, the factory, or runtime providers. The component tuple contains the references allowed for children: ```ts type CounterTemplateTest = SetupTestComponentTemplate< typeof Counter, [typeof CounterButton, typeof PlusIcon] >; ``` The resolver traverses elements, directives, `forNode`, `deferNode`, and child components. A component reference missing from the tuple becomes a type diagnostic. Visited components are tracked so recursive templates do not create a resolution loop. The contract also checks the required public props of `ComponentNode` and keeps the concrete child component reference. Dynamic component unions produce a dedicated diagnostic; split them into static branches so they can be checked at the type level. Components owned by another package form an explicit boundary and should be tested with that package's harness. The available assertions can verify elements, their exact props, event arguments, generator callbacks, and outputs. For example, start with a component whose `disabled` property is nested inside the `counter` state: ```ts import { craftComputed, state } from '@craft-ts/core'; import { button, craftComponent, div } from '@craft-ts/component'; const Counter = craftComponent( 'Counter', {}, function* () { const counter = yield* state('counter', 0, ({ state, update }) => ({ disabled: craftComputed(function* () { return (yield* state()) === 0; }), increment: () => update((value) => value + 1), })); return { counter }; }, ({ counter }) => div( button('increment', { type: 'button', disabled: counter.disabled, *click(_event: MouseEvent) { yield* counter.increment(); }, }, '+', ), ), ); ``` The type assertions inspect the template returned by `Counter`; they do not instantiate the component or render a DOM fixture: ```ts type CounterTemplate = ReturnType>; type HasButton = Expect< Equal, true> >; // TemplateHasElementWithProps checks the exact prop set of the matching node. type HasCounterClass = Expect< Equal< TemplateHasElementWithProps< CounterTemplate, 'div', { readonly class: string } >, true > >; type HasClick = Expect< Equal< TemplateHasYieldableEvent, true > >; type HasNestedDisabledBinding = Expect< Equal, true> >; ``` These checks detect different regressions at compile time. Removing the `button`, changing the `click` callback to an imperative function, changing its event arguments, or replacing `counter.disabled` with another context member makes the corresponding assertion fail. The `TemplateHasElementWithProps` check also catches an unexpected extra, missing, or differently typed prop. Primitive properties follow the same contract as events. For derived state, use `craftComputed` in the `state` insertion: ```ts import { button, craftComponent } from '@craft-ts/component'; import { craftComputed, state } from '@craft-ts/core'; const Counter = craftComponent( 'Counter', {}, function* () { const counter = yield* state('counter', 0, ({ state }) => ({ disabled: craftComputed(function* () { return (yield* state()) % 2 === 0; }), })); return { counter }; }, (context) => button('increment', { type: 'button', disabled: context.counter.disabled, }, '+', ), ); ``` Here, `counter` is created by the component factory and returned in its context. The template receives that context, and the branded `context.counter.disabled()` read is the binding that the type assertion checks: ```ts type HasDerivedDisabledBinding = TemplateRendersStateWhen< ReturnType>, 'counter.disabled' >; type _HasDerivedDisabledBinding = Expect< Equal >; ``` If the template were accidentally changed to use another member, the assertion would fail: ```ts type UsesWrongBinding = Expect< Equal< TemplateRendersStateWhen< ReturnType>, 'counter.enabled' >, false > >; ``` The callback is executed by the Craft driver before the DOM property is written. The assertion verifies the exact binding source without a fixture or DOM. `computed` remains a synchronous signal when called directly (`counter.disabled()`) and in a template context. Templates supplied to `forNode` and `deferNode` are also resolved. When a `deferNode` directly loads a Craft component, that component must appear in the registry. Under this contract, DOM and output callbacks must be generators or branded Craft methods; ordinary imperative callbacks produce a diagnostic. Branded Craft methods are projected into the template context as yieldable callbacks: ```ts button( { *click() { yield* context.counter.increment(2); }, }, '+', ); ``` The renderer executes these callbacks with the Craft driver. Render callbacks (text, classes, styles, `forNode`, `deferNode`) remain synchronous. ## Conditional visibility and named elements Reactive values exposed by Craft primitives and services keep their property name in the template type. They remain synchronously readable in templates, while their name brand is available to `ifNode` and the visibility contract. Use `ifNode` to retain the condition and its branches in the VNode contract: ```ts import { state } from '@craft-ts/core'; import { button, craftComponent, div, ifNode, span, } from '@craft-ts/component'; const Counter = craftComponent( 'Counter', {}, function* () { const isAuth = yield* state('isAuth', true); const brandedStatus = yield* state('brandedStatus', 'ready'); return { isAuth, brandedStatus }; }, ({ isAuth, brandedStatus }) => ifNode( isAuth, () => div([ button('increment', { type: 'button', click: function* () {} }, '+'), span(brandedStatus), ]), () => [], ), ); ``` The local name is rendered as `data-craft-name`; `data-craft-root` remains an internal tracking attribute. ### Proving an element renders only under a condition A named element is asserted with its **full component identity** — `'::'` — and the visibility path it sits behind: ```ts type CounterTemplate = ReturnType>; type CanIncrement = Expect< Equal< TemplateRendersNamedElementWhen< CounterTemplate, 'Counter:button:increment', { when: { isAuth: true } } >, true > >; ``` When the element is truly unconditional, omit `when` (or use an empty object). An element inside an `ifNode` or `forNode` requires its visibility condition; omitting `when` deliberately returns `false` for such an element. Keep the complete `ComponentTemplateOf` type if you want editor completion for the component prefix of the identity. Using `ReturnType<...>` preserves the element and tag, but loses the component name used for the most useful completion suggestions: ```ts type FullDemoTemplate = ComponentTemplateOf; type DisplayNewTodoNameInput = Expect< Equal< TemplateRendersNamedElementWhen< FullDemoTemplate, 'FullDemoCraft:input:TodoNameToAddInput' >, true > >; ``` When editing the second argument, the available identities are proposed from the template, for example `FullDemoCraft:input:TodoNameToAddInput` and `FullDemoCraft:button:AddTodoButton`. `{ when: {} }` is equivalent to omitting the third argument: both assert that the element is unconditional. For an element inside an `ifNode` named `isAuth`, use `{ when: { isAuth: true } }`. The same visibility contract can identify an element through branded direct content. Here, `brandedStatus` is not selected by its text; its brand proves that the `span` renders that value in the authenticated branch: ```ts type CounterTemplate = ReturnType>; type StatusIsRenderedWhenAuthenticated = Expect< Equal< TemplateRendersStateWhen< CounterTemplate, 'brandedStatus', { when: { isAuth: true } } >, true > >; const test = await setupCraftComponentTemplateTest.byRegister(Counter, { context: { isAuth: markYieldableValue(signal(true), 'isAuth'), brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'), }, register: {}, }); const brandedStatusElement = test.locator('span', { content: 'brandedStatus', }); brandedStatusElement?.textContent; test.destroy(); ``` Because the element is conditional, `brandedStatusElement` is typed as `HTMLSpanElement | undefined`. After `updateContext` and `detectChanges`, the same locator returns `undefined` while the branch is absent. ### Proving a binding renders for every item of a non-empty list `forNode` contributes `: 'nonEmpty'` to the visibility path, so you can assert what every item renders — here a translated label exposed by an `insertSelect` insertion: ```typescript import { craftComputed as computed } from '@craft-ts/core'; import { insertSelect, state } from '@craft-ts/core'; import { craftComponent, forNode, span } from '@craft-ts/component'; import type { ComponentTemplateOf, TemplateRendersNamedElementWhen, TemplateRendersStateWhen, } from '@craft-ts/component'; import type { Equal, Expect } from '@craft-ts/dev-tools/testing'; const ItemList = craftComponent( 'ItemList', {}, function* () { const items = yield* state( 'items', [{ key: 'first' }, { key: 'second' }], insertSelect('item', ({ state: selectedItem }) => ({ translatedLabel: craftComputed(function* () { return `translated:${(yield* selectedItem()).key}`; }), })), ); return { items }; }, ({ items }) => forNode(items, { track: (item) => item.key }, (_item, index) => span( 'itemLabel', { 'aria-label': items.selectItem(index)?.translatedLabel }, () => items.selectItem(index)?.translatedLabel() ?? '', ), ), ); type ItemListTemplate = ReturnType>; type HasTranslatedLabel = Expect< Equal< TemplateRendersNamedElementWhen< ItemListTemplate, 'ItemList:span:itemLabel', { when: { items: 'nonEmpty' } } >, true > >; type RendersTranslatedLabel = Expect< Equal< TemplateRendersStateWhen< ItemListTemplate, 'items.selectItem.translatedLabel', { when: { items: 'nonEmpty' } } >, true > >; ``` ### Proving a property is used on a named element The same visibility paths verify that a state really feeds a rendered binding, and that a yieldable action is available on a named element — `'click:increment'` reads as "the `click` action on the element named `increment`": ```typescript import { craftMethod, state } from '@craft-ts/core'; import { button, craftComponent, ifNode } from '@craft-ts/component'; import type { ComponentTemplateOf, TemplateRenderAvailableActionWhen, TemplateRendersStateWhen, } from '@craft-ts/component'; import type { Equal, Expect } from '@craft-ts/dev-tools/testing'; const Counter = craftComponent( 'Counter', {}, function* () { const isAuth = yield* state('isAuth', true); const isAdult = yield* state('isAdult', true); const increment = craftMethod('increment', function* () { return undefined; }); return { isAuth, isAdult, increment }; }, ({ isAuth, isAdult, increment }) => ifNode( isAuth, () => button('increment', { click: increment }, () => isAdult()), () => [], ), ); type CounterTemplate = ReturnType>; type RendersAdultState = Expect< Equal< TemplateRendersStateWhen< CounterTemplate, 'isAdult', { when: { isAuth: true } } >, true > >; // The key is `${event}:${localName}`. type CanIncrementWhenAuthenticated = Expect< Equal< TemplateRenderAvailableActionWhen< CounterTemplate, 'click:increment', { when: { isAuth: true } } >, true > >; ``` `TemplateRendersStateWhen` recognizes branded reads that contribute to visible text or other render bindings such as `class` and `style`. Both assertions return `false` when the state or action exists only under a visibility branch that is incompatible with `when`. `forNode` adds `: 'nonEmpty'` for its item template and `: 'empty'` for its empty template. Interactive helpers must use the named form (`button('increment', {}, '+')`): ESLint `craft-ts/require-interactive-local-name` requires the literal first argument, and `assertInteractiveElementNamed` requires that `data-craft-name` to be unique in the app. `craft-ts/template-element-name-unique` still forbids two `tag:localName` pairs in the same component template, including across conditional branches. ### Proving a named property uses a specific state `TemplateNamedElementRendersStateWhen` combines the named-element identity, the element property, and the context path. All three arguments are constrained by the template type, so editors can complete the element identity, the available property names, and the available context paths: ```ts import type { ComponentTemplateOf, TemplateNamedElementRendersStateWhen, } from '@craft-ts/component'; import type { Equal, Expect } from '@craft-ts/dev-tools/testing'; type FullDemoTemplate = ComponentTemplateOf; type RemoveButtonUsesRemoveLoading = Expect< Equal< TemplateNamedElementRendersStateWhen< FullDemoTemplate, 'FullDemoCraft:button:RemoveTodoButton', 'disabled', 'store.remove.isLoading' >, true > >; ``` For a reactive property binding, keep the read inside a render callback so the context marker remains visible to the template contract: ```ts button('RemoveTodoButton', { disabled: store.remove.isLoading, }); ``` This assertion proves that the `disabled` binding on the named remove button is driven by `store.remove.isLoading`; it does not instantiate the component or observe the DOM. ### Proving a named event delegates to a context method `TemplateNamedElementDelegatesToContext` checks the same relationship for a generator event callback: ```ts import type { TemplateNamedElementDelegatesToContext } from '@craft-ts/component'; type AddButtonClickUsesAddMutation = Expect< Equal< TemplateNamedElementDelegatesToContext< FullDemoTemplate, 'FullDemoCraft:button:AddTodoButton', 'click', 'store.add.mutate' >, true > >; ``` The source callback must delegate with `yield*`: ```ts button('AddTodoButton', { *click() { yield* store.add.mutate(title().trim()); }, }); ``` The named identity prevents a different button's `click` handler from satisfying the assertion. ## Pitfalls **Asserting `true` where the answer is `false`.** These helpers return a boolean type, so `Expect>` is the assertion. Writing the helper alone proves nothing — it just computes a type nobody checks. **Naming the element is what makes it addressable.** A `button('increment', …)` carries the local name that `'Counter:button:increment'` resolves. Without it there is no identity to assert on. **Imperative callbacks are rejected.** Under this contract, DOM and output callbacks must be generators or branded Craft methods; an ordinary function produces a diagnostic. **A `deferNode` that loads a Craft component** requires that component to be present in the registry tuple. **The ergonomics are known to be rough.** `Expect>, …>, true>>` is a lot of ceremony for one assertion. Shorter façades are being explored; until then, alias what repeats: ```ts type Tpl = ReturnType>; type Assert = Expect; ``` ## See Also * [Testing components](/guide/testing/components) — the runtime half * [Testing services](/guide/testing/services) * [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph * [Learn: test what you wrote](/learn/10-testing) --- --- url: https://craft-ts.github.io/craft/guide/testing/browser-boundaries.md --- # Browser boundaries A browser boundary is the line between your logic and the outside world: `localStorage`, `navigator`, `location`, the network. Craft keeps direct access out of your services while still making each of those dependencies **explicit in the graph** — so a test can replace exactly them, and nothing else. **Use them when** a service touches the platform. **Then test with `boundaryOnly`**: the whole application graph stays real and only the boundaries are mocked, which is what makes a passing test mean something. Browser boundaries keep direct browser access out of your `craftService` implementations while still making those dependencies explicit in the service graph. Every boundary on this page is backed by a global crafted service marked with `browserBoundary: true`. ::: warning Some APIs are not documented here yet. ::: ## Import The main DSL exports are: ```typescript import { BrowserCrypto, BrowserDocument, BrowserHistory, BrowserLocation, BrowserNavigator, BrowserPerformance, BrowserWindow, Console, Cookies, LocalStorage, SessionStorage, } from '@craft-ts/core'; ``` When you need to derive methods for later reuse, each boundary also exposes the usual generated helpers: ```typescript import { ConsoleService, CONSOLE_SERVICE_META_DATA } from '@craft-ts/core'; ``` The same pattern exists for the other boundaries: * `LocalStorageService` * `SessionStorageService` * `CookiesService` * `BrowserLocationService` * `BrowserHistoryService` * `BrowserNavigatorService` * `BrowserPerformanceService` * `BrowserCryptoService` * `BrowserDocumentService` * `BrowserWindowService` ## Motivation Direct browser access inside a service hides dependencies inside business logic and makes tracking harder. Browser boundaries solve that in two complementary ways: * use `yield* X.method(...)` when the browser interaction should happen directly inside the generator * use `XService(...)` when you want to derive bound browser helpers and reuse them inside returned callbacks ## Mental Model There are two valid ways to use a browser boundary. ### Direct DSL Use the DSL when the browser interaction belongs to the generator itself. ```ts import { Console, craftService } from '@craft-ts/core'; const { BootLogger } = craftService( { name: 'BootLogger', providedIn: 'global' }, function* () { yield* Console.log('boot'); yield* Console.info('config loaded'); return { ready: true, }; }, ); ``` ### Derived Service Helper Use `XService(...)` when the browser method needs to stay callable later from a returned method. ```ts import { ConsoleService, craftService } from '@craft-ts/core'; const { AuditTrail } = craftService( { name: 'AuditTrail', providedIn: 'global' }, function* () { const consoleService = yield* ConsoleService( undefined, ({ log, error }) => ({ log, error, }), ); return { trackUserAction: (action: string) => consoleService.log('user action', action), trackFailure: (error: unknown) => consoleService.error('unexpected failure', error), }; }, ); ``` That second form is what preserves derivability while still tracking the browser dependency explicitly. ## Core Examples ### Console ```typescript yield * Console.log('my service run'); yield * Console.error('unexpected failure', error); ``` ### Local Storage ```typescript yield * LocalStorage.setItem('token', token); const persistedToken = yield * LocalStorage.getItem('token'); const entryCount = yield * LocalStorage.length(); ``` ### Session Storage ```typescript yield * SessionStorage.setItem('active-tab', 'settings'); const tab = yield * SessionStorage.getItem('active-tab'); ``` ### Cookies ```typescript yield * Cookies.set('session', sessionId, { path: '/', sameSite: 'strict', }); const session = yield * Cookies.get('session'); const hasSession = yield * Cookies.has('session'); ``` ### Location ```typescript const href = yield * BrowserLocation.href(); const pathname = yield * BrowserLocation.pathname(); yield * BrowserLocation.reload(); ``` ### History ```typescript yield * BrowserHistory.replaceState({ step: 2 }, '', '/checkout?step=2'); const state = yield * BrowserHistory.state(); ``` ### Document ```typescript yield * BrowserDocument.setTitle('Checkout'); const title = yield * BrowserDocument.title(); ``` ### Window ```typescript const width = yield * BrowserWindow.innerWidth(); yield * BrowserWindow.scrollTo(0, 0); yield * BrowserWindow.alert('Cache cleared! The page will reload.'); const confirmed = yield * BrowserWindow.confirm('Cache cleared! The page will reload.'); if (confirmed) { yield * BrowserLocation.reload(); } ``` ### Performance And Crypto ```typescript const now = yield * BrowserPerformance.now(); const uuid = yield * BrowserCrypto.randomUUID(); ``` ## API Reference Every service below is: * `providedIn: 'global'` * `browserBoundary: true` * exposed both as a DSL object and as generated service helpers ### `Console` and `ConsoleService` Methods: * `debug` * `info` * `log` * `warn` * `error` * `trace` * `group` * `groupCollapsed` * `groupEnd` * `time` * `timeEnd` Generated helpers: * `ConsoleService` * `ConsoleService` * `CONSOLE_SERVICE_META_DATA` ### `LocalStorage` and `LocalStorageService` Methods: * `getItem` * `setItem` * `removeItem` * `clear` * `key` * `length` ### `SessionStorage` and `SessionStorageService` Methods: * `getItem` * `setItem` * `removeItem` * `clear` * `key` * `length` ### `Cookies` and `CookiesService` Methods: * `get` * `getAll` * `set` * `remove` * `has` ### `BrowserLocation` and `BrowserLocationService` Methods: * `href` * `origin` * `protocol` * `host` * `hostname` * `port` * `pathname` * `search` * `hash` * `assign` * `replace` * `reload` ### `BrowserHistory` and `BrowserHistoryService` Methods: * `length` * `state` * `back` * `forward` * `go` * `pushState` * `replaceState` ### `BrowserNavigator` and `BrowserNavigatorService` Methods: * `userAgent` * `language` * `languages` * `onLine` * `cookieEnabled` * `sendBeacon` ### `BrowserPerformance` and `BrowserPerformanceService` Methods: * `now` * `mark` * `measure` * `clearMarks` * `clearMeasures` ### `BrowserCrypto` and `BrowserCryptoService` Methods: * `randomUUID` * `getRandomValues` * `digest` ### `BrowserDocument` and `BrowserDocumentService` Methods: * `title` * `setTitle` * `lang` * `setLang` * `dir` * `setDir` * `visibilityState` * `hasFocus` ### `BrowserWindow` and `BrowserWindowService` Methods: * `innerWidth` * `innerHeight` * `scrollX` * `scrollY` * `scrollTo` * `alert` * `confirm` ## Related Adapter: `CraftHttpClient` `CraftHttpClient` is implemented, but it is not a browser boundary. Unlike `Console`, `LocalStorage`, or `BrowserLocation`, `CraftHttpClient` is a typed service boundary. It belongs in the dependency graph rather than being treated as a browser-global. Its contract is intentionally different: * it is not treated as `browserBoundary: true` * it requires `success: response()` inside a declarative builder * it can declare ordered `exceptions: [function* (...) { ... }]` rules * it returns a promise of `Success | craftException({ _tag: 'HttpError' })` Usage looks like this: ```typescript const getUsers = yield * CraftHttpClient.get(({ response }) => ({ url: '/api/users', params: { page: 1 }, success: response(), })); const createUser = yield * CraftHttpClient.post(({ response }) => ({ url: '/api/users', payload, success: response(), })); const login = yield * CraftHttpClient.post(({ response }) => ({ url: '/api/login', payload, success: response<{ token: string }>(), exceptions: [ function* ({ status, code, content }) { if (!(yield* status(400))) return; if (!(yield* code('PASSWORD_REQUIRED'))) return; if (!(yield* content('Password is required'))) return; return craftException({ _tag: 'PASSWORD_REQUIRED' }); }, ], })); const users = await getUsers(); const createdUser = await createUser(); const loginResult = await login(); ``` ## Design Constraints The browser boundaries stay intentionally narrow. * Reads are exposed as methods so the public API stays uniform with `yield*`. * Raw `window`, `document`, and DOM nodes are not exposed as public outputs. * `BrowserDocument` and `BrowserWindow` remain minimal rather than becoming generic escape hatches. This keeps the API focused on explicit browser interactions instead of reintroducing broad direct access to host globals. ## Relationship With `craftService` Browser boundaries participate in the same dependency tracking model as any other crafted service. * [`craftService`](/guide/app/craft-service) is what you use to consume them and compose higher-level services. * Use a small `craftService` adapter for host dependencies that are not part of this built-in browser boundary set. ## See Also * [`craftService`](/guide/app/craft-service) * [Architecture rules](/guide/testing/architecture) — assert HTTP only crosses a boundary --- --- url: https://craft-ts.github.io/craft/guide/testing/architecture.md --- # Architecture rules Architecture tests answer one question: > **Is the dependency shape of the app still allowed?** They read the static Craft graph — routes, services, components, primitives and their edges — without starting the application. That makes them useful for rules that are about relationships, ownership or declarations rather than runtime behaviour. ## Choose the right kind of test | If you want to verify… | Use… | Example | | -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- | | one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value | | one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click | | two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` | | a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task | Use an architecture rule when the requirement sounds like one of these: * **must not depend on** — a feature must not reach into another feature; * **must be owned once** — an HTTP endpoint or persisted identity has one owner; * **must declare a relationship** — a mutation must refresh a query; * **must model input-driven work as a form** — a button must not send input state directly into a mutation or async process; * **must remain pure** — reading a computed value must not perform work. A green architecture suite does not prove that a button works. It proves that the app still respects the boundaries that make that button maintainable. ::: tip Start with the graph-wide baseline Add `assertDeclarativeArchitecture(graph.graph)` first. It checks the core invariants that are easiest to break during a refactor: unique identities, unique HTTP ownership, pure `craftComputed` values, no dependency cycles and declared mutation reactions. Add focused rules when your application has an additional boundary, such as route DI, folder ownership or URL-backed resource params. ::: The default baseline also rejects event-only `craftMethod` wrappers through `assertNoEventOnlyCraftMethods`. It scans every TypeScript source file in the application's graph project, so moving the wrapper to another file does not avoid the rule. Use [`eventAction(...)`](/guide/components/directives#event-actions-and-dom-modifiers) on the element to apply DOM event modifiers and invoke the action directly. ## What a rule looks like A rule is an ordinary Vitest assertion. Look up a node, inspect its graph relationships or call a built-in assertion, then let CI protect the invariant: ```typescript it('keeps checkout away from admin internals', () => { noExclusiveLink(graph.route('/checkout'), graph.route('/admin')); }); ``` The rest of this page explains the graph, the setup and the built-in rules. ## Import ```typescript import { analyzeDependencyGraph, architectureCatalogToTypeScript, assertCraftComputedPure, assertCraftEffectNoImperativeSync, assertCraftEffectNoNetwork, assertCraftUnique, assertDeclarativeArchitecture, assertHttpEndpointUnique, assertInputActionForms, assertInsertSelectUnique, assertInteractiveElementNamed, assertMutationHasReactOn, assertNoDependencyCycles, assertNoEventOnlyCraftMethods, assertPathBoundaries, assertPrimitiveLoaderRequirements, assertQueryMutationHasServerState, assertResourceParamsPreferQueryParams, assertPersistedPrimitiveHasUnique, assertRouteComponentsInSeparateFiles, assertRouteDiProofs, buildArchitectureCatalog, createArchitectureGraph, noExclusiveLink, } from '@craft-ts/dev-tools'; ``` ## Mental model `analyzeDependencyGraph` reads the application sources with the TypeScript program — routes, services, components, HTTP calls, `craftUnique` identities, route DI proofs (`CanRun`, `RouteCheckedDI`) — and builds a graph of nodes and edges. `createArchitectureGraph` wraps that graph with typed lookups. Names come from a generated **catalog** (`as const`): autocomplete, and a type error when a renamed symbol disappears. A rule is then a Vitest assertion on those lookups. The suite lives next to `e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no browser. ESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate the route proof blocks. Architecture tests catch **graph-wide** slips those rules cannot see: a feature leaking into another, an endpoint called from two APIs, a duplicate storage key, a route or `app.config` error screen whose DI proof was never armed. See [ESLint rules](/guide/routing/eslint-rules). ## The graph vocabulary Think of the graph as a typed inventory of architectural facts, not as a second runtime. A **node** is a thing the architecture can name; an **edge** is an observed relationship between two nodes. The graph is intentionally more fine-grained than a project graph: one app can contain many services, components, primitives and HTTP endpoints. ### Node families Not every application produces every kind of node. The built-in vocabulary is grouped below by the questions it helps answer: | Family | Node kinds | What they represent | | --- | --- | --- | | Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. | | 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. | | Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. | | 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. | | 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. | For example, a page can be represented as these facts: a `route` **loads** a `component`; the component **contains** a `query`; a `service` **calls** the `GET users` `http-endpoint`; a consumer service **depends-on** a browser boundary; and a `mutation` **triggers** a query. These are independent, typed relations that a rule can inspect directly. The labels are deliberately semantic. A rule can ask “which service calls this endpoint?” or “which mutation triggers this query?” without matching file text or reconstructing the dependency tree itself. ### Edge families The built-in edge kinds describe different types of fact; they should not all be treated as interchangeable dependency arrows: | Edge kinds | Meaning | Typical architecture question | | --- | --- | --- | | `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? | | `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? | | `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? | | `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? | | 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? | The direction matters: `from --kind--> to` is the fact asserted by the analyzer. A `depends-on` edge is therefore different from a `provides` edge, and a structural `contains` edge should not be mistaken for a runtime cycle. This is why `assertNoDependencyCycles` follows `depends-on` rather than every edge in the graph. ### What the graph is based on The analyzer works from the TypeScript program selected by the analysis `tsconfig`: * **AST evidence** records syntax that is visible in the source: a route loading a component, a component rendering an element, or a service calling an HTTP client. * **Type evidence** records relationships resolved through TypeScript: an injected/yielded service, a provider, or a route proof connected to its target. * **Source proofs** keep the file, line, symbol and pattern that explain an edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a failing rule can point back to the declaration that created the fact. The result is static and deterministic: architecture tests do not boot the application, instantiate services, make HTTP requests or observe user behaviour. They prove that the source still has an allowed shape. Runtime behaviour belongs in [service tests](/guide/testing/services), [component tests](/guide/testing/components) and e2e tests. ### Choosing the granularity of a rule Start at the smallest graph level that expresses the invariant, then widen only when the invariant is genuinely architectural: | Granularity | Example assertion | Best for | | --- | --- | --- | | Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules | | Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership | | Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies | | Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles | | Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness | The public API mirrors those levels: use `graph.nodes(kind)` and `graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()` for neighbourhoods, and `graph.pathsBetween()` when the rule is about reachability. Built-in `assert*` helpers package recurring whole-graph checks; custom rules should state the product or team invariant before describing the traversal. ## Setting it up The demo app is the working reference: `apps/demo/architecture/`, run with `npx nx architecture demo`. Commands are listed in `apps/demo/README.md`. Copy that layout, or scaffold it with the migrator (Vitest, Node): ```shell npx craft-migrate-architecture \ --project tsconfig.app.json \ --root src \ --write ``` That writes `tsconfig.graph.json`, `tsconfig.architecture.json`, `vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog, baseline rules, and an `architecture.spec.ts`), an Nx `architecture` target or a `package.json` script, and ignores the generated catalog in the nearest flat ESLint config. `--write` overwrites the scaffold. `--check` fails when the suite is missing or the generated tooling files drifted. `craft-migrate --write` runs this as its last step. Keep the rules and app-specific lookups in one `architecture.spec.ts` file when the graph is expensive to analyze. `loadArchitectureGraph()` caches only within one Vitest worker; separate spec files rebuild the TypeScript graph separately. The three demo apps use this single-file layout, which performs one graph analysis per app run. ### 1. Analysis tsconfig Point analysis at **every application source file**. `tsconfig.app.json` often lists only `main.ts`; the graph would then miss routes, services and components. ```json { "extends": "./tsconfig.json", "compilerOptions": { "skipLibCheck": true }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] } ``` ### 2. Suite tsconfig A second project compiles only the architecture folder, with Node and Vitest types: ```json { "extends": "./tsconfig.json", "compilerOptions": { "types": ["node", "vitest/globals"], "module": "esnext", "moduleResolution": "bundler" }, "include": ["architecture/**/*.ts"] } ``` Reference it from the app `tsconfig.json` `references` array so the IDE typechecks the suite. ### 3. Vitest, at the app root Keep the config next to `project.json` — **not** inside `architecture/`. A nested `vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's unit-test target. ```typescript /// import { defineConfig } from 'vite'; export default defineConfig(() => ({ root: import.meta.dirname, cacheDir: '../../node_modules/.vite/apps/demo-architecture', plugins: [], resolve: { tsconfigPaths: true, }, test: { name: 'demo-architecture', watch: false, globals: true, environment: 'node', testTimeout: 180_000, hookTimeout: 180_000, include: ['architecture/**/*.spec.ts'], }, })); ``` Analysis of a real app takes seconds, not milliseconds. Size the timeouts accordingly; `beforeAll` uses `hookTimeout`. ### 4. Load the graph, rewrite the catalog ```typescript import { writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { analyzeDependencyGraph, architectureCatalogToTypeScript, buildArchitectureCatalog, createArchitectureGraph, mergeStyleDump, } from '@craft-ts/dev-tools'; import { loadStyleDump } from '@craft-ts/style/vite'; import { architectureCatalog } from './catalog'; const workspaceRoot = resolve(import.meta.dirname, '../../..'); const catalogPath = join(import.meta.dirname, 'catalog.ts'); export async function loadArchitectureGraph() { const graph = analyzeDependencyGraph({ rootDir: workspaceRoot, tsConfigFilePath: 'apps/your-app/tsconfig.graph.json', }); writeFileSync( catalogPath, `// Generated. Do not edit.\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`, ); // The style half: every *.style.ts, evaluated by the same code the build // runs. The catalog stays built from the code graph alone. const styleDump = await loadStyleDump(join(workspaceRoot, 'apps/your-app/src')); return createArchitectureGraph( mergeStyleDump(graph, styleDump), architectureCatalog, ); } ``` Load it once in a `beforeAll(async () => { graph = await loadArchitectureGraph(); })`. The imported catalog is what TypeScript autocompletes against. The rewrite keeps it in sync with the sources: after a rename, the next typecheck of the suite fails until the lookups are updated. Ignore the generated catalog in ESLint. Commit it so the first clone typechecks. Bootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`. Rename the generated `catalog.architecture.ts` to `catalog.ts`. After that, loading the graph keeps it current. ### 5. Nx target ```json { "architecture": { "executor": "nx:run-commands", "options": { "command": "npx vitest run --config vitest.architecture.config.ts", "cwd": "apps/your-app" }, "inputs": [ "{projectRoot}/src/**/*.ts", "{projectRoot}/architecture/**/*.ts", "{projectRoot}/tsconfig.graph.json" ], "cache": true } } ``` ```shell npx nx architecture your-app ``` ## Looking up nodes Pass the catalog into `createArchitectureGraph` and names become unions. A missing name throws `Unknown service '…'`. Two nodes sharing a name throw until you pass a relative file path. ```typescript graph.route('craft/query/:userId'); graph.service('UsersApiOnError'); graph.service('ApiService', 'users/api.service.ts'); // homonym graph.component('ListWithPagination'); graph.providedOn('UserList'); graph.httpEndpoint('GET', 'users'); graph.unique('{"key":"user-query","storeName":"demo-app"}'); graph.services({ browserBoundary: true, providedIn: 'global' }); graph.usingHttp(); graph.dependingOnBrowserBoundary(); graph.craftMethods(); ``` | Lookup | Returns | | -------------------------------------------------- | ------------------------------------------------ | | `route(path, file?)` | one route node | | `service(name, file?)` | one service node | | `component(name, file?)` | one component node | | `providedOn(name)` | every node that `provides` that service | | `httpEndpoint(method, url)` | one HTTP endpoint | | `unique(canonicalJson)` | one `craftUnique` identity | | `services({ browserBoundary, scope })` | filtered services | | `usingHttp()` | nodes that call `CraftHttpClient` | | `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service | | `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind | Each node exposes `providers()`, `provider(name)`, `outgoing(kind?)`, `incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`, `provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`, `triggers`. `unique(...)` takes the **canonical JSON** of the identity object: keys sorted in depth. `{ storeName, key }` and `{ key, storeName }` index as the same string. For adding a TypeScript backend with its own typed nodes and relations, see [Extensible architecture graph](/guide/testing/extensible-architecture-graph). To propose project-specific source folders, see the [folder layout organizer guide](/guide/testing/folder-layout). ## Built-in helpers The declarative baseline is the aggregate set of graph-wide checks below. Import them all, then either call each one or `assertDeclarativeArchitecture` for the aggregate checks together. The demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts` so the graph is loaded once. Run it with `npx nx architecture demo`. Each rule has a focused page with the invariant it protects, the failure it prevents and the smallest useful test. Start with the [declarative baseline](/guide/testing/architecture/declarative-baseline), then add the rules that express your application's boundaries. | Helper | Fails when | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal | | [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site | | `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture | | [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` | | [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site | | [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project | | [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) | | [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) | | [`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 | | [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail | | [`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` | | [`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 | | [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist | | [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel | | [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` | | [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive | | [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` | | [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` | | [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` | | [`assertMetricThresholds`](/guide/testing/architecture/metric-thresholds) | **opt-in:** a selected node exceeds a team-defined complexity, size or coupling threshold | | [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary | | [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary | | [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` | ### `noExclusiveLink` Forbids edges that exist only because two branches touch each other. A shared kernel — auth, HTTP client, browser boundaries — is allowed. Membership stops at other `provides` sites, so a leak into a third feature is not reclassified as shared. ```typescript it('keeps exclusive feature branches from linking', () => { const [userList] = graph.providedOn('UserList'); const [userMutation] = graph.providedOn('UserMutation'); expect(userList).toBeDefined(); expect(userMutation).toBeDefined(); noExclusiveLink(userList, userMutation); }); ``` The same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`. ### `assertPathBoundaries` Nx `depConstraints` tag **projects** and forbid TypeScript imports. This helper tags **folders** on the Craft graph and forbids `depends-on` (optionally `calls`) between them — including inside one app, where module-boundary ESLint does not run. Same intention, different altitude: [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx). Paths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth, `:name` captures a segment. The same capture in `source` and `onlyDependOn` / `forbidTarget` must match, so a feature can depend on itself but not on siblings. `onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are set, the target must match the allowlist **and** miss the denylist. Nodes whose path matches no `source` are unconstrained. Edges without a `filePath` on either end, and structural edges (`provides`, `loads`, `renders`, `contains`), are ignored. ```typescript it('keeps features and UI in their folders', () => { assertPathBoundaries(graph.graph, { constraints: [ { source: 'src/app/features/:feature/**', onlyDependOn: [ 'src/app/features/:feature/**', 'src/app/shared/**', 'src/app/ui/**', ], }, { source: 'src/app/ui/**', onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'], forbidTarget: ['src/app/data/**'], }, ], }); }); ``` Sibling features are an allowlist job (`onlyDependOn` includes `features/:feature/**`). A denylist `features/**` would also forbid self. ### `assertCraftUnique` Each `craftUnique(...)` identity must appear once, and the argument must be a static literal — otherwise the graph cannot tell two call sites apart. Used with [persistence](/guide/state/persistence) so two queries cannot silently share a storage key. ```typescript it('requires craftUnique identities to appear once', () => { assertCraftUnique(graph.graph); }); ``` A duplicate or a non-literal argument fails the test with the file:line of each call site. ### `assertHttpEndpointUnique` A `GET users` node is one verb + one URL. Two call sites — two services, or the same service twice — fail the test. Distinct pairs (`GET users` and `POST users`, or `GET orders`) are allowed. ```typescript it('owns each HTTP endpoint once', () => { assertHttpEndpointUnique(graph.graph); }); ``` This is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient` in `craftUnique` is not required: the identity is the verb+URL. ### `assertVisualHappyPathArchitecture` The visual overview contract connects routed pages, the default mobile and desktop viewports, and deterministic API datasets. It consumes the config created with `defineVisualAppConfig` and fails if a routed page is absent, a configured component is unknown, or any `CraftHttpClient` / `CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated `*.happy-path.ts` file. ```typescript import { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools'; import { visualTestConfig } from '../../e2e/visual-test.config'; it('covers every page and HTTP endpoint in the visual happy path', () => { assertVisualHappyPathArchitecture(graph.graph, visualTestConfig); }); ``` The assertion is separate from `assertDeclarativeArchitecture` because it needs the application's visual config. Dynamic URL segments are represented by `*`, so a template URL such as `` `/api/users/${id}` `` is indexed as `/api/users/*` and uses the same key in its fixture. ### `assertCraftComputedPure` A `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`, `increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail. Local slips are also caught by ESLint `craft-ts/no-craft-computed-side-effects`. The graph catches a computed that calls a method declared in another binding. ```typescript it('keeps craftComputed free of methods and source$ writes', () => { assertCraftComputedPure(graph.graph); }); ``` ### `assertNoDependencyCycles` Directed cycles on `depends-on` only: service A → B → A, two `craftComputed` that yield each other, a self-`yield*`. `provides`, `contains`, `loads` and `renders` are structure, not a cycle of use. A shared kernel (Left → Auth, Right → Auth) is not a cycle. ```typescript it('forbids depends-on cycles', () => { assertNoDependencyCycles(graph.graph); }); ``` ### `assertDeclarativeArchitecture` Runs the aggregate checks above and joins their messages. Pass `{ allow }` through to `assertMutationHasReactOn` for fire-and-forget mutations. ```typescript import { architectureWaiverList } from './waivers'; it('keeps the app declarative', () => { assertDeclarativeArchitecture(graph.graph, { allow: ['logout'], waivers: architectureWaiverList, }); }); ``` The aggregate includes four **style rules**. They make `@craft-ts/style` the only way to style a component, and they need the style dump merged into the graph (step 4): | rule | fails when | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `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` | | `style-obligations-discharged` | a sheet `requires(...)` an obligation nothing `provides(...)` | | `no-dangling-css-vars` | a variable is read and never declared, or declared and never read (a read from `craftGlobalStyles` counts) | | `no-global-stylesheet` | an entry file imports a `.css`, or `index.html` links a stylesheet — the only one is `virtual:craft-style.css` | A component of pure composition, with no class at all, is not at fault. A class passed through an input typed `CraftClass` is accepted. ### Waivers A deliberate bypass — a third-party widget, HTML rendered from markdown — is a **waiver**: a rule, a target, and the reason. Declare them in `architecture/waivers.ts`, typed against the catalog, so a target that does not exist does not compile: ```typescript import { defineArchitectureWaivers } from '@craft-ts/dev-tools'; import { architectureCatalog } from './catalog'; export const architectureWaiverList = defineArchitectureWaivers( architectureCatalog, [ { rule: 'style-only-design-system', target: 'MarkdownArticle', reason: 'The HTML rendered from markdown carries its own classes.', }, ], ); ``` The target is a component name, `file:`, `obligation:`, `css-var:`, or `'*'` for the whole rule — the only form a rule that checks the whole graph (`no-dependency-cycles`, …) accepts, and the one a project uses while it migrates. Two things keep the list honest: an **empty reason** is refused, and a waiver that no longer waives anything is **stale** and fails the check. Review Attest lists every waiver for a decision. `craft-architecture-check` reads the same file (statically, without running the app) and takes the dump the build wrote with `--style-dump `. ### `assertRouteDiProofs` The routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and `RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the file: comment one out and TypeScript still compiles. That is the one fragile step in an otherwise compile-time guarantee. This helper makes that step a test failure. It walks the static graph and requires every routed component — including lazy `loadChildren` collections, which a parent proof never covers — every pending or error screen, and every `craftAppConfig` error surface to be hooked to an armed mapper. A mapper without `CanRun` is dead: the graph indexes it, then this rule fails. TypeScript still judges whether a dependency is provided; the architecture suite judges whether that judgement was invoked. ```typescript it('requires a DI proof on every routed component and app-config error screen', () => { assertRouteDiProofs(graph.graph); }); ``` A missing proof, an unarmed mapper, a pending/error screen without its own `RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an `app.config.ts` that registers `provideCraftGlobalErrorComponent` / `provideCraftRouteLoadErrorComponent` (or `withErrorComponent` / `withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails with the file:line of the hole. ### `assertRouteComponentsInSeparateFiles` Route definitions describe navigation and loading; page components live in their own files. This assertion compares the route file with every component target discovered through `component`, `loadComponent` or a lazy `import()`, then rejects multiple routed page components that share one component file. ```typescript it('keeps route definitions separate from page components', () => { assertRouteComponentsInSeparateFiles(graph.graph); }); ``` The rule checks the page file boundary only. It does not restrict components rendered inside a page, and it does not require one route collection per file. ### `assertMutationHasReactOn` A mutation that no query reacts to is the graph-wide form of [the button that knows which lists to refresh](/guide/state/react-on-mutation). The analyzer records `insertReactOnMutation` as a `triggers` edge from the mutation to the query — including when the insertion is nested in `insertQueryPipe`. This helper fails on every `mutation` primitive that has no such edge. Fire-and-forget writes (logout, a form submit with no cache, a demo that refreshes by incrementing local state) pass an `allow` list of mutation names: ```typescript it('requires a query to react to each mutation', () => { assertMutationHasReactOn(graph.graph, { allow: ['logout'] }); }); ``` ### `assertPersistedPrimitiveHasUnique` `assertCraftUnique` says an identity appears once. This helper says a persisted primitive *has* an identity: `insertStoragePersister` / `insertLocalStoragePersister` must take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive as persisted and fails here. ```typescript it('requires craftUnique on every persisted primitive', () => { assertPersistedPrimitiveHasUnique(graph.graph); }); ``` See [Persistence](/guide/state/persistence). ### `assertInsertSelectUnique` `insertSelect('cell')` names a slice on its host `state` / `query`. Two siblings with the same key on the same host stomp each other. The same key on two different hosts is allowed — each list can have a `cell`. ```typescript it('keeps insertSelect keys unique on each host', () => { assertInsertSelectUnique(graph.graph); }); ``` See [Selecting](/guide/state/select). ### `assertCraftEffectNoNetwork` A `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or `mutation` in disguise. Reads of local `state` stay valid. ```typescript it('keeps craftEffect off HTTP and mutations', () => { assertCraftEffectNoNetwork(graph.graph); }); ``` ### `assertCraftEffectNoImperativeSync` A `craftEffect` that writes another `state` or `source$`, or that calls `query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should be a sourced `state` or reactive `params` instead. Logging, focus, and other I/O that does not push into a Craft primitive stay valid. ESLint `craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger half in the editor; this helper is the graph-wide counterpart, including state writes. ```typescript it('keeps craftEffect from pushing into other primitives', () => { assertCraftEffectNoImperativeSync(graph.graph); }); ``` ### `assertInteractiveElementNamed` `button('increment', {}, '+')` stamps `data-craft-name="increment"`. Type-level proofs and DOM tests already key off that name. This helper makes the first string **mandatory** on clickable and fillable elements, and **unique in the app**: two `button('save')` in two components fail, and so does `button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name` is the editor counterpart for the missing / non-static cases. ```typescript it('requires a unique literal data-craft-name on every interactive element', () => { assertInteractiveElementNamed(graph.graph); }); ``` ## Metric thresholds Every node of the graph carries `metrics`: cyclomatic complexity (its own and with everything it contains), line count, fan-in and fan-out. No threshold applies by default; put the ones your team agrees on in the suite. See the [focused rule guide](/guide/testing/architecture/metric-thresholds) for before-and-after examples: ```typescript import { assertMetricThresholds } from '@craft-ts/dev-tools/architecture-graph'; it('keeps services and primitives small', () => { assertMetricThresholds(graph.graph, { kinds: ['service', 'primitive'], max: { cyclomaticOwn: 15, fanOut: 12 }, allow: ['src/legacy/**', 'ReportingService'], }); }); ``` `allow` takes node ids, labels, or path globs. A metric the graph could not compute — a node without a source range — is skipped, never treated as `0`; `graph.diagnostics` lists the unmeasured kinds. The assertion refuses a graph that carries no metrics at all, such as a JSON file written by an older version. `metricThresholdViolations` returns the same findings as data. See [Graph insights](/guide/testing/graph-insights) for how the metrics are computed, the hotspot ranking and the report. ## Documentation rules The graph reads the JSDoc of each declaration and, with the opt-in Markdown collector, the pages that cite a node. `assertNodesDocumented` turns that into a rule: ```typescript import { assertNodesDocumented } from '@craft-ts/dev-tools/architecture-graph'; import { analyzeDependencyGraph, createMarkdownDocsCollector, } from '@craft-ts/dev-tools/dependency-graph'; const documented = analyzeDependencyGraph({ rootDir: workspaceRoot, tsConfigFilePath: 'apps/shop/tsconfig.graph.json', collectors: [createMarkdownDocsCollector({ include: ['docs/**/*.md'] })], }); it('documents every service', () => { assertNodesDocumented(documented, { kinds: ['service'], requireDocPage: true, allow: ['src/legacy/**'], }); }); ``` A node fails without a JSDoc summary, and with `requireDocPage` when no page cites it in inline code. A node without a source range is skipped: its documentation is unknown, not missing. `requireDocPage` refuses a graph built without the collector. `undocumentedNodeViolations` returns the findings as data. ## Writing your own rules Start from a node you care about and assert what should be true of its neighbourhood. The demo suite does this for routes and HTTP; the same pattern covers any invariant you can see on the graph. ### A route provides the feature service ```typescript it('indexes demo routes and provided feature services', () => { expect(graph.route('craft/query/:userId').kind).toBe('route'); expect(graph.providedOn('UserList').map((node) => node.label)).toEqual( expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]), ); }); ``` ### An HTTP endpoint has a single owner ```typescript it('indexes the users HTTP endpoint', () => { expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users'); expect(graph.usingHttp().map((node) => node.label)).toEqual( expect.arrayContaining(['UsersApiOnError']), ); }); ``` ### HTTP only from a browser boundary [Browser boundaries](/guide/testing/browser-boundaries) are the line to the network. A rule can require that `CraftHttpClient` is only yielded from a service marked `browserBoundary: true`: ```typescript it('only browser-boundary services call HTTP', () => { const boundaryIds = new Set( graph.services({ browserBoundary: true }).map((node) => node.id), ); const leaked = graph .usingHttp() .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id)); expect(leaked.map((node) => node.label)).toEqual([]); }); ``` ### A persisted identity exists ```typescript it('looks up a persisted unique identity', () => { expect(graph.unique('{"key":"user-query","storeName":"demo-app"}').kind).toBe( 'unique', ); }); ``` If the lookup throws, the identity left the graph — the key changed, or `craftUnique` was removed. Anything you can express with `outgoing` / `incoming` is a rule: “this `craftMethod` is either called or writes a `source$`, never both”, “this component does not `depends-on` that service”, “only `providedIn: 'global'` services appear under `usingTemporal()`”. Keep the assertion next to a comment that states the product invariant, not the graph traversal. ## Inspecting the graph `npx craft-graph` (also `npx craft graph`) writes the same analysis to disk without running tests: ```shell npx craft-graph \ --project apps/your-app/tsconfig.graph.json \ --root . \ --out craft-dependency-graph \ --format all ``` | `--format` | Writes | | ---------- | --------------------------------------------- | | `json` | the raw graph + a `.architecture.ts` catalog | | `mermaid` | a `.mmd` diagram | | `html` | a standalone explorer (no server, no runtime) | | `both` | JSON + catalog + Mermaid | | `all` | JSON + catalog + Mermaid + HTML + report | | `report` | `.report.md` and `.report.json` | `--include ` restricts analysis to matching source paths. `--feature-glob`, `--churn-since` and `--coverage` shape the [report](/guide/testing/graph-insights#report). Use the HTML explorer to see a route expand into components and services before you write the assertion. ## Pitfalls **The analysis tsconfig must include the app, not just `main.ts`.** An empty graph with a passing `usingHttp()` is the usual symptom. **Do not nest `vitest.config.ts` under `architecture/`.** Put `vitest.architecture.config.ts` at the app root. **The catalog lags by one run.** Lookups are typed against the committed file. After adding a route or service, run the suite once so the rewrite lands, then the new name typechecks. **Homonyms need a file path.** `graph.service('ApiService')` throws `Ambiguous service 'ApiService'` when two files export that name. Pass `'users/api.service.ts'`. **`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as `static: false` and `assertCraftUnique` fails — the graph cannot prove uniqueness. **A commented `CanRun` still type-checks.** Unused aliases are not errors. `assertRouteDiProofs` is the CI counterpart — that is the whole point of the helper. **These tests are not e2e.** They never boot the app. Pair them with [service](/guide/testing/services) and [component](/guide/testing/components) tests for behaviour, and with ESLint for local architecture. ## See Also * [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can and cannot see * [Testing services](/guide/testing/services) — the runtime graph of one service * [Browser boundaries](/guide/testing/browser-boundaries) — the nodes `browserBoundary: true` refers to * [Persistence](/guide/state/persistence) — why `craftUnique` identities must be unique * [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed * [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed * [Learn: test what you wrote](/learn/10-testing) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/declarative-baseline.md --- # Declarative architecture baseline `assertDeclarativeArchitecture` is the first architecture test to add to a Craft app. It is not a style check and it does not test the DOM. It reads the static Craft graph and verifies eight relationships that are easy to lose during a refactor: ```ts export function keepTheAppDeclarative(graph: ArchitectureGraph) { assertDeclarativeArchitecture(graph.graph); } ``` The same test protects eight different failure modes: | Rule | If it is missing, this can happen | | --- | --- | | `assertCraftUnique` | two persisted resources restore from the same storage slot | | `assertHttpEndpointUnique` | two services own `GET users` and evolve it differently | | `assertCraftComputedPure` | reading a derived value writes state or starts work | | `assertNoDependencyCycles` | service construction loops through `A → B → A` | | `assertMutationHasReactOn` | a successful write leaves the visible list stale | | `assertPrimitiveMethodsUsedOnce` | one exposed method silently serves several call sites and loses their context | | `assertNoUnusedPrimitiveMethods` | an exposed method is never called and adds noise to the primitive interface | | `assertInputActionForms` | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary | The following examples show the actual code shape that each rule rejects. ## 1. Two persisted resources share one identity Two feature files can both look reasonable in isolation: ```typescript // features/users/user-list.ts insertStoragePersister( craftUnique({ storeName: 'shop', key: 'user' }), ); ``` ```typescript // features/users/user-detail.ts insertStoragePersister( craftUnique({ storeName: 'shop', key: 'user' }), ); ``` They do not create a list cache and a detail cache. They create one storage identity with two call sites. Restoring the detail can overwrite the value that the list expects, and the bug only appears after a reload or cache restore. `assertCraftUnique` fails with both file locations. The fix is to give the resources distinct identities: ```typescript craftUnique({ storeName: 'shop', key: 'user-list' }); craftUnique({ storeName: 'shop', key: 'user-detail' }); ``` See [Unique identities](./unique-identities) and [Persisted identities](./persisted-identities). ## 2. Two services call the same HTTP endpoint This duplication is also invisible to TypeScript: ```typescript // users-api.ts const users = yield* CraftHttpClient.get(({ response }) => ({ url: 'users', success: response(), })); ``` ```typescript // admin-api.ts const users = yield* CraftHttpClient.get(({ response }) => ({ url: 'users', success: response(), })); ``` Both are `GET users`. One feature can add pagination or change the response shape while the other keeps the old assumption. The two call sites are now competing owners of one transport contract. `assertHttpEndpointUnique` forces one owner. The other service must depend on that owner and derive its own view: ```typescript const users = yield* UsersApi(); const admins = craftComputed('admins', function* () { return (yield* users.list()).filter((user) => user.role === 'admin'); }); ``` See [HTTP endpoint ownership](./http-endpoint-ownership). ## 3. A computed value performs work while being read The purpose of `craftComputed` is to derive a value: ```typescript const remaining = craftComputed('remaining', function* () { return (yield* tasks()).filter((task) => !task.done).length; }); ``` This version changes the meaning of a read: ```typescript const remaining = craftComputed('remaining', function* () { yield* audit.log('recomputed'); yield* tasks.set(normalizeTasks(yield* tasks())); return (yield* tasks()).filter((task) => !task.done).length; }); ``` Now a template read can write state, call a method or trigger another graph branch. Depending on recomputation order, this can produce a loop, duplicate work or state that changes merely because it was displayed. `assertCraftComputedPure` rejects both direct writes and calls through another method binding. Put the work in a method, `on$`, `query` or `mutation`, and let the computed only read. See [Computed purity](./computed-purity). ## 4. Two services depend on each other The graph for this pair is enough to fail the suite: ```text UserList ──depends-on──▶ UserMutation UserMutation ──depends-on──▶ UserList ``` In source, the cycle usually comes from two factories each yielding the other: ```typescript // features/users/user-list.ts function* userListFactory() { const mutation = yield* UserMutation(); return { mutation }; } // features/users/user-mutation.ts function* userMutationFactory() { const list = yield* UserList(); return { list }; } ``` The surrounding `craftService(...)` declarations are omitted here; the important part is the two dependency edges created by the `yield*` calls. The application may not fail until the route first constructs the services. Then it can recurse forever or expose a partially constructed value. `assertNoDependencyCycles` identifies the path. Break it by extracting a small shared contract, passing an input, or moving a derived value into the consumer. Two features depending on a common `Auth` service are not a cycle: ```text UserList ──▶ Auth ◀── Checkout ``` See [Dependency cycles](./dependency-cycles). ## 5. A mutation succeeds but the list never refreshes The orphan mutation is the most user-visible failure: ```typescript const createTask = yield* mutation('createTask', { method: (input: NewTask) => input, loader: saveTask, }); const tasks = yield* query('tasks', { params: () => filters(), loader: loadTasks, }); ``` The server has the new task, but the query has no declared relationship with the mutation. The user clicks “Create”, gets a success response, and still sees the old list until a hard reload. Declare the relationship on the query: ```typescript const tasks = yield* query( 'tasks', { params: () => filters(), loader: loadTasks, }, insertReactOnMutation(createTask, { reload: { onMutationSuccess: true }, }), ); ``` The graph records a `triggers` edge from `createTask` to `tasks`. The exact policy can be a reload, an optimistic patch or another supported insertion; the important part is that it is declared where the query is defined. See [Mutation reactions](./mutation-reactions). ## 6. An input-driven action bypasses a form This relationship can cross files. The component may own the input and button, while a service owns the mutation. The graph follows both sides: ```typescript // todo-store.ts — avant const title = yield* state('title', ''); const addTodo = yield* mutation('addTodo', { method: function* () { return { title: yield* title() }; }, loader: saveTodo, }); ``` ```typescript // todo-page.ts — avant input('TodoTitleInput', { value: store.title }); button('AddTodoButton', { click: function* () { yield* store.addTodo.mutate(); } }, 'Add'); ``` The mutation receives no argument at the click site, but it still depends on the input because its `method` reads the same state. `assertInputActionForms` rejects this shape. A file-local ESLint rule can catch the direct version in one file, but only the architecture graph can follow the input, button, service and resource method across files. The accepted shape makes the boundary explicit: ```typescript // todo-store.ts — après import type { ValidatedFormValue } from '@craft-ts/core'; const addTodo = yield* mutation('addTodo', { method: (title: NonNullable>) => ({ title: title.trim(), }), loader: saveTodo, }); // todo-page.ts — après const titleForm = yield* state( 'titleForm', '', insertForm( insertFormAttributes(() => ({ validators: [cRequired()] })), insertFormSubmit(addTodo), ), ); form('AddTodoForm', { *submit(event) { event.preventDefault(); yield* titleForm.form.submit(); }, }, [ input('TodoTitleInput', { type: 'text' }).pipe( CraftFieldDirective(titleForm.form), ), button('AddTodoButton', { type: 'submit' }, 'Add'), ]); ``` For an object-valued form, add `insertSelectFormTree` and bind the native input to `titleForm.form.selectTitle()` instead. The `insertFormAttributes` insertion is where validators and field attributes belong; `insertFormSubmit(addTodo)` connects the validated value to the mutation. The architecture assertion rejects a direct button call even when a `form` and `insertFormSubmit` are also present, so there is only one submit boundary. `insertForm()` alone is valid, but it is insufficient for this mutation-backed example: the submit insertion is what passes the validated form value to `addTodo`. ## What the aggregate test does — and does not do The aggregate test is now understandable as a compact CI gate: ```typescript it('protects the baseline graph invariants', () => { assertDeclarativeArchitecture(graph.graph); }); ``` It does **not** cover every architecture policy. Add focused assertions for: * route DI and error-screen proofs: [`assertRouteDiProofs`](./route-di-proofs); * route/page file boundaries: [`assertRouteComponentsInSeparateFiles`](./route-component-files); * folder ownership: [`assertPathBoundaries`](./path-boundaries); * Effect loader boundaries: [`assertPrimitiveLoaderRequirements`](./primitive-loader-requirements); * interactive control names: [`assertInteractiveElementNamed`](./interactive-element-names); * `craftEffect` network and imperative-sync constraints: [Effect rules](./craft-effect-network). * input-driven mutations and async processes: `assertInputActionForms` (this page). Keep the aggregate assertion for the common baseline, and keep focused rules for policies whose failure message should explain a product or team boundary. ## Explicit exceptions Some mutations really are fire-and-forget: logout, telemetry or an export with no cached query. Name those exceptions instead of weakening the whole rule: ```typescript assertDeclarativeArchitecture(graph.graph, { allow: ['logout', 'sendTelemetry'], }); ``` An `allow` entry is a documented decision. It should be narrow enough that a new orphan mutation cannot hide inside it. ## See also * [Architecture rules](/guide/testing/architecture) * [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/unique-identities.md --- # Unique `craftUnique` identities `assertCraftUnique` checks that every `craftUnique(...)` identity is a static, single-use identity in the application graph. ```ts export function keepStorageIdentitiesUnique(graph: ArchitectureGraph) { assertCraftUnique(graph.graph); } ``` ## What it prevents Persistence is keyed by identity, not by the variable name around it: ```typescript insertStoragePersister(craftUnique({ storeName: 'shop', key: 'user-list', })); ``` If the list and detail features both use `{ storeName: 'shop', key: 'user' }`, they do not get two caches. They get one storage slot, and whichever feature writes last changes what the other feature restores. The rule also rejects a computed identity: ```typescript const key = featureName(); craftUnique({ storeName: 'shop', key }); // not statically verifiable ``` Static literals let the catalog show every call site and let CI prove that a rename did not silently merge two persisted resources. The canonical JSON is order-independent, so swapping `key` and `storeName` does not evade the check. ## Different stores are different identities The same key is valid in two stores: ```typescript craftUnique({ storeName: 'shop', key: 'user' }); craftUnique({ storeName: 'admin', key: 'user' }); ``` The complete identity is the pair, not the key alone. ## See also * [Persisted primitive identities](./persisted-identities) * [Persistence](/guide/state/persistence) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/http-endpoint-ownership.md --- # Unique HTTP endpoint ownership `assertHttpEndpointUnique` treats an HTTP endpoint as the pair of its method and URL. It fails when two graph sites call the same pair: ```ts export function keepEndpointOwnershipUnique(graph: ArchitectureGraph) { assertHttpEndpointUnique(graph.graph); } ``` ## What it prevents Two services can independently implement this: ```typescript CraftHttpClient.get(({ response }) => ({ url: 'users', success: response(), })); ``` The application still compiles, but there are now two owners for `GET users`. One may add pagination, the other may keep an old response shape. A bug fix in one call site does not reach the other. The rule forces a single boundary service to own `GET users`. Other services depend on that service and can derive feature-specific views without creating a second transport contract. ## What counts as distinct These are separate endpoints and are allowed: ```text GET users POST users GET orders ``` The rule is intentionally narrower than “one URL in the whole app”: a read and a write are different contracts. ## Why this is graph-wide ESLint can flag a local HTTP style mistake. It cannot see that a second feature has claimed an endpoint already owned elsewhere. The graph can inspect every call site in one assertion. ## See also * [Browser boundaries](/guide/testing/browser-boundaries) * [Architecture rules](/guide/testing/architecture) --- --- url: https://craft-ts.github.io/craft/guide/testing/architecture/computed-purity.md --- # Pure `craftComputed` derivations `assertCraftComputedPure` requires a `craftComputed` to read dependencies and return a value. It may not call a method or write to a source: ```ts export function keepDerivationsPure(graph: ArchitectureGraph) { assertCraftComputedPure(graph.graph); } ``` ## The safe shape ```typescript const remaining = craftComputed('remaining', function* () { return (yield* tasks()).filter((task) => !task.done).length; }); ``` ## What it prevents This looks convenient but makes a derivation an imperative workflow: ```typescript const count = craftComputed('count', function* () { yield* audit.log('count recomputed'); yield* tasks.set(normalizeTasks(yield* tasks())); return (yield* tasks()).length; }); ``` Now reading `count` can write state, invoke a method, or trigger another computation. Re-computation order becomes observable, and a harmless template read can cause a loop. ## The `set` can be hidden in a local function Moving the write into a helper does not make it a derivation. This is still invalid: ```typescript const tasks = yield* state( 'tasks', initialTasks, ({ set }) => ({ set }), ); const remaining = craftComputed('remaining', function* () { // The write is not on the next line, but the helper belongs to this computed. const normalizeAndStore = function* (value: Task[]) { yield* tasks.set(normalizeTasks(value)); }; const current = yield* tasks(); yield* normalizeAndStore(current); return current.filter((task) => !task.done).length; }); ``` The graph still records the relationship: ```text craftComputed:remaining ──writes──▶ state:tasks ``` So `assertCraftComputedPure` rejects it even though the computed body only calls `normalizeAndStore` at the apparent call site. The failure points back to the computed and the write target, rather than relying on a reviewer to notice a `set` several lines down. The same applies to an indirect method call: ```typescript const refresh = craftMethod('refresh', function* () { yield* tasks.set(initialTasks); }); const count = craftComputed('count', function* () { const runRefresh = () => refresh(); yield* runRefresh(); return (yield* tasks()).length; }); ``` The rule sees the `calls` edge from `count` to `refresh`. This is why the check belongs on the graph in addition to a local ESLint rule: it protects the invariant even when the side effect is hidden behind a binding. The fix is to keep the computed read-only and move the write to an explicit method or event: ```typescript const normalize = craftMethod('normalize', function* () { yield* tasks.set(normalizeTasks(yield* tasks())); }); const remaining = craftComputed('remaining', function* () { return (yield* tasks()).filter((task) => !task.done).length; }); ``` ## Where the side effect belongs * derive a value with `craftComputed`; * react to an event with `on$`; * update a primitive from a user action with a method; * run external work with `craftEffect` or an explicit resource primitive. Separating these roles makes the dependency graph explainable and tests deterministic. ## See also * [The mental model](/guide/concepts/mental-model) * [`craftComputed`](/guide/reactivity/craft-computed) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/dependency-cycles.md --- # No dependency cycles `assertNoDependencyCycles` checks directed `depends-on` edges between services, components and computeds: ```ts export function keepTheDependencyGraphAcyclic(graph: ArchitectureGraph) { assertNoDependencyCycles(graph.graph); } ``` ## What it prevents The obvious cycle is two services that construct each other: ```text Left → Right → Left ``` In source, it often appears as a harmless pair of `yield*` calls. At runtime it can fail as a recursive construction, an incomplete service or a provider that only breaks when a route is first visited. The rule also catches a self-dependency and cycles involving computed values. ## Shared dependencies are not cycles This is valid: ```text AdminPage → Auth Checkout → Auth ``` Both branches depend on a shared kernel; there is no path back from `Auth` to either branch. `provides`, `contains`, `loads` and `renders` are structural edges and are not treated as dependency cycles. ## How to break a real cycle Usually one side should depend on a smaller contract: * extract a read-only service from the two large services; * move shared policy into a third service; * pass a value as an input instead of resolving the owning service; * move a derived value into the consumer instead of publishing it back. Do not silence a cycle by adding an `allow` list: this assertion has no such escape hatch because a cycle changes construction semantics. ## See also * [Service scopes](/guide/app/service-scopes) * [Composing services](/learn/04-compose) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/mutation-reactions.md --- # Mutations must have a read-side reaction `assertMutationHasReactOn` requires every mutation to have an `insertReactOnMutation` edge to a query, unless the mutation is explicitly allowed: ```ts export function keepReadsFreshAfterWrites(graph: ArchitectureGraph) { assertMutationHasReactOn(graph.graph, { allow: ['logout'], }); } ``` ## What it prevents The stale-list bug is easy to write: ```typescript const createTask = yield* mutation('createTask', { method: (input: NewTask) => input, loader: saveTask, }); // The list query exists, but nothing says it reacts to createTask. ``` The write succeeds and the database contains the new task, while the list on screen remains unchanged until a full reload. The graph sees that the mutation has no `triggers` edge and fails CI. ## The declared relationship Put the insertion on the query: ```typescript const tasks = yield* query( 'tasks', { params: filters, loader: loadTasks }, insertReactOnMutation(createTask, { reload: { onMutationSuccess: true }, }), ); ``` The same rule covers nested `insertQueryPipe` composition. It does not require every mutation to reload every query — only that the write has an explicit read-side policy somewhere. ## Legitimate fire-and-forget writes Logout, telemetry and an export may intentionally have no query to refresh: ```typescript assertMutationHasReactOn(graph.graph, { allow: ['logout', 'sendTelemetry', 'exportUsers'], }); ``` Keep the allowlist named and small so a newly orphaned mutation cannot hide in a generic `allow: ['*']` convention. ## See also * [Reacting to mutations](/guide/state/react-on-mutation) * [Write server data](/learn/06-mutate-data) --- --- url: https://craft-ts.github.io/craft/guide/testing/architecture/route-di-proofs.md --- # Route DI proofs and exception coverage `assertRouteDiProofs` keeps type-level routing guarantees armed at runtime in CI. It checks that routed components, lazy route collections, pending screens and error screens have a live mapper connected to a `CanRun` proof: ```ts export function keepEveryRouteDiProofArmed(graph: ArchitectureGraph) { assertRouteDiProofs(graph.graph); } ``` ## What it prevents `RouteCheckedDI` is intentionally an unused type alias: ```typescript type Check = RouteCheckedDI; type CanRunCheck = CanRun; ``` If somebody comments out `CanRunCheck`, TypeScript still compiles. The proof no longer runs, and a later missing provider can become a runtime navigation failure. `assertRouteDiProofs` spots the unarmed mapper by inspecting the graph. The same applies to a child route file: a parent cascade proof cannot cover a lazy `loadChildren` collection that was added later. ## It also covers error surfaces The rule requires checks for: * routed components; * pending components; * route and global error components; * route-load error components; * `assertExhaustiveRouteExceptions` on route collections. Without the error-screen checks, the happy route can be type-safe while the first missing provider renders an unverified fallback. ## The expected pairing ```typescript type CheckTasks = RouteCheckedDI< ComponentDepsOf, 'CraftRouter', never, 'tasks' >; type CanRunTasks = CanRun; assertExhaustiveRouteExceptions(appRoutes); ``` TypeScript checks whether the provider is available; this rule checks that the application actually invoked that judgement. ## See also * [Routing setup](/guide/routing/setup) * [Route providers](/guide/routing/route-providers) * [Route exception handling](/guide/routing/exception-handling) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/route-component-files.md --- # Route page components each live in their own file `assertRouteComponentsInSeparateFiles` requires every component attached to a route through `component`, `loadComponent` or a lazy `import()` to live outside the route-definition file. It also rejects two different routed page components sharing one common component file: ```ts export function keepRouteComponentsInSeparateFiles( graph: ArchitectureGraph, ) { assertRouteComponentsInSeparateFiles(graph.graph); } ``` ## What it prevents This keeps the page boundary explicit: ```typescript // pages.routes.ts export const pagesRoutes = craftRoutes('pages', [ { path: 'orders', loadComponent: () => import('./orders-page'), }, ]); ``` ```typescript // orders-page.ts export const OrdersPage = craftComponent(/* ... */); ``` Putting both declarations in `pages.routes.ts` makes route files grow into feature modules. Putting `OrdersPage` and `CustomersPage` in one `pages.components.ts` file creates the same problem at the lazy boundary: the chunk is no longer organized around one page entry point. The rule covers both eager route targets and lazy targets. A route collection may contain several routes, but each page component declaration still has its own source file. Child components rendered by a page are not route targets and are not restricted by this rule. Reusing the same component declaration from multiple routes is not treated as two page components. ## The intended shape Keep the route tree responsible for navigation and loading: ```typescript // account.routes.ts export const accountRoutes = craftRoutes('account', [ { path: 'profile', loadComponent: ({ withRetry }) => withRetry(import('./profile-page')).then((module) => module.ProfilePage), }, ]); ``` Keep the page implementation in its own file, with its own component dependencies and tests. `loadChildren` remains the right choice when a whole route collection should be lazy, while the collection's page components still follow this file boundary. ## Failure message The assertion reports the route, component and offending source file. Move each page component to its own sibling file, then keep the route's import literal so the router and bundler can discover the lazy boundary. ## See also * [Routing setup](/guide/routing/setup) * [Scaling routes](/guide/routing/scaling) * [Route DI proofs](./route-di-proofs) --- --- url: https://craft-ts.github.io/craft/guide/testing/architecture/path-boundaries.md --- # Folder path boundaries `assertPathBoundaries` applies architectural allowlists and denylists to paths inside one application. It checks graph dependencies, and can optionally check calls: ```ts export function keepFeaturePathsInTheirLanes(graph: ArchitectureGraph) { assertPathBoundaries(graph.graph, { constraints: [ { source: 'src/app/features/:feature/**', onlyDependOn: [ 'src/app/features/:feature/**', 'src/app/shared/**', 'src/app/ui/**', ], }, ], }); } ``` ## What it prevents Without a folder rule, these imports are easy to introduce: ```text features/users → features/cart ui/widget → data/users-api ``` The first couples sibling features. The second lets presentation code bypass the domain or browser-boundary service. Both can work today and make tomorrow's move or replacement expensive. The `:feature` capture permits a feature to depend on its own folder while forbidding a sibling. A denylist such as `features/**` would accidentally forbid self-dependencies too. ## Why this is not just ESLint Nx `depConstraints` protect project-to-project imports. This rule protects folders within an app, including routes, services and components that belong to the same Nx project. Structural edges such as `loads` and `renders` are ignored; the rule is about ownership and dependency flow. ## See also * [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) * [Writing your own rules](/guide/testing/architecture#writing-your-own-rules) --- --- url: https://craft-ts.github.io/craft/guide/testing/architecture/exclusive-links.md --- # Exclusive branch links `noExclusiveLink(a, b)` checks that two branches do not depend on each other through a private leak. Shared kernel nodes are allowed: ```ts export function keepFeatureBranchesIndependent(graph: ArchitectureGraph) { noExclusiveLink(graph.route('/admin'), graph.route('/checkout')); } ``` ## What it prevents Suppose two features are intended to be independent: ```text admin → checkout-private-service → checkout ``` The application has now created a hidden integration. A future checkout rewrite must preserve an admin-only dependency, and removing the link can break a route that was never listed as a consumer. The same problem appears between feature services: ```text UserList → UserMutation → UserList ``` or when one route reaches directly into another feature's private data service. ## Shared kernels are not leaks This is allowed: ```text admin → Auth checkout → Auth ``` The helper stops membership at other `provides` sites, so a common auth service, HTTP boundary or browser boundary is treated as shared infrastructure rather than as a feature-to-feature link. ## Use it with routes or services The arguments are graph nodes, so the same invariant can protect route branches, feature services or any two catalog lookups. ## See also * [Path boundaries](./path-boundaries) * [Architecture graph lookups](/guide/testing/architecture#looking-up-nodes) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/persisted-identities.md --- # Persisted primitives need a unique identity `assertPersistedPrimitiveHasUnique` checks the inverse of the general identity rule: every primitive using storage persistence must receive a `craftUnique` identity. ```ts export function keepPersistedPrimitivesIdentifiable(graph: ArchitectureGraph) { assertPersistedPrimitiveHasUnique(graph.graph); } ``` ## What it prevents This is not safe enough for a persisted query: ```typescript insertStoragePersister({ storeName: 'shop', key: 'user-list', }); ``` The persister may work, but the graph cannot prove that the identity is static or that another primitive does not use the same slot. A refactor can silently make two resources share storage. Make the boundary explicit: ```typescript insertStoragePersister( craftUnique({ storeName: 'shop', key: 'user-list' }), ); ``` This rule and [`assertCraftUnique`](./unique-identities) are complementary: ```text assertPersistedPrimitiveHasUnique → every persisted primitive has an identity assertCraftUnique → every identity is unique and verifiable ``` ## When persistence is deliberately absent An in-memory `query` or `state` has no persister and needs no identity. Do not wrap every primitive in `craftUnique`; add it where storage, persistence or another identity-indexed integration needs one. ## See also * [Persistence](/guide/state/persistence) * [Unique identities](./unique-identities) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/insert-select-keys.md --- # Unique `insertSelect` keys per host `assertInsertSelectUnique` requires each `insertSelect` key to appear once on a given host primitive: ```ts export function keepSelectedInsertionKeysUnambiguous( graph: ArchitectureGraph, ) { assertInsertSelectUnique(graph.graph); } ``` ## What it prevents An insertion key names a selected slice on its host: ```typescript query( 'users', config, insertSelect('cell', selectUserCell), insertSelect('cell', selectAnotherCell), // collision ); ``` Both insertions claim `cell`. Depending on insertion order, one can replace the other, or consumers can read a type that no longer matches the runtime branch. The failure is especially hard to spot when the two insertions live in separate feature helpers. ## The same key on another host is valid ```typescript state('users', initialUsers, insertSelect('cell', selectUserCell)); state('orders', initialOrders, insertSelect('cell', selectOrderCell)); ``` The key is local to a host. The rule does not impose a useless app-wide naming scheme. ## What to do after a failure Use a key that describes the selected contract (`'summary'`, `'pagination'`, `'selectedUser'`) or merge the two selection behaviours into one insertion when they are really one public slice. ## See also * [Selecting](/guide/state/select) * [Typed insertion pipes](/guide/concepts/insertion-pipes) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/craft-effect-network.md --- # Keep `craftEffect` off the network `assertCraftEffectNoNetwork` prevents a reactive `craftEffect` from calling HTTP or a mutation: ```ts export function keepNetworkWorkInResources(graph: ArchitectureGraph) { assertCraftEffectNoNetwork(graph.graph); } ``` ## What it prevents This is a query disguised as an effect: ```typescript craftEffect('poll', function* () { yield* CraftHttpClient.get(loadUsers); }); ``` It has no standard query loading state, cache identity, cancellation contract or read-side exception flow. It can also run again whenever an unrelated reactive dependency changes. This is a mutation disguised as an effect: ```typescript craftEffect('save', function* () { yield* saveMutation.mutate(payload); }); ``` The write has no explicit user action or mutation relationship in its declaration. ## The intended alternatives * use `query` / `queryEffect` for reads; * use `mutation` / `mutationEffect` for writes; * use `asyncProcess` / `asyncProcessEffect` for explicit commands; * keep `craftEffect` for reactive side effects such as logging, focus or integration with a non-Craft sink. The rule protects the semantic boundary, not the use of Effects in general. ## See also * [craftEffect](/guide/reactivity/craft-effect) * [Which primitive should I use?](/guide/concepts/choose-primitive) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/craft-effect-imperative-sync.md --- # Keep `craftEffect` out of imperative synchronisation `assertCraftEffectNoImperativeSync` prevents a `craftEffect` from writing a state/source or triggering another query, mutation or async process: ```ts export function keepResourceTransitionsDeclarative(graph: ArchitectureGraph) { assertCraftEffectNoImperativeSync(graph.graph); } ``` ## The syntax is valid — the placement is not The following calls are valid Craft generator syntax. `set`, `call` and `mutate` return yieldable operations, so a generator consumes them with `yield*`: ```typescript function* submit() { yield* searchResults.set(yield* rawResults()); yield* usersQuery.call(yield* searchTerm()); yield* saveMutation.mutate(yield* draft()); } ``` The problem is putting the same code in a `craftEffect`. This is exactly the case rejected by `assertCraftEffectNoImperativeSync`: ```typescript craftEffect('sync', function* () { yield* searchResults.set(yield* rawResults()); yield* usersQuery.call(yield* searchTerm()); yield* saveMutation.mutate(yield* draft()); }); ``` The rule is therefore not saying that `yield* searchResults.set(...)` is invalid TypeScript or invalid Craft syntax. It is saying that a reactive effect must not imperatively write or trigger another Craft primitive. ## What it prevents This effect creates three hidden edges in the Craft graph: ```typescript craftEffect('sync', function* () { yield* searchResults.set(yield* rawResults()); yield* usersQuery.call(yield* searchTerm()); yield* saveMutation.mutate(yield* draft()); }); ``` The graph is effectively: ```text sync effect ──writes──▶ searchResults ├─calls────▶ usersQuery └─calls────▶ saveMutation ``` Whenever one of the values read by the effect changes, the effect can write state, start a query and start a mutation again. The direction of data flow is hidden in a callback, which can create feedback loops, duplicate requests or a mutation that runs merely because a signal was read. ## Use the primitive that owns the relationship instead If the query depends on `searchTerm`, make that dependency explicit with `params`: ```typescript const usersQuery = yield * query('usersQuery', { params: searchTerm, loader: ({ params }) => searchUsers(params), }); ``` If the operation is a single explicit user action, a `craftMethod` may call one mutation after normalising the event: ```typescript const save = craftMethod('save', function* (event: Event) { event.preventDefault(); yield* saveMutation.mutate(yield* draft()); }); ``` For several operations belonging to one event, emit a `source$` directly from the submit or click handler and let each affected primitive react to it. A mutation-to-query relationship belongs in `insertReactOnMutation`, not beside the mutation call site: ```typescript const signOut$ = source$('signOut$'); button({ click: () => signOut$.emit() }, 'Sign out'); const logout = yield * mutation('logout', { method: signOut$.asReadonly(), loader: logoutUser, }); const session = yield * query( 'session', { params: () => 'current', loader: loadSession }, insertReactOnMutation(logout, { optimisticUpdate: () => undefined, }), ); ``` `craft-ts/no-imperative-craft-method-actions` and `craft-ts/no-imperative-storage-in-craft-method` enforce this placement in the editor. Storage adapters and intentionally imperative facades remain valid in their `craftService` seam. For a mutation-to-query relationship, use an insertion such as `insertReactOnMutation`. For a named external event, use `on$`. Use a computed value when `searchResults` is only a transformation of `rawResults`, instead of storing a second value and synchronising it. Logging, focus and other effects that do not push into Craft primitives remain valid. The rule protects synchronization, not all side effects. ## See also * [Reacting to mutations](/guide/state/react-on-mutation) * [From event to source](/guide/reactivity/from-event-to-source) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/interactive-element-names.md --- # Named interactive elements `assertInteractiveElementNamed` requires a unique literal Craft name on every interactive element: ```ts export function keepInteractiveControlsAddressable(graph: ArchitectureGraph) { assertInteractiveElementNamed(graph.graph); } ``` ## What it prevents This control has no stable graph or test identity: ```typescript button({ *click() { yield* increment(); } }, '+'); ``` This one is named, but two components using the same name make the app-wide `data-craft-name` lookup ambiguous: ```typescript button('save', { *click() { yield* save(); } }, 'Save'); ``` The ambiguity matters to type-level template tests, browser tests and the live page tooling used by coding agents. A selector based on “the second Save button” is not a stable contract. ## What is checked The rule covers `button`, links, form controls and nodes with `click`, `input`, `change` or `submit`. Hidden inputs are excluded. The first argument must be a literal string and the resulting name must be unique in the application. ```typescript button('save-profile', { type: 'button', *click() { yield* save(); } }, 'Save'); ``` Use a feature-qualified name when the control is likely to recur. ESLint catches local omissions; the architecture rule catches duplicates across components. ## See also * [Components](/guide/components/) * [Live page MCP](/guide/ai/dev-page) * [Testing components](/guide/testing/components) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/metric-thresholds.md --- # Metric thresholds `assertMetricThresholds` is an optional architecture rule inspired by the complexity checks in Sonar. It lets a team agree on limits for the graph nodes it owns, then keep those limits visible in the architecture suite. It is not part of `assertDeclarativeArchitecture`: no threshold is imposed by default. ```ts export function keepOwnedNodesWithinAgreedLimits(graph: ArchitectureGraph) { assertMetricThresholds(graph.graph, { kinds: ['service', 'component', 'primitive'], max: { cyclomaticOwn: 12, cyclomaticTotal: 30, lines: 160, fanOut: 12, }, allow: ['src/legacy/**'], }); } ``` ## Before: complexity grows in one node This code is valid TypeScript, but its decisions accumulate in one unit: ```typescript function prepareCheckout(cart: Cart, user: User) { if (!user.active) return rejected('inactive user'); if (cart.items.length === 0) return rejected('empty cart'); for (const item of cart.items) { if (item.discountable && (user.vip || item.onSale)) { item.discount = calculateDiscount(item); } } try { return persistCheckout(cart); } catch (error) { return rejected(error); } } ``` The graph counts seven decision points here (`if` × 3, `for`, `&&`, `||`, plus `catch`), so the cyclomatic value is eight: one plus the decision points. As this logic grows, its tests and its callers have to understand the same branching unit. ## After: split the responsibilities, then protect the limit The branching can be given clearer seams. Each module can then be tested through a smaller interface, while the architecture suite keeps the nodes from growing back silently: ```typescript function prepareCheckout(cart: Cart, user: User) { const rejection = checkoutRejection(cart, user); if (rejection) return rejection; applyDiscounts(cart, user); return persistCheckout(cart); } function checkoutRejection(cart: Cart, user: User) { if (!user.active) return rejected('inactive user'); if (cart.items.length === 0) return rejected('empty cart'); return undefined; } function applyDiscounts(cart: Cart, user: User) { for (const item of cart.items) { if (item.discountable && (user.vip || item.onSale)) { item.discount = calculateDiscount(item); } } } ``` The exact limit is a team decision. A common starting point is to check local complexity (`cyclomaticOwn`) and keep a higher ceiling for the complete owned subtree (`cyclomaticTotal`): ```typescript assertMetricThresholds(graph.graph, { kinds: ['service', 'primitive'], max: { cyclomaticOwn: 12, cyclomaticTotal: 30, lines: 160, fanOut: 12, }, }); ``` ## What the rule checks * `cyclomaticOwn`: decision points attributed to the node itself. * `cyclomaticTotal`: the node plus everything it contains. * `lines`, `fanIn` and `fanOut`: optional limits for size and coupling. Use `kinds` to scope the rule and `allow` for an id, label or repository- relative path glob such as `src/legacy/**`. Unknown metrics are skipped and reported in `graph.diagnostics`; they are never treated as zero. Start with a generous threshold and lower it when the codebase has a baseline. This rule is a maintainability signal, not a universal quality score: a threshold failure should lead to a focused refactor or an explicit exception. ## See also * [Graph insights](/guide/testing/graph-insights) * [Architecture rules](/guide/testing/architecture) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/server-state-loader.md --- # Queries and mutations must reach server state `assertQueryMutationHasServerState` verifies that `query` and `mutation` loaders reach an approved server-state boundary, such as `CraftHttpClient` or a client-exposed server-function family: ```ts export function keepServerResourcesConnected(graph: ArchitectureGraph) { assertQueryMutationHasServerState(graph.graph); } ``` ## What it prevents This resource is named like server state but only returns local data: ```typescript const users = yield* query('users', { params: () => filter(), loader: () => cachedUsers, }); ``` That can be intentional in a demo, but in a production feature it hides a missing API call or accidentally replaces a remote source with a fixture. The query's loading and cache semantics then give a false impression that the server has been consulted. ## Effect applications choose their boundary An Effect app can use a custom requirement instead of making every loader call `CraftHttpClient`: ```typescript assertPrimitiveLoaderRequirements(graph.graph, { primitives: ['queryEffect', 'mutationEffect'], requirements: [ { label: 'an Effect service', matches: ({ target }) => target.kind === 'service' && target.details?.runtime === 'effect', }, ], }); ``` For local fixtures, use a narrow named `allow` entry and explain why it is not server state. Do not disable the rule for every primitive. ## See also * [Which primitive should I use?](/guide/concepts/choose-primitive) * [Primitive loader requirements](./primitive-loader-requirements) * [Effect integration](/guide/advanced/effect) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/primitive-loader-requirements.md --- # Primitive loader requirements `assertPrimitiveLoaderRequirements` is the configurable form of the server-state rule. It says what a primitive loader must reach, without hard-coding one transport library into the graph: ```ts export function keepEffectLoadersOnAnEffectBoundary( graph: ArchitectureGraph, ) { assertPrimitiveLoaderRequirements(graph.graph, { primitives: ['queryEffect', 'mutationEffect'], requirements: [ { label: 'an Effect service', matches: ({ target }) => target.kind === 'service' && target.details?.['runtime'] === 'effect', }, ], }); } ``` ## What it prevents An Effect-aware query can look perfectly typed while accidentally becoming a local computation: ```typescript const users = yield* queryEffect('users', { params: () => filter(), loader: () => Effect.succeed(localFixture), }); ``` That is a valid Effect program, but it does not prove that the feature reaches a repository, gateway or other server-state boundary. The rule makes the policy explicit and catches the accidental local fallback. ## Requirements are OR-ed A project can accept several boundary styles: ```typescript requirements: [ { label: 'Effect service', matches: isEffectService }, { label: 'domain gateway', matches: isDomainGateway }, { label: 'server function', matches: isServerFunctionFamily }, ] ``` For `queryEffect` and `mutationEffect`, the graph projects the Effect `R` channel onto the matching Effect service nodes. A loader that calls a domain function requiring `UserRepository` therefore satisfies the rule even though the loader itself does not directly yield the service. ## Use `allow` as a documented exception ```typescript assertPrimitiveLoaderRequirements(graph.graph, { primitives: ['queryEffect'], requirements: [{ label: 'Effect service', matches: isEffectService }], allow: ['currentUserQuery'], // app-level DI bridge; intentionally local }); ``` The name should explain the exception. A broad allowlist defeats the point of a loader-boundary rule. ## See also * [Server-state loader rule](./server-state-loader) * [Effect integration](/guide/advanced/effect) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/extensible-architecture-graph.md --- # Extensible architecture graph The Craft architecture graph is extensible by **TypeScript backend**, without making the graph engine import that backend. Use this when an application has server-side concepts that the built-in Craft graph cannot express: services, layers, message brokers, data classifications, external outputs, or another dependency-injection system. The extension has two separate parts: * a type extension, which gives rules typed `kind` details; * a runtime collector, which reads the TypeScript program and contributes nodes, relations, diagnostics, and source proofs. Importing the types never activates a collector. ## 1. Extend the vocabulary Add entries to the node and relation registries with module augmentation. The augmentation must target the graph subpath: ```typescript import type { DependencyGraphCollector, DependencyGraphEdgeRegistry, DependencyGraphNodeRegistry, } from '@craft-ts/dev-tools/dependency-graph'; import { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph'; declare module '@craft-ts/dev-tools/dependency-graph' { interface DependencyGraphNodeRegistry { 'repository-service': { runtime: 'repository'; repositoryName: string; }; } interface DependencyGraphEdgeRegistry { 'requires-repository': { operation: string; }; } } ``` The registry determines the types of the public graph API: ```typescript const repositories = graph.nodes('repository-service'); const name: string = repositories[0]!.details!.repositoryName; const requirements = graph.edges('requires-repository'); const operation: string = requirements[0]!.details!.operation; ``` Keep a new node kind for a concept with its own semantics, renderer, or architecture rules. Put incidental information in the typed `details` object instead of creating a kind for every local symbol. ## 2. Write a collector A collector receives the shared `ts-morph` project and the source files already selected by the graph's tsconfig. It returns a contribution; it does not call architecture rules or mutate a renderer. ```typescript const repositoryCollector: DependencyGraphCollector = { name: 'repository-backend', collect({ rootDir, sourceFiles }) { const nodes = []; const edges = []; for (const sourceFile of sourceFiles) { // Inspect declarations and calls with ts-morph here. // Add a node or relation only when the syntax/type evidence is clear. void rootDir; void sourceFile; } return { nodes, edges }; }, }; ``` Every contributed node needs a stable `id`, a `kind`, and a human-readable `label`. Every relation must point to nodes in the contribution or to nodes already in the graph. A conflicting identity is rejected during the merge. ## 3. Attach source proofs A collector should explain why a fact exists. Add a `proof` to relations when possible: ```typescript edges.push({ from: handlerId, to: repositoryId, kind: 'requires-repository', evidence: 'ast', details: { operation: 'list' }, proof: { filePath: sourceFile.getFilePath(), line: call.getStartLineNumber(), symbol: 'listUsers', pattern: 'repository.list()', }, }); ``` Proofs are available through `graph.proofs(edge)` and are included in paths: ```typescript const paths = graph.pathsBetween(handlerId, repositoryId); for (const path of paths) { console.log(path.nodes.map((node) => node.label)); console.log(path.proofs); } ``` If a dependency cannot be resolved statically, emit a diagnostic or an explicit unknown relation. Do not publish an unresolved dependency as if it were proven. ## 4. Activate the collector explicitly Register the collector in the application's graph loader: ```typescript const graph = analyzeDependencyGraph({ rootDir: workspaceRoot, tsConfigFilePath: 'apps/shop/tsconfig.graph.json', collectors: [repositoryCollector], middlewareCapabilities: { 'shop.audit-sensitive-data': ['personal-data'], }, }); return createArchitectureGraph(graph, architectureCatalog); ``` This keeps runtime analysis separate from declaration merging. A type import cannot accidentally enable an expensive backend analysis or its rules. ## Effect backend The repository currently includes an Effect adapter. It recognizes Effect `Context.Service` declarations and server-function requirements such as: ```typescript export class UserRepository extends Context.Service< UserRepository, UserRepositoryShape >()('demo/UserRepository') {} export const listUsers = serverFunction('demo.users.list', inputSchema, { exposure: 'client', }).handler(({ input }) => Effect.gen(function* () { const repository = yield* UserRepository; return yield* repository.list(input.filter); }), ); ``` The graph exposes `effect-service`, `effect-operation`, and `effect-layer` nodes, plus typed `requires-service`, `provided-by-layer`, and `composes-layer` relations with source proofs. `Layer.succeed`, `Layer.sync`, `Layer.effect`, `Layer.mergeAll`, and `Layer.provide` are followed only when their relevant symbols are statically visible. Dynamic composition is marked partial or unknown. ## Sensitive data and output policies Effect Schema annotations can seed a conservative data-flow graph: ```typescript const Email = Schema.String.pipe( Schema.annotations({ sensitivity: 'personal-data' }), ); ``` When an annotated schema is used as the output of a client-exposed server function, the graph emits a `data-classification` node, an `external-output` node, and an `exposes-data` relation carrying the classification and proof. Classification is retained when propagation is uncertain; the analyser never assumes that an arbitrary transform made data safe. Repositories declare middleware capabilities beside the graph configuration: ```typescript const graph = analyzeDependencyGraph({ tsConfigFilePath: 'apps/shop/tsconfig.graph.json', middlewareCapabilities: { 'shop.audit-sensitive-data': ['personal-data', 'secret'], }, }); ``` Then enforce the policy with: ```typescript assertSensitiveOutputsProtected(graph.graph, { categories: ['personal-data', 'secret'], }); ``` The rule reports the output, classification, expected capability, and source proof when no attached server middleware provides the declared protection. Unknown protection remains blocking unless `allowUnknown: true` is chosen explicitly. ## Rendering and JSON compatibility The JSON graph remains tolerant of kinds a renderer does not know. Generic renderers display the kind and label as a fallback; they do not discard the node. A backend-specific renderer or architecture rule can consume the typed details through declaration merging. The format stays `version: 1` and every addition is an optional field. Nodes carry `metrics` (`cyclomaticOwn`, `cyclomaticTotal`, `lines`, `fanIn`, `fanOut`), computed after every collector has run. A node without `endLine` has no complexity or line count: those fields are absent, not `0`. Fan-in and fan-out count the nodes of your collector too, so a typed relation shows up in the [hotspots](/guide/testing/graph-insights#hotspots). `graphHash` still reads only node ids and relations, so the metrics never move it. Nodes may also carry `doc` (`summary`, `tags`, `rationale`) read from their declaration. The opt-in Markdown collector adds `doc-page` nodes and `documents` relations; both are part of the built-in vocabulary, so a renderer or a rule can rely on them without augmenting the registries. When the vocabulary changes, regenerate the committed architecture catalog and run the architecture suite: ```shell npx craft-graph \ --project apps/shop/tsconfig.graph.json \ --root . \ --out apps/shop/architecture/catalog \ --format json npx nx architecture shop ``` See [Architecture rules](/guide/testing/architecture) for the application loader, catalog generation, and baseline rules. --- --- url: https://craft-ts.github.io/craft/guide/testing/graph-insights.md --- # Graph insights The dependency graph behind the [architecture rules](/guide/testing/architecture) also measures what it models. Complexity, size and coupling are attached to the nodes a CraftTS developer reasons about — a route, a service, a primitive — rather than to files, and they feed a report, metric thresholds and the [graph MCP server](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp). Everything is computed statically and deterministically from the TypeScript program. Nothing is sampled at runtime and nothing is guessed. ## Metrics Each node carries a `metrics` field: | Metric | Meaning | | ----------------- | ------------------------------------------------------------------------------------------ | | `cyclomaticOwn` | `1 +` the decision points of the node itself | | `cyclomaticTotal` | `1 +` the decision points of the node and of everything it `contains` | | `lines` | Lines of the declaration | | `fanIn` | Distinct nodes that depend on it (`loads`, `renders`, `depends-on`, `calls`, `reads`…) | | `fanOut` | Distinct nodes it depends on | Decision points are `if`, `?:`, `case`, `for`, `for…of`, `for…in`, `while`, `do`, `catch`, `&&`, `||` and `??`. Each one is credited to the **innermost** node whose source contains it. A `craftComputed` declared inside a component keeps its own branches; the component counts them only in its total. Nothing is counted twice. `contains` is structure, not coupling: a service owning a primitive does not raise its fan-out. ### Unknown is not zero Some nodes have no source range of their own: an HTTP endpoint aggregates call sites, a synthesised route check shares its alias, an Effect layer comes from a separate collector. Those nodes have `fanIn` and `fanOut` but no complexity and no line count — the fields are absent. The graph adds one `CRAFT_GRAPH_METRICS_UNKNOWN` diagnostic per unmeasured kind, and every consumer below leaves unknown values out instead of treating them as simple. ## God nodes and hotspots {#hotspots} **God nodes** are the nodes most depended upon: highest `fanIn` first. **Hotspots** combine complexity, centrality and change: ```text score = cyclomaticTotal × (1 + fanIn) × (1 + churn) ``` `churn` is the number of commits touching the node's file since a date, read from git. Without it, the score ranks complex central code. Template elements are left out of the report rankings by default: an element's total includes every element nested in it, so a single component's markup would fill the list. ```typescript import { godNodes, graphHotspots } from '@craft-ts/dev-tools/graph-metrics'; graphHotspots(graph, { limit: 5, kinds: ['service', 'component'] }); ``` ## Report {#report} ```shell npx craft graph \ --project apps/shop/tsconfig.graph.json \ --root . \ --out craft-dependency-graph \ --format report \ --feature-glob 'apps/shop/src/features/:feature/**' \ --churn-since '3 months ago' ``` `--format report` writes `craft-dependency-graph.report.md` and `craft-dependency-graph.report.json`; `--format all` writes them next to the JSON graph and the HTML explorer. The report contains: * a summary: nodes and relations per kind, diagnostics per code; * god nodes and hotspots; * dependency cycles and unused primitive methods; * relations between features, when `--feature-glob` names the feature with a `:name` capture; * the violations of the rules `assertArchitecture` enforces, grouped by rule; * coverage per route, when a coverage report is applied with `--coverage`; * documentation per kind, when JSDoc or Markdown pages were collected. Every section is sorted and every id is relative to the root, so two reports of the same code are identical whatever the checkout path. Commit the Markdown file if you want architecture changes to show up in review. The same data is available in code: ```typescript import { formatGraphReportMarkdown, graphReport } from '@craft-ts/dev-tools/graph-report'; import { architectureViolations } from '@craft-ts/dev-tools/architecture-graph'; const report = graphReport(graph, { featureGlob: 'src/features/:feature/**' }); const violations = architectureViolations(graph); // [{ rule, messages }] ``` ## Coverage per node and per route The graph reads test coverage; it does not produce it. Write an Istanbul report with Vitest, then hand it to the graph: ```shell npx vitest run --coverage --coverage.reporter=json npx craft graph --project apps/shop/tsconfig.graph.json --root . \ --format all --coverage coverage/coverage-final.json ``` Each statement is credited to the innermost node whose lines contain it, and the node gets `metrics.coverage = { statements, covered }` for its own statements. A route's coverage sums the nodes of its code slice — everything that can change what it renders — without counting a statement twice. Coverage is never guessed: * a node without a line range, or in a file the report does not mention, has no `coverage` field. `CRAFT_GRAPH_COVERAGE_UNKNOWN` diagnostics count them per kind; * a route lists how many nodes of its slice are unknown next to its percentage, which only describes the measured part. ```typescript import { applyCoverage, routeCoverage } from '@craft-ts/dev-tools/graph-coverage'; const covered = applyCoverage(graph, JSON.parse(readFileSync(reportPath, 'utf8'))); routeCoverage(covered); // [{ label, statements, covered, unknownNodes, … }] ``` ## Documentation Each node carries a `doc` field when its declaration has something to say: ```typescript /** * Loads and caches the signed-in user. * @remarks Shared by every page. */ export const { injectUserService } = craftService(/* … */, function* () { // WHY: the session expires silently, so reload on focus. const user = yield* query(/* … */); }); ``` * `summary` and `tags` come from the JSDoc of the declaration, or of the statement around it (`export const x = query(…)`). * `rationale` collects the `// WHY:`, `// NOTE:` and `// HACK:` comments, each credited to the innermost node that contains it: the comment above `user` belongs to the query, not to the service. Markdown pages join the graph through an opt-in collector, from the CLI with `--docs 'docs/**/*.md'` (repeatable) or in code with `createMarkdownDocsCollector({ include })`. Each page becomes a `doc-page` node labelled by its first heading. A page `documents` a node when it cites the node's label in inline code — `` `UserService` `` — and that label names exactly one route, component, service or primitive. A label shared by several nodes produces a `markdown-docs/CRAFT_GRAPH_DOC_AMBIGUOUS` diagnostic and no relation. Fenced code blocks are ignored. See [Documentation rules](/guide/testing/architecture#documentation-rules) to require them. ## Thresholds Metrics become rules with `assertMetricThresholds`, opt-in and scoped by kind. See [Metric thresholds](/guide/testing/architecture/metric-thresholds) for before-and-after examples. ## Explorer `craft graph --format html` (or `all`) writes a self-contained explorer. On top of the route view it shows: * in the details panel, the node's metrics, coverage, JSDoc, justification comments and the pages that document it — unknown values read "inconnu", never `0`; * a heat map selector (complexity, fan-in, coverage) that colours the node cards, with a striped pattern for unknown values; * "path from" and "path to" buttons that highlight the shortest chain of relations between two nodes, following their direction; * the ten hotspots in the sidebar, and their count next to the uncovered nodes in the header. ## Agents `@craft-ts/graph-mcp` exposes the graph, the metrics, the report and the impact of a change to an AI agent working in your project. See [MCP tools](/guide/ai/mcp-tools#graph-mcp-craft-ts-graph-mcp). --- --- url: https://craft-ts.github.io/craft/guide/testing/craft-graph-vs-nx.md --- # Craft graph vs Nx Nx organises the **workspace**. The Craft graph judges the **shape of one app**. They are complementary — comparing `depConstraints` to `assertHttpEndpointUnique` is comparing a city plan to a wiring diagram. **Use Nx when** the constraint is about projects, TypeScript imports, or what CI should rerun. **Use Craft when** the constraint is about who may yield whom, who owns an HTTP endpoint, or whether a route proof stayed armed. **Not instead of** [architecture rules](/guide/testing/architecture) — this page is the why; that page is the how. ::: tip They already run together The demo suite is an Nx target: `npx nx architecture demo`. Craft does not replace Nx; it adds a graph Nx cannot see. ::: ## Two graphs, two altitudes | | Nx | Craft | | --- | --- | --- | | Node | an app or a lib | a route, a service, a `GET users`, a `craftUnique` identity | | Edge | a TypeScript import (`static` / `dynamic` / `implicit`) | `depends-on`, `provides`, `calls`, `writes`, `checks`, … | | Question | who may import whom, and what should rerun? | who may yield whom, and who owns this endpoint? | | Enforcement | ESLint `@nx/enforce-module-boundaries` | Vitest on the graph (`assert*`, `noExclusiveLink`) | | Visualisation | `nx graph` — projects and tasks, `--affected` | `npx craft-graph --format html` — a route expanding into services and HTTP | Nx orchestrates. Craft asserts. ## What Nx cannot see Nx's node is a project. Everything inside `apps/shop/src` is opaque: routes, providers, `yield*`, HTTP, storage. That is not a bug — the project graph is built to be cheap enough to drive CI. The holes appear as soon as you want to judge an **app**, not a workspace. | Nx sees | Nx misses | Craft counterpart | | --- | --- | --- | | `demo` imports `@craft-ts/core` | `yield* CheckoutApi` in the same app | `depends-on` | | Tags on a lib (`scope:admin`) | Two features that share one app | `assertPathBoundaries`, `noExclusiveLink` | | A circular import between libs | A service cycle `A → B → A` inside one tsconfig | `assertNoDependencyCycles` | | That `HttpClient` was imported | That `GET users` is called from two APIs | `assertHttpEndpointUnique` | | Nothing about storage keys | Two queries sharing a `craftUnique` identity | `assertCraftUnique` | | Nothing about unused type aliases | A commented-out `CanRun` that still compiles | `assertRouteDiProofs` | | Nothing about named buttons | Two interactive helpers sharing `data-craft-name` | `assertInteractiveElementNamed` | Three consequences follow. **An edge is an import, not DI.** `yield* CheckoutApi` does not cross a project boundary. Module-boundary ESLint never fires. The Craft graph records it as `depends-on`. **Isolation costs a library.** For `depConstraints` to protect a feature, that feature must be its own project — barrel, tags, often a build. Craft states the same intention on **folders** and on yield, without extracting forty libs. **ESLint judges a file.** `@nx/enforce-module-boundaries` sees one import. `assertHttpEndpointUnique` and `assertRouteDiProofs` see the whole graph, including lazy `loadChildren` collections a parent proof never covers. Nx Enterprise Conformance can add workspace rules on the project graph and the file tree. Recreating Craft's AST analysis (DI, yield, HTTP) there would be rewriting `@craft-ts/dev-tools`. ## What Craft cannot see The Craft graph is one TypeScript program (`analyzeDependencyGraph` takes one tsconfig). It does not become a build system. | Craft sees | Craft misses | Nx counterpart | | --- | --- | --- | | Who yields whom inside an app | Which projects CI should rerun | project graph + `nx affected` | | Folder lanes on `depends-on` | A deep import that bypasses a lib's `index.ts` | `enforce-module-boundaries` | | A duplicate `GET users` | Nest imported from a frontend project | `bannedExternalImports` | | A service cycle in `apps/shop` | `orders` ↔ `customers` as libs | circular project dependencies | | Craft TypeScript | Python, Nest, assets, configs | polyglot project graph, implicit deps | | A failing Vitest assertion | A generator that rewrites the file | Conformance fix generators | | Seconds of ts-morph analysis | Millisecond cache hits on unchanged libs | local / remote computation cache | The Craft graph **asserts**. The Nx graph **executes**: what to build, test, cache, parallelise. Without Nx (or an equivalent), a green architecture suite does not scale in CI. `assertPathBoundaries` protects a modular monolith. It does not split the **task** graph. Changing one feature folder still invalidates the whole app architecture target — analysis reloads the tsconfig, in seconds, not milliseconds. Craft is a TypeScript analyser. Outside that dialect the graph is empty. Nx tags Nest, React, Python, assets and config files. ## The overlap Same intention, different edge. | Intention | Nx | Craft | Trap | | --- | --- | --- | --- | | This layer must not talk to that one | `sourceTag: type:ui` → `onlyDependOnLibsWithTags: type:util` | `assertPathBoundaries` — `src/app/ui/**` must not `depends-on` `src/app/data/**` | Nx requires libs. Craft runs inside one tsconfig, on yield, not on the import. | | No cycles | `orders` → `customers` → `orders` (project imports) | `assertNoDependencyCycles` on services / `craftComputed` | A service cycle inside `apps/shop` is green for Nx and red for Craft. | | See the graph | `nx graph --affected` (projects or tasks) | `craft-graph --format html` centred on a route | One shows who rebuilds. The other shows who injects. | The `assertPathBoundaries` helper is the closest cousin of `depConstraints`. Nx tags **projects** and forbids TypeScript imports. Craft tags **folders** on the Craft graph and forbids `depends-on` (optionally `calls`) — including inside one app, where module-boundary ESLint does not run. Setup and examples: [Architecture rules](/guide/testing/architecture#assertpathboundaries). ## How they complement Do not recode `depConstraints` as Vitest, and do not recode Craft's AST analysis as an Nx Conformance rule. Each tool stays on its graph. | Keep Nx for | Keep Craft for | | --- | --- | | Monorepo layout, tags between libs, public API barrels | HTTP ownership, `craftUnique` identities | | `nx affected`, local / remote cache, task graph | Route DI proofs staying armed | | Banning an npm package by tag | Pure `craftComputed`, service-level cycles | | Generators, plugins, polyglot projects | `noExclusiveLink`, browser-boundary HTTP | ::: warning Folder lanes are not affected CI Extracting Nx libraries gives you `nx affected`. It still does not prove a single service owns `GET users`. `assertPathBoundaries` keeps features apart inside one app. It still does not slice the task graph. Both layers stay necessary. ::: The working reference is the demo app: an Nx `architecture` target that runs Vitest on the Craft graph. Copy that layout from [Architecture rules](/guide/testing/architecture#setting-it-up). ## See Also * [Architecture rules](/guide/testing/architecture) — helpers, catalog, Nx target * [ESLint rules](/guide/routing/eslint-rules) — local slips the graph cannot autofix * [Learn: test what you wrote](/learn/10-testing) --- --- url: https://craft-ts.github.io/craft/guide/reactivity/craft-computed.md --- # craftComputed A yieldable reactive value that can read Craft dependencies and other reactive Craft values with `yield*`. **Use it when** a derived value reads a Craft reader, a service, or another computed — so those dependencies are recorded on the computed itself. Use `craftComputed` in application code so every reactive dependency can be traced consistently. ## Import ```typescript import { craftComputed } from '@craft-ts/core'; ``` ## Overview `craftComputed` exposes a yieldable reader. Application code uses the generator form so every reactive dependency is recorded on **that** computed: ```typescript const counter = yield* state('counter', 1, ({ state }) => ({ doubled: craftComputed(function* () { return (yield* state()) * 2; }), })); const doubled = yield* counter.doubled(); ``` Two modes exist: * generator factory: `craftComputed(name, function* () { ...; return value; })` — the default for Craft values. The generator is replayed on every recomputation. * plain computation: `craftComputed(name, () => value)` — for a computation that only reads values already held by the surrounding scope. Inside an insertion the name may be omitted: Craft uses the insertion key. ## Signatures ```typescript function craftComputed( name: Name, computation: () => T, options?: CreateComputedOptions, ): YieldableReactiveValue; function craftComputed( name: Name, factory: () => Generator, options?: CreateComputedOptions, ): YieldableReactiveValue; ``` The first argument is the **name** outside an insertion and must match the property (or variable) the computed is assigned to. Inside an insertion it may be omitted: Craft uses the insertion key automatically. The name tags the injector context, reactive graph and dev-tools snapshots. The [`craft-ts/craft-computed-name-match`](/guide/routing/setup) ESLint rule enforces the match and offers a quick fix. ## Generator Computation Use this form whenever the computed reads a Craft reader, a service, or another computed. ```typescript const counter = yield* state('counter', 1, ({ state }) => ({ doubled: craftComputed(function* () { return (yield* state()) * 2; }), })); const doubled = yield* counter.doubled(); ``` `doubled` does not own `state()`, so it yields it. That is how the computed's own dependency graph records the read. ```typescript import { craftComputed, craftService } from '@craft-ts/core'; const { Multiplier } = craftService( { name: 'Multiplier', providedIn: 'function' }, () => ({ factor: 3 }), ); const tripled = craftComputed('tripled', function* () { const multiplier = yield* Multiplier(); return (yield* counter()) * multiplier.factor; }); ``` ## Caveats * `craftComputed(...)` must be created inside an injection context. * Unknown yielded values are rejected with a `craftComputed`-specific error. * `onAppStart(...)` is not supported inside `craftComputed(...)`. ## Typing Both forms return `YieldableReactiveValue`. The underlying reactive value stays internal to Craft. When using a generator, yielded dependencies are tracked and can be extracted with `ExtractDeps<...>`. ## See Also * [`craftMethod`](/guide/reactivity/craft-method) * [`craftEffect`](/guide/reactivity/craft-effect) * [`craftService`](/guide/app/craft-service) * [`onAppStart`](/guide/app/app-start) * [Architecture rules](/guide/testing/architecture) — `assertCraftComputedPure` forbids methods and `source$` writes inside a computed --- --- url: https://craft-ts.github.io/craft/guide/reactivity/craft-effect.md --- # craftEffect An `effect` that can resolve craft dependencies with `yield*`. **Use it when** a side effect needs a service. **Not as a way to sync state** — if a value is a function of another, derive it with `computed` instead of writing it from an effect. ## Import ```typescript import { craftEffect } from '@craft-ts/core'; ``` ```typescript craftEffect('myEffect', function* () { const counter = yield* Counter(); // do some stuff }); ``` ## Resource triggers In generator code, primitive triggers are yieldable and must be consumed with `yield*`: ```typescript function* submit(term: string) { yield* searchQuery.call(term); yield* saveMutation.mutate({ term }); yield* validateProcess.method(term); } ``` Imperative triggers from ordinary UI callbacks remain valid: ```typescript button({ click: () => saveMutation.mutate({ term: input() }) }, 'Save'); ``` Do not use those triggers as dependencies of a `craftEffect`. Prefer a declarative `params` signal, a `source$`, or a mutation/query insertion. The `craft-ts/no-imperative-craft-resource-trigger` rule also follows a `craftGen`, so wrapping the call does not bypass the restriction: ```typescript const triggerSearch = craftGen(function* (term: string) { yield* searchQuery.call(term); }); craftEffect('load', function* () { yield* triggerSearch(input()); // forbidden: indirect imperative trigger }); ``` Use a reactive query instead when the data depends on a signal: ```typescript const user = yield* query('user', { params: userId, loader: ({ params }) => fetchUser(params), }); ``` ## See Also * [craftComputed](/guide/reactivity/craft-computed) * [craftMethod](/guide/reactivity/craft-method) * [Local state](/guide/state/local-state) — deriving instead of writing from an effect * [Architecture rules](/guide/testing/architecture) — `assertCraftEffectNoNetwork` when an effect calls HTTP or a mutation, `assertCraftEffectNoImperativeSync` when it writes a `state`/`source$` or triggers a query/mutation --- --- url: https://craft-ts.github.io/craft/guide/reactivity/craft-method.md --- # craftMethod Wraps a generator so it can be called like an ordinary method — from a template, an event handler, anywhere outside the craft driver — while still resolving its dependencies with `yield*`. **Use it when** a click handler or a component method needs a service. **Not inside a craft factory** — there, `yield*` works directly. ## Import ```typescript import { craftMethod } from '@craft-ts/core'; ``` ## Overview `craftMethod` is designed for component methods such as click handlers and submit handlers. Keep the callback focused: event normalisation, pure input preparation, and at most one imperative Craft action belong here. When one event must coordinate several primitives, emit a `source$` directly and let the affected query react with `insertReactOnMutation(...)` or another declarative insertion. The returned method carries the yieldable-method contract. When it is consumed from a Craft component template, its template view can delegate it with `yield*`; the component renderer drives the callback with the Craft generator runtime while preserving the method's injector and wrappers. The method runs inside the injection context captured when `craftMethod(...)` is created. That makes it useful when a component method needs to: * call Browser Boundaries with `yield*` * compose crafted services through `yield* SomeService()` * keep the handler colocated with component-local signals **All dependencies are cached, which helps to detect missing providers at compile time.** ## Signatures ```typescript function craftMethod( name: Name, factory: (this: This, ...args: Args) => Generator, ): (this: This, ...args: Args) => Result; function craftMethod( name: Name, self: This, factory: (this: This, ...args: Args) => Generator, ): (...args: Args) => Result; ``` The first argument is the **name**: it is required and must match the property (or variable) the method is assigned to. It is the value used to tag the injector context — same role as `provideHostName(...)`. The [`craft-ts/craft-method-name-match`](/guide/routing/eslint-rules) ESLint rule enforces the match and offers a quick fix. ## The common case — inside a Craft component In a Craft component's logic factory there is no `this`: declare the method with `craftMethod(name, fn)` and return it in the context. ```typescript import { button, craftComponent, div, p } from '@craft-ts/component'; import { Console, craftMethod, state } from '@craft-ts/core'; export const Counter = craftComponent( 'Counter', {}, function* () { const counter = yield* state('counter', 0, ({ update }) => ({ update })); const increment = craftMethod('increment', function* (step = 1) { yield* Console.log('increment is called'); yield* counter.update((value) => value + step); }); return { counter, increment }; }, ({ counter, increment }) => [ p(counter), button({ click: increment }, 'Increment'), ], ); ``` `counter` does not belong to `increment`, so the method yields `counter.update`. Pass the method to the template (`click: increment`) rather than wrapping `() => increment()`. ## Composing crafted services `craftMethod` is not limited to Browser Boundaries — it consumes the same crafted service graph as `craftService`: ```typescript const increment = craftMethod('increment', function* (value: number) { return yield* CounterWorker.set(value); }); ``` ::: details Class-based wrappers — capturing `this` When a class-based wrapper needs its instance, use one of the two `this`-aware overloads. ### Recommended form — capture `this` Use `craftMethod(name, this, fn)` when the generator needs component state. ```typescript import { Console, craftMethod, craftSignal } from '@craft-ts/core'; export class Counter { readonly counter = craftSignal(0); readonly increment = craftMethod('increment', this, function* (step = 1) { yield* Console.log('increment is called'); this.counter.update((value) => value + step); }); } ``` This overload captures the instance once, so the callback still works after extraction: ```typescript const increment = component.increment; increment(); ``` ### Receiver-based form Use `craftMethod(name, fn)` when you want the method to resolve `this` from its receiver, and are fine with the receiver-dependent behavior. In strict TypeScript, annotate `this` explicitly inside the generator: ```typescript import { Console, craftMethod, craftSignal } from '@craft-ts/core'; export class Counter { readonly counter = craftSignal(0); readonly increment = craftMethod( 'increment', function* (this: Counter, step = 1) { yield* Console.log('increment is called'); this.counter.update((value) => value + step); return this.counter(); }, ); } ``` ### Composing services from a class ```typescript export class Counter { readonly increment = craftMethod( 'increment', this, function* (value: number) { return yield* CounterWorker.set(value); }, ); } ``` ::: ## Caveats * `craftMethod(...)` must be created inside an injection context, typically during component instantiation. * The first argument is a required name; it must match the property or variable name. The `craft-ts/craft-method-name-match` ESLint rule enforces this and provides a quick fix. * `craftMethod(name, fn)` depends on the receiver used at call time. If you extract the callback, `this` is no longer guaranteed unless you bind it yourself. * `craftMethod(name, this, fn)` is the recommended form whenever the generator reads or writes `this`. * `onAppStart(...)` is not supported inside `craftMethod`. ## See Also * [`Browser Boundaries`](/guide/testing/browser-boundaries) * [`craftService`](/guide/app/craft-service) * [`onAppStart`](/guide/app/app-start) --- --- url: https://craft-ts.github.io/craft/guide/reactivity/source.md --- # source$ An event source: something you `emit()` to, that others react to — with automatic cleanup and signal-based value tracking. **Use it when** several independent pieces of state must react to one event: a "reset everything" action, a refresh trigger, a cross-cutting notification. **Not for** a direct call from A to B — that is just a method. ## Overview `source$` provides a lightweight event streaming solution that combines: * Event emission and subscription capabilities * Automatic subscription cleanup via `DestroyRef` * Signal-based value tracking for reactive access * Optional last value preservation for late subscribers * Read-only variants for encapsulation ## Import ```typescript import { craftComputed, source$ } from '@craft-ts/core'; ``` ## Signature ```typescript function source$(name: string): Source$; ``` ### Parameters * **`name: string`** - Name matching the variable/property this source is assigned to. Used for host tagging and dev-tools snapshot reporting, consistent with `craftComputed`/`craftEffect`. The [`craft-ts/craft-source-name-match`](/guide/routing/eslint-rules) ESLint rule enforces the match and offers a quick fix. ### Returns `Source$` is directly usable as a source and can also be consumed with `yield*`: ```typescript // Direct source API const source = source$('reset$'); source.emit(); // Yieldable primitive API const reset$ = yield* source$('reset$'); ``` The yielded value is the source instance. Its source API is unchanged: * **`emit(value: T)`** - Emits a value to all subscribers and updates the internal signal * **`subscribe(callback: (value: T) => void)`** - Subscribes to emissions with a callback * **`value: Signal`** - A read-only signal containing the last emitted value (or `undefined` if no value has been emitted) * **`asReadonly()`** - Returns a read-only version of the source (only `subscribe` and `value`) * **`preserveLastValue()`** - Returns a source variant that immediately emits the last value to new subscribers ## Types ### Source$ ```typescript type Source$ = SourceInstance & NamedCraftPrimitiveGen>; type SourceInstance = { emit: (value: T) => void; subscribe: (callback: (value: T) => void) => Subscription; value: Signal; asReadonly: () => ReadonlySource$; preserveLastValue: () => { emit: (value: T) => void; subscribe: (callback: (value: T) => void) => void; value: Signal; asReadonly: () => { subscribe: (callback: (value: T) => void) => void; value: Signal; }; }; }; ``` The generator side yields `{ [name]: SourceInstance }`. The source side keeps the existing `emit`, `subscribe`, `value`, `asReadonly` and `preserveLastValue` API. ### ReadonlySource$ ```typescript type ReadonlySource$ = { subscribe: (callback: (value: T) => void) => Subscription; value: Signal; }; ``` ## Key Features ### Source services and dependency tracking Use a `craftService` as the dependency handle when a source is shared by multiple consumers: ```ts import { craftService, on$, source$, state } from '@craft-ts/core'; const { Reset } = craftService( { name: 'Reset', providedIn: 'global' }, function* () { const reset$ = yield* source$('reset$'); return reset$; }, ); const { Counter } = craftService( { name: 'Counter', providedIn: 'global' }, function* () { const counter = yield* state('counter', 0, ({ set }) => ({ reset: on$(Reset, () => set(0)), })); const reset = yield* Reset(); return { counter, reset }; }, ); ``` `on$(Reset, ...)` records `Reset` as a dependency of the primitive. Calling `reset.emit()` only publishes an event; it does not create or modify the dependency graph. ### Automatic Cleanup Subscriptions are automatically cleaned up when the injection context is destroyed, preventing memory leaks: ```typescript const userAction$ = source$('userAction$'); // Subscription is automatically unsubscribed on component destruction userAction$.subscribe((action) => console.log(action)); ``` ### Signal Integration The `value` property provides reactive access to the last emitted value: ```typescript const message$ = source$('message$'); message$.emit('Hello'); console.log(message$.value()); // 'Hello' // Use in templates or craftComputed const uppercased = craftComputed('uppercased', function* () { return (yield* message$.value())?.toUpperCase(); }); ``` ### Last Value Preservation Use `preserveLastValue()` to ensure late subscribers receive the most recent value: ```typescript const counter$ = source$('counter$'); counter$.emit(42); // Standard source: late subscriber receives nothing counter$.subscribe((v) => console.log('Standard:', v)); // Only future values // With preserveLastValue: late subscriber gets the last value immediately const preserved$ = counter$.preserveLastValue(); preserved$.subscribe((v) => console.log('Preserved:', v)); // Logs: Preserved: 42 ``` ## Common Patterns ### Event Broadcasting ```typescript const buttonClick$ = source$('buttonClick$'); // Multiple subscribers buttonClick$.subscribe((event) => console.log('Logger:', event)); buttonClick$.subscribe((event) => trackEvent('button_click')); // Emit events button.addEventListener('click', (e) => buttonClick$.emit(e)); ``` ### Read-Only Access ```typescript class DataService { private dataUpdated$ = source$('dataUpdated$'); // Expose read-only version readonly dataUpdated = this.dataUpdated$.asReadonly(); updateData(data: Data) { this.dataUpdated$.emit(data); } } ``` ### Coordination with State ```typescript const resetTrigger$ = source$('resetTrigger$'); const { counter } = state('counter', 0, ({ set, update }) => ({ increment: () => update((v) => v + 1), decrement: () => update((v) => v - 1), // Reset when source emits reset: on$(resetTrigger$, () => set(0)), })); ``` ## Examples ### Basic Usage with on$ ```typescript import { button, craftComponent, p } from '@craft-ts/component'; import { on$, source$, state } from '@craft-ts/core'; export const Counter = craftComponent( 'Counter', {}, function* () { // a source for reset events const reset$ = source$('reset$'); const counter = yield* state('counter', 0, ({ set, update }) => ({ increment: () => update((v) => v + 1), decrement: () => update((v) => v - 1), // internal: listens to reset$ and sets counter to 0. // NOT exposed on the ref, because it is bound with on$ reset: on$(reset$, () => set(0)), })); return { counter, reset$ }; }, ({ counter, reset$ }) => [ p(function* () { return `Count: ${yield* counter()}`; }), button({ click: counter.increment }, '+1'), button({ click: counter.decrement }, '-1'), button({ click: () => reset$.emit() }, 'Reset'), ], ); ``` ### Multi-Source Coordination ```typescript import { source$, state, on$ } from '@craft-ts/core'; // Multiple sources for different events const userLogin$ = source$('userLogin$'); const userLogout$ = source$('userLogout$'); const { authState } = state('authState', null, ({ set }) => ({ // Respond to multiple sources onLogin: on$(userLogin$, (user) => set(user)), onLogout: on$(userLogout$, () => set(null)), })); // Trigger events userLogin$.emit({ id: 1, name: 'Alice' }); console.log(authState()); // { id: 1, name: 'Alice' } userLogout$.emit(); console.log(authState()); // null ``` ### Late Subscriber Pattern ```typescript import { source$ } from '@craft-ts/core'; const notifications$ = source$('notifications$').preserveLastValue(); // Emit before any subscribers notifications$.emit('Server started'); notifications$.emit('Database connected'); // Late subscriber receives the last value immediately setTimeout(() => { notifications$.subscribe((msg) => { console.log('Late subscriber:', msg); // Logs: Late subscriber: Database connected }); }, 1000); ``` ## Related * [on$](/guide/reactivity/on) - Subscribe to sources with automatic cleanup in state insertions ## See Also * [on$](/guide/reactivity/on) — reacting to a source * [fromEventToSource$](/guide/reactivity/from-event-to-source) * [sourceFromEvent](/guide/reactivity/source-from-event) --- --- url: https://craft-ts.github.io/craft/guide/reactivity/on.md --- # on$ Binds a callback to a [`source$`](/guide/reactivity/source), with automatic cleanup. **Use it inside an insertion** to let a primitive react to an event. ::: warning A method bound with `on$` is not exposed It works internally, driven by the source, and does not appear on the primitive's ref. That is intentional: the source is the trigger, not the caller. ::: ## Overview `on$` enables reactive side effects by: * Listening to source emissions and executing callbacks * Automatically unsubscribing when the injection context is destroyed * Working with `source$`, `EventEmitter`, and any Observable * Accepting a source-returning `craftService` helper directly * Returning `SourceBranded` to prevent method exposure in state insertions * Providing a clean way to coordinate state updates with source events ## Signature ```typescript function on$( source: { subscribe: EventEmitter['subscribe']; }, callback: (source: SourceType) => State, ): SourceBranded; function on$( source: SourceService, callback: (source: SourceServiceOutput) => State, ): SourceBranded; ``` ### Parameters * **`_source`** - A source, EventEmitter, or Observable to listen to * **`callback`** - Function executed when the source emits. Receives the emitted value and can perform side effects ### Returns `SourceBranded` - A branded symbol indicating the method is not exposed on the state/store When `source` is a Craft service helper returning a `Source$`, `on$` resolves the helper in the current injection context and tracks that helper as a dependency of the containing primitive. ```typescript const { Reset } = craftService( { name: 'Reset', providedIn: 'global' }, function* () { const reset$ = yield* source$('reset$'); return reset$; }, ); const counter = yield* state('counter', 0, ({ set }) => ({ reset: on$(Reset, () => set(0)), })); ``` `on$(Reset, ...)` is the dependency edge. `yield* Reset()` can then be used to expose the same source for producing events with `reset.emit()`. ## Primary Use Case Create internal reactive methods in state insertions that respond to sources without being exposed: ```typescript const { myState } = state('myState', 0, ({ set }) => ({ // Exposed method increment: () => set((v) => v + 1), // Internal reactive method (not exposed) reset: on$(resetSource$, () => set(0)), })); myState.increment(); // ✅ Available as a yieldable method (`yield*` / pass the reference) myState.reset(); // ❌ Not available (TypeScript error) ``` ## Automatic Cleanup `on$` automatically unsubscribes from the source when the injection context is destroyed, preventing memory leaks. ## Common Patterns * **State reset**: `on$(resetSource, () => set(initialValue))` - reset state on source emission * **State synchronization**: `on$(source, (value) => set(value))` - sync state with source * **Multi-state coordination**: Multiple states can use `on$` with the same source * **Conditional updates**: `on$(source, (value) => { if(condition) set(value) })` - conditional state changes ## Examples ### Basic state reset on source emission ```typescript import { state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; const resetSource = source$('resetSource'); const { counter } = state('counter', 0, ({ set, update }) => ({ // Exposed methods increment: () => update((v) => v + 1), decrement: () => update((v) => v - 1), // Internal: resets when resetSource emits reset: on$(resetSource, () => set(0)), })); console.log(yield* counter()); // 0 yield* counter.increment(); console.log(yield* counter()); // 1 resetSource.emit(); // Triggers reset console.log(yield* counter()); // 0 // counter.reset() ❌ TypeScript error - not exposed ``` ### Syncing state with a source ```typescript import { state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; interface User { id: string; name: string; } const userUpdateSource = source$('userUpdateSource'); const { currentUser } = state( 'currentUser', null as User | null, ({ set }) => ({ // Exposed method clear: () => set(null), // Internal: updates when source emits syncFromSource: on$(userUpdateSource, (user) => set(user)), }), ); console.log(currentUser()); // null userUpdateSource.emit({ id: '1', name: 'Alice' }); console.log(currentUser()); // { id: '1', name: 'Alice' } userUpdateSource.emit({ id: '2', name: 'Bob' }); console.log(currentUser()); // { id: '2', name: 'Bob' } ``` ### Coordinating multiple states with a single source ```typescript import { state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; const resetAllSource = source$('resetAllSource'); const { search } = state('search', '', ({ set, update }) => ({ set, clear: () => set(''), // Reset when resetAllSource emits resetOnSignal: on$(resetAllSource, () => set('')), })); const { page } = state('page', 1, ({ set, update }) => ({ next: () => update((v) => v + 1), previous: () => update((v) => Math.max(1, v - 1)), // Reset when resetAllSource emits resetOnSignal: on$(resetAllSource, () => set(1)), })); const { filters } = state('filters', [] as string[], ({ set }) => ({ add: (filter: string) => set((current) => [...current, filter]), // Reset when resetAllSource emits resetOnSignal: on$(resetAllSource, () => set([])), })); // Set some values search.set('craft'); page.next(); page.next(); filters.add('tutorial'); filters.add('advanced'); console.log(search()); // 'craft' console.log(page()); // 3 console.log(filters()); // ['tutorial', 'advanced'] // Reset all states at once resetAllSource.emit(); console.log(search()); // '' console.log(page()); // 1 console.log(filters()); // [] ``` ### Using on$ in a craft service ```typescript import { craftService, state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; const { Filters } = craftService({ name: 'Filters', providedIn: 'global' }, () => { const reset = source$('reset'); const { search } = state('search', '', ({ set }) => ({ set, // Internal: reset on source emission handleReset: on$(reset, () => set('')), })); const category = state('all', ({ set }) => ({ set, // Internal: reset on source emission handleReset: on$(reset, () => set('all')), })); return { search, category, resetFilters: () => reset.emit(), }; }); const filters = Filters(); filters.search.set('craft'); filters.category.set('frameworks'); console.log(filters.search()); // 'craft' console.log(filters.category()); // 'frameworks' // Reset all filters filters.resetFilters(); console.log(filters.search()); // '' console.log(filters.category()); // 'all' ``` ### Conditional state updates ```typescript import { state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; interface DataUpdate { value: number; force?: boolean; } const dataSource = source$('dataSource'); const { data } = state('data', 0, ({ state, set }) => ({ // Exposed methods setValue: (value: number) => set(value), // Internal: conditionally update based on source data handleUpdate: on$(dataSource, (update) => { // Only update if value is higher or force flag is set if (update.force || update.value > state()) { set(update.value); } }), })); console.log(data()); // 0 dataSource.emit({ value: 10 }); console.log(data()); // 10 dataSource.emit({ value: 5 }); console.log(data()); // 10 (not updated, 5 < 10) dataSource.emit({ value: 3, force: true }); console.log(data()); // 3 (updated due to force flag) ``` ### Working with complex transformations ```typescript import { state, source$ } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; interface ApiResponse { data: { items: Array<{ id: string; value: number }>; }; metadata: { total: number; }; } const apiResponseSource = source$('apiResponseSource'); const { items } = state( 'items', [] as Array<{ id: string; value: number }>, ({ set }) => ({ add: (item: { id: string; value: number }) => set((current) => [...current, item]), // Internal: extract and set items from API response handleApiResponse: on$(apiResponseSource, (response) => { set(response.data.items); }), }), ); const { totalCount } = state('totalCount', 0, ({ set }) => ({ // Internal: extract and set total from API response handleApiResponse: on$(apiResponseSource, (response) => { set(response.metadata.total); }), })); apiResponseSource.emit({ data: { items: [ { id: '1', value: 100 }, { id: '2', value: 200 }, ], }, metadata: { total: 2, }, }); console.log(items()); // [{ id: '1', value: 100 }, { id: '2', value: 200 }] console.log(totalCount()); // 2 ``` ### Using with EventEmitter ```typescript import { EventEmitter } from '@craft-ts/core'; import { state } from '@craft-ts/core'; import { on$ } from '@craft-ts/core'; const clickEmitter = new EventEmitter<{ x: number; y: number }>(); const { lastClick } = state( 'lastClick', null as { x: number; y: number } | null, ({ set }) => ({ clear: () => set(null), // Internal: update on emitter events handleClick: on$(clickEmitter, (position) => set(position)), }), ); clickEmitter.emit({ x: 100, y: 200 }); console.log(lastClick()); // { x: 100, y: 200 } clickEmitter.emit({ x: 150, y: 250 }); console.log(lastClick()); // { x: 150, y: 250 } ``` ## Best Practices ✅ **Use for internal state coordination** - Perfect for state updates that shouldn't be exposed as methods ✅ **Coordinate multiple states** - Use the same source with multiple `on$` calls across different states ✅ **Keep callbacks simple** - Focus on state updates, avoid heavy computation ✅ **Leverage automatic cleanup** - No need to manually unsubscribe ✅ **Prefer for side effects** - Use `on$` for actions, `afterRecomputation` for transformations ❌ **Don't expose complex logic** - Keep the callback focused on state changes ❌ **Don't use for query/mutation params** - Use `afterRecomputation` instead ❌ **Don't chain multiple on$ calls** - Keep it simple and flat ## Related * [`source$`](/guide/reactivity/source) - Create reactive sources * [`afterRecomputation`](/guide/reactivity/after-recomputation) - Transform sources for method parameters * [`state`](/guide/state/local-state) - Create reactive state with insertions * [`craftService`](/guide/app/craft-service) - Organize coordinated states inside reusable services ## See Also * [source$](/guide/reactivity/source) — what `on$` listens to * [fromEventToSource$](/guide/reactivity/from-event-to-source) * [Local state](/guide/state/local-state) — event-driven methods --- --- url: https://craft-ts.github.io/craft/guide/reactivity/from-event-to-source.md --- # fromEventToSource$ Turns a DOM event into a readonly [`source$`](/guide/reactivity/source), with automatic cleanup. **Use it when** a primitive should react to something happening on the page: a scroll, a key, a window resize. ## Overview `fromEventToSource$` bridges DOM events with craft-ts's reactive system by combining: * Event conversion to `ReadonlySource$` emissions * Automatic event listener cleanup via `DestroyRef` * Optional event payload transformation * Signal-based reactive access to the last emitted value * Manual disposal capability for dynamic use cases ## Import ```typescript import { fromEventToSource$ } from '@craft-ts/core'; ``` The component examples below also use the hyperscript helpers: ```typescript import { button, craftComponent, div, forNode, form, input, p } from '@craft-ts/component'; ``` ## Signature ```typescript function fromEventToSource$( target: EventTarget, eventName: string, options?: { event?: boolean | AddEventListenerOptions; computedValue?: never; }, ): FromEventToSource$; function fromEventToSource$( target: EventTarget, eventName: string, options?: { event?: boolean | AddEventListenerOptions; computedValue: (event: T) => ComputedValue; }, ): FromEventToSource$; ``` ### Parameters * **`target`** - The DOM element or event target to listen to (HTMLElement, Window, Document, etc.) * **`eventName`** - The event name to listen for ('click', 'input', 'scroll', etc.) * **`options`** (optional) * **`event`** - Event listener options (capture, passive, once, etc.) * **`computedValue`** - Function to transform the event before emission ### Returns `FromEventToSource$` - A readonly source with: * **`subscribe(callback: (value: T) => void)`** - Subscribe to event emissions * **`value: Signal`** - Read-only signal containing the last emitted value * **`dispose()`** - Method to manually remove the event listener The result is also a named yieldable primitive. The yielded source remains readonly and keeps `dispose()`: ```typescript const clickSource = fromEventToSource$(button, 'click'); const click = yield* clickSource; click.subscribe((event) => console.log(event)); click.dispose(); ``` ## Types ### FromEventToSource$ ```typescript type FromEventToSource$ = ReadonlySource$ & { dispose: () => void; } & NamedCraftPrimitiveGen< string, ReadonlySource$ & { dispose: () => void; } >; ``` ### ReadonlySource$ ```typescript type ReadonlySource$ = { subscribe: (callback: (value: T) => void) => Subscription; value: Signal; }; ``` ## Key Features ### Source services and dependency tracking Expose the event source through a `craftService` when consumers should depend on the event handle: ```typescript const { Click } = craftService( { name: 'Click', providedIn: 'global' }, function* () { const click = yield* fromEventToSource$(button, 'click'); return click; }, ); const counter = yield* state('counter', 0, ({ set }) => ({ click: on$(Click, () => set(1)), })); ``` `on$(Click, ...)` tracks `Click`. Calling `dispose()` only removes the DOM listener and does not alter dependency metadata. ### Automatic Cleanup Event listeners are automatically removed when the injection context is destroyed: ```ts import { craftComponent, p } from '@craft-ts/component'; import { fromEventToSource$ } from '@craft-ts/core'; export const Demo = craftComponent( 'Demo', {}, function* () { const keydown$ = fromEventToSource$(document, 'keydown'); // the listener is removed automatically when the component is destroyed return { keydown$ }; }, () => p('Press any key'), ); ``` ### Signal Integration Access the last emitted value reactively via the `value` signal: ```typescript const input$ = fromEventToSource$(inputElement, 'input', { computedValue: (event: Event) => (event.target as HTMLInputElement).value, }); // Use in template or computed const trimmedValue = craftComputed('trimmedValue', function* () { return (yield* input$.value())?.trim() ?? ''; }); ``` ### Event Transformation Transform events before emission using `computedValue`: ```typescript const resize$ = fromEventToSource$(window, 'resize', { computedValue: () => ({ width: window.innerWidth, height: window.innerHeight, }), }); // resize$.value() returns { width: number; height: number } | undefined ``` ### Integration with State Use with `on$()` to trigger state updates on DOM events: ```typescript import { state, on$, fromEventToSource$ } from '@craft-ts/core'; const button = document.querySelector('button')!; const click$ = fromEventToSource$(button, 'click'); const { counter } = state('counter', 0, ({ update }) => ({ increment: on$(click$, () => update((count) => count + 1)), })); ``` ## Examples ### Basic Click Counter ```typescript import { craftComponent, p } from '@craft-ts/component'; import { fromEventToSource$, on$, state } from '@craft-ts/core'; export const Clicker = craftComponent( 'Clicker', {}, function* () { const click$ = fromEventToSource$(document, 'click'); const clicks = yield* state('clicks', 0, ({ update }) => ({ // bound to the source, so NOT exposed on the ref increment: on$(click$, () => update((count) => count + 1)), })); return { clicks }; }, ({ clicks }) => p(function* () { return `Clicks: ${yield* clicks()}`; }), ); ``` ### Input Value Tracking ```typescript export const Search = craftComponent( 'Search', {}, function* () { const input$ = fromEventToSource$(document, 'input', { computedValue: (event: Event) => (event.target as HTMLInputElement).value, }); // reactive access to the current input value return { searchTerm: input$.value }; }, ({ searchTerm }) => [ input({ type: 'text', placeholder: 'Search…' }), p(function* () { return `You typed: ${(yield* searchTerm()) || 'nothing yet'}`; }), ], ); ``` ### Window Scroll Tracking ```typescript export const InfiniteScroll = craftComponent( 'InfiniteScroll', {}, function* () { const scroll$ = fromEventToSource$(window, 'scroll', { computedValue: () => ({ scrollY: window.scrollY, scrollHeight: document.documentElement.scrollHeight, clientHeight: window.innerHeight, }), event: { passive: true }, // optimize performance }); scroll$.subscribe((data) => { const nearBottom = data.scrollY + data.clientHeight >= data.scrollHeight - 100; if (nearBottom) { loadMoreData(); } }); return { scrollPosition: scroll$.value }; }, ({ scrollPosition }) => div( p(function* () { return `Scroll position: ${(yield* scrollPosition())?.scrollY}`; }), ), ); ``` ### Window Resize Handling ```typescript export const Responsive = craftComponent( 'Responsive', {}, function* () { const resize$ = fromEventToSource$(window, 'resize', { computedValue: () => ({ width: window.innerWidth, height: window.innerHeight, }), }); const dimensions = resize$.value; return { dimensions, isMobile: craftComputed('isMobile', function* () { const dims = yield* dimensions(); return dims ? dims.width < 768 : false; }), }; }, ({ dimensions }) => div( p(function* () { const dims = yield* dimensions(); return `Viewport: ${dims?.width} x ${dims?.height}`; }), ), ); ``` ### Keyboard Shortcuts ```ts import { craftComponent, p } from '@craft-ts/component'; import { fromEventToSource$ } from '@craft-ts/core'; interface ShortcutEvent { key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; } export const Shortcuts = craftComponent( 'Shortcuts', {}, function* () { const save = () => console.log('Save triggered'); const undo = () => console.log('Undo triggered'); const keydown$ = fromEventToSource$(document, 'keydown', { computedValue: (event: KeyboardEvent) => ({ key: event.key, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, }), }); keydown$.subscribe((shortcut) => { if (shortcut.ctrlKey && shortcut.key === 's') save(); else if (shortcut.ctrlKey && shortcut.key === 'z') undo(); }); return {}; }, () => p('Try Ctrl+S or Ctrl+Z'), ); ``` ### Dynamic Element Listening ```typescript export const Dynamic = craftComponent( 'Dynamic', {}, function* (items: Input) { let currentListener$: FromEventToSource$ | undefined; const attachListener = (element: HTMLElement) => { // remove the previous listener, if any currentListener$?.dispose(); currentListener$ = fromEventToSource$(element, 'click'); currentListener$.subscribe((event) => { console.log('Element clicked:', event); }); }; return { items, attachListener }; }, ({ items, attachListener }) => forNode( () => items(), { track: (item) => item.id }, (item) => div( button( { click: (event) => attachListener(event.target as HTMLElement) }, 'Attach listener', ), ), ), ); ``` ### Mouse Position Tracker ```typescript interface Position { x: number; y: number; } export const CursorTracker = craftComponent( 'CursorTracker', {}, function* () { const mouseMove$ = fromEventToSource$(document, 'mousemove', { computedValue: (event: MouseEvent) => ({ x: event.clientX, y: event.clientY, }), event: { passive: true }, }); return { position: mouseMove$.value }; }, ({ position }) => div( p(function* () { const pos = yield* position(); return `Mouse position: ${pos?.x}, ${pos?.y}`; }), ), ); ``` ### Form Submission ```ts import { button, craftComponent, form, input, p } from '@craft-ts/component'; import { craftComputed, fromEventToSource$, on$, state } from '@craft-ts/core'; export const SubmitDemo = craftComponent( 'SubmitDemo', {}, function* () { const submit$ = fromEventToSource$(document, 'submit', { computedValue: (event: Event) => { event.preventDefault(); const formData = new FormData(event.target as HTMLFormElement); return Object.fromEntries(formData); }, }); const formData = yield* state( 'formData', null as Record | null, ({ state, set }) => ({ // bound to the source, so NOT exposed on the ref handleSubmit: on$(submit$, (data) => set(data)), formDataJson: craftComputed('formDataJson', function* () { return JSON.stringify(yield* state()); }), }), ); return { formData }; }, ({ formData }) => form([ input('username', { type: 'text', name: 'username' }), button('submit', { type: 'submit' }, 'Submit'), p(formData.formDataJson), ]), ); ``` ## Comparison with sourceFromEvent | Feature | `fromEventToSource$` | `sourceFromEvent` | | ------------- | ----------------------------------------------------------- | ------------------------------------------------ | | Return type | `ReadonlySource$` (with `subscribe`, `value`, `dispose`) | `SignalSource` (with `set`, mutation methods) | | Modification | Read-only, no `emit` method | Writable via `set` method | | Use case | Event observation and subscription | Event-driven source with manual control | | Signal access | ✅ via `value` property | ✅ as direct signal | | Subscription | ✅ via `subscribe` method | ❌ (uses `afterRecomputation()`) | ## Best Practices ### Use Passive Event Listeners For scroll and mouse events, use `passive: true` to improve performance: ```typescript const scroll$ = fromEventToSource$(window, 'scroll', { computedValue: () => window.scrollY, event: { passive: true }, }); ``` ### Extract Only Needed Data Transform events to extract only the data you need: ```typescript // ❌ Bad - stores entire event object const click$ = fromEventToSource$(button, 'click'); // ✅ Good - extracts only needed properties const click$ = fromEventToSource$(button, 'click', { computedValue: (event: MouseEvent) => ({ x: event.clientX, y: event.clientY, }), }); ``` ### Cleanup Dynamic Listeners For dynamic elements, manually dispose of listeners: ```typescript private listener$?: FromEventToSource$; attachToElement(element: HTMLElement) { this.listener$?.dispose(); // Clean up previous this.listener$ = fromEventToSource$(element, 'click'); } ngOnDestroy() { this.listener$?.dispose(); } ``` ### Combine with State Management Integrate with state management using `on$()`: ```typescript const input$ = fromEventToSource$(inputElement, 'input', { computedValue: (e: Event) => (e.target as HTMLInputElement).value, }); const { searchResults } = state('searchResults', [], ({ set }) => ({ search: on$(input$, async (term) => { const results = await api.search(term); set(results); }), })); ``` ## Common Patterns ### Debounced Input ```typescript import { debounceTime } from 'rxjs/operators'; const input$ = fromEventToSource$(inputElement, 'input', { computedValue: (e: Event) => (e.target as HTMLInputElement).value, }); // Use with rxjs operators if needed from(input$).pipe( debounceTime(300), subscribe((value) => console.log(value)), ); ``` ### Multiple Event Handlers ```typescript const buttonClick$ = fromEventToSource$(button, 'click'); const buttonHover$ = fromEventToSource$(button, 'mouseenter'); buttonClick$.subscribe(() => console.log('Clicked')); buttonHover$.subscribe(() => console.log('Hovered')); ``` ### Conditional Event Processing ```typescript const keydown$ = fromEventToSource$(document, 'keydown', { computedValue: (event: KeyboardEvent) => event.key, }); keydown$.subscribe((key) => { if (key === 'Escape') { this.closeModal(); } else if (key === 'Enter') { this.submit(); } }); ``` ## Notes * Must be called within an injection context * Event listeners are automatically removed on component destruction * Returns a **readonly** source - no `emit` method is exposed * The `value` signal is `undefined` until the first event is emitted * Use `dispose()` for manual cleanup when needed ## See Also * [source$](/guide/reactivity/source) - Event emitter with signal tracking * [sourceFromEvent](/guide/reactivity/source-from-event) - Writable source from events * [on$](/guide/reactivity/on) - Subscribe to sources in state management * [state](/guide/state/local-state) - State primitive with source integration --- --- url: https://craft-ts.github.io/craft/guide/reactivity/source-from-event.md --- # sourceFromEvent Creates a [`source$`](/guide/reactivity/source) from DOM events or event emitters. **Use it when** the event's origin is an emitter or an element you already hold, rather than a target resolved lazily — for that, see [`fromEventToSource$`](/guide/reactivity/from-event-to-source). ## Import ```typescript import { sourceFromEvent } from '@craft-ts/core'; ``` ## Basic Usage ```typescript import { state, sourceFromEvent } from '@craft-ts/core'; const button = document.querySelector('button')!; // Create source from button clicks const clickSource = sourceFromEvent( button, 'click', () => (count: number) => count + 1, ); const { clickCount } = state('clickCount', 0, { sources: [clickSource], }); ``` ## API ```typescript function sourceFromEvent( target: EventTarget, eventName: string, mapper: (event: E) => (state: T) => T, ): Observable<(state: T) => T>; ``` ## Examples ### Inside a Craft component `sourceFromEvent` takes an `EventTarget` you already hold. Inside a component that usually means a document- or window-level target, since the component's own elements are better handled with a plain event prop: ```typescript import { craftComponent, p } from '@craft-ts/component'; import { sourceFromEvent, state } from '@craft-ts/core'; export const KeyCounter = craftComponent( 'KeyCounter', {}, function* () { const keySource = sourceFromEvent( document, 'keydown', () => (count: number) => count + 1, ); const keys = yield* state('keys', 0, { sources: [keySource] }); return { keys }; }, ({ keys }) => p(function* () { return `Keys pressed: ${yield* keys()}`; }), ); ``` ::: tip For the component's own elements, use an event prop `button({ click: counter.increment }, 'Click me')` is simpler and needs no target. Reach for `sourceFromEvent` when the event comes from outside the component's own markup, or when several states must react to the same event. ::: ### Input Changes ```typescript const input = document.querySelector('input')!; const inputSource = sourceFromEvent( input, 'input', (event: Event) => () => (event.target as HTMLInputElement).value, ); const { inputValue } = state('inputValue', '', { sources: [inputSource], }); ``` ### Mouse Position ```typescript interface Position { x: number; y: number; } const mouseMoveSource = sourceFromEvent( document, 'mousemove', (event: MouseEvent) => () => ({ x: event.clientX, y: event.clientY, }), ); const { mousePosition } = state( 'mousePosition', { x: 0, y: 0 }, { sources: [mouseMoveSource], }, ); ``` ### Keyboard Input ```typescript const keySource = sourceFromEvent( document, 'keydown', (event: KeyboardEvent) => (keys: string[]) => [...keys, event.key], ); const { pressedKeys } = state('pressedKeys', [], { sources: [keySource], }); ``` ### Scroll Position ```typescript const scrollSource = sourceFromEvent( window, 'scroll', () => () => window.scrollY, ); const { scrollPosition } = state('scrollPosition', 0, { sources: [scrollSource], }); ``` ### Form Submit ```typescript const form = document.querySelector('form')!; interface FormData { name: string; email: string; } const submitSource = sourceFromEvent(form, 'submit', (event: Event) => { event.preventDefault(); const form = event.target as HTMLFormElement; const formData = new FormData(form); return () => ({ name: formData.get('name') as string, email: formData.get('email') as string, }); }); const { formState } = state( 'formState', { name: '', email: '' }, { sources: [submitSource], }, ); ``` ### Window Resize ```typescript interface WindowSize { width: number; height: number; } const resizeSource = sourceFromEvent(window, 'resize', () => () => ({ width: window.innerWidth, height: window.innerHeight, })); const { windowSize } = state( 'windowSize', { width: window.innerWidth, height: window.innerHeight }, { sources: [resizeSource], }, ); ``` ## With Operators ```typescript import { debounceTime, map } from 'rxjs/operators'; const input = document.querySelector('input')!; // Debounced input with RxJS operators const debouncedInputSource = sourceFromEvent( input, 'input', (event: Event) => () => (event.target as HTMLInputElement).value, ).pipe(debounceTime(300)); const { searchQuery } = state('searchQuery', '', { sources: [debouncedInputSource], }); ``` ## Best Practices ✅ **Cleanup automatically handled** - Sources unsubscribe when state is destroyed ✅ **Use with throttle/debounce** - For high-frequency events ✅ **Extract to reusable functions** - Create helper functions for common patterns ✅ **Type your events** - Use specific event types (MouseEvent, KeyboardEvent) ## See Also * [`source$`](/guide/reactivity/source) - Create reactive sources --- --- url: https://craft-ts.github.io/craft/guide/reactivity/after-recomputation.md --- # afterRecomputation Runs a callback **after** a recomputation has settled, rather than in the middle of one. **Use it when** you need to act on a value once the reactive graph is stable — scrolling to a freshly rendered row, measuring after a list changed. **Not for** ordinary derivation, which belongs in a `computed`. ## Overview This function binds queries, mutations, and async methods to sources for automatic execution by: * Listening to source emissions and computing new values * Providing a readonly source suitable for method binding * Maintaining reactivity through the effect system * Enabling source-based triggering patterns ## Signature ```typescript function afterRecomputation( _source: Source, callback: (source: SourceType) => State, ): ReadonlySource; ``` ### Parameters * **`_source`** - The source to listen to. When this source emits, the callback is invoked. * **`callback`** - Function that transforms source values. Receives the emitted value and returns the transformed result. ### Returns A readonly source that emits transformed values. Can be used as the `method` parameter in queries, mutations, and async methods. ## Primary Use Case Bind queries/mutations/async methods to sources for automatic execution: ```typescript method: afterRecomputation(mySource, (data) => data); ``` This pattern makes queries/mutations execute automatically when the source emits. ## Execution Flow 1. Source emits a value via `source.set(value)` 2. `afterRecomputation` callback transforms the value 3. Resulting readonly source emits the transformed value 4. Bound query/mutation/async method executes with the new value ## Difference from computedSource * **`afterRecomputation`**: Designed for binding to method parameters * **`computedSource`**: General-purpose source transformation * Both transform source values, but `afterRecomputation` is optimized for method binding ## Common Patterns * **Identity transformation**: `afterRecomputation(source, (x) => x)` - pass value through * **Field extraction**: `afterRecomputation(source, (data) => data.id)` - extract specific field * **Validation**: `afterRecomputation(source, (data) => validate(data))` - transform and validate * **Mapping**: `afterRecomputation(source, (data) => mapToDto(data))` - convert to different type ## Examples ### Binding a query to a source for automatic execution ```typescript import { afterRecomputation, query, source$ } from '@craft-ts/core'; const userIdChange = source$('userIdChange'); const { user } = query('user', { method: afterRecomputation(userIdChange, (userId) => userId), loader: async ({ params }) => { const response = await fetch(`/api/users/${params}`); return response.json(); }, }); // Query executes automatically when source emits userIdChange.emit('user-123'); // -> query loader executes with params 'user-123' userIdChange.emit('user-456'); // -> query loader executes again with params 'user-456' ``` ### Binding a mutation to a source ```typescript import { afterRecomputation, mutation, source$ } from '@craft-ts/core'; const submitForm = source$<{ name: string; email: string }>(); const { submit } = mutation('submit', { method: afterRecomputation(submitForm, (formData) => formData), loader: async ({ params }) => { const response = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(params), }); return response.json(); }, }); // Mutation executes automatically when source emits submitForm.emit({ name: 'John', email: 'john@example.com' }); // -> mutation loader executes with form data // Note: No submit.mutate(...) call is needed here ``` ### Binding async method to a source ```typescript import { afterRecomputation, asyncProcess, source$ } from '@craft-ts/core'; const searchInput = source$('searchInput'); const { search } = asyncProcess('search', { method: afterRecomputation(searchInput, (term) => term), loader: async ({ params }) => { // Debounce at source level before setting const response = await fetch(`/api/search?q=${params}`); return response.json(); }, }); // Async method executes automatically searchInput.emit('query'); // -> search loader executes ``` ### Extracting specific field from complex data ```typescript type FormData = { user: { id: string; name: string }; address: { city: string }; }; import { afterRecomputation, mutation, source$ } from '@craft-ts/core'; const formSubmit = source$('formSubmit'); const { updateUser } = mutation('updateUser', { // Extract only user data method: afterRecomputation(formSubmit, (data) => data.user), loader: async ({ params }) => { const response = await fetch(`/api/users/${params.id}`, { method: 'PATCH', body: JSON.stringify(params), }); return response.json(); }, }); // Only user data is passed to mutation formSubmit.emit({ user: { id: 'user-1', name: 'John' }, address: { city: 'NYC' }, }); // -> mutation receives only { id: 'user-1', name: 'John' } ``` ### Transforming data before execution ```typescript import { afterRecomputation, query, source$ } from '@craft-ts/core'; const searchParams = source$<{ query: string; filters: string[] }>(); const { results } = query('results', { method: afterRecomputation(searchParams, (params) => ({ q: params.query.trim().toLowerCase(), f: params.filters.join(','), })), loader: async ({ params }) => { const queryString = new URLSearchParams(params); const response = await fetch(`/api/search?${queryString}`); return response.json(); }, }); // Data is transformed before query execution searchParams.emit({ query: ' craft ', filters: ['tutorial', 'advanced'], }); // -> query receives { q: 'craft', f: 'tutorial,advanced' } ``` ### Validation and type narrowing ```typescript import { afterRecomputation, asyncProcess, source$ } from '@craft-ts/core'; const inputChange = source$('inputChange'); const { validate } = asyncProcess('validate', { method: afterRecomputation(inputChange, (input) => { // Only proceed if input is valid const trimmed = input.trim(); if (trimmed.length < 3) { throw new Error('Input too short'); } return trimmed; }), loader: async ({ params }) => { const response = await fetch('/api/validate', { method: 'POST', body: JSON.stringify({ input: params }), }); return response.json(); }, }); // Invalid input throws error in callback inputChange.emit('ab'); // Error: Input too short // Valid input proceeds inputChange.emit('valid input'); // Validation executes ``` ### Multiple sources with different transformations ```typescript import { afterRecomputation, query, source$ } from '@craft-ts/core'; const quickSearch = source$('quickSearch'); const advancedSearch = source$<{ query: string; options: unknown }>(); const { quickResults } = query('quickResults', { method: afterRecomputation(quickSearch, (term) => ({ query: term, mode: 'quick', })), loader: async ({ params }) => { const response = await fetch('/api/search', { method: 'POST', body: JSON.stringify(params), }); return response.json(); }, }); const { advancedResults } = query('advancedResults', { method: afterRecomputation(advancedSearch, ({ query, options }) => ({ query, options, mode: 'advanced', })), loader: async ({ params }) => { const response = await fetch('/api/search/advanced', { method: 'POST', body: JSON.stringify(params), }); return response.json(); }, }); // Quick search with simple string quickSearch.emit('craft'); // -> query receives { query: 'craft', mode: 'quick' } advancedSearch.emit({ query: 'craft', options: { tags: ['signals'] }, }); // -> query receives { query: 'craft', options: { ... }, mode: 'advanced' } ``` ### Identity transformation (pass-through) ```typescript import { afterRecomputation, mutation, source$ } from '@craft-ts/core'; const dataUpdate = source$<{ id: string; payload: unknown }>(); const { update } = mutation('update', { // Pass data through unchanged method: afterRecomputation(dataUpdate, (data) => data), loader: async ({ params }) => { const response = await fetch(`/api/data/${params.id}`, { method: 'PUT', body: JSON.stringify(params.payload), }); return response.json(); }, }); // Data passed through unchanged dataUpdate.emit({ id: 'item-1', payload: { value: 123 } }); // -> mutation receives exact same object ``` ## See Also * [craftEffect](/guide/reactivity/craft-effect) * [source$](/guide/reactivity/source) --- --- url: https://craft-ts.github.io/craft/guide/i18n.md --- # Type-safe i18n `@craft-ts/i18n` is the CraftTS i18n integration. The catalogue remains a plain declarative TypeScript value, while DI-aware tokens use the existing CraftTS service contracts. A catalogue that does not use DI can still be formatted by `runtime.t`; a catalogue with DI is rendered through the reactive CraftTS translator so its dependencies are checked like component dependencies. ## The contract Six things are guaranteed, and all six are checked before the app runs. | guarantee | what it costs you to break | | ----------------------------------------------------------- | ------------------------------------------------------------------- | | the key set is a **closed union** | an unknown key does not compile — no silent `order.totl` | | every locale has the **same keys with the same parameters** | a translation you forgot is a compile error, not a fallback | | parameters are **typed by their token** | a date cannot be passed where a currency amount belongs | | a plural carries **every category the locale requires** | Polish needs `one`/`few`/`many`/`other`; French needs `one`/`other` | | a DI-aware token declares its **CraftTS services** | a missing provider is a compile error at the component/route boundary | | a token declared with a **schema** types its own input | the call site passes what the schema parses, not what the formatter wants | The usual failure mode of a translation layer is that all of these are runtime concerns: a missing key renders its own name, a wrong parameter renders `[object Object]`, and a missing plural category renders the wrong branch to the users of one locale only. None of that is observable from the code that calls `t`. ## The shape of it ``` src/i18n/ catalog.ts the reference locale — defineCatalog + msg + plural locales/fr-FR.ts every other locale — defineLocaleLike project-tokens.ts business tokens: defineToken / defineTokenFactory runtime.ts createI18nRuntime, and the reactive binding ``` A key is its dotted path: `order.total` reaches `{ order: { total: msg`…` } }`. ## Where to go next * [The catalogue](./catalog.md) — `defineCatalog`, `msg`, `plural`, `defineLocale`, `defineLocaleLike`. * [Tokens](./tokens.md) — the shipped semantic tokens, and how to add your own. * [The runtime](./runtime.md) — `createI18nRuntime`, `t`, `bind`, lazy locales. * [With Effect](./effect.md) — `@craft-ts/i18n-effect`. Two checks belong in CI, and `craft create` wires both: ```bash npm run i18n:check npm run i18n:test ``` A working example lives in the demo, at `apps/demo/src/app/examples/i18n/`. ## Guard visible text in Craft templates `craft create` enables this preset for every project generated **with** i18n — its own pages already take their copy from the catalogue. A project generated without i18n never sees the rule. To add it by hand to an existing application: ```js import craftRules from '@craft-ts/dev-tools/eslint-rules'; export default [ { plugins: { 'craft-ts': craftRules }, rules: { ...craftRules.configs.i18n.rules }, }, ]; ``` `craft-ts/require-i18n-text` reports static text in visible headings, paragraphs, labels, buttons, links and options, plus visible `placeholder`, `aria-label` and `title` attributes — and it looks *inside* the visible position, so `p('Total: ' + t('cart.total'))`, ``span(`Total: ${amount}`)``, `label(isNew ? 'New' : 'Returning')`, `p(name || 'Anonymous')` and a literal in a children array are reported too. Only what carries letters counts: `first + ' ' + last` is glue between values, not copy. Dynamic business values, `i18n.t(...)`, its key and parameters, a generator child and catalogue files are accepted. Server files and tests are excluded so technical messages and assertions can remain literal. The rule stays separate from the recommended preset, because it only makes sense once the catalogue is the application's source of truth — which is exactly the condition `craft create` checks when it decides to enable it. ## Translation parameters The complete visible sentence belongs to the catalogue. Do not translate one piece and append another piece in the template: word order, punctuation and grammar can change between locales. `craft-ts/no-i18n-composition` reports this pattern in visible text and attributes, including generator children. ### Before: a translated fragment followed by a value ```ts span(function* () { return `${i18n.t('ui.space.expires')} ${formatDate(yield* item.expiresAt())}`; }); ``` This forces every locale to keep the same sentence shape and makes the date a second, untranslated fragment. With the i18n preset, ESLint reports: > Do not compose translated text with other text or values. Put the complete > message in the i18n catalogue and pass dynamic values as translation > parameters. ### After: one message with a typed parameter Declare the value as a semantic token in the catalogue: ```ts import { dateLong, defineCatalog, msg } from '@craft-ts/i18n'; const expiresAt = dateLong('expiresAt'); export const catalog = defineCatalog({ ui: { space: { expires: msg`Space expires ${expiresAt}`, }, }, }); ``` Then pass the value to the translation in the template: ```ts span(function* () { return i18n.t('ui.space.expires', { expiresAt: yield* item.expiresAt(), }); }); ``` Each locale can now choose its own word order, punctuation and date placement, while the parameter remains typed and formatted through `Intl`. For a custom format, define a project token rather than rebuilding a translated sentence in the template; see [Tokens](./tokens.md). --- --- url: https://craft-ts.github.io/craft/guide/i18n/catalog.md --- # The catalogue A catalogue is a plain nested object. Nothing is parsed, nothing is loaded from JSON at build time, and every guarantee on this page comes from the type of the value itself. ```ts import { defineCatalog, defineLocale, defineLocaleLike, money, msg, number, plural, } from '@craft-ts/i18n'; ``` ## Tokens name the parameters ```ts // A token names a parameter and decides how it is formatted. `amount` is a // currency, `count` a number — and that is what types the params object. const amount = money('amount', undefined, { currency: 'EUR' }); const count = number('count'); ``` A token carries a **name** and a **formatter**. The name becomes the parameter key; the formatter decides how the value is rendered in the active locale. That is why `msg` can derive the params type of a message from the tokens it interpolates — see [Tokens](./tokens.md) for the full list. ## `defineCatalog`, `msg`, `plural` ```ts export const enCatalog = defineCatalog({ order: { total: msg`Order total ${amount}.`, items: plural(count, { one: msg`${count} item is in the order.`, other: msg`${count} items are in the order.`, }), }, }); export const en = defineLocale('en-US', enCatalog); ``` `msg` is a **tagged template**: the literal parts are text, the interpolations are tokens. `` msg`Order total ${amount}.` `` has params `{ amount: number }`, and nothing else. `plural(count, branches)` takes the counting token and one message per category. Which categories are *required* is decided by the locale id, not by you: `defineLocale('pl-PL', …)` will not accept a plural missing `few` or `many`. That check is a type error, before any Polish speaker sees the wrong branch. Keys nest as deeply as you like; the key used at the call site is the dotted path. ## Every other locale is `defineLocaleLike` ```ts export const fr = defineLocaleLike(en, 'fr-FR', { order: { total: msg`Total de la commande : ${amount}.`, items: plural(count, { one: msg`${count} article est dans la commande.`, other: msg`${count} articles sont dans la commande.`, }), }, }); ``` `defineLocale` is for the **reference** locale — the one that decides what the key set is. Every other locale goes through `defineLocaleLike(reference, id, catalog)`, which checks three things against the reference at compile time: * the same keys, no more and no fewer; * the same parameters on every message; * the plural categories that *this* locale requires, which may differ from the reference's. `assertLocaleParity` adds one check the types cannot express: two locales must also agree on **how** each token is resolved. A locale that swapped a service-resolved money token for a static one, or dropped a parameter's schema, renders through a different path — it is reported as a `LOCALE_MISMATCH` rather than silently formatting in the wrong currency. A renamed key in the reference therefore breaks every translation file that still has the old name, which is the entire point. It also runs `assertLocaleParity` at construction, so a mismatch that slips past the types — a catalogue built dynamically, say — still fails loudly rather than rendering a key name. ## Checking outside the typechecker ```bash npm run i18n:check ``` Runs catalogue validation and locale parity as an ordinary command, so CI and a pre-commit hook can see what `tsc` sees. Under the hood it is `validateCatalog` / `assertValidCatalog` (also exported from `@craft-ts/i18n/testing`) and `validateLocaleParity` / `assertLocaleParity`. When it fails, it names the key and the locale. Add the key; do not loosen the catalogue's type to make the message go away. ## Delivering a catalogue as data `serializeCatalog(catalog)` produces a JSON-safe shape where each token is reduced to its stable `tokenId` and parameter name — the receiving application registers the executable formatters. A token that parses its input is marked, and a token whose formatter is resolved from the injector is **refused**: its formatter only exists at render time, so a serialised copy would silently format with the default options. Deliver such a message from the application instead. ## Next * [Tokens](./tokens.md) — what `amount` and `count` above actually are. * [The runtime](./runtime.md) — turning these locales into a `t`. --- --- url: https://craft-ts.github.io/craft/guide/i18n/tokens.md --- # Tokens A token is the unit that makes a message parameter typed. It carries a **name** (the parameter key), a **kind**, a way to check the value — a guard or a **schema** — and a way to render it: a **formatter**, or a **resolver** that builds one from the injector. Both see the active locale. ## The shipped tokens They are semantic, not stylistic, and every one of them formats through `Intl`, so the output follows the locale rather than a hand-written rule: ```ts import { compactNumber, dateLong, dateShort, dateTime, integer, money, number, percent, relativeTime, } from '@craft-ts/i18n'; const price = money('price', undefined, { currency: 'EUR' }); const ratio = percent('ratio', undefined, { maximumFractionDigits: 1 }); const placedAt = dateLong('placedAt'); const lastSync = relativeTime('lastSync', undefined, { unit: 'day' }); ``` | factory | parameter type | formats as | | ------------------------------------ | ---------------- | ----------------------------- | | `number`, `integer`, `compactNumber` | `number` | decimal, no fraction, compact | | `percent` | `number` | `0.125` → `12.5 %` | | `money` | `number` | currency, `EUR` by default | | `dateShort`, `dateLong`, `dateTime` | `Date \| number` | date and date-time styles | | `relativeTime` | `number` | `-2` → `2 days ago` | Each is a factory: `factory(name, adapter?, options?)`. The **name** is what the params object will be keyed by, so the same factory serves any number of parameters — `money('amount')` and `money('refund')` are two different tokens. The **adapter** position takes any of three things: a type guard, a [Standard Schema](#validating-and-parsing-with-a-schema), or a [generator](#options-that-come-from-a-service) that resolves the options from a service. ### Options that come from a service Every factory — not only `money` — accepts a CraftTS generator in place of the adapter when its options depend on a service: ```ts const orderAmount = money('amount', function* () { const currency = yield* ClientCurrency(); return { currency: currency.code, minimumFractionDigits: 2 }; }); ``` The yielded service is part of the token's type. It is therefore propagated to the translation reader and then to the component/route DI check. A provider missing from the reachable `craftComponent`/route scope fails compilation. The generator runs when the message is rendered, not when the catalogue module is imported. It must be a **generator function**. An arrow that returns a generator satisfies the signature but is not one, so it is rejected rather than silently installed as the value guard. ## Validating and parsing with a schema The second argument also accepts a **Standard Schema**: the same contract `state`, `query` and forms already take, so a Zod, Valibot or ArkType schema written for the rest of the application drops in unchanged. ```ts const placedAt = dateLong('placedAt', z.coerce.date()); msg`Placed on ${placedAt}.`; // the call site passes a string, the formatter receives a Date translate('order', { placedAt: '2026-08-25T14:30:00Z' }); ``` A schema is not only a guard: the parameter type is the schema's **input** and the formatter receives its **output**. Parsing therefore happens once, in the catalogue, instead of at every call site. An invalid value raises `I18nRuntimeError` with the schema's own issue messages, and an asynchronous schema is refused — a translation renders synchronously. A project token declared with `defineToken` takes the same `schema` field, and may combine it with `resolveFormatter`: the parameter is parsed, the formatter is resolved from the injector. ## Your own token Business vocabulary does not belong in a shared library. `defineToken` builds one, and it looks exactly like a shipped token at the call site: ```ts import { defineToken } from '@craft-ts/i18n'; type OrderStatus = 'paid' | 'pending' | 'refunded'; export const orderStatus = defineToken({ name: 'status', kind: 'order-status', tokenId: 'app.order-status', // The guard is what keeps an arbitrary string out of the params type. validate: (value: unknown): value is OrderStatus => value === 'paid' || value === 'pending' || value === 'refunded', format: (value: OrderStatus, context) => context.locale.startsWith('fr') ? { paid: 'Payée', pending: 'En attente', refunded: 'Remboursée' }[value] : { paid: 'Paid', pending: 'Pending', refunded: 'Refunded' }[value], }); ``` The `validate` guard is what keeps an arbitrary string out of the params type: a message that interpolates this token accepts `'paid' | 'pending' | 'refunded'` and nothing else. Without it, the parameter widens and the token stops earning its place. A `schema` field does the same job and can parse on the way in; `defineToken` accepts either, and may combine a schema with a `resolveFormatter` so the parameter is parsed and the formatter comes from the injector. A token can also resolve its formatter from the injector rather than carry one. Here the unit system is a Craft service, so the same catalogue renders kilogrammes for one user and pounds for another: ```ts import { craftService, state } from '@craft-ts/core'; import { button, craftComponent, div, p } from '@craft-ts/component'; import { createI18nRuntime, defineCatalog, defineLocale, defineToken, msg, } from '@craft-ts/i18n'; type UnitSystem = 'metric' | 'imperial'; const { Units } = craftService( { name: 'Units', providedIn: 'global' }, function* () { const system = yield* state('system', 'metric' as UnitSystem, ({ set }) => ({ useImperial: () => set('imperial'), })); return { system }; }, ); // No `format`: the unit does not exist until the service has answered, so the // token declares a resolver instead of a standalone formatter. const weight = defineToken({ name: 'weight', kind: 'weight', resolveFormatter: function* () { const units = yield* Units(); const unit = (yield* units.system()) === 'imperial' ? 'pound' : 'kilogram'; return (value: number, context) => new Intl.NumberFormat(context.locale, { style: 'unit', unit }).format( unit === 'pound' ? value * 2.20462 : value, ); }, }); const en = defineLocale( 'en-US', defineCatalog({ order: { line: msg`Shipping ${weight}.` } }), ); const runtime = createI18nRuntime({ locales: [en] }); const ShippingLine = craftComponent( 'ShippingLine', {}, function* () { const units = yield* Units(); // The translator re-reads whenever the unit system does. return { translate: runtime.bind(units.system), units }; }, ({ translate, units }) => div([ p(translate('order.line', { weight: 12 })), button( 'imperial', { type: 'button', click: units.system.useImperial }, 'Imperial', ), ]), ); ``` The yielded service travels with the message: pass the reader to the template and a missing provider is a compile error, exactly as for a service yielded by the component factory. See [DI inside a translation](./runtime.md#di-inside-a-translation). `format` receives the value and a context carrying `locale` and, when the runtime was given one, `timeZone`. Keep the branching on `context.locale` coarse — a language prefix, not a full locale match — unless you genuinely have per-region wording. ## `format` or `resolveFormatter`, never both A token formats through exactly one of the two, and both renderers take `resolveFormatter` first whenever it is there: | declared | when the formatter is known | | ------------------ | -------------------------------------------------------------- | | `format` | when the catalogue is written — `formatters.money('EUR')` | | `resolveFormatter` | at render time, from the injector — the client's currency | So a token with a resolver declares no `format`. `t` still renders it if the resolver yields nothing; the moment it yields a service request, the message belongs to a bound translator and its key leaves `StaticTranslationKey`. `percent` takes a ratio, not a percentage: `0.125`, not `12.5`. That is `Intl`'s convention and the token does not second-guess it. ## A family of tokens When the same formatting rule serves several parameter names and options, `defineTokenFactory` builds the factory instead of the token: ```ts import { defineTokenFactory } from '@craft-ts/i18n'; // One factory, many parameter names: `duration('elapsed')`, `duration('ttl')`. export const duration = defineTokenFactory< 'duration', number, { readonly unit?: Intl.RelativeTimeFormatUnit } >({ kind: 'duration', format: (options) => (value: number, context) => new Intl.NumberFormat(context.locale, { style: 'unit', unit: options?.unit ?? 'minute', }).format(value), }); ``` That is exactly how `number`, `money` and the rest are built; there is no privileged path for the shipped ones. Conventionally these live in `src/i18n/project-tokens.ts`, which is where `craft create` puts them and what the generated agent skill points at. ## Next * [The runtime](./runtime.md) — spending a catalogue built from these. --- --- url: https://craft-ts.github.io/craft/guide/i18n/runtime.md --- # The runtime `createI18nRuntime` turns a set of locales into the object the application translates through. It holds one active locale, and it is deliberately small: `locale`, `setLocale`, `translate` (aliased `t`), `bind`, `loadLocale`. ```ts import { createI18nRuntime } from '@craft-ts/i18n'; export const i18n = createI18nRuntime({ locales, defaultLocale: 'en-US', // The time zone belongs here, once, rather than on every call site. timeZone: 'UTC', }); ``` `strict` defaults to **on**. At construction, every catalogue is validated and every locale is checked for parity against the first one — so a catalogue built in a way the types could not see still fails at startup rather than at the moment a user opens the page that needs it. Pass `strict: false` only when you have a reason you can write down. `timeZone` belongs on the runtime, once. Putting it on each call site is how two dates in the same view end up in two zones. ## Translating ```ts i18n.t('order.total', { amount: 1234.5 }); // 'Order total €1,234.50.' i18n.setLocale('fr-FR'); i18n.t('order.items', { count: 2 }); // '2 articles sont dans la commande.' ``` `t` **is** `translate` — the same function under two names, so a call site can read as `t('order.total', …)` without a local alias. The params argument is optional exactly when the message has no parameters, and required, with its exact shape, when it does. `setLocale(id)` throws `I18nRuntimeError` with the code `LOCALE_NOT_LOADED` for a locale the runtime does not hold. So does `t`, if the active locale was somehow never loaded. The error is not a formatting failure to be swallowed: it means the app is about to render the wrong language. The other codes it raises, all for the same reason — rendering something wrong is worse than not rendering: | code | when | | -------------------------- | ------------------------------------------------------------------ | | `MISSING_PARAM` | a token's parameter is absent from the params object | | `INVALID_PARAM` | a guard rejected the value, or a schema's issues, quoted verbatim | | `ASYNC_SCHEMA` | a parameter's schema returned a promise; a message renders in sync | | `CRAFT_INJECTION_REQUIRED` | `t` met a token that resolves a service (see below) | | `INVALID_PLURAL_COUNT` | a plural selector that is not a finite number | | `UNKNOWN_KEY` | a key that no longer exists in the loaded catalogue | ## Reactive translation A string that does not change when the locale changes is not a translation. `runtime.bind(dependency)` returns a translator whose result re-reads whenever the dependency does — the dependency being an ordinary Craft reader, typically the `state` that holds the active locale: ```ts import { craftService, state } from '@craft-ts/core'; type Locale = 'en-US' | 'fr-FR'; // One service owns the active locale, and `bind` turns the runtime into a // translator that re-reads whenever that state changes. Components consume the // service; nothing builds a local binding. export const { I18n } = craftService( { name: 'I18n', providedIn: 'global' }, function* () { const runtime = createI18nRuntime({ locales, defaultLocale: 'en-US' }); const language = yield* state('language', 'en-US' as Locale, ({ set }) => ({ setLocale: function* (next: Locale) { runtime.setLocale(next); yield* set(next); }, })); return { language, setLocale: language.setLocale, translate: runtime.bind(language) }; }, ); ``` `translate('order.items', { count })` then returns a generator the template yields like any other Craft reader. One service owns the locale for the whole app; components consume it rather than each building a local binding, which is what keeps two components from disagreeing about which language is on screen. ### DI inside a translation Dependencies belong to the token that needs them, not to the whole i18n runtime: ```ts const orderAmount = money('amount', function* () { const currency = yield* ClientCurrency(); return { currency: currency.code, minimumFractionDigits: 2 }; }); const catalog = defineCatalog({ order: msg`Order total ${orderAmount}.`, }); ``` In a template, the translator's result is used exactly like any other child or attribute value — pass it, do not drive it: ```ts p(translate('order', { amount: 1234.5 })); p({ title: translate('order', { amount: 1234.5 }) }, 'Order'); ``` Both forms carry `ClientCurrency` into the component dependency contract, so the route check reports a missing provider at compile time, just as it does for a service yielded by the component factory. The reader is a function, so `yield* translate(...)` does not type-check; and driving it yourself inside a template generator (`yield* translate(...)()`) hides the dependency from that check, exactly as a service yielded straight from a template does. Pass the reader. `t` refuses such a message at compile time: its key type is `StaticTranslationKey`, the keys whose formatting resolves nothing. A resolver that yields no request is still a `t` key — the type and the runtime draw the same line. ## Loading catalogues ```ts import { createI18nLoader } from '@craft-ts/i18n'; // Caches by id, and — the part that matters — evicts a *failed* load, so a // catalogue whose chunk died on a flaky network can be retried instead of // staying permanently poisoned. export const loader = createI18nLoader((id: string) => import(`./locales/${id}.ts`).then((module) => module.locale), ); export const runtime = createI18nRuntime({ locales, defaultLocale: 'en-US', loader, }); ``` `createI18nLoader` caches by id and — the part that matters — **evicts a failed load**, so a catalogue whose chunk died on a flaky network can be retried instead of staying permanently poisoned. `loadLocale(id)` resolves once the catalogue is in; only then does `setLocale` accept it. ::: warning A locale must be listed to be named `setLocale` and `loadLocale` are keyed on the ids in `locales`, so today a locale that is **not** in that array cannot be named without a cast — while a locale that *is* in it counts as already loaded and never reaches the loader. In practice that means the fully lazy catalogue is not expressible in the types yet. List every locale, and treat `loader` as the retry-safe cache in front of whatever your own loading code does. ::: A lazily obtained locale is not present at construction, so it is **not** covered by the startup parity check. Keep it covered by `npm run i18n:check`, which reads the files rather than the runtime. ## Next * [With Effect](./effect.md) — the same keys, as an `Effect`. --- --- url: https://craft-ts.github.io/craft/guide/i18n/effect.md --- # i18n with Effect `@craft-ts/i18n-effect` is an **adapter, and only an adapter**. It exposes three things — a service tag, a `Layer`, and one function — over a runtime you built the ordinary way. `@craft-ts/i18n` itself never imports Effect, and plain component code should keep calling `t` directly. ## The Layer ```ts import { Effect } from 'effect'; import { provideI18nRuntime, translateEffect } from '@craft-ts/i18n-effect'; const runtime = createI18nRuntime({ locales, defaultLocale: 'en-US' }); // One Layer, built from the runtime the rest of the app already uses. export const i18nLayer = provideI18nRuntime(runtime); ``` `provideI18nRuntime(runtime)` returns `Layer.Layer`. It wraps the runtime you already have, so there is exactly one active locale in the process — the Effect side does not get its own. ## Bind the locales once `translateEffect` has no value parameter carrying the locales, so TypeScript has nothing to infer them from. Called bare, its key parameter resolves to `never` and **even a valid key is rejected**. Bind them once, in the same file as the Layer: ```ts import type { TranslationKey, TranslationParams } from '@craft-ts/i18n'; type AppLocales = typeof locales; /** * `translateEffect` has no value parameter carrying the locales, so TypeScript * cannot infer them: called bare, its key parameter resolves to `never` and * even a valid key is rejected. Bind them once, here, and every call site gets * the closed key union back. */ export const t = >( key: Key, ...params: keyof TranslationParams< AppLocales[number], Key & string > extends never ? [params?: TranslationParams] : [params: TranslationParams] ) => translateEffect(key, ...params); ``` From there, `t` has the closed key union and the typed params back. Passing the type arguments at every call site — `translateEffect(…)` — works too, and is what this wrapper spares you. ## `translateEffect` ```ts // Same keys, same params, same string as runtime.t — but as an Effect that // declares I18nEffectService in its requirements. const summary = Effect.gen(function* () { const total = yield* t('order.total', { amount: 1234.5 }); const items = yield* t('order.items', { count: 2 }); return `${total} ${items}`; }); ``` The signature is `translateEffect(key, params) => Effect.Effect`. Same closed key union, same typed params, same string as `runtime.t` — the snippet above is checked against `runtime.t` in the docs test suite rather than trusted. The error channel is `never` on purpose: a translation that reaches this point cannot fail on a bad key or a bad parameter, because neither compiles. What *can* fail is the locale not being loaded, and that is a defect in the app's startup, which is why it throws `I18nRuntimeError` rather than becoming a typed failure every call site would have to handle. ## When to reach for it Use `translateEffect` **inside an Effect program** — a domain service building a message, a server handler rendering an email. In a component, the bound translator is the shorter path, and reaching for Effect just to format a string adds a requirement to the program for nothing. An Effect program is not a Craft injection context, so `translateEffect` accepts `StaticTranslationKey` — the keys whose formatting resolves no service. A message whose token yields a Craft service is rendered by the component-side translator; that is what keeps the `never` error channel above honest. See also the [Effect adapters](../advanced/effect.md) page for the rest of the `@craft-ts/*-effect` family. --- --- url: https://craft-ts.github.io/craft/guide/deployment.md --- # Deploying a CraftTS application ::: warning Experimental The deployment tooling is the newest part of CraftTS and it is **not settled**. What is written here works and is covered by tests, but the CLI surface, the manifest fields and the diagnostic codes can still change between minor versions. Pin the version if you build a pipeline on it. Concretely, as of today: `craft-ts check`, `manifest`, `providers`, `deploy preview` and `deploy` exist; `init`, `build` and `deploy init` do not yet. One provider implementation ships, [Alchemy](./alchemy.md), and no real deployment has been run from the CraftTS repository itself. ::: A CraftTS application describes its deployment once, in a typed manifest, and that description is enough to check it, to build it and to hand it to a provider. Nothing in the manifest names a hosting company, so moving from a container to a Worker, or from one publisher to another, does not touch the server-functions or the routes. ## Three notions that never merge | Notion | Question it answers | Values | | ------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **runtime** | What execution shape does the bundle take? | `static`, `node`, `worker`, `lambda` | | **platform** | Which technical platform executes that shape? | `node`, `docker`, `cloudflare`, `aws`, `vercel`, `netlify`, `firebase`, `github-pages` | | **provider** | Which integration builds, publishes or provisions it? | `alchemy`, `docker`, `cloudflare-pages`, `vercel`, `netlify`, `firebase`, `github-pages` | Alchemy is a provider of infrastructure, not a runtime. Cloudflare Pages is a provider of publication for the same artefact. Both read the same manifest. The `static` runtime carries a mode: * **`spa`** — one document, and unknown paths fall back to it. The route table lives in the browser. * **`ssg`** — one pre-rendered document per route. The route list is part of the contract, and any route that cannot be reduced to a single document has to be declared as needing a server runtime. ## The manifest ```ts import { defineCraftDeployment } from '@craft-ts/deploy'; export default defineCraftDeployment({ name: 'demo-ssr', runtime: 'node', platform: 'docker', server: { entry: 'dist/apps/demo-ssr/server/server.js', source: 'apps/demo-ssr/src/production-server.ts', healthPath: '/health', readyPath: '/ready', }, }); ``` The runtime discriminates the type: a `worker` manifest cannot carry an SSR entry, and a `node` manifest cannot omit its health and readiness routes. See the [manifest reference](./manifest.md) for every field. ## The commands ```bash npx craft-ts check --provider docker npx craft-ts manifest --out dist/apps/demo-ssr/craft-deployment-manifest.json npx craft-ts providers npx craft-ts deploy preview --provider alchemy --stage staging npx craft-ts deploy --provider alchemy --stage staging --yes ``` `check` runs before the build. It resolves the manifest, then verifies the declared paths, the runtime/platform pair, the capabilities of the chosen provider and the module graph of the runtime entry — a Node built-in reachable from a Worker entry, an SSR entry that never serves its own health route, an environment variable read but never declared. Add `--artifact` after the build to inspect the directory a provider would actually upload. Every path in the manifest is relative to the directory `check` runs from, which is `--root` when given and the current directory otherwise. In a monorepo, run the commands from the workspace root and point `--config` at the application: ```bash npx craft-ts check --config apps/demo-ssr/craft.deploy.ts --provider docker ``` `manifest` resolves the manifest to its artefact form: every default applied, keys sorted, protocol version stamped. Two builds of the same input produce a byte-identical file, which is what makes `dist//craft-deployment-manifest.json` an immutable artefact. `providers` prints the [capability matrix](./providers.md). A provider listed there is documented; the integration that deploys it is installed separately. `deploy preview` and `deploy` hand the checked manifest to that integration. The CLI resolves `@craft-ts/deploy-` from the project at run time, so it depends on no provider itself. `preview` never mutates anything, and `deploy` runs the same checks and the same preview before it applies, refusing until `--yes` approves the plan. [Alchemy](./alchemy.md) is the provider that ships today. ## What the tooling never does * It never writes a secret. The manifest declares the *names* of the environment variables and whether they are required; a declared value is a reported error. * It never mutates an infrastructure by itself. Publishing and provisioning belong to a provider package, and applying a plan always needs `--yes`. * It never replaces the build. `check` reads what the build declares and what it produced; Vite, Nx and the application scripts stay in charge. ## Where to go next * [Manifest reference](./manifest.md) — every field, every default. * [Diagnostics](./diagnostics.md) — every code, its cause and its fix. * [Providers](./providers.md) — the capability matrix and the limits of each entry. * [Alchemy provider](./alchemy.md) — credentials, state, stages, outputs and rollback for the infrastructure provider. --- --- url: https://craft-ts.github.io/craft/guide/deployment/manifest.md --- # Manifest reference ::: warning Experimental Manifest fields can still be added, renamed or removed between minor versions. The serialised form carries `protocolVersion: '1'`, and a manifest produced by another protocol is refused rather than reinterpreted — so a change here breaks loudly, at the check, and comes with migration notes. ::: The manifest is what an application writes in `craft.deploy.ts`. It describes only the facts a build and a deployment need, and it stays provider-neutral: the provider is chosen when deploying, never to produce the artefact. Accepted file names, in priority order: `craft.deploy.ts`, `craft.deploy.mts`, `craft.deploy.mjs`, `craft.deploy.js`, `craft.deploy.json`. ## Common fields | Field | Required | Default | Meaning | | ------------- | -------- | ------------ | --------------------------------------------------------- | | `name` | yes | — | Name of the deployment. | | `environment` | no | `production` | Target environment, e.g. `staging`. | | `runtime` | yes | — | `static`, `node`, `worker` or `lambda`. | | `platform` | yes | — | Technical platform executing that runtime. | | `client` | depends | — | Browser build command and output directory. | | `functions` | no | — | Server-functions exposed by the deployment. | | `env` | no | `[]` | Environment variables expected, **without their values**. | | `artifact` | no | derived | What a provider ships, and the source map policy. | `client` is required for the `static` runtime and optional for the others. ## Runtime sections The runtime discriminates which section is mandatory, at the type level and at the validation level. A section belonging to another runtime is refused: nothing would ever execute it. ### `static` ```ts static: { mode: 'spa' | 'ssg', fallback?: string, // spa, default `index.html` routes?: readonly string[], // ssg, the routes to pre-render serverRoutes?: readonly string[], } ``` In `ssg` mode the route list is mandatory and every route must reduce to a single document: `/`, `/about`, `/blog/2026` are pre-renderable, `/users/:id` and `/blog/*` are not. Routes that need a server runtime go in `serverRoutes`, which documents the boundary instead of hiding it. A route is considered rendered when the artefact holds either `.html` or `/index.html`. ### `server` — the `node` runtime ```ts server: { entry: string, // SSR entry produced by the build source?: string, // module producing `entry` build?: string, start?: string, healthPath: string, // absolute path, e.g. `/health` readyPath: string, // absolute path, e.g. `/ready` } ``` `healthPath` and `readyPath` are mandatory because an SSR deployment without a readiness signal cannot be rolled out safely. `source` is what makes `craft-ts check` useful *before* a build: the checker reads the module graph of the source, not of an output that does not exist yet. ### `worker` — the `worker` runtime ```ts worker: { entry: string, // module exporting `fetch(request, env, ctx)` source?: string, build?: string, bindings?: readonly { name: string; type: string; description?: string }[], } ``` Bindings are declared, never valued. `createCraftWorkerFetch` makes the HTTP application and the server-functions portable; it does not make an SSR entry that reaches for `node:http` or `node:fs` portable, which is exactly what the Node built-in check catches. ### `lambda` — the `lambda` runtime ```ts lambda: { entry: string, // Function URL handler source?: string, build?: string, permissions?: readonly string[], } ``` ## `functions` ```ts functions: { entry: string, // module building the server-function registry basePath?: string, // default `/api` ids?: readonly string[], // identifiers exposed to clients } ``` The identifier is the routing key of the protocol, so a duplicate is refused. An identifier that never appears in the module graph of `entry` is reported as a warning. ## `env` ```ts env: [ { name: 'PORT', required: false, description: 'TCP port the server listens on.', }, ]; ``` Names are upper snake case. A `value` or a `default` is refused: the manifest is committed and read by every provider, so it carries the contract, not the secret. Variables read by the runtime entry but absent from this list are reported as warnings. ## `artifact` ```ts artifact: { publicDir?: string, // default: client.outDir serverEntry?: string, // default: the runtime entry start?: string, // default: server.start configFiles?: readonly string[], sourceMaps?: 'forbidden' | 'external' | 'allowed', // default: forbidden } ``` The default source map policy is `forbidden`: shipping the maps of a production bundle publishes the sources, so an application has to opt out explicitly. ## The resolved manifest `craft-ts manifest` applies every default and stamps the protocol version: ```bash npx craft-ts manifest --config apps/demo-ssr/craft.deploy.ts \ --out dist/apps/demo-ssr/craft-deployment-manifest.json ``` The output has sorted keys, so two builds of the same input are byte-identical and a diff is reviewable. `protocolVersion` is `1`; a manifest produced by another protocol is refused rather than reinterpreted. ## A complete example The SSR demonstrator of this repository, `apps/demo-ssr/craft.deploy.ts`, checked on every production run: ```ts import { defineCraftDeployment } from '@craft-ts/deploy'; /** * Deployment of the SSR demo. * * Every path is relative to the workspace root, which is the directory * `craft-ts check` runs from. The manifest stays provider-neutral: it says * what the artefact is, never who publishes it. */ export default defineCraftDeployment({ name: 'demo-ssr', environment: 'production', runtime: 'node', platform: 'docker', client: { build: 'nx run demo-ssr:build:production', outDir: 'dist/apps/demo-ssr', }, server: { build: 'nx run demo-ssr:build:production', entry: 'dist/apps/demo-ssr/server/server.js', // The build output only exists after a build; declaring the source lets // `craft-ts check` read the real module graph before that. source: 'apps/demo-ssr/src/production-server.ts', start: 'node dist/apps/demo-ssr/server/server.js', healthPath: '/health', readyPath: '/ready', }, functions: { entry: 'apps/demo-with-server-function/src/server/server.ts', basePath: '/api', ids: [ 'demo.products.list', 'demo.users.list', 'demo.users.authenticated-list', 'demo.users.portable-list', 'demo.users.effect-middleware-list', ], }, env: [ { name: 'HOST', required: false, description: 'Interface the server binds to.', }, { name: 'PORT', required: false, description: 'TCP port the server listens on.', }, { name: 'GRACEFUL_SHUTDOWN_TIMEOUT_MS', required: false, description: 'Delay granted to in-flight requests on SIGTERM.', }, { name: 'RATE_LIMIT_MAX', required: false, description: 'Requests allowed per window on API routes.', }, { name: 'RATE_LIMIT_WINDOW_MS', required: false, description: 'Length of the rate limiting window.', }, { name: 'FORCE_HTTPS', required: false, description: 'Treat requests as HTTPS behind a trusted proxy.', }, { name: 'CORS_ORIGINS', required: false, description: 'Comma-separated list of allowed origins.', }, { name: 'TRUSTED_HOSTS', required: false, description: 'Comma-separated list of accepted Host headers.', }, { name: 'PUBLIC_ORIGIN', required: false, description: 'Origin used to build canonical and Open Graph URLs.', }, ], artifact: { publicDir: 'dist/apps/demo-ssr', configFiles: ['apps/demo-ssr/Dockerfile', 'docker-compose.production.yml'], sourceMaps: 'forbidden', }, }); ``` --- --- url: https://craft-ts.github.io/craft/guide/deployment/diagnostics.md --- # Deployment diagnostics ::: warning Experimental Codes can be added, and their messages can change, between minor versions. Do not match on a message in a CI script: match on the `code` field of the `--json` report, which is the part meant to be stable. ::: Every problem `craft-ts check` reports carries a code, the concerned runtime or platform, a location, what is wrong and what to change. This page is the reference for the codes; it is verified against the checker, so a code cannot ship undocumented. `error` fails the check and the command exits with `1`. `warning` is printed and the command still succeeds — warnings cover the heuristics, such as scanning sources for environment variable reads, where a false positive must not block a deployment. Read a report with `--json` when a machine consumes it: ```bash npx craft-ts check --config apps/demo-ssr/craft.deploy.ts --json ``` ## Codes ### `CRAFT_DEPLOY_ARTIFACT_MISSING` The artefact directory does not exist. * **Cause** — The directory the provider would publish has not been produced. * **Fix** — Run the declared build command before checking the artefact. ### `CRAFT_DEPLOY_ARTIFACT_NO_ENTRY` The artefact has no browser entry point. * **Cause** — The public directory contains no `index.html`. * **Fix** — Check the build output directory declared in `client.outDir`. ### `CRAFT_DEPLOY_ARTIFACT_NO_JAVASCRIPT` The artefact contains no JavaScript. * **Cause** — The public directory has no `.js` or `.mjs` file, which means the build produced nothing executable. * **Fix** — Check the build command and its output directory. ### `CRAFT_DEPLOY_ARTIFACT_SOURCE_MAP` The artefact ships source maps. * **Cause** — The source map policy is `forbidden` and the public directory contains `.map` files. * **Fix** — Disable source maps in the production build, or relax `artifact.sourceMaps`. ### `CRAFT_DEPLOY_CONFIG_LOAD_FAILED` The deployment manifest could not be imported. * **Cause** — Loading the manifest threw, or the serialised manifest is not valid JSON. A TypeScript manifest also fails when the running Node cannot strip types and TypeScript is not installed. * **Fix** — Fix the thrown error, or run the CLI under a TypeScript loader, or commit a `craft.deploy.json` produced by the build. ### `CRAFT_DEPLOY_CONFIG_NOT_FOUND` No deployment manifest found. * **Cause** — No `craft.deploy.ts`, `craft.deploy.mjs`, `craft.deploy.js` or `craft.deploy.json` was found in the checked directory. * **Fix** — Create a manifest with `defineCraftDeployment` or point the CLI at one with `--config`. ### `CRAFT_DEPLOY_CONFIG_NO_DEFAULT_EXPORT` The deployment manifest has no default export. * **Cause** — The module loaded but exposes no default export to read. * **Fix** — Add `export default defineCraftDeployment({ ... })`. ### `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` A deployment was requested without confirmation. * **Cause** — `craft-ts deploy` mutates an infrastructure, so it refuses to run until the plan has been approved explicitly. * **Fix** — Review `craft-ts deploy preview`, then pass `--yes` to apply it. ### `CRAFT_DEPLOY_ENV_NAME_INVALID` Invalid environment variable name. * **Cause** — A declared name is not an upper snake case identifier, which several platforms reject. * **Fix** — Rename the variable to `UPPER_SNAKE_CASE`. ### `CRAFT_DEPLOY_ENV_UNDECLARED` An environment variable is read but not declared. * **Cause** — The module graph of the runtime entry reads a variable the manifest does not list, so no provider can know it must be set. * **Fix** — Declare the variable in `env`, or stop reading it from the runtime entry. ### `CRAFT_DEPLOY_ENV_VALUE_FORBIDDEN` An environment variable carries a value. * **Cause** — The manifest is committed and read by every provider, so it declares names and requirements only. * **Fix** — Remove the value and provide it through the CI or the provider secret store. ### `CRAFT_DEPLOY_FUNCTION_ID_DUPLICATE` A server-function identifier is declared twice. * **Cause** — The identifier is the routing key of the protocol, so a duplicate makes the exposed contract ambiguous. * **Fix** — Keep one declaration per identifier. ### `CRAFT_DEPLOY_FUNCTION_ID_UNKNOWN` A declared server-function identifier is not in the registry entry. * **Cause** — The identifier does not appear in the module graph of `functions.entry`. * **Fix** — Register the function in the entry, or remove the identifier from the manifest. ### `CRAFT_DEPLOY_HEALTH_PATH_MISSING` The health route is not served by the SSR entry. * **Cause** — The path declared as `server.healthPath` was not found in the module graph of the SSR entry. * **Fix** — Serve the declared path, or align the manifest with the path the server exposes. ### `CRAFT_DEPLOY_MANIFEST_INVALID_FIELD` A manifest field has an invalid value. * **Cause** — The field exists but its type or its shape does not match the contract. * **Fix** — Correct the value reported by `path`; the message states what was expected. ### `CRAFT_DEPLOY_MANIFEST_MISSING_FIELD` A required manifest field is missing. * **Cause** — A field the runtime or the providers need is absent. * **Fix** — Add the field reported by `path`. ### `CRAFT_DEPLOY_MANIFEST_NOT_AN_OBJECT` The manifest is not an object. * **Cause** — The default export or the parsed JSON is not a plain object. * **Fix** — Export the object returned by `defineCraftDeployment`. ### `CRAFT_DEPLOY_MANIFEST_SECTION_MISSING` The runtime section is missing. * **Cause** — Each runtime requires its own section: `static` and `client`, `server`, `worker` or `lambda`. * **Fix** — Add the section the runtime requires. ### `CRAFT_DEPLOY_MANIFEST_SECTION_UNEXPECTED` A section does not belong to this runtime. * **Cause** — A section of another runtime is present; nothing would ever execute it. * **Fix** — Remove the section, or change the runtime to the one that uses it. ### `CRAFT_DEPLOY_MANIFEST_UNKNOWN_PLATFORM` Unknown platform. * **Cause** — `platform` is not part of the documented platform list. * **Fix** — Pick a supported platform, or open an issue to add it to the matrix. ### `CRAFT_DEPLOY_MANIFEST_UNKNOWN_RUNTIME` Unknown runtime. * **Cause** — `runtime` is not one of `static`, `node`, `worker` or `lambda`. * **Fix** — Pick one of the four supported runtimes. ### `CRAFT_DEPLOY_NODE_BUILTIN_IMPORT` A Node built-in is imported by a Worker or Lambda entry. * **Cause** — The module graph reachable from the entry imports a Node built-in such as `node:fs` or `node:http`, which a Worker runtime does not provide. * **Fix** — Replace the built-in with a Web API, or move the code behind a platform adapter that the Worker entry does not import. ### `CRAFT_DEPLOY_PATH_MISSING` A declared path does not exist. * **Cause** — An entry point, an output directory or a configuration file declared by the manifest is absent from disk. * **Fix** — Run the build that produces it, or correct the path in the manifest. ### `CRAFT_DEPLOY_PLATFORM_MISMATCH` The requested platform is not the manifest platform. * **Cause** — `--platform` was passed with a value the manifest does not declare. * **Fix** — Drop the flag, or change `platform` in the manifest. ### `CRAFT_DEPLOY_PROTOCOL_VERSION_UNSUPPORTED` Unsupported manifest protocol version. * **Cause** — The serialised manifest was produced by another version of the protocol. * **Fix** — Rebuild the manifest with the current CraftTS tooling, or follow the migration notes. ### `CRAFT_DEPLOY_PROVIDER_CAPABILITY_MISSING` The provider does not support this runtime. * **Cause** — The capability required by the runtime, and by the static mode when relevant, is not offered by the provider. * **Fix** — Choose a provider that declares the capability, or change the runtime or the static mode. ### `CRAFT_DEPLOY_PROVIDER_CREDENTIALS_MISSING` The provider has no credentials. * **Cause** — A credential the platform requires is absent from the environment. The tooling never stores one, so it can only report the name it expected. * **Fix** — Export the named variable in the shell or the CI secret store, then run the command again. ### `CRAFT_DEPLOY_PROVIDER_INVALID_MODULE` The provider package does not export a provider. * **Cause** — The module loaded but exposes no `createCraftDeploymentProvider` factory. * **Fix** — Export `createCraftDeploymentProvider(options?)` returning a `CraftDeploymentProvider`. ### `CRAFT_DEPLOY_PROVIDER_NOT_INSTALLED` The provider package is not installed. * **Cause** — The CLI resolves a provider from `@craft-ts/deploy-` at run time, and that package is absent from the project. * **Fix** — Install the provider package, or point `--provider-module` at the module that exports it. ### `CRAFT_DEPLOY_PROVIDER_PLATFORM_UNSUPPORTED` The provider does not target this platform. * **Cause** — The provider cannot deploy to the platform declared by the manifest. * **Fix** — Choose a provider that targets the platform, or change the platform. ### `CRAFT_DEPLOY_PROVIDER_STATE_UNAVAILABLE` The provider state cannot be read. * **Cause** — An infrastructure provider reconciles against a recorded state; without it, a deployment cannot tell a creation from an update. * **Fix** — Make the state backend reachable, or initialise it for this stage before deploying. ### `CRAFT_DEPLOY_PROVIDER_TOOLCHAIN_MISSING` A tool the provider drives is missing. * **Cause** — The provider shells out to a CLI or imports a runtime package that is not installed. * **Fix** — Install the reported tool, or choose a provider that does not need it. ### `CRAFT_DEPLOY_PROVIDER_UNKNOWN` Unknown provider. * **Cause** — The provider name is absent from the capability matrix. * **Fix** — Use a documented provider name, or register the provider before checking. ### `CRAFT_DEPLOY_PROVIDER_UNSUPPORTED_RESOURCE` The provider has no resource for this part of the manifest. * **Cause** — The runtime, the platform or a declared binding maps to nothing the provider knows how to create. * **Fix** — Remove the declaration, or deploy that part with a provider that supports it. ### `CRAFT_DEPLOY_READY_PATH_MISSING` The readiness route is not served by the SSR entry. * **Cause** — The path declared as `server.readyPath` was not found in the module graph of the SSR entry. * **Fix** — Serve the declared path, or align the manifest with the path the server exposes. ### `CRAFT_DEPLOY_RUNTIME_MISMATCH` The requested runtime is not the manifest runtime. * **Cause** — `--runtime` was passed with a value the manifest does not declare. * **Fix** — Drop the flag, or change `runtime` in the manifest. ### `CRAFT_DEPLOY_RUNTIME_PLATFORM_INCOMPATIBLE` This platform cannot execute this runtime. * **Cause** — The platform has no execution shape for the declared runtime, for instance a `lambda` runtime on Cloudflare. * **Fix** — Change the runtime or the platform; the compatibility matrix lists the supported pairs. ### `CRAFT_DEPLOY_SPA_FALLBACK_MISSING` The SPA fallback document is missing from the client output. * **Cause** — A `spa` deployment answers unknown paths with the fallback document, which is absent from the built output. * **Fix** — Build the client, or correct `static.fallback`. ### `CRAFT_DEPLOY_SSG_ROUTES_MISSING` The SSG mode declares no route. * **Cause** — A `ssg` deployment pre-renders one HTML file per route and the route list is empty. * **Fix** — List the routes in `static.routes`, or switch the mode to `spa`. ### `CRAFT_DEPLOY_SSG_ROUTE_NOT_RENDERED` A declared SSG route has no pre-rendered document. * **Cause** — The artefact contains neither `.html` nor `/index.html` for a route listed in `static.routes`. * **Fix** — Run the pre-render step for that route, or remove it from `static.routes`. ### `CRAFT_DEPLOY_SSG_ROUTE_NOT_STATIC` An SSG route cannot be pre-rendered. * **Cause** — The route is not an absolute literal path: it carries a `:param`, a wildcard or a query string, so no single HTML file represents it. * **Fix** — Expand the route into its literal paths, or declare it in `static.serverRoutes`. --- --- url: https://craft-ts.github.io/craft/guide/deployment/providers.md --- # Deployment providers ::: warning Experimental The provider contract is not settled: the method signatures below, the capability list and the plan shape can still change between minor versions. A provider written today may need a small update to keep up. ::: A provider is the integration that builds, publishes or provisions a platform. It is never a runtime of CraftTS, and never a dependency of the application bundle: the CLI owns the manifest and delegates every mutation to a provider package installed separately. A provider implements this contract: ```ts export type CraftDeploymentProvider = { readonly name: string; readonly capabilities: readonly CraftDeploymentCapability[]; check?( request: CraftDeploymentRequest, ): Promise; preview(request: CraftDeploymentRequest): Promise; deploy(request: CraftDeploymentRequest): Promise; }; export type CraftDeploymentRequest = { readonly manifest: CraftDeploymentManifest; readonly rootDir: string; readonly stage: string; }; export type CraftDeploymentCapability = | 'static-spa' | 'static-ssg' | 'node-ssr' | 'worker' | 'lambda' | 'infrastructure' | 'local-preview'; ``` `check` reports instead of throwing, so its diagnostics join the ones of `craft-ts check`. `preview` returns the plan rather than printing it, because the plan is the approval surface: `craft-ts deploy` shows it and refuses to apply anything until `--yes` approves it. A provider package exports one factory, and the CLI resolves it at run time from the project being deployed: ```ts export function createCraftDeploymentProvider( options?: Record, ): CraftDeploymentProvider; ``` Adding a provider therefore never means changing the CLI. Two families share that contract without sharing an implementation. A publication provider uploads an artefact. An infrastructure provider also creates the resources, the bindings, the permissions and the state. Both read the same manifest. ## Capability matrix | Provider | static-spa | static-ssg | node-ssr | worker | lambda | infrastructure | local-preview | Platforms | | ------------------ | ---------- | ---------- | -------- | ------ | ------ | -------------- | ------------- | ------------------- | | `alchemy` | yes | yes | yes | yes | yes | yes | yes | `cloudflare`, `aws` | | `docker` | no | no | yes | no | no | no | yes | `docker`, `node` | | `cloudflare-pages` | yes | yes | no | no | no | no | no | `cloudflare` | | `vercel` | yes | yes | yes | no | no | no | yes | `vercel` | | `netlify` | yes | yes | yes | no | yes | no | no | `netlify` | | `firebase` | yes | yes | yes | no | yes | no | no | `firebase` | | `github-pages` | yes | yes | no | no | no | no | no | `github-pages` | `craft-ts check --provider ` refuses a manifest whose runtime — and, for the `static` runtime, whose mode — is not covered by the provider, and refuses a provider that does not target the declared platform. Print the same matrix from the CLI: ```bash npx craft-ts providers --json ``` ## Runtime and platform compatibility A pair absent from this table is not a missing integration: nothing on that platform executes that shape. | Runtime | Platforms | | -------- | -------------------------------------------------------------------------------------- | | `static` | `node`, `docker`, `cloudflare`, `aws`, `vercel`, `netlify`, `firebase`, `github-pages` | | `node` | `node`, `docker`, `aws`, `vercel`, `netlify`, `firebase` | | `worker` | `cloudflare` | | `lambda` | `aws`, `netlify`, `firebase` | ## Provider details ### `alchemy` * **Artefact** — Public directory plus the runtime entry declared by the manifest. * **Local preview** — `craft-ts deploy preview --provider alchemy` * **Credentials** — Cloudflare or AWS credentials read from the environment by the Alchemy CLI. * **Limit** — Requires the Alchemy CLI and a reachable state backend. * **Limit** — Shipped as the separate package `@craft-ts/deploy-alchemy`. ### `docker` * **Artefact** — Image built from the SSR entry and the client output. * **Local preview** — `docker compose -f docker-compose.production.yml up` * **Credentials** — Registry credentials handled by the Docker CLI. * **Limit** — No static-only publication path: a plain bucket is cheaper. * **Limit** — Provisioning of the host is out of scope. ### `cloudflare-pages` * **Artefact** — Public directory uploaded as-is. * **Local preview** — `wrangler pages dev ` * **Credentials** — `CLOUDFLARE_API_TOKEN` read by Wrangler. * **Limit** — SSR and Worker runtimes need a Worker deployment, not Pages. ### `vercel` * **Artefact** — Public directory plus an optional Node server entry. * **Local preview** — `vercel dev` * **Credentials** — `VERCEL_TOKEN` read by the Vercel CLI. * **Limit** — Worker and Lambda runtimes map to platform-specific functions and are not covered by this matrix. * **Limit** — Infrastructure provisioning is partial and platform-owned. ### `netlify` * **Artefact** — Public directory plus a functions directory. * **Local preview** — `netlify dev` * **Credentials** — `NETLIFY_AUTH_TOKEN` read by the Netlify CLI. * **Limit** — The Lambda capability is served by Netlify Functions, not by AWS Function URLs. * **Limit** — No infrastructure provisioning. ### `firebase` * **Artefact** — Hosting public directory plus Cloud Functions. * **Local preview** — `firebase emulators:start` * **Credentials** — Firebase CLI login or a service account key. * **Limit** — No Worker runtime. * **Limit** — Infrastructure provisioning is partial and project-scoped. ### `github-pages` * **Artefact** — Public directory published as a Pages artefact. * **Local preview** — none * **Credentials** — The `GITHUB_TOKEN` of the publishing workflow. * **Limit** — No server runtime at all. * **Limit** — SPA fallback requires a `404.html` copy of the fallback document. ## Using a provider ```bash npx craft-ts deploy preview --provider alchemy --stage staging npx craft-ts deploy --provider alchemy --stage staging --yes ``` `deploy` runs the manifest check, the provider check and the preview before it applies anything, and stops with `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` when the plan has not been approved. Capabilities are verified against the provider that was actually loaded, not against this table, so a project can deploy with a provider CraftTS does not ship. ## Status The matrix above is documentation, not a list of shipped integrations. One implementation ships today: [Alchemy](./alchemy.md), as the separate package `@craft-ts/deploy-alchemy`, so Alchemy never appears in the dependencies of the CraftTS runtime. The other entries describe integrations a project can write against the same contract. --- --- url: https://craft-ts.github.io/craft/guide/deployment/alchemy.md --- # Alchemy provider ::: warning Experimental — validate in a non-production account first This provider is the least validated part of the deployment tooling. The presets, the plan, the credential checks and the refusal paths are covered by tests through a runtime port, so what CraftTS *decides* is verified. What is **not** verified is the last hop: the adapter that invokes Alchemy's current CLI has never run against a real Cloudflare or AWS account from this repository. Treat your first deployment as the validation of that adapter — run `deploy preview` first and read the plan. ::: `@craft-ts/deploy-alchemy` deploys a CraftTS manifest to Cloudflare or AWS through [Alchemy](https://alchemy.run). It is an optional package: Alchemy never appears in the dependencies of a CraftTS application, and a project that publishes a static artefact somewhere else never installs it. Alchemy is a **provider of infrastructure**, not a runtime. It can create the resources, the bindings, the permissions and the state, where a publication provider only uploads an artefact. Both read the same manifest. ## Install ```bash npm install --save-dev @craft-ts/deploy-alchemy alchemy ``` The CLI resolves `@craft-ts/deploy-` from the project being deployed, so nothing else has to be configured. A provider living elsewhere is pointed at with `--provider-module`. ## Credentials Credentials are read from the environment. The tooling checks that they are set, never reads their value beyond that, and never writes one to disk. | Platform | Variables | | ------------ | ------------------------------------------------------------------------------------------------------- | | `cloudflare` | `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`), `CLOUDFLARE_ACCOUNT_ID` (or `ALCHEMY_PROFILE`) | | `aws` | `AWS_ACCESS_KEY_ID` (or `AWS_PROFILE`, `AWS_ROLE_ARN`), `AWS_REGION` (or `AWS_DEFAULT_REGION`) | Alchemy 2 uses its provider state store and profile configuration. The adapter does not require the legacy `ALCHEMY_PASSWORD` variable. ## State and stages Alchemy reconciles against a recorded state, which is what lets a preview tell a creation from an update. Every resource name carries the application **and** the stage: ```text demo-production-worker demo-preview-42-worker ``` A stage is passed with `--stage`; it defaults to the `environment` of the manifest. Two stages never share a resource, so a preview deployment cannot overwrite production. ## Preview before mutating ```bash npx craft-ts deploy preview --provider alchemy --stage staging ``` ```text plan: alchemy → stage staging (2 resource(s)) create cloudflare:KV.Namespace demo-staging-sessions binding: SESSIONS update cloudflare:Worker demo-staging-worker entrypoint: dist/apps/demo/worker.js assets: dist/apps/demo note: Alchemy 2.0.0-beta.76, stage `staging`. Preview only: nothing was created, updated or deleted. ``` The preview opens Alchemy in its read phase, so it resolves the recorded state and creates nothing. A resource Alchemy still records that the manifest no longer declares appears as `delete`: hiding it would understate the change. ## Deploy ```bash npx craft-ts deploy --provider alchemy --stage staging --yes ``` `deploy` runs, in order: the manifest check, the provider check (credentials, artefacts, presets), then the same preview. It refuses to apply until `--yes` approves the plan, and reports `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` otherwise. Outputs are printed as `.`, and the first `url` output becomes the deployment URL: ```text url: https://demo-staging.workers.dev output demo-staging-sessions.id: 5f1c… output demo-staging-worker.url: https://demo-staging.workers.dev Deployed to stage `staging` with alchemy. ``` Use `--json` to get `{ applied, plan, result, diagnostics }` for a CI step. ## What each manifest becomes | Manifest | Resources | | ------------------------ | ----------------------------------------------------- | | `static` on `cloudflare` | `Website.StaticSite` | | `worker` on `cloudflare` | binding resources, then `Worker` | | `static` on `aws` | `Website.StaticSite` | | `lambda` on `aws` | `Lambda.Function` with its built-in Function URL | | `node` on `aws` | refused; use the Docker provider or an image preset | Bindings map to the resource their `type` names — `kv`, `r2`, `d1`, `queue`, `durable_object`. A binding typed `secret` is never created: its value must already exist in the Alchemy state or the environment, and the plan says so instead of carrying it. Any other type is refused with `CRAFT_DEPLOY_PROVIDER_UNSUPPORTED_RESOURCE` rather than silently dropped. The Function URL keeps the `{ id, input, context }` protocol of a server-function, so the same function behaves as it does locally. ## Rollback Alchemy has no "undo": a rollback is a deployment of the previous artefact. 1. Check out the commit whose artefact was healthy, or restore its `dist//craft-deployment-manifest.json`. 2. Rebuild it: the manifest is byte-identical for a given input, so a rebuild of the same commit produces the same declaration. 3. `npx craft-ts deploy preview --provider alchemy --stage ` and read the plan: a rollback shows `update` on the resources that moved forward. 4. Apply it with `--yes`. Two things do not roll back on their own and have to be handled explicitly: a resource deleted by a finalize is recreated empty, and a stateful binding such as a KV namespace or a bucket keeps the data written by the newer version. Roll a stateful change back through the data, not through the deployment. ## What stays in CraftTS, what is delegated CraftTS owns the manifest, the checks, the resolved artefact and the plan shape. It decides *what* has to exist, and it refuses to deploy a manifest that does not pass `craft-ts check`. Alchemy owns the resources, the state, the credentials handling and the reconciliation. It decides *how* what CraftTS declared comes to exist. The adapter generates a temporary Alchemy 2 stack and invokes the installed Alchemy CLI. `ALCHEMY_RESOURCE_EXPORTS` maps each planned resource type to the current module and nested export used in that generated stack. ## Limits * The adapter over the Alchemy API has never run against a live account, as stated at the top of this page. * The Fargate fallback runs the artefact as a container: the image build stays outside CraftTS. * Alchemy has no preset here for the platforms a publication provider already covers (`vercel`, `netlify`, `firebase`, `github-pages`). --- --- url: https://craft-ts.github.io/craft/guide/advanced/ssr-hydration.md --- # SSR and hydration Craft can render a complete application to deterministic HTML on the server, transfer its serializable state, and then attach the browser runtime to the existing DOM. This runtime path does not require the Craft compiler. **Use it when** the first response must contain useful HTML without rebuilding the same component tree during browser startup. ## Render one isolated request `renderCraft` creates a new platform, injector, primitive registry, in-memory history, and storage pair for every call. Do not reuse its injector between requests. ```ts import { renderCraft } from '@craft-ts/component'; import { appConfig } from './app.config'; const controller = new AbortController(); const rendered = await renderCraft({ config: appConfig, url: '/dashboard?page=2', signal: controller.signal, timeoutMs: 5_000, }); return new Response( `${rendered.html}`, { headers: { 'content-type': 'text/html; charset=utf-8' }, }, ); ``` The result separates `rootHtml`, collected `styles`, and the transfer `snapshot`; `html` combines all three. `renderToString(component, options)` is the smaller API when no application config is needed. All app initializers are run before the server render. Aborting the request rejects pending SSR work with the signal reason. A blocking render that exceeds `timeoutMs` rejects with `CraftSsrTimeoutError` and lists the pending sources. A route policy may set a shorter `timeoutMs` for the sources it blocks. ## Hydrate the existing DOM Serve the generated ``, style element, and transfer script without changing them. The browser entry point then uses the same app config. `startCraft` chooses hydration when the SSR marker is present and falls back to a normal client mount when the page was not rendered by Craft SSR: ```ts import { startCraft } from '@craft-ts/component'; import { appConfig } from './app.config'; const app = startCraft({ config: appConfig }); ``` Hydration restores the snapshot before creating primitives, claims elements, text markers, and block boundaries by their structural keys, and attaches bindings and listeners. A resolved transferred query therefore does not issue the same initial request again. The transfer script and server style element are removed after a successful first pass; the normal client style registry then owns the styles. Call `app.destroy()` when the application host is removed. Pass `host`, `snapshot`, or `onMismatch` when the defaults are not appropriate. Use `hydrateCraft` directly when the application needs to force hydration or pass hydration-specific options. ## Choose what SSR does with pending data The boundary that owns the pending UI owns its SSR policy. A query still only describes data and its loader. ```ts div(UserList()).pipe( pendingNode({ ssr: 'block', fallback: () => UserListSkeleton(), }), ); ``` The three modes are: | Mode | Server action | Initial HTML | | ---------- | -------------------------------------- | ---------------------------------------- | | `block` | Starts and awaits the suspended source | Resolved content and query snapshot | | `fallback` | Does not await the source | Boundary fallback | | `client` | Does not start the source | Explicit browser-owned shell or fallback | `client` requires an explicit fallback in the catch-all form. An exhaustive boundary already supplies explicit source fallbacks. A route can provide the page default: ```ts craftRoute('dashboard', { path: 'dashboard', loadComponent: () => import('./dashboard'), ssr: { mode: 'block' }, }); ``` The nearest local `pendingNode` wins over the route policy. A read that suspends without either policy fails with `CraftUnhandledSsrResolutionError`; Craft never silently skips the loader or waits forever. Reloading queries keep rendering their previous value and do not suspend. ## Structural identity and local recovery Hydration keys come from the component and template path, not from a global counter or random id. Static children use their logical position, blocks use a stable boundary segment, and `forNode` entries use the declared business key. Server and client must therefore execute the same template structure and use stable `forNode` keys. If a key is absent, a tag differs, text changed, or a dynamic branch no longer matches, Craft recreates that local subtree and keeps compatible siblings. In development it also reports a `HydrationMismatchError` containing the key, expected node, actual node, and reason. ## Transfer snapshot rules Only values composed of JSON primitives, plain objects, and arrays are transferred. Functions, `bigint`, class instances, and cycles fail explicitly. Unreadable or absent primitive values are omitted. Query entries include their status, resolved value when present, and a plain `{ name, message }` error when the runtime exposes one. `serializeCraftTransferSnapshot` escapes `<`, `>`, `&`, U+2028, and U+2029, so the JSON cannot close its `application/json` script element. Treat the snapshot as application data nevertheless: do not place secrets in state that reaches the browser. ## Current scope This first runtime release covers full-page hydration, deterministic HTML, CSS collection, state/query transfer, async boundary policies, keyed `forNode` recovery, and local mismatch remounts. Streaming, resumability, islands, cross-boundary event replay, compiler-generated renderers, and a direct server-function transport are later work. --- --- url: https://craft-ts.github.io/craft/guide/advanced/effect.md --- # Using Effect with CraftTS Effect belongs in CraftTS when the problem is a **domain program**: composing services, modelling typed failures, controlling resources, or running an operation that crosses an I/O boundary. CraftTS remains responsible for components, fine-grained rendering, reactive state and resource lifecycles. The integration is deliberately a boundary, not a second UI runtime: ```text Craft component / template ↓ Craft primitive or generator ↓ Effect ↓ Layer provided by the Craft injector ``` If a value is only local UI state, keep it in Craft. If it is a domain operation with typed errors or services, define it as an Effect and adapt it at the Craft boundary. ## The short decision | Need | Use | Why | | -------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------- | | Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state | | Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns | | Derive a reactive value from a synchronous Effect | `computedEffect` | runs a `SyncOp` Effect in place — a value, not a resource | | Expose a synchronous Effect as a callable method | `methodEffect` | the Effect counterpart of `craftMethod` | | Run a synchronous Effect in a lower-level position | `syncEffect` | `params`, a `craftMethod`, a `state` updater | | Guard a state-machine transition with an Effect | `transitionGuardEffect` | a declared-synchronous Effect service decision | | Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions | | Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process | | Provide Effect services | `provideLayer` | app and route injectors own Layer scope | | Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members | | Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions | | Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect | There is intentionally no `stateEffect`. A reactive value is not made better by being an Effect. Use `state` for the value, and use Effect for the computation that loads or changes it. ## Install the packages ```shell npm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta npm i effect@rc ``` Keep the three Craft packages on the same version. `@craft-ts/effect` declares `effect` as a peer dependency. ## Run Effect diagnostics For an Effect-enabled project, make the diagnostics command part of the normal feedback loop: ```shell npm run effect-check # or, from the repository root: node tools/run-effect-tsgo.mjs diagnostics \ --project apps/demo-effect/tsconfig.json \ --severity error,warning,message ``` The repository wrapper prints the resolved project scope, the TypeScript program file count and source candidates excluded by the project configuration, then asks EffectTS to print per-file progress. It refuses a zero-file program, which catches a wrong `--project`, an over-broad `exclude`, or an `include` that misses the intended source tree before the check can look green. Review warnings in two groups: the existing baseline that is accepted for the project, and warnings introduced by the current change. Keep the command's scope explicit in CI and link a diagnostic back to this page when handing the result to an agent. `--project=...` and `-p ...` are both supported. ## Install the bridge once The bridge teaches Craft's generator driver how to execute a yielded Effect. Install it at application bootstrap: ```typescript import { provideAppInitializer } from '@craft-ts/core'; import { installCraftEffectBridge } from '@craft-ts/effect'; export const appConfig = craftAppConfig({ providers: [ provideAppInitializer(() => { installCraftEffectBridge(); }), ], }); ``` In a test, install it in `beforeEach` and call the returned disposer in `afterEach`. Do not install a new bridge in every loader or component. ## Keep components in Craft A Craft component still has a generator factory and a typed template. The component should call a domain operation, not resolve its repository or start a fiber from a click handler: ```typescript import { button, craftComponent, p } from '@craft-ts/component'; import { queryEffect } from '@craft-ts/effect'; import { loadUserProfile } from './profile-domain'; export const Profile = craftComponent( 'Profile', {}, function* () { const profile = yield* queryEffect('profile', { params: () => 'user-ada', loader: ({ params }) => loadUserProfile(params), }); return { profile }; }, ({ profile }) => [ p(function* () { const user = yield* profile.value(); return user?.name ?? 'Loading…'; }), button( 'reload', { *click() { yield* profile.reload(); }, }, 'Reload', ), ], ); ``` The template consumes Craft readers. It does not subscribe to an Effect, call `Effect.runPromise`, or convert a Promise into a signal manually. ## Define the domain in Effect Use Effect for domain contracts and implementations. Tagged errors are values in the `E` channel: ```typescript import { Context, Data, Effect, Layer } from 'effect'; export class UserNotFound extends Data.TaggedError('UserNotFound')<{ readonly userId: string; }> {} export type UserRepository = { readonly byId: (userId: string) => Effect.Effect; }; export class UserRepositoryService extends Context.Service< UserRepositoryService, UserRepository >()('app/UserRepository') {} export const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({ byId: (userId) => findUserInDatabase(userId), })); export const loadUserProfile = Effect.fnUntraced(function* (userId: string) { const repository = yield* UserRepositoryService; return yield* repository.byId(userId); }); ``` The resulting program carries its success value, its typed failures and its requirements. A Craft component only needs `loadUserProfile`; it does not need to know which Layer implements `UserRepositoryService`. ## Choose the right adapter ### `queryEffect`: Effect-backed reads ```typescript const users = yield * queryEffect('users', { params: () => ({ filter: search() }), loader: ({ params }) => listUsers(params), }); ``` Use it when the result is server or domain state. Craft owns `status`, loading, previous value, cancellation and reloading. The loader returns `Effect`. The `params` factory and `method` are synchronous. They may read Craft dependencies, but must not create an Effect or read an Effect service. The loader is the only Effect-aware callback: ```typescript const users = yield * queryEffect('users', { params: function* () { const input = yield* searchInput(); return resolveSearchParams(input); }, loader: ({ params }) => listUsers(params), }); ``` The Effect ESLint rule enforces this boundary. A **declared-synchronous** Effect is allowed here through `syncEffect(...)`; for an input that has to suspend, use a `queryEffect` and feed its settled value to this one. ### `mutationEffect`: Effect-backed writes ```typescript const saveUser = yield * mutationEffect('saveUser', { method: (input: UserInput) => input, loader: ({ params }) => saveUserEffect(params), }); ``` Trigger it with `yield* saveUser.mutate(input)`. Use the normal Craft `insertReactOnMutation` insertion to reload a query or apply an optimistic patch. The mutation `method` only maps its arguments to synchronous params. The `loader` is the only Effect-aware callback. ### `asyncProcessEffect`: explicit commands ```typescript const exportUsers = yield * asyncProcessEffect('exportUsers', { method: (filter: Filter) => filter, loader: ({ params }) => exportUsersEffect(params), }); yield * exportUsers.method(currentFilter); ``` The `asyncProcessEffect` method follows the same rule: it returns plain params; the loader owns the asynchronous Effect program. Use it for an operation with a lifecycle but without a query cache or mutation relationship. ### `methodEffect`: synchronous callable methods Use `methodEffect` when a domain operation is a synchronous Effect and should be exposed as a callable method rather than as a resource: ```typescript const formatPrice = methodEffect('formatPrice', (cents: number) => Effect.gen(function* () { yield* SyncOp; return `${(cents / 100).toFixed(2)} €`; }), ); formatPrice(1499); // '14.99 €' ``` It is the Effect-aware convenience form of `craftMethod`. The `SyncOp` requirement is mandatory because the method returns immediately. For an Effect that can suspend, use `asyncProcessEffect`, `mutationEffect`, or `queryEffect`. ### `computedEffect`: a derived value, not a resource `computedEffect` runs a **synchronous** Effect in place and hands back a value. It is the adapter for a derivation — a formatted price, a validity flag — where `queryEffect` would wrap the answer in a resource with a loading state nothing can ever be in: ```typescript import { computedEffect } from '@craft-ts/effect'; const totalLabel = computedEffect('totalLabel', function* () { const lines = yield* cartLines(); return cartTotalLabel(lines); // returns the Effect, never runs it }); ``` The factory reads Craft dependencies with `yield*` and **returns** an Effect; the adapter runs it in place against the nearest `provideLayer(...)`. Read the result like any `craftComputed` — no `value`, no `isLoading`, no `pendingNode`. The Effect it returns must be declared synchronous — `Effect` — for the same reason `syncEffect` requires it: a computation is asked for its value now and cannot suspend to produce it, so one whose `R` does not carry `SyncOp` is refused at the call site. See [Run a synchronous member from a computed](#run-a-synchronous-member-from-a-computed) for what `SyncOp` is and the three mechanisms that check the claim. Use `syncEffect` instead when the synchronous Effect is not the whole derivation — inside a `craftMethod`, a `params`, or a `state` updater. ### `runEffect`: the low-level form Use `runEffect` when an Effect is yielded directly by a guard, resolver or Craft program and you need its typed errors to be visible to Craft: ```typescript import { runEffect } from '@craft-ts/effect'; const user = yield * runEffect(loadUserProfile(userId)); ``` The adapter is the right choice for most component resources. A bare `yield* someEffect` may execute at runtime, but it does not advertise the Effect's `E` channel to Craft's route-exception analysis. `runEffect` does. ## Provide services with `Layer` `provideLayer` attaches a built Effect context to a Craft injector: ```typescript export const appConfig = craftAppConfig({ providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))], }); ``` Use one merged Layer per injector level. A route can add a narrower Layer: ```typescript const routes = craftRoutes('app', [ { path: 'team', ...loadCraftComponent(() => import('./team'), [ provideLayer(TeamContextLive), ] as const), }, ]); ``` The parent context is reused and the child Layer is added for that route. Its Effect scope is closed with the route injector. For compile-time coverage, compare the program's `Effect.Services<...>` with the values provided by the app and route: ```typescript type Check = EffectRequirementsCheckedDI< Effect.Services, | AppProvidedEffectServices | ProvidedEffectServicesOfRoute >; type CanRunCheck = CanRun; ``` See [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for the full `AppProvidedDependencyValuesOf` setup. ## Understand the error mapping The bridge keeps Effect's distinctions intact: | Effect outcome | Craft outcome | Handle it with | | -------------------------- | ------------------------------------- | --------------------------------------- | | `Effect.succeed(value)` | resource value / generator result | normal rendering | | typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchNode`, `catchTag`, route handlers | | `Effect.die(defect)` | technical error | error boundary / monitoring | | interruption | cancellation | normally no user-facing handler | Use exhaustive matching for business errors: ```typescript matchNode.exhaustive(resource.exception, '_tag', { UserNotFound: () => p('No user was found.'), Unauthorized: () => p('Your session has expired.'), }); ``` The error union is only visible to the compiler when the Effect crosses through `queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`. ## Use Effect Schema at data boundaries `@craft-ts/core` accepts Standard Schema. Effect Schema participates through one conversion call: ```typescript import { Schema } from 'effect'; const UserInput = Schema.toStandardSchemaV1( Schema.Struct({ name: Schema.String, email: Schema.String, }), ); const saveUser = yield * mutationEffect('saveUser', { methodSchema: UserInput, method: (input) => input, loader: ({ params }) => saveUserEffect(params), }); ``` This schema interop does not require `@craft-ts/effect`; it follows the Standard Schema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema) for async decoding and loader result validation. ## Select an Effect service from Craft Most components should consume a domain operation. A Craft service or adapter that really needs an Effect service can select only the members it uses: ```typescript const { byId } = yield * effectService(UserRepositoryService, ({ byId }) => ({ byId })); ``` The selection narrows the graph and keeps generic member signatures intact. It does not replace `Layer`; the service still comes from the nearest `provideLayer(...)`. ## Run a synchronous member from a computed `params`, `craftComputed(...)` and `craftMethod(...)` run on Craft's synchronous driver: they complete on one tick and cannot wait. `Effect` does not say whether running an Effect will suspend, and a service member makes it worse — a `Layer` closes over its dependencies at construction, so a network call and a pure calculation both surface as `R = never`. Declare the difference in `R`, the one channel Effect accumulates: ```typescript export type CartPricingShape = { readonly fetchCatalog: (skus: readonly string[]) => Effect.Effect; readonly lineTotal: (line: CartLine) => Effect.Effect; }; ``` `SyncOp` is a phantom requirement: nothing provides it, it costs nothing at runtime, and `Effect` is assignable to `Effect` — so declaring it in the shape is enough, the implementation needs no ceremony. Where `R` is inferred (a standalone `Effect.gen` calling nothing already marked), add `yield* SyncOp` to the body. Run it with `syncEffect(...)`, which resolves in place instead of suspending: ```typescript const totalLabel = craftComputed('totalLabel', function* () { const cents = yield* syncEffect(cartTotal(yield* lines())); return yield* syncEffect(formatPrice(cents)); }); ``` Requirements other than `SyncOp` travel through untouched — the level in force satisfies them exactly as it does for a loader. The only thing checked at the type level is that `SyncOp` is among them. The declaration is a claim, and three independent mechanisms check it: the type refuses an undeclared Effect at the call; `craft-ts/sync-effect-body` reads the body, every branch at once, and rejects one that yields something async; and at runtime `syncEffect` goes through `Effect.runSyncExitWith`, which cannot suspend — a broken promise throws `CraftEffectNotSynchronous` at the first call rather than freezing the UI. Full walkthrough: [Declare a synchronous member](/learn-effect/03-effect-domain#declare-a-synchronous-member). ## Testing Use `mockEffectService` for a focused Layer: ```typescript const repository = mockEffectService(UserRepositoryService, { byId: () => Effect.succeed(expectedUser), }); ``` Combine it with Craft's register-based tests. The Effect mock covers the Effect service; the Craft register covers every Craft dependency and boundary. An unstubbed member fails with `UnstubbedEffectMember` instead of silently returning an incomplete value. See [testing with Effect](/learn-effect/08-testing) and [browser boundaries](/guide/testing/browser-boundaries). ## Package map | Package | Responsibility | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@craft-ts/component` | functional Craft components and typed templates | | `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry | | `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers | | `@craft-ts/i18n-effect` | the Effect adapter over an `@craft-ts/i18n` runtime: `provideI18nRuntime`, `translateEffect`, `I18nEffectService` — see [i18n with Effect](/guide/i18n/effect) | | `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime | | `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment | | `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks | Install only the packages needed by the layer you are building. For example, Effect Schema validation can be used with `@craft-ts/core` alone; the bridge and Effect-aware resource adapters require `@craft-ts/effect`. ## Server functions: current POC The current server-function integration is a **proof of concept**, not a final API. It currently combines: * `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`; * `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`; * `Effect`/`Layer` on the server; * a local HTTP transport and `@effect/platform-node` in the demo. The client must import only the server function's type, while the server owns the implementation and server-only Layers. Authentication and authorization must be checked again on the server; a client Layer is never a security boundary. See the [server functions POC chapter](/learn-effect/09-server-functions) and the [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function). Expect the transport, file conventions, middleware API and production integration to change before this becomes a stable feature. ## Common mistakes * **Putting every value in Effect:** keep local UI state in `state` and URL state in `queryParams`. * **Subscribing in a component:** return an Effect from a resource adapter and let Craft own loading and cancellation. * **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI can handle it exhaustively. * **Providing a Layer inside a loader:** provide it at app or route scope so its lifetime and requirements are visible. * **Trusting client context in a server function:** treat it as a claim and verify it on the server. * **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft sees the typed exception union. * **Declaring a member `SyncOp` to get it into a computed:** the marker states a fact, it does not create one. If the member can suspend, move the work to a loader — the runtime will refuse it anyway. ## See also * [Learn CraftTS with Effect](/learn-effect/) * [Which primitive should I use?](/guide/concepts/choose-primitive) * [Exceptions as values](/guide/concepts/exceptions) * [Program operators](/guide/advanced/program-operators) * [Effect Schema](/guide/state/schema-validation#effect-schema) * [Effect integration tests](/learn-effect/08-testing) --- --- url: https://craft-ts.github.io/craft/guide/advanced/program-operators.md --- # Program operators `.pipe(...)` composes, recovers and retries a [`craftGen`](/guide/concepts/generators) program with Effect-inspired operators — without leaving the generator model. **Use it when** a program should handle its own failures locally: retry a flaky call, recover from one specific exception code and carry on. **Not when** the failure should reach the route — let it propagate to [route exception handling](/guide/routing/exception-handling) instead. ## Import ```typescript import { catchTag, retry } from '@craft-ts/core'; import type { CraftProgramOperator, CraftRetryPolicy } from '@craft-ts/core'; ``` ## What it does Every `craftGen` invocation is a **program**: a `yield*`-composable generator that carries three typed channels: * **`A`** — the success value returned through `yield*` * **`E`** — the union of `craftException` codes it may short-circuit with (type-level only) * **dependencies** — the craft service yields relayed to the surrounding driver Invocations now expose `.pipe(...)`, which applies **program operators** left-to-right: ```typescript const report = yield * loadSlowReport().pipe( catchTag('REPORT_EMPTY', function* () { return { generatedAt: 'n/a', totalUsers: 0 }; }), retry({ times: 2, backoff: 'exponential', delayMs: 200 }), ); ``` Existing code is untouched: `yield* myProgram(args)` without `.pipe` works exactly as before. ## Why it matters Before operators, the **only** place a program's exception could be handled was the route boundary (`handleExceptions`). Every reachable code forced a route-level handler, even when the right answer was local ("fall back to an empty report", "retry the flaky call"). With `.pipe`: * `catchTag` recovers a code **where the fallback is known** — the code leaves `E`, so the route no longer requires a handler for it * `retry` re-executes the whole upstream chain on failure * everything stays typed: `E` shrinks/grows through each operator, and route exhaustiveness keeps checking the **remaining** codes ## `catchTag(code, handler)` Catches one exception code. The handler is a generator: its yields (craft services, nested programs, `craftUntilSettled`) are relayed to the driver, so its dependencies stay tracked. ```typescript const loadSlowReport = craftGen(function* () { const reportRef = yield* SlowReport(); const report = yield* craftUntilSettled(reportRef); return report.totalUsers === 0 ? craftException({ _tag: 'REPORT_EMPTY' }) : report; }); // In a route resolve: REPORT_EMPTY is recovered locally, so it is REMOVED from // the route's exception union — `handleExceptions` does not need (and must not // declare) a handler for it. resolve: craftResolve(function* () { return yield* loadSlowReport().pipe( catchTag('REPORT_EMPTY', function* () { return { generatedAt: 'n/a', totalUsers: 0 }; }), ); }), ``` Type effects: * `E' = E \ code` plus whatever the handler itself may produce * `A' = A | handler success value` * the handler receives the caught exception (`code` + `payload`) A handler can also **re-enter** the exception channel by returning a `craftException` (the new code is added to `E`): ```typescript catchTag('HTTP_TIMEOUT', function* (exception) { const flags = yield* FeatureFlags(); return flags.offlineMode() ? cachedFallback : craftException({ _tag: 'SERVICE_UNAVAILABLE' }); }); ``` ## `catchTag.exhaustive(handlerMap)` Catches **every** reachable code through a map that must cover the program's exception union exactly — a missing code or a handler for an unreachable code is a **compile error** at the `.pipe` application site. Afterwards `E = never`. ```typescript const user = yield * loadUser(userId).pipe( catchTag.exhaustive({ NOT_FOUND: function* () { return GUEST_USER; }, FORBIDDEN: function* () { const audit = yield* Audit(); audit.report('forbidden-user-access'); return GUEST_USER; }, }), ); // `user` can no longer fail: E = never ``` ```typescript // ⛔ compile error — missing handler for 'FORBIDDEN' loadUser(userId).pipe( catchTag.exhaustive({ NOT_FOUND: function* () { return GUEST_USER; }, }), ); // ⛔ compile error — 'TEAPOT' is not a reachable code loadUser(userId).pipe( catchTag.exhaustive({ NOT_FOUND: function* () { return GUEST_USER; }, FORBIDDEN: function* () { return GUEST_USER; }, TEAPOT: function* () { return GUEST_USER; }, }), ); ``` ::: tip Where the check happens The exception union is only known once the operator is applied to a program, so exhaustiveness is verified at the `.pipe` call site — not when `catchTag.exhaustive({...})` is built. The handler parameter is typed `AnyCraftException & { _tag }` (payload `unknown`): narrow the payload yourself if you need it. ::: ## `retry(policy)` Re-executes the program when it fails with a matched `craftException`, up to `times` extra attempts, then rethrows. Each attempt **replays the whole upstream `.pipe` chain** from the source invocation. ```typescript canActivate: function* () { return yield* slowAccessGuard().pipe( retry({ times: 2, backoff: 'linear', delayMs: 250 }), ); }, ``` ```typescript export type CraftRetryPolicy = { times: number; // max RE-executions after the initial attempt while?: string[]; // only retry these codes (all when omitted) backoff?: 'none' | 'linear' | 'exponential'; delayMs?: number; // 'none' → flat, 'linear' → delayMs * attempt, // 'exponential' → delayMs * 2^(attempt-1) }; ``` Notes: * `E` and `A` are unchanged — retry may exhaust and rethrow the same exception * a non-zero `delayMs` suspends between attempts (an internal await-request), so it needs an async driver: a route chain or a primitive loader. In a purely synchronous context the usual await-not-supported behaviour of that driver applies * only re-invocable programs can be retried (a `craftGen` invocation or a `.pipe` stage); passing a hand-built bare generator raises an explicit error on the first needed retry ## Programs inside `query` / `mutation` / `asyncProcess` loaders The three primitives drive their generator loaders with the same async pump as route guards, so loaders can suspend on `craftUntilSettled` and compose programs: ```typescript const { userQuery } = query('userQuery', { params: () => userId(), loader: function* ({ params }) { const api = yield* UserApi(); return yield* loadUser(params).pipe( retry({ times: 3, backoff: 'exponential', delayMs: 200 }), ); }, }); ``` An **uncaught** program exception does not crash the loader: it feeds the primitive's exception channel — ```typescript queryRef.status(); // 'exception' queryRef.hasException(); // true queryRef.exception()?._tag; // e.g. 'USER_NOT_FOUND' queryRef.exception()?.payload; // the exception payload ``` — and the loader's reachable program exceptions are folded into the primitive's typed `exception()` union. ## Custom operators An operator is just a function from one program generator to another. Type yours with `CraftProgramOperator`: ```typescript import type { CraftProgramOperator } from '@craft-ts/core'; // Measures the program duration; passes everything else through unchanged. const timed = (label: string): CraftProgramOperator => (program) => (function* () { const start = performance.now(); try { return yield* program; } finally { console.debug(`${label}: ${performance.now() - start}ms`); } })(); ``` Guidelines: * always consume the received program with `yield*` (a generator that is never driven does nothing) * relay foreign yields untouched so dependency tracking keeps working * let `CraftGenShortCircuit` propagate unless handling exceptions is the operator's purpose ## How it behaves * Operators are plain generator wrappers: no driver knows about them, and `E` travels only at the type level (the runtime signal is a thrown `CraftGenShortCircuit`) * `.pipe` folds left-to-right; each stage is itself a program, so operators after it can replay it (that is how `retry` after `catchTag` re-runs the recovery too) * `catchTag.exhaustive` rethrows a code outside its typed map (runtime safety net for exceptions that escaped the types) ## See Also * [`craftGen`](/guide/concepts/generators) — building programs * [`Route Guards`](/guide/routing/guards) — where guard programs run * [`Exception Handling`](/guide/concepts/exceptions) — the route-boundary counterpart (`handleExceptions`) * [`query`](/guide/state/server-state) — loaders as programs --- --- url: https://craft-ts.github.io/craft/guide/advanced/pattern-matching.md --- # Pattern matching `craftMatch` is type-safe pattern matching over a **bare literal union** (a `string` / `number` / `enum` union). It is the value-level counterpart of the exception-level [`catchTag` / `catchTag.exhaustive`](/guide/advanced/program-operators) pair: one call for a single case, a `.exhaustive` variant whose handler map is checked to cover the union **at compile time**. **Use it when** a literal union drives a decision and forgetting a case should be a build error — a status, a role, a mode. **Not for** a two-way boolean; a ternary is clearer. Use it wherever a `switch` would go — mapping a status to a label, an icon, a component input — but with a compiler that refuses to build when you add a member to the union and forget a branch. ## Why not a plain `switch`? A `switch` (or an object lookup) over a union has three recurring problems: ```ts type Status = 'active' | 'idle' | 'error'; function label(status: Status) { switch (status) { case 'active': return 'Running'; case 'idle': return 'Waiting'; // 'error' forgotten → no error, `label` silently returns undefined } } ``` | Concern | `switch` / object lookup | `craftMatch.exhaustive` | | --------------------------- | ----------------------------------------- | --------------------------------- | | Missing a union member | silent `undefined` at runtime | **compile error** | | Handler for a non-member | silent dead code | **compile error** | | Value passed to each branch | widened to the whole union | narrowed to its own literal | | Return type | union incl. `undefined` unless you assert | exact union of the branch returns | ## Signature ```ts // Single case — optional match craftMatch( value: Value, matchCase: Case, handler: (value: Case) => R, ): R | undefined; // Exhaustive — every union member needs a handler craftMatch.exhaustive( value: Value, handlers: { [K in Value]: (value: K) => R }, ): R; ``` Exhaustiveness is enforced natively by the mapped handler type `{ [K in Value]: (value: K) => R }` — the union members **are** the required keys, so a missing key or a key outside the union is a plain type error, and each handler receives its own narrowed literal. ## Single case Runs the handler only when `value` equals `matchCase`, otherwise returns `undefined`: ```ts import { craftMatch } from '@craft-ts/core'; const status = 'active' as Status; const spinner = craftMatch(status, 'active', () => '⏳'); // '⏳' const nope = craftMatch(status, 'error', () => '💥'); // undefined ``` The handler's argument is narrowed to the matched literal (`'active'` above), and the result is typed `R | undefined`. ## Exhaustive match The handler map must cover **exactly** the union — no more, no less: ```ts import { craftMatch } from '@craft-ts/core'; const label = (status: Status) => craftMatch.exhaustive(status, { active: () => 'Running', idle: () => 'Waiting', error: () => 'Failed', }); ``` Add `'pending'` to `Status` and every `craftMatch.exhaustive` over it stops compiling until you add the branch — the exhaustiveness wall you would otherwise hand-roll with a `never` assertion in a `switch`'s `default`. Each handler is narrowed to its own literal, and the result is the union of the branch return types: ```ts const view = craftMatch.exhaustive(status, { active: () => ({ color: 'green', text: 'Running' }), idle: () => ({ color: 'gray', text: 'Waiting' }), error: () => ({ color: 'red', text: 'Failed' }), }); // view: { color: string; text: string } ``` ### The compile errors it catches ```ts // ❌ missing a member — the union is not fully covered craftMatch.exhaustive(status, { active: () => 'Running', idle: () => 'Waiting', }); // Type error: property 'error' is missing // ❌ a handler for something outside the union craftMatch.exhaustive(status, { active: () => 'Running', idle: () => 'Waiting', error: () => 'Failed', unknown: () => 'nope', // Type error: 'unknown' is not in Status }); ``` ## With enums A TypeScript `enum` compiles to a `string | number` union, so it works unchanged: ```ts enum Tab { Overview = 'overview', Billing = 'billing', Team = 'team', } const title = (tab: Tab) => craftMatch.exhaustive(tab, { [Tab.Overview]: () => 'Overview', [Tab.Billing]: () => 'Billing', [Tab.Team]: () => 'Team', }); ``` ## Scope & limits * **Bare literal unions only.** `craftMatch` matches on the value itself, not on a discriminant field — it does not (yet) take a union of objects keyed by a `type` / `kind` field. Map the discriminant to a literal first if you need that: `craftMatch.exhaustive(shape.kind, { … })`. * **Pure and synchronous.** Unlike [`catchTag`](/guide/advanced/program-operators), `craftMatch` is not a craft program: it does not `yield*` and does not track dependencies. To run a different craft program per branch, `yield*` inside the handler bodies of a normal generator instead. * **No catch-all.** There is the exhaustive form (compile wall) or the single-case form (optional `undefined`) — there is no `.otherwise(fallback)`. ## API | Export | Purpose | | ---------------------------------------- | --------------------------------------------------------------------------------- | | `craftMatch(value, case, handler)` | Match a single literal; returns `R \| undefined`. | | `craftMatch.exhaustive(value, handlers)` | Match every member; compile-time exhaustive; returns the union of branch results. | | `CraftMatchHandlers` | The `{ [K in Value]: (value: K) => R }` handler-map type. | ## See Also * [Program operators](/guide/advanced/program-operators) — the exception-level counterpart * [Exceptions as values](/guide/concepts/exceptions) --- --- url: https://craft-ts.github.io/craft/guide/advanced/observability.md --- # Observability Because every dependency is resolved through one system, that system is also the place to cross-cut them all — logging, timing, correlation ids, snapshots — with no change to the business code. **Use it when** you need to see what your app is doing in production, or to connect craft to your monitoring stack. **Start with `Console`**: it is yieldable, so overriding it once redirects every log in the app. The same DI system that powers `craftService` also lets you cross-cut every crafted function with side effects — logging, snapshots, correlation tracking, timing, error reporting — without touching the business code. ## Mental Model `craft-ts` distinguishes two kinds of failures: * **Expected errors**: handled explicitly with [`craftException`](/guide/app/craft-service) in your business code. * **Unexpected errors**: bugs. They should never happen — and if they do, they should never happen *again*. Unexpected errors are exactly where observability shines. Since they are supposed to be impossible, you want to capture the maximum amount of context the moment one is thrown: stack, app state, correlation chain, etc. That context can then be shipped to a log server, an alerting pipeline, or directly to an AI webhook for triage. The three pillars `craft-ts` exposes for that are: * [`provideFnWrapper`](#providefnwrapper) — wrap every crafted function with cross-cutting behavior * [`provideTemplateTrace`](#providetemplatetrace) — observe effective component and template renders * [`provideCraftRouterTrace`](#providecraftroutertrace) — observe navigation events and Craft route stages * [`provideCraftHttpTrace`](#providecrafthttptrace) — wrap every `CraftHttpClient` request * [`provideTakeAppSnapshot`](#providetakeappsnapshot) — capture all active state when something goes wrong * [`provideCraftDomEventHook`](#craft-dom-event-hooks) — observe or wrap every DOM action declared in a Craft template * [`provideCorrelationIdTracking`](#providecorrelationidtracking) — link a user gesture to every async operation it triggered ## `provideFnWrapper` `provideFnWrapper` lets you wrap **every** generator-based function executed by `craft-ts` (services, methods, async processes, queries, mutations, effects…). It is the single best entry point to add cross-cutting side effects. Basic use case — log any unexpected error to the console: ```ts import { craftAppConfig, provideFnWrapper, Console } from '@craft-ts/core'; export const appConfig = craftAppConfig({ // ... providers: [ provideFnWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (factory, thisArg, args) { try { return yield* factory.apply(thisArg, args); } catch (error) { yield* Console.error(error); throw error; } }, ), ], }); ``` You can register multiple wrappers — they compose. The first registered is the outermost. ## `provideTemplateTrace` `provideTemplateTrace` is the render-specific counterpart to `provideFnWrapper`. It runs synchronously around the children produced by an effective render, including component templates, reactive updates, blocks, projections, deferred branches, and nested callbacks. ```ts import { provideTemplateTrace } from '@craft-ts/core'; provideTemplateTrace((context, next) => { const start = performance.now(); try { return next(); } finally { console.debug( context.phase, context.componentName, performance.now() - start, ); } }); ``` The context contains the render unit (`component`, `block`, `projection`, `deferNode`, or `callback`), its phase (`create`, `initialRender`, `update`, or `destroy`), the optional component/unit names, and the owning component's `renderCount`. Wrappers compose in registration order and execute in the current render injector, so component-scoped providers remain injectable. The wrapper can return different children or return an empty children value without calling `next()` to replace or block a render. Errors propagate to the normal Craft render error boundary. ## `provideCraftRouterTrace` `provideCraftRouterTrace` traces both the Router event stream and the Craft outlet's non-blocking route chain. The latter exposes `match`, `guard`, and `resolve` stages, including reactive guard re-evaluation. ```ts import { provideCraftRouterTrace } from '@craft-ts/core'; provideCraftRouterTrace((context, next) => { console.log('[router:start]', context); const result = next(); console.log('[router:end]', context); return result; }); ``` Multiple wrappers compose in registration order. The wrapper must call `next()` to preserve the navigation or route-chain work. ## `provideCraftHttpTrace` `provideCraftHttpTrace` wraps the actual thenable request produced by `CraftHttpClient`, after its method, URL, params, and payload have been built. It is therefore useful for timing, request logging, redaction, and error reporting without changing feature code. ```ts import { provideCraftHttpTrace } from '@craft-ts/core'; provideCraftHttpTrace(async (context, next) => { const start = performance.now(); try { return await next(); } finally { console.log(context.method, context.url, performance.now() - start); } }); ``` ### Important: injection inside `provideFnWrapper` is not type-safe The wrapper body runs in **the injection context where the error was raised**, not where the wrapper was declared. That makes it extremely practical: you can yield browser boundaries, inject host-tagged metadata, read the offending service's correlation id, etc. But it has two consequences: * injections inside the wrapper are **not type-safe** — `craft-ts` cannot prove statically that the dependency you ask for is actually provided where the wrapper runs * the wrapper is therefore a **risky** place to do business work ::: tip Use `provideFnWrapper` mostly for **side effects** — logging, metrics, snapshots, correlation propagation. Avoid pulling business state through it. ::: When the wrapped function is an insertion method, the wrapper can inject the matching runtime context — `injectQueryMethodRuntimeContext()`, `injectStateMethodRuntimeContext()`, and the siblings for `mutation`, `queryParams`, and `asyncProcess` — and call `get` / `set` / `update` / `patch` on the owning primitive. That is how registries, WebMCP tools, and other advanced patterns seed or replace a query result, a mutation value, a `state`, and so on. See [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). ### Example: timing every craft function ```ts import { craftAppConfig, provideFnWrapper, HostTag } from '@craft-ts/core'; provideFnWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (factory, thisArg, args) { const start = performance.now(); try { return yield* factory.apply(thisArg, args); } finally { const name = yield* HostTag(); console.log(`${name} took ${performance.now() - start}ms`); } }, ); ``` ## `provideTakeAppSnapshot` `provideTakeAppSnapshot` captures the list of all **active states** in the app the moment an unexpected error occurs. This is one of the most valuable pieces of context you can ship to a log server or AI webhook: you get not just the stack, but the full picture of what the app was holding when it broke. ```ts import { craftAppConfig, provideTakeAppSnapshot } from '@craft-ts/core'; export const appConfig = craftAppConfig({ // ... providers: [ provideTakeAppSnapshot((reports) => { // reports: SnapshotReport[] // — one entry per active state, with its source, ancestry, and current value console.warn('App snapshot:', reports); // In production you would forward this to a log server or AI webhook: // fetch('/api/incident', { method: 'POST', body: JSON.stringify({ reports }) }); }), ], }); ``` Each `SnapshotReport` contains: * `source` — the source tag of the state * `from` — the ancestry chain that produced it * `state` — the actual current value Under the hood, `provideTakeAppSnapshot` registers its own `provideFnWrapper` that triggers the snapshot collection whenever an unexpected error bubbles up. CraftTS control-flow throws such as `CraftGenShortCircuit` and `CraftNotSettled` are deliberately excluded: they are consumed by `catchNode` and `pendingNode` boundaries during normal rendering. An unhandled boundary error remains observable and still triggers a snapshot. You do not need to call it manually. ## Craft DOM event hooks Every DOM event bound from a Craft template goes through the `CRAFT_DOM_EVENT_HOOK` token. Hooks run in the injector of the component that declared the element, and compose in registration order. A hook must call `next()` to preserve the component action. ```ts import { craftComponent, button } from '@craft-ts/component'; import { provideCraftDomEventHook } from '@craft-ts/core'; const save = () => console.debug('saved'); export const SavePanel = craftComponent( 'SavePanel', { providers: [ provideCraftDomEventHook((interaction, next) => { console.debug(interaction.interactionName, interaction.event); return next(); }), ], }, () => ({}), () => button('save', { type: 'button', click: save }, 'Save'), ); ``` The hook receives the native event, its normalized name, the element, the component name, and a descriptive `interactionName` such as `SavePanel:button:save:click`. This is the extension point for analytics, authorization, tracing, or correlation IDs. A hook can also stop an action by not calling `next()`. ## `provideCorrelationIdTracking` `provideCorrelationIdTracking` ties every async operation back to the **user gesture** that triggered it. When a Craft template action runs, a fresh correlation id is generated from its location (`SavePanel:button:save:click:uuid`, for example). Navigation back and forward still generate `nav-back:uuid` and `nav-forward:uuid`. Every generator invoked downstream — directly or transitively, sync or async — captures that id at invocation time. ```ts import { craftAppConfig, provideCorrelationIdTracking } from '@craft-ts/core'; export const appConfig = craftAppConfig({ // ... providers: [provideCorrelationIdTracking()], }); ``` Once enabled, the correlation id is attached to the metadata of browser boundaries like `Console`, so a single `yield* Console.error(...)` carries: * `startCorrelationId` — the id captured when the current generator was invoked * `lastCorrelationId` — the most recent id observed in the app * `mayCorrelatedIds` — the chain of ids the operation can be linked to This lets you reconstruct, from logs alone, the full causal chain between *"user clicked Save"* and *"the third sub-request returned 500 four seconds later"*. Combined with `provideTakeAppSnapshot`, you get on every unexpected error: * the stack * the snapshot of all active states * the correlation id chain back to the originating user gesture ## Putting It All Together Wire all three in your `appConfig`: ```ts import { craftAppConfig, Console, provideFnWrapper, provideTakeAppSnapshot, provideCorrelationIdTracking, } from '@craft-ts/core'; export const appConfig = craftAppConfig({ // ... providers: [ provideFnWrapper( 'Warning: dependency injection here is not type-safe and may fail at runtime', function* (factory, thisArg, args) { try { return yield* factory.apply(thisArg, args); } catch (error) { yield* Console.error(error); throw error; } }, ), provideCorrelationIdTracking(), provideTakeAppSnapshot((reports) => { // forward to your log server or AI webhook console.warn('App snapshot:', reports); }), ], }); ``` You now have, on any unexpected error: a console error in dev, a full app snapshot, and the correlation chain back to the originating user action — all without a single line of instrumentation inside your business code. ## See Also * [`craftService`](/guide/app/craft-service) * [`Browser Boundaries`](/guide/testing/browser-boundaries) — `Console`, `LocalStorage`, etc., used inside wrappers --- --- url: https://craft-ts.github.io/craft/guide/advanced/temporal-runtime.md --- # Temporal runtime Craft treats time as a runtime capability rather than as a direct call to the browser or Node timer APIs. This gives asynchronous programs one temporal seam that can be replaced in tests, inspected during diagnostics, and cleaned up with the lifetime that created it. **Use it when** a Craft program needs a delay, timeout, retry backoff, polling or another cancellable time-based operation. **Do not use it** for civil dates such as timestamps stored in a database: those are dates, not elapsed-time measurements. ## Import ```typescript import { CRAFT_TEMPORAL_RUNTIME, craftSleep, exponentialTemporalSchedule, fixedTemporalSchedule, provideCraftTemporalRuntime, VirtualCraftTemporalRuntime, withCraftTimeout, } from '@craft-ts/core'; ``` ## The temporal model The runtime separates three responsibilities: * **clock** — reads monotonic time for durations and civil time for dates; * **task** — schedules one cancellable callback or sleep operation; * **schedule** — decides whether an operation continues and how long the next wait should be. ```text Craft program ├── waits → craftSleep(...) ├── times out → withCraftTimeout(...) └── retries → a temporal schedule │ ▼ CRAFT_TEMPORAL_RUNTIME ├── RealCraftTemporalRuntime └── VirtualCraftTemporalRuntime ``` `setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` are runtime implementation details. A polling loop should normally be expressed as a sequence of operations and a schedule so it can stop when its owner is destroyed. ## Waiting in a Craft program `craftSleep` is a yieldable delay. It does not create a native timer when the generator is created. The asynchronous Craft driver receives the request and delegates it to the configured temporal runtime. ```typescript import { craftGen, craftSleep } from '@craft-ts/core'; const refreshAfterDelay = craftGen(function* () { yield* craftSleep(500, { owner: 'refresh' }); return 'refresh now'; }); ``` The delay can be used in route guards and in asynchronous primitive loaders, which already use the asynchronous program driver: ```typescript const data = query('data', { loader: function* () { yield* craftSleep(100); return loadData(); }, }); ``` When a query is retriggered, its loader abort signal is propagated to pending temporal awaits. A stale `craftSleep` is therefore cancelled and its generator does not resume. The resource still protects the latest result if an operation has already passed its sleep or does not observe the signal. Mutation loaders intentionally keep their already-started temporal operation valid when a new mutation is triggered. The previous resource result can still be ignored as stale, but the mutation's generator is not interrupted. Synchronous drivers such as `craftUse` cannot suspend on `craftSleep`. They fail with an explicit async-driver error instead of silently creating an untracked Promise. ## Replacing the runtime in tests `VirtualCraftTemporalRuntime` never waits for wall-clock time. It starts at zero by default, orders equal deadlines by creation order, and exposes the pending tasks for assertions. ```typescript import { ɵInjector as Injector } from '@craft-ts/core'; import { executeGeneratorCompatibleFactoryAsync, provideCraftTemporalRuntime, VirtualCraftTemporalRuntime, } from '@craft-ts/core'; const clock = new VirtualCraftTemporalRuntime(); const injector = Injector.create({ providers: [provideCraftTemporalRuntime(clock)], }); const result = executeGeneratorCompatibleFactoryAsync({ factory: function* () { yield* craftSleep(100); return 'done'; }, thisArg: undefined, getInjector: () => injector, args: [], invalidYieldErrorMessage: 'Invalid Craft yield.', }); await clock.advanceBy(99); // The program is still suspended. await clock.advanceBy(1); await expect(result).resolves.toMatchObject({ kind: 'done', value: 'done', }); ``` The main test operations are: ```typescript await clock.advanceBy(250); // move time forward await clock.advanceTo(1_000); // move to an exact time await clock.advanceToNextTask(); // execute the nearest task await clock.runUntilIdle(); // execute until no task remains clock.pendingTasks(); // inspect all pending tasks clock.pendingTasks('refresh'); // inspect one owner clock.reset(); // cancel tasks and restore the clock ``` Tasks with the same deadline run in creation order. A task created while an expired task is running is then considered at the same virtual time and is also executed before the clock advances beyond that deadline. ## Timeouts `withCraftTimeout` races an operation against the configured runtime. If the operation wins, the timeout task is cancelled. If the deadline wins, the Promise rejects with `CraftTimeoutError`. ```typescript const response = await withCraftTimeout( fetch('/api/report').then((response) => response.json()), 5_000, { owner: 'report-loader' }, ); ``` The timeout controls the Craft operation's result. It does not automatically cancel an external HTTP request. Pass an `AbortSignal` to the underlying API when the resource itself must be interrupted as well. ## Schedules A schedule is a pure policy. It does not own a timer and it does not run an interval. It receives the next attempt number and returns either a delay or a stop decision. ```typescript const backoff = exponentialTemporalSchedule(100, { factor: 2, maxAttempts: 4, maxDelayMs: 2_000, }); backoff.next({ attempt: 1, elapsedMs: 0 }); // { done: false, delayMs: 100 } backoff.next({ attempt: 2, elapsedMs: 100 }); // { done: false, delayMs: 200 } ``` Available policies include: ```typescript fixedTemporalSchedule(500, { maxAttempts: 3 }); exponentialTemporalSchedule(100, { factor: 2 }); sequenceTemporalSchedule([100, 250, 1_000]); ``` `retry` uses the temporal runtime for non-zero backoff delays and accepts a custom schedule when the built-in policies are not enough: ```typescript const user = yield * loadUser().pipe( retry({ times: 3, schedule: exponentialTemporalSchedule(200, { factor: 2, maxDelayMs: 2_000, }), }), ); ``` Prefer a schedule over `setInterval` for polling. The operation completes one step, the schedule decides whether another step is needed, and the next task is created only after the current step has finished. This avoids accidental overlap and makes destruction cancellable. ## Ownership and cleanup Every task can carry an `owner` label for inspection. Runtime integrations that have a `DestroyRef` attach the task to that lifetime: ```typescript const task = runtime.schedule(refresh, 1_000, { kind: 'polling', owner: 'user-list', destroyRef, }); task.cancel(); // idempotent; returns whether cancellation happened ``` Destroying the owner cancels its pending tasks. A suspended `craftSleep` is rejected with `TemporalCancelledError`, so a destroyed program cannot resume and mutate state after its lifetime has ended. ## Choosing the right abstraction | Need | Use | | -------------------------------- | ------------------------------------------- | | Wait once inside a generator | `craftSleep` | | Bound an operation by a deadline | `withCraftTimeout` | | Retry after an error | `retry` with a schedule | | Repeat work without overlap | one operation plus a schedule | | Store a timestamp | a civil date value, not the monotonic clock | | Test time-dependent behavior | `VirtualCraftTemporalRuntime` | ## What not to do Avoid timer Promises created directly inside Craft programs: ```typescript // Avoid yield * new Promise((resolve) => setTimeout(resolve, 500)); ``` Use the temporal request instead: ```typescript // Prefer yield * craftSleep(500); ``` Direct timer globals are reported by the `no-direct-temporal-globals` dev-tools rule. The temporal runtime implementation itself is the explicit exception. ## Limitations * Browser background-tab throttling is not simulated by the virtual runtime. * Microtasks and macrotasks remain distinct; advancing virtual time flushes the microtasks caused by the tasks it executes. * RxJS schedulers are not automatically replaced by the Craft runtime. * A timeout does not cancel an external resource unless that resource accepts and observes an abort signal. * Timers created by third-party APIs remain outside Craft ownership. ## See also * [`retry`](/guide/advanced/program-operators#retrypolicy) * [`asyncProcess`](/guide/state/async-process) * [Testing services](/guide/testing/services) * [Generators and `yield*`](/guide/concepts/generators) --- --- url: https://craft-ts.github.io/craft/reference.md --- # API index Every documented export, with the page that covers it. Use Ctrl/⌘+F. For an explanation rather than a lookup, start from the [Guide](/guide/). Coding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and [coding agents](/resources/ai-agents). ## Primitives | Symbol | What it does | Page | | ------------------- | -------------------------------------------------------- | --------------------------------------------- | | `state` | Signal-based state you own | [Local state](/guide/state/local-state) | | `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) | | `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) | | `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) | | `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) | | `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) | | `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) | Not sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive) ## Runtime context Typed helpers that recover `get` / `set` / `update` / `patch` from DI, for wrappers, WebMCP tools, and other advanced patterns. Everyday insertions already receive those methods as arguments — see [Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context). | Symbol | What it does | Page | | ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | | `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) | ## Composition | Symbol | What it does | Page | | ------------------------ | ----------------------------------------------- | -------------------------------------------------------- | | `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) | | `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) | | `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) | | `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) | | `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) | | `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) | | `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) | ## Insertions | Symbol | What it does | Page | | --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | | `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) | | `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) | | `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) | | `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) | | `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) | ## Forms | Symbol | What it does | Page | | --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- | | `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) | | `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) | | `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) | | `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) | | `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) | | `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) | | `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) | | `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) | | `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) | | `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) | ## Services and DI | Symbol | What it does | Page | | --------------------------- | ------------------------------------------ | ------------------------------------------------- | | `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) | | `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) | | `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) | | `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) | | `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) | | `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) | | `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) | | `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) | | `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) | | `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) | | `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) | ## Routing | Symbol | What it does | Page | | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | | `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) | | `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) | | `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) | | `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) | | `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) | | `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) | | `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) | | `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) | | `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) | | `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) | | `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) | ## Server rendering | Symbol | What it does | Page | | ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- | | `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) | | `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) | | `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) | | `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) | | `CraftSsrPolicy`, `provideCraftSsrPolicy` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) | | `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) | ## Exceptions | Symbol | What it does | Page | | ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- | | `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) | | `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) | | `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) | | `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) | ## Reactivity | Symbol | What it does | Page | | -------------------- | ---------------------------------- | ------------------------------------------------------------ | | `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) | | `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) | | `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) | | `source$` | An imperative event source | [source$](/guide/reactivity/source) | | `on$` | Binds a method to a source | [on$](/guide/reactivity/on) | | `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) | | `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) | | `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) | ## HTTP and boundaries | Symbol | What it does | Page | | ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- | | `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) | | `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) | | `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) | | `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) | | `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) | ## Testing | Symbol | What it does | Page | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- | | `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) | | `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) | | `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) | | `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) | | `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) | | `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) | | `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) | ## Effect integration `@craft-ts/effect`, in full. The guide is [Effect integration](/guide/advanced/effect). | Symbol | What it does | Page | | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) | | `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) | | `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) | | `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) | | `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) | | `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) | | `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) | | `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) | | `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) | | `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) | ### Lower-level exports Public, but rarely needed directly. They exist for wrappers, generated code and tooling rather than for application code. | Symbol | What it is | | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. | | `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. | | `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. | | `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. | | `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect` instead of a raw generator type. | | `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. | ## Typed styles `@craft-ts/style` is a **build step**: none of these symbols emit anything without `craftStyle` from `@craft-ts/style/vite` in the Vite config. See [Activating the style system](/guide/style/setup). | Symbol | What it does | Page | | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) | | `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) | | `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) | | `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) | | `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) | | `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) | | `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) | | `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) | | `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) | | `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) | | `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) | | `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) | | `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) | | `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) | ## Internationalisation `@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain TypeScript value, and a token may resolve a Craft service or parse its parameter with a Standard Schema. The package imports core for types only. | Symbol | What it does | Page | | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- | | `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) | | `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) | | `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) | | `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) | | `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) | | `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) | | `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) | | `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) | | `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) | | `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) | | `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) | | `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) | | `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) | ## Tooling | Command / rule | What it does | Page | | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) | | `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) | | `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) | | `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) | | `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) | | `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) | | `npx nx architecture ` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) | | Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) | | Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) | ## Deployment ::: warning Experimental The deployment tooling is not settled: these symbols and commands can still change between minor versions. See the [deployment guide](/guide/deployment/) for what exists today. ::: | Symbol / command | What it does | Page | | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ | | `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) | | `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) | | `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) | | `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) | | `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) | | `npx craft-ts manifest` | Writes `dist//craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) | | `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) | | `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) | | `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) | | `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) | | `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) | --- --- url: https://craft-ts.github.io/craft/resources/examples.md --- # Examples Every example below is a real route of one of the demo applications. Each opens in StackBlitz on the relevant file, already navigated to the page. The demo groups them the way you would meet them: **components** first, then the **primitives** on their own, then the same features **behind services**, then **routing** and the rest. ::: tip Just want to poke at something? The [Playground](https://stackblitz.com/fork/github/craft-ts/craft-ts-demo/tree/main?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts\&initialpath=%2Fplayground) is a shareable sandbox with a small todo flow — the fastest way to try an idea. ::: ## Components Functional, selectorless components rendered from typed hyperscript. | Example | What it shows | | --- | --- | | [Functional Components](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-demo.ts\&initialpath=/) | `craftComponent`, inputs and outputs as factory parameters, hyperscript templates | | [Reactive Composition](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-composition-demo.ts\&initialpath=/component-composition) | Composing components and directives with `.pipe(...)` | | [Content Projection](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/content-projection-demo.ts\&initialpath=/content-projection) | Free DOM content, typed DOM contracts, and logical projection by contract | | [Pending Block](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-node-demo.ts\&initialpath=/pending-node) | Type-safe async suspension with `settledValue`, `settled(...)` and `pendingNode` | | [Pending Block — Exception](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-node-exception-demo.ts\&initialpath=/pending-node/exception) | Coordinating pending, reloading and business-exception fallbacks with `pendingNode` and `catchNode` | ## Primitives Using `state`, `query`, `mutation`, `queryParams` and `asyncProcess` directly, with no service layer. | Example | What it shows | | --- | --- | | [Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/query/query.ts\&initialpath=/query/1) | `query()` with reactive params, status and caching | | [Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/mutation/mutation.ts\&initialpath=/mutation/1) | `mutation()` with manual control of modification operations | | [List with Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/list-with-pagination/list-with-pagination.ts\&initialpath=/list-with-pagination) | Pagination with hand-managed query params and page state | | [Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/granular-mutation/granular-mutation.ts\&initialpath=/granular-mutation) | Optimistic updates and cache invalidation, done by hand | | [Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/full-demo/full-demo.ts\&initialpath=/full-demo) | Everything at once, without store or service abstractions | | [Login Form](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/forms/login-form.ts\&initialpath=/login-form) | `insertForm`, validators, and a typed submit wired to a mutation | | [Pixel Art](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art/pixel-art.ts\&initialpath=/pixel-art) | `state` + `insertSelect` over a flat array | | [Pixel Art Matrix](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts\&initialpath=/pixel-art-matrix) | Nested `insertSelect` and internal `source$` between rows and cells | | [Exceptions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exceptions.ts\&initialpath=/exceptions) | Business exceptions on `query()`, rendered per code with `matchNode.exhaustive` | | [Exception QueryParams](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exception-query-params.ts\&initialpath=/exception-query-params) | `queryParams` decode failures through `hasException()` and `exceptions().parse` | ## State machines State machines for explicit transitions, history and collection-oriented UI. | Example | What it shows | | --- | --- | | [Profile editor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine/profile-editor.ts\&initialpath=/state-machine) | `craftStateMachine`, typed transitions and persisted history | | [Text editor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine/text-editor.ts\&initialpath=/state-machine-text) | A compact state machine for editing, validation and transitions | | [Task board](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/state-machine-list/task-board.ts\&initialpath=/state-machine-list) | A state machine per list item with history and reactive collection updates | ## Services The same features, packaged behind `craftService`. | Example | What it shows | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | [Craft Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/query/query.ts\&initialpath=/craft/query/1) | A reusable query service with configured storage persistence (localStorage by default) | | [Craft Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/mutation/mutation.ts\&initialpath=/craft/mutation/1) | Create / update / delete with reactive cache synchronisation | | [Craft List Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/list-with-pagination/list-with-pagination.ts\&initialpath=/craft/list-with-pagination) | `queryParams` + `insertPaginationPlaceholderData` in a service | | [Craft Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/granular-mutation/granular-mutation.ts\&initialpath=/craft/granular-mutation) | `insertReactOnMutation` updating cached data without a reload | | [Craft Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/full-demo/full-demo.ts\&initialpath=/craft/full-demo) | Queries, mutations, async work, URL state and persistence together | | [craftService Counter](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-counter.ts\&initialpath=/craft-service/counter) | The smallest possible service — scopes and composition | | [craftService User Detail](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-user-detail.ts\&initialpath=/craft-service/user-detail) | Service inputs, and exposing only part of a dependency | | [craftRegisterFor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/register-for.ts\&initialpath=/craft-service/register-for) | A parent driving live children through a typed registry | ## Effect Concrete EffectTS integration examples, using the dedicated Effect demo. | Example | What it shows | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | [Profile Lookup](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-profile-lookup.ts\&initialpath=/) | `queryEffect`, typed business errors, and pending / exception rendering | | [Access Check](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-access-check-shared-service.ts\&initialpath=/access) | An Effect service provided by the application Layer | | [Team Overview](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-team-overview-layer-scope.ts\&initialpath=/team) | Combining application-wide and route-scoped Effect Layers | | [Effect Playground](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/examples/effect/effect-playground.ts\&initialpath=/playground) | A shareable todo sandbox with `queryEffect`, `mutationEffect`, and a route-provided Effect service | | [Translate in an Effect](https://stackblitz.com/github/craft-ts/craft-demo-effect/tree/main/?file=src/app/shared/i18n-domain.ts\&initialpath=/i18n) | `provideI18nRuntime` as a route Layer, `translateEffect` inside a plain Effect program, and the locale as Craft state driving the query params | ## Design system The typed style system, at all three of its levels. Both routes read from the same sheets under `src/app/examples/design-system/`, which has a README walking through the same progression in code. | Example | What it shows | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Mini Design System](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/design-system/foundation.style.ts\&initialpath=/design-system) | `definePalette`, `defineStateAxis`, `cssVars` and the theme: one dark-mode rule for the whole system, and variants as `data-*` attributes rather than class strings | | [Scroll context](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/design-system/scroll.style.ts\&initialpath=/design-system/scroll) | Level 3: `requires(scrollPort.block)` travelling up the tree, `provides(...)` on the layout that owns the area, and the `scrollState` axis | Start from [Activating the style system](/guide/style/setup) — the sheets emit nothing without the Vite plugin. ## Internationalisation | Example | What it shows | | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Type-safe i18n](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/i18n/i18n.service.ts\&initialpath=/i18n) | `defineCatalog` + `msg` + `plural`, a second locale through `defineLocaleLike`, every shipped semantic token, a custom `defineToken` that resolves a service and parses its parameter with a schema, and `runtime.bind` switching the whole page reactively | The guide is [Type-safe i18n](/guide/i18n/). ## Routing | Example | What it shows | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | [Query Params in the route](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/list-with-pagination/qp-list-with-pagination.ts\&initialpath=/query-params) | `queryParams` declared on the route rather than in a component | | [Guard Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/guard-demo/GuardDemo.ts\&initialpath=/guard-demo) | Guards as bare generators, and `handleExceptions` per code | | [Slow Page](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/slow-page/slow-page.routes.ts\&initialpath=/slow-page) | Non-blocking navigation: the stay → blank → loader phases, and a `craftGen` resolver recovered locally with `catchTag` | | [View Transitions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/view-transitions/view-transitions.routes.ts\&initialpath=/view-transitions) | Outlet-driven view transitions surviving the guard/resolve chain, with a per-route skeleton | | [Lazy Layout](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/lazy-layout/lazy-layout.routes.ts\&initialpath=/craft/lazy-layout/1) | A lazy child collection with its own DI check and a route-provided service | ## Tooling | Example | What it shows | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | [Playground](https://stackblitz.com/fork/github/craft-ts/craft-ts-demo/tree/main?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts\&initialpath=%2Fplayground) | A shareable sandbox: a small todo flow with `craftService`, `query()` and `mutation()` | | [Send Context to AI](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/ia/demo-send-context/demo-send-context.ts\&initialpath=/demo-send-context) | Exporting the live dependency graph and app context to an assistant | ## Notes Each example ships its own `api.service.ts` simulating the network, so every route works standalone. Source repository: [craft-ts-demo](https://github.com/craft-ts/craft-ts-demo). Effect demo source repository: [craft-demo-effect](https://github.com/craft-ts/craft-demo-effect). --- --- url: https://craft-ts.github.io/craft/resources/migration.md --- # Migrating an existing application `craft-migrate` runs the CraftTS codemods in a safe, explicit order: 1. primitive migration points 2. service composition 3. typed route collections and dependency checks 4. legacy `component(...)` factories to `craftComponent(name, ...)` 5. baseline architecture tests The migration is intentionally conservative. Deterministic transformations are written automatically; code requiring a business or lifecycle decision is reported as a manual diagnostic. ## Install the migration tool ```shell npm install @craft-ts/core npm install --save-dev @craft-ts/dev-tools@beta ``` The migration binaries are available from the `beta` tag. Verify the resolved version before starting: ```shell npm ls @craft-ts/dev-tools ``` Point the coding agent at [coding agents](/resources/ai-agents) so it uses `craft-migrate` through the `migrate-to-craft-ts` skill. The final step scaffolds the [architecture suite](/guide/testing/architecture) as the graph contract. Do not add one architecture rule per migrated feature. Commit or stash the current application changes before writing a migration. The codemod does not revert unrelated local changes. ## Preview the migration Run the command from the application workspace: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --dry-run ``` Use a JSON report when diagnostics need to be reviewed or archived: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --dry-run \ --json migration-report.json ``` ## Apply the migration ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --write ``` `--write` runs ESLint fixes on files touched by the primitive and service migrations. Use `--no-eslint` only when linting is managed separately. The specialized commands remain available when a migration must be applied or debugged one stage at a time: ```shell npx craft-migrate-primitives --project tsconfig.app.json --root src --write npx craft-migrate-services --project tsconfig.app.json --root src --write npx craft-migrate-routes --project tsconfig.app.json --root src --write npx craft-migrate-components --project tsconfig.app.json --root src --write npx craft-migrate-architecture --project tsconfig.app.json --root src --write ``` For a pasted HTML or Web Component snippet, use the standalone template converter: ```shell printf '

Hello

' | npx craft-migrate-template ``` The generated callback can be pasted as the fourth argument of `craftComponent(...)`. The interactive [template converter](/guide/components/template-migrator) uses the same converter. ## Work remaining after the codemod Search the generated report and source code for migration diagnostics. Complete the following before considering the migration done: * Consume every primitive invocation inside a generator with `yield*`, or use `craftUse(...)` at a synchronous boundary. * Map synchronous validators to `cRequired`, `cMaxLength`, and the other Craft validators. * Replace asynchronous validation with `query` and `cAsyncValidate`. * Replace form submission workflows with `mutation` and `insertFormSubmit`. * Resolve every `CRAFT_IMPLEMENTATION_REQUIRED` companion service. * Review service scopes and move `provideX(...)` close to the route or feature that owns the instance. * Resolve imperative workflow diagnostics instead of only removing comments. * Migrate guards, dynamic redirects, nested route collections, inherited route providers, and other route diagnostics that could not be inferred safely. * Confirm `componentDeps`, route provider names, and file-level DI checks. * Review HTTP mutations and subscriptions whose lifecycle semantics could not be moved automatically. * Add app-specific graph lookups in `architecture.spec.ts`. ## Verify the result First make remaining migration work fail CI: ```shell npx craft-migrate \ --project tsconfig.app.json \ --root src \ --check \ --fail-on-manual ``` Then run the normal project verification: ```shell npx eslint "src/**/*.ts" npx tsc --noEmit -p tsconfig.app.json npx vitest run --config vitest.architecture.config.ts ``` Use the workspace-specific lint, test, and build commands when they differ. Finally, exercise forms, navigation, pending/error UI, and write operations in the browser: those lifecycle behaviours cannot be fully established by a structural codemod. --- --- url: https://craft-ts.github.io/craft/guide/migration/wave-1-tag-and-provided-in.md --- # Migrating to `_tag` and `providedIn` Two renames on the public API: ```ts // exceptions craftException({ code: 'UserNotFound' }, payload) craftException({ _tag: 'UserNotFound' }, payload) // after // services craftService({ name: 'UserApi', scope: 'global' }, …) craftService({ name: 'UserApi', providedIn: 'global' }, …) // after ``` A codemod ships with this release and does most of the work: ```bash node node_modules/@craft-ts/dev-tools/craft-migrate-errors/rename-field.mjs \ tsconfig.json --from=scope --to=providedIn --not-with=_tag ``` It is compiler-driven and AST-based: it only rewrites where `tsc` actually breaks, and only in positions the AST confirms are the field. Run it per tsconfig, then re-run until it reports `clean`. ## Read this before you trust a green build **The dangerous part of this migration is invisible.** If your code reads the discriminant in a *value* position, the compiler will point at every site and you cannot get it wrong: ```ts craftException({ code: 'X' }) // errors: '_tag' is missing exception.code // errors: no such property ``` If your code reads it in a **type** position — a conditional or an `Extract` — it does not error. It resolves to `never`, and everything downstream quietly becomes empty: ```ts type CodesOf = E extends { code: infer C } ? C : never; // -> never type Only = Extract; // -> never type HasScope = V extends { scope: unknown } ? … : …; // -> the else branch ``` Nothing fails. No test goes red. The capability just stops existing. This happened four times inside CraftTS itself while performing this migration, each caught late and by accident: | What broke | How it surfaced | |---|---| | route exhaustiveness (`CraftExceptionCodes`) | one dev-tools test that compiles fixtures expected to FAIL | | component exception codes | a runtime template test, several commits later | | settled exception codes | a type assertion in an unrelated spec | | route HTTP dependency derivation | a stale-looking assertion, chased on a hunch | In every case the whole library suite — 1489 tests — was green. ### What to do about it Run the finder before you run the codemod, and again afterwards: ```bash node node_modules/@craft-ts/dev-tools/craft-migrate-errors/find-silent-sites.mjs code src node node_modules/@craft-ts/dev-tools/craft-migrate-errors/find-silent-sites.mjs scope src ``` It walks the AST for the two shapes that cannot fail loudly — a conditional whose `extends` clause reads the field, and an `Extract`/`Exclude`/`Omit`/`Pick` over a literal containing it — and exits non-zero while any remain. Everything it lists must be migrated **by hand**: the codemod cannot see them, because the compiler never reports them. Ten such positions existed inside CraftTS. Four were found by accident, over several days. The other six took this tool about a second. And keep at least one test that asserts something must *not* compile (`@ts-expect-error`, or a fixture your build is supposed to reject). It is the only kind of test that notices a guarantee disappearing. ## What did NOT change `scope` on an exception is untouched: ```ts craftException({ _tag: 'UserNotFound', scope: 'loader' }, payload) ``` It says where an exception came from — an origin, not a container or a lifetime — so it keeps its name. Only the *service* scope became `providedIn`. Also unchanged: the HTTP client's `{ source: 'code' }` matcher and the `code` field of a server response body. Those are the server's vocabulary, not CraftTS's, and the codemod leaves them alone by construction. ## Performance None of this costs anything. Measured across the rename: * discrimination: **6.8 ns/op** on both sides; * test-suite wall time: **+0.02%**; * type-check instantiations on the published build: **−0.00%**. `@craft-ts/effect` is marginally *cheaper* afterwards, because mapping an Effect error onto a craft exception stopped being a transposition and became the identity — Effect and CraftTS now discriminate on the same field. --- --- url: https://craft-ts.github.io/craft/resources/effect-compatibility.md --- # Effect compatibility and maturity This page describes the current repository contract. It is a decision aid for teams evaluating CraftTS, not a promise that beta APIs will remain unchanged. ## Compatibility matrix | Area | Current contract | Status | | --- | --- | --- | | Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta | | Craft components | `@craft-ts/component` on the same Craft version | Beta | | Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration | | Effect runtime | `effect` `^4.0.0-rc.112` | Effect 4 release candidate required by current Alchemy | | Effect 3 projects | No compatibility contract | Migrate or isolate before adopting | | Node.js | 20.19+ or 22.12+ | Required by the current docs | | TypeScript | Use the version supported by the selected Craft beta; verify with the project lockfile | Toolchain-sensitive | | Browser application | Vite demo and jsdom tests are covered | Experimental but executable | | SSR | No product SSR renderer in this release | Not ready | | Server functions | Local transport and middleware experiment | Proof of concept | | Migration tooling | `craft-migrate` for Craft concepts | No complete Effect-specific migration | Install all Craft packages from the same beta channel. `@craft-ts/effect` also declares `effect` as a peer dependency, so the Effect version is part of the application's compatibility surface. ## Maturity by capability | Capability | What is covered today | Adoption guidance | | --- | --- | --- | | Effect domain programs | `Effect`, tagged errors, `Context.Service`, `Layer` | Good candidate for a pilot | | Effect-backed reads and writes | `queryEffect`, `mutationEffect`, `asyncProcessEffect` | Pilot with real tests and a narrow feature | | Synchronous Effect members in a computation | `SyncOp`, `computedEffect`, `syncEffect` | Declare the members that never suspend, then reuse them in `craftComputed` and `params` | | Layer scoping | application, route, component and primitive providers | Use after the basic boundary is understood | | Typed error mapping | `E` becomes Craft exceptions; defects stay technical errors | Suitable for explicit UI error handling | | Effect service mocks | `mockEffectService` plus Craft registers | Suitable for focused tests | | Static Effect graph | Effect services, operations and Layers are collected | Useful for architecture rules; still evolving | | Server functions | `executeEffect`, middleware and local HTTP demo | Keep behind an experimental boundary | | SSR and deployment integration | Not shipped as a product contract | Do not make it a prerequisite for adoption | ## How to read this table The safest first adoption is browser-side, one feature, with an existing Effect domain and an application Layer provided by Craft. Defer SSR-specific decisions and server functions until their contracts are stable. See [Adopting CraftTS progressively](/resources/effect-adoption) for a staged plan and [the quickstart's verification section](/learn-effect/00-start-here#5-verify-the-boundary) for the executable Effect demo checks. --- --- url: https://craft-ts.github.io/craft/resources/effect-adoption.md --- # Adopting CraftTS progressively You do not need to rewrite an Effect application before evaluating CraftTS. Keep the domain programs and Layers intact, then introduce Craft at the browser boundary one feature at a time. ## Recommended path ### 0. Establish the constraints Before changing application code, confirm the [compatibility matrix](/resources/effect-compatibility). In particular, check the Effect 4 release-candidate requirement, the Node version and whether SSR is a hard requirement. ### 1. Keep the domain in Effect Select one existing operation with a clear type: ```ts Effect ``` Keep its `Context.Service`, `Layer`, tagged errors and tests. The first Craft change should be an adapter, not a rewrite of the business logic. ### 2. Add the Craft boundary Install `@craft-ts/effect`, install the bridge once at bootstrap, and expose the operation through `queryEffect`, `mutationEffect` or `asyncProcessEffect`. The component should call the domain operation. It should not resolve the repository, call `Effect.runPromise`, start a fiber from a click handler or duplicate the domain state in a Craft `state`. ### 3. Pilot one read-only feature Start with a page that has: * one query; * one application or route Layer; * one loading state; * one typed business error; * one technical error path; * one executable test. This exposes the real cost of the Craft UI model without mixing in forms, optimistic updates or server-function transport. ### 4. Add writes and forms Once the read path is stable, add `mutationEffect`, then connect it to Craft forms and `insertReactOnMutation`. Keep validation responsibilities explicit: * Effect Schema or `methodSchema` validates a boundary payload; * Craft owns field state, validity and interaction; * Effect typed errors represent business rejection; * defects remain technical failures. ### 5. Introduce route and feature scopes Move a Layer to the narrowest scope that owns it. Add the compile-time Effect requirements proof for the route, and inline single-use route providers so the typed route collection can preserve them for the type checker. Do this after the first feature works. The proof is valuable, but introducing it before the boundary is understood makes the first experiment look more complex than it is. ### 6. Evaluate server functions separately Treat the current server-function integration as a separate experiment. Its transport, file conventions, middleware API and deployment integration are not final. Never treat a client Layer as an authentication or authorization boundary; the server must verify claims again. ## What can stay and what changes? | Existing Effect application asset | During a Craft pilot | | --- | --- | | Domain types and business operations | Keep | | Tagged errors and error unions | Keep; map at the Craft boundary | | `Context.Service` contracts | Keep | | Live and test `Layer`s | Keep; expose through `provideLayer` | | Effect unit tests | Keep | | Existing UI components and templates | Keep outside the pilot; replace only the selected Craft feature | | UI loading, cancellation and rendering state | Move to Craft resources | | URL state and form interaction | Model with Craft primitives and forms | ## Go / no-go signals Proceed when the pilot has a clear resource boundary, an executable Layer setup and tests that distinguish business errors from defects. Pause and resolve the issue before expanding when: * the project is still on Effect 3 without an isolation plan; * SSR is mandatory but no SSR host has been selected; * the team cannot explain which side owns a piece of state; * every feature requires a custom bridge or manual subscription; * typecheck time or type errors make the feedback loop unacceptable. --- --- url: https://craft-ts.github.io/craft/resources/press-kit.md --- # Press Kit Resources and information about `@craft-ts/core` for articles, presentations, and sharing. ## Project description ### Short description `@craft-ts/core` is a reactive TypeScript toolkit for URL, client, and server state. Its primitives make dependencies explicit and keep application code fully type-safe. ### Long description `@craft-ts/core` brings state, asynchronous work, services, forms, routing, and testing into one composable model. Named generators and typed insertions make the dependency graph visible to both the compiler and development tools. The result is granular reactivity, typed failures, optimistic updates, persistence, and predictable loading states without repetitive coordination code. ## Key features * ✅ **Type-safe** — TypeScript inference minimizes manual declarations * ✅ **Composable** — primitives and insertions share one composition model * ✅ **Granular** — updates target the readers that actually depend on them * ✅ **Declarative** — state, effects, forms, and routes are explicit data * ✅ **Observable** — the same graph powers logging, tracing, and diagnostics * ✅ **Testable** — services, components, and architecture contracts can be tested independently ## Logo and brand assets ![craft-ts Logo](/assets/craft-ts-logo.png) * [Download logo](/assets/craft-ts-logo.png) ## Installation ```shell npm i @craft-ts/core@beta ``` ## Links * **GitHub**: [github.com/craft-ts/craft-ts](https://github.com/craft-ts/craft-ts) * **Documentation**: [craft-ts.github.io/craft/](https://craft-ts.github.io/craft/) * **NPM**: [npmjs.com/package/@craft-ts/core](https://npmjs.com/package/@craft-ts/core) ## Social media ### LinkedIn ```text Excited to share @craft-ts/core — a type-safe toolkit for state, services, forms, routing, and asynchronous work. Craft makes dependencies explicit and gives every primitive a predictable lifecycle, typed failures, and composable behaviour: • Reactive local and server state • URL state with typed codecs • Optimistic mutations • Typed forms and validation • Explicit service composition • Architecture checks and observability Check it out: [link] #TypeScript #WebDevelopment #OpenSource ``` ## License MIT License — free for personal and commercial use. ## Credits Created and maintained by Romain Geffrault. ## Contact * **Issues**: [GitHub Issues](https://github.com/craft-ts/craft-ts/issues) * **Discussions**: [GitHub Discussions](https://github.com/craft-ts/craft-ts/discussions) --- --- url: https://craft-ts.github.io/craft/resources/roadmap.md --- # Roadmap @craft-ts/core is evolving through real-world usage, careful experimentation, and feedback from the community. This roadmap describes the areas I am currently planning to explore; it is intentionally not a promise of fixed release dates. ## Near-term priorities ### SSR as a Craft host SSR is a Craft deployment concern: serialize Craft trees to HTML at the runtime boundary. That work lives in a later compiler/host plan; this release does not ship a product SSR renderer. ### Real-world integration and stability I will continue integrating `@craft-ts/core` into projects so that I can experiment with the different situations and constraints that applications encounter in practice. This ongoing use should help uncover edge cases, validate the API, and move the library towards the most stable version possible. I am also studying improvements that could make the codebase more robust. I am open to suggestions, proposals, and discussions about changes that would improve reliability, maintainability, or the developer experience. ## Type-safe design systems Another area I am actively exploring is how to create a design system that is as type-safe as possible. The aim is to make design-system APIs expressive and safe to use while preserving a good development experience. * Improve the type-level techniques used by the library so that they are more efficient. In particular, I want to reduce type compilation time and make the feedback loop faster for developers. One current challenge is TypeScript's memory limitation. A very ambitious type-level design can place a significant load on the TypeScript compiler, so this constraint has to be considered alongside the benefits of stronger inference. If you have ideas for addressing this problem, I would be very happy to hear them. Please feel free to share your opinions and suggestions. I am willing to introduce utilities or adaptations where necessary to make promising approaches compatible with the library and practical to use. ## Tooling for understanding changes I also plan to create a precise dependency graph and a tool that can compare two branches. The goal is to make the changes introduced by artificial intelligence easier to inspect and understand, by providing a clearer view of the affected dependencies and the differences between two versions of a codebase. I may also extend the dependency graph to represent complete paths through the graph, making it possible to follow how a change propagates across the codebase. This could provide a foundation for adding architecture tests and architecture constraints directly to the same tooling, so that intended dependencies and boundaries can be checked automatically. I am also considering building DevTools for `@craft-ts/core`, although I am not yet certain how valuable a traditional DevTools experience would be for the library. If there are features or workflows you would find useful in this area, please feel free to tell me about them. Several of my current ideas are more AI-first: tools designed to help an AI agent debug an application through WebMCP and observability, for example by making runtime state, dependency relationships, and application events easier to inspect and reason about. Feedback will help determine whether these ideas should become part of a DevTools experience or evolve as separate tools. ## Exploring a typed RxJS-like library I am also studying the possibility of creating a typed RxJS-like library built around the principles of `@craft-ts/core`. The goal would be to preserve the advantages of the existing RxJS ecosystem while providing stronger typing, treating errors as exceptions, and integrating observability natively with CraftTS. It would also include dependency tracking, making reactive relationships explicit and inspectable. ## Longer-term exploration: type-safe server functions Further ahead, I am considering a server-function system built around the same principles. The idea is to allow dependency injection in server functions while keeping it fully type-safe. Such a system could also allow the server function to depend on data supplied by the front end. That data would be passed automatically and checked in a type-safe way, so the contract between the client and the server remains explicit and reliable from end to end. This is an early exploration rather than a committed API. Feedback about the design, the use cases, and the trade-offs would be especially valuable as the idea develops. ## Share your ideas The roadmap will evolve as these experiments produce results. If you have feedback, use cases, or ideas for making `@craft-ts/core` more robust and type-safe, please share them through [GitHub Discussions](https://github.com/craft-ts/craft-ts/discussions) or [GitHub Issues](https://github.com/craft-ts/craft-ts/issues). --- --- url: https://craft-ts.github.io/craft/resources/backlog.md --- ## Backlog * \[ ] For query/mutation/AsyncProcess insertions, expose a set and state similar to other primitive states that will simplify creating reusable insertions. (for persister one more property isStable ? To invalidate state while mutating) * \[ ] Improve storage persister (better invalidation, handle storing state) * \[x] Explore to make Source similar to Subject/ReplaySubject * \[ ] Add support for RxJs source without having an explicit dependency on RxJs and accepts Observable as params for mutation/query/asyncProcess * \[ ] Clean internal code * \[ ] Explore explicit type safe error in primitive / use eslint to force handling it (create adapter for OpenApi contract, TS-Rest contract...) * \[ ] Explore a way to handle selectedIds (that can be used for bulk delete ...), creating a dedicated state, or a dedicated insertion. It will expose all selected, some selected, toggleOne/toggleAll... * \[ ] Add to-source$ utility to create a source from a DOM event * \[ ] Propose a state or pattern to handle trees (to explore) * \[ ] add crossLayerEvent to insertSelect (from bottom to top) * \[ ] Rename craftException to cException * \[ ] Create a insertContract similar to a class to implement an interface, also add an helper with a proxy to mock the data ? * Explore an explicit way to pass dependencies of primitives (it would be easier for testing) * forms: * Handle async validator calls in parallel * login form example, explain how to trigger an exception on submit and debounce errors * show an error if the form submit mutation doesn't have the same payload as the form value * formRoot can't be used for submission, create an alternative directive? --- --- url: https://craft-ts.github.io/craft/guide/testing/folder-layout.md --- # Folder layout organizer `craft organize` analyzes a fresh Craft dependency graph and proposes a folder layout. It does not move files. The default placement follows route ownership and application-shell reachability; project-specific rules can add known architectural constraints without changing those defaults for other projects. ## Tune an organizer run The programmatic entry point is `organizeProject`, exported by `@craft-ts/dev-tools` and `@craft-ts/dev-tools/folder-layout`: ```ts import { organizeProject } from '@craft-ts/dev-tools/folder-layout'; organizeProject({ project: 'apps/shop/tsconfig.graph.json', graph: 'craft-dependency-graph.json', out: 'apps/shop/folder-layout', weights: { calls: 4 }, thresholds: { maxDepth: 2 }, placementRules: [ { id: 'app-start-services', when: { nodeKind: 'service', nodeDetails: { appStart: true }, }, scope: 'core', folder: 'core/app-start', reason: 'Runs during application bootstrap.', }, ], }); ``` `weights` adjusts the relative influence of graph relations. `thresholds` controls the maximum proposed feature depth and the fan-in/fan-out values that mark hubs. Omitted values keep their defaults. ## Keep project rules in JSON The CLI accepts the same `weights`, `thresholds`, and `placementRules` in a JSON file passed with `--config`: ```json { "placementRules": [ { "id": "app-start-services", "when": { "nodeKind": "service", "nodeDetails": { "appStart": true } }, "scope": "core", "folder": "core/app-start", "reason": "Runs during application bootstrap." } ] } ``` ```bash craft organize \ --project apps/shop/tsconfig.graph.json \ --graph craft-dependency-graph.json \ --config apps/shop/organizer.config.json \ --out apps/shop/folder-layout ``` The organizer evaluates rules in array order, before its default ownership classification. A rule matches a file when at least one Craft graph node in that file has the selected `nodeKind` and all selected `nodeDetails` values. The first matching rule sets the file's scope and destination. `folder` is relative to the organizer's target root, which defaults to the app's `src/` folder when present. Rules need a unique `id`, a `nodeKind`, optional `nodeDetails`, a scope (`feature-local`, `parent-shared`, `global-shared`, `core`, or `unresolved`), a safe relative `folder`, and a human-readable `reason`. In TypeScript, `nodeKind` narrows `nodeDetails`; for `service`, keys and values are checked against the service metadata (`appStart` and `browserBoundary` are booleans). The JSON CLI validates known service detail keys and value types at runtime. The reason appears with the file's proposal. Resolved rules are written to `folder-layout-analysis.json` and contribute to the proposal's `configHash`, so changing policy produces a distinct review artifact. A matching explicit rule has confidence `1`; destination collisions still require review. ### File-level behavior The organizer proposes moves for whole files. If one service in a file matches a rule, the entire file receives that placement. Keep declarations with different folder policies in separate files. If several rules match a file, the first rule wins; put narrower matchers first. ## Demo policy: app-start services The dependency graph records `appStart: true` on Craft service nodes. The demo uses that fact in `apps/demo/organizer.config.json` to place matching source files under `src/core/app-start/`. This policy is passed only by the demo's `attest:demo:folder-layout` command, so it does not change the organizer's defaults for other applications. To regenerate the proposal: ```bash npm run attest:demo:folder-layout ``` The command refreshes the read-only analysis and proposal artifacts. Apply a proposal separately with `craft organize apply` after reviewing it. --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/primitive-method-usage.md --- # Primitive method usage `assertPrimitiveMethodsUsedOnce` requires every method exposed by a primitive insertion to have one source-level call site. It complements `craft-ts/no-reused-primitive-method`, which checks usages inside one file. ```ts export function keepPrimitiveMethodsLocal(graph: ArchitectureGraph) { assertPrimitiveMethodsUsedOnce(graph.graph); } ``` The rule applies to methods returned by insertions on `state`, `query`, `mutation`, `asyncProcess` and `queryParams`. A callback reference counts as a usage just like an explicit generator call: ```typescript button({ click: counter.increment }); yield* counter.increment(); ``` Two distinct source locations must have distinct names so the method itself explains its context: ```typescript const counter = yield* state('counter', 0, ({ update }) => ({ incrementFromToolbar: () => update((value) => value + 1), incrementFromKeyboard: () => update((value) => value + 1), })); ``` Methods bound internally with `on$` are not exposed and are not checked. A single call inside a loop is also one call site: the rule concerns the source shape, not how many times the application executes it. The architecture assertion keeps the invariant across service, component and feature-file boundaries, including unchanged method references forwarded through a component template context. Its error lists every known file and line so the method can be split into context-specific insertion methods. ## See also * [`craft-ts/no-reused-primitive-method`](/guide/routing/eslint-rules) * [Insertions](/guide/concepts/insertions) * [The architecture graph](/guide/testing/architecture) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/resource-params-query-state.md --- # Resource params should prefer URL-backed state `assertResourceParamsPreferQueryParams` rejects `query` and `asyncProcess` params that depend on a local `state`. Filters, pagination and search values usually belong in `queryParams`, so a reload or a shared URL keeps the same view: ```ts export function keepResourceParamsShareable(graph: ArchitectureGraph) { assertResourceParamsPreferQueryParams(graph.graph, { allow: [ { name: 'locale', file: 'src/app/examples/effect/effect-i18n.ts', }, ], }); } ``` ## What it prevents This shape loses the active filters on reload: ```typescript const search = yield* state('search', ''); const page = yield* state('page', 1); const params = craftComputed('usersParams', function* () { return { search: yield* search(), page: yield* page() }; }); const users = yield* query('users', { params, loader: loadUsers, }); ``` The architecture graph follows the complete params path, including computed values and dependencies declared in other files. It does not inspect state used only by a loader, insertion or unrelated UI code. ## The URL-backed version ```typescript const filters = yield* queryParams('filters', { state: { search: { fallbackValue: '', codec: stringCodec }, page: { fallbackValue: 1, codec: numberCodec }, }, }); const users = yield* query('users', { params: filters, loader: loadUsers, }); ``` ## Intentional exceptions Some state values are not navigation state. For example, a process-wide locale may affect a query without belonging in the route. Whitelist that state with a name and, when necessary, a relative file path: ```typescript assertResourceParamsPreferQueryParams(graph.graph, { allow: [ { name: 'locale', file: 'src/app/examples/effect/effect-i18n.ts', }, ], }); ``` Keep the allowlist narrow and document the reason beside the architecture test. ## See also * [Query params](/guide/state/url-state) * [Architecture rules](/guide/testing/architecture) * [ESLint rules](/guide/routing/eslint-rules) --- --- url: >- https://craft-ts.github.io/craft/guide/testing/architecture/unused-primitive-method.md --- # Unused primitive methods `assertNoUnusedPrimitiveMethods` requires every method exposed by a primitive insertion to have at least one call site in the project. An unused method is dead interface and should be removed from the primitive. ```ts export function removeUnusedPrimitiveMethods(graph: ArchitectureGraph) { assertNoUnusedPrimitiveMethods(graph.graph); } ``` The check is graph-wide, so it can find a method declared in one module that is never called by any component, service or feature module. This also applies to CraftTS libraries included in the analyzed project: public methods must be kept intentionally, or the project should exclude that library from the graph. The error includes the primitive, method and declaration location: ```text Primitive method state:counter.decrement is never used in this project (counter.ts:12). Remove it. ``` Methods bound internally with `on$` are not exposed and are not checked. ## See also * [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) * [The architecture graph](/guide/testing/architecture)